Skip to content

Commit 0f834fa

Browse files
committed
feat(cli): declarative motion verification in inspect (#1437)
Extend `inspect` to verify motion intent against the same seeked timeline the renderer uses, catching render-≠-preview bugs that layout sampling can't: entrance reveals the seek skips, broken stagger order, off-frame drift, and frozen shots. A `*.motion.json` sidecar next to the composition opts in (auto-discovered, no flag, no authoring-framework changes); without one, inspect is unchanged. inspect seeks a dense grid over the asserted selectors, builds an element × time matrix of {rect, opacity, visible} plus per-scope liveness signatures, and evaluates four assertions in Node: appearsBy -> motion_appears_late before -> motion_out_of_order staysInFrame -> motion_off_frame keepsMoving -> motion_frozen A selector matching nothing is reported as motion_selector_missing rather than silently passing. Findings reuse the LayoutIssue shape and flow through the existing dedupe/collapse/limit/format pipeline and JSON envelope; they are errors by default, so a failed assertion fails the run. The motion pass runs in the same Chrome session as the layout audit (no extra launch) and only when a sidecar is present.
1 parent 1e54827 commit 0f834fa

14 files changed

Lines changed: 1230 additions & 30 deletions

File tree

.fallowrc.jsonc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
// path, never imported, so they have no import-graph referrer.
2626
"packages/cli/src/commands/layout-audit.browser.js",
2727
"packages/cli/src/commands/contrast-audit.browser.js",
28+
"packages/cli/src/commands/motion-sample.browser.js",
2829
// Worker entry points loaded dynamically by their *Pool.ts companions.
2930
"packages/producer/src/services/pngDecodeBlitWorker.ts",
3031
"packages/producer/src/services/shaderTransitionWorker.ts",

docs/packages/cli.mdx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ npx hyperframes <command>
1919
- Preview compositions with live hot reload (`preview`)
2020
- Render compositions to MP4 locally or in Docker (`render`)
2121
- Lint compositions for structural issues (`lint`)
22-
- Inspect rendered visual layout for text overflow, clipped containers, and overlapping text (`inspect`)
22+
- Inspect rendered visual layout for text overflow, clipped containers, and overlapping text, plus verify motion intent against the seeked timeline (`inspect`)
2323
- Capture key frames as PNG screenshots (`snapshot`)
2424
- Check your environment for missing dependencies (`doctor`)
2525

@@ -580,6 +580,31 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
580580
npx hyperframes layout [dir] --json
581581
```
582582

583+
#### Motion verification
584+
585+
`inspect` also checks **motion intent** against the same seeked timeline the renderer uses — catching the render-≠-preview bugs that layout sampling can't, like an entrance reveal the seek skips, a broken stagger order, an element that drifts off-frame mid-tween, or a shot that freezes. Drop a `*.motion.json` sidecar next to the composition and `inspect` evaluates it automatically (no flag, no authoring changes); without a sidecar, `inspect` behaves exactly as before.
586+
587+
```json
588+
{
589+
"duration": 6,
590+
"assertions": [
591+
{ "kind": "appearsBy", "selector": "#headline", "bySec": 0.5 },
592+
{ "kind": "before", "a": "#headline", "b": "#cta" },
593+
{ "kind": "staysInFrame", "selector": ".card" },
594+
{ "kind": "keepsMoving", "withinSelector": ".scene" }
595+
]
596+
}
597+
```
598+
599+
| Assertion | Checks |
600+
|-----------|--------|
601+
| `appearsBy(selector, bySec)` | the element is visible (opacity ≥ 0.5) no later than `bySec` — catches reveals the seek lands past (`motion_appears_late`) |
602+
| `before(a, b)` | `a` first appears strictly before `b` — catches broken stagger order (`motion_out_of_order`) |
603+
| `staysInFrame(selector)` | once visible, the element's box never leaves the canvas — catches off-frame drift (`motion_off_frame`) |
604+
| `keepsMoving(withinSelector?)` | no fully-static window longer than `maxStaticSec` (default 2s) — catches frozen shots (`motion_frozen`) |
605+
606+
`duration`, `keepsMoving.withinSelector`, and `keepsMoving.maxStaticSec` are optional. Findings are reported in the same shape and JSON envelope as layout findings, are **errors by default** (a failed assertion fails the run), and a selector that matches nothing is reported as `motion_selector_missing` rather than silently passing.
607+
583608
### `snapshot`
584609

585610
Capture key frames from a composition as PNG screenshots — verify visual output without a full render:

packages/cli/scripts/build-copy.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ async function main() {
9797
cpSync(contrastAuditScript, join(DIST, "commands", "contrast-audit.browser.js"));
9898
}
9999

100+
const motionSampleScript = join(CLI_ROOT, "src", "commands", "motion-sample.browser.js");
101+
if (existsSync(motionSampleScript)) {
102+
cpSync(motionSampleScript, join(DIST, "commands", "motion-sample.browser.js"));
103+
}
104+
100105
copyMdFiles(join(CLI_ROOT, "src", "docs"), join(DIST, "docs"));
101106

102107
console.log("[build-copy] done");

packages/cli/src/commands/inspect.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ export const examples: Example[] = [
1010
"Also sample at tween boundaries to catch transient overlaps",
1111
"hyperframes inspect --at-transitions",
1212
],
13+
[
14+
"Verify motion intent (add a *.motion.json sidecar next to the composition)",
15+
"hyperframes inspect --json",
16+
],
1317
["Run the compatibility alias", "hyperframes layout --json"],
1418
];
1519

packages/cli/src/commands/layout.ts

Lines changed: 180 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,22 @@ import {
1818
summarizeLayoutIssues,
1919
type LayoutIssue,
2020
} from "../utils/layoutAudit.js";
21+
import {
22+
ambiguousIssue,
23+
collectSamplingTargets,
24+
evaluateMotion,
25+
type MotionFrame,
26+
} from "../utils/motionAudit.js";
27+
import { findMotionSpec, readMotionSpec, type MotionSpec } from "../utils/motionSpec.js";
2128

2229
const __filename = fileURLToPath(import.meta.url);
2330
const __dirname = dirname(__filename);
2431
const SEEK_SETTLE_MS = 120;
32+
// All new envelope fields are optional (?); additive changes don't bump this.
2533
const INSPECT_SCHEMA_VERSION = 1;
34+
// Motion verification (#1437): dense sampling grid for the seeked-timeline checks.
35+
const MOTION_FPS = 20;
36+
const MOTION_MAX_SAMPLES = 300;
2637

2738
export const examples: Example[] = [
2839
["Inspect visual layout across the current composition", "hyperframes layout"],
@@ -33,6 +44,10 @@ export const examples: Example[] = [
3344
"Also sample at tween boundaries to catch transient overlaps",
3445
"hyperframes layout --at-transitions",
3546
],
47+
[
48+
"Verify motion intent (add a *.motion.json sidecar next to the composition)",
49+
"hyperframes layout --json",
50+
],
3651
];
3752

3853
interface LayoutAuditResult {
@@ -41,6 +56,14 @@ interface LayoutAuditResult {
4156
transitionSamples: number[];
4257
transitionSamplesDropped: number;
4358
rawIssues: LayoutIssue[];
59+
motionSamples: number;
60+
}
61+
62+
function buildMotionSampleTimes(duration: number): number[] {
63+
if (!Number.isFinite(duration) || duration <= 0) return [];
64+
const count = Math.min(MOTION_MAX_SAMPLES, Math.max(2, Math.ceil(duration * MOTION_FPS) + 1));
65+
const step = duration / (count - 1);
66+
return Array.from({ length: count }, (_, index) => Math.round(index * step * 1000) / 1000);
4467
}
4568

4669
async function getCompositionDuration(page: import("puppeteer-core").Page): Promise<number> {
@@ -205,6 +228,7 @@ async function runLayoutAudit(
205228
maxTransitionSamples?: number;
206229
timeout: number;
207230
tolerance: number;
231+
motion?: MotionSpec;
208232
},
209233
): Promise<LayoutAuditResult> {
210234
const { ensureBrowser } = await import("../browser/manager.js");
@@ -259,25 +283,14 @@ async function runLayoutAudit(
259283
transitionSamplesDropped = transitions.dropped;
260284
}
261285
const samples = mergeSampleTimes(baseSamples, transitionSamples);
262-
if (samples.length === 0) {
263-
return { duration, samples, transitionSamples, transitionSamplesDropped, rawIssues: [] };
264-
}
265286

266-
await page.addScriptTag({ content: loadLayoutAuditScript() });
287+
const issues = await collectLayoutIssues(page, samples, opts.tolerance);
267288

268-
const issues: LayoutIssue[] = [];
269-
for (const time of samples) {
270-
await seekTo(page, time);
271-
const sampleIssues = await page.evaluate(
272-
(auditOptions: { time: number; tolerance: number }) => {
273-
const win = window as unknown as {
274-
__hyperframesLayoutAudit?: (options: { time: number; tolerance: number }) => unknown[];
275-
};
276-
return win.__hyperframesLayoutAudit?.(auditOptions) ?? [];
277-
},
278-
{ time, tolerance: opts.tolerance },
279-
);
280-
issues.push(...(sampleIssues as LayoutIssue[]));
289+
let motionSamples = 0;
290+
if (opts.motion) {
291+
const motion = await runMotionPass(page, opts.motion, duration);
292+
issues.push(...motion.issues);
293+
motionSamples = motion.sampleCount;
281294
}
282295

283296
return {
@@ -286,24 +299,151 @@ async function runLayoutAudit(
286299
transitionSamples,
287300
transitionSamplesDropped,
288301
rawIssues: dedupeLayoutIssues(issues),
302+
motionSamples,
289303
};
290304
} finally {
291305
await chromeBrowser?.close().catch(() => {});
292306
await server.close();
293307
}
294308
}
295309

296-
function loadLayoutAuditScript(): string {
297-
const candidates = [
298-
join(__dirname, "layout-audit.browser.js"),
299-
join(__dirname, "commands", "layout-audit.browser.js"),
300-
];
301-
310+
function loadBrowserScript(name: string): string {
311+
const candidates = [join(__dirname, name), join(__dirname, "commands", name)];
302312
for (const candidate of candidates) {
303313
if (existsSync(candidate)) return readFileSync(candidate, "utf-8");
304314
}
315+
throw new Error(`Missing browser script ${name}`);
316+
}
305317

306-
throw new Error("Missing layout audit browser script");
318+
function loadLayoutAuditScript(): string {
319+
return loadBrowserScript("layout-audit.browser.js");
320+
}
321+
322+
async function collectLayoutIssues(
323+
page: import("puppeteer-core").Page,
324+
samples: number[],
325+
tolerance: number,
326+
): Promise<LayoutIssue[]> {
327+
if (samples.length === 0) return [];
328+
await page.addScriptTag({ content: loadLayoutAuditScript() });
329+
330+
const issues: LayoutIssue[] = [];
331+
for (const time of samples) {
332+
await seekTo(page, time);
333+
const sampleIssues = await page.evaluate(
334+
(auditOptions: { time: number; tolerance: number }) => {
335+
const win = window as unknown as {
336+
__hyperframesLayoutAudit?: (options: { time: number; tolerance: number }) => unknown[];
337+
};
338+
return win.__hyperframesLayoutAudit?.(auditOptions) ?? [];
339+
},
340+
{ time, tolerance },
341+
);
342+
issues.push(...(sampleIssues as LayoutIssue[]));
343+
}
344+
return issues;
345+
}
346+
347+
/** Reject selectors matching multiple elements — first-match-only sampling silently passes for siblings. */
348+
async function findAmbiguousSelectors(
349+
page: import("puppeteer-core").Page,
350+
selectors: string[],
351+
): Promise<LayoutIssue[]> {
352+
if (selectors.length === 0) return [];
353+
const multiMatch = await page.evaluate(
354+
(sels: string[]) =>
355+
sels.filter((sel) => {
356+
try {
357+
return document.querySelectorAll(sel).length > 1;
358+
} catch {
359+
return false;
360+
}
361+
}),
362+
selectors,
363+
);
364+
return multiMatch.map(ambiguousIssue);
365+
}
366+
367+
async function collectMotionFrames(
368+
page: import("puppeteer-core").Page,
369+
times: number[],
370+
selectors: string[],
371+
livenessScopes: string[],
372+
): Promise<MotionFrame[]> {
373+
const frames: MotionFrame[] = [];
374+
for (const time of times) {
375+
await seekTo(page, time);
376+
const sample = await page.evaluate(
377+
(options: { selectors: string[]; livenessScopes: string[] }) => {
378+
const win = window as unknown as {
379+
__hyperframesMotionSample?: (o: { selectors: string[]; livenessScopes: string[] }) => {
380+
data: MotionFrame["data"];
381+
liveness: Record<string, string>;
382+
};
383+
};
384+
return win.__hyperframesMotionSample?.(options) ?? { data: {}, liveness: {} };
385+
},
386+
{ selectors, livenessScopes },
387+
);
388+
frames.push({ time, data: sample.data, liveness: sample.liveness });
389+
}
390+
return frames;
391+
}
392+
393+
/**
394+
* Motion verification (#1437): sample the asserted selectors on a dense grid
395+
* against the same seeked timeline the renderer uses, then evaluate the spec's
396+
* assertions in Node. Reuses the live page from the layout audit — no extra
397+
* Chrome launch. Findings reuse the LayoutIssue shape.
398+
*/
399+
async function runMotionPass(
400+
page: import("puppeteer-core").Page,
401+
spec: MotionSpec,
402+
duration: number,
403+
): Promise<{ issues: LayoutIssue[]; sampleCount: number }> {
404+
const times = buildMotionSampleTimes(spec.duration ?? duration);
405+
if (times.length === 0) return { issues: [], sampleCount: 0 };
406+
407+
const { selectors, livenessScopes } = collectSamplingTargets(spec.assertions);
408+
const ambiguous = await findAmbiguousSelectors(page, selectors);
409+
if (ambiguous.length > 0) return { issues: ambiguous, sampleCount: 0 };
410+
411+
const canvas = await page.evaluate(() => ({
412+
width: window.innerWidth,
413+
height: window.innerHeight,
414+
}));
415+
await page.addScriptTag({ content: loadBrowserScript("motion-sample.browser.js") });
416+
const frames = await collectMotionFrames(page, times, selectors, livenessScopes);
417+
return { issues: evaluateMotion(frames, spec.assertions, canvas), sampleCount: frames.length };
418+
}
419+
420+
/** Read + validate the motion sidecar; print the error and exit on a bad spec. */
421+
function resolveMotionSpec(specPath: string, json: boolean): MotionSpec {
422+
const parsed = readMotionSpec(specPath);
423+
if (parsed.ok) return parsed.spec;
424+
425+
const message = `Invalid motion spec ${specPath}: ${parsed.errors.join("; ")}`;
426+
if (json) {
427+
console.log(
428+
JSON.stringify(
429+
withMeta({
430+
schemaVersion: INSPECT_SCHEMA_VERSION,
431+
ok: false,
432+
error: message,
433+
issues: [],
434+
errorCount: 0,
435+
warningCount: 0,
436+
infoCount: 0,
437+
issueCount: 0,
438+
}),
439+
null,
440+
2,
441+
),
442+
);
443+
} else {
444+
console.error(`${c.error("✗")} ${message}`);
445+
}
446+
process.exit(1);
307447
}
308448

309449
function parseAt(value: unknown): number[] | undefined {
@@ -319,7 +459,8 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
319459
return defineCommand({
320460
meta: {
321461
name: commandName,
322-
description: "Inspect rendered composition layout for text and container overflow",
462+
description:
463+
"Inspect rendered composition layout for text/container overflow, plus optional motion verification via a *.motion.json sidecar",
323464
},
324465
args: {
325466
dir: { type: "positional", description: "Project directory", required: false },
@@ -386,11 +527,21 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
386527
const strict = !!args.strict;
387528
const collapseStatic = args["collapse-static"] !== false;
388529

530+
// Motion verification (#1437): an optional `*.motion.json` sidecar opts the
531+
// composition into seeked-timeline assertion checks. Absent → layout-only.
532+
const motionSpecPath = findMotionSpec(project.dir);
533+
const motionSpec = motionSpecPath
534+
? resolveMotionSpec(motionSpecPath, !!args.json)
535+
: undefined;
536+
389537
if (!args.json) {
390538
const baseLabel = at ? `${at.length} explicit timestamp(s)` : `${samples} timeline samples`;
391539
const sampleLabel = atTransitions ? `${baseLabel} + transition boundaries` : baseLabel;
540+
const motionLabel = motionSpec
541+
? ` + motion spec (${motionSpec.assertions.length} assertion(s))`
542+
: "";
392543
console.log(
393-
`${c.accent("◆")} Inspecting layout for ${c.accent(project.name)} (${sampleLabel})`,
544+
`${c.accent("◆")} Inspecting layout for ${c.accent(project.name)} (${sampleLabel}${motionLabel})`,
394545
);
395546
}
396547

@@ -402,6 +553,7 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
402553
maxTransitionSamples,
403554
timeout,
404555
tolerance,
556+
motion: motionSpec,
405557
});
406558
if (!args.json && result.transitionSamplesDropped > 0) {
407559
console.log(
@@ -429,6 +581,8 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
429581
tolerance,
430582
strict,
431583
collapseStatic,
584+
motionSpec: motionSpec ? motionSpecPath : undefined,
585+
motionSamples: motionSpec ? result.motionSamples : undefined,
432586
...summary,
433587
totalIssueCount: limited.totalIssueCount,
434588
truncated: limited.truncated,

0 commit comments

Comments
 (0)