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
17 changes: 17 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}`.
Expand Down
78 changes: 56 additions & 22 deletions src/core/export/export-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -42,29 +49,60 @@ export class ExportCoordinator {
document.addEventListener("keydown", this.handleKeyDown);
}

async exportBlob(options: ExportBlobOptions = {}): Promise<Blob> {
return this.encode(this.edit.getEdit().output?.fps ?? 30, options);
}

async export(filename = "shotstack-export.mp4", fps?: number): Promise<void> {
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<Blob> {
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;
Expand All @@ -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 });
Expand All @@ -85,43 +124,37 @@ 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);
}
}

private async processFrames(
cfg: ExportConfig,
videoSource: CanvasSource,
_canvas: HTMLCanvasElement,
ctx: CanvasRenderingContext2D
ctx: CanvasRenderingContext2D,
report: Progress,
throwIfAborted: () => void
): Promise<void> {
const container = this.edit.getViewportContainer();
this.edit.pause();
Expand All @@ -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);

Expand Down Expand Up @@ -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...");
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion test-package.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
182 changes: 182 additions & 0 deletions tests/export-coordinator.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading