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
16 changes: 16 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,22 @@ const file = await exporter
});
```

Before encoding, both methods check that the browser can draw the edit the way a cloud render would. If it can't, they reject with an `ExportError` whose `phase` is `unsupported` and whose `issues` list each problem as `{ code, message, clipId? }`. `clipId` is the clip's `id` as returned by `edit.getEdit({ includeIds: true })`. A clip that failed to load, or whose asset is still loading after 30 seconds, rejects with `phase` `assets` and its `clipId` in `context`.

| Code | Reason |
| --- | --- |
| `ai-pending` | A prompt clip hasn't been generated yet |
| `output-format` | `output.format` isn't `mp4` |
| `video-source` | The browser can't decode a video (MP4 and WebM only) |
| `chroma-key` | A video uses chroma key |
| `filter` | A clip uses a filter |
| `html` | An `html` clip that isn't the plain text form, or any `html5` clip |
| `effect` | An effect without a preset in the editor |
| `transition` | A transition without a preset in the editor |
| `font` | A font that is neither built in nor listed in `timeline.fonts` |
| `too-long` | The edit runs longer than five minutes |
| `browser` | The browser can't encode H.264 at the output size |

## Merge Fields

Merge fields are template placeholders, typically in the form `{{ FIELD_NAME }}`.
Expand Down
3 changes: 2 additions & 1 deletion src/components/canvas/players/html-player.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Edit } from "@core/edit-session";
import { isTextHtml } from "@core/shared/asset-utils";
import { type Size } from "@layouts/geometry";
import { type ResolvedClip, type HtmlAsset, HtmlAssetPosition } from "@schemas";
import * as pixiFilters from "pixi-filters";
Expand Down Expand Up @@ -160,7 +161,7 @@ export class HtmlPlayer extends Player {
const htmlAsset = this.clipConfiguration.asset as HtmlAsset;
const { html, css = "", position } = htmlAsset;

if (!html.includes('data-html-type="text"')) {
if (!isTextHtml(html)) {
console.warn("Unsupported html format.");
return null;
}
Expand Down
21 changes: 2 additions & 19 deletions src/components/canvas/players/rich-text-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Edit } from "@core/edit-session";
import { InternalEvent } from "@core/events/edit-events";
import { parseFontFamily, resolveFontPath } from "@core/fonts/font-config";
import { extractFontNames, isGoogleFontUrl } from "@core/fonts/font-utils";
import { NAMED_FONT_WEIGHTS } from "@core/fonts/font-weights";
import { type Size, type Vector } from "@layouts/geometry";
import { RichTextAssetSchema, type RichTextAsset, type ResolvedClip } from "@schemas";
import { createTextEngine, type CanvasRichTextAsset } from "@shotstack/shotstack-canvas";
Expand All @@ -14,24 +15,6 @@ type TextEngine = Awaited<ReturnType<typeof createTextEngine>>;

export class RichTextPlayer extends Player {
private static readonly PREVIEW_FPS = 60;
/** CSS font-weight string → numeric value. Extends WEIGHT_MODIFIERS (@core/fonts/font-config.ts) with CSS aliases. */
private static readonly NAMED_WEIGHTS: Record<string, number> = {
thin: 100,
hairline: 100,
extralight: 200,
ultralight: 200,
light: 300,
normal: 400,
regular: 400,
medium: 500,
semibold: 600,
demibold: 600,
bold: 700,
extrabold: 800,
ultrabold: 800,
black: 900,
heavy: 900
};
private static readonly fontCapabilityCache = new Map<string, Promise<boolean>>();
private static readonly fontBytesCache = new Map<string, Promise<ArrayBuffer>>();
private textEngine: TextEngine | null = null;
Expand Down Expand Up @@ -82,7 +65,7 @@ export class RichTextPlayer extends Player {
const explicitWeight = richTextAsset.font?.weight;
if (typeof explicitWeight === "number") return explicitWeight;
if (typeof explicitWeight === "string") {
const named = RichTextPlayer.NAMED_WEIGHTS[explicitWeight.toLowerCase().trim()];
const named = NAMED_FONT_WEIGHTS[explicitWeight.toLowerCase().trim()];
if (named !== undefined) return named;
const parsed = parseInt(explicitWeight, 10);
if (!Number.isNaN(parsed)) return parsed;
Expand Down
5 changes: 5 additions & 0 deletions src/core/edit-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2405,6 +2405,11 @@ export class Edit {
return this.playerReconciler.whenSettled();
}

/** @internal */
public getLoadingClipIds(): string[] {
return this.playerReconciler.getLoadingClipIds();
}

/**
* Capture the rendered output frame as a base64 data URL.
*
Expand Down
57 changes: 50 additions & 7 deletions src/core/export/export-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import { Output, Mp4OutputFormat, BufferTarget, CanvasSource, AudioBufferSource
import * as pixi from "pixi.js";

import { AudioProcessor } from "./audio-processor";
import { findBrowserIssue, findEditIssues } from "./export-preflight";
import { ExportProgressUI } from "./export-progress-ui";
import { ExportError, BrowserCompatibilityError } from "./export-utils";
import { ExportError } from "./export-utils";
import { VideoFrameProcessor } from "./video-frame-processor";

interface ExportConfig {
fps: number;
size: { width: number; height: number };
bitrate: number;
frames: number;
frameDuration: number;
}
Expand All @@ -32,6 +34,30 @@ export interface ExportBlobOptions {

type Progress = (fraction: number, stage: string) => void;

/** How long an export waits for in-flight asset loads before failing with phase `assets`. */
export const ASSET_SETTLE_TIMEOUT_MS = 30_000;

const cancelled = (): ExportError => new ExportError("Export cancelled", "cancelled");

// A stalled asset request can keep a load pending indefinitely, so the wait is bounded and a cancel ends it early.
function waitForLoads(edit: Pick<Edit, "whenSettled" | "getLoadingClipIds">, signal?: AbortSignal): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined;
let onAbort = (): void => {};
const stopped = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
const [clipId] = edit.getLoadingClipIds();
reject(new ExportError(`A clip's asset didn't finish loading within ${ASSET_SETTLE_TIMEOUT_MS / 1000} seconds`, "assets", { clipId }));
}, ASSET_SETTLE_TIMEOUT_MS);
onAbort = () => reject(cancelled());
if (signal?.aborted) onAbort();
else signal?.addEventListener("abort", onAbort, { once: true });
});
return Promise.race([edit.whenSettled(), stopped]).finally(() => {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
});
}

export class ExportCoordinator {
private readonly edit: Edit;
private readonly canvas: Canvas;
Expand Down Expand Up @@ -76,12 +102,9 @@ export class ExportCoordinator {
}

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");
if (signal?.aborted) throw cancelled();
};
throwIfAborted();
const report: Progress = (fraction, stage) => onProgress?.(fraction, stage);
Expand All @@ -99,7 +122,7 @@ export class ExportCoordinator {
const cfg = this.prepareConfig(fps);
report(0, "Preparing...");

await this.videoProcessor.initialize(this.exportCommand.getClips());
await this.checkExportable(cfg, signal);
throwIfAborted();
report(0.1, "Video ready");

Expand All @@ -109,7 +132,7 @@ export class ExportCoordinator {
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("No 2D context");

const videoSource = new CanvasSource(canvas, { codec: "avc", bitrate: 5_000_000 });
const videoSource = new CanvasSource(canvas, { codec: "avc", bitrate: cfg.bitrate });
output.addVideoTrack(videoSource);

report(0.15, "Mixing audio...");
Expand Down Expand Up @@ -149,6 +172,25 @@ export class ExportCoordinator {
}
}

private async checkExportable(cfg: ExportConfig, signal?: AbortSignal): Promise<void> {
await waitForLoads(this.edit, signal);

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())));
if (issues.length > 0) {
throw new ExportError("This edit can't be exported in the browser", "unsupported", {}, undefined, issues);
}

for (const [trackIndex, track] of this.edit.getTracks().entries()) {
for (const [clipIndex, player] of track.entries()) {
const failure = this.edit.getClipError(trackIndex, clipIndex)?.error ?? (player.needsResolution ? "its source isn't ready yet" : null);
if (failure) throw new ExportError(`A clip couldn't be loaded: ${failure}`, "assets", { clipId: player.clipId });
}
}
}

private async processFrames(
cfg: ExportConfig,
videoSource: CanvasSource,
Expand Down Expand Up @@ -219,6 +261,7 @@ export class ExportCoordinator {
return {
fps,
size,
bitrate: 5_000_000,
frames: Math.ceil(this.edit.totalDuration * fps),
frameDuration: 1 / fps // seconds per frame
};
Expand Down
79 changes: 79 additions & 0 deletions src/core/export/export-preflight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { EffectPresetBuilder } from "@animations/effect-preset-builder";
import { TransitionPresetBuilder } from "@animations/transition-preset-builder";
import type { Edit } from "@core/edit-session";
import { resolveFontPath } from "@core/fonts/font-config";
import { NAMED_FONT_WEIGHTS } from "@core/fonts/font-weights";
import { isPendingAiAsset } from "@core/shared/ai-asset-utils";
import { isTextHtml } from "@core/shared/asset-utils";
import type { ResolvedClip } from "@schemas";
import { canEncodeVideo } from "mediabunny";

import type { ExportIssue, ExportIssueCode } from "./export-utils";

/** The encoded file and the audio mix are both held in memory until the export finishes. */
export const MAX_EXPORT_SECONDS = 300;

type PreflightEdit = Pick<Edit, "getResolvedEdit" | "getOutputFormat" | "getFontUrlByFamilyAndWeight" | "totalDuration">;

const UNIT_SIZE = { width: 1, height: 1 };

// The preset builders return no keyframes for a name they don't know, which the preview draws as no animation.
const animates = (keyframeSet: object): boolean => Object.values(keyframeSet).some(value => Array.isArray(value) && value.length > 0);

// Mirrors RichTextPlayer's weight reading, so the check asks for the weight the preview registers.
function fontWeightOf(weight: unknown): number {
if (typeof weight === "number") return weight;
if (typeof weight !== "string") return 400;
const parsed = NAMED_FONT_WEIGHTS[weight.toLowerCase().trim()] ?? parseInt(weight, 10);
return Number.isNaN(parsed) ? 400 : parsed;
}

function findClipIssues(edit: PreflightEdit, clip: ResolvedClip): ExportIssue[] {
const issues: ExportIssue[] = [];
const add = (code: ExportIssueCode, message: string): void => {
issues.push({ code, message, clipId: clip.id });
};
const { asset } = clip;

if (isPendingAiAsset(asset)) add("ai-pending", "This clip hasn't been generated yet.");
if (asset.type === "video" && asset.chromaKey) add("chroma-key", "Chroma key isn't available in the browser.");
if (clip.filter && clip.filter !== "none") add("filter", `The ${clip.filter} filter isn't available in the browser.`);
if (asset.type === "html5" || (asset.type === "html" && !isTextHtml(asset.html))) add("html", "This HTML clip can only be rendered in the cloud.");
if (clip.effect && !animates(new EffectPresetBuilder(clip).buildRelative(UNIT_SIZE, UNIT_SIZE))) {
add("effect", `The ${clip.effect} effect isn't available in the browser.`);
}

const transitions = new TransitionPresetBuilder(clip).buildRelative();
for (const direction of ["in", "out"] as const) {
const name = clip.transition?.[direction];
if (name && name !== "none" && !animates(transitions[direction])) add("transition", `The ${name} transition isn't available in the browser.`);
}

if ("font" in asset && asset.font?.family) {
const { family } = asset.font;
const weight = fontWeightOf("weight" in asset.font ? asset.font.weight : undefined);
if (!resolveFontPath(family, weight) && !edit.getFontUrlByFamilyAndWeight(family, weight)) {
add("font", `The ${family} font isn't built in or listed in the edit's fonts.`);
}
}

return issues;
}

export function findEditIssues(edit: PreflightEdit): ExportIssue[] {
const issues: ExportIssue[] = [];
const format = edit.getOutputFormat();
if (format !== "mp4") issues.push({ code: "output-format", message: `The browser exports MP4 only, and this edit asks for ${format}.` });
if (edit.totalDuration > MAX_EXPORT_SECONDS) {
issues.push({ code: "too-long", message: `The browser exports up to ${MAX_EXPORT_SECONDS / 60} minutes of video.` });
}
for (const track of edit.getResolvedEdit().timeline.tracks) {
for (const clip of track.clips) issues.push(...findClipIssues(edit, clip));
}
return issues;
}

export async function findBrowserIssue(size: { width: number; height: number }, bitrate: number): Promise<ExportIssue | null> {
if (await canEncodeVideo("avc", { width: size.width, height: size.height, bitrate })) return null;
return { code: "browser", message: `This browser can't encode H.264 video at ${size.width}×${size.height}.` };
}
22 changes: 11 additions & 11 deletions src/core/export/export-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ export class SimpleLRUCache<T> extends Map<string, T> {
}
}

export type ExportIssueCode =
"ai-pending" | "output-format" | "video-source" | "chroma-key" | "filter" | "html" | "effect" | "transition" | "font" | "too-long" | "browser";

export interface ExportIssue {
code: ExportIssueCode;
message: string;
clipId?: string;
}

/**
* Export error with context
*/
Expand All @@ -42,20 +51,11 @@ export class ExportError extends Error {
message: string,
public readonly phase: string = "unknown",
public readonly context: Record<string, unknown> = {},
cause?: Error
cause?: Error,
public readonly issues?: ExportIssue[]
) {
super(message);
this.name = "ExportError";
this.cause = cause;
}
}

/**
* Browser compatibility error
*/
export class BrowserCompatibilityError extends ExportError {
constructor(message: string, missingFeatures: string[]) {
super(message, "initialization", { missingFeatures });
this.name = "BrowserCompatibilityError";
}
}
3 changes: 2 additions & 1 deletion src/core/export/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export { VideoFrameProcessor } from "./video-frame-processor";
export { AudioProcessor } from "./audio-processor";

// Export utilities and errors
export { SimpleLRUCache, ExportError, BrowserCompatibilityError } from "./export-utils";
export { SimpleLRUCache, ExportError } from "./export-utils";
export type { ExportIssue, ExportIssueCode } from "./export-utils";

// Export UI component
export { ExportProgressUI } from "./export-progress-ui";
Expand Down
Loading
Loading