#!/usr/bin/env node // transitions.mjs — merges three former Phase-4c scripts behind one subcommand dispatcher. // // inject ← inject-transitions.mjs (Tier-B/Tier-A wrapper overlap + GSAP stamp) // verify ← verify-transitions.mjs (deterministic gate over injector output) // check-bridge ← check-bridge-continuity.mjs (Step-6 preflight gate for Tier-A bridges) // // Each subcommand reads its args from process.argv AFTER the subcommand token // (i.e. process.argv.slice(3)). Original per-subcommand usage: // node transitions.mjs inject --group-spec ./group_spec.json --hyperframes . // node transitions.mjs verify --group-spec ./group_spec.json --index ./index.html // node transitions.mjs check-bridge --hyperframes . --group-spec ./group_spec.json 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 rawInjectable = transitions.filter((t) => t.tier === "b" || t.tier === "a"); let html = readFileSync(indexPath, "utf8"); if (rawInjectable.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 VISUAL clip id set. Captions / voice / bgm / sfx also use // id="el-…" or data-composition-src, but they are not visual scene clips and // must not join the ping-pong transition track assignment. const visualIds = new Set(); if (Array.isArray(spec.visual_clips) && spec.visual_clips.length > 0) { for (const v of spec.visual_clips) if (v?.id) visualIds.add(v.id); } else { for (const g of spec.groups || []) for (const sid of g.scene_ids || []) visualIds.add(sid); } if (visualIds.size === 0) die(`group_spec has no visual clips — cannot identify wrappers`); const sceneToVisual = spec.scene_to_visual || {}; const normalizeClipId = (id) => (visualIds.has(id) ? id : sceneToVisual[id] || id); // ---------- parse VISUAL clip wrappers out of index.html ---------- // assemble-index emits each scene clip as a stable multi-line block: // // We only keep clips whose id is a known visual clip — captions (el-captions) // and audio (el--voice) are excluded. Play order comes from data-start ascending. const clipRe = /<\/div>/g; const clips = new Map(); // visual_id -> { sid, start, duration, track, block } let cm; while ((cm = clipRe.exec(html)) !== null) { const sid = cm[1]; if (!visualIds.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 visual clip wrappers found in index.html for ids: ${[...visualIds].join(", ")}`); // Completeness guard against assemble-index emit drift. Every "HF-VISUAL-CLIP " // marker (emitted by assemble-index.mjs immediately before each visual clip
) // whose id is a known visual clip 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 clip from the ping-pong track // reassignment below (which would surface much later as overlapping_clips_same_track). // Older index.html without the marker → markerIds empty → no-op (backward compatible). // Keep the marker string in lockstep with assemble-index.mjs. const markerIds = [...html.matchAll(/