diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 7ab3687d6f..844b08ff79 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -2206,6 +2206,55 @@ return issues; } + // Canvases whose WebGL context drew since the last audit; the author's own hf-seek loop decides what draws. + const webglDraws = (window.__hyperframesWebglDraws ??= new Set()); + const drawGate = Symbol.for("hyperframes.webglDrawGate"); + for (const name of ["WebGLRenderingContext", "WebGL2RenderingContext"]) { + const proto = window[name]?.prototype; + if (!proto || proto.__hyperframesDrawCount) continue; + proto.__hyperframesDrawCount = true; + for (const method of [ + "clear", + "drawArrays", + "drawElements", + "drawArraysInstanced", + "drawElementsInstanced", + ]) { + const draw = proto[method]; + if (typeof draw !== "function") continue; + proto[method] = function (...args) { + if (!window[drawGate]?.skip) webglDraws.add(this.canvas); + return draw.apply(this, args); + }; + } + } + + function inactiveWebglIssues(root, time) { + const drawn = [...webglDraws].filter((canvas) => canvas?.isConnected); + webglDraws.clear(); + return drawn + .filter( + (canvas) => canvas.closest(RUNTIME_HIDDEN_CLIP) || canvas.checkVisibility?.() === false, + ) + .map((canvas) => { + const scene = + canvas.closest("[data-composition-id]:not([data-width])") ?? + canvas.closest("[data-composition-id]"); + const name = scene?.getAttribute("data-composition-id") || uniqueSelectorFor(canvas); + return { + code: "webgl_draws_while_inactive", + severity: "warning", + time, + selector: uniqueSelectorFor(canvas), + containerSelector: uniqueSelectorFor(root), + message: `Scene "${name}" draws WebGL while it is not on screen, so every seek pays for it.`, + rect: toRect(canvas.getBoundingClientRect()), + fixHint: + "Draw only while the scene is active (its data-start to data-start + data-duration), or skip the draw in your hf-seek listener when the scene is hidden.", + }; + }); + } + // Soft prior only — the counterfactual attach test (below) is what makes detachment a finding. const CONNECTOR_NAME = /\b(conn(ector)?|arrow|edge|link|flow|wire)\b/i; const CONNECTOR_SKIP_CONTAINERS = "defs, marker, clipPath, mask, symbol, pattern"; @@ -2631,6 +2680,7 @@ issues.push(...escaped.issues); issues.push(...panelOutOfCanvasIssues(root, rootRect, time, tolerance, escaped.flagged)); issues.push(...canvasEdgeIssues(root, rootRect, time, tolerance)); + issues.push(...inactiveWebglIssues(root, time)); issues.push(...connectorDetachmentIssues(root, rootRect, time)); issues.push(...connectorOrphanIssues(root, rootRect, time)); return issues; diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index d0099f0d4a..7a1f7de8ac 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -1310,6 +1310,48 @@ describe("layout-audit.browser coordinate-frame findings", () => { expect(issues.find((issue) => issue.selector === "#bleed")).toMatchObject({ severity: "info" }); }); + describe("webgl_draws_while_inactive", () => { + function mountScenes(hidden: boolean): HTMLCanvasElement { + document.body.innerHTML = ` +
+
+ +
+
+ `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + scene2: rect({ left: 0, top: 0, width: 640, height: 360 }), + shader: rect({ left: 0, top: 0, width: 640, height: 360 }), + }); + installAuditScript(); + return document.getElementById("shader") as HTMLCanvasElement; + } + + const drawn = () => + (window as unknown as { __hyperframesWebglDraws: Set }).__hyperframesWebglDraws; + + it("names the scene whose canvas draws while its clip is hidden", () => { + drawn().add(mountScenes(true)); + + const found = runAudit().filter((issue) => issue.code === "webgl_draws_while_inactive"); + + expect(found).toHaveLength(1); + expect(found[0]).toMatchObject({ severity: "warning", selector: "#shader" }); + expect(found[0]?.message).toContain("scene2"); + }); + + it("stays quiet for a canvas drawing on screen, and reports a draw once", () => { + drawn().add(mountScenes(false)); + expect(runAudit().some((issue) => issue.code === "webgl_draws_while_inactive")).toBe(false); + + drawn().add(document.getElementById("shader") as HTMLCanvasElement); + document.getElementById("scene2")?.setAttribute("data-hf-clip-hidden", ""); + expect(runAudit().filter((i) => i.code === "webgl_draws_while_inactive")).toHaveLength(1); + expect(runAudit().some((issue) => issue.code === "webgl_draws_while_inactive")).toBe(false); + }); + }); + describe("canvas_content_at_edge", () => { // One grey RGBA pixel per entry: a 1px-thick band, read the same along every edge. function edgeLine(levels: number[]): Uint8ClampedArray { diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index 7b840a49f3..7dba04d3da 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -2189,6 +2189,7 @@ const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [ "escaped_container", "panel_out_of_canvas", "canvas_content_at_edge", + "webgl_draws_while_inactive", "connector_detached", "connector_orphan", "rotation_pivot_drift", diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index 106557ddd9..a051046c5d 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -27,6 +27,7 @@ export type LayoutIssueCode = | "escaped_container" | "panel_out_of_canvas" | "canvas_content_at_edge" + | "webgl_draws_while_inactive" | "connector_detached" | "connector_orphan" // Cross-sample rotation finding — a spinning element whose bbox center drifts