← Files JuicyLucy AdsARCHIVED FILE

skills/motion-doctrine/scripts/seam-stamp.mjs

5.62 KB · Oct 3, 2026 · 06:35 UTC

↓ Download file

#!/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}`);
}

SHA-256: 38040de56d700efd51512714405eda30cfe0bdf1ec4ebb221ec42a50c9f96c4d