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
30 changes: 17 additions & 13 deletions packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -895,22 +895,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;
Expand All @@ -933,9 +937,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("|");
};
Expand Down
32 changes: 32 additions & 0 deletions packages/cli/src/commands/layout-audit.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,38 @@ describe("layout-audit.browser", () => {
clearGeometryCollector();
});

it("changes the sweep fingerprint when visible video pixels advance", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<video id="footage"></video>
</div>
`;
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 = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
Expand Down
Loading