From c07b2bfef5359368300b361b78ec0e6847623b09 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sat, 31 Oct 2026 16:20:34 +1100 Subject: [PATCH] feat: export html5 clips in the browser --- src/components/canvas/players/html5-player.ts | 12 +++++++--- src/components/canvas/players/player.ts | 5 ++-- src/core/export/export-coordinator.ts | 2 +- src/core/export/export-preflight.ts | 2 +- tests/export-coordinator.test.ts | 1 + tests/export-preflight.test.ts | 12 ++++------ tests/html5-player.test.ts | 23 +++++++++++++++++++ 7 files changed, 42 insertions(+), 15 deletions(-) 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: "
Hi
" } }), - 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: "
Hi
" } })])).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);