diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index a0c611df84..edb539fc53 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -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", diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 748ea3f160..3efe04d62e 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -19,7 +19,7 @@ npx hyperframes - 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: diff --git a/packages/cli/scripts/build-copy.mjs b/packages/cli/scripts/build-copy.mjs index 510d779d3a..57ad453387 100644 --- a/packages/cli/scripts/build-copy.mjs +++ b/packages/cli/scripts/build-copy.mjs @@ -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"); diff --git a/packages/cli/src/commands/inspect.ts b/packages/cli/src/commands/inspect.ts index a791f593d8..46a77aaa0b 100644 --- a/packages/cli/src/commands/inspect.ts +++ b/packages/cli/src/commands/inspect.ts @@ -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"], ]; diff --git a/packages/cli/src/commands/layout.ts b/packages/cli/src/commands/layout.ts index 874d379ed4..18d1fe8112 100644 --- a/packages/cli/src/commands/layout.ts +++ b/packages/cli/src/commands/layout.ts @@ -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 { @@ -205,6 +228,7 @@ async function runLayoutAudit( maxTransitionSamples?: number; timeout: number; tolerance: number; + motion?: MotionSpec; }, ): Promise { 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 { + 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 { + 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 { + 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; + }; + }; + 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, diff --git a/packages/cli/src/commands/motion-sample.browser.js b/packages/cli/src/commands/motion-sample.browser.js new file mode 100644 index 0000000000..9d3e520d5f --- /dev/null +++ b/packages/cli/src/commands/motion-sample.browser.js @@ -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) }; + }; +})(); diff --git a/packages/cli/src/commands/motion-sample.browser.test.ts b/packages/cli/src/commands/motion-sample.browser.test.ts new file mode 100644 index 0000000000..d8003cf646 --- /dev/null +++ b/packages/cli/src/commands/motion-sample.browser.test.ts @@ -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; +} + +function installGeometry(byId: Record): 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 = ` +
Hi
+ `; + 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 = ` +
Hi
+ `; + 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 = `
x
`; + + 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 = ` +
x
+ `; + 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(""); + }); +}); diff --git a/packages/cli/src/utils/layoutAudit.ts b/packages/cli/src/utils/layoutAudit.ts index fcf8459434..2826c1dcf5 100644 --- a/packages/cli/src/utils/layoutAudit.ts +++ b/packages/cli/src/utils/layoutAudit.ts @@ -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"; diff --git a/packages/cli/src/utils/motionAudit.test.ts b/packages/cli/src/utils/motionAudit.test.ts new file mode 100644 index 0000000000..0b4e082410 --- /dev/null +++ b/packages/cli/src/utils/motionAudit.test.ts @@ -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; + liveness?: Record; + }, +): 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"]); + }); +}); diff --git a/packages/cli/src/utils/motionAudit.ts b/packages/cli/src/utils/motionAudit.ts new file mode 100644 index 0000000000..368de902ee --- /dev/null +++ b/packages/cli/src/utils/motionAudit.ts @@ -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; + /** Liveness signature per scope ("*" = whole canvas; otherwise a withinSelector). */ + liveness: Record; +} + +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(); + const scopes = new Set(); + 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] }; +} diff --git a/packages/cli/src/utils/motionSpec.test.ts b/packages/cli/src/utils/motionSpec.test.ts new file mode 100644 index 0000000000..bc8af47b16 --- /dev/null +++ b/packages/cli/src/utils/motionSpec.test.ts @@ -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): 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"), "
"); + 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"), "
"); + 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"), "
"); + writeFileSync(join(dir, "landing.html"), "
"); + 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); + }); +}); diff --git a/packages/cli/src/utils/motionSpec.ts b/packages/cli/src/utils/motionSpec.ts new file mode 100644 index 0000000000..b6dec6bab3 --- /dev/null +++ b/packages/cli/src/utils/motionSpec.ts @@ -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 { + 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, at: string) => MotionAssertion | string; + +const VALIDATORS: Record = { + 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 = { 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); +} diff --git a/skills/hyperframes-cli/SKILL.md b/skills/hyperframes-cli/SKILL.md index 79142a7d4e..e77c833efe 100644 --- a/skills/hyperframes-cli/SKILL.md +++ b/skills/hyperframes-cli/SKILL.md @@ -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 diff --git a/skills/hyperframes-cli/references/lint-validate-inspect.md b/skills/hyperframes-cli/references/lint-validate-inspect.md index 5f9d0b814a..13d2245062 100644 --- a/skills/hyperframes-cli/references/lint-validate-inspect.md +++ b/skills/hyperframes-cli/references/lint-validate-inspect.md @@ -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