From 6af90574f4bc22ae5f2705e0b75d5820eb701488 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 19 Oct 2026 21:06:13 +1100 Subject: [PATCH 1/2] feat(export): check the edit before encoding and fail on placeholders --- readme.md | 16 +++ src/components/canvas/players/html-player.ts | 3 +- src/core/edit-session.ts | 5 + src/core/export/export-coordinator.ts | 57 +++++++- src/core/export/export-preflight.ts | 70 +++++++++ src/core/export/export-utils.ts | 22 +-- src/core/export/index.ts | 3 +- src/core/export/video-frame-processor.ts | 42 +++--- src/core/player-reconciler.ts | 12 +- src/core/shared/asset-utils.ts | 5 + src/index.ts | 1 + tests/export-coordinator.test.ts | 103 +++++++++++++- tests/export-preflight.test.ts | 141 +++++++++++++++++++ 13 files changed, 435 insertions(+), 45 deletions(-) create mode 100644 src/core/export/export-preflight.ts create mode 100644 tests/export-preflight.test.ts diff --git a/readme.md b/readme.md index 7ed0eda4..7edbd035 100644 --- a/readme.md +++ b/readme.md @@ -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 }}`. diff --git a/src/components/canvas/players/html-player.ts b/src/components/canvas/players/html-player.ts index 4be6fdbe..4cdb4870 100644 --- a/src/components/canvas/players/html-player.ts +++ b/src/components/canvas/players/html-player.ts @@ -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"; @@ -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; } diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts index 22261058..11e43c57 100644 --- a/src/core/edit-session.ts +++ b/src/core/edit-session.ts @@ -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. * diff --git a/src/core/export/export-coordinator.ts b/src/core/export/export-coordinator.ts index 1a27b830..35f9a03b 100644 --- a/src/core/export/export-coordinator.ts +++ b/src/core/export/export-coordinator.ts @@ -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; } @@ -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, signal?: AbortSignal): Promise { + let timer: ReturnType | undefined; + let onAbort = (): void => {}; + const stopped = new Promise((_, 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; @@ -76,12 +102,9 @@ export class ExportCoordinator { } 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"); + if (signal?.aborted) throw cancelled(); }; throwIfAborted(); const report: Progress = (fraction, stage) => onProgress?.(fraction, stage); @@ -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"); @@ -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..."); @@ -149,6 +172,25 @@ export class ExportCoordinator { } } + private async checkExportable(cfg: ExportConfig, signal?: AbortSignal): Promise { + 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, @@ -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 }; diff --git a/src/core/export/export-preflight.ts b/src/core/export/export-preflight.ts new file mode 100644 index 00000000..46d22436 --- /dev/null +++ b/src/core/export/export-preflight.ts @@ -0,0 +1,70 @@ +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 { 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; + +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); + +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 = "weight" in asset.font && typeof asset.font.weight === "number" ? asset.font.weight : 400; + 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 { + 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}.` }; +} diff --git a/src/core/export/export-utils.ts b/src/core/export/export-utils.ts index cb70d8df..b65ed45d 100644 --- a/src/core/export/export-utils.ts +++ b/src/core/export/export-utils.ts @@ -34,6 +34,15 @@ export class SimpleLRUCache extends Map { } } +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 */ @@ -42,20 +51,11 @@ export class ExportError extends Error { message: string, public readonly phase: string = "unknown", public readonly context: Record = {}, - 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"; - } -} diff --git a/src/core/export/index.ts b/src/core/export/index.ts index ae7b5fd0..4e90329a 100644 --- a/src/core/export/index.ts +++ b/src/core/export/index.ts @@ -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"; diff --git a/src/core/export/video-frame-processor.ts b/src/core/export/video-frame-processor.ts index 8046a0c8..659bd9b0 100644 --- a/src/core/export/video-frame-processor.ts +++ b/src/core/export/video-frame-processor.ts @@ -2,9 +2,10 @@ import { Input, UrlSource, MP4, WEBM, CanvasSink, type InputVideoTrack } from "m import * as pixi from "pixi.js"; import { videoSourceTime } from "./export-timing"; -import { BrowserCompatibilityError } from "./export-utils"; +import type { ExportIssue } from "./export-utils"; export interface VideoPlayerExtended { + clipId?: string | null; texture?: pixi.Texture & { source?: { resource?: unknown; update?: () => void } }; sprite?: { texture?: pixi.Texture }; clipConfiguration?: { asset?: { src?: string; trim?: number; type?: string } }; @@ -41,32 +42,37 @@ interface ClipDecoder { export class VideoFrameProcessor { private decoders = new Map(); - /** Open a decoder per video clip; throws if the browser cannot decode a source. */ - async initialize(clips: ReadonlyArray): Promise { + /** Open a decoder per video clip; a source the browser can't decode comes back as an issue. */ + async initialize(clips: ReadonlyArray): Promise { const players = clips.filter(isVideoPlayer) as VideoPlayerExtended[]; + const issues: ExportIssue[] = []; await Promise.all( players.map(async player => { const src = this.getVideoKey(player); - if (src) { - const input = new Input({ source: new UrlSource(src), formats: [MP4, WEBM] }); + if (!src) return; + const input = new Input({ source: new UrlSource(src), formats: [MP4, WEBM] }); + try { const track = await input.getPrimaryVideoTrack(); - if (track) { - if (!(await track.canDecode())) { - throw new BrowserCompatibilityError(`Cannot decode video source: ${src}`, ["VideoDecoder"]); - } - this.decoders.set(player, { - input, - track, - sink: new CanvasSink(track, { poolSize: 2 }), - trim: player.clipConfiguration?.asset?.trim ?? 0, - start: player.getStart?.() ?? 0, - originalTexture: player.texture - }); - } + if (!track) return; + if (!(await track.canDecode())) throw new Error("No decoder for this codec"); + this.decoders.set(player, { + input, + track, + sink: new CanvasSink(track, { poolSize: 2 }), + trim: player.clipConfiguration?.asset?.trim ?? 0, + start: player.getStart?.() ?? 0, + originalTexture: player.texture + }); + } catch (error) { + input.dispose(); + console.warn("Export: can't decode video", src, error); + issues.push({ code: "video-source", message: "This video's format can't be decoded in the browser.", clipId: player.clipId ?? undefined }); } }) ); + + return issues; } /** Point a clip's texture at the decoded source frame for the given timeline time. */ diff --git a/src/core/player-reconciler.ts b/src/core/player-reconciler.ts index 97a23036..4953722c 100644 --- a/src/core/player-reconciler.ts +++ b/src/core/player-reconciler.ts @@ -37,8 +37,8 @@ export class PlayerReconciler { */ private enableCreation = true; - /** In-flight player load promises, so off-playback captures (captureFrame) can await asset readiness. */ - private readonly inFlightLoads = new Set>(); + /** In-flight player loads by clip id: captureFrame awaits them, and an export that gives up on one names its clip. */ + private readonly inFlightLoads = new Map, string>(); constructor(private readonly edit: Edit) { this.edit.getInternalEvents().on(InternalEvent.Resolved, this.onResolved); @@ -72,10 +72,14 @@ export class PlayerReconciler { */ public async whenSettled(): Promise { while (this.inFlightLoads.size > 0) { - await Promise.allSettled([...this.inFlightLoads]); + await Promise.allSettled([...this.inFlightLoads.keys()]); } } + public getLoadingClipIds(): string[] { + return [...this.inFlightLoads.values()]; + } + /** * Reconcile Players to match the ResolvedEdit. * @@ -252,7 +256,7 @@ export class PlayerReconciler { }); // Track the load so off-playback captures can await asset readiness (see whenSettled()). - this.inFlightLoads.add(loadPromise); + this.inFlightLoads.set(loadPromise, clipId); return loadPromise.finally(() => this.inFlightLoads.delete(loadPromise)); } diff --git a/src/core/shared/asset-utils.ts b/src/core/shared/asset-utils.ts index c55053e6..f02e6348 100644 --- a/src/core/shared/asset-utils.ts +++ b/src/core/shared/asset-utils.ts @@ -16,3 +16,8 @@ export function inferAssetTypeFromUrl(src: string): "image" | "video" { } return "image"; } + +/** The only HTML form HtmlPlayer can draw: marked-up text styled by a single CSS rule. */ +export function isTextHtml(html: string): boolean { + return html.includes('data-html-type="text"'); +} diff --git a/src/index.ts b/src/index.ts index e51b65a9..2b7b3bb3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export { Edit } from "@core/edit-session"; export { Canvas } from "@canvas/shotstack-canvas"; export { Controls } from "@core/inputs/controls"; export { VideoExporter, ExportError } from "@core/export"; +export type { ExportIssue, ExportIssueCode } from "@core/export"; export { Timeline } from "@timeline/index"; export { UIController } from "@core/ui/ui-controller"; export { WebGLUnsupportedError } from "@core/webgl-support"; diff --git a/tests/export-coordinator.test.ts b/tests/export-coordinator.test.ts index 279e4da0..ad4cf812 100644 --- a/tests/export-coordinator.test.ts +++ b/tests/export-coordinator.test.ts @@ -2,9 +2,10 @@ import type { Canvas } from "@canvas/shotstack-canvas"; import type { Edit } from "@core/edit-session"; -import { ExportCoordinator } from "@core/export/export-coordinator"; +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"; const mockOutputs: Array<{ cancel: jest.Mock; state: string }> = []; @@ -34,6 +35,7 @@ jest.mock("mediabunny", () => ({ BufferTarget: jest.fn().mockImplementation(() => ({ buffer: null })), CanvasSource: jest.fn().mockImplementation(() => ({ add: jest.fn().mockResolvedValue(undefined) })), AudioBufferSource: jest.fn(), + canEncodeVideo: jest.fn(), Input: jest.fn(), UrlSource: jest.fn(), CanvasSink: jest.fn(), @@ -46,6 +48,9 @@ jest.mock("pixi.js", () => ({ Texture: { from: jest.fn() } })); +// The canvas package needs TextEncoder, which jsdom lacks; the export only uses its font-name parser. +jest.mock("@shotstack/shotstack-canvas", () => ({ parseFontFamily: (family: string) => ({ family, weight: "400" }) })); + jest.mock("../src/core/export/audio-processor", () => ({ AudioProcessor: jest.fn().mockImplementation(() => ({ renderMix: jest.fn().mockResolvedValue(null) })) })); @@ -58,7 +63,15 @@ function createHarness() { isPlaying: false, playbackTime: 1.5, totalDuration: 1, + tracks: [] as Array>, getEdit: () => ({ output: { fps: 2, size: SIZE } }), + getOutputFormat: jest.fn(() => "mp4"), + getResolvedEdit: () => ({ timeline: { tracks: [] }, output: { format: "mp4" } }), + getFontUrlByFamilyAndWeight: () => null, + whenSettled: jest.fn(() => Promise.resolve()), + getLoadingClipIds: jest.fn(() => [] as string[]), + 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: () => [] }) @@ -78,9 +91,8 @@ function createHarness() { } beforeAll(() => { - // jsdom has no WebCodecs or 2D canvas; the coordinator only needs them to exist. + // jsdom has no 2D canvas; the coordinator only needs one 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() }) }); @@ -89,6 +101,8 @@ beforeAll(() => { beforeEach(() => { mockOutputs.length = 0; document.body.innerHTML = ""; + jest.mocked(canEncodeVideo).mockResolvedValue(true); + jest.mocked(CanvasSource).mockClear(); }); describe("ExportCoordinator.exportBlob", () => { @@ -150,6 +164,89 @@ describe("ExportCoordinator.exportBlob", () => { exporter.dispose(); }); + it("refuses an unsupported edit before encoding and lists every issue", async () => { + const { exporter, edit } = createHarness(); + edit.getOutputFormat.mockReturnValue("gif"); + jest.mocked(canEncodeVideo).mockResolvedValue(false); + + const error = await exporter.exportBlob().catch((e: unknown) => e); + + expect(error).toMatchObject({ phase: "unsupported" }); + expect((error as ExportError).issues?.map(issue => issue.code)).toEqual(["output-format", "browser"]); + expect(CanvasSource).not.toHaveBeenCalled(); + exporter.dispose(); + }); + + it("waits for loads to settle, then fails a clip left on its placeholder with phase assets", async () => { + const { exporter, edit } = createHarness(); + edit.tracks = [ + [ + { clipId: "clip-ok", needsResolution: false }, + { clipId: "clip-404", needsResolution: false } + ] + ]; + let settled = false; + edit.whenSettled.mockImplementation(async () => { + settled = true; + }); + edit.getClipError.mockImplementation((_track, clip) => { + expect(settled).toBe(true); + return clip === 1 ? { error: "Request failed with status 404", assetType: "image" } : null; + }); + + const error = await exporter.exportBlob().catch((e: unknown) => e); + + expect(error).toMatchObject({ phase: "assets", context: { clipId: "clip-404" } }); + expect(CanvasSource).not.toHaveBeenCalled(); + exporter.dispose(); + }); + + it("fails a clip still waiting for its source with phase assets", async () => { + const { exporter, edit } = createHarness(); + edit.tracks = [[{ clipId: "caption", needsResolution: true }]]; + + await expect(exporter.exportBlob()).rejects.toMatchObject({ phase: "assets", context: { clipId: "caption" } }); + exporter.dispose(); + }); + + it("fails with phase assets, naming a clip, when a load hasn't settled by the timeout", async () => { + jest.useFakeTimers(); + const { exporter, edit } = createHarness(); + edit.whenSettled.mockReturnValue(new Promise(() => {})); + edit.getLoadingClipIds.mockReturnValue(["slow-clip"]); + let outcome: unknown = "pending"; + + const result = exporter.exportBlob().catch((e: unknown) => { + outcome = e; + }); + await jest.advanceTimersByTimeAsync(ASSET_SETTLE_TIMEOUT_MS - 1); + expect(outcome).toBe("pending"); + await jest.advanceTimersByTimeAsync(1); + await result; + + expect(outcome).toMatchObject({ phase: "assets", context: { clipId: "slow-clip" } }); + expect((outcome as ExportError).message).toContain("didn't finish loading"); + expect(CanvasSource).not.toHaveBeenCalled(); + exporter.dispose(); + jest.useRealTimers(); + }); + + it("still cancels a load that never settles when aborted before the timeout", async () => { + jest.useFakeTimers(); + const { exporter, edit } = createHarness(); + edit.whenSettled.mockReturnValue(new Promise(() => {})); + const controller = new AbortController(); + + const pending = exporter.exportBlob({ signal: controller.signal }); + await jest.advanceTimersByTimeAsync(ASSET_SETTLE_TIMEOUT_MS / 2); + controller.abort(); + + await expect(pending).rejects.toMatchObject({ phase: "cancelled" }); + expect(jest.getTimerCount()).toBe(0); + exporter.dispose(); + jest.useRealTimers(); + }); + it("refuses a second export while one is running", async () => { const { exporter } = createHarness(); const first = exporter.exportBlob(); diff --git a/tests/export-preflight.test.ts b/tests/export-preflight.test.ts new file mode 100644 index 00000000..d2270f6a --- /dev/null +++ b/tests/export-preflight.test.ts @@ -0,0 +1,141 @@ +import { findBrowserIssue, findEditIssues, MAX_EXPORT_SECONDS } from "@core/export/export-preflight"; +import { VideoFrameProcessor } from "@core/export/video-frame-processor"; +import { sec } from "@core/timing/types"; +import type { ResolvedClip } from "@schemas"; +import { canEncodeVideo } from "mediabunny"; + +jest.mock("mediabunny", () => ({ + canEncodeVideo: jest.fn(), + Input: jest.fn().mockImplementation(() => ({ + getPrimaryVideoTrack: jest.fn().mockRejectedValue(new Error("Input has an unsupported or unrecognizable format.")), + dispose: jest.fn() + })), + UrlSource: jest.fn(), + CanvasSink: jest.fn(), + MP4: {}, + WEBM: {} +})); + +jest.mock("pixi.js", () => ({ Texture: { from: jest.fn() } })); + +const clip = (fields: Partial & Pick): ResolvedClip => ({ + id: "clip-1", + start: sec(0), + length: sec(5), + ...fields +}); + +function editWith( + clips: ResolvedClip[], + { format = "mp4", duration = 5 }: { format?: string; duration?: number } = {} +): Parameters[0] { + return { + getResolvedEdit: () => ({ timeline: { tracks: [{ clips }] }, output: { format: "mp4" } }), + getOutputFormat: () => format, + getFontUrlByFamilyAndWeight: (family: string) => (family === "Brand Sans" ? "https://example.com/brand-sans.ttf" : null), + totalDuration: duration + }; +} + +const codes = (clips: ResolvedClip[], options?: { format?: string; duration?: number }): string[] => + findEditIssues(editWith(clips, options)).map(issue => issue.code); + +describe("findEditIssues", () => { + it("passes an edit the browser draws the way the cloud does", () => { + const faithful = [ + clip({ asset: { type: "image", src: "https://example.com/a.png" }, effect: "zoomInSlow", transition: { in: "fade", out: "none" } }), + clip({ asset: { type: "rich-text", text: "Hi", font: { family: "Montserrat", weight: 700 } }, transition: { out: "slideLeftFast" } }), + clip({ asset: { type: "text", text: "Hi", font: { family: "Brand Sans" } } }), + clip({ asset: { type: "html", html: '

Hi

' }, filter: "none" }) + ]; + + expect(findEditIssues(editWith(faithful))).toEqual([]); + }); + + it("reports a prompt clip that hasn't been generated as ai-pending", () => { + expect(findEditIssues(editWith([clip({ id: "prompt-clip", asset: { type: "image", prompt: "A red kite" } })]))).toEqual([ + { code: "ai-pending", message: "This clip hasn't been generated yet.", clipId: "prompt-clip" } + ]); + }); + + it("reports a legacy generative asset as ai-pending", () => { + expect(codes([clip({ asset: { type: "text-to-image", prompt: "A red kite" } })])).toEqual(["ai-pending"]); + }); + + it("reports a non-MP4 output as output-format", () => { + expect(codes([], { format: "gif" })).toEqual(["output-format"]); + }); + + it("reports chroma key as chroma-key", () => { + expect(codes([clip({ asset: { type: "video", src: "https://example.com/a.mp4", chromaKey: { color: "#00b140" } } })])).toEqual(["chroma-key"]); + }); + + it("reports a clip filter as filter", () => { + expect(codes([clip({ asset: { type: "image", src: "https://example.com/a.png" }, filter: "greyscale" })])).toEqual(["filter"]); + }); + + it("reports HTML that isn't the plain text form, and any html5 clip, as html", () => { + expect( + codes([ + clip({ asset: { type: "html", html: "
Hi
" } }), + clip({ asset: { type: "html5", html: "
Hi
" } }) + ]) + ).toEqual(["html", "html"]); + }); + + it("reports an effect the presets don't animate as effect", () => { + // Every schema effect has a preset today; an unknown name stands in for one the schema adds later. + const effect = "spin" as ResolvedClip["effect"]; + expect(codes([clip({ asset: { type: "image", src: "https://example.com/a.png" }, effect })])).toEqual(["effect"]); + }); + + it("reports a transition the presets don't animate as transition", () => { + expect(codes([clip({ asset: { type: "image", src: "https://example.com/a.png" }, transition: { in: "shuffleTopRight", out: "fade" } })])).toEqual( + ["transition"] + ); + }); + + it("reports a font that is neither built in nor in the edit's fonts as font", () => { + expect(codes([clip({ asset: { type: "rich-text", text: "Hi", font: { family: "Not A Real Font" } } })])).toEqual(["font"]); + }); + + it("reports an edit longer than the limit as too-long", () => { + expect(codes([], { duration: MAX_EXPORT_SECONDS + 1 })).toEqual(["too-long"]); + expect(codes([], { duration: MAX_EXPORT_SECONDS })).toEqual([]); + }); +}); + +describe("findBrowserIssue", () => { + it("reports a browser that can't encode the output as browser", async () => { + jest.mocked(canEncodeVideo).mockResolvedValueOnce(false); + + await expect(findBrowserIssue({ width: 1920, height: 1080 }, 5_000_000)).resolves.toEqual({ + code: "browser", + message: "This browser can't encode H.264 video at 1920×1080." + }); + expect(canEncodeVideo).toHaveBeenCalledWith("avc", { width: 1920, height: 1080, bitrate: 5_000_000 }); + }); + + it("reports nothing when the encoder accepts the config", async () => { + jest.mocked(canEncodeVideo).mockResolvedValueOnce(true); + + await expect(findBrowserIssue({ width: 1280, height: 720 }, 2_222_222)).resolves.toBeNull(); + }); +}); + +describe("VideoFrameProcessor.initialize", () => { + it("reports a video the browser can't decode as video-source", async () => { + // isVideoPlayer recognises a player by its class name. + class VideoPlayer { + clipId = "mov-clip"; + + clipConfiguration = { asset: { type: "video", src: "https://example.com/clip.mov" } }; + } + + jest.spyOn(console, "warn").mockImplementation(() => undefined); + + const issues = await new VideoFrameProcessor().initialize([new VideoPlayer()]); + + expect(issues).toEqual([{ code: "video-source", message: "This video's format can't be decoded in the browser.", clipId: "mov-clip" }]); + }); +}); From d54fe34b53f8155ed2086e503bbc420d9e60ef08 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 19 Oct 2026 21:10:04 +1100 Subject: [PATCH 2/2] fix(export): read string font weights in the pre-flight font check --- .../canvas/players/rich-text-player.ts | 21 ++----------------- src/core/export/export-preflight.ts | 11 +++++++++- src/core/fonts/font-weights.ts | 18 ++++++++++++++++ tests/export-preflight.test.ts | 17 ++++++++++++++- 4 files changed, 46 insertions(+), 21 deletions(-) create mode 100644 src/core/fonts/font-weights.ts diff --git a/src/components/canvas/players/rich-text-player.ts b/src/components/canvas/players/rich-text-player.ts index 56a7ee4b..eb4847b6 100644 --- a/src/components/canvas/players/rich-text-player.ts +++ b/src/components/canvas/players/rich-text-player.ts @@ -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"; @@ -14,24 +15,6 @@ type TextEngine = Awaited>; 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 = { - 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>(); private static readonly fontBytesCache = new Map>(); private textEngine: TextEngine | null = null; @@ -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; diff --git a/src/core/export/export-preflight.ts b/src/core/export/export-preflight.ts index 46d22436..45e11fef 100644 --- a/src/core/export/export-preflight.ts +++ b/src/core/export/export-preflight.ts @@ -2,6 +2,7 @@ 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"; @@ -19,6 +20,14 @@ 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 => { @@ -42,7 +51,7 @@ function findClipIssues(edit: PreflightEdit, clip: ResolvedClip): ExportIssue[] if ("font" in asset && asset.font?.family) { const { family } = asset.font; - const weight = "weight" in asset.font && typeof asset.font.weight === "number" ? asset.font.weight : 400; + 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.`); } diff --git a/src/core/fonts/font-weights.ts b/src/core/fonts/font-weights.ts new file mode 100644 index 00000000..002a6968 --- /dev/null +++ b/src/core/fonts/font-weights.ts @@ -0,0 +1,18 @@ +/** CSS font-weight name → numeric value. Extends WEIGHT_MODIFIERS with CSS aliases. */ +export const NAMED_FONT_WEIGHTS: Record = { + 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 +}; diff --git a/tests/export-preflight.test.ts b/tests/export-preflight.test.ts index d2270f6a..167bf236 100644 --- a/tests/export-preflight.test.ts +++ b/tests/export-preflight.test.ts @@ -18,6 +18,11 @@ jest.mock("mediabunny", () => ({ jest.mock("pixi.js", () => ({ Texture: { from: jest.fn() } })); +const customFonts: Record = { + "Brand Sans|400": "https://example.com/brand-sans.ttf", + "Bold Only|700": "https://example.com/bold-only.ttf" +}; + const clip = (fields: Partial & Pick): ResolvedClip => ({ id: "clip-1", start: sec(0), @@ -32,7 +37,7 @@ function editWith( return { getResolvedEdit: () => ({ timeline: { tracks: [{ clips }] }, output: { format: "mp4" } }), getOutputFormat: () => format, - getFontUrlByFamilyAndWeight: (family: string) => (family === "Brand Sans" ? "https://example.com/brand-sans.ttf" : null), + getFontUrlByFamilyAndWeight: (family: string, weight: number) => customFonts[`${family}|${weight}`] ?? null, totalDuration: duration }; } @@ -99,6 +104,16 @@ describe("findEditIssues", () => { expect(codes([clip({ asset: { type: "rich-text", text: "Hi", font: { family: "Not A Real Font" } } })])).toEqual(["font"]); }); + it("reads string font weights the way the preview does", () => { + const withWeight = (weight: string): ResolvedClip[] => [ + clip({ asset: { type: "rich-text", text: "Hi", font: { family: "Bold Only", weight } } }) + ]; + + expect(codes(withWeight("bold"))).toEqual([]); + expect(codes(withWeight("700"))).toEqual([]); + expect(codes(withWeight("normal"))).toEqual(["font"]); + }); + it("reports an edit longer than the limit as too-long", () => { expect(codes([], { duration: MAX_EXPORT_SECONDS + 1 })).toEqual(["too-long"]); expect(codes([], { duration: MAX_EXPORT_SECONDS })).toEqual([]);