Skip to content
Merged
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
167 changes: 167 additions & 0 deletions web-ui/src/playback-engine/render/renderer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createVideoRenderPipeline, type VideoRenderPipeline } from ".";

const { present, upload } = vi.hoisted(() => ({ present: vi.fn(), upload: vi.fn() }));

// Exercise the real pipeline and frame scheduling; GPU output is checked in a browser.
vi.mock("./presenters", () => ({
PassthroughPresenter: class {
init() {}
present = present;
destroy() {}
},
}));
vi.mock("./fsr", () => ({
FsrPresenter: class {
init() {}
present = present;
releaseTransientResources() {}
destroy() {}
},
}));
vi.mock("./denoise", () => ({
TemporalDenoiser: class {
init() {}
render(_gl: unknown, texture: unknown) {
return texture;
}
reset() {}
releaseTransientResources() {}
destroy() {}
},
}));
vi.mock("./filters/bwdif", () => ({}));
vi.mock("./filters/types", () => ({
createFilter: (name: string) => ({ name, historyFrames: 2, init() {}, render() {}, destroy() {} }),
}));

class TestVideo extends EventTarget {
videoWidth = 768;
videoHeight = 576;
readyState = 1;
paused = true;
private nextHandle = 0;
private callbacks = new Map<number, VideoFrameRequestCallback>();

requestVideoFrameCallback(callback: VideoFrameRequestCallback): number {
const handle = ++this.nextHandle;
this.callbacks.set(handle, callback);
return handle;
}

cancelVideoFrameCallback(handle: number): void {
this.callbacks.delete(handle);
}

deliverFrame(mediaTime = 0): void {
const callbacks = [...this.callbacks.values()];
this.callbacks.clear();
const now = mediaTime * 1000;
for (const callback of callbacks) {
callback(now, {
width: 720,
height: 576,
mediaTime,
presentationTime: now,
expectedDisplayTime: now,
presentedFrames: Math.round(mediaTime * 25) + 1,
processingDuration: 0,
});
}
}
}

function createCanvas(): HTMLCanvasElement {
const gl = {
getParameter: () => 4096,
createTexture: () => ({}),
createFramebuffer: () => ({}),
checkFramebufferStatus: () => 0x8cd5,
FRAMEBUFFER_COMPLETE: 0x8cd5,
texSubImage2D: upload,
bindTexture() {},
texStorage2D() {},
texImage2D() {},
texParameteri() {},
activeTexture() {},
bindFramebuffer() {},
framebufferTexture2D() {},
viewport() {},
deleteTexture() {},
deleteFramebuffer() {},
};
return Object.assign(new EventTarget(), {
width: 300,
height: 150,
parentElement: null,
getContext: () => gl,
}) as unknown as HTMLCanvasElement;
}

describe("first decoded video frame", () => {
let pipeline: VideoRenderPipeline;

beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("HTMLVideoElement", TestVideo);
vi.stubGlobal("HTMLMediaElement", { HAVE_CURRENT_DATA: 2 });
vi.stubGlobal("window", { requestAnimationFrame: vi.fn(() => 1), cancelAnimationFrame: vi.fn() });
});

afterEach(() => {
pipeline?.destroy();
vi.unstubAllGlobals();
});

it.each([
{ enhancement: true, deinterlace: false },
{ enhancement: false, deinterlace: true },
{ enhancement: true, deinterlace: true },
])("presents without waiting for a second frame: %o", ({ enhancement, deinterlace }) => {
const video = new TestVideo();
const canvas = createCanvas();
pipeline = createVideoRenderPipeline(video as unknown as HTMLVideoElement, canvas);
pipeline.setPictureEnhancementEnabled(enhancement);
pipeline.setAutoDeinterlaceEnabled(deinterlace);
pipeline.setScanType("interlaced");

// Chromium can deliver rVFC before it advances readyState to HAVE_CURRENT_DATA.
video.deliverFrame();

expect(pipeline.active).toBe(true);
expect(upload).toHaveBeenCalledTimes(1);
expect(present).toHaveBeenCalledTimes(1);
expect(present.mock.calls[0].slice(2, 6)).toEqual([720, 576, 768, 576]);
expect([canvas.width, canvas.height]).toEqual([768, 576]);
});

it("does not upload or filter the first frame twice when it was already primed", () => {
const video = new TestVideo();
video.readyState = 2;
pipeline = createVideoRenderPipeline(video as unknown as HTMLVideoElement, createCanvas());

video.deliverFrame();

expect(upload).toHaveBeenCalledTimes(1);
expect(present).toHaveBeenCalledTimes(1);

video.deliverFrame(0.04);

expect(upload).toHaveBeenCalledTimes(2);
expect(present).toHaveBeenCalledTimes(2);
});

it("keeps raw video when both processing features are disabled", () => {
const video = new TestVideo();
pipeline = createVideoRenderPipeline(video as unknown as HTMLVideoElement, createCanvas());
pipeline.setPictureEnhancementEnabled(false);
pipeline.setAutoDeinterlaceEnabled(false);
pipeline.setScanType("interlaced");

video.deliverFrame();

expect(pipeline.active).toBe(false);
expect(upload).not.toHaveBeenCalled();
expect(present).not.toHaveBeenCalled();
});
});
7 changes: 5 additions & 2 deletions web-ui/src/playback-engine/render/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,8 +603,11 @@ export class VideoRenderer {
this.frameHeight = metadata.height;
if (sizeChanged) this.onFrameSizeChange?.();
if (!this.running) return;
// Starting from the size notification already primes this same frame.
if (wasRunning) this.processFrame(now, metadata);
// Starting from the size notification may have primed this frame, but
// rVFC can arrive before HAVE_CURRENT_DATA and make primeCanvas skip it.
// Render that decoded frame now instead of showing an empty canvas until
// the next callback; do not upload/filter a successfully primed frame twice.
if (wasRunning || this.textures.length === 0) this.processFrame(now, metadata);
if (this.running) this.scheduleFrame();
});
}
Expand Down
Loading