From ada16fa3ec3a42009832264d8b55a8353fe07223 Mon Sep 17 00:00:00 2001 From: tongtongstudio Date: Mon, 21 Sep 2026 10:19:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(timeline):=20=E4=BF=AE=E5=A4=8D=E6=8B=96?= =?UTF-8?q?=E5=8A=A8=E6=92=AD=E6=94=BE=E5=A4=B4=E6=97=B6=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E4=B8=8D=E5=AE=9E=E6=97=B6=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拖动期间改用 performance.now() 时间片节流替代 requestAnimationFrame,避免在窗口被遮挡或后台节流时整段拖动不写 playhead。同时草稿档不再跳过景深渲染,仅降低模糊采样点数以保留移焦与变焦预览。 --- src/editor/canvas/LensMonitor.tsx | 5 +-- src/editor/canvas/viewportBokeh.tsx | 16 ++++----- src/editor/panels/TimelinePanel.test.tsx | 35 ++++++++++++++++++ src/editor/panels/TimelinePanel.tsx | 46 ++++++++++++------------ 4 files changed, 68 insertions(+), 34 deletions(-) diff --git a/src/editor/canvas/LensMonitor.tsx b/src/editor/canvas/LensMonitor.tsx index a0ef3af..508ad08 100644 --- a/src/editor/canvas/LensMonitor.tsx +++ b/src/editor/canvas/LensMonitor.tsx @@ -64,7 +64,7 @@ function LensMonitorCamera({ // playhead 自己订阅(而不是从外层当 prop 传进来):外壳组件不必因为这个每秒变几十次的值 // 去 reconcile 整棵场景树(见 LensMonitorCanvas 的 memo)。 const playhead = usePlaybackStore((state) => state.playhead); - // 拖动播放头期间走草稿档:跳过景深整套后处理,把帧预算留给「跟手」 + // 拖动播放头期间走草稿档:景深照常渲染(移焦 / 变焦要看得到),只把模糊采样点数降到最低档 const isScrubbing = usePlaybackStore((state) => state.isScrubbing); // 关键帧感知:拖播放头时,监视器上的对焦距离 / 光圈 / 焦段 / 景深强度都按关键帧插值, // 让「移焦 / 渐入渐出虚化 / 变焦」在监视器上所见即所得(与导出管线一致的 resolveLensAtTime)。 @@ -128,7 +128,8 @@ function LensMonitorCamera({ // 与主视口 / 视频导出共用同一份景深渲染(renderSceneWithBokeh):监视器不再自建一套 // EffectComposer —— 两套实现很容易漂移,导出与监视器的取景 / 虚化就会对不上。 // 合成器按 gl 缓存、画布尺寸变化会自动重建;不支持后处理时回退普通渲染,监视器永远可用。 - // 拖动播放头时传 draft:跳过整套景深后处理(松手立刻恢复),拖动才跟手。 + // 拖动播放头时传 draft:景深仍然整套跑(拖动期间「移焦 / 变焦 / 虚化强弱」必须看得见), + // 只是把模糊采样点数压到最低档省开销,松手立刻回到自适应档。 renderSceneWithBokeh(gl, scene, renderCamera, lens, undefined, { draft: isScrubbing }); }, 1); diff --git a/src/editor/canvas/viewportBokeh.tsx b/src/editor/canvas/viewportBokeh.tsx index f242f9f..5e08f80 100644 --- a/src/editor/canvas/viewportBokeh.tsx +++ b/src/editor/canvas/viewportBokeh.tsx @@ -150,15 +150,6 @@ export function renderSceneWithBokeh( viewportRect?: { left: number; top: number; width: number; height: number }, options: { draft?: boolean } = {} ): void { - // 草稿档(拖动播放头时用):直接普通渲染,跳过「深度 pass + 41~96 点景深」整套。 - // 拖动期间人看的是时间与构图,这里省掉的是最贵的那几笔(约 2~3 倍开销),松手立刻恢复完整景深。 - // 特意没采用「拖动时降分辨率」:那会重建合成器的渲染目标(几十 MB 显存回收 + 重新分配), - // 反倒让每次开始拖动的头几帧卡一下。 - if (options.draft) { - gl.render(scene, camera); - return; - } - const state = ensureBokehState( gl, scene, @@ -178,7 +169,12 @@ export function renderSceneWithBokeh( renderHeight: state.height, pixelRatio: state.pixelRatio, }); - configureAdaptiveBokeh(state.bokeh, settings.taps); + // 草稿档(拖动播放头时):**景深照常渲染**,只把采样点数压到最低档。 + // 拖动时恰恰要看着「移焦 / 变焦 / 虚化强弱」跟着变,直接跳过景深会让人以为镜头没动(画面里根本没有虚化), + // 所以这里只省最贵的「逐像素 41~96 次采样」,模糊的观感几乎不变。 + // 特意没采用「拖动时降分辨率」:那会重建合成器的渲染目标(几十 MB 显存回收 + 重新分配), + // 反倒让每次开始拖动的头几帧卡一下。 + configureAdaptiveBokeh(state.bokeh, options.draft ? BOKEH_MIN_TAPS : settings.taps); const uniforms = (state.bokeh as unknown as { uniforms?: Record }).uniforms; if (uniforms) { diff --git a/src/editor/panels/TimelinePanel.test.tsx b/src/editor/panels/TimelinePanel.test.tsx index 703a9a2..40b08cd 100644 --- a/src/editor/panels/TimelinePanel.test.tsx +++ b/src/editor/panels/TimelinePanel.test.tsx @@ -340,6 +340,41 @@ 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("keeps writing the playhead while dragging the ruler even when requestAnimationFrame never fires", () => { + // 拖动播放头的写入必须与 rAF 解耦:rAF 在窗口被遮挡 / 页面在后台 / 被节流的 WebView 里 + // 会整段拖动都不回调,只靠它就会出现「拖动期间画布完全不动、松手才跳一下」。 + vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 1); + + const { container } = render(); + const ruler = screen.getByRole("slider", { name: "时间指针" }); + // jsdom 不做布局(getBoundingClientRect 全是 0),这里给刻度尺一份固定几何:100px 起、1000px 宽 → 5s + vi.spyOn(ruler, "getBoundingClientRect").mockReturnValue({ + x: 100, + y: 0, + top: 0, + bottom: 22, + left: 100, + right: 1100, + width: 1000, + height: 22, + toJSON: () => ({}), + } as DOMRect); + + fireEvent.pointerDown(ruler, { clientX: 100, pointerId: 1 }); + expect(usePlaybackStore.getState().playhead).toBe(0); + + // 拖动期间(还没松手)就必须已经写进 playhead —— rAF 永远不回调也不影响 + fireEvent.pointerMove(ruler, { clientX: 600, pointerId: 1 }); + expect(usePlaybackStore.getState().playhead).toBeCloseTo(2.5, 5); + + fireEvent.pointerMove(ruler, { clientX: 1000, pointerId: 1 }); + fireEvent.pointerUp(ruler, { clientX: 1000, pointerId: 1 }); + + // 松手时补齐最终位置,播放头指示器一并落到位 + expect(usePlaybackStore.getState().playhead).toBeCloseTo(4.5, 5); + expect(container.querySelector(".timeline-playhead")?.getAttribute("style")).toContain("90%"); +}); + it("stops playback and rewinds to the start from the transport controls", () => { usePlaybackStore.getState().setPlaying(true); usePlaybackStore.getState().setPlayhead(3); diff --git a/src/editor/panels/TimelinePanel.tsx b/src/editor/panels/TimelinePanel.tsx index b0718e4..4a9c3b5 100644 --- a/src/editor/panels/TimelinePanel.tsx +++ b/src/editor/panels/TimelinePanel.tsx @@ -237,6 +237,17 @@ const TRACK_DRAG_THRESHOLD_PX = 4; /** 时间滑块(片段 / 关键帧)吸附阈值(px):拖动的边缘进入其它片段边缘 / 播放头这么近就吸上去 */ const SNAP_THRESHOLD_PX = 8; +/** + * 拖动播放头时写 playhead 的最小间隔(ms):指针事件频率远高于刷新率(高回报率鼠标 / 触控板 + * 一秒几百条),逐条写会把帧预算全花在 React 同步重渲染上(playbackStore 走 useSyncExternalStore), + * 所以同一个时间片内只保留最新位置写一次,约等于「一帧一次」。 + * + * 刻意不用 requestAnimationFrame 来当这个节流时钟:rAF 在窗口被遮挡 / 页面在后台 / 被节流的 + * WebView 里会整段拖动都不回调,那样拖动期间一次都写不进 playhead —— 画布就会「完全不动, + * 松手才跳一下」(松手走 pointerup 里的直接写入)。用 performance.now() 判断时间片不依赖任何回调。 + */ +const SCRUB_APPLY_INTERVAL_MS = 16; + /** 轨道行的纵向几何(拖拽开始时量一次就缓存,移动中只查表) */ interface TrackRowRect { top: number; @@ -1213,19 +1224,9 @@ export function TimelinePanel() { const rulerRef = useRef(null); const rulerRectRef = useRef(null); const [isScrubbing, setIsScrubbing] = useState(false); - // 拖动播放头:指针事件频率远高于刷新率(高回报率鼠标 / 触控板一秒几百次), - // 逐条 setPlayhead 会把帧预算全花在 React 重渲染上。只记最新位置,按帧应用一次。 + // 拖动播放头:只记最新位置,按时间片写一次(见 SCRUB_APPLY_INTERVAL_MS 的注释) const scrubClientXRef = useRef(null); - const scrubFrameRef = useRef(0); - - function cancelScrubFrame() { - if (scrubFrameRef.current === 0) return; - cancelAnimationFrame(scrubFrameRef.current); - scrubFrameRef.current = 0; - } - - // 卸载时兜底:扔掉待执行的 scrub 帧,别在组件已经没了之后还去写 playhead - useEffect(() => () => cancelScrubFrame(), []); + const scrubLastApplyRef = useRef(0); const rulerTicks = useMemo(() => getTimelineRulerTicks(duration), [duration]); const rulerMinorTicks = useMemo( @@ -1974,6 +1975,8 @@ export function TimelinePanel() { event.preventDefault(); setIsScrubbing(true); setScrubbing(true); + // 本次拖动第一次移动立刻生效,不要被上一次拖动残留的时间片挡住 + scrubLastApplyRef.current = 0; // 拖动播放头期间同样冻结全站毛玻璃:面板 / 画幅遮罩都带 backdrop-filter, // 它们背后是整张 WebGL 画面(机位视角下就是监视器那块大画面),只要被重绘就会 // 整块重算背景模糊 —— 这正是「拖动轨道卡死」的同一条原因,拖播放头一样躲不掉。 @@ -1987,9 +1990,8 @@ export function TimelinePanel() { } } - /** 按帧应用一次拖动位置(指针事件可能一秒几百条,这里只保留最后一条) */ + /** 把当前指针位置换算成时间写进 playhead */ function applyScrubFrame() { - scrubFrameRef.current = 0; const clientX = scrubClientXRef.current; if (clientX === null) return; const time = getTimeFromPointer(clientX); @@ -1999,21 +2001,21 @@ export function TimelinePanel() { function handleRulerPointerMove(event: ReactPointerEvent) { if (!isScrubbing) return; + // 只记最新位置;本时间片内已经写过就等下一次移动再写(末尾由 pointerup 补齐)—— + // 拖动期间必须真的写进去,画布才会实时预览,所以这里同步写、不挂在 rAF 上。 scrubClientXRef.current = event.clientX; - if (scrubFrameRef.current !== 0) return; - scrubFrameRef.current = requestAnimationFrame(applyScrubFrame); + const now = performance.now(); + if (now - scrubLastApplyRef.current < SCRUB_APPLY_INTERVAL_MS) return; + scrubLastApplyRef.current = now; + applyScrubFrame(); } function handleRulerPointerEnd(event: ReactPointerEvent) { if (!isScrubbing) return; // 松手前先把最后一帧的位置补上,避免丢掉最后一次移动 - cancelScrubFrame(); - if (scrubClientXRef.current !== null) { - const time = getTimeFromPointer(scrubClientXRef.current); - if (time !== null) setPlayhead(time); - scrubClientXRef.current = null; - } + applyScrubFrame(); + scrubClientXRef.current = null; setIsScrubbing(false); setScrubbing(false); document.body.classList.remove("is-timeline-dragging");