Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion js/watch/src/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export class MultiBackend implements Backend {
paused: this.paused,
});

const videoRenderer = new Video.Renderer(videoSource, { canvas: element, paused: this.paused });
const videoRenderer = new Video.Renderer(videoSource, { canvas: element, paused: this.paused, mode: "pull" });

effect.cleanup(() => {
videoSource.close();
Expand Down
48 changes: 45 additions & 3 deletions js/watch/src/video/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import { Time } from "@moq/lite";
import { Effect, Signal } from "@moq/signals";
import type { Decoder } from "./decoder";

export type RendererMode = "push" | "pull";

export type RendererProps = {
canvas?: HTMLCanvasElement | Signal<HTMLCanvasElement | undefined>;
paused?: boolean | Signal<boolean>;
mode?: RendererMode | Signal<RendererMode>;
};

// An component to render a video to a canvas.
// A component to render a video to a canvas.
export class Renderer {
decoder: Decoder;

Expand All @@ -17,6 +20,10 @@ export class Renderer {
// Whether the video is paused.
paused: Signal<boolean>;

// "push" re-renders each time the decoder publishes a new frame; "pull" drives a
// continuous rAF loop and redraws only when the decoder's current frame differs.
mode: Signal<RendererMode>;

// The most recently rendered frame, updated after each rAF paint.
readonly frame = new Signal<VideoFrame | undefined>(undefined);

Expand All @@ -31,6 +38,7 @@ export class Renderer {
this.decoder = decoder;
this.canvas = Signal.from(props?.canvas);
this.paused = Signal.from(props?.paused ?? false);
this.mode = Signal.from(props?.mode ?? "push");

this.#signals.run((effect) => {
const canvas = effect.get(this.canvas);
Expand Down Expand Up @@ -108,6 +116,15 @@ export class Renderer {
const ctx = effect.get(this.#ctx);
if (!ctx) return;

const mode = effect.get(this.mode);
if (mode === "push") {
this.#runPush(effect, ctx);
} else {
this.#runPull(effect, ctx);
}
}

#runPush(effect: Effect, ctx: CanvasRenderingContext2D) {
const paused = effect.get(this.paused);

// Read new frames from the decoder when not paused.
Expand All @@ -120,7 +137,7 @@ export class Renderer {
// Always render, even when paused (to show last frame)
let animate: number | undefined = requestAnimationFrame(() => {
const frame = decoded ?? this.frame.peek();
this.#render(ctx, frame);
this.#draw(ctx, frame);

// Update signals to reflect what's actually on screen.
if (decoded) {
Expand All @@ -141,7 +158,32 @@ export class Renderer {
});
}

#render(ctx: CanvasRenderingContext2D, frame?: VideoFrame) {
#runPull(effect: Effect, ctx: CanvasRenderingContext2D) {
let lastDrawn: VideoFrame | undefined;
let rafId: number | undefined;

const tick = () => {
const current = this.decoder.frame.peek();
if (current && current !== lastDrawn) {
this.#draw(ctx, current);
this.frame.update((old) => {
old?.close();
return current.clone();
});
this.timestamp.set(Time.Milli.fromMicro(current.timestamp as Time.Micro));
lastDrawn = current;
}
rafId = requestAnimationFrame(tick);
};

rafId = requestAnimationFrame(tick);
Copy link
Copy Markdown
Collaborator

@kixelated kixelated May 1, 2026

Choose a reason for hiding this comment

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

I'm not sure if this is worth the compute cost of busy looping requestAnimationFrame if there's no work to be done. Is 120fps versus 144fps that important?

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.

its 120fps vs the actual video fps (24) so the GPU is actually less busy with the change.

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.

wait what? how does that work?

With push we should schedule 24 rAF callbacks, while pull should schedule refresh rate rAF callbacks? Are you sure those screenshots are correct?

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.

Pretty sure, but let me do some actual power testing on my phone or laptop...

I think it has to do with v-sync, but yea, it doesn't really make sense :)

Copy link
Copy Markdown
Contributor Author

@skirsten skirsten May 1, 2026

Choose a reason for hiding this comment

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

Okay, I did some more testing and the screenshots are real and there is definitely different magic on chrome's side between them.

But there is actually very little difference in the power draw. I remember this being a real issue in the past though, but that might have been fixed in a chrome or driver update.

Also it seems to depend on the display refresh rate a lot as on a 120 fps display I was getting very different numbers for both.

Anyway, I don't think this is actually needed so we can close this or continue but keep the current push as default?

Btw, both of these run into a issue on 60hz display with 60hz content: It's actually rendering at ~45 fps or so because some frames get unlucky and are being scheduled too late and get overwritten by the next one.

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.

Yeah, it might be a good idea to schedule the next RAF within the loop, but don't schedule again if the frame didn't change.


effect.cleanup(() => {
if (rafId !== undefined) cancelAnimationFrame(rafId);
});
}

#draw(ctx: CanvasRenderingContext2D, frame?: VideoFrame) {
if (!frame) {
// Clear canvas when no frame
ctx.fillStyle = "#000";
Expand Down
Loading