Skip to content

perf(core): the preview loads only the media near the playhead - #4682

Merged
miguel-heygen merged 21 commits into
mainfrom
perf/runtime-preload-media-near-playhead
Sep 29, 2026
Merged

miguel-heygen merged 21 commits into
mainfrom
perf/runtime-preload-media-near-playhead

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

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.

  • Near-playhead window. Media farther than 2 s from the playhead stays at preload="none" and is armed as it comes due (packages/core/src/runtime/init.ts, preloadMedia.ts).
  • The next clip on a track. While playing, a clip that starts arms the next clip on its track (read by the timeline's own parseAuthoredTrack, same composition host), so a track never waits for its next clip. Paused or scrubbing, nothing extra is armed.
  • Leaving the window closes the fetch. Chromium keeps downloading through preload="none" plus load(), so a clip that leaves the window drops and restores its src; it loads again when it comes due.
  • Timeline readiness. Studio's readiness scan waited for every clip to become playable, so with clips left unloaded it hit its 8 s timeout. It now checks a clip the preview left unloaded only for its first frame, as it already does for lazy images (packages/core/src/compositionReadiness.ts).
  • Loop playback. Studio's loop tick tells the runtime where the loop starts (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.
  • No flicker at the edge. A clip is armed 2 s ahead and released only beyond 4 s, so scrubbing across the edge does not drop and refetch it. An arm made for the next clip on a track lasts only while the clip that made it is playing, so a jump while playing drops it.
  • Clips without an authored length stay out of the window and load as in a render, since reloading one would lose its duration.
  • Player. When a clip stops preloading, the player's parent-frame copy of it (which fetches the whole file) is dropped, and it is adopted again when the clip comes due (packages/player/src/parent-media.ts).

Test plan

  • Unit tests, each failing without its change: the next clip is armed when the current one starts playing and nothing else is; nothing is armed while paused; a far clip on another track is not armed; an armed next clip survives a window pass; readiness waits on a later clip only once the runtime set it to buffer; the player drops a clip's copy and adopts it again when due; loop-start clips stay loaded while the loop plays; a jump while playing drops the next-clip arm; scrubbing across the edge does not refetch; back-to-back clips; same host and same authored track; a render document keeps loading every clip.
  • Render unchanged: a 3-video repo fixture rendered to 60 frames twice per side gives the same frame-set md5 on main and this branch.
  • 60-clip fixture built from repo media (6 tracks), headless Chrome, filmstrip / "no preview" / blank tiles and open media requests:
30 s 60 s 90 s 150 s
main, run 1 1/0/59, 116 open 2/0/58 2/1/57 2/3/55
main, run 2 1/0/59, 95 open 2/0/58 7/1/52 58/1/1
branch, run 1 15/0/45, 0 open 40/0/20 60/0/0 60/0/0
branch, run 2 24/0/36, 0 open 60/0/0 60/0/0 60/0/0
  • One-track playback, 13 clip boundaries: 13 of 13 clips start within 1 s of coming due, median 25 to 28 ms, worst 44 ms; a far jump from 0 to 150 s shows its first frame in about 0.2 s. On main the same jump took 16.5 s or never showed a frame (main's runs were on a busier machine).
  • Throttled to 3 MB/s, one track, rate-capped 6 Mb/s clips: 13 of 13 on time, median first frame about 30 ms, no waiting events, far jump 0.18 to 0.25 s. Main: 13 of 13 with 3 waiting events, 77 media requests open before play, and the far jump never became ready in 20 s.
  • Loop playback (in point 9.5 s, 6 s span, looped by Studio, 3 wraps): wrap to first moving frame 94 to 114 ms on a normal link (main 64 to 133 ms) and 62 to 142 ms at 3 MB/s over 3 runs (main 63 to 88 ms). Before the loop change the branch took up to 1.7 s at 3 MB/s.
  • Timeline: on the benchmark film, Studio's timeline fills 1.50 s after a cold open (median of 5; main 1.62 s). Before the readiness change the branch took 9.1 to 10.1 s.

Known limits

  • A clip with a very heavy first second on a slow link. The branch arms each clip about 2 to 3 s before it is due, and the playing clip and the next one share the link. A file that puts most of its bytes at the start (the repo's looped test clips carry 9.5 MB in their first second, then about 0.3 MB a second) cannot arrive in that time at 3 MB/s: 0 of 13 clips on time, where main managed 12 of 13 only because it had spent a minute buffering the start of every clip with 120 requests open. A rate-capped encode of the same frames at the same bitrate plays 13 of 13 (the row above).
  • On a 3 MB/s link filmstrips still fill slowly (8 of 60 by 150 s, 2 on main), because thumbnail downloads are limited by bandwidth, not by connections.
  • A clip more than 4 s ahead is released when playback pauses, so resuming far from it downloads it again.
  • Clips with no authored length load in full, as on main (Studio writes a length on drop and on trim, so this is uncommon).
  • Clips that use <source> children instead of src are not released, as on main.
  • The preview's disk fallback loads the runtime at the end of the page, and nested clips at time 0 inside a later composition are not deferred; both load as on main.
  • Idle memory and CPU are level with main; this change does not lower them.

Before

Main, 60 s after opening the 60-clip film on a normal link: 2 filmstrips, 58 blank tiles.

Before: main, 60 s, normal link

Main at 150 s on a 3 MB/s link: 120 media requests open.

Before: main, 150 s, 3 MB/s

After

This branch, 60 s, normal link: all 60 filmstrips.

After: branch, 60 s, normal link

This branch at 150 s on 3 MB/s: no media requests left open.

After: branch, 150 s, 3 MB/s

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.
…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.
@miguel-heygen
miguel-heygen marked this pull request as ready for review September 29, 2026 15:19

@somanshreddy somanshreddy 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.

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 build clean.
  • 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's useTimelinePlayer.seek.test.ts fails with TypeError: act is not a function — checked this against origin/main with 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 the if (!near) { el.preload = "none"; ... releaseMedia(el) } block to a no-op): 11 of the touched entry.test.ts tests 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 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.

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 in studio-server/src/routes/preview.ts:283, and not when hf-capture is 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.ts early return, M11). They are still parsed at preload="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 releaseMedia cannot re-adopt the copy, because _adoptIframeMedia returns early while preload is none.
  • 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-diff is 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 children … untouched"
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 includes useTimelinePlayer.seek.test.ts, which runs cleanly here.
  • packages/core typecheck (tsc and tsconfig.runtime.json), plus player and studio tsc --noEmit: clean.
  • oxlint and oxfmt --check on 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

@somanshreddy

Copy link
Copy Markdown
Contributor

Verified Rames's finding independently — it's real, and my earlier approval missed it.

dueSoon (init.ts:2649) only ever evaluates forward: (start > t && start <= t + ahead) || visibleAt(node, t + ahead). There's no symmetric t - ahead check anywhere in mediaStaysDue/preloadNearPlayhead, so once the playhead clears a clip's end by more than RELEASE_LOOKAHEAD_SECONDS (4s), that clip gets preload="none" + releaseMedia()'d with nothing to re-arm it on a backward step. Confirmed the existing "holds a clip past the look-ahead edge a scrub crosses" test (entry.test.ts:411) only exercises a clip approached from the future (scrubbing near its start) — there's no test for stepping back across an already-ended clip, which matches Rames's "add a test that crosses a clip's end" ask.

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 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.

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

  • 55fa64871 merges main at 6d00d94b4. git show --remerge-diff is 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.
  • cf01282be makes dueSoon read a media element's start through resolveAbsoluteMediaStartSeconds, the same resolver visibility and the media cache use (init.ts:2649-2651).
  • 3a35ffdd3 adds 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 the visibleAt(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" at data-start="6". resolveStartForElement returns 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, 0 src/preload writes over 20 round trips) and in Chrome (0 emptied events, down from 20 at cc0b4a64).

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 full bun 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/core typecheck (tsc and tsconfig.runtime.json), plus player and studio tsc --noEmit: clean.
  • oxlint and oxfmt --check on 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

@miguel-heygen
miguel-heygen added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 5a77dbc Sep 29, 2026
71 checks passed
@miguel-heygen
miguel-heygen deleted the perf/runtime-preload-media-near-playhead branch September 29, 2026 18:56
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.

3 participants