diff --git a/readme.md b/readme.md index 6e56e4c9..7ed0eda4 100644 --- a/readme.md +++ b/readme.md @@ -337,6 +337,23 @@ const exporter = new VideoExporter(edit, canvas); await exporter.export("my-video.mp4", 25); ``` +`exportBlob()` runs the same export without the progress overlay or the download and resolves with the MP4 `Blob`. `onProgress` receives a fraction from 0 to 1 and a stage label. Aborting the signal rejects with an `ExportError` whose `phase` is `cancelled`. + +```typescript +import { ExportError } from "@shotstack/shotstack-studio"; + +const controller = new AbortController(); +const file = await exporter + .exportBlob({ + signal: controller.signal, + onProgress: (fraction, stage) => console.log(`${Math.round(fraction * 100)}% ${stage}`) + }) + .catch(error => { + if (error instanceof ExportError && error.phase === "cancelled") return null; + throw error; + }); +``` + ## Merge Fields Merge fields are template placeholders, typically in the form `{{ FIELD_NAME }}`. diff --git a/src/core/export/export-coordinator.ts b/src/core/export/export-coordinator.ts index b11856ad..1a27b830 100644 --- a/src/core/export/export-coordinator.ts +++ b/src/core/export/export-coordinator.ts @@ -25,6 +25,13 @@ interface EditState { scale: { x: number; y: number }; } +export interface ExportBlobOptions { + signal?: AbortSignal; + onProgress?: (fraction: number, stage: string) => void; +} + +type Progress = (fraction: number, stage: string) => void; + export class ExportCoordinator { private readonly edit: Edit; private readonly canvas: Canvas; @@ -42,29 +49,60 @@ export class ExportCoordinator { document.addEventListener("keydown", this.handleKeyDown); } + async exportBlob(options: ExportBlobOptions = {}): Promise { + return this.encode(this.edit.getEdit().output?.fps ?? 30, options); + } + async export(filename = "shotstack-export.mp4", fps?: number): Promise { + if (this.isExporting) throw new ExportError("Export in progress", "init"); + + this.progressUI.create(); + try { + const blob = await this.encode(fps ?? this.edit.getEdit().output?.fps ?? 30, { + 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); + + this.progressUI.update(100, 100, "Complete!"); + } finally { + this.progressUI.remove(); + } + } + + private async encode(fps: number, { signal, onProgress }: ExportBlobOptions): Promise { if (typeof VideoEncoder === "undefined") { throw new BrowserCompatibilityError("WebCodecs API not supported", ["VideoEncoder"]); } if (this.isExporting) throw new ExportError("Export in progress", "init"); + const throwIfAborted = (): void => { + if (signal?.aborted) throw new ExportError("Export cancelled", "cancelled"); + }; + throwIfAborted(); + 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.progressUI.create(); this.canvas.pauseTicker(); this.edit.executeEditCommand(this.exportCommand); - const cfg = this.prepareConfig(fps ?? this.edit.getEdit().output?.fps ?? 30); - this.progressUI.update(0, 100, "Preparing..."); + const cfg = this.prepareConfig(fps); + report(0, "Preparing..."); await this.videoProcessor.initialize(this.exportCommand.getClips()); - this.progressUI.update(10, 100, "Video ready"); + throwIfAborted(); + report(0.1, "Video ready"); - const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() }); const canvas = document.createElement("canvas"); canvas.width = cfg.size.width; canvas.height = cfg.size.height; @@ -74,8 +112,9 @@ export class ExportCoordinator { const videoSource = new CanvasSource(canvas, { codec: "avc", bitrate: 5_000_000 }); output.addVideoTrack(videoSource); - this.progressUI.update(15, 100, "Mixing audio..."); + report(0.15, "Mixing audio..."); const mixedAudio = await this.audioProcessor.renderMix(this.exportCommand.getTracks(), this.edit.totalDuration); + throwIfAborted(); let audioSource: AudioBufferSource | null = null; if (mixedAudio) { audioSource = new AudioBufferSource({ codec: "aac", bitrate: 192_000 }); @@ -85,34 +124,27 @@ export class ExportCoordinator { await output.start(); if (audioSource && mixedAudio) { - this.progressUI.update(20, 100, "Encoding audio..."); + report(0.2, "Encoding audio..."); await audioSource.add(mixedAudio); } - this.progressUI.update(25, 100, "Exporting..."); - await this.processFrames(cfg, videoSource, canvas, ctx); + report(0.25, "Exporting..."); + await this.processFrames(cfg, videoSource, ctx, report, throwIfAborted); await output.finalize(); const data = (output.target as BufferTarget).buffer; if (!data) throw new Error("No video data"); - const blob = new Blob([data], { type: "video/mp4" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); - - this.progressUI.update(100, 100, "Complete!"); + 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(); throw error instanceof ExportError ? error : new ExportError(`Export failed: ${error}`, "export"); } finally { this.isExporting = false; this.edit.setExportMode(false); this.canvas.resumeTicker(); - this.progressUI.remove(); this.restoreEditState(savedState); } } @@ -120,8 +152,9 @@ export class ExportCoordinator { private async processFrames( cfg: ExportConfig, videoSource: CanvasSource, - _canvas: HTMLCanvasElement, - ctx: CanvasRenderingContext2D + ctx: CanvasRenderingContext2D, + report: Progress, + throwIfAborted: () => void ): Promise { const container = this.edit.getViewportContainer(); this.edit.pause(); @@ -132,6 +165,7 @@ export class ExportCoordinator { const players = this.videoProcessor.disableVideoPlayback(this.exportCommand.getClips()); for (let i = 0; i < cfg.frames; i += 1) { + throwIfAborted(); const frameTime = i * cfg.frameDuration; this.edit.playbackTime = sec(frameTime); @@ -160,7 +194,7 @@ export class ExportCoordinator { await videoSource.add(i / cfg.fps, 1 / cfg.fps); - this.progressUI.update(25 + Math.round(((i + 1) / cfg.frames) * 75), 100, "Exporting..."); + report(0.25 + ((i + 1) / cfg.frames) * 0.75, "Exporting..."); } } diff --git a/src/index.ts b/src/index.ts index e3b4b33c..e51b65a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import pkg from "../package.json"; export { Edit } from "@core/edit-session"; export { Canvas } from "@canvas/shotstack-canvas"; export { Controls } from "@core/inputs/controls"; -export { VideoExporter } from "@core/export"; +export { VideoExporter, ExportError } from "@core/export"; export { Timeline } from "@timeline/index"; export { UIController } from "@core/ui/ui-controller"; export { WebGLUnsupportedError } from "@core/webgl-support"; diff --git a/test-package.js b/test-package.js index 4667721d..46275266 100644 --- a/test-package.js +++ b/test-package.js @@ -26,7 +26,7 @@ const CONTRACT = { require: "./dist/internal.umd.js" } }, - runtimeExports: ["Edit", "Canvas", "Controls", "Timeline", "UIController", "VideoExporter", "VERSION"], + runtimeExports: ["Edit", "Canvas", "Controls", "Timeline", "UIController", "VideoExporter", "ExportError", "VERSION"], internalRuntimeExports: ["Edit", "ShotstackEdit", "registerGenerationSettings", "registerGenerationStatus"], dtsHiddenMembersByClass: { UIController: [ diff --git a/tests/export-coordinator.test.ts b/tests/export-coordinator.test.ts new file mode 100644 index 00000000..279e4da0 --- /dev/null +++ b/tests/export-coordinator.test.ts @@ -0,0 +1,182 @@ +/** @jest-environment jsdom */ + +import type { Canvas } from "@canvas/shotstack-canvas"; +import type { Edit } from "@core/edit-session"; +import { ExportCoordinator } from "@core/export/export-coordinator"; +import { ExportProgressUI } from "@core/export/export-progress-ui"; +import { ExportError } from "@core/export/export-utils"; + +const mockOutputs: Array<{ cancel: jest.Mock; state: string }> = []; + +jest.mock("mediabunny", () => ({ + Output: jest.fn().mockImplementation(({ target }: { target: { buffer: ArrayBuffer | null } }) => { + const output = { + target, + state: "pending", + addVideoTrack: jest.fn(), + addAudioTrack: jest.fn(), + start: jest.fn(async () => { + output.state = "started"; + }), + finalize: jest.fn(async () => { + output.state = "finalized"; + // eslint-disable-next-line no-param-reassign -- the fake target receives the muxed bytes + target.buffer = new ArrayBuffer(8); + }), + cancel: jest.fn(async () => { + output.state = "canceled"; + }) + }; + mockOutputs.push(output); + return output; + }), + Mp4OutputFormat: jest.fn(), + BufferTarget: jest.fn().mockImplementation(() => ({ buffer: null })), + CanvasSource: jest.fn().mockImplementation(() => ({ add: jest.fn().mockResolvedValue(undefined) })), + AudioBufferSource: jest.fn(), + Input: jest.fn(), + UrlSource: jest.fn(), + CanvasSink: jest.fn(), + MP4: {}, + WEBM: {} +})); + +jest.mock("pixi.js", () => ({ + Rectangle: jest.fn().mockImplementation((x: number, y: number, width: number, height: number) => ({ x, y, width, height })), + Texture: { from: jest.fn() } +})); + +jest.mock("../src/core/export/audio-processor", () => ({ + AudioProcessor: jest.fn().mockImplementation(() => ({ renderMix: jest.fn().mockResolvedValue(null) })) +})); + +const SIZE = { width: 4, height: 2 }; + +function createHarness() { + const viewport = { position: { x: 120, y: 80 }, scale: { x: 0.5, y: 0.5 }, visible: true }; + const edit = { + isPlaying: false, + playbackTime: 1.5, + totalDuration: 1, + getEdit: () => ({ output: { fps: 2, size: SIZE } }), + setExportMode: jest.fn(), + executeEditCommand: jest.fn((command: { execute: (context: unknown) => unknown }) => + command.execute({ getClips: () => [], getTracks: () => [] }) + ), + getViewportContainer: () => viewport, + pause: jest.fn(), + play: jest.fn(), + seek: jest.fn() + }; + const renderer = { + render: jest.fn(), + extract: { pixels: jest.fn(() => ({ 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 }; +} + +beforeAll(() => { + // jsdom has no WebCodecs or 2D canvas; the coordinator only needs them to exist. + Object.assign(globalThis, { + VideoEncoder: jest.fn(), + ImageData: jest.fn((data: Uint8ClampedArray, width: number, height: number) => ({ data, width, height })) + }); + Object.assign(HTMLCanvasElement.prototype, { getContext: () => ({ putImageData: jest.fn() }) }); +}); + +beforeEach(() => { + mockOutputs.length = 0; + document.body.innerHTML = ""; +}); + +describe("ExportCoordinator.exportBlob", () => { + it("resolves to an MP4 blob without an overlay or a download", async () => { + const { exporter } = createHarness(); + const click = jest.spyOn(HTMLAnchorElement.prototype, "click"); + + const blob = await exporter.exportBlob(); + + expect(blob).toBeInstanceOf(Blob); + expect(blob.type).toBe("video/mp4"); + expect(click).not.toHaveBeenCalled(); + expect(document.body.children).toHaveLength(0); + exporter.dispose(); + }); + + it("reports the existing stages as fractions, ending at the last frame", async () => { + const { exporter } = createHarness(); + const onProgress = jest.fn(); + + await exporter.exportBlob({ onProgress }); + + expect(onProgress.mock.calls).toEqual([ + [0, "Preparing..."], + [0.1, "Video ready"], + [0.15, "Mixing audio..."], + [0.25, "Exporting..."], + [0.625, "Exporting..."], + [1, "Exporting..."] + ]); + exporter.dispose(); + }); + + it("rejects with phase cancelled when the signal is already aborted", async () => { + const { exporter, edit } = createHarness(); + const controller = new AbortController(); + controller.abort(); + + await expect(exporter.exportBlob({ signal: controller.signal })).rejects.toMatchObject({ name: "ExportError", phase: "cancelled" }); + expect(edit.setExportMode).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 controller = new AbortController(); + const onProgress = jest.fn((fraction: number) => { + if (fraction > 0.25) controller.abort(); + }); + + const error = await exporter.exportBlob({ signal: controller.signal, onProgress }).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(ExportError); + 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(); + exporter.dispose(); + }); + + it("refuses a second export while one is running", async () => { + const { exporter } = createHarness(); + const first = exporter.exportBlob(); + + await expect(exporter.exportBlob()).rejects.toMatchObject({ phase: "init" }); + await first; + exporter.dispose(); + }); +}); + +describe("ExportCoordinator.export", () => { + it("downloads the file under the given name behind its overlay", async () => { + const { exporter } = createHarness(); + const downloads: string[] = []; + jest.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function recordDownload(this: HTMLAnchorElement) { + downloads.push(this.download); + }); + Object.assign(URL, { createObjectURL: jest.fn(() => "blob:export"), revokeObjectURL: jest.fn() }); + const create = jest.spyOn(ExportProgressUI.prototype, "create"); + const remove = jest.spyOn(ExportProgressUI.prototype, "remove"); + + await exporter.export("my-video.mp4", 2); + + expect(downloads).toEqual(["my-video.mp4"]); + expect(create).toHaveBeenCalledTimes(1); + expect(remove).toHaveBeenCalled(); + expect(document.body.children).toHaveLength(0); + exporter.dispose(); + }); +});