mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 16:42:27 +00:00
feat(skills): add changelog-video skill for repo-native CC + Codex discovery (#2552)
Packages Jake Moran's changelog-video pipeline (v1, validated end-to-end
by Home on the Jun 23-29 range) as a repo-native skill set that Claude
Code (.claude/skills/) and Codex CLI (.agents/skills/) auto-discover the
moment the repo is opened. No install step; run the skill against a
changelog markdown for a given git range and it produces a lint-clean,
seam-gate-green 1080x1080 MP4 (~45-60s, Annie VO, mock-UI visualizations,
caption rail) end-to-end.
Six skills added byte-identical in both mirror dirs:
- changelog-video (pipeline entry point)
- motion-doctrine (carries seam-stamp.mjs + seam-gate.mjs)
- cut-the-curve, captions-overlay, seam-craft, oversized-cursor
Layout:
- .claude/skills/ - Claude Code project-local auto-discover
- .agents/skills/ - Codex CLI project-local auto-discover (verified via
Magi's clean-home Codex 0.144.3 repro; NOT .codex/skills/)
Fonts, animated background (12 MB), house BGM (5 MB), lexicon, and
align-captions ship inside the skill dirs. .gitattributes routes only
.claude/skills/**/*.{mp4,mp3} + .agents/skills/**/*.{mp4,mp3} through
LFS — narrowly scoped so unrelated Player, Studio, registry, and
marketplace media stay put. HeyGen CLI auth is the one credential the
skill needs; Node >= 22, ffmpeg, and headless Chrome are documented
alongside in both READMEs.
.gitignore: rewrites .claude/ and .agents/ blocks to keep agent-installed
skill hygiene while re-including the six repo-native skill dirs plus
README.md.
CI:
- Extends changes.skills filter to match .claude/skills/**,
.agents/skills/**, scripts/lint-skills.ts, and scripts/check-skill-mirror.mjs.
- New 'Skills: project-native lint + mirror' job runs the extended
lint-skills.ts (schema-driven; required { name, description } + optional
{ license, allowed-tools, metadata }, name pattern check, description
length check) plus a new check-skill-mirror.mjs byte-integrity script
(24 mirrored files must match; README.md deliberately per-CLI).
- Wired into 'bun run lint' locally.
Frontmatter validator:
- Rejects unsupported top-level keys (catches category:-style drift).
- Requires name + description.
- Validates name pattern (^[a-z][a-z0-9-]{0,63}$) and description shape
(non-empty, <=1024 chars).
- Missing frontmatter block itself is a first-class error.
Also strips unsupported top-level 'category:' frontmatter from Jake's
motion-doctrine and cut-the-curve SKILL.mds (both mirrors), rewrites the
TTS invocation from ~/.claude/skills/media-use/... to the tracked
skills/hyperframes-media/scripts/heygen-tts.mjs, swaps npx hyperframes@latest
for the repo-local CLI in the gate step, and fixes a lint issue in Jake's
seam-gate.mjs (ternary-for-side-effect -> if/else).
Validated end-to-end by Home on Jun 23-29 (MP4 posted in C0ACCNHLG3U
thread 1784181166.041319). Independently reviewed R1/R2/R3 by Magi.
Co-authored-by: Jake Moran <jake@heygen.com>
This commit is contained in:
co-authored by
Jake Moran
parent
172f2757f0
commit
e96ebd74de
@@ -0,0 +1,173 @@
|
||||
---
|
||||
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]"
|
||||
---
|
||||
|
||||
# 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) |
|
||||
@@ -0,0 +1,93 @@
|
||||
# seam-stamp.mjs + seam-gate.mjs — usage + ledger schema
|
||||
|
||||
Generate-and-verify pair for the Seam Gate. Zero npm deps (node ≥ 22 + a local Chrome;
|
||||
the gate finds `~/.cache/puppeteer` chrome-headless-shell or system Chrome automatically).
|
||||
|
||||
```bash
|
||||
# STAMP: write the master seam block (base sets + all wrapper tweens) from the ledger.
|
||||
# Replaces the // <seams:auto> … // </seams:auto> block (inserts after the
|
||||
# window.__timelines["main"] registration if markers are absent). Stamped seams pass
|
||||
# the gate by construction. match-cut/morph rows get visibility sets only — the
|
||||
# carrier handoff stays hand-authored.
|
||||
node <SKILL_DIR>/scripts/seam-stamp.mjs --ledger ledger.json --write index.html
|
||||
```
|
||||
|
||||
Per-seam stamp options in the ledger: `exit.dur` / `entry.dur` (durations),
|
||||
`entry.travel` (xPercent/yPercent offset, default 10 — use 8 for a soft entry),
|
||||
`blur` (Z seams, default 18px full-frame / set 10 for text-scale).
|
||||
|
||||
```bash
|
||||
# verify every seam in the ledger (exit 0 = gate passed)
|
||||
node <SKILL_DIR>/scripts/seam-gate.mjs verify --ledger ledger.json --project <project-dir>
|
||||
|
||||
# reuse a RUNNING preview server (restart it after comp edits — stale bundle!)
|
||||
node <SKILL_DIR>/scripts/seam-gate.mjs verify --ledger ledger.json --url http://localhost:5244
|
||||
|
||||
# discover movers around a cut time (use to author/fix ledger rows)
|
||||
node <SKILL_DIR>/scripts/seam-gate.mjs probe --t 44.8 --project <project-dir>
|
||||
```
|
||||
|
||||
`--project` spawns a fresh preview server with `HYPERFRAME_RUNTIME_URL` unset and kills
|
||||
it after — preferred. `--json` for machine output. `--fps 30` default.
|
||||
|
||||
## ledger.json
|
||||
|
||||
Lives at the project root. One row per seam; this is the vector ledger as data.
|
||||
|
||||
```json
|
||||
{
|
||||
"fps": 30,
|
||||
"seams": [
|
||||
{
|
||||
"id": "hook→claim",
|
||||
"cut": 4.2,
|
||||
"technique": "cut-the-curve LEFT",
|
||||
"exit": { "selector": "#el-hook", "axis": "x", "dir": -1 },
|
||||
"entry": { "selector": "#el-claim", "axis": "x", "dir": -1 }
|
||||
},
|
||||
|
||||
{
|
||||
"id": "claim→payoff",
|
||||
"cut": 10.2,
|
||||
"technique": "inverse zoom-through",
|
||||
"exit": { "selector": "#el-claim", "axis": "z", "dir": -1 },
|
||||
"entry": { "selector": "#el-payoff", "axis": "z", "dir": -1, "scanRoot": "#el-payoff" }
|
||||
},
|
||||
|
||||
{
|
||||
"id": "ui→player (match cut)",
|
||||
"cut": 60.6,
|
||||
"type": "match-cut",
|
||||
"carrier": { "out": "#resting-card", "in": "#product-video" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `cut` — seconds on the master clock, the frame the incoming side ignites.
|
||||
- `type` — `"cut"` (default; full vector checks), `"match-cut"` / `"morph"`
|
||||
(carrier-continuity + overlap only; motion may start AT the boundary).
|
||||
- `axis` — `"x"`, `"y"`, or `"z"` (z = scale). `dir` — sign of motion:
|
||||
x −1 = leftward, y −1 = upward, z +1 = push (growing), z −1 = pull (shrinking).
|
||||
- `selector` — the element that CARRIES the seam motion. Use the wrapper when the
|
||||
master timeline moves the wrapper; use the in-comp hero (id or `[data-hf-id=…]`)
|
||||
when the seam motion is authored inside the sub-comp. `probe` tells you which.
|
||||
- `entry.scanRoot` (z seams) — subtree scanned for sign-fighting internal entrances;
|
||||
defaults to the entry selector.
|
||||
- `carrier` — optional on `"cut"` rows; required on match-cut/morph. `out`/`in` rects
|
||||
must match at cut±1 frame (12px center / 5% size tolerance, ancestors included).
|
||||
|
||||
## What each check enforces (Seam Gate rule ↔ report row)
|
||||
|
||||
| Report row | Rule |
|
||||
| ------------------------------------ | -------------------------------------------------------------- |
|
||||
| `ledger` | exit/entry vectors match in the PLAN (axis + dir) |
|
||||
| `exit-moving` / `entry-moving` | rule 1/3 — no settled exits, no from-rest entries |
|
||||
| `exit-direction` / `entry-direction` | rule 3 — measured sign matches the ledger |
|
||||
| `speed-match` (WARN) | law §3 — entry velocity ≈ exit velocity |
|
||||
| `zero-overlap` | rule 6 — one side visible per frame, never both |
|
||||
| `z-sign-scan` | rule 7 — incoming scene's own entrances don't fight the Z sign |
|
||||
| `carrier-*` | rules 3/4 — carrier rect continuity, ancestor scale included |
|
||||
|
||||
Velocities are measured on `getBoundingClientRect` (center for x/y, width-ratio for z),
|
||||
so ancestor wrapper transforms are automatically included.
|
||||
@@ -0,0 +1,595 @@
|
||||
#!/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
|
||||
const cmd = flag("server-cmd", `npx --yes hyperframes preview --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.e1);
|
||||
};
|
||||
const movers = (a, b, span) =>
|
||||
join(a, b)
|
||||
.map(({ e1, e2 }) => ({
|
||||
sel: e2.sel,
|
||||
vx: (e2.cx - e1.cx) / span,
|
||||
vy: (e2.cy - e1.cy) / span,
|
||||
vs: (e2.es - e1.es) / span,
|
||||
op1: e1.op,
|
||||
op2: e2.op,
|
||||
w: e2.w,
|
||||
h: e2.h,
|
||||
}))
|
||||
.filter(
|
||||
(m) =>
|
||||
(m.op1 > VIS || m.op2 > VIS) &&
|
||||
(Math.abs(m.vx) > EPS_XY ||
|
||||
Math.abs(m.vy) > EPS_XY ||
|
||||
Math.abs(m.vs) > EPS_Z ||
|
||||
Math.abs(m.op2 - m.op1) > 0.1),
|
||||
)
|
||||
.sort(
|
||||
(x, y) =>
|
||||
Math.abs(y.vx) +
|
||||
Math.abs(y.vy) +
|
||||
Math.abs(y.vs) * 800 -
|
||||
(Math.abs(x.vx) + Math.abs(x.vy) + Math.abs(x.vs) * 800),
|
||||
)
|
||||
.slice(0, 14);
|
||||
const fmt = (m) =>
|
||||
` ${m.sel.padEnd(44)} vx ${m.vx.toFixed(0).padStart(6)} vy ${m.vy.toFixed(0).padStart(6)} vscale ${m.vs.toFixed(3).padStart(7)} op ${m.op1.toFixed(2)}→${m.op2.toFixed(2)} (${m.w.toFixed(0)}×${m.h.toFixed(0)})`;
|
||||
console.log(`\nPROBE @ ${t}s (window ±${win}s, 1f = ${dt.toFixed(3)}s)`);
|
||||
console.log(`\n— OUTGOING side (${(t - win).toFixed(2)} → ${(t - dt).toFixed(2)}) — movers:`);
|
||||
movers(scans[t - win], scans[t - dt], win - dt).forEach((m) => console.log(fmt(m)));
|
||||
console.log(`\n— INCOMING side (${(t + dt).toFixed(2)} → ${(t + win).toFixed(2)}) — movers:`);
|
||||
movers(scans[t + dt], scans[t + win], win - dt).forEach((m) => console.log(fmt(m)));
|
||||
console.log(
|
||||
`\nUse these selectors + signs to write the ledger row (x-: left, y-: up, scale+: push, scale-: pull).`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- main ----------
|
||||
try {
|
||||
if (mode === "probe") {
|
||||
await probe();
|
||||
} else {
|
||||
const results = await verify();
|
||||
if (has("json")) {
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
} else {
|
||||
let fails = 0,
|
||||
warns = 0;
|
||||
for (const r of results) {
|
||||
const bad = r.rows.filter((x) => x.status === "FAIL").length;
|
||||
fails += bad;
|
||||
warns += r.rows.filter((x) => x.status === "WARN").length;
|
||||
console.log(
|
||||
`\n■ ${r.id} (cut @${r.cut}s, ${r.type}) ${bad ? "✗ " + bad + " FAIL" : "✓"}`,
|
||||
);
|
||||
for (const row of r.rows)
|
||||
console.log(` ${row.status.padEnd(4)} ${row.check.padEnd(16)} ${row.detail}`);
|
||||
}
|
||||
console.log(
|
||||
`\n${fails ? "SEAM GATE: FAILED" : "SEAM GATE: PASSED"} — ${fails} fail, ${warns} warn across ${results.length} seams`,
|
||||
);
|
||||
}
|
||||
process.exit(results.some((r) => r.rows.some((x) => x.status === "FAIL")) ? 1 : 0);
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
console.error("seam-gate error:", e.message);
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/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}`);
|
||||
}
|
||||
Reference in New Issue
Block a user