Skip to content

Commit 32f85bf

Browse files
committed
fix: broaden auto worker cap for webgl renders
1 parent f530819 commit 32f85bf

4 files changed

Lines changed: 120 additions & 57 deletions

File tree

packages/producer/src/services/htmlCompiler.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,36 @@ describe("detectRenderModeHints", () => {
327327
expect(result.reasons.map((reason) => reason.code)).toEqual(["requestAnimationFrame"]);
328328
});
329329

330+
it("detects inline WebGL and Three.js scenes without forcing screenshot mode", () => {
331+
const html = `<!DOCTYPE html>
332+
<html><body>
333+
<canvas id="scene"></canvas>
334+
<script>
335+
import * as THREE from "three";
336+
const renderer = new THREE.WebGLRenderer({ canvas: document.getElementById("scene") });
337+
const composer = new EffectComposer(renderer);
338+
</script>
339+
</body></html>`;
340+
341+
const result = detectRenderModeHints(html);
342+
343+
expect(result.recommendScreenshot).toBe(false);
344+
expect(result.reasons.map((reason) => reason.code)).toEqual(["webgl"]);
345+
});
346+
347+
it("detects external Three.js scripts as WebGL-heavy", () => {
348+
const html = `<!DOCTYPE html>
349+
<html><body>
350+
<canvas id="scene"></canvas>
351+
<script src="https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.min.js"></script>
352+
</body></html>`;
353+
354+
const result = detectRenderModeHints(html);
355+
356+
expect(result.recommendScreenshot).toBe(false);
357+
expect(result.reasons.map((reason) => reason.code)).toEqual(["webgl"]);
358+
});
359+
330360
it("ignores requestAnimationFrame inside comments and external scripts", () => {
331361
const html = `<!DOCTYPE html>
332362
<html><body>

packages/producer/src/services/htmlCompiler.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export interface CompiledComposition {
5353
renderModeHints: RenderModeHints;
5454
}
5555

56-
export type RenderModeHintCode = "iframe" | "requestAnimationFrame";
56+
export type RenderModeHintCode = "iframe" | "requestAnimationFrame" | "webgl";
5757

5858
export interface RenderModeHint {
5959
code: RenderModeHintCode;
@@ -76,6 +76,11 @@ function dedupeElementsById<T extends { id: string }>(elements: T[]): T[] {
7676
const INLINE_SCRIPT_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
7777
const COMPILER_MOUNT_BLOCK_START = "/* __HF_COMPILER_MOUNT_START__ */";
7878
const COMPILER_MOUNT_BLOCK_END = "/* __HF_COMPILER_MOUNT_END__ */";
79+
const WEBGL_CONTEXT_PATTERN = /\bgetContext\s*\(\s*["'`](?:webgl2?|experimental-webgl)["'`]\s*\)/i;
80+
const WEBGL_INLINE_PATTERN =
81+
/\b(?:THREE\.(?:WebGLRenderer|PMREMGenerator)|WebGL(?:2)?RenderingContext|PMREMGenerator|EffectComposer|UnrealBloomPass)\b|(?:import\s*\(?\s*["'`]three(?:\/|["'`]))/i;
82+
const WEBGL_SCRIPT_SRC_PATTERN =
83+
/(?:@react-three\/(?:fiber|drei))|(?:^|[/@-])three(?:[./@-]|(?:\.module|\.min)?(?:\.js)?(?:$|[?#/]))/i;
7984

8085
function stripJsComments(source: string): string {
8186
return source.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
@@ -105,21 +110,49 @@ export function detectRenderModeHints(html: string): RenderModeHints {
105110

106111
let scriptMatch: RegExpExecArray | null;
107112
const scriptPattern = new RegExp(INLINE_SCRIPT_PATTERN.source, INLINE_SCRIPT_PATTERN.flags);
113+
let detectedInlineRequestAnimationFrame = false;
114+
let detectedWebgl = false;
108115
while ((scriptMatch = scriptPattern.exec(html)) !== null) {
109116
const attrs = scriptMatch[1] || "";
110-
if (/\bsrc\s*=/i.test(attrs)) continue;
117+
if (/\bsrc\s*=/i.test(attrs)) {
118+
const srcMatch = attrs.match(/\bsrc\s*=\s*["']([^"']+)["']/i);
119+
if (srcMatch?.[1] && WEBGL_SCRIPT_SRC_PATTERN.test(srcMatch[1])) {
120+
detectedWebgl = true;
121+
}
122+
continue;
123+
}
111124
const content = stripJsComments(stripCompilerMountBootstrap(scriptMatch[2] || ""));
112-
if (!/requestAnimationFrame\s*\(/.test(content)) continue;
125+
if (!detectedInlineRequestAnimationFrame && /requestAnimationFrame\s*\(/.test(content)) {
126+
detectedInlineRequestAnimationFrame = true;
127+
}
128+
if (
129+
!detectedWebgl &&
130+
(WEBGL_CONTEXT_PATTERN.test(content) || WEBGL_INLINE_PATTERN.test(content))
131+
) {
132+
detectedWebgl = true;
133+
}
134+
}
135+
136+
if (detectedInlineRequestAnimationFrame) {
113137
reasons.push({
114138
code: "requestAnimationFrame",
115139
message:
116140
"Detected raw requestAnimationFrame() in an inline script. This render is routed through screenshot capture mode with virtual time enabled.",
117141
});
118-
break;
142+
}
143+
144+
if (detectedWebgl) {
145+
reasons.push({
146+
code: "webgl",
147+
message:
148+
"Detected WebGL/Three.js scene setup. GPU-heavy compositions often need fewer auto workers to avoid Chrome compositor starvation.",
149+
});
119150
}
120151

121152
return {
122-
recommendScreenshot: reasons.length > 0,
153+
recommendScreenshot: reasons.some(
154+
(reason) => reason.code === "iframe" || reason.code === "requestAnimationFrame",
155+
),
123156
reasons,
124157
};
125158
}

packages/producer/src/services/renderOrchestrator.test.ts

Lines changed: 44 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,31 @@ import {
1313
} from "./renderOrchestrator.js";
1414
import { toExternalAssetKey } from "../utils/paths.js";
1515

16+
function createCompiledComposition(
17+
reasonCodes: Array<"iframe" | "requestAnimationFrame" | "webgl">,
18+
): CompiledComposition {
19+
return {
20+
html: "<html></html>",
21+
subCompositions: new Map(),
22+
videos: [],
23+
audios: [],
24+
unresolvedCompositions: [],
25+
externalAssets: new Map(),
26+
width: 1920,
27+
height: 1080,
28+
staticDuration: 5,
29+
renderModeHints: {
30+
recommendScreenshot: reasonCodes.some(
31+
(code) => code === "iframe" || code === "requestAnimationFrame",
32+
),
33+
reasons: reasonCodes.map((code) => ({
34+
code,
35+
message: `reason: ${code}`,
36+
})),
37+
},
38+
};
39+
}
40+
1641
describe("extractStandaloneEntryFromIndex", () => {
1742
it("reuses the index wrapper and keeps only the requested composition host", () => {
1843
const indexHtml = `<!DOCTYPE html>
@@ -160,29 +185,6 @@ describe("writeCompiledArtifacts — external assets on Windows drive-letter pat
160185
});
161186

162187
describe("applyRenderModeHints", () => {
163-
function createCompiledComposition(
164-
reasonCodes: Array<"iframe" | "requestAnimationFrame">,
165-
): CompiledComposition {
166-
return {
167-
html: "<html></html>",
168-
subCompositions: new Map(),
169-
videos: [],
170-
audios: [],
171-
unresolvedCompositions: [],
172-
externalAssets: new Map(),
173-
width: 1920,
174-
height: 1080,
175-
staticDuration: 5,
176-
renderModeHints: {
177-
recommendScreenshot: reasonCodes.length > 0,
178-
reasons: reasonCodes.map((code) => ({
179-
code,
180-
message: `reason: ${code}`,
181-
})),
182-
},
183-
};
184-
}
185-
186188
function createConfig(): EngineConfig {
187189
return {
188190
fps: 30,
@@ -247,29 +249,6 @@ describe("applyRenderModeHints", () => {
247249
});
248250

249251
describe("applyAutoWorkerCompatibilityHints", () => {
250-
function createCompiledComposition(
251-
reasonCodes: Array<"iframe" | "requestAnimationFrame">,
252-
): CompiledComposition {
253-
return {
254-
html: "<html></html>",
255-
subCompositions: new Map(),
256-
videos: [],
257-
audios: [],
258-
unresolvedCompositions: [],
259-
externalAssets: new Map(),
260-
width: 1920,
261-
height: 1080,
262-
staticDuration: 5,
263-
renderModeHints: {
264-
recommendScreenshot: reasonCodes.length > 0,
265-
reasons: reasonCodes.map((code) => ({
266-
code,
267-
message: `reason: ${code}`,
268-
})),
269-
},
270-
};
271-
}
272-
273252
it("caps auto workers for requestAnimationFrame screenshot-mode compositions", () => {
274253
const log = {
275254
error: vi.fn(),
@@ -308,6 +287,25 @@ describe("applyAutoWorkerCompatibilityHints", () => {
308287
expect(log.info).not.toHaveBeenCalled();
309288
});
310289

290+
it("caps auto workers for detected WebGL-heavy compositions", () => {
291+
const log = {
292+
error: vi.fn(),
293+
warn: vi.fn(),
294+
info: vi.fn(),
295+
debug: vi.fn(),
296+
};
297+
298+
const workers = applyAutoWorkerCompatibilityHints(
299+
6,
300+
{ fps: 30, quality: "standard" },
301+
createCompiledComposition(["webgl"]),
302+
log,
303+
);
304+
305+
expect(workers).toBe(2);
306+
expect(log.info).toHaveBeenCalledOnce();
307+
});
308+
311309
it("does not cap when compatibility hints are unrelated to requestAnimationFrame", () => {
312310
const log = {
313311
error: vi.fn(),

packages/producer/src/services/renderOrchestrator.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,9 @@ export function applyRenderModeHints(
396396
});
397397
}
398398

399-
const SCREENSHOT_MODE_AUTO_WORKER_CAP = 2;
399+
// Issue #410 reported that 2 workers stayed stable for GPU-heavy scenes where
400+
// the default auto heuristic chose 6 and hit Chrome compositor starvation.
401+
const WEBGL_AUTO_WORKER_CAP = 2;
400402

401403
export function applyAutoWorkerCompatibilityHints(
402404
workerCount: number,
@@ -406,15 +408,15 @@ export function applyAutoWorkerCompatibilityHints(
406408
): number {
407409
if (config.workers !== undefined) return workerCount;
408410

409-
const hasRequestAnimationFrameHint = compiled.renderModeHints.reasons.some(
410-
(reason) => reason.code === "requestAnimationFrame",
411+
const hasCompatibilityHint = compiled.renderModeHints.reasons.some(
412+
(reason) => reason.code === "requestAnimationFrame" || reason.code === "webgl",
411413
);
412-
if (!hasRequestAnimationFrameHint || workerCount <= SCREENSHOT_MODE_AUTO_WORKER_CAP) {
414+
if (!hasCompatibilityHint || workerCount <= WEBGL_AUTO_WORKER_CAP) {
413415
return workerCount;
414416
}
415417

416-
const reducedWorkerCount = SCREENSHOT_MODE_AUTO_WORKER_CAP;
417-
log.info("Reduced auto worker count for screenshot-mode composition", {
418+
const reducedWorkerCount = WEBGL_AUTO_WORKER_CAP;
419+
log.info("Reduced auto worker count for WebGL-heavy composition", {
418420
from: workerCount,
419421
to: reducedWorkerCount,
420422
reasonCodes: compiled.renderModeHints.reasons.map((reason) => reason.code),

0 commit comments

Comments
 (0)