Skip to content

perf(studio): smooth timeline zoom on long projects - #5109

Merged
miguel-heygen merged 38 commits into
mainfrom
perf/studio-timeline-zoom
Oct 7, 2026
Merged

miguel-heygen merged 38 commits into
mainfrom
perf/studio-timeline-zoom

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

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.

  • During a zoom gesture (pinch, Ctrl/Cmd+wheel, slider, buttons) the timeline is scaled on the compositor (scaleX plus 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.
  • Every zoom input goes through one owner, 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.
  • Clips are placed in percent inside a time layer, and lane clips are memoized, so a zoom that is laid out restyles one layer, not every clip.
  • Waveforms draw only the stretch of a clip near the screen, using the strip tracker the filmstrips already use. Filmstrips decode the width they settle on, not each step's. Fade handles measure only while shown.

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.

Input main this PR
Pinch 12.8 / 12.4 fps (p95 433 / 450 ms) 33.8 / 32.2 fps (p95 117 / 67 ms)
Slider 5.2 / 5.7 fps (p95 883 / 867 ms) 11.6 / 11.9 fps (p95 367 / 367 ms)
Ctrl+wheel 14.5 / 15.3 fps (p95 267 / 267 ms) 18.9 / 22.6 fps (p95 183 / 200 ms)
Zoom buttons 17.2 / 15.7 fps (p95 283 / 250 ms) 15.7 / 14.6 fps (p95 217 / 233 ms)
Scroll while zoomed 21.3 / 24.7 fps 18.5 / 20.8 fps

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

  • Preview, then one layout. requestTimelineZoom keeps 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.
  • Zooming out. An eased zoom-out (a Zoom out click, or a wider range) lays its target out in its first frame and eases by scaling that layout down from the old view, so a click lays out once instead of partway and again at the end.
  • Interaction. A press on the timeline lays a pending zoom out first, so the press lands at the time it shows. A scroll during a preview rescales what it mounted. Fit drops a pending zoom. Rows React mounts during a preview are scaled before they paint.
  • Strips. The shared strip tracker does not measure while a preview scales the strips; it re-measures every strip each time a preview is laid out. Waveforms mark their undrawn ends, so a long clip that moves without a scroll is re-measured.
  • Mounted window. The render window's overscan grows from a quarter to half a viewport each side, so a zoom-out preview has clips to show. Measured on the same build at 659%, where the view does not hold every clip: 338 clips mounted instead of 280 (+21%), 61,998 page elements instead of 61,338 (+1%), and JS heap after GC 43.4 MB instead of 36.7 MB (between runs the heap varies by up to 11 MB at the same clip count). At Fit and 251% every clip is mounted either way. Kept, since a smaller window shows blank strips while scrolling.
  • Known limits. During a preview, text in the timeline is stretched sideways with the scale (up to 2x on a zoom-out click, up to 4x on a long range zoom) until the zoom lands. A pinch below Fit lays out every frame.

Test plan

  • Unit tests added and updated for the zoom owner, the playhead and pinch hook, the toolbar, lanes, fades, the strip tracker and waveforms. Each fix's test was checked to fail with the fix reverted, and each passed 3 runs in a row.
  • Full Studio suite green (688 files).
  • Frame timings with the harness above, main and this PR alternated in the same session.
  • Before/After video of the 60-minute take below.
  • Documentation updated (not applicable)
  • Comments say why, not what.

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

…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.
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 2059 (base branch 2059), smooth 1571 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

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.
@miguel-heygen
miguel-heygen marked this pull request as ready for review October 6, 2026 22:24
The comment ratchet failed on ten files this branch touches. Comments are
cut or folded into one line; no code changes.

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 now requestTimelineZoom
  • the buttons go through zoomTimelineStep, which eases via easeToRange
  • 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 pointerdown on the scroller settles a pending zoom first, so a press or drag never starts against a scaled preview.
  • Fade boxes re-measure whenever handlesVisible or widthPx changes. clipZoomKey returns the raw pps while fades exist or the clip is hovered or selected, so the memoized clip re-renders and the fades get a fresh pps before 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 --noEmit is 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
    • previewNeedsLayout never 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

  1. The takeTimelineZoomAnchor branch in useTimelinePlayhead's layout effect looks redundant. Replacing if (anchor) with if (false) passes every test. That's because commitPreview computes left before flushSync and writes scrollLeft itself afterwards. The only path that sets an anchor without a later commitPreview is request() with no registered viewport, and there is no scroller to move in that case. Either drop anchorForCommit, takeTimelineZoomAnchor and that branch, or add a test that needs them.

  2. playerStore.setManualZoomPercent has no production caller left. useTimelineZoom now hands out requestTimelineZoom under that name, and only playerStore.test.ts calls the store action. It's leftover state from the old path, and it bypasses the owner if anyone picks it up later.

  3. Two mutations survived:

    • removing the isTimelineZoomPreviewing() guard in the strip tracker's refresh (the measure and remeasureAfterPreview paths are covered)
    • removing the drop-on-unmount in registerTimelineZoomViewport
  4. 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.

  5. zoomTimelineToRange and currentTimelineRange are new public exports from @hyperframes/studio with no consumer in this repo yet. The buttons use the internal easeToRange. That's fine if a host is about to use them. Otherwise it's API surface to keep stable.

— Rames

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants