Skip to content

Refactor/video pacing rAF#1401

Open
skirsten wants to merge 2 commits into
moq-dev:mainfrom
skirsten:refactor/video-pacing-rAF
Open

Refactor/video pacing rAF#1401
skirsten wants to merge 2 commits into
moq-dev:mainfrom
skirsten:refactor/video-pacing-rAF

Conversation

@skirsten
Copy link
Copy Markdown
Contributor

@skirsten skirsten commented May 11, 2026

Summary

Consolidate video frame pacing into the renderer's requestAnimationFrame loop. Previously pacing happened twice: in the decoder via Sync.wait() (a setTimeout/#update race per in-flight frame, trying to wake near vsync) and again in the renderer via a single-shot rAF triggered by a frame signal. Two layers, two places to stall, and systematic beating when the display refresh and content fps lined up (the wait would resolve a fraction of a ms past vsync, the signal-driven rAF would schedule against the next vsync, so every other frame got dropped).

This PR moves all pacing to paint time:

  • DecoderTrack enqueues decoded frames into #queue: VideoFrame[] immediately; the output callback no longer awaits anything.
  • Decoder.consume() returns the newest queued frame whose PTS is <= sync.now(), closing older entries and transferring ownership to the caller. Late-frame drop and decode-buffer trim move here.
  • The renderer's rAF calls decoder.consume() each vsync.
  • Sync.wait() and the #update notification machinery are removed. Buffer changes take effect on the next vsync (~<=16ms), which is fine for live media.

The "first-paint preview" path and the Decoder.frame mirror that fed it are dropped. The preview saved 20-100ms of black canvas at first paint, but it added a special case to the render loop and held a cloned VideoFrame resident at all times for every active track. It's replaced with two narrower signals: DecoderTrack.display (set once from the first decoded frame's display dimensions, as a catalog-display fallback) and #runBuffering now reacting to consume-time timestamp instead of decode-time frame presence.

Net: one rAF loop in the system. Stalls now show up as held frames instead of dropped frames.

Test plan

  • just check (biome + tsc + clippy + cargo check, etc.)
  • New js/watch/src/video/decoder.test.ts covers consumeFrame():
    • empty queue, all-future frames, boundary (pts === now)
    • newest-ready selection with older frames closed
    • 60Hz vsync + 120fps content (older frame closed, newer painted)
    • 120Hz vsync + 30fps content (no frame returned between content ticks)
    • late frame still selectable when nothing newer is queued
    • 60fps x 60Hz steady state: one paint per vsync, no queue growth, no spurious closes
  • Manual: watch a live stream and confirm no per-frame beating on a 60Hz display

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 11, 2026

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR replaces reactive frame delivery with a pull-based consumption model. The Decoder no longer signals decoded frames; DecoderTrack maintains an internal PTS-ordered queue and enqueues frames synchronously from VideoDecoder.output. A new consumeFrame() helper and Decoder.consume() select and return the newest eligible frame (caller-owned). The Renderer drives rendering from decoder.consume() inside rAF, transferring ownership and closing previous frames. Sync.wait() and related #update coordination were removed. Tests exercise consumeFrame queue semantics, vsync interactions, and multi-tick behavior.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Refactor/video pacing rAF' is directly related to the main change: consolidating video frame pacing into the renderer's requestAnimationFrame loop by removing Sync.wait() and the #update mechanism.
Description check ✅ Passed The description is comprehensive and closely related to the changeset, explaining the rationale for moving pacing from the decoder to the renderer's rAF loop and detailing all major modifications across the affected files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
✨ Simplify code
  • Create PR with simplified code

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 and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
js/watch/src/video/decoder.ts (1)

504-505: 💤 Low value

Nit: stray blank line.

Line 505 adds an extra empty line between consumeFrame and supported. Not a blocker; the project's formatter probably handles it on next save.

🤖 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/watch/src/video/decoder.ts` around lines 504 - 505, There's an extra blank
line between the functions consumeFrame and supported; remove the stray empty
line so consumeFrame and supported are adjacent and keep file formatting
consistent by deleting the lone blank line separating the two functions (look
for the function declarations consumeFrame(...) and supported(...)).
js/watch/src/video/renderer.ts (1)

134-156: 💤 Low value

Optional: tighten #draw signature now that the clear branch is unreachable.

The only caller (line 116) is inside if (frame), so frame?: VideoFrame and the if (!frame) { ... clear ...; return; } branch are effectively dead. If you don't plan to call #draw(ctx) externally (e.g. on close to blank the canvas), making the parameter required would make intent clearer.

Proposed simplification
-	`#draw`(ctx: CanvasRenderingContext2D, frame?: VideoFrame) {
-		if (!frame) {
-			// Clear canvas when no frame
-			ctx.fillStyle = "#000";
-			ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
-			return;
-		}
-
+	`#draw`(ctx: CanvasRenderingContext2D, frame: VideoFrame) {
 		// Prepare background and transformations for this draw
 		ctx.save();
🤖 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/watch/src/video/renderer.ts` around lines 134 - 156, The private renderer
method `#draw` currently accepts an optional frame and contains an unreachable
clear branch; change the signature to require frame (e.g., `#draw`(ctx:
CanvasRenderingContext2D, frame: VideoFrame)) and remove the if (!frame) { ...
clear ...; return; } block. If you still need an explicit canvas-clear outside
normal draws, extract that code into a new clearCanvas(ctx:
CanvasRenderingContext2D) helper and call it where appropriate instead of
keeping an optional parameter.
🤖 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/watch/src/video/decoder.test.ts`:
- Around line 119-140: The test's closed assertion is vacuous because
consumeFrame splices closed frames out of queue before you inspect it; to fix,
track frames out-of-band by keeping a separate createdFrames (or inputFrames)
list of the StubFrame instances when you construct them and then inspect that
list for .closed instead of iterating queue; update the loop that currently does
"for (const f of queue) ..." to iterate createdFrames (or check specific frame
ids/timestamps) so closed detection reflects frames removed by consumeFrame, and
assert against that out-of-band list alongside the existing painted and queue
assertions to ensure no frames were incorrectly closed/dropped by consumeFrame.

---

Nitpick comments:
In `@js/watch/src/video/decoder.ts`:
- Around line 504-505: There's an extra blank line between the functions
consumeFrame and supported; remove the stray empty line so consumeFrame and
supported are adjacent and keep file formatting consistent by deleting the lone
blank line separating the two functions (look for the function declarations
consumeFrame(...) and supported(...)).

In `@js/watch/src/video/renderer.ts`:
- Around line 134-156: The private renderer method `#draw` currently accepts an
optional frame and contains an unreachable clear branch; change the signature to
require frame (e.g., `#draw`(ctx: CanvasRenderingContext2D, frame: VideoFrame))
and remove the if (!frame) { ... clear ...; return; } block. If you still need
an explicit canvas-clear outside normal draws, extract that code into a new
clearCanvas(ctx: CanvasRenderingContext2D) helper and call it where appropriate
instead of keeping an optional parameter.
🪄 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

Run ID: 4bae9da4-229a-400e-a144-34b7d26542d9

📥 Commits

Reviewing files that changed from the base of the PR and between 6adf3c3 and 0512d02.

📒 Files selected for processing (4)
  • js/watch/src/sync.ts
  • js/watch/src/video/decoder.test.ts
  • js/watch/src/video/decoder.ts
  • js/watch/src/video/renderer.ts

Comment thread js/watch/src/video/decoder.test.ts
@skirsten skirsten force-pushed the refactor/video-pacing-rAF branch 2 times, most recently from 0f47448 to 583540a Compare May 23, 2026 21:21
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
js/watch/src/video/decoder.test.ts (1)

2-3: 💤 Low value

Import package mismatch with implementation.

The test imports Time from @moq/lite, but decoder.ts imports it from @moq/net. While this works today since it's a type-only import and the branded types are likely identical, using different source packages could cause type divergence if these packages evolve independently.

Suggested fix
 import { expect, test } from "bun:test";
-import type { Time } from "`@moq/lite`";
+import type { Time } from "`@moq/net`";
 import { consumeFrame } from "./decoder.ts";
🤖 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/watch/src/video/decoder.test.ts` around lines 2 - 3, The test imports the
Time type from a different package than the implementation which risks type
divergence; update the import in this test to import Time from the same package
used by decoder.ts (i.e., replace the current Time import source with the one
used in decoder.ts) so the types for Time used alongside consumeFrame remain
consistent across files.
🤖 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/watch/src/video/decoder.test.ts`:
- Around line 2-3: The test imports the Time type from a different package than
the implementation which risks type divergence; update the import in this test
to import Time from the same package used by decoder.ts (i.e., replace the
current Time import source with the one used in decoder.ts) so the types for
Time used alongside consumeFrame remain consistent across files.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 63041e5d-678e-484e-b601-1905d2e635c1

📥 Commits

Reviewing files that changed from the base of the PR and between 0f47448 and 583540a.

📒 Files selected for processing (4)
  • js/watch/src/sync.ts
  • js/watch/src/video/decoder.test.ts
  • js/watch/src/video/decoder.ts
  • js/watch/src/video/renderer.ts

@skirsten skirsten force-pushed the refactor/video-pacing-rAF branch 2 times, most recently from 8b84e97 to 9bee308 Compare May 23, 2026 21:41
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

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/watch/src/video/renderer.ts`:
- Around line 103-104: The preview frame is cached then decoding is disabled
(effect.get(this.frame) + this.decoder.enabled.set(!frame)), but no redraw is
performed when the canvas is resized/replaced so the paused view can stay blank;
after computing frame and disabling the decoder, explicitly paint the cached
frame (call the existing frame-draw routine — e.g., paintFrame/ renderFrame/
drawPreview — or trigger the same rendering path the consumer uses) so the
preview is repainted immediately, and apply the same change to the other block
in the render loop (the code around lines 111-130) so canvas changes force a
redraw even when no new frames arrive (alternatively track a canvasVersion and
force a redraw when it changes).
🪄 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

Run ID: 45856bf5-884e-4288-9070-d51ea23448e5

📥 Commits

Reviewing files that changed from the base of the PR and between 583540a and a566fc7.

📒 Files selected for processing (4)
  • js/watch/src/sync.ts
  • js/watch/src/video/decoder.test.ts
  • js/watch/src/video/decoder.ts
  • js/watch/src/video/renderer.ts

Comment thread js/watch/src/video/renderer.ts
skirsten added 2 commits May 23, 2026 23:53
Previously, frame pacing happened in the decoder via Sync.wait(), which
slept on a setTimeout / #update race per in-flight frame to wake near
vsync. The renderer then ran a single-shot rAF on every frame signal
update to paint. Two pacing layers, two places that could stall, and
systematic beating when the display refresh and content fps line up
(the wait resolves a fraction of a ms past vsync, the signal-driven rAF
schedules against the next vsync, so you display every other frame).

Move pacing to the renderer:

- DecoderTrack queues decoded frames immediately into #queue: VideoFrame[].
  The output callback no longer awaits anything.
- Decoder.consume() returns the newest queued frame whose PTS is
  <= sync.now(), closing any older entries; caller takes ownership.
  Late-frame drop and decode-buffer trim move here.
- The renderer's rAF calls decoder.consume() each vsync.
- Drop Sync.wait() and the #update notification machinery. Buffer
  changes now take effect on the next vsync (<=16ms), which is fine for
  live media.

Drop the "first-paint preview" path and Decoder.frame mirror that fed
it. The preview was painting decoder.frame.peek() while the buffer
filled, which saved 20-100ms of black canvas at first paint but added
a special case to the render loop and held a cloned VideoFrame
resident at all times for every active track. Replace with two
narrower signals: DecoderTrack.display, set once from the first
decoded frame's displayWidth/displayHeight as a catalog-display
fallback; and #runBuffering now reacts to consume-time `timestamp`
instead of decode-time frame presence.

Net: one rAF loop in the system. Pacing happens at paint time, where
vsync is. Stalls show up as held frames instead of dropped frames.
Extract the queue-selection logic from DecoderTrack.consume into a
top-level consumeFrame() helper that takes any { timestamp, close() }
queue. The DecoderTrack method now wraps it and applies the side
effects (timestamp signal, decode-buffer trim).

The new decoder.test.ts exercises the behavior with a StubFrame across:
- empty queue / all-future / boundary (pts === now)
- newest-ready selection with older frames closed
- monitor refresh < content fps (the reviewer's concern: older frame
  closed, newer painted)
- monitor refresh > content fps (no frame returned between content
  ticks; renderer is expected to hold)
- late frame still selectable when nothing newer is queued
- 60fps × 60Hz steady state: one paint per vsync, no queue growth
@skirsten skirsten force-pushed the refactor/video-pacing-rAF branch from 9bee308 to 89056be Compare May 23, 2026 21:54
this.frame.set(frame.clone());
}

const wait = this.source.sync.wait(timestamp).then(() => true);
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.

This is really important for higher latency playback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I did some tests on Chrome and Firefox.

It's the same peak frame count either way (latency * fps). Old code holds them in awaiting output() closures, new in the queue. Same VRAM either way, and decodeQueueSize stays at 0 so the decoder isn't backpressuring us implicitly.

Only real way to shrink this would be capping the queue and pausing decoder.decode() when it's full, so we hold encoded bytes instead of decoded frames. Container.Consumer already buffers at that layer, so would probably be better to do it there 🤔

But playback seems to be the fine even at high latency:
https://pr-31-moq-monitor.skirsten.workers.dev/?relay=https%3A%2F%2Fcdn.moq.dev%2Fdemo&watch=bbb

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hmm, actually it depends on the type of decoder used. If chrome is using hardware decoding it only stores max 10 frames, if software there does not seem to be a limit as far as I can see...
Anyway, probably makes sense to add a limit from our side: https://github.com/skirsten/moq/pull/5/changes
Should I include this in this PR?

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.

2 participants