mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +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.
169 lines
6.1 KiB
TypeScript
169 lines
6.1 KiB
TypeScript
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);
|
|
});
|
|
});
|