From 8d67f17a843cd63e020d60663b47787859486273 Mon Sep 17 00:00:00 2001 From: tongtongstudio Date: Thu, 17 Sep 2026 17:30:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(timeline):=20=E6=94=AF=E6=8C=81=E6=8B=96?= =?UTF-8?q?=E6=8B=BD=E8=B0=83=E6=95=B4=E6=97=B6=E9=95=BF=E4=B8=8E=E8=BF=90?= =?UTF-8?q?=E9=95=9C/=E8=BD=A8=E8=BF=B9=E5=85=B3=E9=94=AE=E5=B8=A7?= =?UTF-8?q?=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/canvas/DirectorCanvas.grid.test.ts | 22 + src/editor/canvas/DirectorCanvas.tsx | 31 +- src/editor/canvas/SceneRoot.tsx | 4 +- src/editor/io/videoExport.test.ts | 46 ++ src/editor/io/videoExport.ts | 47 +- src/editor/panels/CharacterPanel.test.tsx | 14 + src/editor/panels/InspectorControls.tsx | 2 + src/editor/panels/TimelinePanel.test.tsx | 509 +++++++++++- src/editor/panels/TimelinePanel.tsx | 731 ++++++++++++++++-- src/editor/runtime/PlaybackFrameDriver.tsx | 8 +- src/editor/runtime/playbackStore.test.ts | 52 +- src/editor/runtime/playbackStore.ts | 21 +- src/editor/schema/directorProject.ts | 10 + src/editor/schema/timeline.test.ts | 138 +++- src/editor/schema/timeline.ts | 138 +++- src/editor/schema/trajectoryMotion.test.ts | 290 ++++++- src/editor/schema/trajectoryMotion.ts | 238 +++++- src/editor/store/directorStore.test.ts | 61 +- src/editor/store/directorStore.ts | 90 ++- src/styles/index.css | 287 ++++++- src/styles/index.css.test.ts | 11 + 21 files changed, 2556 insertions(+), 194 deletions(-) create mode 100644 src/editor/io/videoExport.test.ts diff --git a/src/editor/canvas/DirectorCanvas.grid.test.ts b/src/editor/canvas/DirectorCanvas.grid.test.ts index 83e88c5..8eb8f24 100644 --- a/src/editor/canvas/DirectorCanvas.grid.test.ts +++ b/src/editor/canvas/DirectorCanvas.grid.test.ts @@ -1,4 +1,5 @@ import { + applyExternalViewSnapshot, applySnapshotToControls, DEFAULT_DIRECTOR_VIEW_SNAPSHOT, getViewportGizmoHitButtonStyle, @@ -43,6 +44,27 @@ it("flushes leftover orbit inertia before writing the reset view back", () => { expect(camera.position.toArray()).toEqual(DEFAULT_DIRECTOR_VIEW_SNAPSHOT.position); }); +it("writes an external view change on the spot instead of deferring it to the next render", () => { + // 机位视角下没有 OrbitControls:写不进去,交给切回导演视角时的副作用补写 + expect(applyExternalViewSnapshot(null, DEFAULT_DIRECTOR_VIEW_SNAPSHOT)).toBe(false); + + const dampingStateDuringUpdate: boolean[] = []; + const controls = { + enableDamping: true, + target: new Vector3(9, 9, 9), + object: new PerspectiveCamera(), + update: () => dampingStateDuringUpdate.push(controls.enableDamping), + }; + + expect(applyExternalViewSnapshot(controls, DEFAULT_DIRECTOR_VIEW_SNAPSHOT)).toBe(true); + + // 当场就把残余惯性结算掉并落到目标视角(惯性滑行中点重置视角不会再被推走) + expect(dampingStateDuringUpdate).toEqual([false, false]); + expect(controls.target.toArray()).toEqual(DEFAULT_DIRECTOR_VIEW_SNAPSHOT.target); + expect(controls.object.position.toArray()).toEqual(DEFAULT_DIRECTOR_VIEW_SNAPSHOT.position); + expect(controls.enableDamping).toBe(true); +}); + it("keeps the viewport grid visible when a panorama background is active", () => { expect(shouldRenderViewportGrid(true, false)).toBe(true); }); diff --git a/src/editor/canvas/DirectorCanvas.tsx b/src/editor/canvas/DirectorCanvas.tsx index d9bb08d..9656e70 100644 --- a/src/editor/canvas/DirectorCanvas.tsx +++ b/src/editor/canvas/DirectorCanvas.tsx @@ -166,6 +166,20 @@ function applySnapshotToCamera(camera: ThreePerspectiveCamera, snapshot: CameraS camera.updateMatrixWorld(); } +/** + * 外部改视角(重置视角 / 坐标控件切轴)时「当场」结算残余惯性并把视角写回相机与 OrbitControls。 + * 返回是否真的写回去了 —— 机位视角下没有 OrbitControls,只能等切回导演视角时由副作用补写。 + */ +export function applyExternalViewSnapshot( + controls: { enableDamping: boolean; target: Vector3; update: () => void; object: unknown } | null, + snapshot: CameraShotSnapshot +): boolean { + if (!controls) return false; + + applySnapshotToControls(controls, controls.object as ThreePerspectiveCamera, snapshot); + return true; +} + /** * 把视角快照写回相机 + OrbitControls(重置视角、坐标控件切轴、切回导演视角都走这里)。 * @@ -747,13 +761,28 @@ export function DirectorCanvas() { return viewportCameraSnapshotRef.current; } - /** 记录/广播导演视角快照;source 决定 DirectorViewCameraSync 要不要把它写回相机 */ + /** + * 记录/广播导演视角快照;source 决定这次变化是谁产生的。 + * + * 外部改视角(重置视角 / 坐标控件切轴)时**当场写回**相机 + OrbitControls, + * 不能只丢给 DirectorViewCameraSync 的 layout effect。惯性滑行时有两道闸门会把它吞掉: + * 1. 快照可能因为「和当前视角足够接近」被 React 合并掉 → 状态没变 → 副作用不会重跑; + * 2. OrbitControls 每帧都抛 change,把来源标成 "controls",effect 里那道 + * 「用户自己在操作就别写回」的闸门会顺手拦掉这次外部请求。 + * 两者都表现为「点了重置视角没有反应、镜头继续滑」。当场结算残余惯性最稳; + * 随后 effect 再跑到也只是同值重写(幂等)。 + */ function updateDirectorViewSnapshot( snapshot: CameraShotSnapshot, source: ViewportSnapshotSource = "external" ) { viewportCameraSnapshotRef.current = snapshot; viewportSnapshotSourceRef.current = source; + + if (source === "external") { + applyExternalViewSnapshot(controlsRef.current, snapshot); + } + setDirectorViewSnapshot((currentSnapshot) => areCameraSnapshotsClose(currentSnapshot, snapshot) ? currentSnapshot : snapshot ); diff --git a/src/editor/canvas/SceneRoot.tsx b/src/editor/canvas/SceneRoot.tsx index 93cbde8..1ab5b00 100644 --- a/src/editor/canvas/SceneRoot.tsx +++ b/src/editor/canvas/SceneRoot.tsx @@ -30,7 +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 { evaluateTrajectoryMotionAtTime, getTrajectoryPointTimes } from "../schema/trajectoryMotion"; import type { TransformMode } from "../store/directorStore"; import { useDirectorStore } from "../store/directorStore"; import { usePlaybackStore } from "../runtime/playbackStore"; @@ -920,7 +920,7 @@ function TrajectoryPath({ if (pointIndex < 0) return; setPlaying(false); - setPlayhead(getTrajectoryPointTime(pointIndex, points.length, playbackDuration)); + setPlayhead(getTrajectoryPointTimes(points, playbackDuration)[pointIndex] ?? 0); } /** diff --git a/src/editor/io/videoExport.test.ts b/src/editor/io/videoExport.test.ts new file mode 100644 index 0000000..8d9ae3d --- /dev/null +++ b/src/editor/io/videoExport.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, expect, it } from "vitest"; +import { usePlaybackStore } from "../runtime/playbackStore"; +import { exportVideo } from "./videoExport"; + +beforeEach(() => { + usePlaybackStore.setState({ isPlaying: false, playhead: 0, duration: 5, loop: false, editingObjectId: null }); +}); + +/** + * jsdom 里既没有视口画布也没有 WebCodecs,导出会立刻失败 —— + * 但这里要测的正是「导出期间 / 导出之后」的播放状态收口,与导出是否成功无关。 + */ +it("turns the playback loop off while exporting and puts it back afterwards", async () => { + usePlaybackStore.getState().setLoop(true); + + const loopHistory: boolean[] = []; + const unsubscribe = usePlaybackStore.subscribe((state) => loopHistory.push(state.loop)); + + await expect(exportVideo("director")).rejects.toThrow(); + unsubscribe(); + + // 导出一开始就关掉循环:导出永远只播一遍,不会录到回卷的一段 + expect(loopHistory[0]).toBe(false); + // 导出结束后循环设置原样还回去 + expect(usePlaybackStore.getState().loop).toBe(true); +}); + +it("stops playback after an export instead of resuming an endless loop", async () => { + // 导出前正在播放,而且开着循环 —— 这就是「播完一直循环播放」的现场 + usePlaybackStore.getState().setLoop(true); + usePlaybackStore.getState().setPlaying(true); + + await expect(exportVideo("director")).rejects.toThrow(); + + const state = usePlaybackStore.getState(); + expect(state.isPlaying).toBe(false); + expect(state.loop).toBe(true); +}); + +it("keeps the playhead where the export found it", async () => { + usePlaybackStore.getState().setPlayhead(2.5); + + await expect(exportVideo("director")).rejects.toThrow(); + + expect(usePlaybackStore.getState().playhead).toBe(2.5); +}); diff --git a/src/editor/io/videoExport.ts b/src/editor/io/videoExport.ts index bc2ce07..f528aba 100644 --- a/src/editor/io/videoExport.ts +++ b/src/editor/io/videoExport.ts @@ -6,10 +6,10 @@ import { usePlaybackStore } from "../runtime/playbackStore"; import { getVideoExportContext } from "./videoExportBridge"; import { applyActiveCameraViewAtTime, applyTimelineTransformsAtTime } from "../runtime/playbackTransforms"; import { getViewportAspectRatioValue } from "../schema/viewportAspectRatio"; +import { normalizeTimelineFps } from "../schema/timeline"; export type VideoExportView = "director" | "camera"; -const EXPORT_FPS = 30; /** 导出分辨率按「短边 = 720」基准缩放,画面比例取自 viewportAspectRatio(auto 时用视口当前比例) */ const EXPORT_SHORT_EDGE = 720; @@ -75,20 +75,20 @@ export function computeExportSize( return { width: width + (width % 2), height: height + (height % 2) }; } -async function pickVideoEncoderConfig(width: number, height: number) { +async function pickVideoEncoderConfig(width: number, height: number, fps: number) { const candidates = [ { codec: "avc1.640028" }, // High 4.0 { codec: "avc1.4d0028" }, // Main 4.0 { codec: "avc1.42001f" }, // Baseline 3.1 ]; - const bitrate = Math.round(width * height * EXPORT_FPS * 0.15); // ≈6Mbps @720p30 + const bitrate = Math.round(width * height * fps * 0.15); // ≈6Mbps @720p30 for (const { codec } of candidates) { const config: VideoEncoderConfig = { codec, width, height, bitrate, - framerate: EXPORT_FPS, + framerate: fps, }; try { const support = await VideoEncoder.isConfigSupported(config); @@ -115,15 +115,17 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise { const directorStore = useDirectorStore.getState(); const { width, height } = computeExportSize(directorStore.viewportAspectRatio); const duration = directorStore.project.timeline.duration; - const totalFrames = Math.max(1, Math.round(duration * EXPORT_FPS)); + // 帧率取自工程设置(时间轴设置里的「每秒帧数」),导出与读数保持一致 + const fps = normalizeTimelineFps(directorStore.project.timeline.fps); + const totalFrames = Math.max(1, Math.round(duration * fps)); - const encoderConfig = await pickVideoEncoderConfig(width, height); + const encoderConfig = await pickVideoEncoderConfig(width, height, fps); if (!encoderConfig) throw new Error("当前浏览器不支持 H.264 编码,请用最新版 Chrome / Edge"); - // 保存现场:导出完原样恢复(视角 / 指针 / 渲染尺寸 / 相机比例) + // 保存现场:导出完原样恢复(视角 / 指针 / 渲染尺寸 / 相机比例)。 + // 播放状态不在这里恢复:exportVideo 统一收口成「导出后停住」。 const previousViewMode = directorStore.viewMode; const previousPlayhead = usePlaybackStore.getState().playhead; - const previousPlaying = usePlaybackStore.getState().isPlaying; const previousSize = new Vector2(); gl.getSize(previousSize); const previousPixelRatio = gl.getPixelRatio(); @@ -156,11 +158,11 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise { // 等一拍让 VideoExportBridge 重新注册最新相机,导出循环里也每帧取最新引用 await new Promise((resolve) => window.setTimeout(resolve, 0)); - const frameDurationUs = Math.round(1_000_000 / EXPORT_FPS); + const frameDurationUs = Math.round(1_000_000 / fps); try { for (let frameIndex = 0; frameIndex <= totalFrames; frameIndex += 1) { if (encodeError) throw encodeError; - const time = Math.min(frameIndex / EXPORT_FPS, duration); + const time = Math.min(frameIndex / fps, duration); // 相机可能因视角切换被替换,每帧从桥取最新;并保证导出画面比例 const frameCamera = getVideoExportContext()?.camera ?? camera; frameCamera.aspect = width / height; @@ -194,7 +196,7 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise { timestamp: frameIndex * frameDurationUs, duration: frameDurationUs, }); - encoder.encode(frame, { keyFrame: frameIndex % (EXPORT_FPS * 2) === 0 }); + encoder.encode(frame, { keyFrame: frameIndex % (fps * 2) === 0 }); frame.close(); // 背压:编码队列太深时等一等,防止内存堆积 @@ -221,7 +223,6 @@ async function exportVideoFrameByFrame(view: VideoExportView): Promise { restoreCamera.aspect = previousAspect; restoreCamera.updateProjectionMatrix(); directorStore.setViewMode(previousViewMode); - usePlaybackStore.getState().setPlaying(previousPlaying); usePlaybackStore.getState().setPlayhead(previousPlayhead); } } @@ -298,11 +299,25 @@ async function exportVideoWithMediaRecorder(view: VideoExportView): Promise { - if (typeof VideoEncoder !== "undefined") { - await exportVideoFrameByFrame(view); - return; + const previousLoop = usePlaybackStore.getState().loop; + usePlaybackStore.getState().setLoop(false); + + try { + if (typeof VideoEncoder !== "undefined") { + await exportVideoFrameByFrame(view); + return; + } + await exportVideoWithMediaRecorder(view); + } finally { + const playback = usePlaybackStore.getState(); + playback.setLoop(previousLoop); + playback.setPlaying(false); } - await exportVideoWithMediaRecorder(view); } diff --git a/src/editor/panels/CharacterPanel.test.tsx b/src/editor/panels/CharacterPanel.test.tsx index af5870f..9a09a10 100644 --- a/src/editor/panels/CharacterPanel.test.tsx +++ b/src/editor/panels/CharacterPanel.test.tsx @@ -68,6 +68,20 @@ it("uses the provided right inspector layout for role properties", () => { expect(screen.getByLabelText("角色颜色")).toHaveClass("inspector-color-swatch"); }); +it("paints the color swatch with the current color so its rounded corners never leak", async () => { + const user = userEvent.setup(); + render(); + + const swatch = screen.getByLabelText("角色颜色") as HTMLInputElement; + + await user.clear(screen.getByLabelText("角色颜色 HEX")); + await user.type(screen.getByLabelText("角色颜色 HEX"), "#123456"); + + // 元素底色跟着当前颜色走,而不是固定的强调蓝(蓝底会在圆角处透出来) + expect(swatch.style.background).not.toBe(""); + expect(swatch.style.background).not.toContain("3, 150, 255"); +}); + it("marks the pose adjustment section for the compact character inspector layout", async () => { const user = userEvent.setup(); render(); diff --git a/src/editor/panels/InspectorControls.tsx b/src/editor/panels/InspectorControls.tsx index 6958a24..d2f99a1 100644 --- a/src/editor/panels/InspectorControls.tsx +++ b/src/editor/panels/InspectorControls.tsx @@ -648,6 +648,8 @@ export function InspectorColorField({ className="inspector-color-swatch" type="color" value={value} + // 元素底色跟着当前颜色走:内层取色块的圆角边缘是半透明的,底色一致时圆角处才看不出接缝 + style={{ background: value }} onChange={(event) => onColorChange(event.currentTarget.value)} onBlur={endInteraction} onFocus={beginInteraction} diff --git a/src/editor/panels/TimelinePanel.test.tsx b/src/editor/panels/TimelinePanel.test.tsx index 1e86f45..bb8b174 100644 --- a/src/editor/panels/TimelinePanel.test.tsx +++ b/src/editor/panels/TimelinePanel.test.tsx @@ -9,22 +9,38 @@ import { getTimelineRulerTicks, reorderTimelineTrackKeys, resolveTimelineTrackOrder, - TIMELINE_FPS, + TIMELINE_DURATION_DRAG_PIXELS_PER_SECOND, TimelinePanel, } from "./TimelinePanel"; +import { DEFAULT_TIMELINE_FPS } from "../schema/timeline"; function getDefaultCharacter() { return useDirectorStore.getState().project.objects.find((item) => item.kind === "character")!; } /** - * jsdom 不做真实布局(所有 getBoundingClientRect 都是 0), - * 轨道拖拽要靠行几何算落点,这里按 data-track-key 造一份给定布局。 + * jsdom 不做真实布局(所有 getBoundingClientRect 都是 0): + * - 轨道拖拽排序要靠行几何算落点 → 按 data-track-key 造一份给定布局; + * - 轨迹时间滑块要靠轨道宽度把像素换成秒 → 统一给轨道车道一个固定宽度。 */ -function stubTrackRowLayout(layout: Record) { +function stubTrackRowLayout(layout: Record, laneWidth = 500) { const original = Element.prototype.getBoundingClientRect; return vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) { + if (this instanceof HTMLElement && this.classList.contains("timeline-track-lane")) { + return { + x: 0, + y: 0, + top: 0, + bottom: 28, + left: 0, + right: laneWidth, + width: laneWidth, + height: 28, + toJSON: () => ({}), + } as DOMRect; + } + const key = this instanceof HTMLElement ? this.dataset.trackKey : undefined; const entry = key ? layout[key] : undefined; if (!entry) return original.call(this); @@ -44,6 +60,10 @@ function stubTrackRowLayout(layout: Record item.id === objectId)?.trajectoryPoints ?? []; +} + // 导出是浏览器能力(WebCodecs / MediaRecorder),单测里只关心调用的是哪个视角 const mockExportVideo = vi.hoisted(() => vi.fn()); @@ -83,11 +103,15 @@ it("shows the playhead as a frame number next to the time", () => { expect(container.querySelector(".timeline-time-readout")?.textContent).toBe("F45/1.5s"); }); -it("gets the frame number from the exported frame rate", () => { +it("gets the frame number from the frame rate in the settings", () => { expect(getTimelineFrame(0)).toBe(0); - expect(getTimelineFrame(1)).toBe(TIMELINE_FPS); - expect(getTimelineFrame(0.5)).toBe(TIMELINE_FPS / 2); + expect(getTimelineFrame(1)).toBe(DEFAULT_TIMELINE_FPS); + expect(getTimelineFrame(0.5)).toBe(DEFAULT_TIMELINE_FPS / 2); expect(getTimelineFrame(-3)).toBe(0); + // 帧率可配置:60fps 下同一时刻的帧号翻倍;非法帧率回落到默认值 + expect(getTimelineFrame(1, 60)).toBe(60); + expect(getTimelineFrame(0.5, 60)).toBe(30); + expect(getTimelineFrame(1, 0)).toBe(DEFAULT_TIMELINE_FPS); }); it("draws 0.1s minor ticks between the major ticks", () => { @@ -416,6 +440,85 @@ it("does not start a track drag from the collapse button", () => { expect(panel).not.toHaveClass("is-track-dragging"); }); +it("renders a trajectory bar whose right handle shows the walk duration", () => { + const character = getDefaultCharacter(); + useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]); + useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]); + + const { container } = render(); + + // 2 个轨迹点在 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", () => { + const character = getDefaultCharacter(); + useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]); + useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]); + + 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 }); + // 拖轨迹时冻结全站毛玻璃,避免背后 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 }); + + const points = getTrajectoryPoints(character.id); + expect(points[0].time).toBeCloseTo(2, 5); + expect(points[1].time).toBeCloseTo(4, 5); + expect(document.body).not.toHaveClass("is-timeline-dragging"); + // 拖滑块不应被当成「轨道拖动排序」 + expect(useDirectorStore.getState().timelineTrackOrder).toEqual([]); +}); + +it("drags a single trajectory keyframe to change only its own time", () => { + const character = getDefaultCharacter(); + useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]); + useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]); + + render(); + stubTrackRowLayout({}, 500); + + const firstKeyframe = screen.getByRole("button", { name: "轨迹关键帧 2.5s" }); + fireEvent.pointerDown(firstKeyframe, { button: 0, pointerId: 1, clientX: 250 }); + // 往右拖 50px → 该点 2.5s → 3s + fireEvent.pointerMove(firstKeyframe, { pointerId: 1, clientX: 300 }); + fireEvent.pointerUp(firstKeyframe, { pointerId: 1, clientX: 300 }); + + const points = getTrajectoryPoints(character.id); + expect(points[0].time).toBeCloseTo(3, 5); + // 其它点没被拖动过,仍按均匀铺开推导 + expect(points[1].time).toBeUndefined(); + expect(useDirectorStore.getState().timelineTrackOrder).toEqual([]); + expect(screen.getByRole("button", { name: "轨迹关键帧 3.0s" })).toBeInTheDocument(); +}); + +it("nudges the trajectory duration with the keyboard", () => { + const character = getDefaultCharacter(); + useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]); + useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]); + + render(); + + 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); +}); + it("collapses and expands a character track", () => { const character = getDefaultCharacter(); useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]); @@ -478,6 +581,235 @@ it("gives the camera track a collapse toggle as well", () => { expect(screen.queryByRole("button", { name: "运镜关键帧 1.0s" })).not.toBeInTheDocument(); }); +/** 给默认机位挑几条运镜关键帧(time 会被 store 夹进时间轴) */ +function addCameraKeyframeAt(cameraId: string, time: number) { + useDirectorStore.getState().addCameraKeyframe(cameraId, time, { + position: [time, 0, 0], + target: [0, 0, 0], + fov: 50, + }); +} + +function getCameraKeyframeTimes(cameraId: string) { + const track = useDirectorStore + .getState() + .project.timeline.cameraTracks.find((item) => item.cameraId === cameraId)!; + return track.keyframes.map((keyframe) => keyframe.time); +} + +it("renders a camera clip bar spanning the first and last camera keyframes", () => { + const camera = useDirectorStore.getState().project.cameras[0]; + addCameraKeyframeAt(camera.id, 1); + addCameraKeyframeAt(camera.id, 4); + + const { container } = render(); + + const bar = container.querySelector(".timeline-clip-bar-camera")!; + // 1s → 4s 的运镜:条从 20% 开始、宽 60%(5s 时间轴) + expect(bar.style.left).toBe("20%"); + expect(bar.style.width).toBe("60%"); + // 条上写的是「这段运镜的时长」 + expect(bar.querySelector(".timeline-trajectory-bar-label")?.textContent).toBe("3.0s"); + + const handle = screen.getByRole("slider", { name: `${camera.name} 运镜时长` }); + expect(handle).toHaveAttribute("aria-valuenow", "4"); + expect(handle).toHaveAttribute("aria-valuemax", "5"); +}); + +it("does not draw a camera clip bar for a single camera keyframe", () => { + const camera = useDirectorStore.getState().project.cameras[0]; + addCameraKeyframeAt(camera.id, 1); + + const { container } = render(); + + expect(container.querySelector(".timeline-clip-bar-camera")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "运镜关键帧 1.0s" })).toBeInTheDocument(); +}); + +it("drags the camera clip handle to rescale the whole camera move", () => { + const camera = useDirectorStore.getState().project.cameras[0]; + addCameraKeyframeAt(camera.id, 1); + addCameraKeyframeAt(camera.id, 4); + + render(); + // 5s 时间轴对应 500px 轨道 → 1px = 0.01s + stubTrackRowLayout({}, 500); + + const handle = screen.getByRole("slider", { name: `${camera.name} 运镜时长` }); + fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 480 }); + // 拖条时同样冻结全站毛玻璃 + expect(document.body).toHaveClass("is-timeline-dragging"); + + // 往左拖 100px → 运镜从 1s→4s 缩短到 1s→3s,首帧固定不动 + fireEvent.pointerMove(handle, { pointerId: 1, clientX: 380 }); + fireEvent.pointerUp(handle, { pointerId: 1, clientX: 380 }); + + const times = getCameraKeyframeTimes(camera.id); + expect(times[0]).toBe(1); + expect(times[1]).toBeCloseTo(3, 5); + expect(document.body).not.toHaveClass("is-timeline-dragging"); +}); + +it("nudges the camera clip duration 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: "ArrowLeft" }); + + // 末帧 4s → 3.9s,中间没有别的帧,首帧仍在 1s + const times = getCameraKeyframeTimes(camera.id); + expect(times[0]).toBe(1); + expect(times[1]).toBeCloseTo(3.9, 5); +}); + +it("drags a single camera keyframe to change only its own time", () => { + const camera = useDirectorStore.getState().project.cameras[0]; + addCameraKeyframeAt(camera.id, 1); + addCameraKeyframeAt(camera.id, 4); + + render(); + stubTrackRowLayout({}, 500); + + const firstKeyframe = screen.getByRole("button", { name: "运镜关键帧 1.0s" }); + fireEvent.pointerDown(firstKeyframe, { button: 0, pointerId: 1, clientX: 100 }); + // 往右拖 50px → 该帧 1s → 1.5s,末帧不受影响 + fireEvent.pointerMove(firstKeyframe, { pointerId: 1, clientX: 150 }); + fireEvent.pointerUp(firstKeyframe, { pointerId: 1, clientX: 150 }); + + const times = getCameraKeyframeTimes(camera.id); + expect(times[0]).toBeCloseTo(1.5, 5); + expect(times[1]).toBe(4); +}); + +it("keeps the camera track ordered when a clipped keyframe is dragged past its neighbour", () => { + const camera = useDirectorStore.getState().project.cameras[0]; + addCameraKeyframeAt(camera.id, 1); + addCameraKeyframeAt(camera.id, 4); + + render(); + stubTrackRowLayout({}, 500); + + const firstKeyframe = screen.getByRole("button", { name: "运镜关键帧 1.0s" }); + fireEvent.pointerDown(firstKeyframe, { button: 0, pointerId: 1, clientX: 100 }); + // 想把它拖到末帧后面:被夹在末帧之前,时间仍然递增 + fireEvent.pointerMove(firstKeyframe, { pointerId: 1, clientX: 900 }); + fireEvent.pointerUp(firstKeyframe, { pointerId: 1, clientX: 900 }); + + const times = getCameraKeyframeTimes(camera.id); + expect(times).toHaveLength(2); + expect(times[0]).toBeLessThan(times[1]); +}); + +it("drags the trajectory bar body to move the whole walk later", () => { + const character = getDefaultCharacter(); + useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]); + useDirectorStore.getState().addTrajectoryPoint(character.id, [2, 0, 0]); + // 先把走位缩短到 0s → 3s,右边才留出可挪的空间 + useDirectorStore.getState().setTrajectoryPointTimes(character.id, [1.5, 3]); + + const { container } = render(); + stubTrackRowLayout({}, 500); + + const bar = container.querySelector(".timeline-trajectory-lane .timeline-trajectory-bar")!; + expect(bar.style.left).toBe("0%"); + expect(bar.style.width).toBe("60%"); + + // 往右拖 100px = +1s:整段刚性平移,两个点一起 +1s + fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 }); + fireEvent.pointerMove(bar, { pointerId: 1, clientX: 200 }); + fireEvent.pointerUp(bar, { pointerId: 1, clientX: 200 }); + + const updated = useDirectorStore.getState().project.objects.find((item) => item.id === character.id)!; + expect(updated.trajectoryStartTime).toBe(1); + expect(updated.trajectoryPoints!.map((point) => point.time)).toEqual([2.5, 4]); +}); + +it("keeps the trajectory clip inside the timeline when dragged to the end", () => { + 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]); + + const { container } = render(); + stubTrackRowLayout({}, 500); + + const bar = container.querySelector(".timeline-trajectory-lane .timeline-trajectory-bar")!; + fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 }); + // 往右拖 1000px 远超时间轴:末点顶到 5s 就不再挪 + fireEvent.pointerMove(bar, { pointerId: 1, clientX: 1100 }); + fireEvent.pointerUp(bar, { pointerId: 1, clientX: 1100 }); + + const updated = useDirectorStore.getState().project.objects.find((item) => item.id === character.id)!; + expect(updated.trajectoryStartTime).toBeCloseTo(2, 5); + expect(updated.trajectoryPoints!.map((point) => point.time)).toEqual([3.5, 5]); +}); + +it("does not move a trajectory that already fills the whole timeline", () => { + const character = getDefaultCharacter(); + // 单点默认铺开 = 0s → 5s,正好占满整条时间轴 + useDirectorStore.getState().addTrajectoryPoint(character.id, [1, 0, 0]); + + const { container } = render(); + stubTrackRowLayout({}, 500); + + const bar = container.querySelector(".timeline-trajectory-lane .timeline-trajectory-bar")!; + expect(bar.style.width).toBe("100%"); + + fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 }); + fireEvent.pointerMove(bar, { pointerId: 1, clientX: 200 }); + fireEvent.pointerUp(bar, { pointerId: 1, clientX: 200 }); + + // 没有余量:不会挪走,也不会把走位压缩成两秒半 + const updated = useDirectorStore.getState().project.objects.find((item) => item.id === character.id)!; + expect(updated.trajectoryStartTime).toBeUndefined(); + expect(updated.trajectoryPoints![0].time).toBeUndefined(); +}); + +it("drags the camera clip bar to move the whole camera move later", () => { + const camera = useDirectorStore.getState().project.cameras[0]; + addCameraKeyframeAt(camera.id, 1); + addCameraKeyframeAt(camera.id, 3); + + const { container } = render(); + stubTrackRowLayout({}, 500); + + const bar = container.querySelector(".timeline-clip-bar-camera")!; + expect(bar.style.left).toBe("20%"); + expect(bar.style.width).toBe("40%"); + + // 往右拖 100px = +1s:两个关键帧一起 +1s + fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 }); + fireEvent.pointerMove(bar, { pointerId: 1, clientX: 200 }); + fireEvent.pointerUp(bar, { pointerId: 1, clientX: 200 }); + + const times = getCameraKeyframeTimes(camera.id); + expect(times[0]).toBeCloseTo(2, 5); + expect(times[1]).toBeCloseTo(4, 5); +}); + +it("keeps the camera clip inside the timeline when dragged past the end", () => { + 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")!; + fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 100 }); + fireEvent.pointerMove(bar, { pointerId: 1, clientX: 1100 }); + fireEvent.pointerUp(bar, { pointerId: 1, clientX: 1100 }); + + // 末帧顶到 5s 为止,首帧跟着挪到 2s(整段长度不变) + const times = getCameraKeyframeTimes(camera.id); + expect(times[0]).toBeCloseTo(2, 5); + expect(times[1]).toBeCloseTo(5, 5); +}); + it("jumps to the first and last frame from the transport controls", () => { usePlaybackStore.getState().setPlayhead(3); render(); @@ -489,6 +821,169 @@ it("jumps to the first and last frame from the transport controls", () => { expect(usePlaybackStore.getState().playhead).toBe(5); }); +it("shows the frame readout with the frame rate from the settings", () => { + useDirectorStore.getState().setTimelinePlaybackSettings({ fps: 60 }); + usePlaybackStore.getState().setPlayhead(1.5); + + const { container } = render(); + + // 1.5s × 60fps = 90 帧(默认 30fps 时是 45) + expect(container.querySelector(".timeline-time-readout")?.textContent).toBe("F90/1.5s"); +}); + +it("drags the duration handle to lengthen the timeline", () => { + render(); + + const handle = screen.getByRole("slider", { name: "拖动调整总时长" }); + const twoSeconds = TIMELINE_DURATION_DRAG_PIXELS_PER_SECOND * 2; + + fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 200 }); + fireEvent.pointerMove(handle, { pointerId: 1, clientX: 200 + twoSeconds }); + fireEvent.pointerUp(handle, { pointerId: 1, clientX: 200 + twoSeconds }); + + expect(useDirectorStore.getState().project.timeline.duration).toBe(7); +}); + +it("drags the duration handle left to shorten the timeline", () => { + render(); + + const handle = screen.getByRole("slider", { name: "拖动调整总时长" }); + const twoSeconds = TIMELINE_DURATION_DRAG_PIXELS_PER_SECOND * 2; + + fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 200 }); + fireEvent.pointerMove(handle, { pointerId: 1, clientX: 200 - twoSeconds }); + fireEvent.pointerUp(handle, { pointerId: 1, clientX: 200 - twoSeconds }); + + expect(useDirectorStore.getState().project.timeline.duration).toBe(3); +}); + +it("never drags the timeline below the minimum duration", () => { + render(); + + const handle = screen.getByRole("slider", { name: "拖动调整总时长" }); + fireEvent.pointerDown(handle, { button: 0, pointerId: 1, clientX: 400 }); + fireEvent.pointerMove(handle, { pointerId: 1, clientX: 0 }); + fireEvent.pointerUp(handle, { pointerId: 1, clientX: 0 }); + + expect(useDirectorStore.getState().project.timeline.duration).toBe(0.1); +}); + +it("nudges the duration by 0.1s from the drag handle keyboard", () => { + render(); + + const handle = screen.getByRole("slider", { name: "拖动调整总时长" }); + fireEvent.keyDown(handle, { key: "ArrowRight" }); + + expect(useDirectorStore.getState().project.timeline.duration).toBeCloseTo(5.1, 5); + expect(handle).toHaveAttribute("aria-valuenow", "5.1"); + + fireEvent.keyDown(handle, { key: "ArrowLeft" }); + + expect(useDirectorStore.getState().project.timeline.duration).toBeCloseTo(5, 5); +}); + +it("steps the duration by 0.1s with the up / down spinner buttons", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "总时长增加 0.1s" })); + expect(useDirectorStore.getState().project.timeline.duration).toBeCloseTo(5.1, 5); + + // 连续点两下:以最新的工程时长继续累加,而不是等着 playbackStore 同步 + fireEvent.click(screen.getByRole("button", { name: "总时长增加 0.1s" })); + expect(useDirectorStore.getState().project.timeline.duration).toBeCloseTo(5.2, 5); + + fireEvent.click(screen.getByRole("button", { name: "总时长减少 0.1s" })); + expect(useDirectorStore.getState().project.timeline.duration).toBeCloseTo(5.1, 5); +}); + +it("opens the timeline settings with the current frame rate and loop state", () => { + useDirectorStore.getState().setTimelinePlaybackSettings({ fps: 24, loop: true }); + + render(); + fireEvent.click(screen.getByRole("button", { name: "时间轴设置" })); + + const dialog = screen.getByRole("dialog", { name: "时间轴设置" }); + expect(within(dialog).getByLabelText("每秒帧数")).toHaveValue(24); + expect(within(dialog).getByLabelText("播放循环")).toBeChecked(); +}); + +it("only saves and applies the settings after pressing 完成", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "时间轴设置" })); + + const dialog = screen.getByRole("dialog", { name: "时间轴设置" }); + fireEvent.change(within(dialog).getByLabelText("每秒帧数"), { target: { value: "60" } }); + fireEvent.click(within(dialog).getByLabelText("播放循环")); + + // 还没点「完成」:工程里的设置不变 + expect(useDirectorStore.getState().project.timeline.fps).toBe(30); + expect(useDirectorStore.getState().project.timeline.loop).toBe(false); + + fireEvent.click(within(dialog).getByRole("button", { name: "完成" })); + + const timeline = useDirectorStore.getState().project.timeline; + expect(timeline.fps).toBe(60); + expect(timeline.loop).toBe(true); + expect(screen.queryByRole("dialog", { name: "时间轴设置" })).not.toBeInTheDocument(); +}); + +it("discards the edited settings when the dialog is dismissed without 完成", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "时间轴设置" })); + + const dialog = screen.getByRole("dialog", { name: "时间轴设置" }); + fireEvent.change(within(dialog).getByLabelText("每秒帧数"), { target: { value: "120" } }); + // 点遮罩关掉 = 取消 + fireEvent.click(screen.getByTestId("timeline-settings-overlay")); + + expect(useDirectorStore.getState().project.timeline.fps).toBe(30); + expect(screen.queryByRole("dialog", { name: "时间轴设置" })).not.toBeInTheDocument(); + + // 再次打开时草稿值重新从工程读取,不会残留上次没保存的 120 + fireEvent.click(screen.getByRole("button", { name: "时间轴设置" })); + expect(screen.getByLabelText("每秒帧数")).toHaveValue(30); +}); + +it("closes the settings dialog from the top-right icon button without applying", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "时间轴设置" })); + + const dialog = screen.getByRole("dialog", { name: "时间轴设置" }); + // 关闭按钮在标题行右侧 + expect(within(dialog.querySelector(".timeline-settings-head") as HTMLElement).getByRole("button", { + name: "关闭设置", + })).toBeInTheDocument(); + + fireEvent.change(within(dialog).getByLabelText("每秒帧数"), { target: { value: "50" } }); + fireEvent.click(within(dialog).getByRole("button", { name: "关闭设置" })); + + expect(useDirectorStore.getState().project.timeline.fps).toBe(30); + expect(screen.queryByRole("dialog", { name: "时间轴设置" })).not.toBeInTheDocument(); +}); + +it("clamps an out-of-range frame rate when the settings are applied", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "时间轴设置" })); + + const dialog = screen.getByRole("dialog", { name: "时间轴设置" }); + fireEvent.change(within(dialog).getByLabelText("每秒帧数"), { target: { value: "999" } }); + fireEvent.click(within(dialog).getByRole("button", { name: "完成" })); + + expect(useDirectorStore.getState().project.timeline.fps).toBe(120); +}); + +it("closes the settings dialog with Escape without applying", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "时间轴设置" })); + + const dialog = screen.getByRole("dialog", { name: "时间轴设置" }); + fireEvent.change(within(dialog).getByLabelText("每秒帧数"), { target: { value: "50" } }); + fireEvent.keyDown(document, { key: "Escape" }); + + expect(useDirectorStore.getState().project.timeline.fps).toBe(30); + expect(screen.queryByRole("dialog", { name: "时间轴设置" })).not.toBeInTheDocument(); +}); + it("nudges the playhead with the ruler keyboard controls", () => { usePlaybackStore.getState().setPlayhead(2); render(); diff --git a/src/editor/panels/TimelinePanel.tsx b/src/editor/panels/TimelinePanel.tsx index 999585c..81c6fd5 100644 --- a/src/editor/panels/TimelinePanel.tsx +++ b/src/editor/panels/TimelinePanel.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, @@ -9,6 +10,7 @@ import { Camera, ChevronDown, ChevronRight, + ChevronUp, DiamondPlus, Eraser, Film, @@ -17,16 +19,38 @@ import { PersonStanding, Play, RefreshCw, + Settings2, SkipBack, SkipForward, Square, Trash2, Video, + X, type LucideIcon, } from "lucide-react"; -import type { CameraKeyframeValue, KeyframeTransformValue } from "../schema/timeline"; +import { + CAMERA_KEYFRAME_MIN_GAP, + DEFAULT_TIMELINE_FPS, + MAX_TIMELINE_DURATION, + MAX_TIMELINE_FPS, + MIN_TIMELINE_DURATION, + MIN_TIMELINE_FPS, + TIMELINE_DURATION_STEP, + getCameraClipSpan, + moveCameraKeyframeTime, + normalizeTimelineDuration, + normalizeTimelineFps, + scaleCameraKeyframeTimes, + type CameraKeyframeValue, + type KeyframeTransformValue, +} from "../schema/timeline"; import type { MotionKind } from "../schema/directorProject"; -import { getTrajectoryPointTime } from "../schema/trajectoryMotion"; +import { + getTrajectoryPointTimes, + getTrajectoryStartTime, + moveTrajectoryPointTime, + scaleTrajectoryPointTimes, +} from "../schema/trajectoryMotion"; import { useDirectorStore } from "../store/directorStore"; import { usePlaybackStore } from "../runtime/playbackStore"; import { exportVideo, type VideoExportView } from "../io/videoExport"; @@ -37,6 +61,15 @@ interface SelectedKeyframe { keyframeId: string; } +/** + * 时间滑块手势的数据主人: + * - trajectory:人物轨迹(拖单个点走 updateTrajectoryPoint,按点 id 定位) + * - camera:运镜片段(拖把手 / 拖关键帧都整条覆盖时间,按索引对应) + */ +type TimelineTimeDragOwner = + | { kind: "trajectory"; objectId: string; pointId: string } + | { kind: "camera"; cameraId: string }; + function formatTime(t: number) { return `${t.toFixed(1)}s`; } @@ -56,13 +89,22 @@ 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 { +/** 把秒换算成帧号(四舍五入到最近的一帧);帧率来自工程设置,默认 30fps */ +export function getTimelineFrame(time: number, fps: number = DEFAULT_TIMELINE_FPS): number { const safeTime = Number.isFinite(time) ? Math.max(0, time) : 0; - return Math.round(safeTime * TIMELINE_FPS); + const safeFps = Number.isFinite(fps) && fps > 0 ? fps : DEFAULT_TIMELINE_FPS; + return Math.round(safeTime * safeFps); +} + +/** 拖动总时长时的灵敏度:横向 25px = 1s,拖起来不会太窜 */ +export const TIMELINE_DURATION_DRAG_PIXELS_PER_SECOND = 25; + +/** + * 把秒数对齐到 0.1s 刻度。 + * 顺带消掉浮点尾巴:5 + 0.1 会得到 5.0999999999999996,直接塞进 number 输入框会显示出这一长串小数。 + */ +export function alignTimelineDuration(seconds: number): number { + return Number(seconds.toFixed(2)); } export interface TimelineRulerTick { @@ -261,16 +303,21 @@ export function TimelinePanel() { const addCameraKeyframe = useDirectorStore((state) => state.addCameraKeyframe); const removeCameraKeyframe = useDirectorStore((state) => state.removeCameraKeyframe); const updateCameraKeyframeValue = useDirectorStore((state) => state.updateCameraKeyframeValue); + const setCameraKeyframeTimes = useDirectorStore((state) => state.setCameraKeyframeTimes); 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 updateTrajectoryPoint = useDirectorStore((state) => state.updateTrajectoryPoint); + const setTrajectoryPointTimes = useDirectorStore((state) => state.setTrajectoryPointTimes); + const setTrajectoryClipStart = useDirectorStore((state) => state.setTrajectoryClipStart); 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 setTimelinePlaybackSettings = useDirectorStore((state) => state.setTimelinePlaybackSettings); const isPlaying = usePlaybackStore((state) => state.isPlaying); const playhead = usePlaybackStore((state) => state.playhead); @@ -280,9 +327,22 @@ export function TimelinePanel() { const setPlaying = usePlaybackStore((state) => state.setPlaying); const setPlayhead = usePlaybackStore((state) => state.setPlayhead); + // 帧率 / 循环来自工程设置(旧工程在 store 迁移时补默认值) + const fps = project.timeline.fps ?? DEFAULT_TIMELINE_FPS; + const loop = project.timeline.loop === true; + // 时长编辑以工程里的值为准:playbackStore 那份是给播放用的副本,靠帧驱动同步、会慢一拍, + // 连续拖 / 连续点微调按钮时会把上一次的结果吞掉。 + const editableDuration = project.timeline.duration ?? duration; + const [selectedKeyframe, setSelectedKeyframe] = useState(null); const [isRecording, setIsRecording] = useState(false); const [exportMenuOpen, setExportMenuOpen] = useState(false); + // 设置弹窗:fps / loop 先放草稿里改,点「完成」才写回工程(不点则原样丢弃) + const [settingsOpen, setSettingsOpen] = useState(false); + const [draftFps, setDraftFps] = useState(fps); + const [draftLoop, setDraftLoop] = useState(loop); + /** 拖动总时长:按下时记一次起始时长与指针位置,移动中只做线性换算 */ + const durationDragRef = useRef<{ pointerId: number; startClientX: number; startDuration: number } | null>(null); // 轨道拖拽:生命周期(谁在拖 → 高亮 / 毛玻璃降级)走 React state,一次拖拽只重渲染两次; // 高频的落点线则完全不经过 React(ref + transform),详见 getTrackDragProps。 const [draggingTrackKey, setDraggingTrackKey] = useState(null); @@ -296,6 +356,23 @@ export function TimelinePanel() { startX: number; startY: number; } | null>(null); + /** + * 正在拖时间滑块(人物轨迹条 / 运镜片段条共用): + * - point:拖单个关键帧,只改这一帧的时间 + * - duration:拖条右端把手,整段时长按比例缩放 + * - clip:拖条本体,整段左右平移(可以脱离 0s 往后放) + */ + const timeDragRef = useRef<{ + pointerId: number; + owner: TimelineTimeDragOwner; + kind: "point" | "duration" | "clip"; + index: number; + startTimes: number[]; + /** 片段左端在按下时的位置(平移基准) */ + startEdge: number; + startClientX: number; + secondsPerPixel: number; + } | null>(null); /** 拖拽开始时一次性量好的行几何:移动中只查表,绝不在事件里读布局(强制同步布局 = layout thrashing) */ const trackRectsRef = useRef(new Map()); const trackLayoutOriginRef = useRef(0); @@ -355,6 +432,18 @@ export function TimelinePanel() { }; }, [exportMenuOpen]); + // 设置弹窗:Esc 直接关掉(等同取消,草稿值丢弃) + useEffect(() => { + if (!settingsOpen) return; + + function closeSettingsOnEscape(event: KeyboardEvent) { + if (event.key === "Escape") setSettingsOpen(false); + } + + document.addEventListener("keydown", closeSettingsOnEscape); + return () => document.removeEventListener("keydown", closeSettingsOnEscape); + }, [settingsOpen]); + const timeline = project.timeline; // 选中目标解析:物体 or 相机 @@ -431,6 +520,72 @@ export function TimelinePanel() { selectTrajectoryPoint(objectId, pointId); } + /** 总时长微调(上下按钮 / 键盘左右键):按 0.1s 刻度走,统一交给 store 夹取 */ + function nudgeDuration(offsetSeconds: number) { + setTimelineDuration(normalizeTimelineDuration(alignTimelineDuration(editableDuration + offsetSeconds))); + } + + /** + * 总时长拖动把手(「总时长」左边的双向箭头):左右拖动减 / 加总时长。 + * 灵敏度固定(25px = 1s)并对齐到 0.1s,避免拖一下跳一大截。 + */ + function handleDurationDragMove(event: ReactPointerEvent) { + const drag = durationDragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + + event.preventDefault(); + const deltaSeconds = (event.clientX - drag.startClientX) / TIMELINE_DURATION_DRAG_PIXELS_PER_SECOND; + setTimelineDuration(normalizeTimelineDuration(alignTimelineDuration(drag.startDuration + deltaSeconds))); + } + + function handleDurationDragEnd(event: ReactPointerEvent) { + const drag = durationDragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + + durationDragRef.current = null; + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch { + // jsdom 等环境不支持 pointer capture,忽略后仍能继续拖动 + } + } + + function getDurationDragProps() { + return { + onPointerDown: (event: ReactPointerEvent) => { + if (event.button !== 0) return; + // 这次手势归总时长把手,不要被下面的轨道拖拽抢走 + event.stopPropagation(); + durationDragRef.current = { + pointerId: event.pointerId, + startClientX: event.clientX, + startDuration: editableDuration, + }; + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + // 同上 + } + }, + onPointerMove: handleDurationDragMove, + onPointerUp: handleDurationDragEnd, + onPointerCancel: handleDurationDragEnd, + }; + } + + function openSettings() { + // 每次打开都从当前工程值重新生成草稿,避免上次没点「完成」的残留值 + setDraftFps(fps); + setDraftLoop(loop); + setSettingsOpen(true); + } + + /** 「完成」才保存并应用:帧率 / 循环一次性写回工程,弹窗随之关闭 */ + function confirmSettings() { + setTimelinePlaybackSettings({ fps: normalizeTimelineFps(draftFps), loop: draftLoop }); + setSettingsOpen(false); + } + function handleClearAll() { if (timeline.tracks.length === 0 && timeline.cameraTracks.length === 0) return; if (window.confirm("确定清空所有关键帧吗?此操作可用撤销恢复。")) { @@ -481,8 +636,8 @@ export function TimelinePanel() { ), [rulerMinorTicks, rulerTicks] ); - const playheadFrame = getTimelineFrame(playhead); - const totalFrames = getTimelineFrame(duration); + const playheadFrame = getTimelineFrame(playhead, fps); + const totalFrames = getTimelineFrame(duration, fps); 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:轨道底色用它画贯穿网格线,与刻度尺天然对齐 @@ -603,6 +758,181 @@ export function TimelinePanel() { }; } + /** 拖整段时长(条右端把手 / 键盘微调):末帧落到 newEnd,其余关键帧按比例缩放,片段起点固定 */ + function scaleClipDuration( + owner: TimelineTimeDragOwner, + times: number[], + startEdge: number, + deltaSeconds: number + ) { + const targetEnd = (times[times.length - 1] ?? startEdge) + deltaSeconds; + + if (owner.kind === "trajectory") { + setTrajectoryPointTimes( + owner.objectId, + scaleTrajectoryPointTimes(times, targetEnd, duration, startEdge) + ); + return; + } + + setCameraKeyframeTimes(owner.cameraId, scaleCameraKeyframeTimes(times, targetEnd, duration)); + } + + /** 拖单个关键帧:只改它自己的时间(被前后邻居夹住) */ + function moveClipKeyframe( + owner: TimelineTimeDragOwner, + times: number[], + index: number, + startEdge: number, + deltaSeconds: number + ) { + const targetTime = (times[index] ?? 0) + deltaSeconds; + + if (owner.kind === "trajectory") { + const nextTimes = moveTrajectoryPointTime(times, index, targetTime, duration, startEdge); + updateTrajectoryPoint(owner.objectId, owner.pointId, { time: nextTimes[index] }); + return; + } + + setCameraKeyframeTimes(owner.cameraId, moveCameraKeyframeTime(times, index, targetTime, duration)); + } + + /** + * 拖条本体:整段片段左右平移,可以脱离 0s 往后放(前面那段时间角色原地等待 / 相机保持首帧机位)。 + * 先把位移夹住(片段两端都不越出时间轴),再整体平移 —— 片段刚性移动,各关键帧的节奏不变。 + */ + function shiftClip( + owner: TimelineTimeDragOwner, + times: number[], + startEdge: number, + deltaSeconds: number + ) { + const endEdge = times.length > 0 ? times[times.length - 1] : startEdge; + const minShift = -startEdge; + const maxShift = Math.max(duration - endEdge, minShift); + const shift = Math.min(Math.max(deltaSeconds, minShift), maxShift); + // 顶到头(例如片段已经占满整条时间轴)时什么都不写,避免留下没有变化的一步撤销 + if (shift === 0) return; + + if (owner.kind === "trajectory") { + setTrajectoryClipStart(owner.objectId, startEdge + shift); + return; + } + + setCameraKeyframeTimes(owner.cameraId, times.map((time) => time + shift)); + } + + /** + * 时间滑块(人物轨迹条 / 运镜片段条同构):拖动关键帧改「这一帧的时间」, + * 拖动条右端把手改「整段时长」(其余帧按比例缩放)。像素 → 秒用轨道宽度换算,宽度只在按下时量一次。 + */ + function beginTimeDrag( + event: ReactPointerEvent, + owner: TimelineTimeDragOwner, + kind: "point" | "duration" | "clip", + index: number, + times: number[], + startEdge = 0 + ) { + if (event.button !== 0) return; + if (isTrackDragBlockedTarget(event.target)) return; + + const lane = event.currentTarget.closest(".timeline-track-lane"); + const laneWidth = lane instanceof HTMLElement ? lane.getBoundingClientRect().width : 0; + if (laneWidth <= 0 || duration <= 0) return; + + // 这次手势归时间滑块,轨道行的「拖动排序」不要来抢 + event.stopPropagation(); + + timeDragRef.current = { + pointerId: event.pointerId, + owner, + kind, + index, + startTimes: [...times], + startEdge, + startClientX: event.clientX, + secondsPerPixel: duration / laneWidth, + }; + + // 拖动时冻结全站毛玻璃:条 / 关键帧每次重绘都会让背后 WebGL 画布重算一次模糊 + document.body.classList.add("is-timeline-dragging"); + + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + // jsdom 等环境不支持 pointer capture,忽略后仍能继续拖动 + } + } + + function handleTimeDragMove(event: ReactPointerEvent) { + const drag = timeDragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + + event.preventDefault(); + event.stopPropagation(); + + const deltaTime = (event.clientX - drag.startClientX) * drag.secondsPerPixel; + + if (drag.kind === "duration") { + scaleClipDuration(drag.owner, drag.startTimes, drag.startEdge, deltaTime); + return; + } + + if (drag.kind === "clip") { + shiftClip(drag.owner, drag.startTimes, drag.startEdge, deltaTime); + return; + } + + moveClipKeyframe(drag.owner, drag.startTimes, drag.index, drag.startEdge, deltaTime); + } + + function handleTimeDragEnd(event: ReactPointerEvent) { + const drag = timeDragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + + timeDragRef.current = null; + document.body.classList.remove("is-timeline-dragging"); + + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch { + // 同上,忽略不支持的环境 + } + } + + /** 把某个关键帧 / 时长把手 / 条本体接到同一套拖拽手势上 */ + function getTimeDragProps( + owner: TimelineTimeDragOwner, + kind: "point" | "duration" | "clip", + index: number, + times: number[], + startEdge = 0 + ) { + return { + onPointerDown: (event: ReactPointerEvent) => + beginTimeDrag(event, owner, kind, index, times, startEdge), + onPointerMove: handleTimeDragMove, + onPointerUp: handleTimeDragEnd, + onPointerCancel: handleTimeDragEnd, + }; + } + + /** 时长把手支持键盘微调(左右方向键 ±0.1s,按住 Shift ±0.5s) */ + function handleTimeHandleKeyDown( + event: ReactKeyboardEvent, + owner: TimelineTimeDragOwner, + times: number[], + startEdge = 0 + ) { + const step = event.shiftKey ? 0.5 : 0.1; + const delta = event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0; + if (delta === 0 || times.length === 0) return; + + event.preventDefault(); + scaleClipDuration(owner, times, startEdge, delta); + } + function handleTrackPointerMove(event: ReactPointerEvent) { const gesture = dragGestureRef.current; if (!gesture || gesture.pointerId !== event.pointerId) return; @@ -767,7 +1097,7 @@ export function TimelinePanel() { {/* 读数 = 当前帧号 / 当前时间;总时长单独放在右边的时长输入框里 */} F{playheadFrame} {formatReadoutTime(playhead)} -