Skip to content

fix(studio): a clip that cannot be read gives up after two retries with one typed line - #6477

Merged
miguel-heygen merged 2 commits into
mainfrom
fix/media-fetch-typed-errors
Oct 26, 2026
Merged

miguel-heygen merged 2 commits into
mainfrom
fix/media-fetch-typed-errors

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

What

When Studio's media probe or thumbnail decoder cannot read a clip, it now retries twice and then gives up with one typed line: which reader, the HTTP status or the network error, and how many retries, never the URL. Example: [Studio] probe range fetch failed: network TypeError after 2 retries.

Why

Both readers build a mediabunny UrlSource with its defaults. That default retries a same-origin network failure forever, backing off up to 16 s, and logs each attempt as Retrying failed fetch. Error: TypeError: Failed to fetch. When a range request fails while no read is waiting on it, mediabunny also rethrows it so it isn't swallowed, which reaches the page's unhandledrejection as a bare TypeError. A media file that cannot be read (locked by another program, for example) therefore produced an endless stream of bare TypeErrors with nothing saying which reader failed or why. An HTTP error status, on the other hand, came back as Error fetching <full URL>: <status>, putting the local media URL in the log.

How

  • player/lib/mediaFetch.ts owns the UrlSource options for both readers:
    • fetchFn turns a rejected fetch into MediaFetchError with the network code (cause.code when present, else the error's name) and an error status into MediaFetchError with that status. It also wraps the response body, so a stream that breaks mid-read errors with MediaFetchError too (mediabunny rethrows a body error as-is). Cancellations pass through untouched. It calls fetch directly, with a scoped lint disable: this is a media read, on the media's own sockets, the same request mediabunny's default makes; studioApiFetch is for Studio's API calls.
    • getRetryDelay retries a network failure or a 5xx twice (0.5 s, then 1 s), never retries a 4xx, and writes the retry count into the error's message. It counts per source, because mediabunny passes attempt 1 for every failed mid-stream read.
    • reportMediaFetchFailure writes the one [Studio] line for a MediaFetchError and ignores other errors.
  • mediaProbe.ts and thumbnailVideoDecoder.ts pass those options and report at their existing catch. The probe still records the URL as failed and returns null; the decoder still rethrows.
  • The rethrown copy mediabunny leaves unhandled is now the same MediaFetchError, so the page's unhandled-rejection report names the reader and cause too.

Before

Headless Chrome 152, a page reading a clip from a server that drops every connection, with the default UrlSource, 20 s: a bare TypeError per attempt, backing off, and the read never settles.

Before: default UrlSource, bare TypeError forever

After

The same page with the options from this PR: two retries, one typed line, and the read settles at 2.3 s.

After: two retries, one typed line

Test plan

  • Unit tests added/updated

  • Manual testing performed

  • Documentation updated (if applicable)

  • Comments follow CONTRIBUTING.md "Comments": they say why, not what, and a bug fix says what the code must do and how to reproduce the bug

  • mediaFetch.test.ts: a network failure and an error status become MediaFetchError without the URL; a cause.code is reported; a cancellation passes through; a network failure is retried twice then given up with the count in the message; mid-stream read failures (always attempt 1) are counted too; a 4xx is not retried; a real mediabunny Input fails with the typed error, and so does its unawaited read-ahead; a 206 whose body breaks on the first read gives up through a real Input with network TypeError after 2 retries; the report writes one line for a typed failure and nothing for others.

  • mediaProbe.test.ts and thumbnailVideoDecoder.test.ts: each reader builds its source with the retry rule and writes the one line on a failed fetch. Both fail with the readers put back on mediabunny's default UrlSource.

  • Without the body wrapper, the broken-body test fails (the read gives up with a bare TypeError).

  • 38 tests pass 3 runs in a row; studio tsc --noEmit, oxlint . over the repo, oxfmt, the comment ratchet and the fallow audit are clean.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: REQUEST CHANGES · 2 blockers · 0 important · 0 nits · head 21e313f3

  • blocker: the required CI › Lint check fails, and so does preview-regression's preflight. no-restricted-globals rejects the raw fetch at mediaFetch.ts:47 and :50 ("Use studioApiFetch…"). The body says oxlint is clean, but it is not. I recommend keeping the plain fetch, because that is what mediabunny's default already used, and adding a scoped disable with the reason (a media read, not an API call). studioApiFetch sets credentials: "omit" on loopback, which moves these reads to a different socket pool.
  • blocker: a stream that breaks mid-read still gives up with a bare TypeError. ↓ inline
Scope

I read mediaFetch.ts, both readers and mediabunny 1.45.3 retriedFetch and UrlSource._runWorker. These are the only two UrlSource sites in the repo. The 37 tests pass locally.

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 149 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verdict: APPROVE · 2 resolved, 0 open, 0 new · 21e313f3 → 55fb3f7e

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 26, 2026
Merged via the queue into main with commit dfd2028 Oct 26, 2026
73 of 74 checks passed
@miguel-heygen
miguel-heygen deleted the fix/media-fetch-typed-errors branch October 26, 2026 21:27
meefs pushed a commit to meefs/hyperframes that referenced this pull request Oct 28, 2026
…ails (heygen-com#6629)

* perf(studio): keep released timeline video frames for scroll-back

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

* perf(studio): open each video source once for all its timeline clips

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 heygen-com#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]>

* perf(studio): start no timeline video decodes while the preview plays

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

* perf(studio): bound thumbnail range reads and their read cache

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

* fix(studio): bound thumbnail ranges only once Content-Range is readable

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

* fix(studio): count a thumbnail reader's read failures consecutively

A reader's retry budget (two retries, from heygen-com#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]>

* fix(studio): a read stalled mid-response no longer hangs its video source

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

* fix(studio): a clip's own failed result keeps its source's reader

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

* fix(studio): keep a thumbnail reader's retry budget per start byte

ff16aed98 reset a pooled reader's retry count whenever any read delivered
bytes. That broke the heygen-com#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]>

* fix(studio): fail a stalled thumbnail read on silence, not on a timeout

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

* test(studio): pin the thumbnail behaviours mutation testing left unguarded

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

* test(studio): pin the stall watchdog's guarantees about its own requests

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

* fix(studio): time a thumbnail read's silence only once its answer has 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]>

* refactor(studio): move the thumbnail reader's explanations into names

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

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
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