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
23 changes: 16 additions & 7 deletions src/core/export/export-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand All @@ -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();
Expand Down
32 changes: 27 additions & 5 deletions tests/export-coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> = [];

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -97,6 +101,7 @@ function createHarness() {
};
const viewAtExtract: Array<typeof viewport> = [];
const renderer = {
view: { antialias: true },
render: jest.fn(),
extract: {
pixels: jest.fn(() => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
Loading