mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user