Skip to content

Commit f431c6b

Browse files
authored
fix(export): draw export frames into one stencil target so Firefox keeps masked content (#278)
1 parent e1fa158 commit f431c6b

2 files changed

Lines changed: 43 additions & 12 deletions

File tree

‎src/core/export/export-coordinator.ts‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,16 @@ export class ExportCoordinator {
221221
const tickerWasRunning = this.app.ticker.started;
222222
this.app.stop();
223223
const stopWatching: Array<() => void> = [];
224+
// Into a new texture pixi adds the stencil buffer at the first mask, mid-frame, and Firefox then draws that
225+
// frame's masked containers blank. A target that has its stencil from the start draws every frame.
226+
const frameTexture = pixi.RenderTexture.create({
227+
width: cfg.size.width,
228+
height: cfg.size.height,
229+
resolution: 1,
230+
antialias: this.app.renderer.view.antialias
231+
});
232+
const frameTarget = new pixi.RenderTarget({ colorTextures: [frameTexture], stencil: true, depth: true });
233+
const frameTransform = new pixi.Matrix();
224234

225235
try {
226236
this.edit.pause();
@@ -250,13 +260,10 @@ export class ExportCoordinator {
250260
try {
251261
await Promise.all(active.map(clip => clip.prepareStaticRender()));
252262
throwIfStopped();
253-
// pixi renders the container into a texture with its own transform swapped for the frame's,
254-
// so the editor's pan and zoom never reach these pixels; resolution pins them to output pixels.
255-
const pixels = this.app.renderer.extract.pixels({
256-
target: container,
257-
frame: new pixi.Rectangle(0, 0, cfg.size.width, cfg.size.height),
258-
resolution: 1
259-
});
263+
// The identity transform stands in for the container's own, so the editor's pan and zoom never reach
264+
// these pixels; the texture's resolution pins them to output pixels.
265+
this.app.renderer.render({ container, target: frameTarget, transform: frameTransform, clearColor: [0, 0, 0, 0] });
266+
const pixels = this.app.renderer.extract.pixels(frameTexture);
260267

261268
ctx.putImageData(new ImageData(new Uint8ClampedArray(pixels.pixels), pixels.width, pixels.height), 0, 0);
262269
} finally {
@@ -272,6 +279,8 @@ export class ExportCoordinator {
272279
for (const stop of stopWatching) stop();
273280
// Before restoring playback, so a throw there can't leave the editor frozen. start() only schedules a frame.
274281
if (tickerWasRunning) this.app.start();
282+
frameTarget.destroy();
283+
frameTexture.destroy(true);
275284
this.videoProcessor.restore();
276285
this.edit.seek(sec(playbackTime));
277286
if (wasPlaying) this.edit.play();

‎tests/export-coordinator.test.ts‎

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ExportProgressUI } from "@core/export/export-progress-ui";
99
import { ExportError, exportBitrate } from "@core/export/export-utils";
1010
import { calculateSizeFromPreset } from "@core/output-settings-manager";
1111
import { canEncodeAudio, canEncodeVideo, CanvasSink, CanvasSource, Input, Output } from "mediabunny";
12+
import { RenderTarget, RenderTexture } from "pixi.js";
1213

1314
const mockOutputs: Array<{ cancel: jest.Mock; addAudioTrack: jest.Mock; state: string }> = [];
1415

@@ -53,7 +54,10 @@ jest.mock("pixi.js", () => ({
5354
...require("./helpers/pixi-mock-filters").pixiFilterStubs,
5455
Rectangle: jest.fn().mockImplementation((x: number, y: number, width: number, height: number) => ({ x, y, width, height })),
5556
Texture: jest.fn((options: object) => ({ ...options, destroy: jest.fn() })),
56-
TextureSource: { from: jest.fn(() => ({ update: jest.fn() })) }
57+
TextureSource: { from: jest.fn(() => ({ update: jest.fn() })) },
58+
Matrix: jest.fn(),
59+
RenderTexture: { create: jest.fn((options: object) => ({ ...options, destroy: jest.fn() })) },
60+
RenderTarget: jest.fn().mockImplementation((options: object) => ({ ...options, destroy: jest.fn() }))
5761
}));
5862

5963
// The canvas package needs TextEncoder, which jsdom lacks; the export only uses its font-name parser.
@@ -97,6 +101,7 @@ function createHarness() {
97101
};
98102
const viewAtExtract: Array<typeof viewport> = [];
99103
const renderer = {
104+
view: { antialias: true },
100105
render: jest.fn(),
101106
extract: {
102107
pixels: jest.fn(() => {
@@ -451,10 +456,12 @@ describe("off-screen export", () => {
451456
await exporter.exportBlob();
452457

453458
expect(renderer.extract.pixels).toHaveBeenCalledTimes(2);
454-
expect(renderer.extract.pixels).toHaveBeenCalledWith({ target: viewport, frame: { x: 0, y: 0, width: 4, height: 2 }, resolution: 1 });
459+
expect(RenderTexture.create).toHaveBeenCalledWith(expect.objectContaining({ width: 4, height: 2, resolution: 1 }));
460+
// Every draw goes to the export's own target, never to the screen.
461+
expect(renderer.render).toHaveBeenCalledTimes(2);
462+
expect(renderer.render.mock.calls.every(([options]) => options.container === viewport && options.target)).toBe(true);
455463
expect(viewAtExtract).toEqual([before, before]);
456464
expect(viewport).toEqual(before);
457-
expect(renderer.render).not.toHaveBeenCalled();
458465
expect(edit.setExportMode).not.toHaveBeenCalled();
459466
expect(edit.executeEditCommand).not.toHaveBeenCalled();
460467
exporter.dispose();
@@ -641,19 +648,34 @@ describe("editor restart", () => {
641648

642649
describe("output size", () => {
643650
it("draws and encodes at the size the editor lays clips out in, with a bitrate for that size", async () => {
644-
const { exporter, edit, renderer } = createHarness();
651+
const { exporter, edit } = createHarness();
645652
// An edit sized by resolution "hd" and aspectRatio "9:16", with no output.size.
646653
edit.size = calculateSizeFromPreset("hd", "9:16");
647654

648655
await exporter.exportBlob();
649656

650-
expect(renderer.extract.pixels).toHaveBeenCalledWith(expect.objectContaining({ frame: { x: 0, y: 0, width: 720, height: 1280 } }));
657+
expect(RenderTexture.create).toHaveBeenCalledWith(expect.objectContaining({ width: 720, height: 1280, resolution: 1 }));
651658
expect(canEncodeVideo).toHaveBeenCalledWith("avc", { width: 720, height: 1280, bitrate: 2_222_222 });
652659
expect(CanvasSource).toHaveBeenCalledWith(expect.objectContaining({ width: 720, height: 1280 }), { codec: "avc", bitrate: 2_222_222 });
653660
exporter.dispose();
654661
});
655662
});
656663

664+
it("draws every frame into one render target that has a stencil buffer from its first frame", async () => {
665+
const { exporter, renderer } = createHarness(); // 1 s at 2 fps: 2 frames
666+
jest.mocked(RenderTarget).mockClear();
667+
668+
await exporter.exportBlob();
669+
670+
expect(RenderTarget).toHaveBeenCalledTimes(1);
671+
expect(RenderTarget).toHaveBeenCalledWith(expect.objectContaining({ stencil: true }));
672+
const target = jest.mocked(RenderTarget).mock.results[0].value;
673+
expect(renderer.render).toHaveBeenCalledTimes(2);
674+
expect(renderer.render.mock.calls.map(([options]) => options.target)).toEqual([target, target]);
675+
expect(target.destroy).toHaveBeenCalled();
676+
exporter.dispose();
677+
});
678+
657679
describe("exportBitrate", () => {
658680
it("keeps 5 Mbps at 1080p in either orientation and scales with pixel count", () => {
659681
expect(exportBitrate({ width: 1920, height: 1080 })).toBe(5_000_000);

0 commit comments

Comments
 (0)