From 44653b31da5621e7b8ae273e8b82dc32ae7acf43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 12 Jul 2026 22:17:49 -0400 Subject: [PATCH] fix(cli): detect video motion in sweep guard (#2308) * fix(cli): detect video motion in sweep guard * docs(cli): clarify iframe fingerprint scope --- .../cli/src/commands/layout-audit.browser.js | 30 +++++++++-------- .../src/commands/layout-audit.browser.test.ts | 32 +++++++++++++++++++ 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 4f4977a4b..1d57956fa 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -942,22 +942,26 @@ // 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. + // 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 { - 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; @@ -980,9 +984,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 8ee4b7976..1a334a5b6 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 = `