fix(timeline): 修复拖动播放头时画布不实时更新
拖动期间改用 performance.now() 时间片节流替代 requestAnimationFrame,避免在窗口被遮挡或后台节流时整段拖动不写 playhead。同时草稿档不再跳过景深渲染,仅降低模糊采样点数以保留移焦与变焦预览。
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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<string, { value: number }> }).uniforms;
|
||||
if (uniforms) {
|
||||
|
||||
@@ -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(<TimelinePanel />);
|
||||
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);
|
||||
|
||||
@@ -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<HTMLDivElement | null>(null);
|
||||
const rulerRectRef = useRef<DOMRect | null>(null);
|
||||
const [isScrubbing, setIsScrubbing] = useState(false);
|
||||
// 拖动播放头:指针事件频率远高于刷新率(高回报率鼠标 / 触控板一秒几百次),
|
||||
// 逐条 setPlayhead 会把帧预算全花在 React 重渲染上。只记最新位置,按帧应用一次。
|
||||
// 拖动播放头:只记最新位置,按时间片写一次(见 SCRUB_APPLY_INTERVAL_MS 的注释)
|
||||
const scrubClientXRef = useRef<number | null>(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<HTMLDivElement>) {
|
||||
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<HTMLDivElement>) {
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user