Repository navigation
perf(core): the preview loads only the media near the playhead - #4682
Conversation
Studios preview bound every timed video and audio at preload=auto and called
A trimmed clip outside the window now waits at preload=none, reloaded so a fetch the parser started stops too; at metadata, 60 clips' header reads still queued on the origin under a slow link. An untrimmed clip keeps metadata, since its window is read from the element's duration.
Chromium keeps fetching a clip once it has started, whatever preload says after, so the reload at preload=none from the bind only restarted the fetch. In Studio's preview the runtime now sets preload=none on each trimmed clip after the first frame as it is parsed, and reloads only a clip the window armed when it leaves.
At 3 MB/s a cold 1080p clip needed about 3.3 s to show a frame, longer than the 2 s look-ahead, so every clip on a one-track film was still empty when it came due. While the film plays, a clip that starts now arms the next clip on the same track (authored track index within one composition host, read as the timeline payload reads it), and the window pass keeps that clip armed. Paused or scrubbing, only the look-ahead applies.
…t unloaded Studio's timeline readiness scans every media element for canplay, so each far clip the preview deferred held it until the 8 s timeout. In a document with the Studio preview mark, a clip the runtime has not set to buffer now counts only if it is on the first frame, as a lazy image already does.
…ow link Chromium keeps a fetch in flight when preload drops to none and load() runs, so on a 3 MB/s link each played clip kept its connection and the armed next clip queued behind them with no bytes. The release now drops src around the reload and puts the same attribute back, which ends the fetch without a new one.
…me copy The player adopts each iframe clip whose preload turns auto into a parent-frame proxy that fetches the whole file, but never let go when the runtime set the clip back to none. On a 3 MB/s link those proxies and the thumbnail reads held all six connections, so the armed next clip got no bytes. A demoted clip now releases its proxy through the same path a removed clip uses.
…aits A playing preview clip that fires waiting now holds every thumbnail job and aborts the ones in flight until the transport stops; they re-run afterwards. An aborted job frees its slot at once, and a filmstrip decode closes its range requests on abort.
… media waits" This reverts commit 36926eb.
…media-near-playhead
…p leaves behind Studio's loop tick now tells the runtime where a wrap lands (window.__hf.setLoopStart), and while playing the preview keeps the clips due there loaded, so a wrap no longer starts cold. A next-on-track arm lasts only while the clip that set it plays on screen, so a jump away releases it. A clip with no authored length stays out of the window and loads as in a render, so no reload drops its duration. A clip is held until 4 s ahead after it is armed at 2 s, so a scrub or a pause at the edge does not refetch it. The parse-time watcher is installed only in the preview, once per page. One helper stops a clip's download, <source> children included, for released and detached media alike.
The loop start's clips are held out to the 4 s release distance, as a clip being left is, so the wrap no longer reloads the clip after the loop-in one from nothing.
…elease Putting a clip's <source> children back after the reload started a new download and moved them ahead of its other children, which a scene swap reads as an edit. Release now stops only a src clip's fetch; detached media is dropped as on main. Tests cover the loop hold's playing check, its time check, and the untrimmed guard on the next-clip arm.
somanshreddy
left a comment
There was a problem hiding this comment.
Independent review at head cc0b4a64 (worktree, not relying on the "already reviewed" claim in Slack — GitHub showed zero reviews on this PR when I checked, so I did a full pass rather than rubber-stamping).
Scope verified: 15 files, 530/23 — core preload-window logic, a readiness-scope tweak, a player parent-media detach path, and a Studio loop-start wire-through. All new behavior is gated behind skipsHiddenImages(), which reads a style tag injected only when a <meta name="hyperframes-studio-preview"> (STUDIO_PREVIEW_MARK_META) tag is present — confirmed that tag is only ever injected by packages/studio-server/src/routes/preview.ts, so render/producer documents never see it and this whole change is a no-op there. That's the load-bearing safety property for "render is unaffected," and it's a pre-existing gating mechanism (already used for hiding non-media clips' images), not something new and untested.
Ran it, not just read it:
bun install && bun run buildclean.packages/core(preloadMedia.test.ts,compositionReadiness.test.ts,entry.test.ts): 67/67 pass.packages/player(parent-media.test.ts): 23/23 pass.packages/studio'suseTimelinePlayer.seek.test.tsfails withTypeError: act is not a function— checked this againstorigin/mainwith the same test file (stash + control run): identical failure, same count (14/14), pre-existing devbox React/vitest environment issue, not caused by this PR.- Mutation-tested the core "release a clip that left the window" branch in
init.ts(short-circuited theif (!near) { el.preload = "none"; ... releaseMedia(el) }block to a no-op): 11 of the touchedentry.test.tstests failed with specific, meaningful assertion diffs (not generic crashes) — confirms the tests actually exercise the windowing logic, not mock-theater. Restored after.
Design read: the "known limits" section is honest and specific (front-loaded-first-second-on-a-slow-link case genuinely regresses vs. main's brute-force buffering, with numbers) rather than glossing over it — that's the right way to disclose a real, narrow tradeoff instead of hiding it.
No blocking findings. CI on cc0b4a64 had nothing red at check time (several jobs still running).
jrusso1020
left a comment
There was a problem hiding this comment.
Review at cc0b4a64f319b0bc71d30d8dd40e04e37ba93c1e.
Verdict: REQUEST_CHANGES
The design is sound and the render path is safe. Every new branch sits behind the Studio preview mark, and the tests are strong: 19 of 19 mutations I tried were caught. One gap blocks me. The window only looks forward, so a clip is dropped the moment the playhead passes its end. Stepping back one frame across a cut, the most common check an editor makes, now reopens the outgoing clip from scratch and shows a blank frame until it decodes. On main that step is instant. The fix is small.
Findings
Blocking
1. Stepping back across a cut reopens the clip you just left. packages/core/src/runtime/init.ts:2649, :2717, :2708
dueSoon only asks about t + ahead, and mediaStaysDue only holds a clip that is still ahead of the playhead. A clip whose end is just behind the playhead is neither, so the first pass after its end sets preload="none" and calls releaseMedia (src dropped, load(), src restored). The 2 s arm / 4 s release hysteresis protects the leading edge of the window but not the trailing one.
Scenario: clip A is 0 to 2 s, clip B is 2 to 4 s on the same track. Paused at 2.1 s, step back to 1.9 s.
- jsdom, the suite's own helpers: after the step to 2.1 s, A is
preload="none"and released. Two round trips across the cut released it twice. - Headless Chrome 153, runtime bundle built from this head, two 2 s H.264 clips, a range-capable local server:
| A at 2.1 s | A right after stepping back to 1.9 s | A has a frame | |
|---|---|---|---|
| same page, no preview mark (main's media path) | readyState 4, currentTime 1.9 | readyState 4 | 0 ms |
| preview page (this PR) | preload none, readyState 0 | readyState 1 | 28 to 40 ms |
currentTime does land on 1.9 after the reload, so no frame is lost for good. The cost is a blank flash at exactly the spot people scrub to check a cut. My clips were 28 KB and the bytes came back from the browser's buffer cache, so this is the floor. A real multi-MB long-GOP clip reopens its demuxer and decodes from the prior keyframe, and with 60 clips competing for that cache it may go back to the network.
The same gap makes the reverse shuttle (useTimelinePlayer.ts:256, paused seeks moving backward) enter every earlier clip cold. With A at 0 to 2 s and the playhead moving back from 3.9 s in 0.1 s steps, A stays none until 1.9 s, the frame it is already on screen.
Suggested fix: treat the window as symmetric. Arm a clip that ended within LOOKAHEAD_SECONDS behind the playhead, and hold one that ended within RELEASE_LOOKAHEAD_SECONDS behind it. For example, add visibleAt(node, t - ahead) to dueSoon. That keeps at most one extra clip per track loaded and covers both frame-stepping and reverse. A test like "holds a clip past the look-ahead edge a scrub crosses", but crossing a clip's end, would pin it.
Non-blocking
2. The look-ahead is in composition seconds, not wall seconds. init.ts:2639. At a 2x preview rate, 2 s of look-ahead is 1 s of wall time. The next-clip arm covers ordinary playback. A clip on another track that starts during fast playback gets half the lead time. Scaling ahead by the transport rate would fix it. Fine as a follow-up.
3. Each release and arm invalidates the timing caches. releaseMedia writes src twice and fires emptied, and the re-arm fires loadedmetadata and durationchange. All of those are inputs to compositionTimingRevision and to the debounced metadata rebind (init.ts:1234, :2300). I checked the rebind: it only acts when the duration grows, so it stays inert for clips with an authored length, which are the only ones this touches. The cost is one clip-index rebuild per clip boundary. No change needed. I'm noting it so nobody is surprised by it in a profile.
Claims checked
- Render and export never run this. Confirmed. Everything keys off
skipsHiddenImages()(init) or the preview meta (readiness and parse deferral). That meta is injected only instudio-server/src/routes/preview.ts:283, and not whenhf-captureis set, so thumbnail captures load every clip too. Producer, engine and CLI render never import or emit it. Mutations M8 and M9 below, which drop the gate, are each caught by "keeps a render loading every media element at bind". - Leaving the window ends the fetch. Confirmed in code and in Chrome: A went to readyState 0 and networkState idle after release. A later play or seek lands correctly: currentTime came back at 1.9 in Chrome.
<source>clips are not released. Confirmed (preloadMedia.tsearly return, M11). They are still parsed atpreload="none", so they start no fetch before they come due.- Clips without an authored length load as on main. Confirmed (M10). A reload cannot drop a duration the timeline depends on.
- Timeline readiness no longer waits on unloaded clips. Confirmed at
compositionReadiness.ts:108(M17). There is no hang path: a clip the preview left unloaded is only checked for the first frame. - Player drops the parent-frame copy and adopts it again. Confirmed (M18). The src flip in
releaseMediacannot re-adopt the copy, because_adoptIframeMediareturns early whilepreloadisnone. - The next-clip arm is scoped to the same host and track, and only lasts while the arming clip plays. Confirmed (M3, M4, M5, M13, M14).
- Loop start stays loaded. Confirmed in the runtime (M6, M7) and in Studio (M19).
- The merge commits change nothing outside the PR.
git show --remerge-diffis empty for 11071f7 and 11ccdfe.
Mutation table
Each mutation was applied alone, the touched suites were run, and the file was restored.
| # | Mutation | Result | Caught by |
|---|---|---|---|
| M1 | look-ahead reduced to on-screen only | killed | "loads a preview's media only on screen or due within the look-ahead…" (+4) |
| M2 | release hysteresis removed | killed | "holds a clip past the look-ahead edge a scrub crosses…" |
| M3 | next-on-track arm disabled | killed | "arms the next clip on a clip's track when it starts playing…" |
| M4 | same-track filter removed | killed | same, plus "…only in the same host and on an authored track" |
| M5 | same-host filter removed | killed | "…only in the same host and on an authored track" |
| M6 | loop-start hold removed | killed | "keeps the clips due at the loop start loaded…" |
| M7 | loop-start hold also while paused | killed | "holds the loop start only while playing…" |
| M8 | render gate removed from the init window | killed | "keeps a render loading every media element at bind" |
| M9 | render gate removed from parse deferral | killed | same |
| M10 | authored-length check removed | killed | "loads a clip with no authored length as a render does…" |
| M11 | <source> skip removed from release |
killed | "leaves a clip on |
| M12 | release on leave removed | killed | "…and frees what the playhead left" |
| M13 | arm outlives the clip that set it | killed | "drops an arm once the clip that set it leaves the screen…" |
| M14 | a paused playhead arms the next clip | killed | "arms nothing ahead of a paused playhead…" |
| M15 | load() called on a clip a jump lands on |
killed | "arms a clip a far jump lands on without reloading it under the seek" |
| M16 | parse deferral includes t = 0 | killed | "gives each parsed clip after the first frame preload none…" |
| M17 | readiness first-frame scope removed | killed | "waits on a later clip in Studio's preview only once the runtime has set it to buffer" |
| M18 | player detach removed | killed | "drops a clip's proxy when the runtime stops preloading the clip…" |
| M19 | Studio never reports the loop start | killed | "tells the preview where the loop wraps to while it plays…" |
Tests run
packages/core: full vitest suite, 160 files, 3670 passed, 1 skipped.packages/player: full suite, 15 files, 513 passed.packages/studio:src/player, 142 files, 1837 passed. This includesuseTimelinePlayer.seek.test.ts, which runs cleanly here.packages/coretypecheck (tscandtsconfig.runtime.json), plus player and studiotsc --noEmit: clean.oxlintandoxfmt --checkon the 15 changed files: clean.- Headless Chrome probe for finding 1, as described above.
Checks
All 65 checks pass, 7 are skipped, and all 10 required checks pass at this head.
Gate
The ruleset requires 1 approval, a code-owner review and approval of the last push. It does not dismiss stale reviews. CODEOWNERS only covers the PR-capture scripts, so no code owner is required for these files. There is one APPROVED review at cc0b4a64, and the PR shows as mergeable and clean. A change request would close that gate until it is resolved.
— Rames
|
Verified Rames's finding independently — it's real, and my earlier approval missed it.
I verified the forward-direction release logic itself is correct (mutation-tested it), but I didn't think to check the reverse-scrub case — that's the gap. Holding on Rames's CR; not attempting to clear it myself since the fix hasn't landed yet. |
jrusso1020
left a comment
There was a problem hiding this comment.
Re-review at 3a35ffdd30fd2505825dea9ad06fdf9083807614. This follows my REQUEST_CHANGES at cc0b4a64.
Verdict: APPROVE
The blocker is fixed, and I checked it in Chrome, not only in jsdom. A clip that ended within 2 s behind the playhead is now armed, and one that ended within 4 s is held. Stepping back across a cut no longer reloads the outgoing clip. A reverse shuttle finds the earlier clip loaded 2 s before it reaches it. Scrubbing back and forth across a cut no longer resets anything. The nested-clip fix is correct. The merge is clean. The notes below are non-blocking.
Prior findings
| # | Finding at cc0b4a64 |
Status at 3a35ffdd |
|---|---|---|
| 1 | Blocking. The window only looked forward, so a step back across a cut reloaded the clip, and a reverse shuttle entered each clip cold | Resolved. init.ts:2655 adds end <= t && end >= t - ahead. The same dueSoon drives the 2 s arm and the 4 s hold, so the window is now symmetric with the same hysteresis. Chrome numbers below |
| 2 | Non-blocking. Look-ahead is in composition seconds, not wall seconds | Open, deferred as a follow-up. That is fine. See note 4 for how it now applies to reverse too |
| 3 | Non-blocking. Each release and arm invalidates the timing caches | Unchanged, and now hit less often. Scrubbing a cut 20 times fired 20 emptied events on the outgoing clip at cc0b4a64 and 0 at this head |
Delta
55fa64871merges main at6d00d94b4.git show --remerge-diffis empty. The PR's diff against main is the same 15 files, with the same +530/-23, before and after the merge. Nothing was dropped.cf01282bemakesdueSoonread a media element's start throughresolveAbsoluteMediaStartSeconds, the same resolver visibility and the media cache use (init.ts:2649-2651).3a35ffdd3adds the check for a clip ending behind the playhead (init.ts:2652-2657), adds three tests, and moves the seek targets in four existing tests so they stay clear of the new 4 s hold behind.
I checked the claims for each commit:
- The window is symmetric, with exact 2 s and 4 s edges. Confirmed. Clip A is 0 to 2 s and B is 2 to 10 s. From cold, A is armed at t = 4.0 and not at 4.033. Once armed, it is held at 6.0 and freed at 6.033. A pass that lands exactly on the cut (t = 2.0) keeps A armed.
- The end check covers clips shorter than the window, and
visibleAt(node, t - ahead)would not. Confirmed. A 0.5 s clip at 0 to 0.5 s is armed on a shuttle back at t = 2.5, which is 2 s after its end. With thevisibleAt(t - ahead)variant (N5) it is not armed at 2.5, and a step back across its end reloads it. - The three new tests fail without the fix. Confirmed. Removing the new line (N1) fails exactly those three tests and nothing else.
- A nested clip timed in root seconds had its host offset added twice. Confirmed. Take a host at root 5 s with a clip marked
data-hf-media-start-basis="global"atdata-start="6".resolveStartForElementreturns 5 + 6 = 11. The media resolver returns 6, which is where visibility and the timeline place it. Reverting the fix (N2) fails the new test. - Scrubbing back and forth across a cut causes no release or re-arm. Confirmed in jsdom (0
load()calls, 0src/preloadwrites over 20 round trips) and in Chrome (0emptiedevents, down from 20 atcc0b4a64).
I also worked the nested start math through nested-in-nested hosts with offsets. The outer host is at 3 s, and an inner host at local 2 s sits at root 5 s. A local clip at data-start="1" and a global clip at data-start="6" both resolve to 6. Both stay unarmed at 3.9, are armed at 4.5, are held behind at 7.5 (end 6.4) and are freed at 12. The local-basis case was already correct before cf01282be. The fix only changes global-basis clips, which is what it says.
Findings
Blocking
None.
Non-blocking
1. Landing exactly on a clip's end is not pinned by a test. packages/core/src/runtime/init.ts:2655
end <= t is what keeps the outgoing clip armed when the playhead sits exactly on the cut. That is where frame stepping lands: at 30 fps a cut at 2.0 s is frame 60. With end < t (N4) the suite still passes, but that version releases A at t = 2.0 and reloads it on the step back to 59/30. The new step-back test uses 2.1 and 1.9, so it never lands on the cut. A test that seeks to exactly the clip's end and then back one frame would pin it.
2. The loaded set grows more than one clip per track when clips are short. init.ts:2655, :2725
My earlier review said a symmetric window keeps "at most one extra clip per track". That only holds for clips 4 s or longer. The hold behind is 4 s, so a track of short clips keeps up to 4 s of them loaded behind the playhead. With 40 clips of 0.5 s each on one track, in jsdom:
forward only (cc0b4a64 logic) |
this head | |
|---|---|---|
| paused at 10 s after scrubbing forward from 0 | 5 armed | 14 armed |
| paused at 10 s after a jump from 19 s | 5 armed | 10 armed |
The set is still bounded by 2 s to 4 s of clips on each side, and it is far below loading every clip, which is what main does. No change needed. It is worth knowing when the next report is about memory on a montage of short clips.
3. The image skip now also looks behind. init.ts:2774
dueSoon also sets data-hf-upcoming, which is what lifts the hidden-image skip for non-media clips. So images in a clip that ended within 2 s behind are no longer display: none. I checked this: a clip at 0 to 2 s with an image is not skipped at 2.1 and is skipped again at 7. This is the right behavior, since a step back onto an image clip no longer holds the seek while the image decodes. It changes the image path as well as the media path, though, and no test covers it for images. One assertion in the image-skip test would cover it.
4. The rate note now covers reverse too, and reverse has no next-clip arm. packages/studio/src/player/hooks/useTimelinePlayer.ts:227
Reverse shuttle runs as paused seeks at up to 4x. While paused, the arm that plays the next clip on a track does not fire, so reverse relies only on the 2 s window. At 4x that is 0.5 s of wall time before the earlier clip is on screen. My Chrome shuttle at 1x found the clip ready. Scaling the window by the transport rate, as the author plans, would cover fast reverse too. Fine as a follow-up.
5. Four existing tests had their seek targets moved. packages/core/src/runtime/entry.test.ts:323, :474, :486, :547
The moves (3.6 to 7.6, 7.5 and 8.2 to 10.5, 9.5 to 19.5 on a 20 s timeline) keep each test outside the new 4 s hold, so each still tests what it says. One assertion was dropped: the jump test no longer checks that the clip it lands on is armed. The far-jump test still covers that, and M13 and M15 below are still caught. No change needed.
Probes
Headless Chrome 153 ran the runtime bundle built from each head, served side by side in the same session. Each page had two 2 s H.264 clips on one track and a local server that supports range requests. "No mark" is the same page without the preview mark, which is main's media path.
Step back across a cut (A 0 to 2 s, B 2 to 4 s; pause at 1.9, step to 2.1, step back to 1.9):
| A at 2.1 s | A right after stepping back | A has a frame | |
|---|---|---|---|
cc0b4a64, preview |
preload none, readyState 0 | readyState 0 | 15 ms (28 to 40 ms in my first run) |
3a35ffdd, preview |
preload auto, readyState 4 | readyState 4 | 0 ms |
3a35ffdd, no mark |
preload auto, readyState 4 | readyState 4 | 0 ms |
Frame step onto the cut and back (seek to exactly 2.0, then back to 59/30): at this head A stays preload auto and readyState 4 on the cut. The step back shows a frame in 9 ms, against 11 ms with no mark. That is an ordinary seek within a loaded clip, not a reload. At cc0b4a64 A was released on the cut.
Reverse shuttle (A 0 to 2 s, B 2 to 10 s; jump to 9 so A is released, then step back 0.1 s at a time with 50 ms per step):
| A is armed at | A at 2.0 s, one step before it shows | |
|---|---|---|
cc0b4a64, preview |
1.9 s, the frame it is already on screen | preload none, readyState 0 |
3a35ffdd, preview |
4.0 s | preload auto, readyState 4 |
| no mark | always loaded | readyState 4 |
Scrub across a cut 20 times (1.9 and 2.1, 30 ms apart): emptied fired on A 20 times at cc0b4a64 and 0 times at this head. B fired 0 times on both.
jsdom probes (a temporary test file using the suite's own helpers, deleted afterwards): the edge values, the short-clip case, nested-in-nested hosts, scrub stability and loaded-set size quoted above. All pass at this head.
Mutation tests
Each mutation was applied alone. The touched suites were run, then the file was restored with git checkout. M1 to M19 are the same mutations as last time, re-anchored where the code moved.
| # | Mutation | Result | Caught by |
|---|---|---|---|
| N1 | revert 3a35ffdd (drop the check behind) |
killed | the three new tests: step back, reverse shuttle, still frees |
| N2 | revert cf01282be (start through resolveStartForElement) |
killed | "arms a short nested clip whose start is already root time…" |
| N3 | behind edge exclusive (end > t - ahead) |
killed | "arms a clip behind a playhead shuttling back…" |
| N4 | at-end exclusive (end < t) |
survived | none in the suite (note 1). My exact-cut probe catches it |
| N5 | check behind via visibleAt(node, t - ahead) |
killed | the reverse-shuttle test, through its exact-edge assertion only. No suite test uses a clip shorter than the window |
| N6 | window behind doubled | killed | 4 tests |
| N7 | hold behind not widened to 4 s | killed | "still frees a clip that ended well behind the playhead" |
| M1 to M19 | same as at cc0b4a64 |
19 of 19 killed | same tests as before |
Tests run
bun install, then a fullbun run build: clean.packages/core: full vitest suite, 160 files, 3674 passed, 1 skipped.packages/player: full suite, 15 files, 513 passed.packages/studio:src/player, 142 files, 1832 passed.packages/coretypecheck (tscandtsconfig.runtime.json), plus player and studiotsc --noEmit: clean.oxlintandoxfmt --checkon the 15 changed files: clean.- Headless Chrome and jsdom probes as above.
Checks
62 checks pass and 9 are skipped at this head. All 10 required checks pass. All four commits since cc0b4a64 are signed and verified.
Gate
The ruleset requires 1 approval, a code-owner review, approval of the last push and signed commits. It does not dismiss stale reviews. The only approval is at cc0b4a64, so the last-push rule needs a new one at this head. My change request at cc0b4a64 is still live and closes the gate until this review replaces it. CODEOWNERS does not cover these files.
— Rames
What
The Studio preview loads only the clips on screen or due in the next 2 s, instead of every video at once. On a 60-clip film, every clip's filmstrip shows within 60 to 90 s instead of 2 to 58 of them by 150 s, a far jump shows its first frame in 0.2 s instead of 16 s or never, and Studio's timeline appears as fast as on main.
Why
With 60 video clips the preview opened every video at once and held over 100 media requests to the one origin that also serves the timeline thumbnails. Thumbnails queued behind them, so most clips showed a blank tile or "no preview", and a jump far down the film waited behind every other clip's download.
How
All of it runs only in Studio's preview (behind the preview mark); render and export documents never carry it.
preload="none"and is armed as it comes due (packages/core/src/runtime/init.ts,preloadMedia.ts).parseAuthoredTrack, same composition host), so a track never waits for its next clip. Paused or scrubbing, nothing extra is armed.preload="none"plusload(), so a clip that leaves the window drops and restores itssrc; it loads again when it comes due.packages/core/src/compositionReadiness.ts).window.__hf.setLoopStart), and while looping the runtime keeps the clips due within 4 s of the loop start loaded, so a wrap back to the in point does not start cold.packages/player/src/parent-media.ts).Test plan
waitingevents, far jump 0.18 to 0.25 s. Main: 13 of 13 with 3waitingevents, 77 media requests open before play, and the far jump never became ready in 20 s.Known limits
<source>children instead ofsrcare not released, as on main.Before
Main, 60 s after opening the 60-clip film on a normal link: 2 filmstrips, 58 blank tiles.
Main at 150 s on a 3 MB/s link: 120 media requests open.
After
This branch, 60 s, normal link: all 60 filmstrips.
This branch at 150 s on 3 MB/s: no media requests left open.