Skip to content

feat(js/publish): publish files by demuxing them, not by capturing a MediaStreamTrack - #2541

Open
kixelated wants to merge 3 commits into
mainfrom
claude/github-issues-2532-2374-aa49dd
Open

feat(js/publish): publish files by demuxing them, not by capturing a MediaStreamTrack#2541
kixelated wants to merge 3 commits into
mainfrom
claude/github-issues-2532-2374-aa49dd

Conversation

@kixelated

@kixelated kixelated commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Closes #2532. Closes #2374 (same work, older duplicate; #2374 proposed MediaBunny, #2532 restated the problem in more detail).

Summary

js/publish/src/source/file.ts published a local file by playing it in a <video> element and turning that back into a live capture track. Publishing a file is a transcode, not a capture, and forcing it through a capture pipe is what caused the problems. It now demuxes and decodes the file directly with mediabunny, driving VideoDecoder/AudioDecoder and pacing the output against a wall clock.

Picked mediabunny over mp4box.js/libav.js (the alternatives floated in #2532): zero dependencies, tree-shakable, reads MP4/MOV/WebM/MKV/MP3/WAV/Ogg/ADTS/FLAC/MPEG-TS, and it already wraps WebCodecs decoding with a seek/iterate API, which is exactly the demux + decode + loop shape needed here.

What this fixes, per #2532:

  • Audio on Safari, including audio-only files. Nothing depends on captureStream or createMediaElementSource any more, so the video-only fallback and the "this browser can't capture audio from files" error are both gone.
  • Real timestamps. Frames carry the container's presentation timestamps rebased onto our performance.now() epoch, the same one live capture uses, instead of the canvas track's frozen timestamp: 0.
  • Not compositor-bound. Frames come from a decoder driven by a pacing loop, not from a draw loop over a <video> element, so a window that isn't composited no longer freezes the content (see the caveat below).
  • No wasted blit on the common path, and no decode the media element already did.
  • The file's real frame rate, measured from the container, instead of resampling everything to 30fps.

Looping restarts the sinks with a duration offset. Audio and video share one Timeline so they restart together and stay in sync rather than drifting apart by the difference in their track durations. Images keep working through the same frame-stream shape (a paced VideoFrame per tick off an ImageBitmap), so they are no longer compositor-bound either.

Rotation metadata is honored: toVideoFrame() drops it, so a rotated track draws through an OffscreenCanvas to bake it into the pixels. Sideways phone footage would otherwise publish sideways.

Root causes found while verifying in the browser

Two of these only reproduce with this change, and both are fixed here rather than papered over:

  1. EncodingError: Input audio buffer is incompatible with codec parameters. I had assumed WebCodecs resamples mismatched input, which the comment in js/hang/src/util/opus.ts ("Chrome hides this by ignoring the configured rate") suggests. That is true of the decode side only: Chrome's Opus encoder rejects 44.1kHz input at a 48kHz config outright. Opus cannot carry 44.1kHz at all and 44.1kHz is the most common file rate, so conversion is mandatory. Added audio/resampler.ts, which carries interpolation state across chunks so seams aren't audible; resampling each chunk independently would tick once per chunk. It sits in the encode loop, so it rebuilds with the config instead of being pinned to the one-shot stream.

  2. drawImage: The VideoFrame has been closed flooding the console. Preview.Renderer copied the frame through effect.proxy, so it held a frame one microtask behind Capture, which closes a frame when it replaces it. A camera hides this because its 33ms gap lets the microtask queue drain; bursty file delivery fires it constantly. The renderer now holds the signal and reads the current frame, which is never a closed one. Pre-existing race, exposed by this change.

  3. Muting a file used to silence it permanently. The audio encoder gated its whole source setup on enabled, which for a stream source cancelled the one-shot ReadableStream; unmuting then read a dead stream. A sample source now stays running while muted (there is no device to release, and stalling it would put audio behind video on unmute), and #runConfig gates the catalog on enabled explicitly, mirroring what the video encoder already does.

Public API changes

Additive only, all in @moq/publish (not a dev-branch package), no wire or catalog format change:

  • Video.Source widens from StreamTrack to StreamTrack | FrameSource; new Video.FrameSource and Video.isStreamTrack().
  • Audio.Source widens to include a new Audio.SampleSource; new Audio.isSampleSource() and Audio.sourceKind().
  • Audio.normalizeSource's parameter narrows from Source to StreamTrack | SourceConfig. Not a break: that union is exactly what Source was before this change, so every existing call still typechecks.
  • Audio.Resampler is new but internal to the package (not re-exported from audio/index.ts); same for audio/pcm.ts and source/timeline.ts.

Behavior change: a <video> preview element can't show a decoded-file source (srcObject only takes a MediaStream). It now hides itself and warns to use a <canvas>, which renders both. demo/web already uses a <canvas>.

Caveat: background-tab timer throttling

Pacing uses setTimeout on the main thread, which browsers clamp to ~1s for a hidden page. Measured in a hidden tab: output stays correct (monotonic timestamps, exact 41.67ms cadence, no dropped or duplicated frames, clean loop wrap) but delivery batches into ~1s bursts. That is strictly better than the old canvas path, which froze on the same throttle and published duplicate frames, but it is not the fully unthrottled result. Moving the decode + pacing loop into a worker (as #2532 sketches) is the follow-up; I left it out rather than guess at whether worker timers are throttled the same way.

Follow-up fixes from adversarial review

  • Audio framing swallowed timestamp gaps. Framer derives every output timestamp by counting samples forward from its first input and ignores later ones. A file whose audio is shorter than its video leaves a gap at each loop wrap, so the encoded stream slid earlier by the gap and stayed there, compounding on every pass. Fixed in Framer (the layer that owns timestamp derivation) rather than in the file source, so it also covers a stalled mic. Regression tests cover a bare gap, the composed resampler→framer path with a 2s wrap gap, and that sub-millisecond jitter does not re-anchor; all three fail with the resync removed.
  • Decodability was probed before the WebCodecs polyfill loaded. canDecode() returns false rather than throwing when AudioDecoder is missing, so an engine the polyfill could have covered would have its audio track dropped first. The polyfill now runs before the probe. Note this does not extend AAC coverage: the bundled variant is @libav.js/variant-opus-af (Opus only, with a standing TODO build with AAC support in js/hang/src/util/libav.ts), so widening that is a separate change to @moq/hang's bundle.

Re-verified in the browser after both fixes: 50.17 audio frames/s and exactly 24.0 video fps across two loop wraps with 0 console errors, and a purpose-built 5s-video/3s-audio file publishes across three wraps at 30.4 audio frames/s (the expected 60% duty cycle) with 0 errors.

Test plan

Unit (bun test js/publish, 48 pass): new resampler.test.ts (rate conversion up and down, exact linear-ramp reproduction, chunked output identical to unchunked, channel preservation, short-chunk handling), timeline.test.ts (loop offsets, monotonic wrap, unusable durations don't loop), plus types.test.ts for both source-shape discriminators. just js check and just js fix clean.

Browser (Chrome, via just dev against the local relay, publishing to a real <moq-watch> subscriber). Test media generated with ffmpeg: 5s 640x480@24 H.264 + 44.1kHz stereo AAC, an audio-only 44.1kHz .m4a, and a PNG.

result
video+audio file FrameSource, frameRate 24 detected from the container (not 30)
audio catalog opus @ 48000 from 44.1kHz source, resampled
steady-state rates 24.0 video fps, 50.3 audio frames/s (nominal 50 for 20ms Opus)
loop wrap timestamps monotonic, max delta 41.67ms, no gap
subscriber 63 kbps audio received, decoded at 48kHz, 65ms buffered, not stalled
audio-only file publishes (previously impossible on Safari); no video track
image 320x240 @30, timestamps advance, no audio
mute/unmute catalog drops, 0 frames encoded while muted, audio resumes at 50.5 fps
console 0 errors over a 6s steady-state window

Not verified: Safari and Firefox (no driver in this environment), so the Safari audio fix is verified by construction rather than by running it. Rotated-file handling is likewise unexercised.

Cross-Package Sync: no rows apply. No wire, catalog, moq-ffi, or CLI surface changes; doc/ documents source="file" as a source but nothing about the removed captureStream path.

(written by Opus 5)

…track

Publishing a file is a transcode, not a capture. Routing it through a
<video> element and scraping tracks back out via captureStream is what
caused the Safari gaps, the lost timestamps, and the compositor coupling.
Demux and decode the file directly with mediabunny instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry @kixelated, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@kixelated, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4fc4000a-71f1-476d-bd03-e32ccdbc2991

📥 Commits

Reviewing files that changed from the base of the PR and between 0be1913 and 23bb37d.

📒 Files selected for processing (1)
  • js/publish/src/audio/framer.test.ts

Walkthrough

Adds mediabunny-based file decoding with synchronized, looping sample streams for image, video, and audio sources. Audio now uses a shared PCM abstraction supporting worklet and decoded samples, gain control, sample-rate conversion, and updated AAC/Opus framing. Video sources now support either stream tracks or frame streams, with corresponding capture, encoding, and preview updates. New source guards, timeline behavior, resampling tests, and type tests are included.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes implement direct demux/decode publishing, Safari audio, timestamps, looping, API widening, and capture fallback removal requested by #2532 and #2374.
Out of Scope Changes check ✅ Passed The added tests and helper modules all support the file-publishing rewrite; no unrelated changes stand out.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title accurately describes the main change: file publishing now uses demuxing instead of MediaStreamTrack capture.
Description check ✅ Passed The description is detailed and clearly describes the same demuxing/decoding pipeline changes and related fixes.
✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/github-issues-2532-2374-aa49dd

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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

🧹 Nitpick comments (1)
js/publish/src/audio/resampler.test.ts (1)

9-16: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the no-op / 1 in the timestamp calculation.

Harmless, but Math.round(((offset / rate) * 1_000_000) / 1) divides by 1 for no effect.

🧹 Proposed cleanup
-		timestamp: micro(Math.round(((offset / rate) * 1_000_000) / 1)),
+		timestamp: micro(Math.round((offset / rate) * 1_000_000)),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@js/publish/src/audio/resampler.test.ts` around lines 9 - 16, Remove the
redundant “/ 1” operation from the timestamp calculation in tone, preserving the
existing Math.round conversion and resulting timestamp behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@js/publish/src/audio/resampler.test.ts`:
- Around line 9-16: Remove the redundant “/ 1” operation from the timestamp
calculation in tone, preserving the existing Math.round conversion and resulting
timestamp behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8f8ee6df-d65c-4cc0-bc8c-4b03437f0ba9

📥 Commits

Reviewing files that changed from the base of the PR and between ad5a10c and 476b258.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (19)
  • js/publish/package.json
  • js/publish/src/audio/encoder.ts
  • js/publish/src/audio/pcm.ts
  • js/publish/src/audio/resampler.test.ts
  • js/publish/src/audio/resampler.ts
  • js/publish/src/audio/types.test.ts
  • js/publish/src/audio/types.ts
  • js/publish/src/element.ts
  • js/publish/src/preview.ts
  • js/publish/src/source/camera.ts
  • js/publish/src/source/file.ts
  • js/publish/src/source/timeline.test.ts
  • js/publish/src/source/timeline.ts
  • js/publish/src/support/index.ts
  • js/publish/src/video/capture.ts
  • js/publish/src/video/encoder.ts
  • js/publish/src/video/processor.ts
  • js/publish/src/video/types.test.ts
  • js/publish/src/video/types.ts
💤 Files with no reviewable changes (1)
  • js/publish/src/support/index.ts

…uity

Framer derives every output timestamp by counting samples forward from
its first input, ignoring later ones. A file whose audio is shorter than
its video leaves a gap at each loop wrap, which was silently swallowed:
the encoded stream slid earlier by the gap and stayed there, compounding
on every pass until audio and video were visibly apart.

Detect an input timestamp that leaves the running timeline and start a
new segment at it, dropping the partial frame that belonged to the old
one. Also install the WebCodecs polyfill before probing decodability,
since canDecode() reports false rather than throwing when AudioDecoder
is missing, which would drop the audio track before the polyfill could
cover it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@js/publish/src/audio/framer.test.ts`:
- Around line 89-103: Update the test around framer.push and the “keeps counting
through jitter below the threshold” case so the nudged timestamp completes and
flushes the pending 960-sample frame before asserting. Add enough input samples
or an explicit flush using the existing framer API, then assert the emitted
frame timestamp remains 1,000,000 microseconds, ensuring the test fails if
jitter handling resets the timeline.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d219afb2-5f37-443a-893c-be24cad2360c

📥 Commits

Reviewing files that changed from the base of the PR and between 476b258 and 0be1913.

📒 Files selected for processing (4)
  • js/publish/src/audio/framer.test.ts
  • js/publish/src/audio/framer.ts
  • js/publish/src/audio/resampler.test.ts
  • js/publish/src/source/file.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • js/publish/src/audio/resampler.test.ts
  • js/publish/src/source/file.ts

Comment thread js/publish/src/audio/framer.test.ts
The nudged push added 128 samples to an empty 960-sample frame buffer, so
it emitted nothing and the assertion loop ran zero times. The test passed
whether or not the discontinuity check worked at all.

Push a full frame so one is emitted, and assert on it directly. It now
fails if the threshold is tightened enough to re-anchor on jitter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant