mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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.
This commit is contained in:
@@ -25,6 +25,7 @@
|
||||
// path, never imported, so they have no import-graph referrer.
|
||||
"packages/cli/src/commands/layout-audit.browser.js",
|
||||
"packages/cli/src/commands/contrast-audit.browser.js",
|
||||
"packages/cli/src/commands/motion-sample.browser.js",
|
||||
// Worker entry points loaded dynamically by their *Pool.ts companions.
|
||||
"packages/producer/src/services/pngDecodeBlitWorker.ts",
|
||||
"packages/producer/src/services/shaderTransitionWorker.ts",
|
||||
|
||||
+26
-1
@@ -19,7 +19,7 @@ npx hyperframes <command>
|
||||
- Preview compositions with live hot reload (`preview`)
|
||||
- Render compositions to MP4 locally or in Docker (`render`)
|
||||
- Lint compositions for structural issues (`lint`)
|
||||
- Inspect rendered visual layout for text overflow, clipped containers, and overlapping text (`inspect`)
|
||||
- Inspect rendered visual layout for text overflow, clipped containers, and overlapping text, plus verify motion intent against the seeked timeline (`inspect`)
|
||||
- Capture key frames as PNG screenshots (`snapshot`)
|
||||
- Check your environment for missing dependencies (`doctor`)
|
||||
|
||||
@@ -580,6 +580,31 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_
|
||||
npx hyperframes layout [dir] --json
|
||||
```
|
||||
|
||||
#### Motion verification
|
||||
|
||||
`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.
|
||||
|
||||
```json
|
||||
{
|
||||
"duration": 6,
|
||||
"assertions": [
|
||||
{ "kind": "appearsBy", "selector": "#headline", "bySec": 0.5 },
|
||||
{ "kind": "before", "a": "#headline", "b": "#cta" },
|
||||
{ "kind": "staysInFrame", "selector": ".card" },
|
||||
{ "kind": "keepsMoving", "withinSelector": ".scene" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Assertion | Checks |
|
||||
|-----------|--------|
|
||||
| `appearsBy(selector, bySec)` | the element is visible (opacity ≥ 0.5) no later than `bySec` — catches reveals the seek lands past (`motion_appears_late`) |
|
||||
| `before(a, b)` | `a` first appears strictly before `b` — catches broken stagger order (`motion_out_of_order`) |
|
||||
| `staysInFrame(selector)` | once visible, the element's box never leaves the canvas — catches off-frame drift (`motion_off_frame`) |
|
||||
| `keepsMoving(withinSelector?)` | no fully-static window longer than `maxStaticSec` (default 2s) — catches frozen shots (`motion_frozen`) |
|
||||
|
||||
`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.
|
||||
|
||||
### `snapshot`
|
||||
|
||||
Capture key frames from a composition as PNG screenshots — verify visual output without a full render:
|
||||
|
||||
@@ -97,6 +97,11 @@ async function main() {
|
||||
cpSync(contrastAuditScript, join(DIST, "commands", "contrast-audit.browser.js"));
|
||||
}
|
||||
|
||||
const motionSampleScript = join(CLI_ROOT, "src", "commands", "motion-sample.browser.js");
|
||||
if (existsSync(motionSampleScript)) {
|
||||
cpSync(motionSampleScript, join(DIST, "commands", "motion-sample.browser.js"));
|
||||
}
|
||||
|
||||
copyMdFiles(join(CLI_ROOT, "src", "docs"), join(DIST, "docs"));
|
||||
|
||||
console.log("[build-copy] done");
|
||||
|
||||
@@ -10,6 +10,10 @@ export const examples: Example[] = [
|
||||
"Also sample at tween boundaries to catch transient overlaps",
|
||||
"hyperframes inspect --at-transitions",
|
||||
],
|
||||
[
|
||||
"Verify motion intent (add a *.motion.json sidecar next to the composition)",
|
||||
"hyperframes inspect --json",
|
||||
],
|
||||
["Run the compatibility alias", "hyperframes layout --json"],
|
||||
];
|
||||
|
||||
|
||||
@@ -18,11 +18,22 @@ import {
|
||||
summarizeLayoutIssues,
|
||||
type LayoutIssue,
|
||||
} from "../utils/layoutAudit.js";
|
||||
import {
|
||||
ambiguousIssue,
|
||||
collectSamplingTargets,
|
||||
evaluateMotion,
|
||||
type MotionFrame,
|
||||
} from "../utils/motionAudit.js";
|
||||
import { findMotionSpec, readMotionSpec, type MotionSpec } from "../utils/motionSpec.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const SEEK_SETTLE_MS = 120;
|
||||
// All new envelope fields are optional (?); additive changes don't bump this.
|
||||
const INSPECT_SCHEMA_VERSION = 1;
|
||||
// Motion verification (#1437): dense sampling grid for the seeked-timeline checks.
|
||||
const MOTION_FPS = 20;
|
||||
const MOTION_MAX_SAMPLES = 300;
|
||||
|
||||
export const examples: Example[] = [
|
||||
["Inspect visual layout across the current composition", "hyperframes layout"],
|
||||
@@ -33,6 +44,10 @@ export const examples: Example[] = [
|
||||
"Also sample at tween boundaries to catch transient overlaps",
|
||||
"hyperframes layout --at-transitions",
|
||||
],
|
||||
[
|
||||
"Verify motion intent (add a *.motion.json sidecar next to the composition)",
|
||||
"hyperframes layout --json",
|
||||
],
|
||||
];
|
||||
|
||||
interface LayoutAuditResult {
|
||||
@@ -41,6 +56,14 @@ interface LayoutAuditResult {
|
||||
transitionSamples: number[];
|
||||
transitionSamplesDropped: number;
|
||||
rawIssues: LayoutIssue[];
|
||||
motionSamples: number;
|
||||
}
|
||||
|
||||
function buildMotionSampleTimes(duration: number): number[] {
|
||||
if (!Number.isFinite(duration) || duration <= 0) return [];
|
||||
const count = Math.min(MOTION_MAX_SAMPLES, Math.max(2, Math.ceil(duration * MOTION_FPS) + 1));
|
||||
const step = duration / (count - 1);
|
||||
return Array.from({ length: count }, (_, index) => Math.round(index * step * 1000) / 1000);
|
||||
}
|
||||
|
||||
async function getCompositionDuration(page: import("puppeteer-core").Page): Promise<number> {
|
||||
@@ -205,6 +228,7 @@ async function runLayoutAudit(
|
||||
maxTransitionSamples?: number;
|
||||
timeout: number;
|
||||
tolerance: number;
|
||||
motion?: MotionSpec;
|
||||
},
|
||||
): Promise<LayoutAuditResult> {
|
||||
const { ensureBrowser } = await import("../browser/manager.js");
|
||||
@@ -259,25 +283,14 @@ async function runLayoutAudit(
|
||||
transitionSamplesDropped = transitions.dropped;
|
||||
}
|
||||
const samples = mergeSampleTimes(baseSamples, transitionSamples);
|
||||
if (samples.length === 0) {
|
||||
return { duration, samples, transitionSamples, transitionSamplesDropped, rawIssues: [] };
|
||||
}
|
||||
|
||||
await page.addScriptTag({ content: loadLayoutAuditScript() });
|
||||
const issues = await collectLayoutIssues(page, samples, opts.tolerance);
|
||||
|
||||
const issues: LayoutIssue[] = [];
|
||||
for (const time of samples) {
|
||||
await seekTo(page, time);
|
||||
const sampleIssues = await page.evaluate(
|
||||
(auditOptions: { time: number; tolerance: number }) => {
|
||||
const win = window as unknown as {
|
||||
__hyperframesLayoutAudit?: (options: { time: number; tolerance: number }) => unknown[];
|
||||
};
|
||||
return win.__hyperframesLayoutAudit?.(auditOptions) ?? [];
|
||||
},
|
||||
{ time, tolerance: opts.tolerance },
|
||||
);
|
||||
issues.push(...(sampleIssues as LayoutIssue[]));
|
||||
let motionSamples = 0;
|
||||
if (opts.motion) {
|
||||
const motion = await runMotionPass(page, opts.motion, duration);
|
||||
issues.push(...motion.issues);
|
||||
motionSamples = motion.sampleCount;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -286,6 +299,7 @@ async function runLayoutAudit(
|
||||
transitionSamples,
|
||||
transitionSamplesDropped,
|
||||
rawIssues: dedupeLayoutIssues(issues),
|
||||
motionSamples,
|
||||
};
|
||||
} finally {
|
||||
await chromeBrowser?.close().catch(() => {});
|
||||
@@ -293,17 +307,143 @@ async function runLayoutAudit(
|
||||
}
|
||||
}
|
||||
|
||||
function loadLayoutAuditScript(): string {
|
||||
const candidates = [
|
||||
join(__dirname, "layout-audit.browser.js"),
|
||||
join(__dirname, "commands", "layout-audit.browser.js"),
|
||||
];
|
||||
|
||||
function loadBrowserScript(name: string): string {
|
||||
const candidates = [join(__dirname, name), join(__dirname, "commands", name)];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return readFileSync(candidate, "utf-8");
|
||||
}
|
||||
throw new Error(`Missing browser script ${name}`);
|
||||
}
|
||||
|
||||
throw new Error("Missing layout audit browser script");
|
||||
function loadLayoutAuditScript(): string {
|
||||
return loadBrowserScript("layout-audit.browser.js");
|
||||
}
|
||||
|
||||
async function collectLayoutIssues(
|
||||
page: import("puppeteer-core").Page,
|
||||
samples: number[],
|
||||
tolerance: number,
|
||||
): Promise<LayoutIssue[]> {
|
||||
if (samples.length === 0) return [];
|
||||
await page.addScriptTag({ content: loadLayoutAuditScript() });
|
||||
|
||||
const issues: LayoutIssue[] = [];
|
||||
for (const time of samples) {
|
||||
await seekTo(page, time);
|
||||
const sampleIssues = await page.evaluate(
|
||||
(auditOptions: { time: number; tolerance: number }) => {
|
||||
const win = window as unknown as {
|
||||
__hyperframesLayoutAudit?: (options: { time: number; tolerance: number }) => unknown[];
|
||||
};
|
||||
return win.__hyperframesLayoutAudit?.(auditOptions) ?? [];
|
||||
},
|
||||
{ time, tolerance },
|
||||
);
|
||||
issues.push(...(sampleIssues as LayoutIssue[]));
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/** Reject selectors matching multiple elements — first-match-only sampling silently passes for siblings. */
|
||||
async function findAmbiguousSelectors(
|
||||
page: import("puppeteer-core").Page,
|
||||
selectors: string[],
|
||||
): Promise<LayoutIssue[]> {
|
||||
if (selectors.length === 0) return [];
|
||||
const multiMatch = await page.evaluate(
|
||||
(sels: string[]) =>
|
||||
sels.filter((sel) => {
|
||||
try {
|
||||
return document.querySelectorAll(sel).length > 1;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
selectors,
|
||||
);
|
||||
return multiMatch.map(ambiguousIssue);
|
||||
}
|
||||
|
||||
async function collectMotionFrames(
|
||||
page: import("puppeteer-core").Page,
|
||||
times: number[],
|
||||
selectors: string[],
|
||||
livenessScopes: string[],
|
||||
): Promise<MotionFrame[]> {
|
||||
const frames: MotionFrame[] = [];
|
||||
for (const time of times) {
|
||||
await seekTo(page, time);
|
||||
const sample = await page.evaluate(
|
||||
(options: { selectors: string[]; livenessScopes: string[] }) => {
|
||||
const win = window as unknown as {
|
||||
__hyperframesMotionSample?: (o: { selectors: string[]; livenessScopes: string[] }) => {
|
||||
data: MotionFrame["data"];
|
||||
liveness: Record<string, string>;
|
||||
};
|
||||
};
|
||||
return win.__hyperframesMotionSample?.(options) ?? { data: {}, liveness: {} };
|
||||
},
|
||||
{ selectors, livenessScopes },
|
||||
);
|
||||
frames.push({ time, data: sample.data, liveness: sample.liveness });
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
/**
|
||||
* Motion verification (#1437): sample the asserted selectors on a dense grid
|
||||
* against the same seeked timeline the renderer uses, then evaluate the spec's
|
||||
* assertions in Node. Reuses the live page from the layout audit — no extra
|
||||
* Chrome launch. Findings reuse the LayoutIssue shape.
|
||||
*/
|
||||
async function runMotionPass(
|
||||
page: import("puppeteer-core").Page,
|
||||
spec: MotionSpec,
|
||||
duration: number,
|
||||
): Promise<{ issues: LayoutIssue[]; sampleCount: number }> {
|
||||
const times = buildMotionSampleTimes(spec.duration ?? duration);
|
||||
if (times.length === 0) return { issues: [], sampleCount: 0 };
|
||||
|
||||
const { selectors, livenessScopes } = collectSamplingTargets(spec.assertions);
|
||||
const ambiguous = await findAmbiguousSelectors(page, selectors);
|
||||
if (ambiguous.length > 0) return { issues: ambiguous, sampleCount: 0 };
|
||||
|
||||
const canvas = await page.evaluate(() => ({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
}));
|
||||
await page.addScriptTag({ content: loadBrowserScript("motion-sample.browser.js") });
|
||||
const frames = await collectMotionFrames(page, times, selectors, livenessScopes);
|
||||
return { issues: evaluateMotion(frames, spec.assertions, canvas), sampleCount: frames.length };
|
||||
}
|
||||
|
||||
/** Read + validate the motion sidecar; print the error and exit on a bad spec. */
|
||||
function resolveMotionSpec(specPath: string, json: boolean): MotionSpec {
|
||||
const parsed = readMotionSpec(specPath);
|
||||
if (parsed.ok) return parsed.spec;
|
||||
|
||||
const message = `Invalid motion spec ${specPath}: ${parsed.errors.join("; ")}`;
|
||||
if (json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
withMeta({
|
||||
schemaVersion: INSPECT_SCHEMA_VERSION,
|
||||
ok: false,
|
||||
error: message,
|
||||
issues: [],
|
||||
errorCount: 0,
|
||||
warningCount: 0,
|
||||
infoCount: 0,
|
||||
issueCount: 0,
|
||||
}),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
console.error(`${c.error("✗")} ${message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseAt(value: unknown): number[] | undefined {
|
||||
@@ -319,7 +459,8 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
|
||||
return defineCommand({
|
||||
meta: {
|
||||
name: commandName,
|
||||
description: "Inspect rendered composition layout for text and container overflow",
|
||||
description:
|
||||
"Inspect rendered composition layout for text/container overflow, plus optional motion verification via a *.motion.json sidecar",
|
||||
},
|
||||
args: {
|
||||
dir: { type: "positional", description: "Project directory", required: false },
|
||||
@@ -386,11 +527,21 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
|
||||
const strict = !!args.strict;
|
||||
const collapseStatic = args["collapse-static"] !== false;
|
||||
|
||||
// Motion verification (#1437): an optional `*.motion.json` sidecar opts the
|
||||
// composition into seeked-timeline assertion checks. Absent → layout-only.
|
||||
const motionSpecPath = findMotionSpec(project.dir);
|
||||
const motionSpec = motionSpecPath
|
||||
? resolveMotionSpec(motionSpecPath, !!args.json)
|
||||
: undefined;
|
||||
|
||||
if (!args.json) {
|
||||
const baseLabel = at ? `${at.length} explicit timestamp(s)` : `${samples} timeline samples`;
|
||||
const sampleLabel = atTransitions ? `${baseLabel} + transition boundaries` : baseLabel;
|
||||
const motionLabel = motionSpec
|
||||
? ` + motion spec (${motionSpec.assertions.length} assertion(s))`
|
||||
: "";
|
||||
console.log(
|
||||
`${c.accent("◆")} Inspecting layout for ${c.accent(project.name)} (${sampleLabel})`,
|
||||
`${c.accent("◆")} Inspecting layout for ${c.accent(project.name)} (${sampleLabel}${motionLabel})`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -402,6 +553,7 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
|
||||
maxTransitionSamples,
|
||||
timeout,
|
||||
tolerance,
|
||||
motion: motionSpec,
|
||||
});
|
||||
if (!args.json && result.transitionSamplesDropped > 0) {
|
||||
console.log(
|
||||
@@ -429,6 +581,8 @@ export function createInspectCommand(commandName: "inspect" | "layout") {
|
||||
tolerance,
|
||||
strict,
|
||||
collapseStatic,
|
||||
motionSpec: motionSpec ? motionSpecPath : undefined,
|
||||
motionSamples: motionSpec ? result.motionSamples : undefined,
|
||||
...summary,
|
||||
totalIssueCount: limited.totalIssueCount,
|
||||
truncated: limited.truncated,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// In-page motion sampler for `hyperframes inspect` motion verification (#1437).
|
||||
// Runs inside the seeked, paused page (via page.evaluate). For each asserted
|
||||
// selector it returns this frame's { rect, opacity, visible }; for each liveness
|
||||
// scope it returns a bucketed signature of all visible elements, so the Node-side
|
||||
// evaluator can detect frozen windows by comparing signatures across frames.
|
||||
(function () {
|
||||
const IGNORE_TAGS = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "META", "LINK"]);
|
||||
|
||||
function round(value) {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function toRect(rect) {
|
||||
return {
|
||||
left: round(rect.left),
|
||||
top: round(rect.top),
|
||||
right: round(rect.right),
|
||||
bottom: round(rect.bottom),
|
||||
width: round(rect.width),
|
||||
height: round(rect.height),
|
||||
};
|
||||
}
|
||||
|
||||
function opacityChain(element) {
|
||||
let opacity = 1;
|
||||
for (let current = element; current; current = current.parentElement) {
|
||||
const parsed = Number.parseFloat(getComputedStyle(current).opacity || "1");
|
||||
if (Number.isFinite(parsed)) opacity *= parsed;
|
||||
}
|
||||
return opacity;
|
||||
}
|
||||
|
||||
// Mirrors layout-audit.browser.js isVisibleElement.
|
||||
// fallow-ignore-next-line complexity
|
||||
function isVisibleElement(element) {
|
||||
if (IGNORE_TAGS.has(element.tagName)) return false;
|
||||
const style = getComputedStyle(element);
|
||||
if (
|
||||
style.display === "none" ||
|
||||
style.visibility === "hidden" ||
|
||||
style.visibility === "collapse"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (opacityChain(element) < 0.2) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0.5 && rect.height > 0.5;
|
||||
}
|
||||
|
||||
function sampleElement(element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
rect: toRect(rect),
|
||||
opacity: round(opacityChain(element)),
|
||||
visible: isVisibleElement(element),
|
||||
};
|
||||
}
|
||||
|
||||
function compositionRoot() {
|
||||
return document.querySelector("[data-composition-id]") || document.body;
|
||||
}
|
||||
|
||||
// ponytail: bucket position to 2px and opacity to 0.08 so the RFC's "moves ≥2px /
|
||||
// opacity ≥0.08" thresholds fall out of bucketing. Boundary-straddling moves are
|
||||
// approximate — good enough for liveness; tighten only if false negatives show up.
|
||||
function elementSignature(element) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const bx = Math.round(rect.left / 2);
|
||||
const by = Math.round(rect.top / 2);
|
||||
const bw = Math.round(rect.width / 2);
|
||||
const bh = Math.round(rect.height / 2);
|
||||
const bo = Math.round(opacityChain(element) / 0.08);
|
||||
return bx + "," + by + "," + bw + "," + bh + "," + bo;
|
||||
}
|
||||
|
||||
function livenessSignature(root) {
|
||||
if (!root) return "";
|
||||
const parts = [];
|
||||
// ponytail: O(DOM) × MOTION_MAX_SAMPLES (300) — fine for typical compositions;
|
||||
// narrow selector (e.g. "[id],[class]") if heavy-DOM compositions slow this down.
|
||||
const all = root.querySelectorAll("*");
|
||||
for (const element of all) {
|
||||
if (!isVisibleElement(element)) continue;
|
||||
parts.push(elementSignature(element));
|
||||
}
|
||||
return parts.join("|");
|
||||
}
|
||||
|
||||
function safeQuery(selector) {
|
||||
try {
|
||||
return document.querySelector(selector);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sampleSelectors(selectors) {
|
||||
const data = {};
|
||||
for (const selector of selectors) {
|
||||
// Multi-match selectors are rejected before this point by findAmbiguousSelectors
|
||||
// in layout.ts; querySelector is safe here.
|
||||
const element = safeQuery(selector);
|
||||
data[selector] = element ? sampleElement(element) : null;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function sampleLiveness(scopes) {
|
||||
const liveness = {};
|
||||
for (const scope of scopes) {
|
||||
const root = scope === "*" ? compositionRoot() : safeQuery(scope);
|
||||
liveness[scope] = livenessSignature(root);
|
||||
}
|
||||
return liveness;
|
||||
}
|
||||
|
||||
window.__hyperframesMotionSample = function motionSample(options) {
|
||||
const { selectors = [], livenessScopes = [] } = options || {};
|
||||
return { data: sampleSelectors(selectors), liveness: sampleLiveness(livenessScopes) };
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,131 @@
|
||||
// @vitest-environment happy-dom
|
||||
// fallow-ignore-file code-duplication
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const script = readFileSync(join(__dirname, "motion-sample.browser.js"), "utf-8");
|
||||
|
||||
interface Geo {
|
||||
rect?: { left: number; top: number; width: number; height: number };
|
||||
opacity?: string;
|
||||
display?: string;
|
||||
visibility?: string;
|
||||
}
|
||||
|
||||
interface SampleResult {
|
||||
data: Record<
|
||||
string,
|
||||
{ rect: { left: number; right: number }; opacity: number; visible: boolean } | null
|
||||
>;
|
||||
liveness: Record<string, string>;
|
||||
}
|
||||
|
||||
function installGeometry(byId: Record<string, Geo>): void {
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
|
||||
const geo = byId[(element as Element).id] ?? {};
|
||||
return {
|
||||
display: geo.display ?? "block",
|
||||
visibility: geo.visibility ?? "visible",
|
||||
opacity: geo.opacity ?? "1",
|
||||
} as unknown as CSSStyleDeclaration;
|
||||
});
|
||||
|
||||
vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) {
|
||||
const geo = byId[this.id]?.rect ?? { left: 0, top: 0, width: 0, height: 0 };
|
||||
return {
|
||||
left: geo.left,
|
||||
top: geo.top,
|
||||
right: geo.left + geo.width,
|
||||
bottom: geo.top + geo.height,
|
||||
width: geo.width,
|
||||
height: geo.height,
|
||||
} as DOMRect;
|
||||
});
|
||||
}
|
||||
|
||||
function installScript(): void {
|
||||
// eslint-disable-next-line no-new-func
|
||||
new Function(script)();
|
||||
}
|
||||
|
||||
function sample(options: { selectors?: string[]; livenessScopes?: string[] }): SampleResult {
|
||||
const fn = (window as unknown as { __hyperframesMotionSample: (o: unknown) => SampleResult })
|
||||
.__hyperframesMotionSample;
|
||||
return fn(options);
|
||||
}
|
||||
|
||||
describe("motion-sample.browser", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
delete (window as unknown as { __hyperframesMotionSample?: unknown }).__hyperframesMotionSample;
|
||||
});
|
||||
|
||||
it("samples a present, visible selector and returns null for an absent one", () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-composition-id="main"><div id="headline">Hi</div></div>
|
||||
`;
|
||||
installGeometry({
|
||||
headline: { rect: { left: 100, top: 50, width: 300, height: 80 }, opacity: "1" },
|
||||
});
|
||||
installScript();
|
||||
|
||||
const result = sample({ selectors: ["#headline", "#missing"] });
|
||||
expect(result.data["#headline"]).toMatchObject({ visible: true, opacity: 1 });
|
||||
expect(result.data["#headline"]?.rect).toMatchObject({ left: 100, right: 400 });
|
||||
expect(result.data["#missing"]).toBeNull();
|
||||
});
|
||||
|
||||
it("reflects inherited ancestor opacity", () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-composition-id="main"><div id="wrap"><div id="headline">Hi</div></div></div>
|
||||
`;
|
||||
installGeometry({
|
||||
wrap: { rect: { left: 0, top: 0, width: 400, height: 200 }, opacity: "0.5" },
|
||||
headline: { rect: { left: 100, top: 50, width: 300, height: 80 }, opacity: "0.6" },
|
||||
});
|
||||
installScript();
|
||||
|
||||
const result = sample({ selectors: ["#headline"] });
|
||||
expect(result.data["#headline"]?.opacity).toBeCloseTo(0.3, 5);
|
||||
});
|
||||
|
||||
it("produces a different liveness signature when an element moves and an identical one when static", () => {
|
||||
document.body.innerHTML = `<div data-composition-id="main"><div id="box">x</div></div>`;
|
||||
|
||||
installGeometry({ box: { rect: { left: 100, top: 100, width: 50, height: 50 } } });
|
||||
installScript();
|
||||
const before = sample({ livenessScopes: ["*"] }).liveness["*"];
|
||||
vi.restoreAllMocks();
|
||||
|
||||
installGeometry({ box: { rect: { left: 100, top: 100, width: 50, height: 50 } } });
|
||||
installScript();
|
||||
const stillStatic = sample({ livenessScopes: ["*"] }).liveness["*"];
|
||||
vi.restoreAllMocks();
|
||||
|
||||
installGeometry({ box: { rect: { left: 300, top: 100, width: 50, height: 50 } } });
|
||||
installScript();
|
||||
const moved = sample({ livenessScopes: ["*"] }).liveness["*"];
|
||||
|
||||
expect(stillStatic).toBe(before);
|
||||
expect(moved).not.toBe(before);
|
||||
});
|
||||
|
||||
it("scopes liveness to a withinSelector and returns empty for a missing scope", () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-composition-id="main"><div id="scene"><div id="box">x</div></div></div>
|
||||
`;
|
||||
installGeometry({
|
||||
scene: { rect: { left: 0, top: 0, width: 500, height: 500 } },
|
||||
box: { rect: { left: 10, top: 10, width: 50, height: 50 } },
|
||||
});
|
||||
installScript();
|
||||
|
||||
const result = sample({ livenessScopes: ["#scene", "#nope"] });
|
||||
expect((result.liveness["#scene"] ?? "").length).toBeGreaterThan(0);
|
||||
expect(result.liveness["#nope"]).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,14 @@ export type LayoutIssueCode =
|
||||
| "canvas_overflow"
|
||||
| "container_overflow"
|
||||
| "content_overlap"
|
||||
| "text_occluded";
|
||||
| "text_occluded"
|
||||
// Motion-verification findings (#1437) — evaluated against the seeked timeline.
|
||||
| "motion_appears_late"
|
||||
| "motion_out_of_order"
|
||||
| "motion_off_frame"
|
||||
| "motion_frozen"
|
||||
| "motion_selector_missing"
|
||||
| "motion_selector_ambiguous";
|
||||
|
||||
export type LayoutIssueSeverity = "error" | "warning" | "info";
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectSamplingTargets,
|
||||
evaluateMotion,
|
||||
type FrameSample,
|
||||
type MotionFrame,
|
||||
} from "./motionAudit.js";
|
||||
import type { LayoutIssue } from "./layoutAudit.js";
|
||||
import type { MotionAssertion } from "./motionSpec.js";
|
||||
|
||||
const CANVAS = { width: 1920, height: 1080 };
|
||||
|
||||
function expectOne(issues: LayoutIssue[]): LayoutIssue {
|
||||
expect(issues).toHaveLength(1);
|
||||
const issue = issues[0];
|
||||
if (!issue) throw new Error("expected exactly one issue");
|
||||
return issue;
|
||||
}
|
||||
|
||||
function rect(left: number, top: number, width: number, height: number) {
|
||||
return { left, top, right: left + width, bottom: top + height, width, height };
|
||||
}
|
||||
|
||||
function visible(r = rect(100, 100, 200, 80), opacity = 1): FrameSample {
|
||||
return { rect: r, opacity, visible: true };
|
||||
}
|
||||
|
||||
const hidden: FrameSample = { rect: rect(0, 0, 0, 0), opacity: 0, visible: false };
|
||||
|
||||
/** Build frames at the given times; `at(time)` supplies per-selector samples + liveness. */
|
||||
function frames(
|
||||
times: number[],
|
||||
at: (time: number) => {
|
||||
data?: Record<string, FrameSample | null>;
|
||||
liveness?: Record<string, string>;
|
||||
},
|
||||
): MotionFrame[] {
|
||||
return times.map((time) => {
|
||||
const { data = {}, liveness = {} } = at(time);
|
||||
return { time, data, liveness: { "*": "x", ...liveness } };
|
||||
});
|
||||
}
|
||||
|
||||
describe("appearsBy", () => {
|
||||
const assertion: MotionAssertion = { kind: "appearsBy", selector: "#h", bySec: 0.5 };
|
||||
|
||||
it("passes when visible by the deadline", () => {
|
||||
const f = frames([0.1, 0.3, 0.6], (t) => ({ data: { "#h": t >= 0.3 ? visible() : hidden } }));
|
||||
expect(evaluateMotion(f, [assertion], CANVAS)).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags a late entrance with both times", () => {
|
||||
const f = frames([0.3, 0.83, 1.2], (t) => ({ data: { "#h": t >= 0.83 ? visible() : hidden } }));
|
||||
const issue = expectOne(evaluateMotion(f, [assertion], CANVAS));
|
||||
expect(issue.code).toBe("motion_appears_late");
|
||||
expect(issue.message).toContain("0.83s");
|
||||
expect(issue.message).toContain("0.5s");
|
||||
});
|
||||
|
||||
it("flags an element that never reaches visible opacity", () => {
|
||||
const f = frames([0.3, 0.6], () => ({
|
||||
data: { "#h": { rect: rect(0, 0, 10, 10), opacity: 0.2, visible: true } },
|
||||
}));
|
||||
const issue = expectOne(evaluateMotion(f, [assertion], CANVAS));
|
||||
expect(issue.code).toBe("motion_appears_late");
|
||||
expect(issue.message).toContain("never");
|
||||
});
|
||||
|
||||
it("flags a selector that matches nothing", () => {
|
||||
const f = frames([0.3, 0.6], () => ({ data: { "#h": null } }));
|
||||
const issue = expectOne(evaluateMotion(f, [assertion], CANVAS));
|
||||
expect(issue.code).toBe("motion_selector_missing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("before", () => {
|
||||
const assertion: MotionAssertion = { kind: "before", a: "#a", b: "#b" };
|
||||
|
||||
it("passes when a appears before b", () => {
|
||||
const f = frames([0.2, 0.4], (t) => ({
|
||||
data: { "#a": visible(), "#b": t >= 0.4 ? visible() : hidden },
|
||||
}));
|
||||
expect(evaluateMotion(f, [assertion], CANVAS)).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags reversed order", () => {
|
||||
const f = frames([0.2, 0.4], (t) => ({
|
||||
data: { "#a": t >= 0.4 ? visible() : hidden, "#b": visible() },
|
||||
}));
|
||||
const issue = expectOne(evaluateMotion(f, [assertion], CANVAS));
|
||||
expect(issue.code).toBe("motion_out_of_order");
|
||||
});
|
||||
|
||||
it("treats a simultaneous appearance as out of order (strict before)", () => {
|
||||
const f = frames([0.2, 0.4], () => ({ data: { "#a": visible(), "#b": visible() } }));
|
||||
const issue = expectOne(evaluateMotion(f, [assertion], CANVAS));
|
||||
expect(issue.code).toBe("motion_out_of_order");
|
||||
});
|
||||
});
|
||||
|
||||
describe("staysInFrame", () => {
|
||||
const assertion: MotionAssertion = { kind: "staysInFrame", selector: ".card" };
|
||||
|
||||
it("passes when the box stays inside the canvas", () => {
|
||||
const f = frames([0, 1, 2], () => ({ data: { ".card": visible(rect(100, 100, 200, 80)) } }));
|
||||
expect(evaluateMotion(f, [assertion], CANVAS)).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags drift past the right edge", () => {
|
||||
const f = frames([0, 1, 2], (t) => ({
|
||||
data: { ".card": visible(rect(t >= 2 ? 1850 : 100, 100, 200, 80)) },
|
||||
}));
|
||||
const issue = expectOne(evaluateMotion(f, [assertion], CANVAS));
|
||||
expect(issue.code).toBe("motion_off_frame");
|
||||
expect(issue.time).toBe(2);
|
||||
});
|
||||
|
||||
it("ignores off-canvas position before the element is first visible", () => {
|
||||
const f = frames([0, 1], (t) => ({
|
||||
data: {
|
||||
".card":
|
||||
t < 1
|
||||
? { rect: rect(5000, 0, 100, 100), opacity: 0, visible: false }
|
||||
: visible(rect(100, 100, 200, 80)),
|
||||
},
|
||||
}));
|
||||
expect(evaluateMotion(f, [assertion], CANVAS)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keepsMoving", () => {
|
||||
it("passes when the signature changes every frame", () => {
|
||||
const assertion: MotionAssertion = { kind: "keepsMoving" };
|
||||
const f = frames([0, 1, 2, 3], (t) => ({ liveness: { "*": `sig-${t}` } }));
|
||||
expect(evaluateMotion(f, [assertion], CANVAS)).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags a static window longer than the threshold", () => {
|
||||
const assertion: MotionAssertion = { kind: "keepsMoving", maxStaticSec: 2 };
|
||||
// frozen 1s..4s (3s static) then moves
|
||||
const f = frames([0, 1, 2, 3, 4, 5], (t) => ({
|
||||
liveness: { "*": t >= 1 && t <= 4 ? "frozen" : `m-${t}` },
|
||||
}));
|
||||
const issue = expectOne(evaluateMotion(f, [assertion], CANVAS));
|
||||
expect(issue.code).toBe("motion_frozen");
|
||||
expect(issue.time).toBe(1);
|
||||
});
|
||||
|
||||
it("scopes liveness to withinSelector", () => {
|
||||
const assertion: MotionAssertion = {
|
||||
kind: "keepsMoving",
|
||||
withinSelector: ".scene",
|
||||
maxStaticSec: 1,
|
||||
};
|
||||
// .scene frozen the whole time, whole-canvas "*" moving — only the scope matters
|
||||
const f = frames([0, 1, 2, 3], (t) => ({ liveness: { "*": `m-${t}`, ".scene": "frozen" } }));
|
||||
const issue = expectOne(evaluateMotion(f, [assertion], CANVAS));
|
||||
expect(issue.code).toBe("motion_frozen");
|
||||
expect(issue.selector).toBe(".scene");
|
||||
});
|
||||
|
||||
it("flags a missing withinSelector instead of reporting it frozen", () => {
|
||||
const assertion: MotionAssertion = { kind: "keepsMoving", withinSelector: ".nope" };
|
||||
const f = frames([0, 1, 2], () => ({ liveness: { "*": "moving" } }));
|
||||
const issue = expectOne(evaluateMotion(f, [assertion], CANVAS));
|
||||
expect(issue.code).toBe("motion_selector_missing");
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateMotion edge cases", () => {
|
||||
it("returns nothing for an empty frame set", () => {
|
||||
expect(evaluateMotion([], [{ kind: "appearsBy", selector: "#h", bySec: 1 }], CANVAS)).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectSamplingTargets", () => {
|
||||
it("collects selectors and liveness scopes without duplicates", () => {
|
||||
const targets = collectSamplingTargets([
|
||||
{ kind: "appearsBy", selector: "#h", bySec: 0.5 },
|
||||
{ kind: "before", a: "#h", b: "#cta" },
|
||||
{ kind: "staysInFrame", selector: ".card" },
|
||||
{ kind: "keepsMoving", withinSelector: ".scene" },
|
||||
{ kind: "keepsMoving" },
|
||||
]);
|
||||
expect(targets.selectors.sort()).toEqual(["#cta", "#h", ".card"]);
|
||||
expect(targets.livenessScopes.sort()).toEqual(["*", ".scene"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import type { LayoutIssue, LayoutRect } from "./layoutAudit.js";
|
||||
import type { MotionAssertion } from "./motionSpec.js";
|
||||
|
||||
/** Opacity at/above which an element counts as "appeared" (RFC: opacity ≥ threshold). */
|
||||
const APPEAR_OPACITY = 0.5;
|
||||
/** Pixels an element may exceed the canvas edge before it counts as off-frame. */
|
||||
const FRAME_TOLERANCE = 1;
|
||||
/** Default longest allowed fully-static window for keepsMoving, in seconds. */
|
||||
const DEFAULT_MAX_STATIC_SEC = 2;
|
||||
|
||||
export interface FrameSample {
|
||||
rect: LayoutRect;
|
||||
opacity: number;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
/** One seeked frame of the dense motion grid. */
|
||||
export interface MotionFrame {
|
||||
time: number;
|
||||
/** Per asserted selector: its sample this frame, or null when it matched nothing. */
|
||||
data: Record<string, FrameSample | null>;
|
||||
/** Liveness signature per scope ("*" = whole canvas; otherwise a withinSelector). */
|
||||
liveness: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Canvas {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const ZERO_RECT: LayoutRect = { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 };
|
||||
|
||||
export function ambiguousIssue(selector: string): LayoutIssue {
|
||||
return {
|
||||
code: "motion_selector_ambiguous",
|
||||
severity: "error",
|
||||
time: 0,
|
||||
selector,
|
||||
message: `${selector} matches multiple elements — use a more specific selector so the assertion targets exactly one`,
|
||||
rect: ZERO_RECT,
|
||||
fixHint:
|
||||
"Use #id or :nth-child() instead of a class selector when multiple elements share the same class.",
|
||||
};
|
||||
}
|
||||
|
||||
function round(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function everMatched(frames: MotionFrame[], selector: string): boolean {
|
||||
return frames.some((frame) => frame.data[selector] != null);
|
||||
}
|
||||
|
||||
/** First frame where the selector is visible at/above the appear threshold. */
|
||||
function firstAppear(
|
||||
frames: MotionFrame[],
|
||||
selector: string,
|
||||
): { time: number; rect: LayoutRect } | null {
|
||||
for (const frame of frames) {
|
||||
const sample = frame.data[selector];
|
||||
if (sample && sample.visible && sample.opacity >= APPEAR_OPACITY) {
|
||||
return { time: frame.time, rect: sample.rect };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function missingIssue(selector: string, time: number): LayoutIssue {
|
||||
return {
|
||||
code: "motion_selector_missing",
|
||||
severity: "error",
|
||||
time,
|
||||
selector,
|
||||
message: `${selector} matched no element in any sampled frame — check the selector`,
|
||||
rect: ZERO_RECT,
|
||||
fixHint: "Verify the selector exists in the composition and is spelled correctly.",
|
||||
};
|
||||
}
|
||||
|
||||
function appearsBy(frames: MotionFrame[], selector: string, bySec: number): LayoutIssue[] {
|
||||
if (!everMatched(frames, selector)) return [missingIssue(selector, 0)];
|
||||
const appear = firstAppear(frames, selector);
|
||||
if (appear && appear.time <= bySec) return [];
|
||||
return [
|
||||
{
|
||||
code: "motion_appears_late",
|
||||
severity: "error",
|
||||
time: appear ? appear.time : bySec,
|
||||
selector,
|
||||
message: appear
|
||||
? `appears at ${round(appear.time)}s but should be visible by ${round(bySec)}s (check its entrance reveal fires under seek)`
|
||||
: `never reaches visible opacity but should be visible by ${round(bySec)}s (check its entrance reveal fires under seek)`,
|
||||
rect: appear ? appear.rect : ZERO_RECT,
|
||||
fixHint:
|
||||
"The renderer seeks a paused timeline; a forward-only reveal can be skipped. Ensure the entrance is applied at this time, not only played through.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function before(frames: MotionFrame[], a: string, b: string): LayoutIssue[] {
|
||||
const issues: LayoutIssue[] = [];
|
||||
if (!everMatched(frames, a)) issues.push(missingIssue(a, 0));
|
||||
if (!everMatched(frames, b)) issues.push(missingIssue(b, 0));
|
||||
if (issues.length > 0) return issues;
|
||||
|
||||
const appearA = firstAppear(frames, a);
|
||||
const appearB = firstAppear(frames, b);
|
||||
const timeA = appearA ? appearA.time : Number.POSITIVE_INFINITY;
|
||||
const timeB = appearB ? appearB.time : Number.POSITIVE_INFINITY;
|
||||
if (timeA < timeB) return [];
|
||||
|
||||
const label = (t: number) => (Number.isFinite(t) ? `${round(t)}s` : "never");
|
||||
return [
|
||||
{
|
||||
code: "motion_out_of_order",
|
||||
severity: "error",
|
||||
time: Number.isFinite(timeA) ? timeA : 0,
|
||||
selector: a,
|
||||
message: `${a} should appear before ${b}, but ${a} appears at ${label(timeA)} and ${b} at ${label(timeB)} — reorder the entrances`,
|
||||
rect: appearA ? appearA.rect : ZERO_RECT,
|
||||
fixHint: `Make ${a}'s entrance land before ${b}'s on the timeline.`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function isOffFrame(r: LayoutRect, canvas: Canvas): boolean {
|
||||
return (
|
||||
r.left < -FRAME_TOLERANCE ||
|
||||
r.top < -FRAME_TOLERANCE ||
|
||||
r.right > canvas.width + FRAME_TOLERANCE ||
|
||||
r.bottom > canvas.height + FRAME_TOLERANCE
|
||||
);
|
||||
}
|
||||
|
||||
// Note: off-frame check uses sample.visible (opacity ≥ 0.2 from the browser sampler);
|
||||
// the first-appear anchor uses APPEAR_OPACITY (0.5). Elements fading in between those
|
||||
// thresholds are tracked for position but don't start the window — intentional.
|
||||
function staysInFrame(frames: MotionFrame[], selector: string, canvas: Canvas): LayoutIssue[] {
|
||||
if (!everMatched(frames, selector)) return [missingIssue(selector, 0)];
|
||||
const appear = firstAppear(frames, selector);
|
||||
if (!appear) return [];
|
||||
|
||||
for (const frame of frames) {
|
||||
const sample = frame.data[selector];
|
||||
if (frame.time < appear.time || !sample || !sample.visible) continue;
|
||||
if (!isOffFrame(sample.rect, canvas)) continue;
|
||||
const r = sample.rect;
|
||||
return [
|
||||
{
|
||||
code: "motion_off_frame",
|
||||
severity: "error",
|
||||
time: frame.time,
|
||||
selector,
|
||||
message: `${selector} drifts off the ${canvas.width}×${canvas.height} canvas at ${round(frame.time)}s (box ${r.left},${r.top}→${r.right},${r.bottom})`,
|
||||
rect: r,
|
||||
fixHint:
|
||||
"Clamp the element's motion so its box stays within the canvas for the whole shot.",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function keepsMoving(
|
||||
frames: MotionFrame[],
|
||||
within: string | undefined,
|
||||
maxStaticSec: number,
|
||||
): LayoutIssue[] {
|
||||
// "*" is reserved for whole-canvas scope; motionSpec.ts rejects it as a user-supplied withinSelector.
|
||||
const scope = within ?? "*";
|
||||
if (within && frames.every((frame) => !frame.liveness[scope])) {
|
||||
return [missingIssue(within, 0)];
|
||||
}
|
||||
|
||||
const issues: LayoutIssue[] = [];
|
||||
const first = frames[0];
|
||||
if (!first) return issues;
|
||||
let runStart = first.time;
|
||||
let runSig = first.liveness[scope] ?? "";
|
||||
const flush = (endTime: number) => {
|
||||
const span = endTime - runStart;
|
||||
if (span > maxStaticSec) {
|
||||
issues.push({
|
||||
code: "motion_frozen",
|
||||
severity: "error",
|
||||
time: runStart,
|
||||
selector: within ?? "composition",
|
||||
message: `nothing moves${within ? ` within ${within}` : ""} between ${round(runStart)}s and ${round(endTime)}s (${round(span)}s static) — should keep moving`,
|
||||
rect: ZERO_RECT,
|
||||
fixHint:
|
||||
"Add or extend motion so no shot freezes for this long, or shorten the static hold.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let lastTime = runStart;
|
||||
for (const frame of frames.slice(1)) {
|
||||
lastTime = frame.time;
|
||||
const sig = frame.liveness[scope] ?? "";
|
||||
if (sig !== runSig) {
|
||||
flush(frame.time);
|
||||
runStart = frame.time;
|
||||
runSig = sig;
|
||||
}
|
||||
}
|
||||
flush(lastTime);
|
||||
return issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate motion assertions against the dense `element × time` matrix.
|
||||
* Pure — no browser. Findings reuse the LayoutIssue shape and flow through
|
||||
* inspect's existing dedupe/collapse/limit/format pipeline.
|
||||
*/
|
||||
export function evaluateMotion(
|
||||
frames: MotionFrame[],
|
||||
assertions: MotionAssertion[],
|
||||
canvas: Canvas,
|
||||
): LayoutIssue[] {
|
||||
if (frames.length === 0) return [];
|
||||
return assertions.flatMap((assertion) => {
|
||||
switch (assertion.kind) {
|
||||
case "appearsBy":
|
||||
return appearsBy(frames, assertion.selector, assertion.bySec);
|
||||
case "before":
|
||||
return before(frames, assertion.a, assertion.b);
|
||||
case "staysInFrame":
|
||||
return staysInFrame(frames, assertion.selector, canvas);
|
||||
case "keepsMoving":
|
||||
return keepsMoving(
|
||||
frames,
|
||||
assertion.withinSelector,
|
||||
assertion.maxStaticSec ?? DEFAULT_MAX_STATIC_SEC,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Selectors and liveness scopes the in-page sampler must read for a spec.
|
||||
* Selectors feed the per-element matrix; scopes feed keepsMoving liveness.
|
||||
*/
|
||||
export function collectSamplingTargets(assertions: MotionAssertion[]): {
|
||||
selectors: string[];
|
||||
livenessScopes: string[];
|
||||
} {
|
||||
const selectors = new Set<string>();
|
||||
const scopes = new Set<string>();
|
||||
for (const assertion of assertions) {
|
||||
switch (assertion.kind) {
|
||||
case "appearsBy":
|
||||
case "staysInFrame":
|
||||
selectors.add(assertion.selector);
|
||||
break;
|
||||
case "before":
|
||||
selectors.add(assertion.a);
|
||||
selectors.add(assertion.b);
|
||||
break;
|
||||
case "keepsMoving":
|
||||
scopes.add(assertion.withinSelector ?? "*");
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { selectors: [...selectors], livenessScopes: [...scopes] };
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { findMotionSpec, parseMotionSpec, readMotionSpec, type MotionSpec } from "./motionSpec.js";
|
||||
|
||||
const RFC_SPEC = {
|
||||
duration: 6,
|
||||
assertions: [
|
||||
{ kind: "appearsBy", selector: "#headline", bySec: 0.5 },
|
||||
{ kind: "before", a: "#headline", b: "#cta" },
|
||||
{ kind: "staysInFrame", selector: ".card" },
|
||||
{ kind: "keepsMoving", withinSelector: ".scene" },
|
||||
],
|
||||
};
|
||||
|
||||
function expectOk(result: ReturnType<typeof parseMotionSpec>): MotionSpec {
|
||||
if (!result.ok) throw new Error(`expected ok, got errors: ${result.errors.join(", ")}`);
|
||||
return result.spec;
|
||||
}
|
||||
|
||||
describe("parseMotionSpec", () => {
|
||||
it("parses the RFC four-assertion spec", () => {
|
||||
const spec = expectOk(parseMotionSpec(RFC_SPEC));
|
||||
expect(spec.duration).toBe(6);
|
||||
expect(spec.assertions).toHaveLength(4);
|
||||
expect(spec.assertions[0]).toEqual({ kind: "appearsBy", selector: "#headline", bySec: 0.5 });
|
||||
expect(spec.assertions[3]).toEqual({ kind: "keepsMoving", withinSelector: ".scene" });
|
||||
});
|
||||
|
||||
it("allows a missing duration", () => {
|
||||
const spec = expectOk(
|
||||
parseMotionSpec({ assertions: [{ kind: "staysInFrame", selector: ".card" }] }),
|
||||
);
|
||||
expect(spec.duration).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects an unknown assertion kind", () => {
|
||||
const result = parseMotionSpec({ assertions: [{ kind: "onBeat", selector: "#x" }] });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.errors[0]).toContain("unknown assertion kind");
|
||||
});
|
||||
|
||||
it("reports per-field errors for missing required fields", () => {
|
||||
const result = parseMotionSpec({
|
||||
assertions: [
|
||||
{ kind: "appearsBy", selector: "#h" },
|
||||
{ kind: "before", a: "#a" },
|
||||
{ kind: "staysInFrame" },
|
||||
],
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors).toHaveLength(3);
|
||||
expect(result.errors[0]).toContain("bySec");
|
||||
expect(result.errors[1]).toContain('"b"');
|
||||
expect(result.errors[2]).toContain("selector");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a non-object spec and an empty assertion list", () => {
|
||||
expect(parseMotionSpec(42).ok).toBe(false);
|
||||
expect(parseMotionSpec({ assertions: [] }).ok).toBe(false);
|
||||
expect(parseMotionSpec({}).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a non-positive maxStaticSec", () => {
|
||||
const result = parseMotionSpec({
|
||||
assertions: [{ kind: "keepsMoving", maxStaticSec: 0 }],
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an unsupported spec version", () => {
|
||||
const result = parseMotionSpec({
|
||||
version: 2,
|
||||
assertions: [{ kind: "staysInFrame", selector: ".card" }],
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.errors[0]).toContain("version");
|
||||
});
|
||||
|
||||
it("rejects NaN as duration", () => {
|
||||
const result = parseMotionSpec({
|
||||
duration: NaN,
|
||||
assertions: [{ kind: "staysInFrame", selector: ".card" }],
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.errors[0]).toContain("duration");
|
||||
});
|
||||
|
||||
it('rejects "*" as withinSelector', () => {
|
||||
const result = parseMotionSpec({
|
||||
assertions: [{ kind: "keepsMoving", withinSelector: "*" }],
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.errors[0]).toContain('"*"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMotionSpec", () => {
|
||||
it("returns null when no sidecar is present", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "motion-none-"));
|
||||
writeFileSync(join(dir, "main.html"), "<div></div>");
|
||||
expect(findMotionSpec(dir)).toBeNull();
|
||||
});
|
||||
|
||||
it("finds the single sidecar", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "motion-one-"));
|
||||
writeFileSync(join(dir, "anything.motion.json"), "{}");
|
||||
expect(findMotionSpec(dir)).toBe(join(dir, "anything.motion.json"));
|
||||
});
|
||||
|
||||
it("prefers the sidecar matching a composition html basename", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "motion-many-"));
|
||||
writeFileSync(join(dir, "aaa.motion.json"), "{}");
|
||||
writeFileSync(join(dir, "main.motion.json"), "{}");
|
||||
writeFileSync(join(dir, "main.html"), "<div></div>");
|
||||
expect(findMotionSpec(dir)).toBe(join(dir, "main.motion.json"));
|
||||
});
|
||||
|
||||
it("throws when multiple sidecars each match a different composition", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "motion-ambig-"));
|
||||
writeFileSync(join(dir, "hero.motion.json"), "{}");
|
||||
writeFileSync(join(dir, "landing.motion.json"), "{}");
|
||||
writeFileSync(join(dir, "hero.html"), "<div></div>");
|
||||
writeFileSync(join(dir, "landing.html"), "<div></div>");
|
||||
expect(() => findMotionSpec(dir)).toThrow("ambiguous motion sidecars");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readMotionSpec", () => {
|
||||
it("returns error for a nonexistent file", () => {
|
||||
const result = readMotionSpec("/tmp/__nonexistent_motion_spec__.json");
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.errors[0]).toContain("could not read");
|
||||
});
|
||||
|
||||
it("returns error for a file with invalid JSON", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "motion-bad-"));
|
||||
const path = join(dir, "bad.motion.json");
|
||||
writeFileSync(path, "not json {{");
|
||||
const result = readMotionSpec(path);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.errors[0]).toContain("could not read");
|
||||
});
|
||||
|
||||
it("returns error for a file with a valid JSON but invalid spec", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "motion-invalid-"));
|
||||
const path = join(dir, "invalid.motion.json");
|
||||
writeFileSync(path, JSON.stringify({ assertions: [] }));
|
||||
const result = readMotionSpec(path);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.errors[0]).toContain("no assertions");
|
||||
});
|
||||
|
||||
it("parses a valid sidecar file", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "motion-valid-"));
|
||||
const path = join(dir, "main.motion.json");
|
||||
writeFileSync(
|
||||
path,
|
||||
JSON.stringify({ assertions: [{ kind: "staysInFrame", selector: ".card" }] }),
|
||||
);
|
||||
const result = readMotionSpec(path);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.spec.assertions).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
|
||||
/**
|
||||
* Declarative motion-verification spec (issue #1437). A sidecar JSON file
|
||||
* (`*.motion.json`) sits next to the composition; `inspect` evaluates these
|
||||
* assertions against the same seeked timeline the renderer uses.
|
||||
*/
|
||||
export type MotionAssertion =
|
||||
| { kind: "appearsBy"; selector: string; bySec: number }
|
||||
| { kind: "before"; a: string; b: string }
|
||||
| { kind: "staysInFrame"; selector: string }
|
||||
| { kind: "keepsMoving"; withinSelector?: string; maxStaticSec?: number };
|
||||
|
||||
export interface MotionSpec {
|
||||
version?: number;
|
||||
duration?: number;
|
||||
assertions: MotionAssertion[];
|
||||
}
|
||||
|
||||
export type MotionSpecParse = { ok: true; spec: MotionSpec } | { ok: false; errors: string[] };
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isSelector(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isPositive(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
type Validator = (raw: Record<string, unknown>, at: string) => MotionAssertion | string;
|
||||
|
||||
const VALIDATORS: Record<string, Validator> = {
|
||||
appearsBy: (raw, at) => {
|
||||
if (!isSelector(raw.selector))
|
||||
return `${at} (appearsBy): "selector" must be a non-empty string`;
|
||||
if (typeof raw.bySec !== "number" || !Number.isFinite(raw.bySec) || raw.bySec < 0)
|
||||
return `${at} (appearsBy): "bySec" must be a number >= 0`;
|
||||
return { kind: "appearsBy", selector: raw.selector, bySec: raw.bySec };
|
||||
},
|
||||
before: (raw, at) => {
|
||||
if (!isSelector(raw.a)) return `${at} (before): "a" must be a non-empty string`;
|
||||
if (!isSelector(raw.b)) return `${at} (before): "b" must be a non-empty string`;
|
||||
return { kind: "before", a: raw.a, b: raw.b };
|
||||
},
|
||||
staysInFrame: (raw, at) => {
|
||||
if (!isSelector(raw.selector))
|
||||
return `${at} (staysInFrame): "selector" must be a non-empty string`;
|
||||
return { kind: "staysInFrame", selector: raw.selector };
|
||||
},
|
||||
keepsMoving: (raw, at) => {
|
||||
if (raw.withinSelector !== undefined && !isSelector(raw.withinSelector))
|
||||
return `${at} (keepsMoving): "withinSelector" must be a non-empty string when present`;
|
||||
if (raw.withinSelector === "*")
|
||||
return `${at} (keepsMoving): "withinSelector" cannot be "*" — omit it for whole-composition liveness`;
|
||||
if (raw.maxStaticSec !== undefined && !isPositive(raw.maxStaticSec))
|
||||
return `${at} (keepsMoving): "maxStaticSec" must be a number > 0 when present`;
|
||||
const assertion: Extract<MotionAssertion, { kind: "keepsMoving" }> = { kind: "keepsMoving" };
|
||||
if (isSelector(raw.withinSelector)) assertion.withinSelector = raw.withinSelector;
|
||||
if (isPositive(raw.maxStaticSec)) assertion.maxStaticSec = raw.maxStaticSec;
|
||||
return assertion;
|
||||
},
|
||||
};
|
||||
|
||||
function validateAssertion(raw: unknown, index: number): MotionAssertion | string {
|
||||
const at = `assertions[${index}]`;
|
||||
if (!isObject(raw)) return `${at}: must be an object`;
|
||||
const validator = typeof raw.kind === "string" ? VALIDATORS[raw.kind] : undefined;
|
||||
if (!validator) return `${at}: unknown assertion kind ${JSON.stringify(raw.kind)}`;
|
||||
return validator(raw, at);
|
||||
}
|
||||
|
||||
export function parseMotionSpec(raw: unknown): MotionSpecParse {
|
||||
if (!isObject(raw)) return { ok: false, errors: ["spec must be a JSON object"] };
|
||||
if (raw.version !== undefined && raw.version !== 1)
|
||||
return {
|
||||
ok: false,
|
||||
errors: [`spec version ${raw.version} is not supported — upgrade the hyperframes CLI`],
|
||||
};
|
||||
if (!Array.isArray(raw.assertions))
|
||||
return { ok: false, errors: ['spec must have an "assertions" array'] };
|
||||
if (
|
||||
raw.duration !== undefined &&
|
||||
(typeof raw.duration !== "number" || !Number.isFinite(raw.duration) || raw.duration <= 0)
|
||||
)
|
||||
return { ok: false, errors: ['"duration" must be a positive number when present'] };
|
||||
|
||||
const assertions: MotionAssertion[] = [];
|
||||
const errors: string[] = [];
|
||||
raw.assertions.forEach((entry, index) => {
|
||||
const result = validateAssertion(entry, index);
|
||||
if (typeof result === "string") errors.push(result);
|
||||
else assertions.push(result);
|
||||
});
|
||||
|
||||
if (errors.length > 0) return { ok: false, errors };
|
||||
if (assertions.length === 0) return { ok: false, errors: ["spec has no assertions"] };
|
||||
|
||||
const spec: MotionSpec = { assertions };
|
||||
if (typeof raw.duration === "number") spec.duration = raw.duration;
|
||||
return { ok: true, spec };
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate a `*.motion.json` sidecar in the project dir. When several exist,
|
||||
* prefer the one whose basename matches a composition html file; otherwise
|
||||
* take the first alphabetically. Throws when multiple sidecars each match a
|
||||
* different composition — the bundler and this resolver would diverge silently.
|
||||
* Returns null when none is present.
|
||||
*/
|
||||
export function findMotionSpec(projectDir: string): string | null {
|
||||
if (!existsSync(projectDir)) return null;
|
||||
const entries = readdirSync(projectDir);
|
||||
const sidecars = entries.filter((name) => name.endsWith(".motion.json")).sort();
|
||||
if (!sidecars[0]) return null;
|
||||
if (sidecars.length === 1) return join(projectDir, sidecars[0]);
|
||||
const htmlBases = new Set(
|
||||
entries.filter((name) => name.endsWith(".html")).map((name) => basename(name, ".html")),
|
||||
);
|
||||
const matched = sidecars.filter((name) => htmlBases.has(basename(name, ".motion.json")));
|
||||
if (matched.length > 1) {
|
||||
throw new Error(
|
||||
`ambiguous motion sidecars in ${projectDir}: ${matched.join(", ")} each match a composition — remove the sidecars you do not need, or use one composition per project`,
|
||||
);
|
||||
}
|
||||
return join(projectDir, matched[0] ?? sidecars[0]);
|
||||
}
|
||||
|
||||
export function readMotionSpec(path: string): MotionSpecParse {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(readFileSync(path, "utf-8"));
|
||||
} catch (err) {
|
||||
return { ok: false, errors: [`could not read ${basename(path)}: ${(err as Error).message}`] };
|
||||
}
|
||||
return parseMotionSpec(raw);
|
||||
}
|
||||
@@ -21,9 +21,9 @@ Everything runs through `npx hyperframes` unless project instructions specify a
|
||||
- CI / cross-host repro: `npx hyperframes render --docker --strict --output out.mp4`
|
||||
- Cloud (long / large): `npx hyperframes lambda render ./my-project --width 1920 --height 1080 --wait` (see Lambda below)
|
||||
|
||||
Run lint, validate, and inspect before preview. `lint` catches missing `data-composition-id`, overlapping tracks, and unregistered timelines. `validate` loads the composition in headless Chrome and reports runtime console errors plus WCAG contrast issues. `inspect` seeks through the timeline and reports text spilling out of bubbles/containers or off the canvas.
|
||||
Run lint, validate, and inspect before preview. `lint` catches missing `data-composition-id`, overlapping tracks, and unregistered timelines. `validate` loads the composition in headless Chrome and reports runtime console errors plus WCAG contrast issues. `inspect` seeks through the timeline and reports text spilling out of bubbles/containers or off the canvas — and, when a `*.motion.json` sidecar is present, verifies motion intent (entrances firing under seek, stagger order, in-frame, liveness) against that same seeked timeline.
|
||||
|
||||
For motion-heavy work, prefer snapshot-driven iteration — see `references/lint-validate-inspect.md` for the discipline.
|
||||
For motion-heavy work, prefer snapshot-driven iteration and a `*.motion.json` sidecar — see `references/lint-validate-inspect.md` for the discipline and motion-verification spec.
|
||||
|
||||
## Agent Conventions
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ When the composition is animation-driven, run the checks before you reach for `p
|
||||
- Capture `snapshot` at meaningful timeline states; look at the PNGs.
|
||||
- Inspect snapshots _before_ tuning automated warnings — your eye catches what the auditor misses.
|
||||
- Treat layout warnings as defects unless a snapshot proves the overflow is intentional, in which case mark it with `data-layout-allow-overflow`.
|
||||
- State motion intent in a `*.motion.json` sidecar so `inspect` checks it automatically — entrances firing under seek, stagger order, in-frame, liveness. This is the closest automated proxy for "watch the MP4" and catches render-≠-preview bugs the eye misses (see **Motion verification** below).
|
||||
|
||||
## lint
|
||||
|
||||
@@ -82,6 +83,33 @@ Errors should be fixed before rendering. Warnings are surfaced for agent review;
|
||||
|
||||
`npx hyperframes layout` remains available as a compatibility alias for the same visual inspection pass.
|
||||
|
||||
### Motion verification (`*.motion.json` sidecar)
|
||||
|
||||
`inspect` also checks **motion intent** against the same seeked timeline the renderer uses — the closest automated proxy for "render the MP4 and watch it". It catches render-≠-preview bugs layout sampling can't: an entrance reveal the seek lands past, a broken stagger order, an element drifting off-frame mid-tween, a frozen shot.
|
||||
|
||||
Drop a `*.motion.json` sidecar next to the composition (matching the html basename when several compositions share a dir). `inspect` discovers it automatically — no flag, no authoring-framework changes. With no sidecar, `inspect` behaves exactly as before.
|
||||
|
||||
```json
|
||||
{
|
||||
"duration": 6,
|
||||
"assertions": [
|
||||
{ "kind": "appearsBy", "selector": "#headline", "bySec": 0.5 },
|
||||
{ "kind": "before", "a": "#headline", "b": "#cta" },
|
||||
{ "kind": "staysInFrame", "selector": ".card" },
|
||||
{ "kind": "keepsMoving", "withinSelector": ".scene" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Assertion | Fails (code) when |
|
||||
| ------------------------------ | --------------------------------------------------------------------------- |
|
||||
| `appearsBy(selector, bySec)` | not visible (opacity ≥ 0.5) by `bySec` — `motion_appears_late` |
|
||||
| `before(a, b)` | `a` does not first appear strictly before `b` — `motion_out_of_order` |
|
||||
| `staysInFrame(selector)` | once visible, its box leaves the canvas — `motion_off_frame` |
|
||||
| `keepsMoving(withinSelector?)` | a fully-static window exceeds `maxStaticSec` (default 2s) — `motion_frozen` |
|
||||
|
||||
`duration`, `withinSelector`, and `maxStaticSec` are optional. Findings are **errors by default** (a failed assertion fails the run, like a layout error — `--strict` still gates warnings) and appear in the same human and `--json` output as layout findings. A selector that matches nothing is reported as `motion_selector_missing` rather than silently passing — so a typo'd selector fails loudly. Use this in the feedback loop instead of eyeballing the render: assert what the motion is supposed to do, and let `inspect` tell you when the seek diverges from intent.
|
||||
|
||||
## snapshot
|
||||
|
||||
```bash
|
||||
|
||||
Reference in New Issue
Block a user