Repository navigation
perf(studio): share video sources and keep frames for timeline thumbnails - #6629
Conversation
A video thumbnail frame was revoked the moment no mounted strip held it, so scrolling back past the scheduler's cache decoded every clip again: 80 decode passes for 40 clips on a 44-minute source, the whole first pass repeated. Released frames now stay shared in a least-recently-released list capped by the new sharedFrameCacheBytes budget (32 MiB of JPEG, about 11 frames for each of a 473-clip episode's clips). Object URLs are revoked only on eviction, and a frame any strip still shows is never evicted. Which frame a strip shows is unchanged: the acceptance window is the same predicate. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Every clip's thumbnail job opened its own mediabunny Input on the source URL, so 40 clips cut from one 44-minute source opened it 80 times, re-reading and re-parsing its 1.44 MB index each time (behind a 304, at 13-30 ms an open). Jobs now share one pooled reader per source: the Input, its primary video track and the track's size and duration, reference-counted and disposed thumbnailSourceIdleMs (10 s) after the last job ends, or at once when the source's contentVersion changes. Each job keeps its own CanvasSink: the sink reuses one pooled canvas and a job blanks the canvases it saw on release, so sharing one across jobs would corrupt a neighbour's frames. A failed open leaves the pool at once, and a job that fails for any reason but cancellation retires its reader, so the next clip opens afresh. Retries are still counted per reader, as mediaUrlSourceOptions has done since #6477: two jobs reading through one reader cannot tell whose read failed, and retiring a reader whose retries are spent keeps that budget per reader rather than letting a spent count fail later clips at once. The pool also owns the one import("mediabunny"), which the decoder now takes from the opened source. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A playing preview shares the machine's video decoder with timeline thumbnails, and a long many-clip timeline queues hundreds of thumbnail decodes. While the player store reports isPlaying, the scheduler now starts no new visible or overscan video decode; a scrub's interaction request still starts, decodes already running finish, and pausing drains the queue. Image, composition and waveform work is unaffected. The new thumbnailPlaybackHold module subscribes to the store's state rather than wrapping setIsPlaying, so a reset or a project switch, which write isPlaying: false directly, also release the held work. VideoThumbnail binds it to the app's scheduler where video thumbnails load, which is also the path HyperFrames Desktop runs (it embeds StudioApp, not Studio's main.tsx). Co-Authored-By: Claude Opus 5.5 <[email protected]>
mediabunny reads a URL with open-ended `Range: bytes=N-` requests and cancels each once it has the bytes it wanted, by when a local server has streamed megabytes past them: about 4 MB sent for each thumbnail frame decoded. The thumbnail reader's fetch now asks for at most 512 KiB from N; a shorter answer than mediabunny wanted makes it ask again from where it stopped (its worker resumes a capped response when the file size is known, which the first 206's Content-Range supplies). Closed ranges and the probe reader are untouched, and so is Studio's playback, which does not read through mediaUrlSourceOptions. Measured on two 44-minute 1080p sources, 40 clips each (a poster and a 4-frame strip per clip), against the same branch unbounded: bytes sent fall 13-22% one decode at a time and 33% two at a time; first-pass wall time falls 4-20%. The frames decoded are byte-identical. Most of what remains is real: a strip slot whose keyframe is outside its window decodes from that keyframe forward through its GOP. A pooled reader also now caches at most 16 MiB of what it read, not mediabunny's default 64 MiB, since it outlives its clip by up to thumbnailSourceIdleMs. 8 MiB cost requests when a clip's poster and strip decode together; 16 MiB measured the same as 64. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A thumbnail reader bounded even its first range request to 512 KiB. When the
answer's Content-Range cannot be read, mediabunny sizes the file as the request
start plus the answer's Content-Length, assuming the answer runs to the end of
the file, so a bounded first answer made a 10 MB file end at 512 KiB. Browsers
hide Content-Range cross-origin unless the server lists it in
Access-Control-Expose-Headers, and Studio reads remote https sources such as
HeyGen's gen-os-static bucket, which does not. With the header hidden, every
format failed: faststart and fragmented MP4 decoded 1 of 12 thumbnails, and
moov-at-end MP4, MOV and WebM none ("no decodable video track").
A reader now bounds its ranges only after an answer of its own was a 206 whose
Content-Range it could read and that starts where the request asked; its
first request stays open-ended, as before the bound. Any answer without such
a Content-Range, a whole-file 200 included, keeps that reader unbounded for
good. Same-origin readers, where Content-Range is readable, still bound.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A reader's retry budget (two retries, from #6477) counted every failure over the reader's life. Pooled, a reader serves every clip of its source, so the third transient failure anywhere on the source failed a clip with no retry. The count now resets whenever a read delivers bytes, so a long-lived reader keeps the per-clip tolerance a reader had when each clip opened its own, and three failures in a row still give up. Bytes, not an ok status: a body that breaks on its first read must still use up the budget. A reader that gives up is retired, as before, so the next clip opens a fresh one. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…urce The thumbnail job's signal reached only the source's open: keyframe lookups and the decode loop ignore it, so a job whose response stalled mid-body never ended and never released its pooled reader. The reader was never idle, so never closed, and every later clip that needed the same bytes waited on the same stalled read for the rest of the session. Before pooling, each clip had its own reader, so only the stalled clip hung. The scheduler's timeout now aborts with a TimeoutError reason. A job that times out drops its reader at once; a job cancelled any other way that has still not ended a load timeout later drops it then; a cancel the job answers, a scroll away, leaves the reader pooled. Dropping disposes the reader whoever still uses it, which fails their pending reads too. Disposing a mediabunny Input does not end a read already waiting on a stalled body (its orchestrator only flags the worker, which never wakes), so mediaUrlSourceOptions gains cancelReads(), which aborts every fetch the reader has in flight; the worker's read then fails and mediabunny rejects the reads waiting on it. Measured in Chromium with a server that stalls one response: the stalled clip settles at its timeout, and the same clip asked again, now and after 11 s idle, decodes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A thumbnail job that failed for any reason but a cancel retired its pooled reader, so one clip whose frames all failed to decode, or whose canvas would not encode, made the next clip re-read and re-parse the source's index. Those two failures are now a ClipResultError, which leaves the reader pooled; fetch, demux and decoder failures still retire it. Also pins four pool behaviours mutation testing found unguarded: a reader closed after sitting idle has left the pool, so the next clip opens a live one; a version change closes the old reader only once the job still decoding on it ends; a job cancelled while the track is read stops the open there; and a scroll-away cancel keeps the reader (the last landed with the stall fix). Co-Authored-By: Claude Opus 5.5 <[email protected]>
ff16aed98 reset a pooled reader's retry count whenever any read delivered bytes. That broke the #6477 give-up in two ways, both measured with real mediabunny: - A server that ignores Range answers 200 from byte 0, so mediabunny resumes at the bytes it got and is sent byte 0 again. With a body that breaks after 64 KiB, those bytes reset the count every time: 61 fetches in 30 s and the read still pending. - mediabunny reads with two workers. A healthy stream in one region kept forgiving a region that always answered 503: 41 fetches in 20 s, pending. Failures are now counted per start byte, with no reset. A resume after progress asks for a new start and gets its own two retries; asking for the same start again uses them up whatever else the reader reads meanwhile. The start is the byte the answer began at: the asked start for a 206 or a failed request, 0 for a 200. Traced for the 200 case: requests at 0, 65536 and 65536 are all keyed 0, so the third failure gives up. Both repros now give up after 3 fetches ("network TypeError after 2 retries", "HTTP 503 after 2 retries"), and failures at different starts, one per clip, never add up. The fetch and the answer's typing move into typedFetch and typedResponse, so fetchFn stays under the complexity gate. Co-Authored-By: Claude Opus 5.5 <[email protected]>
dbd820706 dropped a reader when the job using it timed out, but only once the reader had opened: a timeout during the shared open released the job and left the stalled open pooled, so every clip of that source joined it and timed out in turn while clips kept arriving. Dropping on a timeout was also wrong when bytes were still coming (a slow link's 25 s open threw its parsed index away), and dropping a reader other clips were using failed them with "Media reads cancelled", leaving them on "no preview" until they remounted. A thumbnail read now fails only on silence. A request waiting 8 s for its answer, or for its next bytes, is aborted and fails as a MediaFetchError "stalled", which mediabunny retries like any read failure: a resume after progress asks for a new start and continues, and three silent waits at one start (8 + 0.5 + 8 + 1 + 8 = 25.5 s, inside the 30 s load timeout) give up, which retires the reader. This covers the open and the decode alike, keeps a reader whose bytes are still arriving, and never disposes a reader another clip is using, so the job-level abandon and the scheduler's TimeoutError reason are removed. Measured in Chromium against a stalling server: - one-shot stall in the open: the clip decodes at 8.7 s (was: it and the next clip both timed out at 30 s); - one-shot stall mid-decode: decodes at 8.7 s (main: hangs); - a source that never sends a byte: each clip fails typed at 25.5 s and the next opens a fresh reader. Each request gets its own AbortController, which follows mediabunny's signal and the reader's cancel without AbortSignal.any; every way a request ends unlinks it from the reader's long-lived cancel signal. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…arded - A bounded answer's Content-Range keeps the reader bounded: a closed-range pattern that matched only open ranges flipped it unbounded after the first bounded request, undoing cf8f4554e. - A frame that will not encode, through toBlob or OffscreenCanvas convertToBlob, is the clip's failure and keeps its reader. The convertToBlob rejection is now a ClipResultError too; it escaped as the browser's own error and retired the reader. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Four guarantees of the thumbnail reader's per-request watchdog had no test; each mutant that broke one passed the suite: - a silent body fails as "stalled" and aborts the fetch under it, so the stalled connection is not left open; - every way a request ends unlinks it from mediabunny's signal: an answered body, a rejected fetch, a 503, no body, a broken body, a cancel; - a probe reader's body is never timed, however slow; - a request made after the reader's reads were cancelled starts aborted. Co-Authored-By: Claude Opus 5.5 <[email protected]>
… started
The 8 s stall rule also timed the wait for a response's headers, and that
wait includes Chrome's queue for one of six HTTP/1.1 connections per host.
Desktop serves all media from one loopback host, where Studio's event stream
and streaming previews hold connections. Measured in Chromium against a
healthy server: with five of six connections held and 20 cancel/re-ask bursts
over the 40-clip pass, no false stall; with all six held for 40 s, two clips
failed as "stalled". The watchdog now times only a started body.
A server that never answers is now caught at the job's own timeout. The
scheduler's timeout again aborts with a TimeoutError reason. A job that timed
out without progress in its whole run (its source never finished opening, or
it decoded no frame) failed because of its source: a source that never
answers, or one that trickles bytes and never finishes. It logs one typed
MediaFetchError line ("network timeout") and retires its reader. A job that
waited for an open that finished, or decoded a frame, timed out on a slow link
and keeps the reader and its parsed index. Bytes alone are not progress, or a
trickling source would keep its reader for good.
An aborted job also ends its claim on the reader at the abort, rather than
when it settles: a job stuck in a read that ignores its signal no longer keeps
the reader open. A retired reader still waits for the clips decoding on it.
Measured in Chromium: a source that never answers fails each clip with one
typed line at 30 s and the next opens a fresh reader; with all six
connections held for 40 s, the two starved clips fail typed, none as a stall.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The comment ratchet (scripts/comment-ratchet.mjs) fails a source file whose share of comment lines rises over its merge-base, and a new file above its package's share. Five files of this branch did. The reasoning they carried is in the commit messages and the PR description; what stays in code is named: SILENT_BODY_STALL_MS, POOLED_READER_CACHE_BYTES, failuresByStart, inFlightReads, decodedByJob, timeoutWithoutProgress and madeProgress. No behaviour changes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…e watermarks Commit axis to bf7ebe9 (merge 2fa8452), PR axis to heygen-com#6629 and issue axis to heygen-com#6610, each item with its verdict, head SHA and trigger in docs/DECISIONS.md 2026-10-28. Upstream PR heygen-com#6512 is adopted (554dbb7). Co-Authored-By: Claude Opus 5.5 <[email protected]>
Edit accuracy did not run: 0 of 5 shards reported results; see the shard jobs.Nothing was compared with the base branch. |
There was a problem hiding this comment.
Verdict: APPROVE · 0 blocker · 0 important · 3 nits · 4cd77d31
Pool ref-counting, frame-cache accounting, and the abort/timer cleanup in mediaFetch all held up when I traced them. The bounded ranges look safe against mediabunny 1.45.3's UrlSource: they turn on only after a Content-Range with a total, so the file size is already known and an early end resumes without spending retry budget. Nits are inline. One more: the body says the probe reader is untouched, but it shares getRetryDelay, so #6477's "2 retries per reader" budget is now per start byte for the probe too. That's harmless in practice, but worth fixing in the description.
Scope
Read end to end: every runtime file in the diff. Also read: mediabunny 1.45.3 UrlSource._runWorker and retriedFetch. Ran a separate adversarial pass over pool lifecycle, the stall watchdog, the per-start retry budget, and the playback hold. CI and PR captures are green at this head. There were no prior reviews.
— Genesis reviewer
| throw error; | ||
| } | ||
| const releaseOnAbort = () => { | ||
| const madeProgress = source.waitedForOpen || resources.decodedByJob > 0; |
There was a problem hiding this comment.
nit: "progress" here only counts this job's own progress. Say a poster job gets a reader that is already open, maybe while a sibling job is decoding on it. On a slow link, or while Chrome's 6 connections are held, it takes more than 30 s to reach its one frame. It then logs network timeout after 0 retries and retires a healthy reader, so the next job has to re-read the moov (at the 3.4 GB offset for camera A). Recording progress on the pooled entry instead, as any completed read or decoded frame, would separate "source dead" from "this job was slow". That matches the slow-link case the body says keeps the reader.
| export function mediaUrlSourceOptions(reader: MediaReader) { | ||
| let failures = 0; | ||
| // Per start byte: a resume asks for a new start, while asking for one again uses its budget up. | ||
| const failuresByStart = new Map<number | null, number>(); |
There was a problem hiding this comment.
nit: failuresByStart only grows, and a pooled reader can now live for many minutes, because each new job resets its 10 s idle timer. Two transient failures at start S early in a session (S comes back once the 16 MiB cache evicts it) make a third failure at S, 20 minutes later, give up immediately. That retires the reader and shows the clip as an error. Deleting the entry once a read from that start delivers bytes would keep "no progress at this start" accurate.
What
Timeline video thumbnails on long, many-clip projects:
Why
A HyperFrames Desktop podcast episode has ~473 video clips cut from three 44-minute 1080p sources (2.7–3.4 GB each; one camera has its 1.44 MB
moovafter 3.4 GB ofmdat). Its filmstrips fill slowly and refill on every scroll back:Input, so 40 clips of one source opened it 80 times (a poster and a strip each);bytes=N-read was cancelled at its target after a loopback server had streamed megabytes past it: ~4 MB sent per decoded frame.Related work
Refs #6477, whose retry guarantees (two retries per reader, then one typed line) this keeps.
How
Released-frame cache. A frame no strip shows stays shared in a least-recently-released list capped by the new
sharedFrameCacheBytesbudget: 32 MiB of JPEG, which at ~6 KB a frame holds ~11 frames for each clip of a 473-clip episode. Object URLs are revoked only on eviction, and a frame any strip still shows is never evicted. Which frame a strip shows is unchanged (same acceptance window).One reader per source (
thumbnailSourcePool.ts). TheInput, primary track, size and duration are shared by every clip of a(source, contentVersion)and reference-counted.thumbnailSourceIdleMs(10 s, so a pause between scrolls reuses the index) after its last job, and is retired when the source'scontentVersionchanges, once the jobs still decoding on it end.CanvasSink: a sink reuses one pooled canvas and a job blanks the canvases it saw on release.Playback hold. While the player store reports
isPlaying, the scheduler starts no newvisibleoroverscanvideo decode. Aninteractionrequest (a scrub) still starts, decodes already running finish, and pausing drains the queue.thumbnailPlaybackHoldsubscribes to the store's state rather than wrappingsetIsPlaying, soreset()and a project switch, which writeisPlaying: falsedirectly, also release it.VideoThumbnailbinds it, which is also the path an app embeddingStudioAppruns.Thumbnail reads (
mediaUrlSourceOptions("thumbnail"); playback is untouched, and the probe reader shares only the per-start retry budget below):Content-Rangeis readable. A reader boundsbytes=N-to 512 KiB only after one of its own answers was a 206 whoseContent-Rangeit could read and that starts where it asked. Without a readableContent-Range(hidden cross-origin unless the server lists it inAccess-Control-Expose-Headers, as on an S3 bucket) mediabunny sizes the file from the answer'sContent-Length, so a bounded answer would end the file. Any answer without one keeps that reader unbounded for good.getRetryDelay: fix(studio): a clip that cannot be read gives up after two retries with one typed line #6477's two retries per reader are now two per start byte). A resume after progress asks for a new start and gets its own two retries; asking for the same start again uses them up, whatever else the reader reads meanwhile. A 200 is keyed to byte 0, so a server that ignores Range and breaks at the same byte gives up after three fetches.MediaFetchError"stalled", which mediabunny retries like any read failure; three silent waits at one start (8 + 0.5 + 8 + 1 + 8 = 25.5 s) give up inside the 30 s load timeout and retire the reader. The wait for the answer itself is not timed: it includes Chrome's queue for one of six HTTP/1.1 connections per host, which streaming previews and Studio's event stream hold.cancelReads()aborts a reader's in-flight fetches when it is disposed. mediabunny'sInput.dispose()alone leaves a read waiting on a stalled body.A timeout without progress is the source's failure. The scheduler's timeout aborts with a
TimeoutErrorreason. A job that timed out with no progress in its whole run (its source never finished opening, or it decoded no frame) logs one typed line (thumbnail range fetch failed: network timeout after 0 retries) and retires the reader: this catches a server that never answers and one that trickles bytes but never finishes. A job that waited for an open that finished, or decoded a frame, timed out on a slow link and keeps the reader and its parsed index.Test plan
docs/describes how timeline thumbnails load; this lands in the release changelog.check-comment-citations.mjsandcomment-ratchet.mjspassMeasured in Chromium 152 (headless Chrome for Testing) with mediabunny 1.45.3 on the episode's real 1080p files. They were served by a Range server that mirrors studio-server's media route (open-ended ranges stream to the end,
ETagplusCache-Control: private, no-cache, 304 onIf-None-Match). The workload is 40 clips of 5.6 s spread over one source, each a poster and a 4-frame strip (80 jobs), then the same 80 jobs again after every result was disposed (a scroll-back past the scheduler's cache).origin/mainand the branch were interleaved in one sitting, two repetitions each, at load average 7–10. Numbers are relative to each other, not absolute. They were measured at85e1df2f8, whose runtime code is this head's: the rebase brought no studio changes and the last commit only renames.First pass,
main→ branch (both repetitions):moovat end), one decode at a time: 6.5 / 6.7 s → 6.8 / 5.4 s, ~1,020 → ~848 requests, 422 / 456 MB → 362 / 363 MB sent.Inputs opened 80 → 1.main's (SHA-1 over every tile's JPEG).Cross-origin, with
Content-Rangehidden by a server that sends noAccess-Control-Expose-Headers: faststart MP4, moov-at-end MP4, MOV, fragmented MP4 and WebM each decode 12/12 thumbnails withmain's frames, cross-origin and same-origin. Cross-origin readers stay unbounded; same-origin readers bound.Stalls, against a server that stalls on purpose (30 s job timeouts):
mainthe mid-decode stall left the clip still pending when the harness gave up at 35 s.mainthe same timeout is untyped).Suites, by exit code: studio
test(716 files, 8,174 tests), studiotypecheck,fallow audit --base origin/main, rootlintandformat:check, studiobuild,check-comment-citations.mjsandcomment-ratchet.mjsall pass. Each new test was mutation-checked against the code it guards: 86 mutants, all killed. Where one survived at first, the test was strengthened until it failed.Follow-ups, not changed here:
ThumbnailScheduler.setScrollingis never called outside tests, so "pause rich work while scrolling" never fires.Before
studio-thumbnails-before.mp4
origin/main, an 80-clip timeline cut from three generated 5-minute 1080p sources, encoded like the episode's cameras (H.264 Main, 23.976 fps, ~9.4 Mb/s, a keyframe every ~2 s). (1) The strips fill: 42 of 44 tiles at 1.5 s. (2) Jump ahead and press Play: the new clips' strips load while the preview plays. (3) Pause. (4) Scroll to the end, then back to clips already seen: they come back blank and decode again, 6 of 44 tiles 0.3 s after returning and all 44 by 3.5 s. Mid-scroll, clips not seen yet are blank in both recordings while they load.After
studio-thumbnails-after.mp4
This branch, same project and steps. (1) The strips fill: 44 of 44 tiles at 1.5 s. (2) While the preview plays, the new clips' strips start no loads. (3) On pause they fill. (4) Scrolling back to clips already seen, every strip is there at once: 44 of 44 tiles at 0.3 s, with no fetch.
🤖 Generated with Claude Code
Follow-ups from review: #6642 (a start's failures expire after a minute) and #6643 (a timed-out job counts sibling clips' frames as its reader's progress).