Skip to content

Conversation

@kixelated
Copy link
Collaborator

@kixelated kixelated commented Jan 28, 2026

The b-frames "support" was well intentioned, but introduced a lot of latency. The main problem is that #ref is computed AFTER queuing, we only pop from the queue after sleeping for some amount. This means we were rendering frames significantly later than intended.

Instead of moving the ref computation before adding to the queue, I'd like to remove the queue. However, this means that frames are rendered in parallel. This is annoying but also means we support b-frames automatically. In fact maybe they just work now, who knows.

The syncStatus is racey because of this change, and it was pretty wrong in the first place. I believe it would only flag when a frame needed to be rendered in the future, which is the exact opposite of what we wanted it to do anyway. Any buffering detection can't be keyed off frames received anyway; it should instead spin if frames are not forthcoming.

The b-frames "support" was well intentioned, but introduced a lot of latency.
The main problem is that #ref is computed AFTER queuing, we only pop from
the queue after sleeping for some amount. This means we were rendering frames
significantly later than intended.

Instead of moving the ref computation before adding to the queue, I'd like
to remove the queue. However, this means that frames are rendered in parallel.
This is annoying but also means we support b-frames automatically.
In fact maybe they just work now, who knows.

But there are undoubtedly some bugs with the "buffering" detection as a result.
However, that's probably just wrong anyway, as we shouldn't be deciding if we're buffering
when deciding how long to sleep, as that implies we have a frame.
@kixelated kixelated changed the title Redo video rendering. Improve video rendering and sync Jan 28, 2026
@kixelated kixelated marked this pull request as ready for review January 28, 2026 14:40
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 28, 2026

Warning

Rate limit exceeded

@kixelated has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 15 minutes and 23 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

Walkthrough

This pull request introduces a new Sync module to centralize playback timing synchronization. The changes refactor Audio and Video sources to depend on Sync for latency management instead of maintaining local buffering logic. BufferProvider is simplified to a placeholder, and corresponding test files and type definitions are cleaned up by removing syncStatus and latency properties from VideoSource. Broadcast is updated to instantiate and wire Sync through its component dependencies. The Signals library gains a promise() method on Getter and Signal types. Watch UI logic is simplified to rely solely on bufferStatus instead of syncStatus for visibility determination.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title directly relates to the main changes: rewriting the video rendering pipeline and introducing synchronization improvements via the new Sync class.
Description check ✅ Passed The description clearly explains the motivation for removing the queue-based rendering approach and addresses the syncStatus issues introduced by the changes.

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


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
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: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
js/hang/src/watch/video/source.ts (1)

208-244: Race condition: parallel frame processing can cause out-of-order rendering.

The late-frame check (line 213) occurs before sync.wait(), but multiple frames can be waiting in parallel. If a later frame's sync.wait() completes before an earlier frame's, the timestamp and frame can regress:

  1. Frame A (ts=100) and Frame B (ts=200) both pass the late-frame check
  2. Both call sync.wait()
  3. Frame B completes first, sets #timestamp = 200
  4. Frame A completes, overwrites with #timestamp = 100

Consider rechecking after sync.wait() completes:

🐛 Proposed fix
         const wait = this.sync.wait(timestamp).then(() => true);
         const ok = await Promise.race([wait, effect.cancel]);
         if (!ok) return;

+        // Recheck after waiting - another frame may have rendered
+        if (timestamp < (this.#timestamp.peek() ?? 0)) {
+          return;
+        }
+
         this.#timestamp.set(timestamp);
js/hang/src/watch/broadcast.ts (1)

163-172: Add missing this.sync.close() cleanup call and implement close() method in Sync class.

The Sync class has a signals Effect field that manages state tracking and effects, but lacks a close() method. All other components (audio, video, location, chat, preview, user) follow a consistent pattern of implementing close() to clean up their signals Effect. The broadcast.ts close() method must call this.sync.close() once Sync implements this method to prevent resource leaks from unclosed effects.

Required changes
  1. Add close() method to Sync class in js/hang/src/watch/sync.ts:
+	close() {
+		this.signals.close();
+	}
  1. Call sync.close() in broadcast.ts close() method:
 	close() {
 		this.signals.close();
 
 		this.audio.close();
 		this.video.close();
 		this.location.close();
 		this.chat.close();
 		this.preview.close();
 		this.user.close();
+		this.sync.close();
 	}
🧹 Nitpick comments (6)
js/signals/src/index.ts (1)

144-148: Potential memory leak: dispose callback from changed() is never invoked.

The changed() method returns a Dispose function to unregister the callback, but promise() discards it. If the caller abandons the promise (e.g., via Promise.race losing) or the signal is never updated again, the callback remains registered.

Consider storing and invoking the dispose function when the promise resolves:

♻️ Proposed fix
 async promise(): Promise<T> {
-  return new Promise((resolve) => {
-    this.changed(resolve);
-  });
+  return new Promise((resolve) => {
+    const dispose = this.changed((value) => {
+      dispose();
+      resolve(value);
+    });
+  });
 }
js/hang-ui/src/shared/components/stats/providers/buffer.ts (1)

11-20: Incomplete implementation: BufferProvider is now a placeholder.

The setup method now only sets "TODO" as display data, but the class docstring still claims it provides "buffer metrics (fill percentage, latency)". This will show "TODO" in the stats panel.

Is this intentional as part of the refactor, with a follow-up planned to implement proper buffer metrics using the new Sync module? If so, consider updating the docstring or adding a TODO comment explaining the planned work.

Would you like me to open an issue to track reimplementing the buffer metrics using the new Sync.latency signal?

js/hang/src/watch/sync.ts (3)

97-112: Accumulated slept time may be inaccurate when sleep is interrupted.

When #update resolves before the timeout completes (e.g., due to reference or latency changes), slept is still incremented by the full requested sleep duration (line 108), not the actual time elapsed. This could cause the returned value to overestimate how long the frame actually waited.

If the return value is used for metrics or decisions, consider tracking actual elapsed time:

♻️ Proposed fix for accurate timing
 async wait(timestamp: Time.Milli): Promise<Time.Milli> {
   let slept = Time.Milli.zero;

   if (!this.#reference) {
     throw new Error("reference not set; call update() first");
   }

   for (;;) {
     const now = performance.now();
     const ref = (now - timestamp) as Time.Milli;

     const sleep = this.#reference - ref + this.#latency.peek();
     if (sleep <= 0) return slept;
+    const start = performance.now();
     const wait = new Promise((resolve) => setTimeout(resolve, sleep)).then(() => true);

     const ok = await Promise.race([this.#update, wait]);
-    slept = (slept + sleep) as Time.Milli;
+    slept = (slept + (performance.now() - start)) as Time.Milli;

     if (ok) return slept;
   }
 }

30-42: Missing cleanup: signals Effect is never closed.

The signals Effect is initialized in the constructor but there's no close() method to clean it up. If Sync instances are created and discarded, the Effect and its subscriptions will leak.

Consider adding a close() method:

♻️ Proposed fix
+  close(): void {
+    this.signals.close();
+  }
 }

25-28: Consider using the new Signal.promise() method.

The manual #update/#resolve pattern could be simplified using the promise() method added to Signal in this PR. However, since the current pattern notifies on both reference and latency changes from different sources, keeping it manual may be intentional.

js/hang/src/watch/audio/source.ts (1)

19-21: Unusual import placement.

The Sync import at line 19 is placed between class fields and other imports, which is unconventional. Consider moving it to the top with the other imports for consistency.

♻️ Proposed fix
 import type * as Moq from "@moq/lite";
 import type { Time } from "@moq/lite";
 import { Effect, type Getter, Signal } from "@moq/signals";
 import type * as Catalog from "../../catalog";
 import * as Container from "../../container";
 import * as Hex from "../../util/hex";
 import * as libav from "../../util/libav";
 import type * as Render from "./render";
+import type { Sync } from "../sync";

 export type SourceProps = {
   // Enable to download the audio track.
   enabled?: boolean | Signal<boolean>;
 };

 export interface AudioStats {
   bytesReceived: number;
 }

-import type { Sync } from "../sync";
 // Unfortunately, we need to use a Vite-exclusive import for now.
 import RenderWorklet from "./render-worklet.ts?worker&url";

@kixelated kixelated enabled auto-merge (squash) January 28, 2026 14:56
@kixelated kixelated merged commit c7ebc4f into main Jan 28, 2026
1 check passed
@kixelated kixelated deleted the better-render branch January 28, 2026 15:13
Copy link
Collaborator

@jdreetz jdreetz left a comment

Choose a reason for hiding this comment

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

I think these changes are good. Just left a few nits about tweaking names/comments.

// Computed latency = catalog.minBuffer + buffer
#latency: Latency;
readonly latency: Getter<Time.Milli>;
// Additional buffer in milliseconds (on top of catalog's minBuffer).
Copy link
Collaborator

Choose a reason for hiding this comment

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

is this comment accurate?

}
if (this.frame.peek() === undefined) {
// Render something while we wait for the sync to catch up.
this.frame.set(frame.clone());
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍

// TODO: This cause the `syncStatus` to be racey especially
await new Promise((resolve) => setTimeout(resolve, sleep));
}
const wait = this.sync.wait(timestamp).then(() => true);
Copy link
Collaborator

Choose a reason for hiding this comment

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

i'd leave a comment about what's this is doing.

video?: Catalog.VideoConfig | Signal<Catalog.VideoConfig | undefined>;
}

export class Sync {
Copy link
Collaborator

Choose a reason for hiding this comment

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

what is this syncing from/to? Broadcast? Should it be called BroadcastSync?

Copy link
Collaborator Author

@kixelated kixelated Jan 29, 2026

Choose a reason for hiding this comment

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

Right now yes, but for tree-shaking reasons I want to move stuff out of Broadcast. You can technically use this Sync class across multiple broadcasts too if they decide to use the same timestamp clock.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

ex. the MSE.Video and WebCodecs.Video modules would take Catalog and Sync modules. Inverting the hierarchy means we can shake.

Copy link
Collaborator

Choose a reason for hiding this comment

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

modules would take Catalog and Sync modules
Probably makes testing easier too with dependency injection.

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.

3 participants