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.
142 lines
6.0 KiB
TypeScript
142 lines
6.0 KiB
TypeScript
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);
|
|
}
|