Repository navigation
perf(studio): smooth timeline zoom on long projects - #5109
Conversation
…to a range Every zoom input goes through one owner that writes the store once per frame. zoomTimelineToRange eases to a time range; the zoom buttons use it.
Edit accuracy: accurate 2059 (base branch 2059), smooth 1571 of thoseThe gate passes. Quarantined, measured but not gated (0) |
…styles nothing A zoom step rewrites one width per row; clip thumbnails start decoding at rest.
Clip handlers read the lane callbacks when pressed, so a skipped clip never acts on a stale zoom.
…rests Waveforms draw only the visible span of a clip. A zoom or scroll step stretches what is drawn until the timeline rests.
…nce at rest No layout or render per gesture frame; past 0.67x or 2x the preview is laid out and continues.
…om-at-rest # Conflicts: # packages/studio/src/player/components/timelineZoomInput.ts
Clips stay mounted half a viewport each side, so a zoom-out preview to 0.5x shows no gaps.
The slider and its percent follow a zoom while it is previewed. A zoom in from below Fit no longer jumps when it lands: the preview now uses the width the timeline will really have. A zoom-out lays out as soon as it would show time with no clips mounted, so edges never show empty strips. Fit, a press or a scroll on the timeline end a pending zoom first. An older abort no longer cancels a newer zoom-to-range, and an instant one lands on the next frame instead of inside its caller. Range selection and gap tints scale with the preview.
…om-at-rest # Conflicts: # packages/studio/src/player/components/timelineZoomInput.ts
The timeline registers its scroll view again whenever clips are added or removed, which used to drop a zoom still waiting to be laid out. Now only a real unmount drops it, and unmounting one timeline leaves a newer timeline's registration alone.
…om-at-rest # Conflicts: # packages/studio/src/player/components/timelineZoomInput.test.ts
…y has Zoom scales come from getTimelinePixelsPerSecond, the range margins from one helper, and a zoom step starts from the same playhead anchor every zoom uses. The clip minimum width is named and shared with the drag ghost.
… end A scroll during a zoom preview, including the one a laid-out zoom makes itself, used to lay the next preview out at once, so a pinch or eased zoom fell back to a full layout every frame. A scroll now only rescales what it mounted. Zooming out also lays out before the preview shows past where the ruler and beat lines are drawn, not only past the mounted clips.
A waveform now draws the stretch of its clip that the shared strip tracker reports near the screen, the tracker the filmstrip already uses. It follows scrolls, resizes and moves (a drag or ripple that shifts the clip), so a moved clip no longer shows a blank stretch, and a zoom redraws it once.
An eased zoom-out (a Zoom out click, or zooming out to a range) used to lay out partway, once the shrinking preview reached time with nothing mounted, and again at the end. It now lays the target out first and eases by scaling that layout down from the old view, so each click lays out once. Each eased frame also draws its preview in that frame.
… rows The preview now lays out only once it would show past the window the timeline draws clips, ruler ticks and beat lines in, with two pixels of slack, so rounding at the content's end no longer lays out every frame. Rows mounted into the timeline during a preview are scaled before they paint.
A waveform drawn whole kept its old bars, stretched, after a zoom changed its clip's width; it now redraws on any resize. A long clip also marks its undrawn ends for the strip tracker, so a move without a scroll (a ripple, a drop, an undo) re-measures it instead of leaving a blank stretch.
An eased zoom-out lays its target out first, so its preview ends on the scale already laid out. Landing it now only sets the scroll, instead of writing the same zoom again and counting the person's zoom twice.
The strip tracker measured clip boxes while a zoom preview scaled them, so after a Zoom out click a long clip's filmstrip or waveform could stay blank across part of the screen. Reads during a preview now wait, and the tracker re-measures every strip once the preview ends. A zoom-out lays its target out in its first frame rather than inside the caller, and only when the new view holds the old one.
A clip drawn whole has no undrawn ends, so it no longer mounts the two empty markers the strip tracker watches; with hundreds of short audio clips that saved an observer entry each.
A zoom-out lays its target out partway into the ease, after which the strip tracker kept the stretch it measured before the zoom, so a long filmstrip or waveform went partly blank near the end of the ease. Every layout of a preview now re-measures the strips.
The comment ratchet failed on ten files this branch touches. Comments are cut or folded into one line; no code changes.
jrusso1020
left a comment
There was a problem hiding this comment.
Reviewed at 97966f58. Approving.
One zoom owner: holds. Every input that changes the zoom percent goes through timelineZoomInput.ts:
- pinch and Ctrl/Cmd+wheel go through
requestTimelineZoom - the slider goes through
useTimelineZoom.setManualZoomPercent, which is nowrequestTimelineZoom - the buttons go through
zoomTimelineStep, which eases viaeaseToRange - Fit goes through
cancelTimelineZoom, then sets the mode
The only other writers of zoomMode/manualZoomPercent don't change the scale: pinTimelineZoom pins the current scale, and useStudioTestHooks is test-only. The old pinch path is gone: skipCenterAnchorRef, the manualZoomPercentRef plumbing, and the rAF scroll write.
Wheel and pinch. The wheel listener is { passive: false, capture: true } and calls preventDefault, so a Chromium/Electron trackpad pinch (ctrl+wheel) can't zoom the page. Cmd+wheel matches NLEPreview's existing ctrlKey || metaKey rule. Safari sends gesture* events rather than ctrl+wheel, but that was already the case on main.
Measuring only what's shown, and stale measurements.
- A
pointerdownon the scroller settles a pending zoom first, so a press or drag never starts against a scaled preview. - Fade boxes re-measure whenever
handlesVisibleorwidthPxchanges.clipZoomKeyreturns the rawppswhile fades exist or the clip is hovered or selected, so the memoized clip re-renders and the fades get a freshppsbefore a fade drag can start. - The strip tracker re-measures every strip when a preview lands, through
subscribeTimelineZoomPreview. - Rows mounted during a preview are scaled through the MutationObserver before they paint.
The overlays that are positioned from pps and not scaled are the drag ghost, the drop preview and the snap guide. They only exist mid-drag, after a press has already settled the zoom.
Verification on a local merge with current main (21 commits ahead, 8 of them touching studio/src/player):
tsc --noEmitis clean.- The full Studio suite passes: 691 files, 7,726 tests.
- I applied 12 mutations and the tests caught 9:
- Cmd+wheel removed
- settle-on-press removed
previewNeedsLayoutnever true- no scroll fix on commit
- no scaling of rows mounted mid-preview
- Fit keeps the preview
- fades always measure
- clip memo ignores
zoomKey - a person's zoom doesn't cancel an ease
- CI: 94 pass.
Non-blocking
-
The
takeTimelineZoomAnchorbranch inuseTimelinePlayhead's layout effect looks redundant. Replacingif (anchor)withif (false)passes every test. That's becausecommitPreviewcomputesleftbeforeflushSyncand writesscrollLeftitself afterwards. The only path that sets an anchor without a latercommitPreviewisrequest()with no registered viewport, and there is no scroller to move in that case. Either dropanchorForCommit,takeTimelineZoomAnchorand that branch, or add a test that needs them. -
playerStore.setManualZoomPercenthas no production caller left.useTimelineZoomnow hands outrequestTimelineZoomunder that name, and onlyplayerStore.test.tscalls the store action. It's leftover state from the old path, and it bypasses the owner if anyone picks it up later. -
Two mutations survived:
- removing the
isTimelineZoomPreviewing()guard in the strip tracker'srefresh(themeasureandremeasureAfterPreviewpaths are covered) - removing the drop-on-unmount in
registerTimelineZoomViewport
- removing the
-
The headline overstates Ctrl/Cmd+wheel. By the body's own table:
- pinch is about 2.6x and the slider about 2.1x
- Ctrl+wheel is about 1.3-1.5x
- the buttons are flat
- scroll while zoomed is about 15% lower (21.3/24.7 to 18.5/20.8 fps), which reads as the cost of doubling the overscan
The tradeoff is disclosed in the body, but "2-3x for a pinch, the slider and Ctrl/Cmd+wheel" isn't what the table shows for the wheel.
-
zoomTimelineToRangeandcurrentTimelineRangeare new public exports from@hyperframes/studiowith no consumer in this repo yet. The buttons use the internaleaseToRange. That's fine if a host is about to use them. Otherwise it's API surface to keep stable.
— Rames
What
Zooming the timeline on a long project (a 60-minute take plus about 200 cuts) is now 2-3x smoother for a pinch, the slider and Ctrl/Cmd+wheel. Waveforms and filmstrips no longer redraw or decode on every zoom step.
scaleXplus a translate on the rows, ruler and overlays). Nothing is laid out and React does not render per frame. About 150 ms after the last input the real zoom is laid out once.timelineZoomInput.ts, anchored at the pointer or the playhead. Cmd+wheel zooms on Mac like Ctrl+wheel.zoomTimelineToRange(start, end, { smooth, signal })is exported. It eases so a time range fills the timeline and resolves"done"or"cancelled". The zoom buttons use it.Why
On main every zoom step laid out and re-rendered every clip: 5 fps on the slider and 12 fps on a pinch with a long take.
Numbers
Linux x86 machine, headless Chromium, no GPU, 1440x900, 402 clips mounted at Fit. Two rounds per build; the machine was shared (load 4-10), so each cell gives both rounds.
Not done: the zoom buttons are no faster than main, and nothing reaches 60 fps on this machine. Each button step, and each mid-gesture relayout, still lays out the whole timeline once (about 150 ms here). That layout is the next piece of work.
How
requestTimelineZoomkeeps a preview (scale, shift) and draws it each frame as a transform on[data-timeline-zoom-scale]elements. It lays the zoom out once at rest, or earlier when the preview would show time the timeline has not drawn (past the render window, with 2 px of slack) or scale past 4x.Test plan
Before
main: a pinch in and out, the slider, Ctrl+wheel and the zoom buttons on the 60-minute take.
before-zoom-60min.mp4
After
This PR, the same zooms.
after-zoom-60min.mp4