diff --git a/packages/cli/src/commands/motion-signature.browser.chromium.test.ts b/packages/cli/src/commands/motion-signature.browser.chromium.test.ts
index 40d1746453..54c8c4b9c0 100644
--- a/packages/cli/src/commands/motion-signature.browser.chromium.test.ts
+++ b/packages/cli/src/commands/motion-signature.browser.chromium.test.ts
@@ -568,6 +568,23 @@ describe.skipIf(!RUNS_CHROMIUM)("motion-signature.browser in Chromium", () => {
expect(after).toEqual(before);
});
+ it("sees a small sprite move across a large canvas", async () => {
+ await load(
+ composition(
+ "#c { width: 640px; height: 360px; }",
+ '',
+ ),
+ );
+ const draw = (x: number) =>
+ `{ const ctx = document.getElementById("c").getContext("2d"); ctx.clearRect(0, 0, 1080, 1920); ctx.fillStyle = "#fc8"; ctx.fillRect(${x}, 500, 24, 24); }`;
+ await mutate(draw(300));
+ const before = await sample();
+ await mutate(draw(308));
+ const after = await sample();
+
+ expect(after.liveness).not.toBe(before.liveness);
+ });
+
it("ignores a repaint of a canvas whose contents are skipped by content-visibility:hidden", async () => {
await load(
composition(
diff --git a/packages/cli/src/commands/motion-signature.browser.js b/packages/cli/src/commands/motion-signature.browser.js
index 29705291ac..c3922e7329 100644
--- a/packages/cli/src/commands/motion-signature.browser.js
+++ b/packages/cli/src/commands/motion-signature.browser.js
@@ -6,6 +6,8 @@
(function () {
const IGNORE_TAGS = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "META", "LINK"]);
const MEDIA_TAGS = new Set(["CANVAS", "VIDEO", "IMG"]);
+ const COARSE_MEDIA_SAMPLE_SIDE = 8;
+ const LIVENESS_MEDIA_SAMPLE_SIDE = 32;
const FNV_OFFSET_BASIS = 2166136261;
const FNV_PRIME = 16777619;
const LIVENESS_POSITION_BUCKET_PX = 2;
@@ -221,9 +223,10 @@
}
// Pixel-only media motion (canvas repaint, playing video, same-size swap) is invisible to DOM channels,
- // so fold in an 8x8 downsample. Unreadable media hashes to a constant; iframe media is a separate document.
+ // so fold in a downsample: fine for liveness (a small sprite moving), coarse for the frozen-sweep guard,
+ // which a wall-clock repaint between seeks would fool. Unreadable media hashes to a constant.
// fallow-ignore-next-line complexity
- function mediaPixelChannel(element) {
+ function mediaPixelChannel(element, ctx) {
if (!MEDIA_TAGS.has(element.tagName)) return "";
try {
const rect = element.getBoundingClientRect();
@@ -231,12 +234,14 @@
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 side = ctx && ctx.quantize ? LIVENESS_MEDIA_SAMPLE_SIDE : COARSE_MEDIA_SAMPLE_SIDE;
+ off.width = side;
+ off.height = side;
const ctx2d = off.getContext("2d");
if (!ctx2d) return "x";
- ctx2d.drawImage(element, 0, 0, 8, 8);
- const data = ctx2d.getImageData(0, 0, 8, 8).data;
+ if (side === LIVENESS_MEDIA_SAMPLE_SIDE) ctx2d.imageSmoothingQuality = "high";
+ ctx2d.drawImage(element, 0, 0, side, side);
+ const data = ctx2d.getImageData(0, 0, side, side).data;
let hash = 0;
for (let i = 0; i < data.length; i++) hash = (hash * 31 + data[i]) >>> 0;
return String(hash);
diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts
index 17779e5435..efc9d00fe6 100644
--- a/packages/lint/src/rules/gsap.test.ts
+++ b/packages/lint/src/rules/gsap.test.ts
@@ -3721,6 +3721,61 @@ describe("SVG draw-on rules", () => {
expect(finding).toBeUndefined();
});
+ describe("gsap_fullscreen_overlay_starts_visible — a scene child hidden through a scoped query", () => {
+ const scene = (hide: string) => `
+