From e895092a788ae95a625350e19dcd60edb483bc1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 13 Jul 2026 00:30:06 +0000 Subject: [PATCH 1/2] fix(cli): detect video motion in sweep guard --- .../cli/src/commands/layout-audit.browser.js | 28 ++++++++-------- .../src/commands/layout-audit.browser.test.ts | 32 +++++++++++++++++++ 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index e3d9e25f1f..b2d855583d 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -895,22 +895,24 @@ // actually moved anything and the whole audit run is unreliable. Deliberately // a single opaque string (not a structured array) since Node only ever needs // equality, not per-element diffing. - // Pixel-only canvas motion (a 2D/WebGL canvas repainting without any element - // moving) is invisible to a geometry+opacity fingerprint and false-positived - // sweep_static (wild report: 4K WebGL comp needed a transparent moving DOM - // sentinel to pass). Downsample each visible canvas to 8x8 and fold its - // pixels into the fingerprint. Tainted/zero-sized/unreadable canvases hash - // to a constant — no worse than today, never a new false NEGATIVE for - // DOM-motion comps. - function canvasPixelHash(canvas) { + // Pixel-only media motion (a 2D/WebGL canvas repainting or a playing video + // without any element moving) is invisible to a geometry+opacity fingerprint + // and false-positives sweep_static. Downsample each visible canvas/video to + // 8x8 and fold its pixels into the fingerprint. Tainted, zero-sized, or + // unreadable media hashes to a constant — no worse than geometry-only + // detection and never a new false negative for DOM-motion compositions. + function mediaPixelHash(element) { try { - if (!canvas.width || !canvas.height) return "x"; + const rect = element.getBoundingClientRect(); + const sourceWidth = element.videoWidth || element.width || rect.width; + const sourceHeight = element.videoHeight || element.height || rect.height; + if (!sourceWidth || !sourceHeight) return "x"; const off = document.createElement("canvas"); off.width = 8; off.height = 8; const ctx = off.getContext("2d"); if (!ctx) return "x"; - ctx.drawImage(canvas, 0, 0, 8, 8); + ctx.drawImage(element, 0, 0, 8, 8); const data = ctx.getImageData(0, 0, 8, 8).data; let hash = 0; for (let i = 0; i < data.length; i++) hash = (hash * 31 + data[i]) >>> 0; @@ -933,9 +935,9 @@ const opacity = round(opacityChain(element)); return `${rect.left},${rect.top},${rect.width},${rect.height},${opacity}`; }); - for (const canvas of root.querySelectorAll("canvas")) { - if (!isVisibleElement(canvas)) continue; - parts.push(`c:${canvasPixelHash(canvas)}`); + for (const media of root.querySelectorAll("canvas, video")) { + if (!isVisibleElement(media)) continue; + parts.push(`p:${mediaPixelHash(media)}`); } return parts.join("|"); }; diff --git a/packages/cli/src/commands/layout-audit.browser.test.ts b/packages/cli/src/commands/layout-audit.browser.test.ts index 821bad8c14..7a99ec91d0 100644 --- a/packages/cli/src/commands/layout-audit.browser.test.ts +++ b/packages/cli/src/commands/layout-audit.browser.test.ts @@ -31,6 +31,38 @@ describe("layout-audit.browser", () => { clearGeometryCollector(); }); + it("changes the sweep fingerprint when visible video pixels advance", () => { + document.body.innerHTML = ` +
+ +
+ `; + installGeometry({ + root: rect({ left: 0, top: 0, width: 640, height: 360 }), + footage: rect({ left: 0, top: 0, width: 640, height: 360 }), + }); + + let pixelValue = 20; + const getContextSpy = vi.spyOn(HTMLCanvasElement.prototype, "getContext") as unknown as { + mockReturnValue(value: CanvasRenderingContext2D): void; + }; + getContextSpy.mockReturnValue({ + drawImage() {}, + getImageData() { + return { data: new Uint8ClampedArray(8 * 8 * 4).fill(pixelValue) }; + }, + } as unknown as CanvasRenderingContext2D); + + installAuditScript(); + const collect = (window as unknown as { __hyperframesLayoutGeometry: () => string }) + .__hyperframesLayoutGeometry; + const before = collect(); + pixelValue = 220; + const after = collect(); + + expect(after).not.toBe(before); + }); + it("uses authored canvas dimensions when the root bounding rect is degenerate", () => { document.body.innerHTML = `
From 41e5a13f43f749d1777f42e83b0bad0d68954ea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 13 Jul 2026 01:53:55 +0000 Subject: [PATCH 2/2] docs(cli): clarify iframe fingerprint scope --- packages/cli/src/commands/layout-audit.browser.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index b2d855583d..934a860b14 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -901,6 +901,8 @@ // 8x8 and fold its pixels into the fingerprint. Tainted, zero-sized, or // unreadable media hashes to a constant — no worse than geometry-only // detection and never a new false negative for DOM-motion compositions. + // Media inside iframes is intentionally outside this fingerprint: it lives + // in a separate document, and cross-origin frames are inaccessible under SOP. function mediaPixelHash(element) { try { const rect = element.getBoundingClientRect();