Skip to content

Commit 1de866f

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 1de866f

14 files changed

Lines changed: 1131 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: 145 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,16 @@ import {
1818
summarizeLayoutIssues,
1919
type LayoutIssue,
2020
} from "../utils/layoutAudit.js";
21+
import { collectSamplingTargets, evaluateMotion, type MotionFrame } from "../utils/motionAudit.js";
22+
import { findMotionSpec, readMotionSpec, type MotionSpec } from "../utils/motionSpec.js";
2123

2224
const __filename = fileURLToPath(import.meta.url);
2325
const __dirname = dirname(__filename);
2426
const SEEK_SETTLE_MS = 120;
2527
const INSPECT_SCHEMA_VERSION = 1;
28+
// Motion verification (#1437): dense sampling grid for the seeked-timeline checks.
29+
const MOTION_FPS = 20;
30+
const MOTION_MAX_SAMPLES = 300;
2631

2732
export const examples: Example[] = [
2833
["Inspect visual layout across the current composition", "hyperframes layout"],
@@ -33,6 +38,10 @@ export const examples: Example[] = [
3338
"Also sample at tween boundaries to catch transient overlaps",
3439
"hyperframes layout --at-transitions",
3540
],
41+
[
42+
"Verify motion intent (add a *.motion.json sidecar next to the composition)",
43+
"hyperframes layout --json",
44+
],
3645
];
3746

3847
interface LayoutAuditResult {
@@ -41,6 +50,14 @@ interface LayoutAuditResult {
4150
transitionSamples: number[];
4251
transitionSamplesDropped: number;
4352
rawIssues: LayoutIssue[];
53+
motionSamples: number;
54+
}
55+
56+
function buildMotionSampleTimes(duration: number): number[] {
57+
if (!Number.isFinite(duration) || duration <= 0) return [];
58+
const count = Math.min(MOTION_MAX_SAMPLES, Math.max(2, Math.ceil(duration * MOTION_FPS) + 1));
59+
const step = duration / (count - 1);
60+
return Array.from({ length: count }, (_, index) => Math.round(index * step * 1000) / 1000);
4461
}
4562

4663
async function getCompositionDuration(page: import("puppeteer-core").Page): Promise<number> {
@@ -205,6 +222,7 @@ async function runLayoutAudit(
205222
maxTransitionSamples?: number;
206223
timeout: number;
207224
tolerance: number;
225+
motion?: MotionSpec;
208226
},
209227
): Promise<LayoutAuditResult> {
210228
const { ensureBrowser } = await import("../browser/manager.js");
@@ -259,25 +277,14 @@ async function runLayoutAudit(
259277
transitionSamplesDropped = transitions.dropped;
260278
}
261279
const samples = mergeSampleTimes(baseSamples, transitionSamples);
262-
if (samples.length === 0) {
263-
return { duration, samples, transitionSamples, transitionSamplesDropped, rawIssues: [] };
264-
}
265280

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

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[]));
283+
let motionSamples = 0;
284+
if (opts.motion) {
285+
const motion = await runMotionPass(page, opts.motion, duration);
286+
issues.push(...motion.issues);
287+
motionSamples = motion.sampleCount;
281288
}
282289

283290
return {
@@ -286,24 +293,122 @@ async function runLayoutAudit(
286293
transitionSamples,
287294
transitionSamplesDropped,
288295
rawIssues: dedupeLayoutIssues(issues),
296+
motionSamples,
289297
};
290298
} finally {
291299
await chromeBrowser?.close().catch(() => {});
292300
await server.close();
293301
}
294302
}
295303

296-
function loadLayoutAuditScript(): string {
297-
const candidates = [
298-
join(__dirname, "layout-audit.browser.js"),
299-
join(__dirname, "commands", "layout-audit.browser.js"),
300-
];
301-
304+
function loadBrowserScript(name: string): string {
305+
const candidates = [join(__dirname, name), join(__dirname, "commands", name)];
302306
for (const candidate of candidates) {
303307
if (existsSync(candidate)) return readFileSync(candidate, "utf-8");
304308
}
309+
throw new Error(`Missing browser script ${name}`);
310+
}
311+
312+
function loadLayoutAuditScript(): string {
313+
return loadBrowserScript("layout-audit.browser.js");
314+
}
305315

306-
throw new Error("Missing layout audit browser script");
316+
async function collectLayoutIssues(
317+
page: import("puppeteer-core").Page,
318+
samples: number[],
319+
tolerance: number,
320+
): Promise<LayoutIssue[]> {
321+
if (samples.length === 0) return [];
322+
await page.addScriptTag({ content: loadLayoutAuditScript() });
323+
324+
const issues: LayoutIssue[] = [];
325+
for (const time of samples) {
326+
await seekTo(page, time);
327+
const sampleIssues = await page.evaluate(
328+
(auditOptions: { time: number; tolerance: number }) => {
329+
const win = window as unknown as {
330+
__hyperframesLayoutAudit?: (options: { time: number; tolerance: number }) => unknown[];
331+
};
332+
return win.__hyperframesLayoutAudit?.(auditOptions) ?? [];
333+
},
334+
{ time, tolerance },
335+
);
336+
issues.push(...(sampleIssues as LayoutIssue[]));
337+
}
338+
return issues;
339+
}
340+
341+
/**
342+
* Motion verification (#1437): sample the asserted selectors on a dense grid
343+
* against the same seeked timeline the renderer uses, then evaluate the spec's
344+
* assertions in Node. Reuses the live page from the layout audit — no extra
345+
* Chrome launch. Findings reuse the LayoutIssue shape.
346+
*/
347+
async function runMotionPass(
348+
page: import("puppeteer-core").Page,
349+
spec: MotionSpec,
350+
duration: number,
351+
): Promise<{ issues: LayoutIssue[]; sampleCount: number }> {
352+
const motionDuration = spec.duration ?? duration;
353+
const times = buildMotionSampleTimes(motionDuration);
354+
if (times.length === 0) return { issues: [], sampleCount: 0 };
355+
356+
const { selectors, livenessScopes } = collectSamplingTargets(spec.assertions);
357+
const canvas = await page.evaluate(() => ({
358+
width: window.innerWidth,
359+
height: window.innerHeight,
360+
}));
361+
362+
await page.addScriptTag({ content: loadBrowserScript("motion-sample.browser.js") });
363+
364+
const frames: MotionFrame[] = [];
365+
for (const time of times) {
366+
await seekTo(page, time);
367+
const sample = await page.evaluate(
368+
(options: { selectors: string[]; livenessScopes: string[] }) => {
369+
const win = window as unknown as {
370+
__hyperframesMotionSample?: (o: { selectors: string[]; livenessScopes: string[] }) => {
371+
data: MotionFrame["data"];
372+
liveness: Record<string, string>;
373+
};
374+
};
375+
return win.__hyperframesMotionSample?.(options) ?? { data: {}, liveness: {} };
376+
},
377+
{ selectors, livenessScopes },
378+
);
379+
frames.push({ time, data: sample.data, liveness: sample.liveness });
380+
}
381+
382+
return { issues: evaluateMotion(frames, spec.assertions, canvas), sampleCount: frames.length };
383+
}
384+
385+
/** Read + validate the motion sidecar; print the error and exit on a bad spec. */
386+
function resolveMotionSpec(specPath: string, json: boolean): MotionSpec {
387+
const parsed = readMotionSpec(specPath);
388+
if (parsed.ok) return parsed.spec;
389+
390+
const message = `Invalid motion spec ${specPath}: ${parsed.errors.join("; ")}`;
391+
if (json) {
392+
console.log(
393+
JSON.stringify(
394+
withMeta({
395+
schemaVersion: INSPECT_SCHEMA_VERSION,
396+
ok: false,
397+
error: message,
398+
issues: [],
399+
errorCount: 0,
400+
warningCount: 0,
401+
infoCount: 0,
402+
issueCount: 0,
403+
}),
404+
null,
405+
2,
406+
),
407+
);
408+
} else {
409+
console.error(`${c.error("✗")} ${message}`);
410+
}
411+
process.exit(1);
307412
}
308413

309414
function parseAt(value: unknown): number[] | undefined {
@@ -319,7 +424,8 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
319424
return defineCommand({
320425
meta: {
321426
name: commandName,
322-
description: "Inspect rendered composition layout for text and container overflow",
427+
description:
428+
"Inspect rendered composition layout for text/container overflow, plus optional motion verification via a *.motion.json sidecar",
323429
},
324430
args: {
325431
dir: { type: "positional", description: "Project directory", required: false },
@@ -386,11 +492,21 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
386492
const strict = !!args.strict;
387493
const collapseStatic = args["collapse-static"] !== false;
388494

495+
// Motion verification (#1437): an optional `*.motion.json` sidecar opts the
496+
// composition into seeked-timeline assertion checks. Absent → layout-only.
497+
const motionSpecPath = findMotionSpec(project.dir);
498+
const motionSpec = motionSpecPath
499+
? resolveMotionSpec(motionSpecPath, !!args.json)
500+
: undefined;
501+
389502
if (!args.json) {
390503
const baseLabel = at ? `${at.length} explicit timestamp(s)` : `${samples} timeline samples`;
391504
const sampleLabel = atTransitions ? `${baseLabel} + transition boundaries` : baseLabel;
505+
const motionLabel = motionSpec
506+
? ` + motion spec (${motionSpec.assertions.length} assertion(s))`
507+
: "";
392508
console.log(
393-
`${c.accent("◆")} Inspecting layout for ${c.accent(project.name)} (${sampleLabel})`,
509+
`${c.accent("◆")} Inspecting layout for ${c.accent(project.name)} (${sampleLabel}${motionLabel})`,
394510
);
395511
}
396512

@@ -402,6 +518,7 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
402518
maxTransitionSamples,
403519
timeout,
404520
tolerance,
521+
motion: motionSpec,
405522
});
406523
if (!args.json && result.transitionSamplesDropped > 0) {
407524
console.log(
@@ -429,6 +546,8 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
429546
tolerance,
430547
strict,
431548
collapseStatic,
549+
motionSpec: motionSpec ? motionSpecPath : undefined,
550+
motionSamples: motionSpec ? result.motionSamples : undefined,
432551
...summary,
433552
totalIssueCount: limited.totalIssueCount,
434553
truncated: limited.truncated,

0 commit comments

Comments
 (0)