#!/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 "// " and "// " 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(`// — generated by seam-stamp.mjs from ledger.json; do not hand-edit.`); emit( `// Regenerate: node /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(`// `); const block = lines.join("\n"); const target = flag("write", null); if (!target) { console.log(block); } else { let html = readFileSync(target, "utf8"); const re = /[ \t]*\/\/ [\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 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}`); }