Skip to content

fix(studio): timeline composition clips show each tile's own frame instead of one repeated poster - #5046

Draft
miguel-heygen wants to merge 21 commits into
mainfrom
fix/studio-comp-filmstrip
Draft

miguel-heygen wants to merge 21 commits into
mainfrom
fix/studio-comp-filmstrip

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

What

A composition clip's timeline strip now shows, in each tile, the frame the composition renders at that tile's time. Before, every tile repeated one poster frame, so a ten-second day-to-night scene looked like ten copies of mid-morning.

The picture also fills the clip's full height: the name now sits on the frame with a shadow that reads on light and dark frames, instead of on a black band above it. While the frames load, the clip shows its own fill colour and its name, with no grey pulse.

Why

CompositionThumbnail built one poster URL (at seekTime + duration / 2) and every tile rendered that same image. Video clips already map tiles to distinct frames; composition clips never did.

How

  • Which frame each tile shows (planCompositionStrip): each tile's centre maps onto a time grid whose step is the largest power of two no longer than one tile, measured from the tile's real width. Neighbouring tiles therefore always get different frames (down to 1/32 s) and each frame falls inside its own tile's time span. The last tile, which can run past the clip's end, shows the clip's last frame. Chunk boundaries come from the clip's range and the grid step alone, so zooming within one power of two or scrolling asks for exactly the same chunks again.
  • One request per chunk of tiles: the grid is cut into fixed chunks of up to 8 frames. Only the tiles on screen mount (ThumbnailTiles), so only their chunks are requested. A chunk is one GET .../thumbnail/<comp>?times=a,b,..., and the server renders its times in ascending order in one generation turn, so the adapter's forward-only warm page serves every frame without reloading. It returns them side by side in one image (sharp, already a studio-server dependency). A malformed times, or times with PNG, is refused with 400 rather than quietly rendering one frame.
  • Posters are never stuck behind strips: a strip holds the server's single render slot for several seconds, so the generation coordinator now queues strips behind every waiting single frame. That covers posters, frame captures and selection snapshots.
  • Visible tiles never wait for the next-open poster: the preview's whole-film poster, rendered for the next time the project opens, is now marked background (background=1). It queues behind all visible work, and if it is already rendering when a visible thumbnail arrives, it is stopped and queued again behind that work, so it still finishes afterwards. The render slot stays taken until the stopped render has actually let go of the browser, so two renders never overlap. Visible work never interrupts other visible work. Before, a clip's first frames could wait for that whole render.
  • One page load per strip: adapters gain an optional generateThumbnailFrames that loads the composition once and seeks and captures every time in that page. The dev server implements it (it opened a page per frame before); the CLI preview server already reuses one warm page across a strip's ascending times, and an adapter without the method keeps rendering frames one by one.
  • Open-time work: on main the timeline's posters and the sidebar's composition cards shared one URL. The timeline now asks for strips instead, so opening the 12-scene work-count fixture now asks for one strip per clip plus the sidebar's posters for the cards it shows: 24 renders and 25 requests on CI, against 14 and 15 on main. The ceilings in perf-ceilings.json go up by exactly that, with the reason recorded.
  • Retried thumbnails reach every tile: a failed thumbnail asked for again after its quiet period was replaced by a fresh entry, so the tiles already waiting on it never heard the retry's result (true on main for every thumbnail kind). It is now retried in place, keeping every lease and listener.
  • Cache file names: the thumbnail cache now names each file by a hash of its full identity. A composition in nested folders already produced names over 255 bytes on main (ENAMETOOLONG, so every request rendered again and failed), and a strip's times would have made that worse. Existing caches regenerate once.
  • One request per chunk, no poster first: a tile asks only for its chunk. Until the first chunk arrives the clip shows its own fill and name; after that a tile keeps showing its last frame until its next chunk arrives, so a zoom or an edit never flashes empty. A tile whose strip fails or times out keeps what it last showed, or shows the clip's poster if it had nothing yet, and keeps that through the retry. The frame's shape is learned from the first chunk of each thumbnail revision (16:9 until then), so an edit that changes it re-plans the tiles once; until the edit's first chunk arrives the clip keeps its last known shape.
  • One decode per chunk, one lease per chunk: each tile shows its slice of the chunk image as a CSS background, so a chunk is fetched and decoded once however many tiles use it. Tiles are plain elements: the clip works out what every visible tile shows and holds one thumbnail lease per chunk it needs, so a zoom that mounts and unmounts tiles takes no per-tile leases. Requests and cells are cached per grid step, and a slice re-renders only when its frame changes.
  • Cheaper thumbnail bookkeeping: a request's identity is computed once per request object; taking a lease on work that is already loading, loaded or failed no longer re-sorts the queue, and dropping a lease no longer re-runs eviction, which only a newly cached result can need.
  • Time mapping: composition clips use their playback start and rate, as the runtime seeks a child composition. Drilled-in and root element clips use their own start and duration.
  • Edits refresh the strip through the existing revision parameter, which every chunk URL carries.
  • Strips wait out a zoom: the thumbnail scheduler has always been able to hold rich loads, but nothing called it. A zoom now holds them until the timeline has kept its zoom for 150 ms, so a zoom gesture no longer starts and cancels strip loads on every step. Scrolling, including playback's auto-follow, does not hold anything.
  • Name on the frame: composition clips carry is-composition; their content starts at the top of the clip, and their name drops the band background and uses --timeline-thumbnail-label-shadow. That token was a single 1 px drop shadow, which barely showed on near-white frames, so it is now a tight dark halo plus the drop shadow. Video, image and text clips keep their band.
  • Loading look: the grey pulse over a loading composition clip is gone; the clip's fill and its name show until its frames arrive.
  • A malformed times, or times with a source-size output (format=png or output=source), is refused with 400.

Measured

CI, the "Studio: timeline viewport gate" job, each arm on one runner: main at 184d254 (run) against this PR at 7fa8fb0 (run).

Measure main this PR gate limit
Scroll, 50,000 clips, virtualized: interaction p95 49.8 ms 48.2 ms 58.3 ms
Same: frame interval p95 16.8 ms 16.7 ms 25 ms
Same: timing runs passing 4 of 5 4 of 5
Scroll, 1,000 clips, unvirtualized: interaction p95 66.2 ms 66.0 ms 75 ms
Same: frame interval p95 33.4 ms 33.4 ms 75 ms
Longest main-thread task seen 325 ms 325 ms

That fixture's clips are plain elements (its default profile), so it measures the timeline's render path at scale rather than composition strips.

Opening the 12-scene work-count fixture (same CI jobs, counted until thumbnail work goes quiet):

Measure main this PR
Time until the film can play 4.74 s 4.04 s
Thumbnail renders / requests 14 / 15 24 / 25
React commits / style recalcs / layouts, whole journey 64 / 2,445 / 81 97 / 2,111 / 92
Same, in the fixed 20 s idle window 15 / 637 / 7 15 / 386 / 5

Server time per thumbnail request on the Studio dev server, one open of the same fixture on a shared Linux box (request start to finish, so it includes waiting behind the other in-flight request): main's poster median 10.9 s (12 requests); this PR's 3-frame strip median 11.8 s (12 strips, all finished, none aborted). The strip's two extra frames cost about 0.9 s because they come from the same page load.

A visible clip's frames requested while the next-open poster renders, on the Studio dev server with the same fixture. The poster request starts, and one second later the clip's frames are requested the way each arm's timeline asks (main: one frame; this PR: a 3-frame strip). Three rounds per arm, all within 0.1 s of each other:

Measure main this PR
Clip frames arrive (after the poster started) 10.9 s 6.9 s
Clip request time 9.9 s, of which about 4.5 s waiting for the poster 5.9 s
Poster finishes 5.5 s 12.4 s, complete (same 20 KB image)

Against the 8 ms per-frame bar: the CI gate reports frame intervals, not per-frame main-thread work, so per-frame work comes from a bench of scroll, zoom and playback on the CLI preview server, with the strip fixture, on a shared 8-core Linux box (load 3.5 to 8). Five alternating main / this PR rounds at this head's tile code; the zoom pass steps the zoom slider from max to min in 21 steps:

Zoom pass main this PR
Frames over 8 ms of main-thread work, per run 5 / 6 / 4 / 4 / 9 (median 5) 3 / 5 / 7 / 6 / 10 (median 6)
Dropped frames (over 25 ms), per run 2 / 2 / 2 / 2 / 3 2 / 3 / 3 / 2 / 5
Main-thread time per zoom pass, average 294 ms 381 ms

Scroll and playback are level with main. Neither side meets the 8 ms bar on this box. The remaining zoom cost is the feature itself: tiles change frames as the zoom moves (about 22 ms more style and paint per pass), strip loads from the scroll before it land during the zoom (about 28 ms of IPC and 17 ms of garbage collection), and about 23 ms of script. At this PR's first strip version the zoom median was 6 frames on main against 11 here. Giving every tile its own compositor layer was tried and measured worse (54 frames over 8 ms against main's 37 in five rounds), so it is not in this PR.

Before / After

Same fixture built for this PR (a 10 s "Day" scene from a near-white dawn to night, a 120 s "Long" one, a 10 s "Night" scene from night to dawn), same zoom, same viewport, current headless Chrome. "Loading" is taken with every thumbnail request held back.

Before

Loading: the black name band over a grey fill.

Main while loading: black name bands over grey fills

Loaded: one repeated frame per clip, under the band.

Main loaded: each clip repeats one frame under a black name band

After

Loading: each clip's own fill with its name, no band, no grey.

Branch while loading: each clip shows its fill colour and its shadowed name

Loaded: each tile shows its own moment, filling the clip's height; the names read on the near-white Day frame and the dark Night frame.

Branch loaded: Day runs dawn to night, Night runs night to dawn, Long drifts pink to blue, names readable on light and dark frames

Test plan

  • CompositionThumbnail.test.ts: an 8 s clip in a 500 px strip of 71 px tiles asks for one strip at 0.5 s ... 7.5 s; each tile shows the frame inside its own span ([0, 1, 2, 3, 5, 6, 7, 7], the last tile running past the clip), at its own cell and one frame's aspect. It fails on main, where every tile is the same poster, and it also fails if the span is taken as range / tile count. Planner properties over five clip shapes: each frame falls inside its tile and is later than the previous tile's, chunks hold at most 8 ascending times, and every zoom inside one power of two asks for identical chunks.
  • thumbnail.test.ts: times=3,1,2 renders 1, 2, 3 in that order and returns one 3-cell image, with each cell holding its frame's colour. Malformed times, more than 8 times, or times with PNG gets a 400 and renders nothing. A strip or a single frame of a composition three folders deep is cached under a short name. All of these fail on main's route; the single-frame long-path case is the existing bug.
  • thumbnailGenerationCoordinator.test.ts: queued single frames start before queued strips, and strips before the background poster, each kept in order. It fails on main's first-in-first-out queue. A strip arriving while the background poster renders stops it, starts only once the stopped render returns, then the poster runs again to completion (fails with preemption removed, and fails if the slot is freed before the stopped render returns); a single frame arriving while a strip renders waits for it.
  • CompositionThumbnail.test.ts: a tile keeps its shown strip leased while the next one loads, so it is never released and re-fetched; a clip one tile wide shows its middle frame.
  • Real Chrome on the CLI preview server, main vs branch: captures above, timings in the table.
  • TimelineClip.test.tsx and timelineMotionStyles.test.ts: only composition clips carry is-composition, their content starts at the top, and their name has no band and uses the shadow token.
  • CompositionThumbnail.test.ts: nothing is drawn over the clip's fill while the poster loads (main draws the grey pulse). Tiles exactly one frame wide are filled edge to edge, so neighbours meet without a seam, and it fails if every tile letterboxes. A portrait frame in a tile held at the 48 px minimum width letterboxes at its own aspect.
  • CompositionThumbnail.test.ts: a clip asks for nothing before it has a width and then for its strip alone (no poster), and a tile keeps its last frame until its new chunk arrives; both fail with the poster-first tile.
  • CompositionThumbnail.test.ts: a tile whose strip fails shows the poster, and an edit that turns the frames portrait re-tiles the clip at the new shape; each fails with its fix removed.
  • vite.browser.test.ts: a 3-frame strip opens one page, loads it once and seeks to 1, 2 and 3 in order. thumbnail.test.ts: an adapter with generateThumbnailFrames gets one call with the ascending times and no single-frame calls.
  • thumbnailScheduler.test.ts: a lease taken before a failure sees the retried result after the quiet period (fails on main's replace-the-entry retry). CompositionThumbnail.test.ts: a portrait clip keeps its 48 px tiles while an edit's frames load.
  • thumbnailScheduler.test.ts: a rich load waits while noteMotion() keeps arriving and starts once the timeline has been still for 150 ms.
  • CompositionThumbnail.test.ts: a clip whose 8 tiles share a chunk takes one lease on it (16 with per-tile leases), and a priority change moves that lease instead of taking another.
  • Studio src/player, src/hooks and src/components: 6118 tests pass, three runs in a row; studio-server routes: 527 tests pass, three runs in a row.
  • Format, lint, typecheck and the comment ratchet.

Size

About 1,450 changed lines, over the usual 1,000. The strip, the render order, the retry fix and the zoom work are kept together because each one is needed for the others to behave, and they ship as one change.

Known limits

  • The next-open poster is best effort. If visible thumbnails keep interrupting it for longer than the client's 30 s thumbnail limit, that render is dropped, and the poster is rendered on the next open instead.

  • When zooming crosses into a new power of two, a tile shows its previous frame (now at the wrong time) until its new chunk arrives. So does a trim that moves a clip edge into another grid cell.

  • A composition whose frames are not 16:9 asks for its first chunks twice on the first open: once planned at 16:9, once at its real shape.

  • With a tile between one and two grid steps wide, up to half of a chunk's frames are not shown at that zoom; they are what the next zoom step shows.

  • A drilled-in element clip whose box changes size over time has every frame fitted to the first frame's cell.

  • A composition clip longer than its composition asks for times past the composition's end; the frame shown is whatever the composition renders there on its own.

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Edit accuracy: accurate 1437 (base branch 2055), smooth 1233 of those

The gate fails: the passing count fell from 2055 to 1437.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

In the base grid but not run (618)

  • crop-fromto-px-r0-nested-z100-after
  • crop-fromto-px-r0-nested-z200-mid
  • crop-fromto-px-r0-nested-z50-after
  • crop-fromto-px-r0-nested-z50-on
  • crop-fromto-px-r0-root-z100-mid
  • crop-fromto-px-r0-root-z200-on
  • crop-fromto-px-r0-root-z50-after
  • crop-fromto-px-r30-nested-z100-on
  • crop-fromto-px-r30-nested-z200-after
  • crop-fromto-px-r30-nested-z200-on
  • crop-fromto-px-r30-nested-z50-mid
  • crop-fromto-px-r30-root-z100-after
  • crop-fromto-px-r30-root-z100-on
  • crop-fromto-px-r30-root-z200-after
  • crop-fromto-px-r30-root-z50-on
  • crop-hold-center-r0-nested-z100
  • crop-hold-center-r0-root-z50
  • crop-hold-center-r30-root-z200
  • crop-hold-pct-r0-nested-z100
  • crop-hold-pct-r0-root-z50
  • crop-hold-pct-r30-root-z200
  • crop-hold-px-r0-nested-z100
  • crop-hold-px-r0-root-z50
  • crop-hold-px-r30-root-z200
  • crop-hold-xpercent-r0-nested-z100
  • crop-hold-xpercent-r0-root-z50
  • crop-hold-xpercent-r30-root-z200
  • crop-idle-center-r0-nested-z100
  • crop-idle-center-r0-root-z50
  • crop-idle-center-r30-root-z200
  • ...

Quarantined, measured but not gated (0)

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

Fallow audit report

Found 42 findings.

Duplication (41)
Severity Rule Location Description
minor fallow/code-duplication packages/studio-server/src/routes/loudness.test.ts:18 Code clone group 5 (9 lines, 3 instances)
minor fallow/code-duplication packages/studio-server/src/routes/projectIdEncoding.test.ts:15 Code clone group 5 (9 lines, 3 instances)
minor fallow/code-duplication packages/studio-server/src/routes/projectIdEncoding.test.ts:15 Code clone group 6 (13 lines, 2 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:40 Code clone group 6 (13 lines, 2 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:40 Code clone group 5 (9 lines, 3 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:289 Code clone group 7 (9 lines, 2 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:297 Code clone group 8 (6 lines, 2 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:395 Code clone group 9 (9 lines, 3 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:395 Code clone group 11 (12 lines, 5 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:395 Code clone group 10 (12 lines, 4 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:421 Code clone group 12 (10 lines, 2 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:421 Code clone group 10 (12 lines, 4 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:421 Code clone group 11 (12 lines, 5 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:421 Code clone group 9 (9 lines, 3 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:445 Code clone group 10 (12 lines, 4 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:445 Code clone group 11 (12 lines, 5 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:467 Code clone group 14 (6 lines, 3 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:467 Code clone group 13 (12 lines, 2 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:506 Code clone group 11 (12 lines, 5 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:522 Code clone group 14 (6 lines, 3 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:539 Code clone group 14 (6 lines, 3 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:539 Code clone group 13 (12 lines, 2 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:567 Code clone group 7 (9 lines, 2 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:575 Code clone group 8 (6 lines, 2 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:598 Code clone group 15 (8 lines, 2 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:609 Code clone group 16 (6 lines, 2 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:626 Code clone group 16 (6 lines, 2 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:654 Code clone group 15 (8 lines, 2 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:664 Code clone group 12 (10 lines, 2 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:664 Code clone group 9 (9 lines, 3 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:664 Code clone group 10 (12 lines, 4 instances)
minor fallow/code-duplication packages/studio-server/src/routes/thumbnail.test.ts:664 Code clone group 11 (12 lines, 5 instances)
minor fallow/code-duplication packages/studio/src/player/components/CompositionThumbnail.test.ts:45 Code clone group 1 (10 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/player/components/ImageThumbnail.test.tsx:58 Code clone group 1 (10 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/player/lib/thumbnailScheduler.test.ts:277 Code clone group 2 (11 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/player/lib/thumbnailScheduler.test.ts:279 Code clone group 3 (8 lines, 3 instances)
minor fallow/code-duplication packages/studio/src/player/lib/thumbnailScheduler.test.ts:296 Code clone group 4 (8 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/player/lib/thumbnailScheduler.test.ts:313 Code clone group 2 (11 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/player/lib/thumbnailScheduler.test.ts:315 Code clone group 3 (8 lines, 3 instances)
minor fallow/code-duplication packages/studio/src/player/lib/thumbnailScheduler.test.ts:329 Code clone group 4 (8 lines, 2 instances)
minor fallow/code-duplication packages/studio/src/player/lib/thumbnailScheduler.test.ts:343 Code clone group 3 (8 lines, 3 instances)
Health (1)
Severity Rule Location Description
minor fallow/high-crap-score packages/studio/src/player/components/CompositionThumbnail.tsx:243 '<arrow>' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)

Generated by fallow.

This branch has not been deployed

No deployments
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