Skip to content

perf(studio): scale timeline strip frames before drawing them - #7651

Merged
miguel-heygen merged 1 commit into
mainfrom
perf/studio-strip-frames-off-main-thread
Nov 14, 2026
Merged

miguel-heygen merged 1 commit into
mainfrom
perf/studio-strip-frames-off-main-thread

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Nov 13, 2026 •

Copy link
Copy Markdown
Collaborator

What

Timeline video strips now get each frame cropped and scaled by the browser (createImageBitmap with the strip's size) instead of drawing the full-size decoded frame onto a canvas. The scaling still runs on the main thread on a box with no GPU (about 140 ms per delete in a CPU profile), so this makes the work smaller, not off-thread.

Why

Drawing a 3456x2234 frame onto a strip-sized canvas costs 100 ms or more of main thread per frame. While strips fill in, an edit's requests (a delete is about eight, one after another) each wait their turn behind those draws, so deleting a clip on a 4K timeline takes well over half a second to land.

Related work

Refs the Studio timeline delete being slow on 4K clips. Leaves the paused-frame decoder alone: switching it off changed nothing.

How

  • New thumbnailFrameSink.ts: scaledFrames reads Mediabunny samples (VideoSampleSink), crops (cover) and scales each straight frame in createImageBitmap, and draws the small bitmap on a strip-sized canvas.
  • Turned or flipped frames keep the sample's own drawWithFit, which applies the turn.
  • thumbnailVideoDecoder.ts uses it in place of CanvasSink; everything downstream (encode, caches, cleanup) is unchanged.

Before

Timeline strips of two 4K clips, drawn the old way.

Before: timeline strips, old draw

After

The same strips with this change: same frames, same crop.

After: timeline strips, scaled frames

What I measured

Cold delete of a 3456x2234, 60 fps, 45 Mbps clip with thumbnails on, in Studio on a shared 2-core Linux box with no GPU (load 2 to 16). Five deletes per run, three runs per build, original and fix alternating, same fixture and build otherwise. Time from pressing Delete to the clip leaving the timeline (median per run):

Run Original This PR
1 1001 ms 346 ms
2 561 ms 680 ms
3 611 ms 287 ms
All 15 deletes 674 ms 354 ms

Main-thread long tasks in the 1.5 s after the press, mean per delete: original 480 ms, this PR 210 ms. With thumbnails switched off in the UI the same delete takes 181 to 269 ms.

Known limit: this does not bring the cold delete under 300 ms on this box every time (run 2 is 680 ms); the box is shared and the spread inside a run is wide.

Test plan

fails without the fix: thumbnailVideoDecoder.test.ts "never hands a full-size frame to a canvas draw" (fails on the old CanvasSink path, where the full frame is drawn).

  • Unit tests added: thumbnailFrameSink.test.ts (cover crop centred, contain whole frame, turned and flipped frames use the sample draw, frames and samples closed when the scale fails); the decoder test mock now yields video samples. Both files pass 3 runs in a row.
  • Manual: the interleaved delete runs above.
  • Documentation updated (not applicable)

What I did NOT exercise

  • Rotated or flipped footage (phone portrait clips) through the real decoder: covered by a unit test of the fallback only.
  • A machine with a GPU or hardware video decode, and a real Mac: all timings are from the shared Linux box.
  • Drag speed: the same box shows drag cost that thumbnails off does not remove; not measured against this change.
  • Other users of the strip frames (waveforms, composition previews).
  • The retired interaction-hold idea (pausing strips during an edit) did not help in the same runs and is not part of this PR.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving at 072a3702. I found no blocker in the code. The PR captures check ("Require Before and After captures") is red on this head and needs captures or an exemption before merge.

What I verified

  • thumbnailFrameSink.test.ts and thumbnailVideoDecoder.test.ts pass at head (50/50).
  • The "fails without the fix:" test fails on base, but run as-is the failure is not the one the body describes. The new mock drops CanvasSink, so the old decoder fails with No "CanvasSink" export is defined on the "mediabunny" mock. I added the old CanvasSink mock back next to VideoSampleSink and it then fails on the real point: createImageBitmap is never called, so the full frame goes through the canvas draw. The test holds either way. Consider keeping a CanvasSink stub in the mock so the failure on main says why.
  • Resource handling: the VideoFrame from toVideoFrame() is closed in .finally even when createImageBitmap rejects. The bitmap is closed after the draw. The sample is closed in finally, which also runs when the decode loop stops iterating early (abort or timeout).
  • No distortion: target comes from targetDimensions(info.aspect), so its aspect already matches the frame. The contain path, which resizes the whole frame into target, can't letterbox or stretch beyond rounding. Turned and flipped samples keep drawWithFit, which applies the rotation as CanvasSink did.

Non-blocking

  • The test "keeps the reader when an offscreen frame will not encode" was deleted. The HTMLCanvasElement encode-failure case ("keeps the reader when a clip's frame will not encode") still covers retaining the reader. The OffscreenCanvas convertToBlob branch is now only reachable without a document, and no test covers it.
  • Each frame now gets a new canvas, where CanvasSink used poolSize: 1. That's fine at strip sizes. I'm noting it in case strip counts get large.
  • Browser support: createImageBitmap(VideoFrame, sx, sy, sw, sh, { resizeWidth, resizeHeight }) is fine in Chromium. As the body says, Safari and Firefox weren't exercised.

@github-actions

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 267 (base branch 267), smooth 86 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 (8)

@miguel-heygen miguel-heygen changed the title perf(studio): scale timeline strip frames off the main thread perf(studio): scale timeline strip frames before drawing them Nov 14, 2026
@miguel-heygen
miguel-heygen added this pull request to the merge queue Nov 14, 2026
Merged via the queue into main with commit f207f61 Nov 14, 2026
309 of 313 checks passed
@miguel-heygen
miguel-heygen deleted the perf/studio-strip-frames-off-main-thread branch November 14, 2026 03:16
@heygengenesis heygengenesis Bot mentioned this pull request Nov 14, 2026
1 of 2 tasks
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.

1 participant