Skip to content

motion-doctrine

by HeyGen · official repository

GATEWAY — load FIRST before composing any HyperFrames animation or video. The high-level motion law that makes a multi-scene video feel like ONE continuous camera move instead of a stack of independently-animated slides. Covers the vector law (how you exit determines how you enter, incl. the Z scale-sign rule), the film's current, carrier elements, causal motion, the Seam Gate (build-gate enforcement), the ban on idle wobble (motion must PERFORM, not breathe), stillness-before-climax, and the sustained-motion routes. Routes to the low-level technique skills (cut-the-curve — the full catalog incl. waterfall entry + nudge curve, oversized-cursor, seam-craft). These rules SUPERSEDE generic / upstream motion guidance. [continuity, direction, vector, momentum, seam, transition, ease, performanc… [47 more characters not shown]

OfficialReaches the networkRuns commands2 scripts1 finding

Labels from its files: reaches the network, runs commands. 4 files, read Oct 21, 2026. Nothing was run.

npx skills add https://github.com/heygen-com/hyperframes --skill motion-doctrine

What it lets an agent do

Each label, and the line in the skill's files that set it.

  • Reaches the network

    Its scripts or instructions fetch from or send to the network.

    • scripts/seam-gate.mjs makes network calls.
  • Runs commands

    Allows a shell or ships scripts the agent runs.

    • It ships 2 scripts the agent can run.

Findings (1)

Matches of fixed checks, with the text they matched.

  • warnscripts/seam-gate.mjs

    scripts/seam-gate.mjs runs a command string through a shell, or evaluates code it builds at run time.

    … import { spawn } from "node:child_process"; import { readFileSync, exis…

    What this check looks for

Not a safety rating, and no finding is not a safety guarantee. These are fixed checks of the files as read on Oct 21, 2026. Nothing was run. How skills are checked.

SKILL.md

As the publisher wrote it. Shown as text, never rendered.

Publisher text
---
name: motion-doctrine
description: "GATEWAY — load FIRST before composing any HyperFrames animation or video. The high-level motion law that makes a multi-scene video feel like ONE continuous camera move instead of a stack of independently-animated slides. Covers the vector law (how you exit determines how you enter, incl. the Z scale-sign rule), the film's current, carrier elements, causal motion, the Seam Gate (build-gate enforcement), the ban on idle wobble (motion must PERFORM, not breathe), stillness-before-climax, and the sustained-motion routes. Routes to the low-level technique skills (cut-the-curve — the full catalog incl. waterfall entry + nudge curve, oversized-cursor, seam-craft). These rules SUPERSEDE generic / upstream motion guidance. [continuity, direction, vector, momentum, seam, transition, ease, performance, idle-motion, narrative-motion, film-grammar]"
metadata:
  internal: true
---

# Motion Doctrine (Gateway)

Read this before composing any animation. It decides WHAT happens at every seam and how
every scene performs; the technique skills implement it. These rules supersede generic /
upstream motion guidance. The failure this prevents: scenes authored in isolation — the
eye's momentum dies at every cut, and scenes wobble in place between entry and exit.

## Route map

| Decision (this skill)                              | Implementation skill                                                                              |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Seam transition choice + parameters + code         | `cut-the-curve` §1–5 (the catalog)                                                                |
| Text / element entry cascades                      | `cut-the-curve` §6 (waterfall entry)                                                              |
| In-scene group repositioning (no cut)              | `cut-the-curve` §7 (nudge curve)                                                                  |
| Cursor-led action / scene kickoff / morph ignition | `oversized-cursor`                                                                                |
| Seam render mechanics / white-flash guard          | `seam-craft`                                                                                      |
| Product-launch / explainer / caption work          | overlays `text-beat-economics`, `brand-faithful`, `captions-overlay` on top of the upstream skill |

Authoring order: **vector ledger (`ledger.json`) → STAMP the master seams from it
(`scripts/seam-stamp.mjs --ledger ledger.json --write index.html`) → sustained-motion
route per phase → carriers and causes → build comps → VERIFY (`scripts/seam-gate.mjs`).**
Hand-author only Tier-A morphs/match-cuts; stamped seams pass the gate by construction.

---

# Part 1 — The Seam Law

## The Vector Law

> How Scene A exits determines how Scene B enters: same axis, same direction, matched
> speed, cut mid-motion on both sides.

1. **Axis** — x stays x, y stays y, Z stays Z. Never trade axes across a cut.
2. **Direction** — never mirror. On Z, direction = the SIGN of scale change: growing =
   push (camera forward), shrinking = pull (camera back). A receding exit answered by a
   grow-from-small entry is a mirrored vector — the most common violation, because
   grow-from-small is the default element entrance.
3. **Speed** — entry initial velocity ≈ exit final velocity, via mirrored eases (exit
   `power4.in` + entry `power4.out`, same distance and duration; the incoming side picks
   up ≥50% through the notional path). Mechanics in `cut-the-curve`.
4. **Phase** — the cut lands mid-motion on BOTH sides. Settling to rest before the cut,
   or starting from rest after it, is a dead beat.

## The Current

Every film picks ONE dominant direction (house default: LEFT). Every ordinary seam uses
it. Other vectors are RESERVED — spending one means something:

| Vector                    | Meaning                                                         |
| ------------------------- | --------------------------------------------------------------- |
| The current (LEFT)        | "next beat" — neutral forward progress                          |
| Upward                    | elevation — a conclusion or reveal rises above what came before |
| Z forward (zoom-through)  | pushing deeper into the same thought                            |
| Z backward (inverse zoom) | ARRIVAL — something bigger lands                                |
| Scale-burst (explode out) | leaving a world — a surface blasts past camera                  |

- Never run consecutive seams in opposing directions — ping-pong reads as an error.
- A direction change needs a visible cause (click / bounce / impact) or a chapter boundary.

## The Vector Ledger

Write it before authoring any master timeline — as **`ledger.json` at the project root**
(schema: `references/seam-gate.md`). One row per seam: cut time, exit and entry vectors
(axis + signed direction; Z rows carry the scale sign), selectors, technique. Exit and
entry must match; if a row mismatches, fix the plan, not the easing. The verifier checks
row consistency statically before any runtime sampling.

## Carriers

The eye follows objects, not abstractions. The strongest seams hand a concrete carrier
across the cut at matched position AND velocity: a cursor mid-path, a container that
shrinks/docks into the next layout, a mark that flies into its exact slot, the word group
of a waterfall cut. With no natural carrier, the scene heroes carry it (partial travel +
early fade, entry mid-flight). Never a crossfade — it has no carrier at all.

## Causal Motion

Chain motion so each move is visibly launched by the last: click → squash → release
spring → flight → impact → recoil → reveal.

- Effects start ON the causing frame — same timeline position, never "shortly after."
- Reactions scale with implied mass: big elements rebound slower, small ones snap.
- A force is a license to change direction; an uncaused flip is a ping-pong.

## The Seam Gate (build gate — run the verifier, exit 0 or the seam is not done)

```bash
node <SKILL_DIR>/scripts/seam-stamp.mjs --ledger ledger.json --write index.html  # generate
node <SKILL_DIR>/scripts/seam-gate.mjs  verify --ledger ledger.json --project .  # verify
```

The script (usage + ledger schema: `references/seam-gate.md`) numerically enforces, per
seam: ledger-row consistency, exit still moving at the cut, entry mid-flight (never from
rest), measured direction = ledger direction, entry/exit speed match (WARN), **zero
overlap** (one side visible per frame — the cut is not a dissolve), the **Z sign** rule
(d(scale)/dt same sign both sides; the incoming scene's own entrances are scanned for
sign-fighting), and carrier rect continuity with ancestor scale included. Use
`seam-gate.mjs probe --t <cut>` to find each seam's true carrier selectors when authoring
the ledger.

Rules the script cannot check — still yours:

1. **Edits re-open the seam.** Any change to a scene's first/last ~1s (including
   re-timing to new VO) invalidates that boundary's audit — re-run the verifier.
2. **Audio is the clock.** Re-time scenes to the VO's real word timestamps; never rush a
   read to fit a slot. A VO regen re-opens its seams.
3. **Clip-gating gotcha** (the usual cause of a zero-overlap FAIL): a clip whose
   `data-start` precedes its entry tween is un-hidden at its initial opacity — set
   initial `autoAlpha: 0` AND `data-start` = the cut time, never earlier.

---

# Part 2 — Performance (the scene keeps performing)

## No idle wobble

Idle sine loops (breathe, float, drift, glow pulse) are BANNED as sustained motion — they
read as "the video is waiting." A scene that finishes entering with seconds left is a
planning bug: add story, not wobble. Every phase between entry and exit is owned by one
of these routes (name the route in the plan):

| Route                  | What it is                                                                                                             |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| **Staged reveals**     | Hold content back; pay it off on narration beats — the frame keeps gaining information (default for ≥2 content groups) |
| **Camera with intent** | A mapped scale+pan path: establish wide → travel → arrive on the subject                                               |
| **Sequenced UI life**  | The product behaves over time: progress advances, highlights step, counts tick                                         |
| **Animated sequences** | Elements act out a beat: a card files into a stack, an item gets dragged, a result assembles                           |
| **Cursor-led action**  | An oversized cursor walks the eye to a trigger; its CLICK ignites the next beat (`oversized-cursor`)                   |

Test: pause at any second — something meaningful must be mid-flight (a reveal landing,
the camera traveling, the UI doing what the narration says).

## Stillness before climax

Schedule a **0.3–0.75s pause** between the major action and its result — the dramatic
comma. A scene that jumps straight from action to result loses it.

## Timing intents

- Single entry ≤ ~800ms; longer buildup = multi-element stagger, not one slow element.
- Exit ≈ 75% of entry. Exception: cut-the-curve inverts this (entry ~127% of exit).
- Total stagger ≤ 500ms; with 8+ elements, tighten per-item delay or stagger the first few.
- Forbidden eases: `bounce.out` / `elastic.out`. Entry overshoot `back.out(1.4–1.7)` is fine.
- Similar elements share one ease+duration intent — never a unique pair per element.

## Transition vocabulary

Use only 2–3 inter-scene transitions per film and repeat them; the default boundary is
**cut-the-curve in the current's direction**. Hand-written shared-element morphs
(`intent: morph`) don't count against the budget.

---

## Anti-Patterns

| Don't                                                                      | Instead                                            |
| -------------------------------------------------------------------------- | -------------------------------------------------- |
| Author each scene's entrance in isolation                                  | Write the vector ledger first                      |
| Crossfade between scenes                                                   | Cut-the-curve in the current's direction           |
| Exit completes, THEN the scene changes                                     | Cut mid-motion on both sides                       |
| Entry starts from rest after a cut                                         | Enter ≥50% through the notional path               |
| Inverse-zoom exit → grow-from-small entry (or push → oversized retraction) | Match the scale-velocity sign (Seam Gate 7)        |
| Incoming scene's own pop-in intro under a Z-seam handoff                   | Hold its opening frame composed, or match the sign |
| Idle wobble / breathe / float to fill time                                 | Assign a sustained-motion route; or add story      |
| Direction flip without a cause                                             | Spend a force, or keep the current                 |
| Reserved vectors used as variety                                           | Default to the current; spend them on meaning      |
| Reaction a few frames after its cause                                      | Same-frame ignition                                |
| Action jumps straight to result                                            | Schedule stillness-before-climax (0.3–0.75s)       |

Scripts (2)

Read as text. anlyon.dev never runs them.

scripts/seam-gate.mjsShow
#!/usr/bin/env node
// seam-gate.mjs — numeric Seam Gate verifier for HyperFrames films (motion-doctrine).
// Zero npm dependencies: drives chrome-headless-shell over raw CDP (node >= 22).
//
//   verify  node seam-gate.mjs verify --ledger ledger.json --project <dir> [--json]
//           node seam-gate.mjs verify --ledger ledger.json --url http://localhost:5244
//   probe   node seam-gate.mjs probe --t 44.8 --project <dir>   # list movers around a cut
//
// --project spawns a FRESH preview server (avoids the stale-bundle cache) with
// HYPERFRAME_RUNTIME_URL unset. --url reuses a running server: restart it after
// comp edits or you verify a stale build.
// Ledger schema: see references/seam-gate.md next to this skill.

import { spawn } from "node:child_process";
import { readFileSync, existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";

// ---------- args ----------
const argv = process.argv.slice(2);
const mode = argv[0];
function flag(name, dflt) {
  const i = argv.indexOf("--" + name);
  return i >= 0 ? argv[i + 1] : dflt;
}
const has = (name) => argv.includes("--" + name);
if (!["verify", "probe"].includes(mode)) {
  console.error(
    "usage: seam-gate.mjs verify --ledger ledger.json (--project <dir> | --url <preview-url>) [--json]",
  );
  console.error(
    "       seam-gate.mjs probe --t <seconds> (--project <dir> | --url <preview-url>) [--window 0.1]",
  );
  process.exit(2);
}

const FPS = Number(flag("fps", 30));
const DT = 1 / FPS;
const VIS = 0.04; // cumulative opacity below this = invisible
const EPS_XY = 15; // px/s — slower than this = "static"
const EPS_Z = 0.04; // effective-scale units/s
const SPEED_RATIO = 3; // entry/exit velocity ratio beyond this = WARN
const CARRIER_POS_TOL = 12; // px center offset
const CARRIER_SIZE_TOL = 0.05;

const cleanup = [];
process.on("exit", () =>
  cleanup.forEach((fn) => {
    try {
      fn();
    } catch {}
  }),
);
for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => process.exit(130));

// ---------- preview server ----------
async function httpOk(url) {
  try {
    const r = await fetch(url, { signal: AbortSignal.timeout(2000) });
    return r.ok;
  } catch {
    return false;
  }
}

async function ensureServer() {
  let base = flag("url", null);
  if (!base) {
    const project = flag("project", null);
    if (!project) throw new Error("need --url or --project");
    const port = 5380 + Math.floor(Math.random() * 20);
    const env = { ...process.env };
    delete env.HYPERFRAME_RUNTIME_URL; // wrong value fails silently as 200 HTML
    // `preview` backgrounds itself when stdin/stdout aren't TTYs, which they never are here: the
    // launcher would exit 0 before the server is up and detach it out of our process group.
    // Default to the REPO-LOCAL CLI whenever this skill is running from its repo
    // checkout. The gate this script backs is defined against that build, and the
    // skill forbids `npx hyperframes@latest` for it, so defaulting to the published
    // package here would let the two gates measure different runtimes. npx stays the
    // fallback for a copy of the skill living outside the repo.
    const repoCli = join(
      import.meta.dirname,
      "..",
      "..",
      "..",
      "..",
      "packages",
      "cli",
      "bin",
      "hyperframes.mjs",
    );
    const cmd = flag(
      "server-cmd",
      existsSync(repoCli)
        ? `node "${repoCli}" preview --foreground --no-open --port ${port}`
        : `npx --yes hyperframes preview --foreground --no-open --port ${port}`,
    );
    const child = spawn("sh", ["-c", cmd.replace(/\{port\}/g, String(port))], {
      cwd: project,
      env,
      stdio: ["ignore", "pipe", "pipe"],
      detached: true,
    });
    cleanup.push(() => {
      try {
        process.kill(-child.pid, "SIGTERM");
      } catch {}
    });
    base = `http://localhost:${port}`;
    const deadline = Date.now() + 120_000;
    while (Date.now() < deadline) {
      if (await httpOk(base + "/api/projects")) break;
      if (child.exitCode !== null) throw new Error("preview server exited early");
      await new Promise((r) => setTimeout(r, 500));
    }
    if (!(await httpOk(base + "/api/projects")))
      throw new Error("preview server never became ready");
  }
  base = base.replace(/\/$/, "");
  let compUrl = flag("comp-url", null);
  if (!compUrl) {
    const r = await fetch(base + "/api/projects");
    const j = await r.json();
    const id = j?.projects?.[0]?.id;
    if (!id) throw new Error("could not resolve project id from /api/projects");
    compUrl = `${base}/api/projects/${id}/preview/comp/index.html`;
  }
  return compUrl;
}

// ---------- chrome ----------
function findChrome() {
  if (process.env.CHROME_PATH) return { bin: process.env.CHROME_PATH, headlessFlag: true };
  const cache = join(homedir(), ".cache", "puppeteer");
  for (const kind of ["chrome-headless-shell", "chrome"]) {
    const root = join(cache, kind);
    if (!existsSync(root)) continue;
    const versions = readdirSync(root).sort().reverse();
    for (const v of versions) {
      const vdir = join(root, v);
      for (const plat of readdirSync(vdir)) {
        const bin =
          kind === "chrome-headless-shell"
            ? join(vdir, plat, "chrome-headless-shell")
            : join(
                vdir,
                plat,
                "Google Chrome for Testing.app",
                "Contents",
                "MacOS",
                "Google Chrome for Testing",
              );
        if (existsSync(bin)) return { bin, headlessFlag: kind !== "chrome-headless-shell" };
      }
    }
  }
  const sys = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
  if (existsSync(sys)) return { bin: sys, headlessFlag: true };
  throw new Error("no Chrome found (set CHROME_PATH)");
}

async function launchChrome() {
  const { bin, headlessFlag } = findChrome();
  const args = [
    "--remote-debugging-port=0",
    "--no-first-run",
    "--no-default-browser-check",
    "--mute-audio",
    "--hide-scrollbars",
    "--disable-extensions",
    "--window-size=1920,1080",
    "about:blank",
  ];
  if (headlessFlag) args.unshift("--headless=new");
  const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"], detached: true });
  cleanup.push(() => {
    try {
      process.kill(-child.pid, "SIGKILL");
    } catch {}
  });
  const wsUrl = await new Promise((resolve, reject) => {
    let buf = "";
    const t = setTimeout(() => reject(new Error("chrome DevTools endpoint timeout")), 20_000);
    child.stderr.on("data", (d) => {
      buf += d;
      const m = buf.match(/DevTools listening on (ws:\/\/\S+)/);
      if (m) {
        clearTimeout(t);
        resolve(m[1]);
      }
    });
    child.on("exit", () => reject(new Error("chrome exited: " + buf.slice(-400))));
  });
  return wsUrl;
}

// ---------- minimal CDP client ----------
class CDP {
  constructor(ws) {
    this.ws = ws;
    this.id = 0;
    this.pending = new Map();
    this.listeners = [];
  }
  static async connect(url) {
    const ws = new WebSocket(url);
    await new Promise((res, rej) => {
      ws.onopen = res;
      ws.onerror = () => rej(new Error("ws connect failed"));
    });
    const c = new CDP(ws);
    ws.onmessage = (ev) => {
      const msg = JSON.parse(ev.data);
      if (msg.id !== undefined && c.pending.has(msg.id)) {
        const { res, rej } = c.pending.get(msg.id);
        c.pending.delete(msg.id);
        if (msg.error) rej(new Error(msg.error.message));
        else res(msg.result);
      } else if (msg.method) {
        c.listeners.forEach((l) => l(msg));
      }
    };
    return c;
  }
  send(method, params = {}, sessionId, timeoutMs = 30_000) {
    const id = ++this.id;
    const payload = { id, method, params };
    if (sessionId) payload.sessionId = sessionId;
    this.ws.send(JSON.stringify(payload));
    return new Promise((res, rej) => {
      this.pending.set(id, { res, rej });
      setTimeout(() => {
        if (this.pending.has(id)) {
          this.pending.delete(id);
          rej(new Error(method + " timeout"));
        }
      }, timeoutMs);
    });
  }
  waitEvent(method, sessionId, timeoutMs = 30_000) {
    return new Promise((res, rej) => {
      const t = setTimeout(() => rej(new Error("waiting " + method + " timeout")), timeoutMs);
      const l = (msg) => {
        if (msg.method === method && (!sessionId || msg.sessionId === sessionId)) {
          clearTimeout(t);
          this.listeners = this.listeners.filter((x) => x !== l);
          res(msg.params);
        }
      };
      this.listeners.push(l);
    });
  }
}

async function openPage(compUrl) {
  const cdp = await CDP.connect(await launchChrome());
  const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
  const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
  await cdp.send("Page.enable", {}, sessionId);
  await cdp.send("Runtime.enable", {}, sessionId);
  await cdp.send(
    "Emulation.setDeviceMetricsOverride",
    { width: 1920, height: 1080, deviceScaleFactor: 1, mobile: false },
    sessionId,
  );
  const loaded = cdp.waitEvent("Page.loadEventFired", sessionId, 60_000);
  await cdp.send("Page.navigate", { url: compUrl }, sessionId);
  await loaded;
  const evalJs = async (expr, awaitPromise = false) => {
    const r = await cdp.send(
      "Runtime.evaluate",
      { expression: expr, returnByValue: true, awaitPromise },
      sessionId,
      60_000,
    );
    if (r.exceptionDetails)
      throw new Error(
        "page error: " + (r.exceptionDetails.exception?.description || r.exceptionDetails.text),
      );
    return r.result.value;
  };
  // wait for the HF runtime player
  const deadline = Date.now() + 45_000;
  while (Date.now() < deadline) {
    if (await evalJs("!!(window.__playerReady && window.__renderReady && window.__player)")) break;
    await new Promise((r) => setTimeout(r, 300));
  }
  if (!(await evalJs("!!window.__player")))
    throw new Error("HF runtime player never appeared — is this a preview comp URL?");
  await evalJs("document.fonts.ready.then(()=>true)", true);
  await evalJs(HARNESS);
  return { evalJs };
}

// ---------- in-page harness ----------
const HARNESS = `window.__seamGate = {
  seek(t){ __player.pause(); __player.seek(t); void document.body.offsetHeight; },
  cumOp(el){
    let op = 1, n = el;
    while (n && n.nodeType === 1) {
      const c = getComputedStyle(n);
      if (c.display === "none" || c.visibility === "hidden") return 0;
      op *= parseFloat(c.opacity || "1");
      n = n.parentElement;
    }
    return op;
  },
  read(sel){
    const el = document.querySelector(sel);
    if (!el) return null;
    const r = el.getBoundingClientRect();
    const lw = el.offsetWidth || r.width || 1;
    const onscreen = r.right > 0 && r.bottom > 0 && r.left < 1920 && r.top < 1080;
    return { cx: r.x + r.width/2, cy: r.y + r.height/2, w: r.width, h: r.height,
             op: this.cumOp(el), es: r.width / lw, onscreen };
  },
  sample(t, sels){ this.seek(t); const o = {}; for (const s of sels) o[s] = this.read(s); return o; },
  pathOf(el){
    if (el.id) return "#" + CSS.escape(el.id);
    const hf = el.getAttribute && el.getAttribute("data-hf-id");
    if (hf) return '[data-hf-id="' + hf + '"]';
    let p = [], n = el, depth = 0;
    while (n && n.nodeType === 1 && depth < 5) {
      if (n.id) { p.unshift("#" + CSS.escape(n.id)); break; }
      const h2 = n.getAttribute("data-hf-id");
      if (h2) { p.unshift('[data-hf-id="' + h2 + '"]'); break; }
      const kids = n.parentElement ? [...n.parentElement.children] : [n];
      p.unshift(n.tagName.toLowerCase() + ":nth-child(" + (kids.indexOf(n) + 1) + ")");
      n = n.parentElement; depth++;
    }
    return p.join(">");
  },
  scan(t, rootSel, cap){
    this.seek(t);
    const root = document.querySelector(rootSel || "#root");
    if (!root) return [];
    const els = [root, ...root.querySelectorAll("*")].slice(0, cap || 900);
    const out = [];
    for (const el of els) {
      if (/^(SCRIPT|STYLE|AUDIO|LINK|META)$/.test(el.tagName)) continue;
      const r = el.getBoundingClientRect();
      if (r.width < 32 && r.height < 32) continue;
      const op = this.cumOp(el);
      const lw = el.offsetWidth || r.width || 1;
      out.push({ sel: this.pathOf(el), cx: r.x + r.width/2, cy: r.y + r.height/2,
                 w: r.width, h: r.height, op, es: r.width / lw });
    }
    return out;
  }
};true`;

// ---------- measurement helpers ----------
const sgn = (v) => (v > 0 ? 1 : v < 0 ? -1 : 0);
const visible = (m) => !!m && m.op > VIS && m.w * m.h > 16 && m.onscreen !== false;
function velocity(m1, m2, dt, axis) {
  if (!m1 || !m2) return null;
  if (axis === "x") return (m2.cx - m1.cx) / dt;
  if (axis === "y") return (m2.cy - m1.cy) / dt;
  return (m2.es - m1.es) / dt; // z
}
const eps = (axis) => (axis === "z" ? EPS_Z : EPS_XY);
const fmtV = (v, axis) =>
  v === null ? "n/a" : axis === "z" ? v.toFixed(3) + " es/s" : v.toFixed(0) + " px/s";

// ---------- verify ----------
async function verify() {
  const ledgerPath = flag("ledger", "ledger.json");
  const ledger = JSON.parse(readFileSync(ledgerPath, "utf8"));
  const fps = ledger.fps || FPS,
    dt = 1 / fps;
  const compUrl = await ensureServer();
  const { evalJs } = await openPage(compUrl);
  const results = [];

  for (const seam of ledger.seams) {
    const rows = [];
    const add = (check, status, detail) => rows.push({ check, status, detail });
    const cut = seam.cut;
    const type = seam.type || "cut";
    const tA1 = Math.max(0, cut - 0.1),
      tA2 = Math.max(0, cut - dt);
    const tB1 = cut + dt,
      tB2 = cut + 0.1;
    const sels = [
      seam.exit?.selector,
      seam.entry?.selector,
      seam.carrier?.out,
      seam.carrier?.in,
    ].filter(Boolean);
    const S = {};
    for (const t of [tA1, tA2, tB1, tB2])
      S[t] = await evalJs(`__seamGate.sample(${t}, ${JSON.stringify([...new Set(sels)])})`);

    if (type === "cut") {
      const ex = seam.exit,
        en = seam.entry;
      // 0 — ledger row itself
      if (ex.axis !== en.axis || ex.dir !== en.dir)
        add(
          "ledger",
          "FAIL",
          `exit ${ex.axis}${ex.dir > 0 ? "+" : "-"} vs entry ${en.axis}${en.dir > 0 ? "+" : "-"} — mirrored/mixed vector in the PLAN`,
        );
      else add("ledger", "PASS", `${ex.axis}${ex.dir > 0 ? "+" : "-"} both sides`);

      for (const [side, cfg, m1, m2, t1, t2] of [
        ["exit", ex, S[tA1][ex.selector], S[tA2][ex.selector], tA1, tA2],
        ["entry", en, S[tB1][en.selector], S[tB2][en.selector], tB1, tB2],
      ]) {
        if (!m1 || !m2) {
          add(side, "FAIL", `selector ${cfg.selector} not found`);
          continue;
        }
        const v = velocity(m1, m2, t2 - t1, cfg.axis);
        const moving = Math.abs(v) >= eps(cfg.axis);
        const vizOk = side === "exit" ? visible(m1) : visible(m2);
        if (!vizOk)
          add(
            side + "-visible",
            "FAIL",
            `${cfg.selector} not visible in its window (op ${(side === "exit" ? m1 : m2)?.op?.toFixed(2)})`,
          );
        if (!moving)
          add(
            side + "-moving",
            "FAIL",
            `${cfg.selector} static at the cut (${fmtV(v, cfg.axis)}) — ${side === "exit" ? "exit settled before the boundary" : "entry starts from rest"}`,
          );
        else if (sgn(v) !== cfg.dir)
          add(
            side + "-direction",
            "FAIL",
            `${cfg.selector} moving ${fmtV(v, cfg.axis)} — opposite of ledger dir ${cfg.dir > 0 ? "+" : "-"}${cfg.axis === "z" ? " (mirrored zoom)" : ""}`,
          );
        else
          add(
            side + "-vector",
            "PASS",
            `${fmtV(v, cfg.axis)} ${cfg.axis}${cfg.dir > 0 ? "+" : "-"}`,
          );
        if (side === "exit") seam.__vExit = v;
        else seam.__vEntry = v;
      }

      // speed match
      if (seam.__vExit != null && seam.__vEntry != null && Math.abs(seam.__vExit) > 0) {
        const ratio = Math.abs(seam.__vEntry) / Math.abs(seam.__vExit);
        if (ratio > SPEED_RATIO || ratio < 1 / SPEED_RATIO)
          add("speed-match", "WARN", `entry/exit velocity ratio ${ratio.toFixed(2)} (want ~1)`);
        else add("speed-match", "PASS", `ratio ${ratio.toFixed(2)}`);
      }

      // zero overlap
      const enPre = S[tA2][en.selector],
        exPost = S[tB1][ex.selector];
      if (visible(enPre))
        add(
          "zero-overlap",
          "FAIL",
          `incoming ${en.selector} already visible at cut-1f (op ${enPre.op.toFixed(2)}) while outgoing still on screen — reads as a dissolve`,
        );
      else if (visible(exPost))
        add(
          "zero-overlap",
          "FAIL",
          `outgoing ${ex.selector} still visible at cut+1f (op ${exPost.op.toFixed(2)})`,
        );
      else add("zero-overlap", "PASS", "one side visible per frame");

      // Z-sign scan: the incoming scene's OWN entrances must not fight the seam's Z sign
      if (en.axis === "z") {
        const scanRoot = en.scanRoot || en.selector;
        const s1 = await evalJs(`__seamGate.scan(${tB1}, ${JSON.stringify(scanRoot)})`);
        const s2 = await evalJs(`__seamGate.scan(${tB2}, ${JSON.stringify(scanRoot)})`);
        const m1 = new Map(s1.map((e) => [e.sel, e]));
        const offenders = [];
        for (const e2 of s2) {
          const e1 = m1.get(e2.sel);
          if (!e1 || e2.op <= 0.1) continue;
          const vs = (e2.es - e1.es) / (tB2 - tB1);
          if (Math.abs(vs) >= EPS_Z && sgn(vs) !== en.dir)
            offenders.push(`${e2.sel} (${vs.toFixed(3)} es/s)`);
        }
        if (offenders.length)
          add(
            "z-sign-scan",
            "FAIL",
            `elements scaling AGAINST the seam's Z sign in the entry window: ${offenders.slice(0, 5).join(", ")}${offenders.length > 5 ? ` +${offenders.length - 5} more` : ""}`,
          );
        else add("z-sign-scan", "PASS", "no sign-fighting entrances");
      }
    }

    // carrier continuity (any seam type that declares one; the whole check for match-cut/morph)
    if (seam.carrier) {
      const out = S[tA2][seam.carrier.out],
        inn = S[tB1][seam.carrier.in];
      if (!out || !inn) add("carrier", "FAIL", "carrier selector not found");
      else {
        const dx = Math.abs(out.cx - inn.cx),
          dy = Math.abs(out.cy - inn.cy);
        const ds = Math.abs(out.w - inn.w) / Math.max(out.w, 1);
        if (dx > CARRIER_POS_TOL || dy > CARRIER_POS_TOL)
          add(
            "carrier-position",
            "FAIL",
            `center off by ${dx.toFixed(0)},${dy.toFixed(0)}px across the cut`,
          );
        else if (ds > CARRIER_SIZE_TOL)
          add(
            "carrier-size",
            "FAIL",
            `size differs ${(ds * 100).toFixed(1)}% across the cut (ancestor scale?)`,
          );
        else
          add(
            "carrier",
            "PASS",
            `Δpos ${dx.toFixed(1)},${dy.toFixed(1)}px Δsize ${(ds * 100).toFixed(1)}%`,
          );
      }
    }

    if (type !== "cut" && !seam.carrier)
      add("carrier", "WARN", `type "${type}" without a carrier — nothing to verify`);

    results.push({ id: seam.id, cut, type, rows });
  }
  return results;
}

// ---------- probe ----------
async function probe() {
  const t = Number(flag("t"));
  if (!Number.isFinite(t)) throw new Error("probe needs --t <seconds>");
  const win = Number(flag("window", 0.1));
  const compUrl = await ensureServer();
  const { evalJs } = await openPage(compUrl);
  const dt = DT;
  const scans = {};
  for (const tt of [t - win, t - dt, t + dt, t + win])
    scans[tt] = await evalJs(`__seamGate.scan(${Math.max(0, tt)}, "#root")`);
  const join = (a, b) => {
    const m = new Map(a.map((e) => [e.sel, e]));
    return b.map((e2) => ({ e1: m.get(e2.sel), e2 })).filter((p) => p.… [2633 more characters not shown]
scripts/seam-stamp.mjsShow
#!/usr/bin/env node
// seam-stamp.mjs — generate master-timeline seam code FROM ledger.json (motion-doctrine).
// The generation half of the Seam Gate: stamped seams pass seam-gate.mjs by construction.
//
//   node seam-stamp.mjs --ledger ledger.json            # print the seam block
//   node seam-stamp.mjs --ledger ledger.json --write index.html
//
// --write replaces the block between "// <seams:auto>" and "// </seams:auto>" markers
// (adds them before the final pad tween if absent). Tier-A morphs / match-cuts get
// visibility sets only — author the carrier handoff by hand.
//
// Per-seam ledger options (all optional):
//   exit.dur / entry.dur   — override durations (defaults below)
//   entry.travel           — xPercent/yPercent entry offset (default 10; "soft" look = 8)
//   blur                   — Z-seam blur px (default 18 full-frame; use 10 for text-scale)

import { readFileSync, writeFileSync } from "node:fs";

const argv = process.argv.slice(2);
const flag = (n, d) => {
  const i = argv.indexOf("--" + n);
  return i >= 0 ? argv[i + 1] : d;
};
const ledger = JSON.parse(readFileSync(flag("ledger", "ledger.json"), "utf8"));

const round = (n) => +n.toFixed(3);
const lines = [];
const emit = (s) => lines.push("      " + s);

// ---------- scene inventory (order of appearance) + base states ----------
const scenes = [];
const zEntries = new Map(); // selector -> {scale, blur} preset for Z arrivals
for (const seam of ledger.seams) {
  for (const sel of [seam.exit?.selector, seam.entry?.selector]) {
    if (sel && !scenes.includes(sel)) scenes.push(sel);
  }
  if (seam.entry?.axis === "z") {
    const blur = seam.blur ?? 18;
    zEntries.set(
      seam.entry.selector,
      seam.entry.dir === -1
        ? { scale: 1.25, blur } // pull: arrives oversized
        : { scale: 0.78, blur },
    ); // push: arrives small, growing
  }
}

emit(`// <seams:auto> — generated by seam-stamp.mjs from ledger.json; do not hand-edit.`);
emit(
  `// Regenerate: node <motion-doctrine>/scripts/seam-stamp.mjs --ledger ledger.json --write index.html`,
);
if (scenes.length) {
  const first = scenes[0];
  const rest = scenes.slice(1).filter((s) => !zEntries.has(s));
  emit(
    `gsap.set("${first}", { autoAlpha: 1, xPercent: 0, yPercent: 0, scale: 1, filter: "blur(0px)", transformOrigin: "50% 50%" });`,
  );
  if (rest.length)
    emit(
      `gsap.set([${rest.map((s) => `"${s}"`).join(",")}], { autoAlpha: 0, xPercent: 0, yPercent: 0, scale: 1, filter: "blur(0px)", transformOrigin: "50% 50%" });`,
    );
  for (const [sel, p] of zEntries)
    emit(
      `gsap.set("${sel}", { autoAlpha: 0, scale: ${p.scale}, filter: "blur(${p.blur}px)", xPercent: 0, yPercent: 0, transformOrigin: "50% 50%" });`,
    );
}
emit(``);

// ---------- per-seam stamping ----------
for (const seam of ledger.seams) {
  const cut = seam.cut,
    type = seam.type || "cut";
  emit(`// SEAM — ${seam.id} : ${seam.technique || type} (cut @${cut})`);

  if (type !== "cut") {
    if (seam.exit?.selector) emit(`tl.set("${seam.exit.selector}", { autoAlpha: 0 }, ${cut});`);
    if (seam.entry?.selector) emit(`tl.set("${seam.entry.selector}", { autoAlpha: 1 }, ${cut});`);
    emit(
      `// ${type}: carrier handoff is Tier-A — author it by hand and keep the carrier row in ledger.json`,
    );
    emit(``);
    continue;
  }

  const ex = seam.exit,
    en = seam.entry;
  if (ex.axis !== en.axis || ex.dir !== en.dir)
    throw new Error(
      `ledger row "${seam.id}" mismatched (${ex.axis}${ex.dir} vs ${en.axis}${en.dir}) — fix the PLAN, not the stamp`,
    );

  if (ex.axis === "z") {
    const blur = seam.blur ?? 18;
    const exDur = ex.dur ?? 0.21,
      enDur = en.dur ?? 0.5;
    const exScale = ex.dir === -1 ? 0.8 : 1.18;
    const enFrom = ex.dir === -1 ? 1.25 : 0.78;
    emit(
      `tl.to("${ex.selector}", { scale: ${exScale}, filter: "blur(${blur}px)", duration: ${exDur}, ease: "power3.in" }, ${round(cut - exDur)});`,
    );
    emit(
      `tl.to("${ex.selector}", { autoAlpha: 0, duration: ${exDur}, ease: "none" }, ${round(cut - exDur)});`,
    );
    emit(`tl.set("${ex.selector}", { autoAlpha: 0 }, ${cut});`);
    emit(
      `tl.fromTo("${en.selector}", { autoAlpha: 0.15, scale: ${enFrom}, filter: "blur(${blur}px)" }, { autoAlpha: 1, scale: 1.0, filter: "blur(0px)", duration: ${enDur}, ease: "expo.out", immediateRender: false }, ${cut});`,
    );
  } else {
    const prop = ex.axis === "x" ? "xPercent" : "yPercent";
    const exDur = ex.dur ?? 0.34,
      enDur = en.dur ?? 0.42;
    const travel = en.travel ?? 10;
    emit(
      `tl.to("${ex.selector}", { ${prop}: ${12 * ex.dir}, autoAlpha: 0, duration: ${exDur}, ease: "power3.in" }, ${round(cut - exDur)});`,
    );
    emit(`tl.set("${ex.selector}", { autoAlpha: 0 }, ${cut});`);
    emit(
      `tl.fromTo("${en.selector}", { ${prop}: ${-travel * en.dir}, autoAlpha: 0.35 }, { ${prop}: 0, autoAlpha: 1, duration: ${enDur}, ease: "power4.out", immediateRender: false }, ${cut});`,
    );
  }
  emit(``);
}
emit(`// </seams:auto>`);

const block = lines.join("\n");
const target = flag("write", null);
if (!target) {
  console.log(block);
} else {
  let html = readFileSync(target, "utf8");
  const re = /[ \t]*\/\/ <seams:auto>[\s\S]*?\/\/ <\/seams:auto>/;
  if (re.test(html)) {
    html = html.replace(re, block);
  } else {
    // insert after the master timeline registration line
    const anchor = /(window\.__timelines\["main"\]\s*=\s*tl;\s*\n)/;
    if (!anchor.test(html))
      throw new Error('no <seams:auto> markers and no window.__timelines["main"] anchor found');
    html = html.replace(anchor, `$1\n${block}\n`);
  }
  writeFileSync(target, html);
  console.log(`stamped ${ledger.seams.length} seams into ${target}`);
}