From 5cb8102ccdb085e11bb8cc43ba08e2680309eeef Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 30 Oct 2026 18:34:16 +1100 Subject: [PATCH] fix(export): draw export frames into one stencil target so Firefox keeps masked content --- src/core/export/export-coordinator.ts | 23 +++++++++++++------ tests/export-coordinator.test.ts | 32 ++++++++++++++++++++++----- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/core/export/export-coordinator.ts b/src/core/export/export-coordinator.ts index 523589d6..5ac92ecf 100644 --- a/src/core/export/export-coordinator.ts +++ b/src/core/export/export-coordinator.ts @@ -221,6 +221,16 @@ export class ExportCoordinator { const tickerWasRunning = this.app.ticker.started; this.app.stop(); const stopWatching: Array<() => void> = []; + // Into a new texture pixi adds the stencil buffer at the first mask, mid-frame, and Firefox then draws that + // frame's masked containers blank. A target that has its stencil from the start draws every frame. + const frameTexture = pixi.RenderTexture.create({ + width: cfg.size.width, + height: cfg.size.height, + resolution: 1, + antialias: this.app.renderer.view.antialias + }); + const frameTarget = new pixi.RenderTarget({ colorTextures: [frameTexture], stencil: true, depth: true }); + const frameTransform = new pixi.Matrix(); try { this.edit.pause(); @@ -250,13 +260,10 @@ export class ExportCoordinator { try { await Promise.all(active.map(clip => clip.prepareStaticRender())); throwIfStopped(); - // pixi renders the container into a texture with its own transform swapped for the frame's, - // so the editor's pan and zoom never reach these pixels; resolution pins them to output pixels. - const pixels = this.app.renderer.extract.pixels({ - target: container, - frame: new pixi.Rectangle(0, 0, cfg.size.width, cfg.size.height), - resolution: 1 - }); + // The identity transform stands in for the container's own, so the editor's pan and zoom never reach + // these pixels; the texture's resolution pins them to output pixels. + this.app.renderer.render({ container, target: frameTarget, transform: frameTransform, clearColor: [0, 0, 0, 0] }); + const pixels = this.app.renderer.extract.pixels(frameTexture); ctx.putImageData(new ImageData(new Uint8ClampedArray(pixels.pixels), pixels.width, pixels.height), 0, 0); } finally { @@ -272,6 +279,8 @@ export class ExportCoordinator { for (const stop of stopWatching) stop(); // Before restoring playback, so a throw there can't leave the editor frozen. start() only schedules a frame. if (tickerWasRunning) this.app.start(); + frameTarget.destroy(); + frameTexture.destroy(true); this.videoProcessor.restore(); this.edit.seek(sec(playbackTime)); if (wasPlaying) this.edit.play(); diff --git a/tests/export-coordinator.test.ts b/tests/export-coordinator.test.ts index c2e03206..792f1ff7 100644 --- a/tests/export-coordinator.test.ts +++ b/tests/export-coordinator.test.ts @@ -9,6 +9,7 @@ import { ExportProgressUI } from "@core/export/export-progress-ui"; import { ExportError, exportBitrate } from "@core/export/export-utils"; import { calculateSizeFromPreset } from "@core/output-settings-manager"; import { canEncodeAudio, canEncodeVideo, CanvasSink, CanvasSource, Input, Output } from "mediabunny"; +import { RenderTarget, RenderTexture } from "pixi.js"; const mockOutputs: Array<{ cancel: jest.Mock; addAudioTrack: jest.Mock; state: string }> = []; @@ -53,7 +54,10 @@ jest.mock("pixi.js", () => ({ ...require("./helpers/pixi-mock-filters").pixiFilterStubs, Rectangle: jest.fn().mockImplementation((x: number, y: number, width: number, height: number) => ({ x, y, width, height })), Texture: jest.fn((options: object) => ({ ...options, destroy: jest.fn() })), - TextureSource: { from: jest.fn(() => ({ update: jest.fn() })) } + TextureSource: { from: jest.fn(() => ({ update: jest.fn() })) }, + Matrix: jest.fn(), + RenderTexture: { create: jest.fn((options: object) => ({ ...options, destroy: jest.fn() })) }, + RenderTarget: jest.fn().mockImplementation((options: object) => ({ ...options, destroy: jest.fn() })) })); // The canvas package needs TextEncoder, which jsdom lacks; the export only uses its font-name parser. @@ -97,6 +101,7 @@ function createHarness() { }; const viewAtExtract: Array = []; const renderer = { + view: { antialias: true }, render: jest.fn(), extract: { pixels: jest.fn(() => { @@ -451,10 +456,12 @@ describe("off-screen export", () => { await exporter.exportBlob(); expect(renderer.extract.pixels).toHaveBeenCalledTimes(2); - expect(renderer.extract.pixels).toHaveBeenCalledWith({ target: viewport, frame: { x: 0, y: 0, width: 4, height: 2 }, resolution: 1 }); + expect(RenderTexture.create).toHaveBeenCalledWith(expect.objectContaining({ width: 4, height: 2, resolution: 1 })); + // Every draw goes to the export's own target, never to the screen. + expect(renderer.render).toHaveBeenCalledTimes(2); + expect(renderer.render.mock.calls.every(([options]) => options.container === viewport && options.target)).toBe(true); expect(viewAtExtract).toEqual([before, before]); expect(viewport).toEqual(before); - expect(renderer.render).not.toHaveBeenCalled(); expect(edit.setExportMode).not.toHaveBeenCalled(); expect(edit.executeEditCommand).not.toHaveBeenCalled(); exporter.dispose(); @@ -641,19 +648,34 @@ describe("editor restart", () => { describe("output size", () => { it("draws and encodes at the size the editor lays clips out in, with a bitrate for that size", async () => { - const { exporter, edit, renderer } = createHarness(); + const { exporter, edit } = createHarness(); // An edit sized by resolution "hd" and aspectRatio "9:16", with no output.size. edit.size = calculateSizeFromPreset("hd", "9:16"); await exporter.exportBlob(); - expect(renderer.extract.pixels).toHaveBeenCalledWith(expect.objectContaining({ frame: { x: 0, y: 0, width: 720, height: 1280 } })); + expect(RenderTexture.create).toHaveBeenCalledWith(expect.objectContaining({ width: 720, height: 1280, resolution: 1 })); expect(canEncodeVideo).toHaveBeenCalledWith("avc", { width: 720, height: 1280, bitrate: 2_222_222 }); expect(CanvasSource).toHaveBeenCalledWith(expect.objectContaining({ width: 720, height: 1280 }), { codec: "avc", bitrate: 2_222_222 }); exporter.dispose(); }); }); +it("draws every frame into one render target that has a stencil buffer from its first frame", async () => { + const { exporter, renderer } = createHarness(); // 1 s at 2 fps: 2 frames + jest.mocked(RenderTarget).mockClear(); + + await exporter.exportBlob(); + + expect(RenderTarget).toHaveBeenCalledTimes(1); + expect(RenderTarget).toHaveBeenCalledWith(expect.objectContaining({ stencil: true })); + const target = jest.mocked(RenderTarget).mock.results[0].value; + expect(renderer.render).toHaveBeenCalledTimes(2); + expect(renderer.render.mock.calls.map(([options]) => options.target)).toEqual([target, target]); + expect(target.destroy).toHaveBeenCalled(); + exporter.dispose(); +}); + describe("exportBitrate", () => { it("keeps 5 Mbps at 1080p in either orientation and scales with pixel count", () => { expect(exportBitrate({ width: 1920, height: 1080 })).toBe(5_000_000);