From a76ebad2704f57467cfbc8d895fa789019220962 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 19 Oct 2026 21:11:58 +1100 Subject: [PATCH 1/3] fix(export): render frames off-screen without moving the editor view --- src/core/commands/export-command.ts | 28 ------- src/core/export/export-coordinator.ts | 116 ++++++++++---------------- tests/export-coordinator.test.ts | 68 ++++++++++++--- 3 files changed, 99 insertions(+), 113 deletions(-) delete mode 100644 src/core/commands/export-command.ts diff --git a/src/core/commands/export-command.ts b/src/core/commands/export-command.ts deleted file mode 100644 index 23b47640..00000000 --- a/src/core/commands/export-command.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Player } from "@canvas/players/player"; - -import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess } from "./types"; - -export class ExportCommand implements EditCommand { - readonly name = "export"; - private clips: Player[] = []; - private tracks: Player[][] = []; - - execute(context: CommandContext): CommandResult { - this.clips = context.getClips(); - this.tracks = context.getTracks(); - return CommandSuccess(); - } - - getClips(): ReadonlyArray { - return this.clips; - } - - getTracks(): ReadonlyArray> { - return this.tracks; - } - - dispose(): void { - this.clips = []; - this.tracks = []; - } -} diff --git a/src/core/export/export-coordinator.ts b/src/core/export/export-coordinator.ts index 35f9a03b..0b4df0c6 100644 --- a/src/core/export/export-coordinator.ts +++ b/src/core/export/export-coordinator.ts @@ -1,5 +1,4 @@ import { Canvas } from "@canvas/shotstack-canvas"; -import { ExportCommand } from "@core/commands/export-command"; import { Edit } from "@core/edit-session"; import { sec } from "@core/timing/types"; import { Output, Mp4OutputFormat, BufferTarget, CanvasSource, AudioBufferSource } from "mediabunny"; @@ -19,14 +18,6 @@ interface ExportConfig { frameDuration: number; } -interface EditState { - wasPlaying: boolean; - time: number; - visible: boolean; - pos: { x: number; y: number }; - scale: { x: number; y: number }; -} - export interface ExportBlobOptions { signal?: AbortSignal; onProgress?: (fraction: number, stage: string) => void; @@ -60,17 +51,14 @@ function waitForLoads(edit: Pick, sig export class ExportCoordinator { private readonly edit: Edit; - private readonly canvas: Canvas; private readonly app: pixi.Application; private isExporting = false; private videoProcessor = new VideoFrameProcessor(); private audioProcessor = new AudioProcessor(); private progressUI = new ExportProgressUI(); - private exportCommand = new ExportCommand(); constructor(edit: Edit, canvas: Canvas) { this.edit = edit; - this.canvas = canvas; this.app = canvas.application; document.addEventListener("keydown", this.handleKeyDown); } @@ -110,15 +98,9 @@ export class ExportCoordinator { const report: Progress = (fraction, stage) => onProgress?.(fraction, stage); this.isExporting = true; - const savedState = this.saveEditState(); - this.edit.setExportMode(true); const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() }); try { - this.canvas.pauseTicker(); - - this.edit.executeEditCommand(this.exportCommand); - const cfg = this.prepareConfig(fps); report(0, "Preparing..."); @@ -136,7 +118,7 @@ export class ExportCoordinator { output.addVideoTrack(videoSource); report(0.15, "Mixing audio..."); - const mixedAudio = await this.audioProcessor.renderMix(this.exportCommand.getTracks(), this.edit.totalDuration); + const mixedAudio = await this.audioProcessor.renderMix(this.edit.getTracks(), this.edit.totalDuration); throwIfAborted(); let audioSource: AudioBufferSource | null = null; if (mixedAudio) { @@ -166,9 +148,8 @@ export class ExportCoordinator { throw error instanceof ExportError ? error : new ExportError(`Export failed: ${error}`, "export"); } finally { this.isExporting = false; - this.edit.setExportMode(false); - this.canvas.resumeTicker(); - this.restoreEditState(savedState); + // Releases decoders the checks opened when the export stops before drawing a frame. + this.videoProcessor.restore(); } } @@ -178,7 +159,7 @@ export class ExportCoordinator { const issues = findEditIssues(this.edit); const browserIssue = await findBrowserIssue(cfg.size, cfg.bitrate); if (browserIssue) issues.push(browserIssue); - issues.push(...(await this.videoProcessor.initialize(this.exportCommand.getClips()))); + issues.push(...(await this.videoProcessor.initialize(this.edit.getTracks().flat()))); if (issues.length > 0) { throw new ExportError("This edit can't be exported in the browser", "unsupported", {}, undefined, issues); } @@ -199,44 +180,54 @@ export class ExportCoordinator { throwIfAborted: () => void ): Promise { const container = this.edit.getViewportContainer(); - this.edit.pause(); - Object.assign(container.position, { x: 0, y: 0 }); - Object.assign(container.scale, { x: 1, y: 1 }); - container.visible = true; - - const players = this.videoProcessor.disableVideoPlayback(this.exportCommand.getClips()); + const clips = this.edit.getTracks().flat(); + const { isPlaying: wasPlaying, playbackTime } = this.edit; - for (let i = 0; i < cfg.frames; i += 1) { - throwIfAborted(); - const frameTime = i * cfg.frameDuration; - this.edit.playbackTime = sec(frameTime); + // Each frame moves every clip to that frame's time. Stopping the app keeps the screen on the last + // frame it drew, so the user never sees those moves; extract renders off-screen either way. + this.app.stop(); + this.edit.pause(); + const players = this.videoProcessor.disableVideoPlayback(clips); - for (const clip of this.exportCommand.getClips()) { - clip.update(0, 0); - } + try { + for (let i = 0; i < cfg.frames; i += 1) { + throwIfAborted(); + const frameTime = i * cfg.frameDuration; + this.edit.playbackTime = sec(frameTime); - for (const player of players) { - const start = player.getStart?.() || 0; - const end = player.getEnd?.() || start + (player.getLength?.() || 0); - if (frameTime >= start && frameTime < end) { - await this.videoProcessor.replaceVideoTexture(player, frameTime); + for (const clip of clips) { + clip.update(0, 0); } - } - this.app.renderer.render(this.app.stage); + for (const player of players) { + const start = player.getStart?.() || 0; + const end = player.getEnd?.() || start + (player.getLength?.() || 0); + if (frameTime >= start && frameTime < end) { + await this.videoProcessor.replaceVideoTexture(player, frameTime); + } + } - const pixels = this.app.renderer.extract.pixels({ - target: container, - frame: new pixi.Rectangle(0, 0, cfg.size.width, cfg.size.height) - }); + // 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 + }); - const imageData = new ImageData(new Uint8ClampedArray(pixels.pixels), pixels.width, pixels.height); + const imageData = new ImageData(new Uint8ClampedArray(pixels.pixels), pixels.width, pixels.height); - ctx.putImageData(imageData, 0, 0); + ctx.putImageData(imageData, 0, 0); - await videoSource.add(i / cfg.fps, 1 / cfg.fps); + await videoSource.add(i / cfg.fps, 1 / cfg.fps); - report(0.25 + ((i + 1) / cfg.frames) * 0.75, "Exporting..."); + report(0.25 + ((i + 1) / cfg.frames) * 0.75, "Exporting..."); + } + } finally { + this.videoProcessor.restore(); + this.edit.seek(sec(playbackTime)); + if (wasPlaying) this.edit.play(); + this.app.start(); } } @@ -266,27 +257,4 @@ export class ExportCoordinator { frameDuration: 1 / fps // seconds per frame }; } - - private saveEditState(): EditState { - const c = this.edit.getViewportContainer(); - return { - wasPlaying: this.edit.isPlaying, - time: this.edit.playbackTime, - visible: c.visible, - pos: { x: c.position.x, y: c.position.y }, - scale: { x: c.scale.x, y: c.scale.y } - }; - } - - private restoreEditState(state: EditState): void { - const c = this.edit.getViewportContainer(); - this.edit.setExportMode(false); - this.videoProcessor.restore(); - - Object.assign(c.position, state.pos); - Object.assign(c.scale, state.scale); - c.visible = state.visible; - this.edit.seek(sec(state.time)); - if (state.wasPlaying) this.edit.play(); - } } diff --git a/tests/export-coordinator.test.ts b/tests/export-coordinator.test.ts index ad4cf812..675c8f77 100644 --- a/tests/export-coordinator.test.ts +++ b/tests/export-coordinator.test.ts @@ -73,21 +73,25 @@ function createHarness() { getTracks: () => edit.tracks, getClipError: jest.fn<{ error: string; assetType: string } | null, [number, number]>(() => null), setExportMode: jest.fn(), - executeEditCommand: jest.fn((command: { execute: (context: unknown) => unknown }) => - command.execute({ getClips: () => [], getTracks: () => [] }) - ), + executeEditCommand: jest.fn(), getViewportContainer: () => viewport, pause: jest.fn(), play: jest.fn(), seek: jest.fn() }; + const viewAtExtract: Array = []; const renderer = { render: jest.fn(), - extract: { pixels: jest.fn(() => ({ pixels: new Uint8ClampedArray(SIZE.width * SIZE.height * 4), width: SIZE.width, height: SIZE.height })) } + extract: { + pixels: jest.fn(() => { + viewAtExtract.push(JSON.parse(JSON.stringify(viewport))); + return { pixels: new Uint8ClampedArray(SIZE.width * SIZE.height * 4), width: SIZE.width, height: SIZE.height }; + }) + } }; - const canvas = { application: { renderer }, pauseTicker: jest.fn(), resumeTicker: jest.fn() }; - const exporter = new ExportCoordinator(edit as unknown as Edit, canvas as unknown as Canvas); - return { edit, canvas, renderer, viewport, exporter }; + const app = { renderer, stop: jest.fn(), start: jest.fn() }; + const exporter = new ExportCoordinator(edit as unknown as Edit, { application: app } as unknown as Canvas); + return { edit, app, renderer, viewport, viewAtExtract, exporter }; } beforeAll(() => { @@ -142,12 +146,12 @@ describe("ExportCoordinator.exportBlob", () => { controller.abort(); await expect(exporter.exportBlob({ signal: controller.signal })).rejects.toMatchObject({ name: "ExportError", phase: "cancelled" }); - expect(edit.setExportMode).not.toHaveBeenCalled(); + expect(edit.whenSettled).not.toHaveBeenCalled(); exporter.dispose(); }); it("stops between frames when aborted, cancels the output and restores the session", async () => { - const { exporter, edit, canvas, renderer } = createHarness(); + const { exporter, edit, app, renderer } = createHarness(); const controller = new AbortController(); const onProgress = jest.fn((fraction: number) => { if (fraction > 0.25) controller.abort(); @@ -159,8 +163,8 @@ describe("ExportCoordinator.exportBlob", () => { expect((error as ExportError).phase).toBe("cancelled"); expect(renderer.extract.pixels).toHaveBeenCalledTimes(1); expect(mockOutputs[0].cancel).toHaveBeenCalled(); - expect(edit.setExportMode).toHaveBeenLastCalledWith(false); - expect(canvas.resumeTicker).toHaveBeenCalled(); + expect(edit.seek).toHaveBeenCalledWith(1.5); + expect(app.start).toHaveBeenCalled(); exporter.dispose(); }); @@ -257,6 +261,48 @@ describe("ExportCoordinator.exportBlob", () => { }); }); +describe("off-screen export", () => { + it("draws each frame from the viewport container at output size while the view stays where the user left it", async () => { + const { exporter, edit, renderer, viewport, viewAtExtract } = createHarness(); + const before = JSON.parse(JSON.stringify(viewport)); + + 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(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(); + }); + + it("holds the on-screen frame while drawing, then puts the playhead back", async () => { + const { exporter, edit, app, renderer } = createHarness(); + + await exporter.exportBlob(); + + const extracts = renderer.extract.pixels.mock.invocationCallOrder; + expect(app.stop.mock.invocationCallOrder[0]).toBeLessThan(extracts[0]); + expect(app.start.mock.invocationCallOrder[0]).toBeGreaterThan(extracts[extracts.length - 1]); + expect(edit.seek).toHaveBeenCalledWith(1.5); + expect(edit.play).not.toHaveBeenCalled(); + exporter.dispose(); + }); + + it("resumes playback that was running when the export started", async () => { + const { exporter, edit } = createHarness(); + edit.isPlaying = true; + + await exporter.exportBlob(); + + expect(edit.pause).toHaveBeenCalled(); + expect(edit.seek.mock.invocationCallOrder[0]).toBeLessThan(edit.play.mock.invocationCallOrder[0]); + exporter.dispose(); + }); +}); + describe("ExportCoordinator.export", () => { it("downloads the file under the given name behind its overlay", async () => { const { exporter } = createHarness(); From ea463e7a40e9ce679e178acbed94a00a4513f351 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 19 Oct 2026 21:13:04 +1100 Subject: [PATCH 2/3] fix(export): keep export errors intact when setup, cancel or download fails --- src/core/export/export-coordinator.ts | 24 ++++++++++----- tests/export-coordinator.test.ts | 43 ++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/src/core/export/export-coordinator.ts b/src/core/export/export-coordinator.ts index 0b4df0c6..94617594 100644 --- a/src/core/export/export-coordinator.ts +++ b/src/core/export/export-coordinator.ts @@ -76,12 +76,16 @@ export class ExportCoordinator { onProgress: (fraction, stage) => this.progressUI.update(fraction * 100, 100, stage) }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); + try { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + } catch (error) { + throw new ExportError(`Export failed: ${error}`, "export"); + } this.progressUI.update(100, 100, "Complete!"); } finally { @@ -98,9 +102,10 @@ export class ExportCoordinator { const report: Progress = (fraction, stage) => onProgress?.(fraction, stage); this.isExporting = true; - const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() }); + let output: Output | undefined; try { + output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() }); const cfg = this.prepareConfig(fps); report(0, "Preparing..."); @@ -144,7 +149,10 @@ export class ExportCoordinator { return new Blob([data], { type: "video/mp4" }); } catch (error) { // An export that stops early would otherwise hold its encoders until garbage collection. - if (output.state === "pending" || output.state === "started") await output.cancel(); + if (output && (output.state === "pending" || output.state === "started")) { + // A failed cancel must not replace the error that stopped the export. + await output.cancel().catch(() => {}); + } throw error instanceof ExportError ? error : new ExportError(`Export failed: ${error}`, "export"); } finally { this.isExporting = false; diff --git a/tests/export-coordinator.test.ts b/tests/export-coordinator.test.ts index 675c8f77..8d97e310 100644 --- a/tests/export-coordinator.test.ts +++ b/tests/export-coordinator.test.ts @@ -5,7 +5,7 @@ import type { Edit } from "@core/edit-session"; import { ASSET_SETTLE_TIMEOUT_MS, ExportCoordinator } from "@core/export/export-coordinator"; import { ExportProgressUI } from "@core/export/export-progress-ui"; import { ExportError } from "@core/export/export-utils"; -import { canEncodeVideo, CanvasSource } from "mediabunny"; +import { canEncodeVideo, CanvasSource, Output } from "mediabunny"; const mockOutputs: Array<{ cancel: jest.Mock; state: string }> = []; @@ -251,6 +251,35 @@ describe("ExportCoordinator.exportBlob", () => { jest.useRealTimers(); }); + it("releases the single-flight flag when the output can't be constructed", async () => { + const { exporter } = createHarness(); + jest.mocked(Output).mockImplementationOnce(() => { + throw new Error("no muxer"); + }); + + await expect(exporter.exportBlob()).rejects.toMatchObject({ phase: "export" }); + await expect(exporter.exportBlob()).resolves.toBeInstanceOf(Blob); + exporter.dispose(); + }); + + it("keeps the original error when cancelling the output also fails", async () => { + const { exporter } = createHarness(); + const controller = new AbortController(); + jest.mocked(CanvasSource).mockImplementationOnce(() => { + controller.abort(); + return { add: jest.fn() } as unknown as CanvasSource; + }); + const construct = jest.mocked(Output).getMockImplementation() as unknown as (...a: unknown[]) => { cancel: jest.Mock }; + jest.mocked(Output).mockImplementationOnce((...args: unknown[]) => { + const output = construct(...args); + output.cancel.mockRejectedValue(new Error("cancel failed")); + return output as never; + }); + + await expect(exporter.exportBlob({ signal: controller.signal })).rejects.toMatchObject({ phase: "cancelled" }); + exporter.dispose(); + }); + it("refuses a second export while one is running", async () => { const { exporter } = createHarness(); const first = exporter.exportBlob(); @@ -304,6 +333,18 @@ describe("off-screen export", () => { }); describe("ExportCoordinator.export", () => { + it("wraps a failed download as an export-phase error and removes the overlay", async () => { + const { exporter } = createHarness(); + const create = jest.fn(() => { + throw new Error("blocked"); + }); + Object.assign(URL, { createObjectURL: create }); + + await expect(exporter.export()).rejects.toMatchObject({ name: "ExportError", phase: "export" }); + expect(document.body.children).toHaveLength(0); + exporter.dispose(); + }); + it("downloads the file under the given name behind its overlay", async () => { const { exporter } = createHarness(); const downloads: string[] = []; From 3561961be2ffdcb91cae1b73eab270b2f51b13b2 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 19 Oct 2026 21:23:49 +1100 Subject: [PATCH 3/3] fix(export): restart the editor even when restoring playback throws --- src/core/export/export-coordinator.ts | 9 ++++--- tests/export-coordinator.test.ts | 36 ++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/core/export/export-coordinator.ts b/src/core/export/export-coordinator.ts index 94617594..87192afb 100644 --- a/src/core/export/export-coordinator.ts +++ b/src/core/export/export-coordinator.ts @@ -193,11 +193,13 @@ export class ExportCoordinator { // Each frame moves every clip to that frame's time. Stopping the app keeps the screen on the last // frame it drew, so the user never sees those moves; extract renders off-screen either way. + const tickerWasRunning = this.app.ticker.started; this.app.stop(); - this.edit.pause(); - const players = this.videoProcessor.disableVideoPlayback(clips); try { + this.edit.pause(); + const players = this.videoProcessor.disableVideoPlayback(clips); + for (let i = 0; i < cfg.frames; i += 1) { throwIfAborted(); const frameTime = i * cfg.frameDuration; @@ -232,10 +234,11 @@ export class ExportCoordinator { report(0.25 + ((i + 1) / cfg.frames) * 0.75, "Exporting..."); } } finally { + // First, so a throw while restoring playback can't leave the editor frozen. start() only schedules a frame. + if (tickerWasRunning) this.app.start(); this.videoProcessor.restore(); this.edit.seek(sec(playbackTime)); if (wasPlaying) this.edit.play(); - this.app.start(); } } diff --git a/tests/export-coordinator.test.ts b/tests/export-coordinator.test.ts index 8d97e310..125ebb35 100644 --- a/tests/export-coordinator.test.ts +++ b/tests/export-coordinator.test.ts @@ -89,7 +89,7 @@ function createHarness() { }) } }; - const app = { renderer, stop: jest.fn(), start: jest.fn() }; + const app = { renderer, ticker: { started: true }, stop: jest.fn(), start: jest.fn() }; const exporter = new ExportCoordinator(edit as unknown as Edit, { application: app } as unknown as Canvas); return { edit, app, renderer, viewport, viewAtExtract, exporter }; } @@ -332,6 +332,40 @@ describe("off-screen export", () => { }); }); +describe("editor restart", () => { + it("restarts the app and rejects when pausing playback throws", async () => { + const { exporter, edit, app } = createHarness(); + edit.pause.mockImplementationOnce(() => { + throw new Error("listener failed"); + }); + + await expect(exporter.exportBlob()).rejects.toMatchObject({ phase: "export" }); + expect(app.start).toHaveBeenCalled(); + exporter.dispose(); + }); + + it("restarts the app even when restoring the playhead throws", async () => { + const { exporter, edit, app } = createHarness(); + edit.seek.mockImplementationOnce(() => { + throw new Error("clip update failed"); + }); + + await expect(exporter.exportBlob()).rejects.toMatchObject({ phase: "export" }); + expect(app.start).toHaveBeenCalled(); + exporter.dispose(); + }); + + it("leaves an app that was already stopped stopped", async () => { + const { exporter, app } = createHarness(); + app.ticker.started = false; + + await exporter.exportBlob(); + + expect(app.start).not.toHaveBeenCalled(); + exporter.dispose(); + }); +}); + describe("ExportCoordinator.export", () => { it("wraps a failed download as an export-phase error and removes the overlay", async () => { const { exporter } = createHarness();