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
29 changes: 29 additions & 0 deletions packages/producer/src/services/htmlCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,35 @@ describe("detectRenderModeHints", () => {
expect(result.reasons.map((reason) => reason.code)).toEqual(["requestAnimationFrame"]);
});

it("detects html-in-canvas API via layoutsubtree canvas attribute", () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="root" data-width="1920" data-height="1080">
<canvas id="glass-canvas" layoutsubtree width="1920" height="1080">
<div class="panel">Glass content</div>
</canvas>
</div>
</body></html>`;

const result = detectRenderModeHints(html);

expect(result.reasons.map((reason) => reason.code)).toContain("htmlInCanvas");
expect(result.recommendScreenshot).toBe(true);
});

it("does not flag htmlInCanvas for plain canvas elements without layoutsubtree", () => {
const html = `<!DOCTYPE html>
<html><body>
<div data-composition-id="root" data-width="1920" data-height="1080">
<canvas id="my-canvas" width="1920" height="1080"></canvas>
</div>
</body></html>`;

const result = detectRenderModeHints(html);

expect(result.reasons.map((reason) => reason.code)).not.toContain("htmlInCanvas");
});

it("does not recommend screenshot mode for nested compositions that hoist GSAP from a CDN script", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-render-mode-"));
const compositionsDir = join(projectDir, "compositions");
Expand Down
10 changes: 9 additions & 1 deletion packages/producer/src/services/htmlCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export interface CompiledComposition {
hasShaderTransitions: boolean;
}

export type RenderModeHintCode = "iframe" | "requestAnimationFrame";
export type RenderModeHintCode = "iframe" | "requestAnimationFrame" | "htmlInCanvas";

export interface RenderModeHint {
code: RenderModeHintCode;
Expand Down Expand Up @@ -96,6 +96,14 @@ export function detectRenderModeHints(html: string): RenderModeHints {
const reasons: RenderModeHint[] = [];
const { document } = parseHTML(html);

if (document.querySelector("canvas[layoutsubtree]")) {
reasons.push({
code: "htmlInCanvas",
message:
"Detected html-in-canvas API (layoutsubtree canvas). Chrome does not support concurrent drawElementImage across multiple workers; render is pinned to a single worker.",
});
}

if (document.querySelector("iframe")) {
reasons.push({
code: "iframe",
Expand Down
14 changes: 14 additions & 0 deletions packages/producer/src/services/render/captureCost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,20 @@ export function resolveRenderWorkerCount(
log: ProducerLogger = defaultLogger,
measuredCaptureCost?: CaptureCostEstimate,
): number {
// TODO(htmlInCanvas): workaround — Chrome's experimental drawElementImage
// API (CanvasDrawElement) is non-deterministic across concurrent browser
// instances due to paint-cache races and SwiftShader contention.
// Remove this clamp once Chromium stabilizes CanvasDrawElement for
// concurrent use.
const reasonCodes = new Set(compiled.renderModeHints.reasons.map((r) => r.code));
if (reasonCodes.has("htmlInCanvas")) {
log.warn(
"[Render] html-in-canvas (drawElementImage) detected — pinning to 1 worker (Chrome concurrency limitation).",
{ requestedWorkers },
);
return 1;
}

const captureCost = combineCaptureCostEstimates(
estimateCaptureCostMultiplier(compiled),
measuredCaptureCost,
Expand Down
52 changes: 52 additions & 0 deletions packages/producer/src/services/renderOrchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,58 @@ describe("resolveRenderWorkerCount", () => {
expect(workers).toBe(1);
});

it("forces single worker when html-in-canvas is detected", () => {
const log = {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
};

const workers = resolveRenderWorkerCount(
900,
undefined,
cfg,
{
hasShaderTransitions: false,
renderModeHints: {
recommendScreenshot: false,
reasons: [{ code: "htmlInCanvas", message: "layoutsubtree canvas" }],
},
},
log,
);

expect(workers).toBe(1);
expect(log.warn).toHaveBeenCalledOnce();
});

it("overrides explicit --workers when html-in-canvas is detected", () => {
const log = {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
};

const workers = resolveRenderWorkerCount(
900,
8,
cfg,
{
hasShaderTransitions: false,
renderModeHints: {
recommendScreenshot: false,
reasons: [{ code: "htmlInCanvas", message: "layoutsubtree canvas" }],
},
},
log,
);

expect(workers).toBe(1);
expect(log.warn).toHaveBeenCalledOnce();
});

it("keeps baseline auto workers after screenshot fallback when measured capture is cheap", () => {
const log = {
error: vi.fn(),
Expand Down
5 changes: 4 additions & 1 deletion packages/producer/src/services/renderOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1699,7 +1699,10 @@ export async function executeRenderJob(
}
| undefined;

if (job.config.workers === undefined && totalFrames >= 60) {
const htmlInCanvasDetected = compiled.renderModeHints.reasons.some(
(r) => r.code === "htmlInCanvas",
);
if (job.config.workers === undefined && totalFrames >= 60 && !htmlInCanvasDetected) {
const outcome = await runCaptureCalibration({
cfg,
fileServer,
Expand Down
Loading