Skip to content

Refactor/video pacing rAF#1401

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

Refactor/video pacing rAF#1401
skirsten wants to merge 3 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

skirsten added 2 commits May 11, 2026 22:31
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
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 11, 2026

Review Change Stack
No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3c97a18f-3d58-44b2-a07e-b48d163177c9

📥 Commits

Reviewing files that changed from the base of the PR and between 0512d02 and 983fd7d.

📒 Files selected for processing (3)
  • js/watch/src/video/decoder.test.ts
  • js/watch/src/video/decoder.ts
  • js/watch/src/video/renderer.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • js/watch/src/video/decoder.test.ts
  • js/watch/src/video/renderer.ts
  • js/watch/src/video/decoder.ts

Walkthrough

This PR replaces reactive frame delivery with a pull-based consumption model. The Decoder no longer signals decoded frames; instead, DecoderTrack maintains an internal queue of VideoFrame objects. The Renderer pulls frames directly via decoder.consume() inside the rAF callback, selecting the newest frame eligible by PTS and transferring ownership (closing the previous frame without cloning). A new consumeFrame() helper encapsulates frame selection and queue cleanup. The Sync.wait() method is removed as it is no longer required. Tests validate queue semantics across timing, vsync, and multi-frame scenarios.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

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.
Description check ❓ Inconclusive No description was provided by the author, making it impossible to verify relevance to the changeset. Add a pull request description that explains the rationale, key changes, and benefits of this refactoring work.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Refactor/video pacing rAF' accurately describes the main changes: refactoring video frame pacing and rendering using requestAnimationFrame.
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
- import type Time in decoder.test.ts (useImportType)
- drop stray blank line in decoder.ts (formatter)
- track frames out-of-band in 60Hz/60fps test so the closed assertion
  actually observes frames consumeFrame has spliced out of the queue
- drop renderer #draw unreachable !frame branch and tighten signature

Co-Authored-By: Claude Opus 4.7 <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

Development

Successfully merging this pull request may close these issues.

1 participant