#!/usr/bin/env node // transitions.mjs — inter-scene transition injector behind one subcommand dispatcher. // // inject — wrapper overlap + GSAP stamp on the index.html clip wrappers // verify — deterministic gate over the injector's output // // Each subcommand reads its args from process.argv AFTER the subcommand token // (i.e. process.argv.slice(3)): // node transitions.mjs inject --group-spec ./group_spec.json --hyperframes . // node transitions.mjs verify --group-spec ./group_spec.json --index ./index.html import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { transitionsByName } from "./lib/transition-registry.mjs"; import { readDims } from "./lib/dimensions.mjs"; // =========================================================================== // inject ← inject-transitions.mjs async function runInject(argv) { const flag = (name, def) => { const i = argv.indexOf(`--${name}`); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def; }; function die(msg) { console.error(`✗ inject-transitions.mjs: ${msg}`); process.exit(1); } const groupSpecPath = resolve(flag("group-spec", "./group_spec.json")); const hyperframesDir = resolve(flag("hyperframes", ".")); const indexPath = join(hyperframesDir, "index.html"); if (!existsSync(groupSpecPath)) die(`group_spec.json not found at ${groupSpecPath}`); if (!existsSync(indexPath)) die(`index.html not found at ${indexPath} — run assemble-index.mjs first`); let spec; try { spec = JSON.parse(readFileSync(groupSpecPath, "utf8")); } catch (e) { die(`group_spec.json parse: ${e.message}`); } // Slide transitions push a wrapper fully off-canvas, so the travel distance is // the canvas dimension along the slide axis (read from group_spec; landscape // default for pre-dims specs). const { width: CANVAS_W, height: CANVAS_H } = readDims(spec); const transitions = Array.isArray(spec.transitions) ? spec.transitions : []; const injectable = transitions.filter((t) => t && t.type); let html = readFileSync(indexPath, "utf8"); if (injectable.length === 0) { console.log(`✓ inject-transitions: 0 transitions to inject — index.html unchanged`); process.exit(0); } let txByName; try { txByName = transitionsByName(); } catch (e) { die(`transition registry: ${e.message}`); } // The authoritative SCENE id set — from group_spec.groups[].scene_ids. The // captions / voice / bgm / sfx clips also use id="el-…" + (for captions) // data-composition-src, but they are NOT scenes: they must keep their own tracks // (10/11/12/20+) and must NOT join the scene ping-pong. Driving off the known // scene list (not a data-composition-src regex) is what keeps captions out. const sceneIds = new Set(); for (const g of spec.groups || []) for (const sid of g.scene_ids || []) sceneIds.add(sid); if (sceneIds.size === 0) die(`group_spec.groups[].scene_ids is empty — cannot identify scene clips`); // ---------- parse SCENE clip wrappers out of index.html ---------- // assemble-index emits each scene clip as a stable multi-line block: // // We only keep clips whose sid is a known scene (sceneIds) — captions (el-captions) // and audio (el--voice) are excluded. Play order comes from data-start ascending. const clipRe = /<\/div>/g; const clips = new Map(); // sid -> { sid, start, duration, track, block } let cm; while ((cm = clipRe.exec(html)) !== null) { const sid = cm[1]; if (!sceneIds.has(sid)) continue; // skip captions / voice / bgm / sfx const block = cm[0]; const attrs = cm[2]; const num = (re) => { const m = attrs.match(re); return m ? Number(m[1]) : null; }; const start = num(/data-start="([\d.]+)"/); const duration = num(/data-duration="([\d.]+)"/); const track = num(/data-track-index="(\d+)"/); if (start == null || duration == null) { die( `scene clip #el-${sid} missing data-start/data-duration — assemble-index output malformed`, ); } clips.set(sid, { sid, start, duration, track: track ?? 0, block }); } if (clips.size === 0) die(`no scene clip wrappers found in index.html for scene_ids: ${[...sceneIds].join(", ")}`); // Completeness guard against assemble-index emit drift. Every "HF-SCENE-CLIP " // marker (emitted by assemble-index.mjs immediately before each scene
) whose // sid is a known scene MUST have been parsed by clipRe above. If the
emit shape changes and the regex stops matching, this fails loudly // here instead of silently dropping a scene from the ping-pong track reassignment // below (which would surface much later as overlapping_clips_same_track). Older // index.html without the marker → markerSids empty → no-op (backward compatible). // Keep the marker string in lockstep with assemble-index.mjs. const markerSids = [...html.matchAll(/