From f8d33e506937f16776f511443af180e0f2e5c1de Mon Sep 17 00:00:00 2001 From: Simon Kirsten Date: Fri, 1 May 2026 17:07:13 +0200 Subject: [PATCH] @moq/watch: add pull mode to video renderer Adds a `mode: "push" | "pull"` prop on Renderer: - "push" (default, existing behavior): the render effect subscribes to decoder.frame and schedules a single rAF paint per decoded frame. - "pull": runs a self-recursive rAF loop and redraws only when the decoder's current frame differs from what was last drawn. MultiBackend's WebCodecs path now constructs the renderer with `mode: "pull"` so playback runs off the display's vsync rather than the decoder's frame cadence. --- js/watch/src/backend.ts | 2 +- js/watch/src/video/renderer.ts | 48 +++++++++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/js/watch/src/backend.ts b/js/watch/src/backend.ts index 03cd057f1..980d7859f 100644 --- a/js/watch/src/backend.ts +++ b/js/watch/src/backend.ts @@ -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(); diff --git a/js/watch/src/video/renderer.ts b/js/watch/src/video/renderer.ts index f40adcdc3..783ad5fb4 100644 --- a/js/watch/src/video/renderer.ts +++ b/js/watch/src/video/renderer.ts @@ -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; paused?: boolean | Signal; + mode?: RendererMode | Signal; }; -// An component to render a video to a canvas. +// A component to render a video to a canvas. export class Renderer { decoder: Decoder; @@ -17,6 +20,10 @@ export class Renderer { // Whether the video is paused. paused: Signal; + // "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; + // The most recently rendered frame, updated after each rAF paint. readonly frame = new Signal(undefined); @@ -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); @@ -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. @@ -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) { @@ -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); + + effect.cleanup(() => { + if (rafId !== undefined) cancelAnimationFrame(rafId); + }); + } + + #draw(ctx: CanvasRenderingContext2D, frame?: VideoFrame) { if (!frame) { // Clear canvas when no frame ctx.fillStyle = "#000";