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
12 changes: 9 additions & 3 deletions src/components/canvas/players/html5-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
public override async prepareStaticRender({ strict = false }: { strict?: boolean } = {}): Promise<void> {
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;
Expand All @@ -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;
}
Expand All @@ -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);
}
}
Expand Down
5 changes: 3 additions & 2 deletions src/components/canvas/players/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
public async prepareStaticRender(_options?: { strict?: boolean }): Promise<void> {
// Default: content is already a Pixi-scene projection — nothing to do.
}

Expand Down
2 changes: 1 addition & 1 deletion src/core/export/export-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/core/export/export-preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))) {
Expand Down
1 change: 1 addition & 0 deletions tests/export-coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Expand Down
12 changes: 4 additions & 8 deletions tests/export-preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<p data-html-type="text">Hi</p>' }, filter: "none" })
clip({ asset: { type: "html", html: '<p data-html-type="text">Hi</p>' }, filter: "none" }),
clip({ asset: { type: "html5", html: "<div>Hi</div>" } })
];

expect(findEditIssues(editWith(faithful), HD_BITRATE)).toEqual([]);
Expand Down Expand Up @@ -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: "<table><tr><td>Hi</td></tr></table>" } }),
clip({ asset: { type: "html5", html: "<div>Hi</div>" } })
])
).toEqual(["html", "html"]);
it("reports HTML that isn't the plain text form as html", () => {
expect(codes([clip({ asset: { type: "html", html: "<table><tr><td>Hi</td></tr></table>" } })])).toEqual(["html"]);
});

it("reports an effect the presets don't animate as effect", () => {
Expand Down
23 changes: 23 additions & 0 deletions tests/html5-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading