diff --git a/src/components/canvas/players/html5-player.ts b/src/components/canvas/players/html5-player.ts
index d5418548..73ed3500 100644
--- a/src/components/canvas/players/html5-player.ts
+++ b/src/components/canvas/players/html5-player.ts
@@ -781,9 +781,13 @@ export class Html5Player extends Player {
* Project the current playhead's frame into the Pixi scene for an off-playback capture.
* @internal
*/
- public override async prepareStaticRender(): Promise {
+ public override async prepareStaticRender({ strict = false }: { strict?: boolean } = {}): Promise {
try {
- if (this.disposed || !this.iframe || this.mode === "failed") return;
+ if (this.disposed) return;
+ if (!this.iframe || this.mode === "failed") {
+ if (strict) throw new Error("The HTML5 clip didn't load");
+ return;
+ }
const seconds = this.getPlaybackTime();
// A scaled bake is too soft for a still, so a still reads only a full-size one.
const frames = this.capturedFrames && this.capturedHash === `${this.contentHash}@1` ? this.capturedFrames : null;
@@ -793,6 +797,7 @@ export class Html5Player extends Player {
: await this.renderStill(seconds);
if (this.disposed) return;
if (!texture) {
+ if (strict) throw new Error("The HTML5 clip's frame couldn't be drawn");
this.hideForCapture();
return;
}
@@ -812,8 +817,9 @@ export class Html5Player extends Player {
if (this.clipConfiguration.width && this.clipConfiguration.height) this.applyFixedDimensions();
}
} catch (err) {
- // A capture failure must leave only this clip blank in the snapshot — never break the whole capture.
+ // A snapshot leaves only this clip blank. An export can't: a video missing the clip would pass for faithful.
this.hideForCapture();
+ if (strict) throw err;
console.warn("[Html5Player] prepareStaticRender failed:", err);
}
}
diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts
index 4737246b..4cd85d81 100644
--- a/src/components/canvas/players/player.ts
+++ b/src/components/canvas/players/player.ts
@@ -655,10 +655,11 @@ export abstract class Player extends Entity {
}
/**
- * Project this player's current frame into the Pixi scene for a one-off static capture.
+ * Project this player's current frame into the Pixi scene for a one-off static capture. `strict` rejects when the
+ * frame can't be drawn, where a snapshot would leave the clip out.
* @internal
*/
- public async prepareStaticRender(): Promise {
+ public async prepareStaticRender(_options?: { strict?: boolean }): Promise {
// Default: content is already a Pixi-scene projection — nothing to do.
}
diff --git a/src/core/export/export-coordinator.ts b/src/core/export/export-coordinator.ts
index 5bd7c153..96fa739a 100644
--- a/src/core/export/export-coordinator.ts
+++ b/src/core/export/export-coordinator.ts
@@ -267,7 +267,7 @@ export class ExportCoordinator {
// update() only starts rich text and caption draws; finish this frame's before reading pixels.
const active = clips.filter(clip => clip.isActive());
try {
- await Promise.all(active.map(clip => clip.prepareStaticRender()));
+ await Promise.all(active.map(clip => clip.prepareStaticRender({ strict: true })));
throwIfStopped();
// The identity transform stands in for the container's own, so the editor's pan and zoom never reach
// these pixels; the texture's resolution pins them to output pixels.
diff --git a/src/core/export/export-preflight.ts b/src/core/export/export-preflight.ts
index 9f9da3f7..2879420c 100644
--- a/src/core/export/export-preflight.ts
+++ b/src/core/export/export-preflight.ts
@@ -40,7 +40,7 @@ function findClipIssues(edit: PreflightEdit, clip: ResolvedClip): ExportIssue[]
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 (asset.type === "html" && !isTextHtml(asset.html)) add("html", "This HTML clip can only be rendered in the cloud.");
// Mattes redraw in the editor's update loop, which export doesn't run, so a video matte would hold one frame.
if (asset.type === "luma" && inferAssetTypeFromUrl(asset.src) === "video") add("luma", "Video luma mattes aren't available in the browser.");
if (clip.effect && !animates(new EffectPresetBuilder(clip).buildRelative(UNIT_SIZE, UNIT_SIZE))) {
diff --git a/tests/export-coordinator.test.ts b/tests/export-coordinator.test.ts
index 23dc6205..d13c4edc 100644
--- a/tests/export-coordinator.test.ts
+++ b/tests/export-coordinator.test.ts
@@ -529,6 +529,7 @@ describe("asynchronous drawing", () => {
await exporter.exportBlob();
expect(order).toEqual(["extracted", "drawn at 0.5", "extracted", "ended"]);
+ expect(title.prepareStaticRender).toHaveBeenCalledWith({ strict: true });
exporter.dispose();
});
});
diff --git a/tests/export-preflight.test.ts b/tests/export-preflight.test.ts
index 65af6c4c..b6f3c579 100644
--- a/tests/export-preflight.test.ts
+++ b/tests/export-preflight.test.ts
@@ -59,7 +59,8 @@ describe("findEditIssues", () => {
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" })
+ clip({ asset: { type: "html", html: 'Hi
' }, filter: "none" }),
+ clip({ asset: { type: "html5", html: "Hi
" } })
];
expect(findEditIssues(editWith(faithful), HD_BITRATE)).toEqual([]);
@@ -92,13 +93,8 @@ describe("findEditIssues", () => {
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: "" } }),
- clip({ asset: { type: "html5", html: "Hi
" } })
- ])
- ).toEqual(["html", "html"]);
+ it("reports HTML that isn't the plain text form as html", () => {
+ expect(codes([clip({ asset: { type: "html", html: "" } })])).toEqual(["html"]);
});
it("reports an effect the presets don't animate as effect", () => {
diff --git a/tests/html5-player.test.ts b/tests/html5-player.test.ts
index f15f853d..18a010ee 100644
--- a/tests/html5-player.test.ts
+++ b/tests/html5-player.test.ts
@@ -1168,6 +1168,29 @@ it("leaves a scaled bake hidden when a capture's still fails", async () => {
expect(playback.visible).toBe(true);
});
+it("rejects an export's frame that can't be drawn, after hiding the clip's bake", async () => {
+ const { player, harness } = createStillPlayer();
+ shownAt(harness, 0.46);
+ await harness.runCapture();
+ await harness.transitionToPlayback();
+ const playback = harness.contentContainer.children[0] as PlaybackSprite;
+ jest.spyOn(harness, "captureIframeAsForeignObjectSvg").mockImplementation(() => {
+ throw new Error("serialise failed");
+ });
+
+ await expect(player.prepareStaticRender({ strict: true })).rejects.toThrow("serialise failed");
+ expect(playback.visible).toBe(false);
+ player.endStaticRender();
+
+ jest.spyOn(harness, "renderStill").mockResolvedValue(null);
+ await expect(player.prepareStaticRender({ strict: true })).rejects.toThrow("couldn't be drawn");
+ player.endStaticRender();
+
+ harness.transitionToFailed();
+ await expect(player.prepareStaticRender({ strict: true })).rejects.toThrow("didn't load");
+ await expect(player.prepareStaticRender()).resolves.toBeUndefined();
+});
+
it("keeps a scaled bake on screen until a capture's still is ready", async () => {
const { player, harness } = createStillPlayer();
shownAt(harness, 0.46);