diff --git a/packages/cli/src/capture/captureCompositionFrame.test.ts b/packages/cli/src/capture/captureCompositionFrame.test.ts index 46840097a4..9b45465c2c 100644 --- a/packages/cli/src/capture/captureCompositionFrame.test.ts +++ b/packages/cli/src/capture/captureCompositionFrame.test.ts @@ -3,6 +3,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { + AUDIT_SEEK_OPTIONS, + DENSE_GEOMETRY_SEEK_OPTIONS, captureRegionCrop, clampCropRegion, installPageFunctionGuard, @@ -374,3 +376,18 @@ describe("installPageFunctionGuard", () => { expect(shim(marker)).toBe(marker); }); }); + +describe("DENSE_GEOMETRY_SEEK_OPTIONS", () => { + it("is genuinely geometry-only — no post-seek settle waits at the 600-sample cap", () => { + // Regression for the dense content_overlap grid: geometry (getBoundingClientRect) + // is valid synchronously after setTime, so the dense pass must NOT inherit AUDIT's + // rAF settle / font wait / paint sleep — those multiply by sample count into tens of + // seconds. Any future edit that reintroduces a wait here must fail this test. + expect(DENSE_GEOMETRY_SEEK_OPTIONS.animationFrameSettle).toBe("none"); + expect(DENSE_GEOMETRY_SEEK_OPTIONS.waitForFontsMs).toBe(0); + expect(DENSE_GEOMETRY_SEEK_OPTIONS.settleMs).toBe(0); + // AUDIT (the full-settle path used by the base grid) must still carry the waits. + expect(AUDIT_SEEK_OPTIONS.animationFrameSettle).toBe("double"); + expect(AUDIT_SEEK_OPTIONS.settleMs).toBeGreaterThan(0); + }); +}); diff --git a/packages/cli/src/capture/captureCompositionFrame.ts b/packages/cli/src/capture/captureCompositionFrame.ts index fff9bf4b2e..115832b006 100644 --- a/packages/cli/src/capture/captureCompositionFrame.ts +++ b/packages/cli/src/capture/captureCompositionFrame.ts @@ -18,6 +18,22 @@ export const AUDIT_SEEK_OPTIONS = { settleMs: 120, } as const; +// Genuinely geometry-only variant for the dense content_overlap re-sampling +// grid. It keeps only the timeline setTime + preferred-target seek cascade and +// drops EVERY post-seek wait — no rAF settle, no font wait, no paint-settle +// sleep — because getBoundingClientRect geometry is valid synchronously once +// GSAP has written the inline transforms at setTime; a geometry-only reader +// never paints, so it needs none of the visual-stability waits. At up to +// OVERLAP_MAX_SAMPLES seeks the full AUDIT settle (double rAF + up to 500ms +// font wait + 120ms sleep) is ~30s of frame waits + ~72s of sleep with no DOM +// work; this variant eliminates all of it. +export const DENSE_GEOMETRY_SEEK_OPTIONS = { + ...AUDIT_SEEK_OPTIONS, + animationFrameSettle: "none", + waitForFontsMs: 0, + settleMs: 0, +} as const; + export interface SeekCompositionTimelineOptions { fallbackToBridgeAndTimelines?: boolean; waitForPreferredSeekTargetMs?: number; diff --git a/packages/cli/src/commands/check.test.ts b/packages/cli/src/commands/check.test.ts index cdac2483ae..0294359c18 100644 --- a/packages/cli/src/commands/check.test.ts +++ b/packages/cli/src/commands/check.test.ts @@ -147,7 +147,9 @@ function fakeDriver(overrides: Partial = {}): CheckAuditDriver getCanvas: vi.fn(async () => ({ width: 1920, height: 1080 })), findAmbiguousSelectors: vi.fn(async (_selectors: string[]) => []), seek: vi.fn(async (_time: number) => undefined), + seekGeometry: vi.fn(async (_time: number) => undefined), collectLayout: vi.fn(async (_time: number, _tolerance: number) => []), + collectOverlap: vi.fn(async (_time: number) => []), collectLayoutGeometry: vi.fn(async () => `geometry-${geometryCallCount++}`), collectRotationSample: vi.fn(async (_time: number) => []), collectOffPivotRotationSample: vi.fn(async (time: number) => ({ time, samples: [] })), @@ -1343,3 +1345,46 @@ describe("contrast candidate round-trip", () => { expect(source).not.toMatch(/prepared\.map\(\(entry\) => entry\.candidate\)/); }); }); + +describe("dense motion-overlap re-sampling", () => { + // The default grid is 9 base samples at index+0.5 (0.5,1.5,...,8.5) over a 9s + // composition; the collision below lives entirely inside (3.5, 4.5), a gap + // the sparse grid seeks straight past. Only the 8fps dense pass observes it. + const inBetweenGridWindow = (time: number): boolean => time >= 3.6 && time <= 4.4; + + it("detects a content_overlap that occurs ONLY between two sparse grid samples", async () => { + const driver = fakeDriver({ + // Sparse base grid sees nothing at any base sample time. + collectLayout: vi.fn(async (_time: number) => []), + // The transient exists only strictly between base samples 3.5 and 4.5. + collectOverlap: vi.fn(async (time: number) => + inBetweenGridWindow(time) + ? [layoutIssue("warning", { time, code: "content_overlap" })] + : [], + ), + }); + const { report } = await runScenario(driver); + expect(driver.collectOverlap).toHaveBeenCalled(); + expect(report.layout.findings.some((f) => f.code === "content_overlap")).toBe(true); + // Held ~750ms across the dense grid (>= the 500ms floor) -> promoted. + expect(report.layout.errorCount).toBeGreaterThan(0); + }); + + it("runs the dense pass even when sparse fingerprints are identical (aliased motion)", async () => { + // A constant geometry fingerprint no longer suppresses the pass: an + // animation aliased to the sparse grid has identical fingerprints yet still + // collides between samples — the false-negative the removed gate caused. + const driver = fakeDriver({ + collectLayoutGeometry: vi.fn(async () => "static"), + collectLayout: vi.fn(async (_time: number) => []), + collectOverlap: vi.fn(async (time: number) => + inBetweenGridWindow(time) + ? [layoutIssue("warning", { time, code: "content_overlap" })] + : [], + ), + }); + const { report } = await runScenario(driver); + expect(driver.collectOverlap).toHaveBeenCalled(); + expect(report.layout.findings.some((f) => f.code === "content_overlap")).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/layout-audit.browser.js b/packages/cli/src/commands/layout-audit.browser.js index 1ee9085dd4..6786d78da2 100644 --- a/packages/cli/src/commands/layout-audit.browser.js +++ b/packages/cli/src/commands/layout-audit.browser.js @@ -1425,6 +1425,20 @@ return issues; }; + // content_overlap only, for the dense motion re-sampling grid (checkPipeline + // collectMotionOverlapSamples). Two free-positioned text blocks crossing mid-orbit + // collide for a fraction of a second the sparse layout grid seeks straight + // past; this reruns just the overlap detector — same collectSolidTextBlocks / + // overlapIssue 0.2-area threshold, no new detection surface — on a fine grid. + window.__hyperframesOverlapAudit = function auditOverlap(options) { + const time = options && typeof options.time === "number" ? options.time : 0; + const root = + document.querySelector("[data-composition-id][data-width][data-height]") || + document.querySelector("[data-composition-id]") || + document.body; + return contentOverlapIssues(root, time); + }; + // Frozen-sweep guard (#U10, checkPipeline.ts): a compact per-sample // fingerprint of every visible element's box + opacity, in DOM order. Node // calls this once per seeked grid point and compares the strings across the diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index d392e82677..c47cced9ca 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -3,6 +3,7 @@ import { join, resolve } from "node:path"; import type { Page } from "puppeteer-core"; import { AUDIT_SEEK_OPTIONS, + DENSE_GEOMETRY_SEEK_OPTIONS, DEFAULT_ZOOM_PADDING_PX, DEFAULT_ZOOM_SCALE, captureRegionCrop, @@ -348,7 +349,12 @@ function createPageDriver(page: Page, setTime: (time: number) => void): CheckAud setTime(time); await seekCompositionTimeline(page, time, AUDIT_SEEK_OPTIONS); }, + seekGeometry: async (time) => { + setTime(time); + await seekCompositionTimeline(page, time, DENSE_GEOMETRY_SEEK_OPTIONS); + }, collectLayout: (time, tolerance) => collectLayout(page, time, tolerance), + collectOverlap: (time) => collectOverlap(page, time), collectLayoutGeometry: () => collectLayoutGeometry(page), collectRotationSample: (time) => collectRotationSample(page, time), collectOffPivotRotationSample: (time) => collectOffPivotRotationSample(page, time), @@ -464,6 +470,19 @@ async function collectLayout( return anchorLayoutIssues(page, raw.flatMap(parseLayoutIssue)); } +async function collectOverlap(page: Page, time: number): Promise { + const raw = await page.evaluate( + (options: { time: number }) => { + const audit = Reflect.get(window, "__hyperframesOverlapAudit"); + if (typeof audit !== "function") return []; + const result = Reflect.apply(audit, window, [options]); + return Array.isArray(result) ? result : []; + }, + { time }, + ); + return anchorLayoutIssues(page, raw.flatMap(parseLayoutIssue)); +} + async function collectLayoutGeometry(page: Page): Promise { return page.evaluate(() => { const geometry = Reflect.get(window, "__hyperframesLayoutGeometry"); diff --git a/packages/cli/src/utils/checkPipeline.ts b/packages/cli/src/utils/checkPipeline.ts index 6f5145c8ff..7d45065d4d 100644 --- a/packages/cli/src/utils/checkPipeline.ts +++ b/packages/cli/src/utils/checkPipeline.ts @@ -417,9 +417,73 @@ async function collectGridSamples( collected.screenshots.push({ time, pngBase64: capture.pngBase64 }); } } + await collectMotionOverlapSamples(driver, grid, collected); return collected; } +// content_overlap sampling density for the dense motion re-pass. The sparse +// layout grid (default 9 points over multiple seconds) seeks straight past a +// mid-orbit text-on-text crossing that only overlaps for a fraction of a +// second: an in-corpus orbit (samples/fuzz016) collides at 28% area for ~0.4s, +// entirely between two adjacent base samples. 8fps (~0.125s spacing) lands +// enough samples inside a window that narrow to observe it. Overlap collection +// is text-only (collectSolidTextBlocks), far cheaper than a full layout audit, +// so a fine grid here is affordable where densifying every detector would not. +const OVERLAP_SAMPLE_FPS = 8; +// Absolute ceiling on dense seeks so the pass stays bounded. This holds a true +// 8fps grid for compositions up to OVERLAP_MAX_SAMPLES / OVERLAP_SAMPLE_FPS +// (~75s); longer compositions degrade below 8fps rather than growing the seek +// budget without limit. (Corpus compositions run 7-8s, well inside 8fps.) +const OVERLAP_MAX_SAMPLES = 600; + +function buildOverlapSampleTimes(duration: number): number[] { + if (!Number.isFinite(duration) || duration <= 0) return []; + const count = Math.min( + OVERLAP_MAX_SAMPLES, + Math.max(2, Math.ceil(duration * OVERLAP_SAMPLE_FPS) + 1), + ); + const step = duration / (count - 1); + return mergeSampleTimes( + Array.from({ length: count }, (_, index) => Math.round(index * step * 1000) / 1000), + ); +} + +/** + * Dense motion-overlap re-sampling. Reruns ONLY content_overlap on a fine time + * grid so transient text collisions during continuous motion are observed at + * all — the detector itself is unchanged (same 0.2-area threshold), only the + * sampling density is. + * + * This runs UNCONDITIONALLY (bounded + text-only), NOT gated on sparse-grid + * geometry fingerprints changing. That gate was the motivating false-negative: + * an animation aliased to the sparse grid (the same pose sampled at every base + * point) has identical fingerprints yet still collides *between* those samples, + * so gating on fingerprint change skipped the exact transient this pass exists + * to catch. A static composition simply yields no overlaps at the extra times, + * so the only cost of running always is a bounded set of cheap text-only seeks. + * Findings feed the existing collapse/persistence tiering (a graze stays info, + * a held collision re-promotes to error). Skips times already in the base grid + * to avoid double-collecting overlaps collectLayout already found. + */ +async function collectMotionOverlapSamples( + driver: CheckAuditDriver, + grid: SampleGrid, + collected: GridSamples, +): Promise { + const baseTimes = new Set(grid.layoutSamples); + for (const time of buildOverlapSampleTimes(grid.duration)) { + if (baseTimes.has(time)) continue; + // Settle-free seek: collectOverlap reads getBoundingClientRect geometry + // only, which is valid synchronously after the timeline setTime (GSAP + // writes inline transforms synchronously). The dense pass makes up to + // OVERLAP_MAX_SAMPLES seeks, so skipping the 120ms per-seek paint settle + // here (vs. the full-settle driver.seek the base grid uses to feed + // contrast/rotation/frame checks) removes ~72s of pure sleep at the ceiling. + await driver.seekGeometry(time); + collected.layoutIssues.push(...(await driver.collectOverlap(time))); + } +} + // Frozen-sweep guard (#U10): compositions this short can legitimately hold a // single static frame the whole time (a title card) — never flag those. const SWEEP_STATIC_MIN_DURATION_SEC = 3; diff --git a/packages/cli/src/utils/checkTypes.ts b/packages/cli/src/utils/checkTypes.ts index 569051724a..2007ea5b19 100644 --- a/packages/cli/src/utils/checkTypes.ts +++ b/packages/cli/src/utils/checkTypes.ts @@ -173,7 +173,16 @@ export interface CheckAuditDriver { getCanvas(): Promise; findAmbiguousSelectors(selectors: string[]): Promise; seek(time: number): Promise; + /** Cheap settle-free seek for the geometry-only dense content_overlap pass: + * setTime + timeline seek with no paint-settle sleep. Only collectOverlap + * (getBoundingClientRect geometry) consumes the result, which is valid + * synchronously after setTime — see checkPipeline collectMotionOverlapSamples. */ + seekGeometry(time: number): Promise; collectLayout(time: number, tolerance: number): Promise; + /** content_overlap only, for the dense motion re-sampling grid — catches + * transient text-on-text collisions the sparse layout grid seeks past. See + * checkPipeline detectMotionTextOverlap. */ + collectOverlap(time: number): Promise; /** Frozen-sweep guard (#U10): an opaque per-sample geometry+opacity * fingerprint of the current seeked state, for detecting a timeline that * never advances under seek. See layout-audit.browser.js. */ diff --git a/packages/cli/src/utils/layoutAudit.test.ts b/packages/cli/src/utils/layoutAudit.test.ts index bccb025ae7..fd99028217 100644 --- a/packages/cli/src/utils/layoutAudit.test.ts +++ b/packages/cli/src/utils/layoutAudit.test.ts @@ -227,6 +227,50 @@ describe("persistence-tiered severity (#U10)", () => { expect(collapsed[0]).toMatchObject({ severity: "error", occurrences: 2 }); }); + it("keeps a content_overlap that spans under the 500ms floor as a warning, even with 2 occurrences", () => { + // Two occurrences from the dense 8fps re-pass span only ~125ms — under the + // held-duration floor, so occurrences>=2 alone must NOT promote to error. + const collapsed = collapseStaticLayoutIssues( + [ + { ...issue("content_overlap", "warning"), time: 4.0 }, + { ...issue("content_overlap", "warning"), time: 4.125 }, + ], + 73, + ); + + expect(collapsed).toHaveLength(1); + expect(collapsed[0]).toMatchObject({ severity: "warning", occurrences: 2 }); + }); + + it("does NOT promote content_overlap whose two occurrences span exactly 499ms (under the floor)", () => { + // Boundary: a two-occurrence span one millisecond short of the 500ms floor + // stays a warning. Guards the AND-tighten for sparse callers (--samples 20, + // --at, short comps) whose two samples can land <500ms apart. + const collapsed = collapseStaticLayoutIssues( + [ + { ...issue("content_overlap", "warning"), time: 4.0 }, + { ...issue("content_overlap", "warning"), time: 4.499 }, + ], + 73, + ); + + expect(collapsed).toHaveLength(1); + expect(collapsed[0]).toMatchObject({ severity: "warning", occurrences: 2 }); + }); + + it("promotes content_overlap whose two occurrences span exactly 500ms (at the floor)", () => { + const collapsed = collapseStaticLayoutIssues( + [ + { ...issue("content_overlap", "warning"), time: 4.0 }, + { ...issue("content_overlap", "warning"), time: 4.5 }, + ], + 73, + ); + + expect(collapsed).toHaveLength(1); + expect(collapsed[0]).toMatchObject({ severity: "error", occurrences: 2 }); + }); + it("promotes a held, canvas-scale canvas_overflow breach from info to warning", () => { const breach = { ...issue("canvas_overflow", "info"), diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index 678e1210c6..d9b3531609 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -183,19 +183,23 @@ export function dedupeLayoutIssues(issues: LayoutIssue[]): LayoutIssue[] { // Persistence-tier thresholds (#U10, adapted from Adam Rosler's visual-linter // design). The approach doc frames these as held-duration floors — ignore -// under ~250ms, re-promote content_overlap at >= ~500ms — measured against -// the SAME firstSeen/lastSeen span this collapse step already tracks. At the -// default 9-sample grid over a multi-second composition, a single collapsed -// occurrence is held 0ms (one entrance/exit transient sample) and two -// collapsed occurrences are already >= one sample-to-sample gap, which is -// well past 500ms — so "held under 250ms" reduces to `occurrences <= 1` and -// "held >= 500ms" reduces to `occurrences >= 2`. Tiering below is written in -// those sample-count terms (the mapping the approach doc asks to document), -// with the literal ms span (CONTENT_OVERLAP_HELD_ERROR_MS) kept as a fallback -// for callers whose samples really are spaced close enough together for the -// ms floor to matter on its own (dense `--at`/`--at-transitions` runs). The -// ~250ms ignore floor needs no separate constant — see the occurrences <= 1 -// branch below. +// under ~250ms, re-promote content_overlap at >= ~500ms — measured against the +// SAME firstSeen/lastSeen span this collapse step already tracks. `occurrences` +// is a NECESSARY guard (one sample can't span any duration), but it is NOT a +// sufficient proxy for the 500ms floor: the dense content_overlap re-pass +// (checkPipeline collectMotionOverlapSamples) samples at 8fps, so two adjacent +// occurrences there span only ~125ms — the old "occurrences >= 2 => held >= +// 500ms" shortcut held only for the coarse ~1s-spaced base grid and breaks +// under dense sampling. content_overlap promotion therefore requires BOTH +// occurrences >= 2 AND a literal firstSeen..lastSeen span >= 500ms, so the +// wall-clock floor is honored regardless of sampling density. The ~250ms +// ignore floor needs no separate constant — see the occurrences <= 1 branch. +// +// SEMANTICS CHANGE (intentional): this is stricter than the prior +// occurrences >= 2 => error rule for SPARSE callers too, not only the dense +// pass. Under `--samples 20`, `--at`, or a short composition, two adjacent +// samples can land < 500ms apart; such a pair now stays a warning instead of +// auto-promoting to error. Only a genuinely held (>= 500ms) collision promotes. const CONTENT_OVERLAP_HELD_ERROR_MS = 500; const HELD_ACROSS_SAMPLES_MIN_OCCURRENCES = 2; @@ -317,11 +321,14 @@ function isCanvasBreachHeldLarge(issue: LayoutIssue, occurrences: number): boole return overlapX > 0 && overlapY > 0; } -// Split out of applyPersistenceTier so the two independent "held long enough" -// signals (sample count vs. wall-clock span) read as one boolean question -// instead of adding a third compound branch to the tiering ladder above. +// Split out of applyPersistenceTier so the compound "held long enough" test +// (>= 2 samples AND wall-clock span >= the ms floor) reads as one boolean +// question instead of adding a compound branch to the tiering ladder above. function isContentOverlapHeldLongEnough(issue: LayoutIssue, occurrences: number): boolean { - if (occurrences >= HELD_ACROSS_SAMPLES_MIN_OCCURRENCES) return true; + // Need at least two samples to measure a span at all, AND that span must + // clear the wall-clock floor — dense 8fps re-sampling makes occurrences>=2 + // alone (potentially ~125ms) too weak to imply a genuinely held collision. + if (occurrences < HELD_ACROSS_SAMPLES_MIN_OCCURRENCES) return false; const firstSeen = issue.firstSeen ?? issue.time; const lastSeen = issue.lastSeen ?? issue.time; const heldMs = (lastSeen - firstSeen) * 1000;