chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,573 @@
|
||||
#!/usr/bin/env node
|
||||
// assemble-index.mjs — deterministic top-level index.html assembly for a
|
||||
// HyperFrames project. No subagent, no judgment: turns STORYBOARD.md + the
|
||||
// built frame files (+ optional audio_meta.json) into the standalone index.html
|
||||
// the renderer consumes, and stages the frame-named capture assets into assets/.
|
||||
//
|
||||
// index.html is a *standalone* composition (root <div id="root"> directly in
|
||||
// <body>, no <template> wrapper — template is for sub-comps). Structure is
|
||||
// modeled on the canonical fixture packages/studio/fixtures/storyboard-sample/
|
||||
// index.html and the authoritative head/audio template in
|
||||
// packages/core/docs/quickstart-template.html. Frame mount order = STORYBOARD
|
||||
// document order. Transitions are NOT written here — the transitions injector
|
||||
// mutates this file afterward (data-start/duration/track-index + GSAP).
|
||||
//
|
||||
// Track lanes (same-track time-overlap is illegal — lint timeline_track_too_dense):
|
||||
// 1 frame sub-comp clips (sequential; the injector 0/1-ping-pongs for overlaps)
|
||||
// 2 captions sub-comp clip (full-duration overlay, on top of frames)
|
||||
// 10 per-frame voice <audio>
|
||||
// 11 BGM <audio>
|
||||
// 20+i SFX <audio> (one lane each)
|
||||
//
|
||||
// audio_meta.json contract (produced by audio.mjs; OPTIONAL — absent ⇒ silent
|
||||
// video, frames only). Durations come from STORYBOARD (audio sync-durations
|
||||
// writes them), NOT from here; this file carries only media PATHS, keyed by
|
||||
// frame number:
|
||||
// { "bgm": { "path": "assets/bgm/x.mp3", "volume": 0.12 } | null,
|
||||
// "voices":[ { "frame": 3, "path": "assets/voice/03.wav" } ],
|
||||
// "sfx": [ { "frame": 3, "file": "assets/sfx/x.mp3", "offset_s": 0,
|
||||
// "duration_s": 1.0, "volume": 0.35 } ] }
|
||||
//
|
||||
// Reads: --storyboard STORYBOARD.md, --hyperframes <project root>,
|
||||
// [--audio-meta audio_meta.json]. On disk: each built frame's src html,
|
||||
// capture/{assets,assets/videos,screenshots}/<basename> for staging, compositions/captions.html.
|
||||
// Writes: <project>/index.html + stages assets/<basename> + (guard ① below)
|
||||
// repairs a frame file in place when its root is missing data-width/height.
|
||||
//
|
||||
// Pre-assembly frame guards (run in the same pass that reads each frame, so common
|
||||
// `lint` failures surface HERE instead of after assembly + a wasted render):
|
||||
// ① AUTO-REPAIR — a sub-comp root missing data-width/data-height: inject the canvas
|
||||
// dims (the renderer needs them on the cloned root; else lint root_missing_dimensions).
|
||||
// ② HARD FAIL — <video>/<audio> inside a sub-comp: the runtime only drives media that
|
||||
// is a DIRECT child of the host root, so sub-comp media renders blank/black.
|
||||
// ③ HARD FAIL — a timed element (data-start+duration+track-index) that is not the root
|
||||
// and lacks class="clip" (shows the whole frame), or two same-track clips that overlap.
|
||||
//
|
||||
// Exit 0 = index.html written + summary. Exit 1 = fatal contract break (no
|
||||
// frames, a built/animated frame missing its src/file, a frame with no
|
||||
// duration, an inner data-composition-id mismatch, or a guard ②/③ violation).
|
||||
// No backstop: fix upstream.
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { parseStoryboard } from "./lib/storyboard.mjs";
|
||||
import { parseFormat } from "./lib/dimensions.mjs";
|
||||
import { stageAssets } from "./lib/assets.mjs";
|
||||
import { parseColors, semanticColors } from "./lib/tokens.mjs";
|
||||
import { bgmDefaultVolume } from "../../media-use/audio/scripts/lib/bgm.mjs";
|
||||
|
||||
// ---------- argv ----------
|
||||
const argv = process.argv.slice(2);
|
||||
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(`✗ assemble-index.mjs: ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Ensure the BGM track is at least `total` seconds long. HeyGen (and most music
|
||||
// libraries) return a short loopable clip (~15–30s); mounting it at data-duration=total
|
||||
// would leave the video's TAIL SILENT. If the file is short, loop-extend it to `total`
|
||||
// (with a 0.4s fade-in + 1.5s fade-out) into a sibling *.loop.mp3 and return that path.
|
||||
// Needs ffprobe+ffmpeg (present in the render env); degrades to the original + a warning
|
||||
// when they're absent, so assembly never hard-fails on audio tooling.
|
||||
function ensureBgmCovers(relPath, hyperframesDir, total) {
|
||||
const abs = join(hyperframesDir, relPath);
|
||||
const probe = spawnSync(
|
||||
"ffprobe",
|
||||
["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", abs],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
if (probe.status !== 0) return { looped: false, short: false, reason: "ffprobe unavailable" };
|
||||
const dur = parseFloat(String(probe.stdout || "").trim());
|
||||
if (!Number.isFinite(dur) || dur <= 0)
|
||||
return { looped: false, short: false, reason: "unreadable duration" };
|
||||
if (dur >= total - 0.1) return { looped: false, short: false, dur }; // already covers
|
||||
const relOut = relPath.replace(/\.([^./]+)$/, ".loop.$1");
|
||||
const absOut = join(hyperframesDir, relOut);
|
||||
const fadeOut = Math.max(0, total - 1.5);
|
||||
const ff = spawnSync(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-y",
|
||||
"-stream_loop",
|
||||
"-1",
|
||||
"-i",
|
||||
abs,
|
||||
"-t",
|
||||
String(total),
|
||||
"-af",
|
||||
`afade=t=in:st=0:d=0.4,afade=t=out:st=${fadeOut}:d=1.5`,
|
||||
"-c:a",
|
||||
"libmp3lame",
|
||||
"-q:a",
|
||||
"2",
|
||||
absOut,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
if (ff.status !== 0 || !existsSync(absOut))
|
||||
return { looped: false, short: true, dur, reason: "ffmpeg unavailable" };
|
||||
return { looped: true, rel: relOut, from: dur };
|
||||
}
|
||||
|
||||
const hyperframesDir = resolve(flag("hyperframes", "."));
|
||||
const storyboardPath = resolve(flag("storyboard", join(hyperframesDir, "STORYBOARD.md")));
|
||||
const audioMetaPath = resolve(flag("audio-meta", join(hyperframesDir, "audio_meta.json")));
|
||||
const outPath = resolve(flag("out", join(hyperframesDir, "index.html")));
|
||||
|
||||
const r3 = (x) => Math.round(x * 1000) / 1000;
|
||||
const anomalies = [];
|
||||
const frameErrors = []; // fatal per-frame composition violations (guards ②/③) — reported together
|
||||
const repairs = []; // auto-repairs applied to frame files in place (guard ①)
|
||||
|
||||
// ---------- parse storyboard ----------
|
||||
if (!existsSync(storyboardPath)) die(`STORYBOARD.md not found at ${storyboardPath}`);
|
||||
const manifest = parseStoryboard(readFileSync(storyboardPath, "utf8"));
|
||||
const { width: WIDTH, height: HEIGHT } = parseFormat(manifest.globals.format);
|
||||
|
||||
// ---------- per-frame composition guards (see header ①②③) ----------
|
||||
// String-level checks on each frame's HTML — no DOM parse, deterministic, run in
|
||||
// the same pass that already reads the file. OPEN_TAG matches one opening tag while
|
||||
// tolerating quoted attribute values that contain ">" (e.g. inline styles).
|
||||
const OPEN_TAG = "<([a-zA-Z][a-zA-Z0-9-]*)((?:[^>\"']|\"[^\"]*\"|'[^']*')*)>";
|
||||
const attrPresent = (attrs, name) => new RegExp(`(?:^|\\s)${name}(?:[\\s=]|$)`).test(attrs);
|
||||
const attrValue = (attrs, name) => {
|
||||
const m = attrs.match(new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`));
|
||||
return m ? (m[1] ?? m[2]) : null;
|
||||
};
|
||||
// The root (or a nested-comp mount) legitimately carries timing without class="clip".
|
||||
const isRootish = (attrs) =>
|
||||
/(?:^|\s)id\s*=\s*["']root["']/.test(attrs) ||
|
||||
attrPresent(attrs, "data-composition-id") ||
|
||||
attrPresent(attrs, "data-composition-src");
|
||||
|
||||
// Locate the composition root opening tag: prefer id="root", else the first element
|
||||
// carrying data-composition-id. Returns { start, end, full, attrs } or null.
|
||||
function findRootTag(html) {
|
||||
const re = new RegExp(OPEN_TAG, "g");
|
||||
let m;
|
||||
let firstCompId = null;
|
||||
while ((m = re.exec(html))) {
|
||||
const attrs = m[2];
|
||||
if (/(?:^|\s)id\s*=\s*["']root["']/.test(attrs))
|
||||
return { start: m.index, end: m.index + m[0].length, full: m[0], attrs };
|
||||
if (attrPresent(attrs, "data-composition-id") && !firstCompId)
|
||||
firstCompId = { start: m.index, end: m.index + m[0].length, full: m[0], attrs };
|
||||
}
|
||||
return firstCompId;
|
||||
}
|
||||
|
||||
// Returns { errors: string[], repairedHtml: string|null, repairNote: string|null }.
|
||||
function guardFrame(html, label) {
|
||||
const errors = [];
|
||||
// Scan a copy with comments + <script>/<style> bodies blanked, so a tag-like string
|
||||
// in a comment (e.g. "<!-- match the host <video> coords -->") or in GSAP code can't
|
||||
// trip ②/③. ① still splices into the ORIGINAL html, so its offsets stay correct.
|
||||
const scan = html
|
||||
.replace(/<!--[\s\S]*?-->/g, " ")
|
||||
.replace(/<script\b[\s\S]*?<\/script[^>]*>/gi, " ")
|
||||
.replace(/<style\b[\s\S]*?<\/style[^>]*>/gi, " ");
|
||||
|
||||
// ② media inside a sub-comp — never driven by the runtime (renders blank/black).
|
||||
const media = scan.match(/<(video|audio)(?=[\s/>])/i);
|
||||
if (media) {
|
||||
errors.push(
|
||||
`${label}: has a <${media[1].toLowerCase()}> inside the sub-composition. The runtime only drives media that is a DIRECT child of the host root (index.html) — sub-comp media renders blank/black. Move the clip to index.html as a root-level <video>/<audio> and drive any per-scene motion on the main timeline (composition-patterns.md archetype B).`,
|
||||
);
|
||||
}
|
||||
|
||||
// ③ timed-element checks: missing class="clip", and same-track window overlap.
|
||||
const re = new RegExp(OPEN_TAG, "g");
|
||||
const clips = [];
|
||||
let m;
|
||||
while ((m = re.exec(scan))) {
|
||||
const attrs = m[2];
|
||||
if (
|
||||
!attrPresent(attrs, "data-start") ||
|
||||
!attrPresent(attrs, "data-duration") ||
|
||||
!attrPresent(attrs, "data-track-index")
|
||||
)
|
||||
continue;
|
||||
if (isRootish(attrs)) continue;
|
||||
if (!/(?:^|\s)class\s*=\s*["'][^"']*\bclip\b[^"']*["']/.test(attrs)) {
|
||||
errors.push(
|
||||
`${label}: a timed <${m[1]}> (data-start/duration/track-index) has no class="clip" — it renders for the whole frame instead of only its window. Add class="clip", or remove the timing attrs if it is a GSAP-animated element meant to be present throughout.`,
|
||||
);
|
||||
}
|
||||
const track = attrValue(attrs, "data-track-index");
|
||||
const start = parseFloat(attrValue(attrs, "data-start"));
|
||||
const dur = parseFloat(attrValue(attrs, "data-duration"));
|
||||
if (track != null && Number.isFinite(start) && Number.isFinite(dur))
|
||||
clips.push({ track, start, end: start + dur });
|
||||
}
|
||||
const EPS = 1e-3; // adjacent clips that merely touch are legal
|
||||
const byTrack = new Map();
|
||||
for (const c of clips) {
|
||||
const arr = byTrack.get(c.track);
|
||||
if (arr) arr.push(c);
|
||||
else byTrack.set(c.track, [c]);
|
||||
}
|
||||
for (const [track, list] of byTrack) {
|
||||
list.sort((a, b) => a.start - b.start);
|
||||
for (let i = 1; i < list.length; i++) {
|
||||
if (list[i].start < list[i - 1].end - EPS) {
|
||||
errors.push(
|
||||
`${label}: clips on track ${track} overlap (one ends at ${r3(list[i - 1].end)}s, the next starts at ${r3(list[i].start)}s) — same-track time-overlap causes a render conflict. Put them on distinct data-track-index lanes or fix their windows.`,
|
||||
);
|
||||
break; // one report per track is enough
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ① auto-repair: ensure the root carries data-width / data-height.
|
||||
let repairedHtml = null;
|
||||
let repairNote = null;
|
||||
const root = findRootTag(html);
|
||||
if (root) {
|
||||
const needW = !attrPresent(root.attrs, "data-width");
|
||||
const needH = !attrPresent(root.attrs, "data-height");
|
||||
if (needW || needH) {
|
||||
const inject =
|
||||
(needW ? ` data-width="${WIDTH}"` : "") + (needH ? ` data-height="${HEIGHT}"` : "");
|
||||
const newTag = root.full.replace(/(\/?>)$/, `${inject}$1`);
|
||||
repairedHtml = html.slice(0, root.start) + newTag + html.slice(root.end);
|
||||
repairNote = `${label}: injected${needW ? " data-width" : ""}${needH ? " data-height" : ""} (${WIDTH}×${HEIGHT}) on the root — was missing (would lint root_missing_dimensions)`;
|
||||
}
|
||||
}
|
||||
|
||||
return { errors, repairedHtml, repairNote };
|
||||
}
|
||||
|
||||
// ---------- resolve mountable frames in document order ----------
|
||||
// A frame mounts when its src html exists on disk. A built/animated frame
|
||||
// missing its src/file is a contract break (die). An outline frame with no
|
||||
// file is skipped (still a placeholder) with an anomaly note.
|
||||
const mounted = [];
|
||||
for (const f of manifest.frames) {
|
||||
const label = `frame ${f.number ?? f.index}${f.title ? ` (${f.title})` : ""}`;
|
||||
const built = f.status === "built" || f.status === "animated";
|
||||
if (!f.src) {
|
||||
if (built) die(`${label} is ${f.status} but has no \`src\` — the orchestrator must write it`);
|
||||
anomalies.push(`${label}: status ${f.status}, no src — skipped`);
|
||||
continue;
|
||||
}
|
||||
const compAbs = join(hyperframesDir, f.src);
|
||||
// Read directly and handle ENOENT here rather than an existsSync precheck — the
|
||||
// check→read/write pair is a TOCTOU race CodeQL flags (js/file-system-race).
|
||||
let html;
|
||||
try {
|
||||
html = readFileSync(compAbs, "utf8");
|
||||
} catch {
|
||||
if (built)
|
||||
die(`${label} is ${f.status} but its src ${f.src} is not on disk — re-dispatch the worker`);
|
||||
anomalies.push(`${label}: src ${f.src} not on disk (status ${f.status}) — skipped`);
|
||||
continue;
|
||||
}
|
||||
if (!Number.isFinite(f.durationSeconds) || f.durationSeconds <= 0) {
|
||||
die(
|
||||
`${label}: no positive duration (got ${JSON.stringify(f.duration)}) — run audio sync-durations`,
|
||||
);
|
||||
}
|
||||
// Host data-composition-id MUST equal the inner file's, or the runtime never
|
||||
// finds the timeline. frame_id = src basename (frame-worker contract); verify
|
||||
// the inner html actually declares it.
|
||||
const compId = basename(f.src).replace(/\.html?$/i, "");
|
||||
// Guard against blank/partial scene files: a worker that errors or is
|
||||
// interrupted mid-write leaves an empty (or markup-less) file that exists but
|
||||
// fails at render with "Composition HTML is empty or could not be parsed".
|
||||
// Catch it here — before emitting data-composition-src — and re-dispatch.
|
||||
if (!html.trim() || !/<\w/.test(html)) {
|
||||
die(
|
||||
`${label}: ${f.src} is empty or has no HTML — the worker wrote a blank/partial file. Re-dispatch that worker before assembling.`,
|
||||
);
|
||||
}
|
||||
// pre-assembly guards: ① repair missing root dims in place, ②/③ collect fatal violations.
|
||||
const guard = guardFrame(html, label);
|
||||
if (guard.repairedHtml) {
|
||||
writeFileSync(compAbs, guard.repairedHtml);
|
||||
html = guard.repairedHtml;
|
||||
repairs.push(guard.repairNote);
|
||||
}
|
||||
for (const e of guard.errors) frameErrors.push(e);
|
||||
if (
|
||||
!html.includes(`data-composition-id="${compId}"`) &&
|
||||
!html.includes(`data-composition-id='${compId}'`)
|
||||
) {
|
||||
die(`${label}: ${f.src} has no data-composition-id="${compId}" (host/inner id must match)`);
|
||||
}
|
||||
mounted.push({ frame: f, compId, durationSeconds: r3(f.durationSeconds) });
|
||||
}
|
||||
if (frameErrors.length) {
|
||||
die(
|
||||
`${frameErrors.length} frame composition violation(s) — fix the worker output and re-assemble:\n` +
|
||||
frameErrors.map((e) => ` • ${e}`).join("\n"),
|
||||
);
|
||||
}
|
||||
if (mounted.length === 0) die("no mountable frames (none built with an on-disk src)");
|
||||
|
||||
// cumulative starts — emitted data-start[i] + data-duration[i] == start[i+1] by
|
||||
// construction (renderer computes end the same way), so adjacent clips touch
|
||||
// exactly with no float-overlap.
|
||||
let acc = 0;
|
||||
for (const m of mounted) {
|
||||
m.start = acc;
|
||||
acc += m.durationSeconds;
|
||||
}
|
||||
const TOTAL = r3(acc);
|
||||
const startOfFrameNumber = new Map();
|
||||
for (const m of mounted) if (m.frame.number != null) startOfFrameNumber.set(m.frame.number, m);
|
||||
|
||||
// ---------- audio_meta (optional) ----------
|
||||
let audio = { bgm: null, voices: [], sfx: [] };
|
||||
if (existsSync(audioMetaPath)) {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(audioMetaPath, "utf8"));
|
||||
audio = { bgm: parsed.bgm ?? null, voices: parsed.voices ?? [], sfx: parsed.sfx ?? [] };
|
||||
} catch (e) {
|
||||
die(`audio_meta.json parse: ${e.message}`);
|
||||
}
|
||||
}
|
||||
const voiceByFrame = new Map();
|
||||
for (const v of audio.voices) if (v.frame != null) voiceByFrame.set(v.frame, v);
|
||||
|
||||
// ---------- build <body> in track order ----------
|
||||
const body = [];
|
||||
let voiceCount = 0;
|
||||
|
||||
for (const m of mounted) {
|
||||
// (track 1) frame sub-comp clip — no class="clip" semantics needed; .scene CSS sizes it.
|
||||
body.push(
|
||||
` <div`,
|
||||
` id="el-${m.compId}"`,
|
||||
` class="scene"`,
|
||||
` data-composition-id="${m.compId}"`,
|
||||
` data-composition-src="${m.frame.src}"`,
|
||||
` data-start="${m.start}"`,
|
||||
` data-duration="${m.durationSeconds}"`,
|
||||
` data-track-index="1"`,
|
||||
` ></div>`,
|
||||
);
|
||||
// (track 10) voice — only when the file is actually on disk.
|
||||
const v = m.frame.number != null ? voiceByFrame.get(m.frame.number) : undefined;
|
||||
if (v?.path) {
|
||||
if (existsSync(join(hyperframesDir, v.path))) {
|
||||
body.push(
|
||||
` <audio`,
|
||||
` id="el-${m.compId}-voice"`,
|
||||
` src="${v.path}"`,
|
||||
` data-start="${m.start}"`,
|
||||
` data-duration="${m.durationSeconds}"`,
|
||||
` data-track-index="10"`,
|
||||
` data-volume="1"`,
|
||||
` ></audio>`,
|
||||
);
|
||||
voiceCount++;
|
||||
} else {
|
||||
anomalies.push(`${m.compId}: voice ${v.path} not on disk — skipped`);
|
||||
}
|
||||
}
|
||||
body.push("");
|
||||
}
|
||||
|
||||
// (track 11) BGM — duck under narration when any voice is present. Loop-extend a short
|
||||
// track to the full video length so the tail isn't silent (libraries return ~15–30s clips).
|
||||
let bgmEmitted = false;
|
||||
let bgmNote = "";
|
||||
if (audio.bgm?.path) {
|
||||
if (existsSync(join(hyperframesDir, audio.bgm.path))) {
|
||||
let bgmSrc = audio.bgm.path;
|
||||
const cov = ensureBgmCovers(audio.bgm.path, hyperframesDir, TOTAL);
|
||||
if (cov.looped) {
|
||||
bgmSrc = cov.rel;
|
||||
bgmNote = ` (looped ${cov.from.toFixed(1)}s→${TOTAL}s)`;
|
||||
} else if (cov.short) {
|
||||
anomalies.push(
|
||||
`bgm is ${cov.dur?.toFixed?.(1) ?? "?"}s (< ${TOTAL}s) and could not be extended (${cov.reason}) — the tail will be silent; install ffmpeg`,
|
||||
);
|
||||
}
|
||||
// An explicit volume from audio_meta always wins; otherwise the shared
|
||||
// media-use default (bed ~ -18 dB under narration, forward for a silent film).
|
||||
const vol = audio.bgm.volume != null ? audio.bgm.volume : bgmDefaultVolume(voiceCount > 0);
|
||||
body.push(
|
||||
` <!-- BGM -->`,
|
||||
` <audio`,
|
||||
` id="el-bgm"`,
|
||||
` src="${bgmSrc}"`,
|
||||
` data-start="0"`,
|
||||
` data-duration="${TOTAL}"`,
|
||||
` data-track-index="11"`,
|
||||
` data-volume="${vol}"`,
|
||||
` ></audio>`,
|
||||
"",
|
||||
);
|
||||
bgmEmitted = true;
|
||||
} else {
|
||||
anomalies.push(`bgm ${audio.bgm.path} not on disk — skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
// (track 2) captions — captions.mjs writes this or legally skips; key off existence.
|
||||
let captionsEmitted = false;
|
||||
if (existsSync(join(hyperframesDir, "compositions/captions.html"))) {
|
||||
body.push(
|
||||
` <!-- captions -->`,
|
||||
` <div`,
|
||||
` id="el-captions"`,
|
||||
` class="scene"`,
|
||||
` data-composition-id="captions"`,
|
||||
` data-composition-src="compositions/captions.html"`,
|
||||
` data-start="0"`,
|
||||
` data-duration="${TOTAL}"`,
|
||||
` data-track-index="2"`,
|
||||
` ></div>`,
|
||||
"",
|
||||
);
|
||||
captionsEmitted = true;
|
||||
}
|
||||
|
||||
// (track 20+i) SFX — placed at its frame's start + offset.
|
||||
let sfxEmitted = 0;
|
||||
audio.sfx.forEach((cue, i) => {
|
||||
const host = cue.frame != null ? startOfFrameNumber.get(cue.frame) : undefined;
|
||||
if (!host) {
|
||||
anomalies.push(`sfx ${cue.file}: frame ${cue.frame} not mounted — skipped`);
|
||||
return;
|
||||
}
|
||||
const rel = cue.file;
|
||||
if (!existsSync(join(hyperframesDir, rel))) {
|
||||
anomalies.push(`sfx ${rel} not on disk — skipped`);
|
||||
return;
|
||||
}
|
||||
const t = r3(host.start + (cue.offset_s ?? 0));
|
||||
const dur = r3(cue.duration_s ?? 1);
|
||||
const vol = cue.volume != null ? cue.volume : 0.35;
|
||||
if (sfxEmitted === 0) body.push(` <!-- SFX -->`);
|
||||
body.push(
|
||||
` <audio`,
|
||||
` id="el-sfx-${i}"`,
|
||||
` src="${rel}"`,
|
||||
` data-start="${t}"`,
|
||||
` data-duration="${dur}"`,
|
||||
` data-track-index="${20 + i}"`,
|
||||
` data-volume="${vol}"`,
|
||||
` ></audio>`,
|
||||
);
|
||||
sfxEmitted++;
|
||||
});
|
||||
|
||||
// ---------- stage frame-named assets: capture/ → assets/ (idempotent backstop) ----------
|
||||
// Frame workers + the live preview reference assets/<basename>; stage-assets.mjs
|
||||
// already ran this at Step 4 close. Re-run as a backstop so a late-named asset
|
||||
// still lands. Shared logic: lib/assets.mjs (first-wins, safe to call twice).
|
||||
const {
|
||||
staged,
|
||||
wanted,
|
||||
anomalies: assetAnomalies,
|
||||
} = stageAssets({
|
||||
hyperframesDir,
|
||||
frames: manifest.frames,
|
||||
});
|
||||
for (const a of assetAnomalies) anomalies.push(a);
|
||||
|
||||
// ---------- <head> ----------
|
||||
// ---------- ground color ----------
|
||||
// Per-frame roots carry data-start/data-duration and get clip-gated against the
|
||||
// global timeline in render (only the first frame's [0,dur] window overlaps global
|
||||
// 0), so a frame's own full-bleed background can't be relied on as the video ground —
|
||||
// every frame after the first would render on the bare body color (black). Paint the
|
||||
// ground on the always-present root composition instead, using the project's frame.md
|
||||
// canvas color (the same ground role the caption skin maps to --cap-canvas). Falls
|
||||
// back to the body letterbox color when frame.md is absent or has no resolvable ground.
|
||||
const framePath = join(hyperframesDir, "frame.md");
|
||||
let groundColor = null;
|
||||
if (existsSync(framePath)) {
|
||||
try {
|
||||
const roles = semanticColors(parseColors(readFileSync(framePath, "utf8")));
|
||||
if (roles && roles.canvas) groundColor = roles.canvas;
|
||||
} catch {
|
||||
/* leave groundColor null — #root stays transparent over the body letterbox */
|
||||
}
|
||||
}
|
||||
|
||||
const headStyle = [
|
||||
" * {",
|
||||
" margin: 0;",
|
||||
" padding: 0;",
|
||||
" box-sizing: border-box;",
|
||||
" }",
|
||||
" html,",
|
||||
" body {",
|
||||
` width: ${WIDTH}px;`,
|
||||
` height: ${HEIGHT}px;`,
|
||||
" overflow: hidden;",
|
||||
" background: #000;",
|
||||
" }",
|
||||
" #root {",
|
||||
" position: relative;",
|
||||
` width: ${WIDTH}px;`,
|
||||
` height: ${HEIGHT}px;`,
|
||||
" overflow: hidden;",
|
||||
...(groundColor ? [` background: ${groundColor};`] : []),
|
||||
" }",
|
||||
" .scene {",
|
||||
" position: absolute;",
|
||||
" inset: 0;",
|
||||
" width: 100%;",
|
||||
" height: 100%;",
|
||||
" }",
|
||||
].join("\n");
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=${WIDTH}, height=${HEIGHT}" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js" integrity="sha384-sG0Hv1tP1lZCk9KQmrIbY/XNwi+OY84GQqhMscbnsoBFqAz8KNCil1kvfL3Hbbk2" crossorigin="anonymous"></script>
|
||||
<style>
|
||||
${headStyle}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-start="0"
|
||||
data-duration="${TOTAL}"
|
||||
data-width="${WIDTH}"
|
||||
data-height="${HEIGHT}"
|
||||
>
|
||||
${body.join("\n")}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
writeFileSync(outPath, html);
|
||||
|
||||
// ---------- summary ----------
|
||||
console.log(`✓ wrote ${outPath}`);
|
||||
console.log(` canvas: ${WIDTH}×${HEIGHT}`);
|
||||
console.log(` frames (track 1): ${mounted.length}`);
|
||||
console.log(` voice (track 10): ${voiceCount}`);
|
||||
console.log(` bgm (track 11): ${bgmEmitted ? "yes" + bgmNote : "no"}`);
|
||||
console.log(` captions (track 2): ${captionsEmitted ? "yes" : "no"}`);
|
||||
console.log(` sfx (track 20+): ${sfxEmitted}`);
|
||||
console.log(` assets staged: ${staged}/${wanted.size}`);
|
||||
console.log(` total duration: ${TOTAL}s`);
|
||||
if (repairs.length) {
|
||||
console.log(`\nrepaired (frame files updated in place):`);
|
||||
for (const rp of repairs) console.log(` - ${rp}`);
|
||||
}
|
||||
if (anomalies.length) {
|
||||
console.log(`\nanomalies (non-fatal):`);
|
||||
for (const a of anomalies) console.log(` - ${a}`);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env node
|
||||
// audio.mjs — audio ADAPTER (reuses the product-launch SCRIPT.md / STORYBOARD.md
|
||||
// model; this file is intentionally identical across the reusing skills). The
|
||||
// TTS / BGM / SFX implementation
|
||||
// no longer lives here: it is the shared engine at
|
||||
// ../../media-use/audio/scripts/audio.mjs. This file only (a) maps the
|
||||
// product-launch model (SCRIPT.md frames + STORYBOARD.md music/sfx) into the
|
||||
// engine's neutral audio_request.json, (b) converts the engine's id-keyed
|
||||
// audio_meta back into the frame-keyed shape captions.mjs / assemble-index.mjs
|
||||
// already consume, and (c) keeps the local `sync-durations` pass (it rewrites
|
||||
// STORYBOARD.md, which is product-launch-specific).
|
||||
//
|
||||
// Three modes (unchanged CLI surface):
|
||||
// (default) generate — engine --only tts,bgm. BGM mode is "retrieve" (strict:
|
||||
// no HeyGen credential ⇒ skip, never a detached generate, since this
|
||||
// workflow has no wait-bgm step). Runs in the background during Step 4.
|
||||
// sync-durations — write real voice durations into STORYBOARD.md (local).
|
||||
// fetch-sfx — engine --only sfx, merged into the existing meta (Step 5,
|
||||
// after the frames' `sfx:` cues exist).
|
||||
//
|
||||
// node audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json
|
||||
// node audio.mjs sync-durations --audio-meta ./audio_meta.json --storyboard ./STORYBOARD.md
|
||||
// node audio.mjs fetch-sfx --storyboard ./STORYBOARD.md --hyperframes .
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseStoryboard } from "./lib/storyboard.mjs";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const DEFAULT_ENGINE = join(HERE, "..", "..", "media-use", "audio", "scripts", "audio.mjs");
|
||||
|
||||
const flag = (argv, name, def) => {
|
||||
const i = argv.indexOf(`--${name}`);
|
||||
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def;
|
||||
};
|
||||
const pad2 = (n) => String(n).padStart(2, "0");
|
||||
|
||||
// SCRIPT.md → [{ frame, text }]. `## … (Frame N)` opens a line; `**key:**` rows
|
||||
// are metadata; the indented block is the spoken text (the only TTS input).
|
||||
function parseScript(md) {
|
||||
const out = [];
|
||||
let cur = null;
|
||||
const flush = () => {
|
||||
if (cur && cur.text.trim()) out.push({ frame: cur.frame, text: cur.text.trim() });
|
||||
cur = null;
|
||||
};
|
||||
for (const line of md.split(/\r?\n/)) {
|
||||
const h = line.match(/^#{2,3}\s+.*?\(frame\s+(\d+)\)/i);
|
||||
if (h) {
|
||||
flush();
|
||||
cur = { frame: Number(h[1]), text: "" };
|
||||
continue;
|
||||
}
|
||||
if (!cur) continue;
|
||||
if (/^\s*\*\*/.test(line)) continue;
|
||||
const m = line.match(/^(?: {4,}|\t)(.+)$/);
|
||||
if (m) cur.text += (cur.text ? " " : "") + m[1].trim();
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
// Path of the engine's neutral meta — a stable sidecar so `--only` merges
|
||||
// (generate then fetch-sfx) accumulate, while audio_meta.json holds the PL shape.
|
||||
const neutralPath = (plOutPath) => join(dirname(plOutPath), "audio_engine_meta.json");
|
||||
|
||||
// Run the shared engine. Returns nothing; dies on a non-zero exit.
|
||||
function runEngine({ request, hyperframesDir, neutral, only, extra = [] }, die) {
|
||||
const reqPath = join(hyperframesDir, "audio_request.json");
|
||||
writeFileSync(reqPath, JSON.stringify(request, null, 2));
|
||||
const engine = process.env.HF_MEDIA_ENGINE || DEFAULT_ENGINE;
|
||||
if (!existsSync(engine)) die(`media audio engine not found at ${engine} (set $HF_MEDIA_ENGINE)`);
|
||||
const args = [
|
||||
engine,
|
||||
"--request",
|
||||
reqPath,
|
||||
"--hyperframes",
|
||||
hyperframesDir,
|
||||
"--out",
|
||||
neutral,
|
||||
"--only",
|
||||
only,
|
||||
...extra,
|
||||
];
|
||||
const r = spawnSync("node", args, { stdio: "inherit" });
|
||||
if (r.status !== 0) die(`media audio engine exited ${r.status}`);
|
||||
}
|
||||
|
||||
// Engine neutral meta (id-keyed) → product-launch meta (frame-keyed) consumed by
|
||||
// captions.mjs / assemble-index.mjs. id is the zero-padded frame number.
|
||||
function toProductLaunchMeta(neutral) {
|
||||
const voices = (neutral.voices ?? []).map((v) => ({
|
||||
frame: Number(v.id),
|
||||
path: v.path,
|
||||
duration_s: v.duration_s,
|
||||
words: (v.words ?? []).map((w) => ({ id: w.id, text: w.text, start: w.start, end: w.end })),
|
||||
}));
|
||||
const bgm = neutral.bgm
|
||||
? {
|
||||
path: neutral.bgm.path,
|
||||
volume: neutral.bgm.volume,
|
||||
query: neutral.bgm.query ?? null,
|
||||
duration_s: neutral.bgm.duration_s ?? null,
|
||||
}
|
||||
: null;
|
||||
const sfx = (neutral.sfx ?? []).map((s) => ({
|
||||
frame: Number(s.id),
|
||||
file: s.file,
|
||||
offset_s: s.offset_s ?? 0,
|
||||
duration_s: s.duration_s ?? 1,
|
||||
volume: s.volume ?? 0.35,
|
||||
}));
|
||||
return { bgm, voices, sfx };
|
||||
}
|
||||
|
||||
// ── generate (TTS + BGM) ────────────────────────────────────────────────────
|
||||
function runGenerate(argv) {
|
||||
const die = (m) => {
|
||||
console.error(`✗ audio generate: ${m}`);
|
||||
process.exit(1);
|
||||
};
|
||||
const hyperframesDir = resolve(flag(argv, "hyperframes", "."));
|
||||
const storyboardPath = resolve(flag(argv, "storyboard", join(hyperframesDir, "STORYBOARD.md")));
|
||||
const scriptPath = resolve(flag(argv, "script", join(hyperframesDir, "SCRIPT.md")));
|
||||
const outPath = resolve(flag(argv, "out", join(hyperframesDir, "audio_meta.json")));
|
||||
const userVoice = flag(argv, "voice", null);
|
||||
const speed = Number(flag(argv, "speed", "1.0")) || 1.0;
|
||||
|
||||
if (!existsSync(storyboardPath)) die(`STORYBOARD.md not found at ${storyboardPath}`);
|
||||
const manifest = parseStoryboard(readFileSync(storyboardPath, "utf8"));
|
||||
const g = manifest.globals;
|
||||
|
||||
const lines = existsSync(scriptPath)
|
||||
? parseScript(readFileSync(scriptPath, "utf8")).map((l) => ({
|
||||
id: pad2(l.frame),
|
||||
text: l.text,
|
||||
}))
|
||||
: [];
|
||||
if (!lines.length) console.error("· no SCRIPT.md — silent film (BGM only)");
|
||||
|
||||
// BGM mood: storyboard `music:` → message → arc → default. `mode: retrieve` is
|
||||
// strict here (no wait-bgm step downstream).
|
||||
const query = (g.extra && g.extra.music) || g.message || g.arc || "calm cinematic underscore";
|
||||
const request = {
|
||||
provider: "auto",
|
||||
speed,
|
||||
lines,
|
||||
bgm: { mode: "retrieve", query, blob: g.message || "", arc: g.arc || "" },
|
||||
};
|
||||
if (userVoice) request.voice = userVoice;
|
||||
|
||||
const neutral = neutralPath(outPath);
|
||||
runEngine({ request, hyperframesDir, neutral, only: "tts,bgm" }, die);
|
||||
|
||||
const meta = toProductLaunchMeta(JSON.parse(readFileSync(neutral, "utf8")));
|
||||
writeFileSync(outPath, JSON.stringify(meta, null, 2));
|
||||
console.log(
|
||||
`✓ audio generate: ${meta.voices.length} voice + ${meta.bgm ? "1 bgm" : "no bgm"} → ${outPath}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── fetch-sfx ────────────────────────────────────────────────────────────────
|
||||
function runFetchSfx(argv) {
|
||||
const die = (m) => {
|
||||
console.error(`✗ audio fetch-sfx: ${m}`);
|
||||
process.exit(1);
|
||||
};
|
||||
const hyperframesDir = resolve(flag(argv, "hyperframes", "."));
|
||||
const storyboardPath = resolve(flag(argv, "storyboard", join(hyperframesDir, "STORYBOARD.md")));
|
||||
const outPath = resolve(flag(argv, "audio-meta", join(hyperframesDir, "audio_meta.json")));
|
||||
|
||||
if (!existsSync(storyboardPath)) die(`STORYBOARD.md not found at ${storyboardPath}`);
|
||||
const manifest = parseStoryboard(readFileSync(storyboardPath, "utf8"));
|
||||
|
||||
// Per-frame `sfx:` cues (comma-separated) → engine lines carrying only sfx.
|
||||
const lines = [];
|
||||
for (const f of manifest.frames) {
|
||||
const names = (f.extra?.sfx ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (names.length && f.number != null) lines.push({ id: pad2(f.number), sfx: names });
|
||||
}
|
||||
|
||||
const neutral = neutralPath(outPath);
|
||||
const request = { lines, bgm: { mode: "none" } };
|
||||
// --only sfx is a MERGE, not an overwrite: the engine reads the existing neutral
|
||||
// sidecar (audio_engine_meta.json) and recomputes only the sfx section, so the
|
||||
// voices/bgm written by the earlier generate (--only tts,bgm) pass are preserved.
|
||||
runEngine({ request, hyperframesDir, neutral, only: "sfx" }, die);
|
||||
|
||||
const meta = toProductLaunchMeta(JSON.parse(readFileSync(neutral, "utf8")));
|
||||
writeFileSync(outPath, JSON.stringify(meta, null, 2));
|
||||
console.log(`✓ audio fetch-sfx: ${meta.sfx.length} SFX cue(s) → ${outPath}`);
|
||||
}
|
||||
|
||||
// ── sync-durations (local; rewrites STORYBOARD.md) ────────────────────────────
|
||||
function runSyncDurations(argv) {
|
||||
const die = (m) => {
|
||||
console.error(`✗ audio sync-durations: ${m}`);
|
||||
process.exit(1);
|
||||
};
|
||||
const hyperframesDir = resolve(flag(argv, "hyperframes", "."));
|
||||
const audioMetaPath = resolve(flag(argv, "audio-meta", join(hyperframesDir, "audio_meta.json")));
|
||||
const storyboardPath = resolve(flag(argv, "storyboard", join(hyperframesDir, "STORYBOARD.md")));
|
||||
if (!existsSync(audioMetaPath)) die(`audio_meta.json not found at ${audioMetaPath}`);
|
||||
|
||||
const meta = JSON.parse(readFileSync(audioMetaPath, "utf8"));
|
||||
const durByFrame = new Map();
|
||||
for (const v of meta.voices ?? []) {
|
||||
if (v.frame != null && v.duration_s) durByFrame.set(v.frame, v.duration_s);
|
||||
}
|
||||
|
||||
// Read directly and handle ENOENT here, rather than an existsSync precheck —
|
||||
// the check→write pair (write-back below) is a TOCTOU race CodeQL flags.
|
||||
let storyboardRaw = "";
|
||||
try {
|
||||
storyboardRaw = readFileSync(storyboardPath, "utf8");
|
||||
} catch {
|
||||
die(`STORYBOARD.md not found at ${storyboardPath}`);
|
||||
}
|
||||
const lines = storyboardRaw.split(/\r?\n/);
|
||||
const FRAME_RE = /^#{2,3}\s+(?:frame|beat|scene)\b.*?(\d+)/i;
|
||||
let curFrame = null;
|
||||
let updated = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const h = lines[i].match(FRAME_RE);
|
||||
if (h) {
|
||||
curFrame = Number(h[1]);
|
||||
continue;
|
||||
}
|
||||
if (curFrame != null && durByFrame.has(curFrame)) {
|
||||
const m = lines[i].match(/^(\s*[-*]\s+duration\s*:\s*).*/i);
|
||||
if (m) {
|
||||
lines[i] = `${m[1]}${durByFrame.get(curFrame)}s`;
|
||||
durByFrame.delete(curFrame);
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
}
|
||||
writeFileSync(storyboardPath, lines.join("\n"));
|
||||
const missing = [...durByFrame.keys()];
|
||||
console.log(
|
||||
`✓ audio sync-durations: ${updated} frame duration(s) updated` +
|
||||
(missing.length ? ` · no \`- duration:\` line for frame(s) ${missing.join(", ")}` : ""),
|
||||
);
|
||||
}
|
||||
|
||||
// ── dispatch ──────────────────────────────────────────────────────────────────
|
||||
const sub = process.argv[2];
|
||||
if (sub === "sync-durations") runSyncDurations(process.argv.slice(3));
|
||||
else if (sub === "fetch-sfx") runFetchSfx(process.argv.slice(3));
|
||||
else runGenerate(process.argv.slice(2)); // default: generate
|
||||
@@ -0,0 +1,533 @@
|
||||
#!/usr/bin/env node
|
||||
// build-frame.mjs — Step 2 design system in ONE command. The LLM only chooses a
|
||||
// preset; this does the deterministic rest: copy the preset's FRAME.md → frame.md,
|
||||
// remix its colors/typography onto the project's brand tokens, copy the preset's
|
||||
// caption-skin.html, and self-validate. "Strict on brand" is deterministic, so it's
|
||||
// a script, not LLM hand-editing (which mis-copies hex / breaks keys).
|
||||
//
|
||||
// node build-frame.mjs --preset capsule --hyperframes .
|
||||
// [--tokens capture/extracted/tokens.json] [--preset-dir <abs path to frame-presets>]
|
||||
//
|
||||
// Remix rule — ONLY `colors:` values and `typography:` fontFamily change; keys,
|
||||
// structure, geometry, and components are untouched:
|
||||
// colors — map brand tokens onto the preset's keys BY ROLE: the ink-role key takes
|
||||
// the brand ink (darkest/ink-named), the canvas-role key takes the brand
|
||||
// canvas (lightest), and every other color is repainted with the nearest
|
||||
// brand accent's hue+saturation while KEEPING its own lightness, so tint
|
||||
// families (sun / sun-soft / haze) stay a family. Empty brand colors → the
|
||||
// preset palette is kept (it is already a complete, good design).
|
||||
// fonts — the preset's display family → the brand display font, its body family →
|
||||
// the brand body font, wherever they appear. Empty brand fonts → kept.
|
||||
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
brandRolesFromStats,
|
||||
chroma,
|
||||
lum,
|
||||
parseColors,
|
||||
parseFonts,
|
||||
pickAccent,
|
||||
semanticColors,
|
||||
STATUS_ROLE_KEY,
|
||||
UA_DEFAULT_COLORS,
|
||||
} from "./lib/tokens.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const argv = process.argv.slice(2);
|
||||
const flag = (name, def) => {
|
||||
const i = argv.indexOf(`--${name}`);
|
||||
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def;
|
||||
};
|
||||
const die = (m) => {
|
||||
console.error(`✗ build-frame: ${m}`);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
const presetName = flag("preset", null);
|
||||
const hyperframesDir = resolve(flag("hyperframes", "."));
|
||||
const presetDir = resolve(
|
||||
flag("preset-dir", join(__dirname, "../../hyperframes-creative/frame-presets")),
|
||||
);
|
||||
const tokensPath = resolve(flag("tokens", join(hyperframesDir, "capture/extracted/tokens.json")));
|
||||
|
||||
if (!presetName) die("--preset <name> is required");
|
||||
const presetFrame = join(presetDir, presetName, "FRAME.md");
|
||||
if (!existsSync(presetFrame)) {
|
||||
const avail = existsSync(presetDir)
|
||||
? readdirSync(presetDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name)
|
||||
: [];
|
||||
die(
|
||||
`no FRAME.md for preset "${presetName}" under ${presetDir}\n available: ${avail.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── HSL helpers (recolor = brand hue+sat, original lightness) ──────────────────
|
||||
function hexToHsl(hex) {
|
||||
const m = /^#?([0-9a-fA-F]{6})$/.exec(String(hex).trim());
|
||||
if (!m) return null;
|
||||
const n = parseInt(m[1], 16);
|
||||
const r = ((n >> 16) & 255) / 255,
|
||||
g = ((n >> 8) & 255) / 255,
|
||||
b = (n & 255) / 255;
|
||||
const max = Math.max(r, g, b),
|
||||
min = Math.min(r, g, b),
|
||||
d = max - min;
|
||||
let h = 0;
|
||||
const l = (max + min) / 2;
|
||||
const s = d === 0 ? 0 : l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
if (d !== 0) {
|
||||
h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
||||
h *= 60;
|
||||
}
|
||||
return { h, s, l };
|
||||
}
|
||||
function hslToHex(h, s, l) {
|
||||
h = (((h % 360) + 360) % 360) / 360;
|
||||
const hue = (p, q, t) => {
|
||||
t = (t + 1) % 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
let r, g, b;
|
||||
if (s === 0) {
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue(p, q, h + 1 / 3);
|
||||
g = hue(p, q, h);
|
||||
b = hue(p, q, h - 1 / 3);
|
||||
}
|
||||
const to = (x) =>
|
||||
Math.round(x * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0")
|
||||
.toUpperCase();
|
||||
return `#${to(r)}${to(g)}${to(b)}`;
|
||||
}
|
||||
const hueDist = (a, b) => {
|
||||
const d = Math.abs(a - b) % 360;
|
||||
return d > 180 ? 360 - d : d;
|
||||
};
|
||||
function hexToRgb(hex) {
|
||||
const m = /^#?([0-9a-fA-F]{6})$/.exec(String(hex).trim());
|
||||
if (!m) return null;
|
||||
const n = parseInt(m[1], 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
const rgbToHsl = (r, g, b) =>
|
||||
hexToHsl("#" + [r, g, b].map((x) => Math.round(x).toString(16).padStart(2, "0")).join(""));
|
||||
// Repaint a chromatic rgba()/rgb() tint with the brand accent's RGB, keeping its alpha.
|
||||
// A near-neutral rgb (shadow / scrim overlay) is left untouched; a non-rgba string → null.
|
||||
function remapRgbaToAccent(val, brAccent, brAccent2, prAccentHsl, prAccent2Hsl) {
|
||||
const m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*(?:[,/]\s*([\d.]+%?))?\s*\)$/i.exec(
|
||||
String(val).trim(),
|
||||
);
|
||||
if (!m) return null;
|
||||
const r = +m[1],
|
||||
g = +m[2],
|
||||
b = +m[3],
|
||||
a = m[4];
|
||||
if (Math.max(r, g, b) - Math.min(r, g, b) < 16) return null; // neutral overlay — keep as-is
|
||||
const src = rgbToHsl(r, g, b);
|
||||
const useSecond =
|
||||
brAccent2 &&
|
||||
prAccentHsl &&
|
||||
prAccent2Hsl &&
|
||||
src &&
|
||||
hueDist(src.h, prAccent2Hsl.h) < hueDist(src.h, prAccentHsl.h);
|
||||
const t = hexToRgb(useSecond ? brAccent2 : brAccent);
|
||||
if (!t) return null;
|
||||
return a !== undefined
|
||||
? `rgba(${t[0]}, ${t[1]}, ${t[2]}, ${a})`
|
||||
: `rgb(${t[0]}, ${t[1]}, ${t[2]})`;
|
||||
}
|
||||
|
||||
// ── brand tokens ──────────────────────────────────────────────────────────────
|
||||
let brandColors = [];
|
||||
let brandFonts = [];
|
||||
let brandFontWeights = []; // weights the brand text font actually ships (tokens fonts[].weights)
|
||||
let brandColorStats = []; // rich per-color usage stats (areaBg / interactiveBg / textCount …)
|
||||
// Icon/glyph fonts capture surfaces as "fonts" — they are never the brand text face
|
||||
// (webflow-icons, Font Awesome, icomoon …) and must not become display/body or contribute weights.
|
||||
const isIconFont = (name) =>
|
||||
/(?:^|[\s_-])icons?(?:[\s_-]|$)|icomoon|font\s*-?awesome|glyphicons?|material\s*icons|feather\s*icons/i.test(
|
||||
String(name),
|
||||
);
|
||||
if (existsSync(tokensPath)) {
|
||||
try {
|
||||
const t = JSON.parse(readFileSync(tokensPath, "utf8"));
|
||||
brandColors = (t.colors ?? [])
|
||||
.map((c) => (typeof c === "string" ? c : (c?.hex ?? c?.value ?? "")))
|
||||
.map((c) => String(c).trim())
|
||||
.filter((c) => /^#?[0-9a-fA-F]{6}$/.test(c))
|
||||
.map((c) => (c.startsWith("#") ? c : `#${c}`));
|
||||
brandFonts = (t.fonts ?? [])
|
||||
.map((f) => (typeof f === "string" ? f : (f?.family ?? f?.name ?? "")))
|
||||
.map((f) => String(f).split(",")[0].replace(/['"]/g, "").trim())
|
||||
.filter(Boolean)
|
||||
.filter((f) => !isIconFont(f));
|
||||
// Union of the (non-icon) brand fonts' available weights — used to clamp the preset's
|
||||
// type ramp so a font shipping only 400/500 never faux-bolds a 600/700 heading.
|
||||
brandFontWeights = [
|
||||
...new Set(
|
||||
(t.fonts ?? [])
|
||||
.filter((f) => f && typeof f === "object" && !isIconFont(f.family ?? f.name ?? ""))
|
||||
.flatMap((f) => (Array.isArray(f.weights) ? f.weights : []))
|
||||
.map((w) => parseInt(w, 10))
|
||||
.filter((w) => Number.isFinite(w)),
|
||||
),
|
||||
].sort((a, b) => a - b);
|
||||
brandColorStats = Array.isArray(t.colorStats) ? t.colorStats : [];
|
||||
} catch (e) {
|
||||
die(`tokens.json parse: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
let md = readFileSync(presetFrame, "utf8");
|
||||
const presetColors = parseColors(md);
|
||||
const summary = [];
|
||||
|
||||
// ── color remix ───────────────────────────────────────────────────────────────
|
||||
if (brandColors.length && presetColors.length) {
|
||||
const pr = semanticColors(presetColors);
|
||||
// Brand roles: prefer the function-based reading of capture colorStats (canvas =
|
||||
// largest background, accent = top interactive bg, ink = dominant contrasting text).
|
||||
// Fall back to the legacy luminance/chroma heuristic only when stats are absent —
|
||||
// but pick the accent via pickAccent either way so a UA-default link color never wins.
|
||||
const br =
|
||||
brandRolesFromStats(brandColorStats, brandColors) ??
|
||||
(() => {
|
||||
// strip UA-default link colors so a stray <a> color can't become ink/canvas/accent
|
||||
const clean = brandColors.filter((h) => !UA_DEFAULT_COLORS.has(h.toUpperCase()));
|
||||
const s = semanticColors(clean.map((h, i) => [`c${i}`, h]));
|
||||
return {
|
||||
ink: s.ink,
|
||||
canvas: s.canvas,
|
||||
accent: pickAccent(brandColorStats, clean, [s.ink, s.canvas]) ?? s.accent,
|
||||
accent2: s.accent2,
|
||||
};
|
||||
})();
|
||||
if (!br.accent) die("accent 选取失败:品牌色里没有可用的强调色");
|
||||
if (chroma(br.accent) <= 40) {
|
||||
console.warn(
|
||||
` ⚠ accent ${br.accent} 彩度很低 (${chroma(br.accent)}) — 确认这是品牌色而非中性/默认色`,
|
||||
);
|
||||
}
|
||||
// Map by LUMINANCE POLARITY. The preset's darker value takes the brand's darker value and
|
||||
// the lighter takes the lighter — UNLESS the brand's GROUND polarity differs from the
|
||||
// preset's. Every shipped preset is light-ground; a dark-mode brand (Linear, Vercel,
|
||||
// Raycast…) has its canvas darker than its ink (colorStats already resolved the real
|
||||
// ground as the largest-area background). On a polarity MISMATCH we INVERT the mapping so a
|
||||
// light preset becomes the dark brand (canvas↔ink swap) instead of forcing the brand onto
|
||||
// an off-brand light video; neutral/tint lightness is then mirrored (L→1−L) so the whole
|
||||
// palette flips to the brand's ground. Same-polarity (the common case) is unchanged.
|
||||
const darker = (a, b) => ((lum(a) ?? 0) <= (lum(b) ?? 0) ? a : b);
|
||||
const prDark = darker(pr.ink, pr.canvas);
|
||||
const prLight = prDark === pr.ink ? pr.canvas : pr.ink;
|
||||
const brDark = darker(br.ink, br.canvas);
|
||||
const brLight = brDark === br.ink ? br.canvas : br.ink;
|
||||
const presetGroundDark = (lum(pr.canvas) ?? 255) < (lum(pr.ink) ?? 0);
|
||||
const brandGroundDark = (lum(br.canvas) ?? 255) < (lum(br.ink) ?? 0);
|
||||
const invert = presetGroundDark !== brandGroundDark;
|
||||
const mapDark = invert ? brLight : brDark; // preset's dark value → this brand value
|
||||
const mapLight = invert ? brDark : brLight; // preset's light value → this brand value
|
||||
const flipL = (l) => (invert ? 1 - l : l); // mirror tint/neutral lightness when flipping
|
||||
const prAccentHsl = hexToHsl(pr.accent);
|
||||
const prAccent2Hsl = hexToHsl(pr.accent2);
|
||||
const newByKey = new Map();
|
||||
for (const [key, val] of presetColors) {
|
||||
const ph = hexToHsl(val);
|
||||
let next;
|
||||
if (val === prDark) next = mapDark;
|
||||
else if (val === prLight) next = mapLight;
|
||||
else if (STATUS_ROLE_KEY.test(key))
|
||||
// semantic status colors (green/red …) — the HUE carries the meaning; never repaint.
|
||||
// MUST precede the accent checks: a preset's red "negative" is often its 2nd-most-chromatic
|
||||
// color and would otherwise be claimed as accent2 and recolored to the brand hue.
|
||||
next = val;
|
||||
else if (val === pr.accent)
|
||||
next = br.accent; // primary accent → the EXACT brand color
|
||||
else if (pr.accent2 !== pr.accent && val === pr.accent2)
|
||||
next = br.accent2; // exact 2nd accent
|
||||
else if (!ph) {
|
||||
// rgba()/rgb() tint → repaint its rgb with the brand accent, keep alpha (a neutral
|
||||
// overlay is kept). A non-color non-hex value (var(), named) falls through unchanged.
|
||||
next = remapRgbaToAccent(val, br.accent, br.accent2, prAccentHsl, prAccent2Hsl) ?? val;
|
||||
} else if (chroma(val) < 16) {
|
||||
// NEUTRAL source (grey text-ladder, hairline borders) → keep it NEUTRAL. Apply at most a
|
||||
// whisper of the brand hue (sat ≤ 0.06); never the accent's full saturation — that is what
|
||||
// turned the grey ladder into saturated blue.
|
||||
const bh = hexToHsl(br.accent);
|
||||
next = bh ? hslToHex(bh.h, Math.min(ph.s, 0.06), flipL(ph.l)) : val;
|
||||
} else {
|
||||
// chromatic tint → repaint with the nearest brand accent's hue+sat, keep THIS color's
|
||||
// lightness so tint families stay families.
|
||||
const useSecond =
|
||||
pr.accent !== pr.accent2 &&
|
||||
prAccentHsl &&
|
||||
prAccent2Hsl &&
|
||||
hueDist(ph.h, prAccent2Hsl.h) < hueDist(ph.h, prAccentHsl.h);
|
||||
const bh = hexToHsl(useSecond ? br.accent2 : br.accent);
|
||||
next = bh ? hslToHex(bh.h, bh.s, flipL(ph.l)) : val;
|
||||
}
|
||||
if (next !== val) newByKey.set(key, next);
|
||||
}
|
||||
// rewrite only the value of each colors: line; everything else byte-identical.
|
||||
let inBlock = false;
|
||||
md = md
|
||||
.split(/\r?\n/)
|
||||
.map((line) => {
|
||||
if (/^colors:\s*$/.test(line)) {
|
||||
inBlock = true;
|
||||
return line;
|
||||
}
|
||||
if (inBlock && /^\S/.test(line)) inBlock = false;
|
||||
if (!inBlock) return line;
|
||||
const m = line.match(
|
||||
/^(\s+)([\w-]+):\s*(?:"[^"]*"|'[^']*'|#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)|[^#\n]*?)(\s+#.*)?$/,
|
||||
);
|
||||
if (m && newByKey.has(m[2])) return `${m[1]}${m[2]}: "${newByKey.get(m[2])}"${m[3] ?? ""}`;
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
summary.push(
|
||||
`colors: ${invert ? "INVERTED (dark-mode brand on light preset) · " : ""}dark ${prDark}→${mapDark}, light ${prLight}→${mapLight}, accent ${pr.accent}→${br.accent}` +
|
||||
` (${newByKey.size}/${presetColors.length} keys repainted${brandColorStats.length ? ", via colorStats" : ""})`,
|
||||
);
|
||||
} else {
|
||||
summary.push(
|
||||
brandColors.length
|
||||
? "colors: preset has no parseable colors — kept"
|
||||
: "colors: no brand colors — preset palette kept",
|
||||
);
|
||||
}
|
||||
|
||||
// ── font remix ────────────────────────────────────────────────────────────────
|
||||
if (brandFonts.length) {
|
||||
const pf = parseFonts(md);
|
||||
const strip = (q) => (q ? q.replace(/^"|"$/g, "") : null);
|
||||
const pDisplay = strip(pf.display);
|
||||
const pBody = strip(pf.body);
|
||||
const pMono = strip(pf.mono);
|
||||
// A monospace brand face is for code / labels / chrome — never the reading display or body.
|
||||
// Split the brand fonts: the primary NON-mono family carries display AND body (the common
|
||||
// single-sans case, e.g. Inter for everything), and a captured mono (Berkeley Mono,
|
||||
// JetBrains Mono…) is routed onto the preset's mono role instead of turning the body
|
||||
// monospace. (Distinct display/body brands still resolve to a clean sans; hand-tune the
|
||||
// display in frame.md if a separate display face is wanted.)
|
||||
const isMonoFont = (n) =>
|
||||
/(?:^|[\s_-])mono(?:[\s_-]|$)|monospace|consol|courier|menlo|monaco|jetbrains|berkeley|space\s*mono|ibm\s*plex\s*mono|sf\s*mono|roboto\s*mono|source\s*code|fira\s*code|geist\s*mono|dm\s*mono/i.test(
|
||||
String(n),
|
||||
);
|
||||
const nonMono = brandFonts.filter((f) => !isMonoFont(f));
|
||||
const monoFonts = brandFonts.filter(isMonoFont);
|
||||
const bDisplay = nonMono[0] ?? brandFonts[0];
|
||||
const bBody = nonMono[0] ?? brandFonts[0];
|
||||
const bMono = monoFonts[0] ?? null;
|
||||
const escRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
// Replace the preset family as a WHOLE WORD/PHRASE everywhere — frontmatter values,
|
||||
// component strings like "Space Grotesk 600", AND prose — case-sensitive with word
|
||||
// boundaries so a single-word family ("Inter") can never corrupt a substring
|
||||
// ("interactive"). Quote-exact replace alone missed names baked into longer strings + prose.
|
||||
const swapFamily = (from, to) => {
|
||||
if (from && to && from !== to) md = md.replace(new RegExp(`\\b${escRe(from)}\\b`, "g"), to);
|
||||
};
|
||||
swapFamily(pDisplay, bDisplay);
|
||||
if (pBody !== pDisplay) swapFamily(pBody, bBody);
|
||||
// route the brand mono onto the preset's mono role (only if the preset has a DISTINCT mono
|
||||
// family — never collapse body/display into mono)
|
||||
if (bMono && pMono && pMono !== pBody && pMono !== pDisplay) swapFamily(pMono, bMono);
|
||||
summary.push(
|
||||
`fonts: display ${pDisplay}→${bDisplay}, body ${pBody}→${bBody}` +
|
||||
(bMono && pMono && pMono !== pBody && pMono !== pDisplay ? `, mono ${pMono}→${bMono}` : ""),
|
||||
);
|
||||
} else {
|
||||
summary.push("fonts: no brand fonts — preset fonts kept");
|
||||
}
|
||||
|
||||
// ── cap type weights to the brand font's available faces ──────────────────────
|
||||
// The remix swaps the font FAMILY but keeps the preset's weights; a brand font that ships
|
||||
// only e.g. 400/500 would faux-bold every 600/700 heading. Clamp each `typography:` weight
|
||||
// to the NEAREST weight the brand font actually provides (tokens.json fonts[].weights).
|
||||
if (brandFonts.length && brandFontWeights.length) {
|
||||
const avail = brandFontWeights;
|
||||
const nearest = (n) =>
|
||||
avail.reduce((best, w) => {
|
||||
const dw = Math.abs(w - n),
|
||||
db = Math.abs(best - n);
|
||||
return dw < db || (dw === db && w > best) ? w : best;
|
||||
}, avail[0]);
|
||||
let capped = 0;
|
||||
const cap = (num) => {
|
||||
const n = parseInt(num, 10);
|
||||
if (avail.includes(n)) return String(n);
|
||||
const c = nearest(n);
|
||||
if (c !== n) capped++;
|
||||
return String(c);
|
||||
};
|
||||
let inType = false;
|
||||
md = md
|
||||
.split(/\r?\n/)
|
||||
.map((line) => {
|
||||
if (/^typography:\s*$/.test(line)) {
|
||||
inType = true;
|
||||
return line;
|
||||
}
|
||||
if (inType && /^\S/.test(line)) inType = false;
|
||||
let out = line;
|
||||
// (a) structured `weight: NNN` in the typography ramp
|
||||
if (inType) out = out.replace(/(\bweight:\s*)(\d{3})\b/g, (m, pfx, num) => pfx + cap(num));
|
||||
// (b) a weight baked into a quoted `typography:` component value, e.g.
|
||||
// cta-button → typography: "Basier Square 600" (NNN not followed by a unit like px)
|
||||
out = out.replace(
|
||||
/(typography:\s*"[^"]*?\b)(\d{3})\b(?![a-z%])/gi,
|
||||
(m, pfx, num) => pfx + cap(num),
|
||||
);
|
||||
return out;
|
||||
})
|
||||
.join("\n");
|
||||
if (capped)
|
||||
summary.push(`fonts: capped ${capped} type weight(s) to brand faces {${avail.join(", ")}}`);
|
||||
}
|
||||
|
||||
// ── brand-adaptation note ─────────────────────────────────────────────────────
|
||||
// The remix fixes the NORMATIVE frontmatter, but the preset's PROSE still carries its
|
||||
// original weight ranges / color-names. Prepend a short "frontmatter is truth" header so a
|
||||
// reader (or frame worker) interprets any lingering preset prose THROUGH the brand values —
|
||||
// instead of fragile per-sentence prose surgery.
|
||||
if (brandFonts.length || (brandColors.length && presetColors.length)) {
|
||||
const bD = brandFonts[0];
|
||||
const bB = brandFonts[1] ?? brandFonts[0];
|
||||
const note =
|
||||
`## Brand adaptation (READ FIRST — the frontmatter is the source of truth)\n\n` +
|
||||
`This is the **${presetName}** preset remixed onto the captured brand. The YAML frontmatter above ` +
|
||||
`(colors · typography · components) is **normative and already correct — use it verbatim.** The prose ` +
|
||||
`below is the ORIGINAL preset's intent; read it THROUGH the frontmatter:\n\n` +
|
||||
(brandFonts.length
|
||||
? `- **Fonts** — already set to **${bD}** (display) / **${bB}** (body); ignore any preset font name lingering in prose.\n`
|
||||
: "") +
|
||||
(brandFontWeights.length
|
||||
? `- **Weights** — the brand font ships \`{${brandFontWeights.join(", ")}}\` only; every weight is clamped to these — ignore higher preset weights (e.g. 600/700) in prose.\n`
|
||||
: "") +
|
||||
`- **Colors** — use the frontmatter hex; preset color NAMES in prose (e.g. "cobalt", "cream") mean the remapped brand values.\n`;
|
||||
if (/^# .*$/m.test(md)) md = md.replace(/^# .*$/m, (m) => `${m}\n\n${note}`);
|
||||
else md = `${note}\n${md}`;
|
||||
summary.push("brand-adaptation note prepended");
|
||||
}
|
||||
|
||||
// ── stage brand font files + emit @font-face ──────────────────────────────────
|
||||
// A brand font is rarely a Google font, so renaming the family in frame.md is not enough:
|
||||
// nothing loads the actual face. If the capture downloaded font files, copy them to
|
||||
// assets/fonts/ under CLEAN, weight-named names (so captions.mjs' family-prefix matcher
|
||||
// finds them too) and append a ready-to-paste, ROOT-RELATIVE @font-face block to frame.md.
|
||||
if (brandFonts.length) {
|
||||
const norm = (s) =>
|
||||
String(s)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, "");
|
||||
const extOf = (f) => (f.match(/\.(woff2|woff|ttf|otf)$/i)?.[1] ?? "").toLowerCase();
|
||||
const FMT = { woff2: "woff2", woff: "woff", ttf: "truetype", otf: "opentype" };
|
||||
const weightInfo = (name) => {
|
||||
const s = name.toLowerCase();
|
||||
if (/black|heavy|ultra|extrabold/.test(s)) return { n: 800, w: "ExtraBold" };
|
||||
if (/semibold|demibold/.test(s)) return { n: 600, w: "SemiBold" };
|
||||
if (/bold/.test(s)) return { n: 700, w: "Bold" };
|
||||
if (/medium/.test(s)) return { n: 500, w: "Medium" };
|
||||
if (/light|thin/.test(s)) return { n: 300, w: "Light" };
|
||||
return { n: 400, w: "Regular" };
|
||||
};
|
||||
const fams = [...new Set(brandFonts)];
|
||||
const srcDirs = [
|
||||
join(hyperframesDir, "capture/assets/fonts"),
|
||||
join(hyperframesDir, "assets/fonts"),
|
||||
].filter((d) => existsSync(d));
|
||||
const files = [];
|
||||
for (const d of srcDirs)
|
||||
for (const f of readdirSync(d).sort()) if (extOf(f)) files.push({ d, f });
|
||||
// Single family → all font files belong to it (the common captured case, hash-named files
|
||||
// included). Multiple families → assign each file to the longest family key its name contains.
|
||||
const ranked = [...fams].sort((a, b) => norm(b).length - norm(a).length);
|
||||
const famOf = (f) =>
|
||||
fams.length === 1 ? fams[0] : ranked.find((x) => norm(f).includes(norm(x)));
|
||||
const outDir = join(hyperframesDir, "assets/fonts");
|
||||
const faces = [];
|
||||
const stagedNames = new Set();
|
||||
for (const { d, f } of files) {
|
||||
const fam = famOf(f);
|
||||
if (!fam) continue;
|
||||
const { n, w } = weightInfo(f);
|
||||
const clean = `${fam.replace(/[^A-Za-z0-9]/g, "")}-${w}.${extOf(f)}`;
|
||||
if (stagedNames.has(clean)) continue;
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
if (!existsSync(join(outDir, clean))) copyFileSync(join(d, f), join(outDir, clean));
|
||||
stagedNames.add(clean);
|
||||
faces.push(
|
||||
`@font-face{font-family:"${fam}";font-weight:${n};font-style:normal;font-display:block;src:url("assets/fonts/${clean}") format("${FMT[extOf(f)]}");}`,
|
||||
);
|
||||
}
|
||||
if (faces.length) {
|
||||
md +=
|
||||
`\n\n## Font loading (auto-generated)\n\n` +
|
||||
`The brand font ships as local files in \`assets/fonts/\` — do NOT link Google Fonts for it. ` +
|
||||
`Paste this \`<style>\` into every frame's \`<head>\`/\`<template>\` (captions use the same files) ` +
|
||||
`so \`font-family\` resolves in preview, snapshot, and render alike:\n\n` +
|
||||
"```html\n<style>\n" +
|
||||
faces.join("\n") +
|
||||
"\n</style>\n```\n";
|
||||
summary.push(
|
||||
`fonts: staged ${stagedNames.size} face(s) → assets/fonts/ + @font-face in frame.md`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── write frame.md ────────────────────────────────────────────────────────────
|
||||
const framePath = join(hyperframesDir, "frame.md");
|
||||
writeFileSync(framePath, md);
|
||||
|
||||
// ── copy caption-skin.html ────────────────────────────────────────────────────
|
||||
const presetSkin = join(presetDir, presetName, "caption-skin.html");
|
||||
let skinCopied = false;
|
||||
if (existsSync(presetSkin)) {
|
||||
const skinDir = join(hyperframesDir, ".hyperframes");
|
||||
mkdirSync(skinDir, { recursive: true });
|
||||
copyFileSync(presetSkin, join(skinDir, "caption-skin.html"));
|
||||
skinCopied = true;
|
||||
}
|
||||
|
||||
// ── self-validate ─────────────────────────────────────────────────────────────
|
||||
const outColors = parseColors(md);
|
||||
if (outColors.length !== presetColors.length) {
|
||||
die(`color keys changed (${presetColors.length}→${outColors.length}) — keys must be preserved`);
|
||||
}
|
||||
const outRoles = semanticColors(outColors);
|
||||
const li = lum(outRoles.ink),
|
||||
lc = lum(outRoles.canvas);
|
||||
// ink (type) and canvas (ground) must differ enough to READ — in EITHER direction. A
|
||||
// light-mode spec has ink darker than canvas; a dark-mode spec (the polarity flip above)
|
||||
// the reverse. Assert luminance SEPARATION, not a fixed polarity.
|
||||
if (li != null && lc != null && Math.abs(li - lc) < 40) {
|
||||
die(
|
||||
`ink (${outRoles.ink}, lum ${li.toFixed(0)}) and canvas (${outRoles.canvas}, lum ${lc.toFixed(0)}) lack contrast — bad brand mapping`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`✓ build-frame: ${presetName} → ${framePath}`);
|
||||
for (const s of summary) console.log(` ${s}`);
|
||||
console.log(
|
||||
` .hyperframes/caption-skin.html: ${skinCopied ? "copied" : "preset ships none — captions will use the default pill"}`,
|
||||
);
|
||||
console.log(` self-check: keys preserved, ink/canvas contrast ok ✓`);
|
||||
@@ -0,0 +1,508 @@
|
||||
#!/usr/bin/env node
|
||||
// captions.mjs — build the captions sub-composition from STORYBOARD + audio_meta.
|
||||
//
|
||||
// One mode: `build`. Reads STORYBOARD.md (frame order + durations → cumulative
|
||||
// frame starts) + audio_meta.json (voices[].words, frame-relative) → absolute-
|
||||
// timed caption groups → writes:
|
||||
// compositions/captions.html — a self-contained sub-composition the index
|
||||
// assembler mounts on its captions track (data-composition-id="captions").
|
||||
// caption_groups.json — the computed groups (debug / inspection / --out).
|
||||
// caption-overrides.json — an empty `[]` shim (silences the captions runtime's
|
||||
// validate-time fetch; only written when captions.html is).
|
||||
// No narration / no words → legal skip: nothing written, assemble-index then omits
|
||||
// the captions track (it keys off compositions/captions.html existence).
|
||||
//
|
||||
// node captions.mjs build --storyboard ./STORYBOARD.md --audio-meta ./audio_meta.json --hyperframes . --out ./caption_groups.json
|
||||
//
|
||||
// CAPTION LOOK — two sources, picked automatically:
|
||||
// 1. PRESET SKIN (preferred). If a project-local `.hyperframes/caption-skin.html`
|
||||
// exists (Step 2 copies the chosen frame-preset's skin into the project), it is
|
||||
// the caption look.
|
||||
// It is a brand-token-strict skin with three reserved holes; this script fills them
|
||||
// and wraps the result in a <template> for the engine:
|
||||
// - `var GROUPS = [];` → the computed caption groups
|
||||
// - `var DURATION = 0;` + data-duration="0" (and data-width/height="0") → real values
|
||||
// - `<style data-brand-tokens></style>` → :root tokens derived from the project's
|
||||
// frame.md (colors + fonts), mapped to a fixed semantic vocab every skin shares:
|
||||
// --cap-ink / --cap-canvas / --cap-accent / --cap-accent-2 / --font-display /
|
||||
// --font-body, plus --cap-band-top / --cap-band-height (the keep-out band).
|
||||
// So the brand-token overlay from Step 2 flows into the captions automatically.
|
||||
// 2. DEFAULT (fallback). No skin file → the built-in Roboto/black pill (buildCaptionsHtml).
|
||||
//
|
||||
// Grouping mirrors the proven heuristics (frame boundary · sentence-end punct ·
|
||||
// silence gap · density-aware word cap); word timings come inline from audio_meta.
|
||||
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { parseStoryboard } from "./lib/storyboard.mjs";
|
||||
import { captionBand, parseFormat } from "./lib/dimensions.mjs";
|
||||
import { parseColors, parseFonts, semanticColors, lum } from "./lib/tokens.mjs";
|
||||
|
||||
const flag = (argv, name, def) => {
|
||||
const i = argv.indexOf(`--${name}`);
|
||||
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def;
|
||||
};
|
||||
const r3 = (x) => Number(x.toFixed(3));
|
||||
|
||||
// ── grouping params ───────────────────────────────────────────────────────────
|
||||
const SILENCE_GAP = 0.18; // s of silence between words → split
|
||||
const TAIL_PAD = 0.12; // s the group lingers after its last word
|
||||
const SENT_END = /[.?!,;:—]$/;
|
||||
const DENSITY_WINDOW = 1.0; // s window for words/sec density
|
||||
function wordCap(density) {
|
||||
return density > 3.5 ? 2 : density > 2.5 ? 3 : 4;
|
||||
}
|
||||
|
||||
function runBuild(argv) {
|
||||
const skip = (reason) => {
|
||||
console.log(`captions: skipped (${reason})`);
|
||||
process.exit(0);
|
||||
};
|
||||
const die = (m) => {
|
||||
console.error(`✗ captions build: ${m}`);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
const hyperframesDir = resolve(flag(argv, "hyperframes", "."));
|
||||
const storyboardPath = resolve(flag(argv, "storyboard", join(hyperframesDir, "STORYBOARD.md")));
|
||||
const audioMetaPath = resolve(flag(argv, "audio-meta", join(hyperframesDir, "audio_meta.json")));
|
||||
const outPath = resolve(flag(argv, "out", join(hyperframesDir, "caption_groups.json")));
|
||||
const htmlPath = join(hyperframesDir, "compositions/captions.html");
|
||||
const overridesPath = join(hyperframesDir, "caption-overrides.json");
|
||||
const skinArg = flag(argv, "skin", null);
|
||||
const hiddenSkinPath = join(hyperframesDir, ".hyperframes", "caption-skin.html");
|
||||
const legacySkinPath = join(hyperframesDir, "caption-skin.html");
|
||||
const skinPath = resolve(
|
||||
skinArg ?? (existsSync(hiddenSkinPath) ? hiddenSkinPath : legacySkinPath),
|
||||
);
|
||||
const framePath = resolve(flag(argv, "frame", join(hyperframesDir, "frame.md")));
|
||||
|
||||
if (!existsSync(storyboardPath)) die(`STORYBOARD.md not found at ${storyboardPath}`);
|
||||
const manifest = parseStoryboard(readFileSync(storyboardPath, "utf8"));
|
||||
const { width: W, height: H } = parseFormat(manifest.globals.format);
|
||||
|
||||
if (!existsSync(audioMetaPath)) skip("no audio_meta.json (silent film)");
|
||||
const meta = JSON.parse(readFileSync(audioMetaPath, "utf8"));
|
||||
if (!Array.isArray(meta.voices) || meta.voices.length === 0) skip("no narration");
|
||||
|
||||
// cumulative frame starts (by frame number) + total duration, from STORYBOARD.
|
||||
const startByFrame = new Map();
|
||||
let acc = 0;
|
||||
for (const f of manifest.frames) {
|
||||
if (f.number != null) startByFrame.set(f.number, acc);
|
||||
acc += Number.isFinite(f.durationSeconds) ? f.durationSeconds : 0;
|
||||
}
|
||||
const total = r3(acc);
|
||||
|
||||
// absolute word stream: frame start + frame-relative word timing.
|
||||
const words = [];
|
||||
for (const v of meta.voices) {
|
||||
const base = startByFrame.get(v.frame);
|
||||
if (base == null || !Array.isArray(v.words)) continue;
|
||||
for (const w of v.words) {
|
||||
const text = String(w.text ?? "").trim();
|
||||
if (!text || /^[.?!,;:—–-]+$/.test(text)) continue; // drop empties + bare punctuation
|
||||
if (!isFinite(w.start) || !isFinite(w.end)) continue;
|
||||
words.push({ text, start: r3(base + w.start), end: r3(base + w.end), frame: v.frame });
|
||||
}
|
||||
}
|
||||
words.sort((a, b) => a.start - b.start);
|
||||
if (words.length === 0) skip("no usable words");
|
||||
|
||||
// density at i = words whose start falls within [w.start, w.start + WINDOW).
|
||||
const densityAt = (i) => {
|
||||
const t0 = words[i].start;
|
||||
let n = 0;
|
||||
for (let j = i; j < words.length && words[j].start < t0 + DENSITY_WINDOW; j++) n++;
|
||||
return n / DENSITY_WINDOW;
|
||||
};
|
||||
|
||||
// group: split on frame change / silence gap / word cap; always flush after a
|
||||
// sentence-ending word.
|
||||
const groups = [];
|
||||
let cur = null;
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const w = words[i];
|
||||
const prev = cur && cur.words[cur.words.length - 1];
|
||||
const crossFrame = cur && w.frame !== cur.frame;
|
||||
const gap = prev && w.start - prev.end > SILENCE_GAP;
|
||||
const full = cur && cur.words.length >= cur.cap;
|
||||
if (!cur || crossFrame || gap || full) {
|
||||
if (cur) groups.push(cur);
|
||||
cur = { frame: w.frame, cap: wordCap(densityAt(i)), words: [] };
|
||||
}
|
||||
cur.words.push(w);
|
||||
if (SENT_END.test(w.text)) {
|
||||
groups.push(cur);
|
||||
cur = null;
|
||||
}
|
||||
}
|
||||
if (cur) groups.push(cur);
|
||||
|
||||
// finalize: ids, start/end (tail-padded, clamped < next group's start), text.
|
||||
const finalized = groups.map((g, gi) => {
|
||||
const first = g.words[0];
|
||||
const last = g.words[g.words.length - 1];
|
||||
const next = groups[gi + 1];
|
||||
let end = r3(last.end + TAIL_PAD);
|
||||
if (next && next.words[0].start < end) end = r3(next.words[0].start);
|
||||
return {
|
||||
id: `caption-group-${gi}`,
|
||||
frame: g.frame,
|
||||
start: r3(first.start),
|
||||
end,
|
||||
text: g.words.map((w) => w.text).join(" "),
|
||||
words: g.words.map((w, wi) => ({
|
||||
id: `caption-word-${gi}-${wi}`,
|
||||
text: w.text,
|
||||
start: r3(w.start),
|
||||
end: r3(w.end),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// ── write caption_groups.json ──
|
||||
mkdirSync(dirname(outPath), { recursive: true });
|
||||
writeFileSync(
|
||||
outPath,
|
||||
JSON.stringify({ total_duration_s: total, width: W, height: H, groups: finalized }, null, 2),
|
||||
);
|
||||
|
||||
// ── write compositions/captions.html (preset skin if present, else default) ──
|
||||
mkdirSync(dirname(htmlPath), { recursive: true });
|
||||
let source;
|
||||
if (existsSync(skinPath)) {
|
||||
const tokens = frameTokensCss(framePath, H);
|
||||
const faces = brandFontFaces(framePath, hyperframesDir);
|
||||
const fonts = existsSync(framePath) ? parseFonts(readFileSync(framePath, "utf8")) : {};
|
||||
writeFileSync(
|
||||
htmlPath,
|
||||
buildFromSkin(
|
||||
readFileSync(skinPath, "utf8"),
|
||||
finalized,
|
||||
total,
|
||||
W,
|
||||
H,
|
||||
tokens,
|
||||
die,
|
||||
faces,
|
||||
fonts,
|
||||
),
|
||||
);
|
||||
source = `preset skin (${skinPath.replace(hyperframesDir + "/", "")})`;
|
||||
} else {
|
||||
writeFileSync(htmlPath, buildCaptionsHtml(finalized, total, W, H));
|
||||
source = "default (built-in pill)";
|
||||
}
|
||||
|
||||
// ── write caption-overrides.json shim ──
|
||||
// Atomic create-if-absent: `wx` throws if the file already exists (which we
|
||||
// ignore) — no existsSync→writeFileSync TOCTOU gap.
|
||||
try {
|
||||
writeFileSync(overridesPath, "[]\n", { flag: "wx" });
|
||||
} catch {
|
||||
/* overrides shim already present */
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✓ captions build: ${finalized.length} group(s) from ${words.length} words → compositions/captions.html (total ${total}s) · skin: ${source}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── preset-skin path ────────────────────────────────────────────────────────
|
||||
// Fill the skin's three reserved holes + the root's 0-placeholders, then wrap the
|
||||
// fragment in a <template> (the engine clones template contents only). One generic
|
||||
// fill works for every preset's skin — no per-skin transform.
|
||||
//
|
||||
// Every preset's skin is authored against ITS OWN fonts/metrics (broadside→Barlow @
|
||||
// line-height 1.02, capsule→Bodoni, …). When the project's brand font differs (it
|
||||
// almost always does), three things must be reconciled so ANY skin renders correctly
|
||||
// for ANY brand — done here generically, not per-project:
|
||||
// · @font-face for the brand fonts (else the renderer can't supply them → fallback)
|
||||
// · the skin's preset-font FALLBACK literals (var(--font-x, "Barlow")) repointed to
|
||||
// the brand family, so no undeclared font name trips font_family_without_font_face
|
||||
// · a metric safety net: a heavier brand font overflows a tight preset line-height,
|
||||
// so the active-word highlight clips — a line-height floor + word padding fixes it
|
||||
// · data-composition-id + dimensions on the <template> root (skins lead with
|
||||
// <script>/<style>, so the root element must carry the id, not the first child)
|
||||
function buildFromSkin(skin, groups, total, W, H, tokens, die, faces = "", fonts = {}) {
|
||||
const fillOnce = (src, re, repl, label) => {
|
||||
const n = (src.match(re) || []).length;
|
||||
if (n !== 1) die(`caption-skin.html: expected exactly one ${label}, found ${n}`);
|
||||
return src.replace(re, () => repl);
|
||||
};
|
||||
let out = skin;
|
||||
// Strip HTML doc-comments first. A skin's authoring comment can contain tag-like text
|
||||
// (broadside's literally says "<template>"), which the linter's tag scanner then picks
|
||||
// up as the root element → false root_missing_composition_id / root_missing_dimensions.
|
||||
// The comments are preview/authoring docs, not needed in the generated composition.
|
||||
// Strip in a fixpoint loop, not a single global pass: removing one comment can
|
||||
// re-form a marker from a nested/partial pair (e.g. <!--<!---->-->), which one
|
||||
// pass misses — CodeQL flags the single replace as incomplete sanitization.
|
||||
for (let prev = ""; prev !== out; ) {
|
||||
prev = out;
|
||||
out = out.replace(/<!--[\s\S]*?-->/g, "");
|
||||
}
|
||||
// brand :root tokens + @font-face for the brand fonts, both into the reserved hole
|
||||
out = fillOnce(
|
||||
out,
|
||||
/<style data-brand-tokens>\s*<\/style>/,
|
||||
`<style data-brand-tokens>\n${faces ? faces + "\n" : ""}${tokens}\n </style>`,
|
||||
"<style data-brand-tokens></style> hole",
|
||||
);
|
||||
// Resolve the skin's font-family var()s to the brand family LITERAL. Two reasons:
|
||||
// (1) the linter's used-font scanner naively comma-splits, so var(--x, "Brand") yields
|
||||
// junk tokens ('var(--x', 'brand")') that never match the @font-face → a false
|
||||
// font_family_without_font_face; a plain "Brand" literal matches the @font-face.
|
||||
// (2) it drops the preset's own fallback name (Barlow / IBM Plex Mono / …), which has
|
||||
// no @font-face in this project. The :root token stays for any other consumer.
|
||||
if (fonts.display)
|
||||
out = out.replace(/var\(\s*--font-display\s*(?:,\s*"[^"]*"\s*)?\)/g, fonts.display);
|
||||
if (fonts.body) out = out.replace(/var\(\s*--font-body\s*(?:,\s*"[^"]*"\s*)?\)/g, fonts.body);
|
||||
out = fillOnce(
|
||||
out,
|
||||
/var GROUPS = \[\];/,
|
||||
`var GROUPS = ${JSON.stringify(groups)};`,
|
||||
"`var GROUPS = [];` hole",
|
||||
);
|
||||
out = fillOnce(out, /var DURATION = 0;/, `var DURATION = ${total};`, "`var DURATION = 0;` hole");
|
||||
out = fillOnce(out, /data-duration="0"/, `data-duration="${total}"`, '`data-duration="0"` hole');
|
||||
out = fillOnce(out, /data-width="0"/, `data-width="${W}"`, '`data-width="0"` hole');
|
||||
out = fillOnce(out, /data-height="0"/, `data-height="${H}"`, '`data-height="0"` hole');
|
||||
// font-robust safety net — appended last so it wins the cascade over the skin's own
|
||||
// (preset-font-tuned) line-height. Kept SNUG (1.1) so the plate hugs the text. NO extra
|
||||
// word/pill padding: inspect's `text_box_overflow` on the highlight words is a cosmetic
|
||||
// false-positive here (heavy-glyph ink slightly exceeds the line box, but there's no
|
||||
// overflow:hidden — nothing is clipped); zeroing it would need an airy line-height that
|
||||
// balloons the pill, which is worse. Override only if a brand font genuinely clips.
|
||||
out += "\n<style>\n .caption-line { line-height: 1.1 !important; }\n</style>";
|
||||
// dark-ground caption contrast (auto). The preset caption skins are tuned for a LIGHT
|
||||
// pill (cream); on a dark brand ground the pill goes near-black, so the skin's faint
|
||||
// upcoming-word mix and light highlight block turn unreadable. When the resolved caption
|
||||
// canvas is dark, override the three word states: a brighter muted upcoming color + an
|
||||
// on-brand ACCENT highlight block with light text. Light grounds are left untouched.
|
||||
const capCanvas = (tokens.match(/--cap-canvas:\s*(#[0-9a-fA-F]{6})/) || [])[1];
|
||||
if (capCanvas && (lum(capCanvas) ?? 255) < 90) {
|
||||
out +=
|
||||
"\n<style>\n" +
|
||||
" .caption-word { color: color-mix(in srgb, var(--cap-ink) 64%, var(--cap-canvas)); }\n" +
|
||||
" .caption-word.is-active { color: var(--cap-ink); background: var(--cap-accent); box-shadow: 0 0 0 0.06em var(--cap-accent); }\n" +
|
||||
" .caption-word.is-spoken { color: var(--cap-ink); background: transparent; box-shadow: none; }\n" +
|
||||
"</style>";
|
||||
}
|
||||
return `<template id="captions-template" data-composition-id="captions" data-width="${W}" data-height="${H}">\n${out.trim()}\n</template>\n`;
|
||||
}
|
||||
|
||||
// @font-face for the brand display/body fonts, matched from the project's font dirs
|
||||
// (staged assets/fonts first, else capture/assets/fonts) by family-name prefix, with
|
||||
// weight parsed from the filename. Paths are relative to compositions/captions.html.
|
||||
// Returns "" when frame.md or font files are absent (then the skin's fallback applies).
|
||||
function brandFontFaces(framePath, hyperframesDir) {
|
||||
if (!existsSync(framePath)) return "";
|
||||
const { display, body } = parseFonts(readFileSync(framePath, "utf8"));
|
||||
const families = [
|
||||
...new Set([display, body].filter(Boolean).map((f) => f.replace(/^"|"$/g, ""))),
|
||||
];
|
||||
if (!families.length) return "";
|
||||
const dirs = [
|
||||
// ROOT-RELATIVE — compositions are served with the project root as their base URL, so a
|
||||
// "../" prefix escapes the root (lint: invalid_parent_traversal_in_asset_path) and 404s in
|
||||
// Studio/preview. Mirror what the frame workers use for images.
|
||||
{ abs: join(hyperframesDir, "assets/fonts"), rel: "assets/fonts" },
|
||||
{ abs: join(hyperframesDir, "capture/assets/fonts"), rel: "capture/assets/fonts" },
|
||||
].filter((d) => existsSync(d.abs));
|
||||
const weightOf = (n) => {
|
||||
const s = n.toLowerCase();
|
||||
if (/black|heavy|ultra|extrabold/.test(s)) return 800;
|
||||
if (/semibold|demibold/.test(s)) return 600; // before /bold/ — "demibold" contains "bold"
|
||||
if (/bold/.test(s)) return 700;
|
||||
if (/medium/.test(s)) return 500;
|
||||
if (/light|thin/.test(s)) return 300;
|
||||
return 400; // book / regular / roman
|
||||
};
|
||||
const fmtOf = (f) =>
|
||||
/\.woff2$/i.test(f)
|
||||
? "woff2"
|
||||
: /\.woff$/i.test(f)
|
||||
? "woff"
|
||||
: /\.ttf$/i.test(f)
|
||||
? "truetype"
|
||||
: "opentype";
|
||||
// Normalize away ALL non-alphanumerics (spaces, underscores, hyphens) on BOTH the
|
||||
// family name and the filename. Real font files use "_" / "-" as word separators
|
||||
// ("TT_Norms_Pro_Bold.woff2"), so stripping only whitespace never matched them — the
|
||||
// family key "ttnormspro" failed `startsWith` against "tt_norms_pro_bold", and the
|
||||
// function silently returned "" → captions shipped with NO @font-face for any
|
||||
// underscore/hyphen-named brand font (e.g. TT Norms Pro), which is exactly the
|
||||
// font_family_without_font_face bug.
|
||||
const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
const faces = [];
|
||||
const seen = new Set();
|
||||
const claimed = new Set(); // each file is claimed by the MOST SPECIFIC family only
|
||||
// Match the longest family key first so "TT Norms Pro" can't swallow the files that
|
||||
// belong to "TT Norms Pro Mono" (its key is a prefix of the longer one's).
|
||||
const ranked = [...families].sort((a, b) => norm(b).length - norm(a).length);
|
||||
for (const fam of ranked) {
|
||||
const key = norm(fam);
|
||||
for (const d of dirs) {
|
||||
let files = [];
|
||||
try {
|
||||
files = readdirSync(d.abs);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const f of files.sort()) {
|
||||
if (!/\.(woff2|woff|ttf|otf)$/i.test(f)) continue;
|
||||
if (claimed.has(f)) continue; // a more specific family already took this file
|
||||
if (!norm(f.replace(/\.(woff2|woff|ttf|otf)$/i, "")).startsWith(key)) continue;
|
||||
const w = weightOf(f);
|
||||
const dedup = `${fam}-${w}`;
|
||||
if (seen.has(dedup)) continue; // one src per weight; assets/fonts wins over capture
|
||||
seen.add(dedup);
|
||||
claimed.add(f);
|
||||
faces.push(
|
||||
` @font-face { font-family: '${fam}'; src: url('${d.rel}/${f}') format('${fmtOf(f)}'); font-weight: ${w}; font-display: block; }`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Loud signal instead of a silent "". If frame.md named a brand font but no file
|
||||
// matched, the caption text WILL fall back to a generic font in the render — surface
|
||||
// the cause here (at build time) rather than letting it surface 2 steps later as a
|
||||
// font_family_without_font_face lint error disconnected from its root cause.
|
||||
if (!faces.length) {
|
||||
const where = dirs.length
|
||||
? dirs.map((d) => d.rel).join(" / ")
|
||||
: "assets/fonts or capture/assets/fonts (neither exists)";
|
||||
console.warn(
|
||||
` ⚠ captions: frame.md names font ${families.map((f) => `"${f}"`).join(", ")} ` +
|
||||
`but no matching .woff2/.woff/.ttf/.otf was found in ${where} — captions will fall back ` +
|
||||
`(text may render in the wrong font). Stage a font file whose name starts with the family ` +
|
||||
`(e.g. "TT Norms Pro" → TT_Norms_Pro_Bold.woff2) so it ships with the project.`,
|
||||
);
|
||||
}
|
||||
return faces.join("\n");
|
||||
}
|
||||
|
||||
// frame.md colors:/typography: → a :root token block, mapped to the fixed semantic
|
||||
// vocab every preset skin references. Robust to per-preset key names: colors are
|
||||
// matched by name, then by luminance. Brand-token overlay (Step 2) flows through
|
||||
// because the values come from the project's frame.md. No frame.md → band vars only.
|
||||
function frameTokensCss(framePath, H) {
|
||||
const band = captionBand(H);
|
||||
const out = [];
|
||||
if (existsSync(framePath)) {
|
||||
const md = readFileSync(framePath, "utf8");
|
||||
const colors = parseColors(md);
|
||||
for (const [k, v] of colors) out.push(` --${k}: ${v};`); // raw, for completeness
|
||||
const sem = semanticColors(colors);
|
||||
if (sem.ink) out.push(` --cap-ink: ${sem.ink};`);
|
||||
if (sem.canvas) out.push(` --cap-canvas: ${sem.canvas};`);
|
||||
if (sem.accent) out.push(` --cap-accent: ${sem.accent};`);
|
||||
if (sem.accent2) out.push(` --cap-accent-2: ${sem.accent2};`);
|
||||
const { display, body } = parseFonts(md);
|
||||
if (display) out.push(` --font-display: ${display}, system-ui, serif;`);
|
||||
if (body) out.push(` --font-body: ${body}, system-ui, sans-serif;`);
|
||||
}
|
||||
out.push(` --cap-band-top: ${band.bandTopY}px;`);
|
||||
out.push(` --cap-band-height: ${band.bandHeight}px;`);
|
||||
return ` :root {\n${out.join("\n")}\n }`;
|
||||
}
|
||||
|
||||
// ── default path (no preset skin) ─────────────────────────────────────────────
|
||||
// Self-contained captions sub-composition. The <template> holds the band container
|
||||
// + style AND the <script> (the HyperFrames loader only executes scripts INSIDE the
|
||||
// cloned template — a sibling <script> after </template> never runs, so the timeline
|
||||
// never registers and captions render blank). The script builds per-word spans and a
|
||||
// paused, seek-safe GSAP timeline (opacity for group show/hide, a quick color tween
|
||||
// per word for the karaoke highlight — no className flips, no JS state) and ends each
|
||||
// group with a hard tl.set kill so an exit can't get stuck. gsap is loaded via CDN
|
||||
// inside the template (matching the frame compositions). Band = captionBand(H).
|
||||
function buildCaptionsHtml(groups, total, W, H) {
|
||||
const band = captionBand(H);
|
||||
const fs = Math.round(H * 0.038);
|
||||
const pad = Math.round(fs * 0.4);
|
||||
return `<template id="captions-template">
|
||||
<div
|
||||
data-composition-id="captions"
|
||||
data-width="${W}"
|
||||
data-height="${H}"
|
||||
data-duration="${total}"
|
||||
id="captions-root"
|
||||
>
|
||||
<div id="cap"></div>
|
||||
</div>
|
||||
<style>
|
||||
#captions-root {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
#cap {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: ${band.bandTopY}px;
|
||||
height: ${band.bandHeight}px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.caption-group {
|
||||
position: absolute;
|
||||
max-width: 80%;
|
||||
padding: ${pad}px ${Math.round(pad * 1.8)}px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
border-radius: ${Math.round(fs * 0.3)}px;
|
||||
font-family: Roboto, sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: ${fs}px;
|
||||
line-height: 1.25;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
opacity: 0;
|
||||
}
|
||||
.caption-word {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
</style>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js" integrity="sha384-sG0Hv1tP1lZCk9KQmrIbY/XNwi+OY84GQqhMscbnsoBFqAz8KNCil1kvfL3Hbbk2" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var GROUPS = ${JSON.stringify(groups)};
|
||||
var cap = document.getElementById("cap");
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
GROUPS.forEach(function (g) {
|
||||
var el = document.createElement("div");
|
||||
el.className = "caption-group";
|
||||
g.words.forEach(function (w) {
|
||||
var s = document.createElement("span");
|
||||
s.className = "caption-word";
|
||||
s.textContent = w.text + " ";
|
||||
el.appendChild(s);
|
||||
});
|
||||
cap.appendChild(el);
|
||||
tl.fromTo(el, { opacity: 0 }, { opacity: 1, duration: 0.18, overwrite: "auto" }, g.start);
|
||||
tl.to(el, { opacity: 0, duration: 0.12, overwrite: "auto" }, g.end);
|
||||
tl.set(el, { opacity: 0, visibility: "hidden" }, g.end + 0.12); // deterministic hard kill
|
||||
g.words.forEach(function (w, i) {
|
||||
tl.to(el.children[i], { color: "#ffffff", duration: 0.06 }, w.start);
|
||||
});
|
||||
});
|
||||
tl.to({}, { duration: ${total} }, 0); // full-span anchor
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["captions"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</template>
|
||||
`;
|
||||
}
|
||||
|
||||
const sub = process.argv[2];
|
||||
if (sub === "build" || sub === undefined) runBuild(process.argv.slice(sub === "build" ? 3 : 2));
|
||||
else {
|
||||
console.error(
|
||||
"usage: node captions.mjs build [--storyboard …] [--audio-meta …] [--hyperframes .]",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// assets.mjs — stage frame-named capture assets into assets/.
|
||||
// Shared by stage-assets.mjs (Step 4 close, BEFORE the frame workers run) and
|
||||
// assemble-index.mjs (Step 5, idempotent backstop). Only assets a frame names
|
||||
// in `asset_candidates` are staged; unnamed assets never reach the project.
|
||||
// asset_candidates value form: "assets/<basename> — desc; assets/… — …".
|
||||
|
||||
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
|
||||
export function basenamesFromCandidates(value) {
|
||||
if (typeof value !== "string") return [];
|
||||
return value
|
||||
.split(";")
|
||||
.map((seg) => seg.split(/\s+[—–-]\s+/)[0].trim()) // strip the " — description"
|
||||
.filter(Boolean)
|
||||
.map((p) => basename(p.replace(/^assets\//, "")));
|
||||
}
|
||||
|
||||
// Copy each frame's asset_candidates from capture/{assets,assets/videos,
|
||||
// screenshots} into assets/. Already-staged files are left as is (first-wins),
|
||||
// so calling this twice is safe. Returns { staged, wanted, anomalies }.
|
||||
export function stageAssets({ hyperframesDir, frames }) {
|
||||
const wanted = new Set();
|
||||
for (const f of frames) {
|
||||
for (const b of basenamesFromCandidates(f.extra?.asset_candidates)) wanted.add(b);
|
||||
}
|
||||
const captureDirs = [
|
||||
join(hyperframesDir, "capture/assets"),
|
||||
join(hyperframesDir, "capture/assets/videos"), // videos download into a subdir
|
||||
join(hyperframesDir, "capture/screenshots"),
|
||||
];
|
||||
const assetsDir = join(hyperframesDir, "assets");
|
||||
const anomalies = [];
|
||||
let staged = 0;
|
||||
if (wanted.size > 0) {
|
||||
mkdirSync(assetsDir, { recursive: true });
|
||||
for (const b of wanted) {
|
||||
const dest = join(assetsDir, b);
|
||||
if (existsSync(dest)) {
|
||||
staged++;
|
||||
continue;
|
||||
} // first-wins / already staged
|
||||
const src = captureDirs.map((d) => join(d, b)).find((p) => existsSync(p));
|
||||
if (src) {
|
||||
copyFileSync(src, dest);
|
||||
staged++;
|
||||
} else {
|
||||
anomalies.push(
|
||||
`asset "${b}" named by a frame but not found under capture/ — frame will 404 it`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { staged, wanted, anomalies };
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// dimensions.mjs — canvas size + caption-band geometry for the video
|
||||
// pipeline. Single source of truth = the STORYBOARD frontmatter `format` global
|
||||
// ("1920x1080" / "1080x1920" / "1080x1080", or a named orientation). Every
|
||||
// script and the index assembler reads the size from here; none hardcodes it.
|
||||
|
||||
// Named orientation presets. Square/portrait are 1080-based so they share the
|
||||
// long-edge pixel budget with landscape (same render-cost ballpark).
|
||||
export const ORIENTATION_PRESETS = {
|
||||
landscape: { width: 1920, height: 1080 }, // 16:9 — default
|
||||
portrait: { width: 1080, height: 1920 }, // 9:16 — reels / shorts / TikTok
|
||||
square: { width: 1080, height: 1080 }, // 1:1 — feed
|
||||
};
|
||||
|
||||
export const DEFAULT_DIMENSIONS = ORIENTATION_PRESETS.landscape;
|
||||
|
||||
function sane(w, h) {
|
||||
return Number.isFinite(w) && Number.isFinite(h) && w >= 240 && h >= 240 && w <= 8192 && h <= 8192;
|
||||
}
|
||||
|
||||
// Parse a STORYBOARD `format` global into { width, height, source }. Accepts
|
||||
// "WxH" (e.g. "1920x1080"; `x` or `×`, any inner spacing) or a named orientation;
|
||||
// falls back to landscape so a storyboard with a missing/garbled format still
|
||||
// renders (no behavior change vs the old landscape lock).
|
||||
export function parseFormat(format) {
|
||||
const s = typeof format === "string" ? format.trim().toLowerCase() : "";
|
||||
if (ORIENTATION_PRESETS[s]) return { ...ORIENTATION_PRESETS[s], source: `orientation=${s}` };
|
||||
const m = s.match(/^(\d+)\s*[x×]\s*(\d+)$/);
|
||||
if (m) {
|
||||
const w = parseInt(m[1] ?? "", 10);
|
||||
const h = parseInt(m[2] ?? "", 10);
|
||||
if (sane(w, h)) return { width: w, height: h, source: "format" };
|
||||
}
|
||||
return { ...DEFAULT_DIMENSIONS, source: "default(landscape)" };
|
||||
}
|
||||
|
||||
// Caption band geometry, derived from canvas height: the bottom ~16.67% (180px
|
||||
// at h=1080). Frame content must end `safetyPx` above the band top. Holds even
|
||||
// when captions are disabled (bottom-edge consistency).
|
||||
export const CAPTION_BAND_FRACTION = 0.1667;
|
||||
export function captionBand(height, safetyPx = 20) {
|
||||
const h = Number.isFinite(height) ? height : DEFAULT_DIMENSIONS.height;
|
||||
const bandHeight = Math.round(h * CAPTION_BAND_FRACTION);
|
||||
const bandTopY = h - bandHeight; // foreground must end at/above this y
|
||||
return { bandHeight, bandTopY, foregroundMaxY: bandTopY - safetyPx };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// pad-frame-duration.mjs — keeps a frame's own #root/clip data-duration in
|
||||
// sync with the padded index.html wrapper duration transitions.mjs computes.
|
||||
//
|
||||
// The frame's OWN internal file declares its #root/clip data-duration to the
|
||||
// STORYBOARD's content-only length (frame-worker.md: duration is "fixed
|
||||
// upstream"). When an outgoing transition pads the index.html WRAPPER's
|
||||
// data-duration to cover the transition tail, the frame's own internal
|
||||
// duration is left short — the render engine clip-gates the sub-composition's
|
||||
// visible content at that shorter value, so content vanishes abruptly at
|
||||
// content-end instead of fading gracefully through the wrapper's extended
|
||||
// fade-out tween. Pad the frame's own file to match so both durations agree.
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
export function padFrameInternalDuration(hyperframesDir, frameSrc, frameId, newDuration) {
|
||||
const framePath = resolve(hyperframesDir, frameSrc);
|
||||
let html;
|
||||
try {
|
||||
html = readFileSync(framePath, "utf8");
|
||||
} catch (err) {
|
||||
if (err?.code === "ENOENT") return;
|
||||
throw err;
|
||||
}
|
||||
const tagRe = /<[a-z][\w:-]*\s[^<>]*?>/gi;
|
||||
let m;
|
||||
while ((m = tagRe.exec(html)) !== null) {
|
||||
const tag = m[0];
|
||||
if (!tag.includes(`data-composition-id="${frameId}"`)) continue;
|
||||
if (!/data-duration="[\d.]+"/.test(tag)) continue;
|
||||
const newTag = tag.replace(/data-duration="[\d.]+"/, `data-duration="${newDuration}"`);
|
||||
if (newTag === tag) return;
|
||||
writeFileSync(framePath, html.slice(0, m.index) + newTag + html.slice(m.index + tag.length));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// storyboard.mjs — vendored lenient parser for STORYBOARD.md.
|
||||
//
|
||||
// Faithful plain-JS port of @hyperframes/core/storyboard
|
||||
// (packages/core/src/storyboard/parseStoryboard.ts). Vendored because skills
|
||||
// ship standalone: installed via `npx skills add`, a skill's scripts can't reach
|
||||
// the monorepo's core package, and the core export points at .ts source that
|
||||
// `node` (which runs these scripts) can't load. CANONICAL contract = the core
|
||||
// parser + skills/hyperframes-core/references/storyboard-format.md; keep this in
|
||||
// lockstep. Behavior: never throws, accepts freeform narrative, recognizes
|
||||
// Frame/Beat/Scene headings at H2/H3, preserves unknown keys verbatim under
|
||||
// `extra` (keys lowercased). Pure node — no deps.
|
||||
|
||||
export const FRAME_STATUSES = ["outline", "built", "animated"];
|
||||
export const DEFAULT_FRAME_STATUS = "outline";
|
||||
|
||||
// Detection-only frame heading (ends at the keyword); ReDoS-hardened — keep as-is.
|
||||
const FRAME_HEADING_RE = /^(#{2,3})[ \t]+(?:frame|beat|scene)\b/i;
|
||||
const FRAME_TITLE_SEP_RE = /^[\s.:—-]+/;
|
||||
const HEADING_LEVEL_RE = /^(#{1,6})\s+/;
|
||||
const META_RE = /^\s*[-*]\s+([A-Za-z_][\w-]*)\s*:\s*(.+?)\s*$/;
|
||||
const LEADING_INT_RE = /^(\d+)/;
|
||||
const DURATION_NUM_RE = /(\d+(?:\.\d+)?)/;
|
||||
const TRANSITION_KEYS = new Set(["transition_in", "transitionin", "transition"]);
|
||||
const SCENE_KEYS = new Set(["scene", "description", "summary", "caption"]);
|
||||
export const VOICEOVER_ALIASES = ["voiceover", "vo", "voice_over", "narration"];
|
||||
const VOICEOVER_KEYS = new Set(VOICEOVER_ALIASES);
|
||||
|
||||
export function parseStoryboard(source) {
|
||||
const warnings = [];
|
||||
const { globals, bodyStartLine, body } = parseFrontmatter(source, warnings);
|
||||
const frames = parseFrames(body, bodyStartLine, warnings);
|
||||
return { globals, frames, warnings };
|
||||
}
|
||||
|
||||
function emptyGlobals() {
|
||||
return { extra: {} };
|
||||
}
|
||||
|
||||
function isFrameStatus(value) {
|
||||
return FRAME_STATUSES.includes(value);
|
||||
}
|
||||
|
||||
// ── Frontmatter ─────────────────────────────────────────────────────────────
|
||||
function findFrontmatterRange(lines, warnings) {
|
||||
let start = 0;
|
||||
while (start < lines.length && (lines[start] ?? "").trim() === "") start++;
|
||||
if ((lines[start] ?? "").trim() !== "---") return null;
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
if ((lines[i] ?? "").trim() === "---") return { start, end: i };
|
||||
}
|
||||
warnings.push({
|
||||
message: "Frontmatter opening '---' has no closing '---'; treating whole file as body.",
|
||||
line: start + 1,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseFrontmatterEntries(lines, start, end, warnings) {
|
||||
const globals = emptyGlobals();
|
||||
for (let i = start + 1; i < end; i++) {
|
||||
const raw = lines[i] ?? "";
|
||||
if (raw.trim() === "") continue;
|
||||
const colon = raw.indexOf(":");
|
||||
if (colon === -1) {
|
||||
warnings.push({
|
||||
message: `Ignored non key:value frontmatter line: "${raw.trim()}"`,
|
||||
line: i + 1,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const key = raw.slice(0, colon).trim().toLowerCase();
|
||||
assignGlobal(globals, key, stripQuotes(raw.slice(colon + 1).trim()));
|
||||
}
|
||||
return globals;
|
||||
}
|
||||
|
||||
function parseFrontmatter(source, warnings) {
|
||||
const lines = source.split(/\r?\n/);
|
||||
const range = findFrontmatterRange(lines, warnings);
|
||||
if (!range) return { globals: emptyGlobals(), bodyStartLine: 1, body: source };
|
||||
const globals = parseFrontmatterEntries(lines, range.start, range.end, warnings);
|
||||
const body = lines.slice(range.end + 1).join("\n");
|
||||
return { globals, bodyStartLine: range.end + 2, body };
|
||||
}
|
||||
|
||||
function assignGlobal(globals, key, value) {
|
||||
switch (key) {
|
||||
case "format":
|
||||
globals.format = value;
|
||||
break;
|
||||
case "message":
|
||||
globals.message = value;
|
||||
break;
|
||||
case "arc":
|
||||
globals.arc = value;
|
||||
break;
|
||||
case "audience":
|
||||
globals.audience = value;
|
||||
break;
|
||||
default:
|
||||
globals.extra[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Frames ──────────────────────────────────────────────────────────────────
|
||||
function openFrameSection(line, headingLine) {
|
||||
const match = FRAME_HEADING_RE.exec(line);
|
||||
if (!match) return null;
|
||||
const headingText = line.slice(match[0].length).replace(FRAME_TITLE_SEP_RE, "").trim();
|
||||
return { headingText, headingLine, level: (match[1] ?? "##").length, lines: [] };
|
||||
}
|
||||
|
||||
function endsFrameSection(line, current) {
|
||||
if (!current) return false;
|
||||
const heading = HEADING_LEVEL_RE.exec(line);
|
||||
return heading !== null && (heading[1] ?? "").length <= current.level;
|
||||
}
|
||||
|
||||
function parseFrames(body, bodyStartLine, warnings) {
|
||||
const lines = body.split(/\r?\n/);
|
||||
const sections = [];
|
||||
let current = null;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? "";
|
||||
const opened = openFrameSection(line, bodyStartLine + i);
|
||||
if (opened) {
|
||||
sections.push(opened);
|
||||
current = opened;
|
||||
} else if (endsFrameSection(line, current)) {
|
||||
current = null;
|
||||
} else if (current) {
|
||||
current.lines.push(line);
|
||||
}
|
||||
}
|
||||
return sections.map((section, idx) => buildFrame(section, idx + 1, warnings));
|
||||
}
|
||||
|
||||
function buildFrame(section, index, warnings) {
|
||||
const frame = { index, status: DEFAULT_FRAME_STATUS, narrative: "", extra: {} };
|
||||
const { number, title } = parseHeading(section.headingText);
|
||||
if (number !== undefined) frame.number = number;
|
||||
if (title) frame.title = title;
|
||||
|
||||
const narrativeLines = [];
|
||||
for (const line of section.lines) {
|
||||
const meta = META_RE.exec(line);
|
||||
if (meta) {
|
||||
applyMeta(
|
||||
frame,
|
||||
(meta[1] ?? "").toLowerCase(),
|
||||
(meta[2] ?? "").trim(),
|
||||
section.headingLine,
|
||||
warnings,
|
||||
);
|
||||
} else {
|
||||
narrativeLines.push(line);
|
||||
}
|
||||
}
|
||||
frame.narrative = narrativeLines.join("\n").trim();
|
||||
return frame;
|
||||
}
|
||||
|
||||
function parseHeading(text) {
|
||||
if (!text) return {};
|
||||
const intMatch = LEADING_INT_RE.exec(text);
|
||||
if (!intMatch) return { title: text };
|
||||
const number = Number.parseInt(intMatch[1] ?? "", 10);
|
||||
const rest = text
|
||||
.slice((intMatch[0] ?? "").length)
|
||||
.replace(/^[\s.:—-]+/, "")
|
||||
.trim();
|
||||
return { number, title: rest || undefined };
|
||||
}
|
||||
|
||||
// Dispatch a recognized metadata key to its field, else stash under `extra`.
|
||||
// Mirrors core's META_SETTERS map exactly (direct keys + alias sets).
|
||||
function applyMeta(frame, key, value, headingLine, warnings) {
|
||||
switch (key) {
|
||||
case "duration":
|
||||
applyDuration(frame, value, headingLine, warnings);
|
||||
return;
|
||||
case "status":
|
||||
applyStatus(frame, value, headingLine, warnings);
|
||||
return;
|
||||
case "poster":
|
||||
applyPoster(frame, value);
|
||||
return;
|
||||
case "src":
|
||||
frame.src = value;
|
||||
return;
|
||||
}
|
||||
if (TRANSITION_KEYS.has(key)) {
|
||||
frame.transitionIn = value;
|
||||
return;
|
||||
}
|
||||
if (SCENE_KEYS.has(key)) {
|
||||
frame.scene = value;
|
||||
return;
|
||||
}
|
||||
if (VOICEOVER_KEYS.has(key)) {
|
||||
frame.voiceover = stripQuotes(value);
|
||||
return;
|
||||
}
|
||||
frame.extra[key] = value;
|
||||
}
|
||||
|
||||
function applyPoster(frame, value) {
|
||||
const num = DURATION_NUM_RE.exec(value);
|
||||
if (num) frame.poster = Number.parseFloat(num[1] ?? "");
|
||||
}
|
||||
|
||||
function applyDuration(frame, value, headingLine, warnings) {
|
||||
frame.duration = value;
|
||||
const num = DURATION_NUM_RE.exec(value);
|
||||
if (num) {
|
||||
frame.durationSeconds = Number.parseFloat(num[1] ?? "");
|
||||
return;
|
||||
}
|
||||
warnings.push({
|
||||
message: `Frame ${frame.index}: could not parse duration "${value}".`,
|
||||
line: headingLine,
|
||||
frameIndex: frame.index,
|
||||
});
|
||||
}
|
||||
|
||||
function applyStatus(frame, value, headingLine, warnings) {
|
||||
const normalized = value.toLowerCase();
|
||||
if (isFrameStatus(normalized)) {
|
||||
frame.status = normalized;
|
||||
return;
|
||||
}
|
||||
frame.extra.status = value;
|
||||
warnings.push({
|
||||
message: `Frame ${frame.index}: unknown status "${value}"; defaulting to "${DEFAULT_FRAME_STATUS}".`,
|
||||
line: headingLine,
|
||||
frameIndex: frame.index,
|
||||
});
|
||||
}
|
||||
|
||||
function stripQuotes(value) {
|
||||
if (value.length >= 2) {
|
||||
const first = value[0];
|
||||
const last = value[value.length - 1];
|
||||
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// tokens.mjs — shared brand-token parsing + semantic role mapping for frame.md /
|
||||
// FRAME.md. Used by build-frame.mjs (remix a preset onto brand tokens) and
|
||||
// captions.mjs (derive caption colors from frame.md). One mapping → frames and
|
||||
// captions stay consistent. Pure node.
|
||||
|
||||
// Collect `key: value` pairs under the top-level `colors:` block (until dedent).
|
||||
export function parseColors(md) {
|
||||
const out = [];
|
||||
let inBlock = false;
|
||||
for (const line of md.split(/\r?\n/)) {
|
||||
if (/^colors:\s*$/.test(line)) {
|
||||
inBlock = true;
|
||||
continue;
|
||||
}
|
||||
if (!inBlock) continue;
|
||||
if (/^\S/.test(line)) break; // dedent to a top-level key → end of block
|
||||
const m = line.match(
|
||||
/^\s+([\w-]+):\s*(?:"([^"]+)"|'([^']+)'|(#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)|[^#\s][^#\n]*?))\s*(?:#.*)?$/,
|
||||
);
|
||||
if (m) out.push([m[1], (m[2] ?? m[3] ?? m[4]).trim()]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// relative luminance of a #rrggbb (null for non-hex like rgba()).
|
||||
export function lum(v) {
|
||||
const m = /^#?([0-9a-fA-F]{6})$/.exec(String(v).trim());
|
||||
if (!m) return null;
|
||||
const n = parseInt(m[1], 16);
|
||||
return 0.2126 * ((n >> 16) & 255) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255);
|
||||
}
|
||||
|
||||
// chroma (max−min channel) of a #rrggbb — a cheap "how colorful" proxy; −1 for non-hex.
|
||||
export function chroma(v) {
|
||||
const m = /^#?([0-9a-fA-F]{6})$/.exec(String(v).trim());
|
||||
if (!m) return -1;
|
||||
const n = parseInt(m[1], 16);
|
||||
const r = (n >> 16) & 255,
|
||||
g = (n >> 8) & 255,
|
||||
b = n & 255;
|
||||
return Math.max(r, g, b) - Math.min(r, g, b);
|
||||
}
|
||||
|
||||
// Browser user-agent default colors for links / visited links. These leak into a
|
||||
// capture from any UNSTYLED <a> and are NOT brand colors — but being pure & saturated
|
||||
// they beat a real accent on chroma alone. Never let one become the accent.
|
||||
export const UA_DEFAULT_COLORS = new Set(
|
||||
["#0000EE", "#0000FF", "#0000CC", "#1A0DAB", "#551A8B", "#EE0000"].map((c) => c.toUpperCase()),
|
||||
);
|
||||
|
||||
// Semantic STATUS roles (green "positive", red "negative"/"error", amber "warning" …). Their HUE
|
||||
// carries the meaning, so they are never a brand ACCENT — a status red is frequently the most
|
||||
// chromatic color in a palette (e.g. #dc2626 chroma 182 beats a deep-blue accent #1E40AF chroma
|
||||
// 145) and would otherwise win a pure chroma ranking, painting captions/highlights the error red.
|
||||
// build-frame.mjs uses this same key set to protect status colors during the preset→brand remix.
|
||||
export const STATUS_ROLE_KEY =
|
||||
/(?:^|[-_])(?:positive|negative|success|error|warning|danger|good|bad|up|down|info|neutral|alert|caution|critical)(?:[-_]|$)/i;
|
||||
|
||||
// Pick the brand ACCENT — never by raw chroma alone, never a UA-default link color.
|
||||
// Priority:
|
||||
// 1) with capture colorStats → the colorful color that RECURS across the UI. The brand
|
||||
// accent shows up in MANY roles (link text + icon + button + badge), whereas a one-off
|
||||
// CTA fill appears in just one. So rank chromatic (chroma>40) candidates by role
|
||||
// diversity first, then total prevalence, then interactive use, then chroma. This keeps
|
||||
// a pervasive brand color (e.g. an indigo used everywhere) ahead of a single bright
|
||||
// button fill (e.g. a lime used once) — the old "top interactiveBg" rule picked the
|
||||
// latter. Requiring interactiveBg>0 is dropped so a text/icon-only accent can still win.
|
||||
// 2) no stats → most chromatic color AFTER removing UA defaults + `exclude`.
|
||||
// A stray default link color (e.g. #0000EE) can win under neither path.
|
||||
export function pickAccent(stats, colors, exclude = []) {
|
||||
const ban = new Set([...exclude, ...UA_DEFAULT_COLORS].map((c) => String(c).toUpperCase()));
|
||||
const ok = (h) => /^#[0-9a-fA-F]{6}$/.test(String(h)) && !ban.has(String(h).toUpperCase());
|
||||
// Prominence rank from the (frequency-ordered) `colors` palette: index 0 = most used.
|
||||
// A saturated color sitting at the TAIL is almost always a one-off (a single CTA fill),
|
||||
// not the brand accent — capture colorStats counts are too sparse to tell these apart
|
||||
// (e.g. Linear's indigo and a lime CTA both register count≈1), but palette ORDER does.
|
||||
const rank = new Map((colors ?? []).map((h, i) => [String(h).toUpperCase(), i]));
|
||||
const prom = (h) => (rank.has(String(h).toUpperCase()) ? rank.get(String(h).toUpperCase()) : 1e9);
|
||||
if (Array.isArray(stats) && stats.length) {
|
||||
const roles = (s) =>
|
||||
((s.interactiveBg || 0) > 0 ? 1 : 0) +
|
||||
((s.textCount || 0) > 0 ? 1 : 0) +
|
||||
((s.bgCount || 0) > 0 ? 1 : 0);
|
||||
const a = stats
|
||||
.filter((s) => ok(s?.hex) && chroma(s.hex) > 40)
|
||||
.sort(
|
||||
(x, y) =>
|
||||
roles(y) - roles(x) || // used in MORE roles (link+icon+button) = the brand accent
|
||||
prom(x.hex) - prom(y.hex) || // earlier in the palette = more prominent
|
||||
(y.count || 0) - (x.count || 0) ||
|
||||
(y.interactiveBg || 0) - (x.interactiveBg || 0) ||
|
||||
chroma(y.hex) - chroma(x.hex),
|
||||
);
|
||||
if (a.length) return a[0].hex;
|
||||
}
|
||||
const c = (colors ?? [])
|
||||
.map(String)
|
||||
.filter(ok)
|
||||
.sort((x, y) => chroma(y) - chroma(x));
|
||||
return c[0];
|
||||
}
|
||||
|
||||
// Derive brand roles from rich capture colorStats (areaBg / interactiveBg / textCount /
|
||||
// maxArea) — by semantic FUNCTION, not luminance/chroma proxies. Returns null when stats
|
||||
// are unusable, so the caller can fall back. canvas = the color painting the most real
|
||||
// background area (the page ground, dark or light); ink = the dominant text color that
|
||||
// actually contrasts with the canvas; accent via pickAccent.
|
||||
export function brandRolesFromStats(stats, colorsInOrder) {
|
||||
if (!Array.isArray(stats) || !stats.length) return null;
|
||||
const v = stats.filter((s) => /^#[0-9a-fA-F]{6}$/.test(s?.hex || ""));
|
||||
if (!v.length) return null;
|
||||
const canvas = [...v].sort(
|
||||
(a, b) =>
|
||||
(b.areaBg || 0) - (a.areaBg || 0) ||
|
||||
(b.maxArea || 0) - (a.maxArea || 0) ||
|
||||
(b.bgCount || 0) - (a.bgCount || 0),
|
||||
)[0]?.hex;
|
||||
// pass the frequency-ordered palette (tokens.colors) so pickAccent can use palette
|
||||
// PROMINENCE — colorStats counts alone are too sparse to rank rare accents.
|
||||
const accent = pickAccent(v, colorsInOrder ?? v.map((s) => s.hex), [canvas]);
|
||||
if (!canvas || !accent) return null;
|
||||
const cl = lum(canvas) ?? 0;
|
||||
const ink =
|
||||
[...v]
|
||||
.filter((s) => s.hex !== canvas && s.hex !== accent)
|
||||
.sort((a, b) => (b.textCount || 0) - (a.textCount || 0))
|
||||
.find((s) => Math.abs((lum(s.hex) ?? 0) - cl) > 64)?.hex ??
|
||||
(cl > 128 ? "#000000" : "#FFFFFF");
|
||||
const accent2 =
|
||||
v
|
||||
.filter(
|
||||
(s) =>
|
||||
![canvas, ink, accent].includes(s.hex) &&
|
||||
(s.interactiveBg || 0) > 0 &&
|
||||
chroma(s.hex) > 40 &&
|
||||
!UA_DEFAULT_COLORS.has(s.hex.toUpperCase()),
|
||||
)
|
||||
.sort((a, b) => (b.interactiveBg || 0) - (a.interactiveBg || 0))[0]?.hex ?? accent;
|
||||
return { ink, canvas, accent, accent2 };
|
||||
}
|
||||
|
||||
// Map a list of [key, value] colors to semantic roles. ink = a dark/ink-named
|
||||
// color (else darkest); canvas = a paper/cream/white-named color (else lightest);
|
||||
// accents = whatever's left, ranked by chroma (the loudest color is almost always
|
||||
// the brand accent) — UA-default link colors AND semantic status colors (positive/
|
||||
// negative/error…) excluded so neither a stray <a> color nor a status red ever wins.
|
||||
// For an unkeyed brand list, pass synthetic keys — name matching simply no-ops and it
|
||||
// falls back to luminance/chroma, which is what we want. NOTE: when capture colorStats
|
||||
// exist, prefer brandRolesFromStats() — it picks by function, not these proxies.
|
||||
export function semanticColors(colors) {
|
||||
if (!colors.length) return {};
|
||||
const named = (re) => colors.find(([k]) => re.test(k));
|
||||
const hexes = colors.filter(([, v]) => lum(v) != null);
|
||||
const byLum = [...hexes].sort((a, b) => (lum(a[1]) ?? 1e9) - (lum(b[1]) ?? 1e9));
|
||||
const pick = (m, fallback) => (m ? m[1] : fallback ? fallback[1] : undefined);
|
||||
// "ink" must be a whole word-segment so "soft-pink"/"pink" don't match it.
|
||||
const ink = pick(
|
||||
named(/(?:^|[-_])ink(?:[-_]|$)|black|charcoal|^text(?:-dark)?$|outline|noir/i),
|
||||
byLum[0] ?? colors[0],
|
||||
);
|
||||
const canvas = pick(
|
||||
named(/cream|paper|canvas|white|bg|ground|surface|base|sand|parchment|off-?white|bone/i),
|
||||
byLum[byLum.length - 1] ?? colors[colors.length - 1],
|
||||
);
|
||||
const accents = colors
|
||||
.filter(
|
||||
([k, v]) =>
|
||||
v !== ink &&
|
||||
v !== canvas &&
|
||||
!UA_DEFAULT_COLORS.has(String(v).toUpperCase()) &&
|
||||
!STATUS_ROLE_KEY.test(k), // a status red/green carries meaning by hue — never an accent
|
||||
)
|
||||
.sort((a, b) => chroma(b[1]) - chroma(a[1]))
|
||||
.map(([, v]) => v);
|
||||
return { ink, canvas, accent: accents[0] ?? ink, accent2: accents[1] ?? accents[0] ?? ink };
|
||||
}
|
||||
|
||||
// Collect role→fontFamily under the top-level `typography:` block; pick a display
|
||||
// + body family from the usual role names. Returns quoted families (or null).
|
||||
export function parseFonts(md) {
|
||||
const roles = {};
|
||||
let inBlock = false;
|
||||
for (const line of md.split(/\r?\n/)) {
|
||||
if (/^typography:\s*$/.test(line)) {
|
||||
inBlock = true;
|
||||
continue;
|
||||
}
|
||||
if (!inBlock) continue;
|
||||
if (/^\S/.test(line)) break;
|
||||
const m = line.match(/^\s+([\w-]+):\s*\{[^}]*fontFamily:\s*"([^"]+)"/);
|
||||
if (m) roles[m[1]] = m[2];
|
||||
}
|
||||
const q = (s) => (s ? `"${s}"` : null);
|
||||
const body = roles.body ?? roles.subtitle ?? Object.values(roles)[0];
|
||||
const display =
|
||||
roles.display ??
|
||||
roles.headline ??
|
||||
roles["card-headline"] ??
|
||||
roles["section-headline"] ??
|
||||
roles["quote-display"] ??
|
||||
roles.h1 ??
|
||||
roles.h2 ??
|
||||
roles.title ??
|
||||
roles.hero ??
|
||||
body;
|
||||
// the monospace / chrome family (code, tags, ticks, page numbers) — so the remix can
|
||||
// route a captured brand mono (Berkeley Mono, JetBrains Mono…) onto this role instead
|
||||
// of the reading body. null when the preset has no distinct mono role.
|
||||
const mono =
|
||||
roles.mono ??
|
||||
roles["mono-tag"] ??
|
||||
roles["mono-chrome"] ??
|
||||
roles["mono-tick"] ??
|
||||
roles.code ??
|
||||
roles.data ??
|
||||
roles.pagenum ??
|
||||
null;
|
||||
return { display: q(display), body: q(body), mono: q(mono) };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// transition-registry.mjs — loader for this skill's vendored transition registry
|
||||
// (./transitions.json). The registry is the curated Tier-B subset (transform /
|
||||
// opacity / filter on the two frame clip wrappers `#el-<id>`, no overlay DOM) +
|
||||
// each type's GSAP template. Vendored into the skill so it ships standalone; the
|
||||
// recipes originate from the shared catalog skills/hyperframes-animation/
|
||||
// transitions/ (css-*.md) — keep them in step if those shared recipes change.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
export const DEFAULT_REGISTRY_PATH = resolve(here, "./transitions.json");
|
||||
|
||||
let _cache = null;
|
||||
|
||||
export function loadTransitionRegistry(registryPath = DEFAULT_REGISTRY_PATH) {
|
||||
if (_cache && _cache.path === registryPath) return _cache.data;
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(readFileSync(registryPath, "utf8"));
|
||||
} catch (e) {
|
||||
throw new Error(`transition registry not loadable at ${registryPath}: ${e.message}`);
|
||||
}
|
||||
if (!Array.isArray(data.transitions) || data.transitions.length === 0) {
|
||||
throw new Error(`transition registry ${registryPath} has no transitions[]`);
|
||||
}
|
||||
_cache = { path: registryPath, data };
|
||||
return data;
|
||||
}
|
||||
|
||||
// Convenience: a Map name -> transition record.
|
||||
export function transitionsByName(registryPath = DEFAULT_REGISTRY_PATH) {
|
||||
const data = loadTransitionRegistry(registryPath);
|
||||
const map = new Map();
|
||||
for (const t of data.transitions) map.set(t.name, t);
|
||||
return map;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"_comment": "Vendored transition registry for the product-launch workflow — the curated Tier-B subset (transform/opacity/filter on the two frame clip wrappers #el-<id>, no overlay DOM, no per-frame cooperation). Each type carries its GSAP template; the transitions.mjs injector stamps it onto window.__timelines[\"main\"]. Recipes originate from the shared catalog skills/hyperframes-animation/transitions/ (css-*.md) — keep in step if those change. Token placeholders the injector substitutes: __OLD__ (#el-<from>), __NEW__ (#el-<to>), __T__ (overlap-start s), __DUR__ (this boundary's duration), __DX__/__DXIN__ (horizontal travel + incoming offset), __DY__/__DYIN__ (vertical).",
|
||||
"transitions": [
|
||||
{
|
||||
"name": "crossfade",
|
||||
"energy": "any",
|
||||
"default_duration_s": 0.5,
|
||||
"directions": [],
|
||||
"source": "css-dissolve.md",
|
||||
"gsap_template": [
|
||||
"tl.to(__OLD__, { opacity: 0, duration: __DUR__, ease: \"power2.inOut\" }, __T__);",
|
||||
"tl.fromTo(__NEW__, { opacity: 0 }, { opacity: 1, duration: __DUR__, ease: \"power2.inOut\" }, __T__);"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "blur-crossfade",
|
||||
"energy": "calm",
|
||||
"default_duration_s": 0.6,
|
||||
"directions": [],
|
||||
"source": "css-dissolve.md",
|
||||
"note": "Default when the two frames' #root backgrounds differ a lot — the blur masks the background-color clash a plain crossfade would expose.",
|
||||
"gsap_template": [
|
||||
"tl.to(__OLD__, { filter: \"blur(10px)\", scale: 1.03, opacity: 0, duration: __DUR__, ease: \"power2.inOut\" }, __T__);",
|
||||
"tl.fromTo(__NEW__, { filter: \"blur(10px)\", scale: 0.97, opacity: 0 }, { filter: \"blur(0px)\", scale: 1, opacity: 1, duration: __DUR__, ease: \"power2.inOut\" }, __T__);"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "push-slide",
|
||||
"energy": "medium",
|
||||
"default_duration_s": 0.5,
|
||||
"directions": ["LEFT", "RIGHT", "UP", "DOWN"],
|
||||
"default_direction": "LEFT",
|
||||
"source": "css-push.md",
|
||||
"note": "Directional. The injector picks __DX__/__DY__ from the direction and emits the horizontal OR vertical pair (not both).",
|
||||
"gsap_template_horizontal": [
|
||||
"tl.to(__OLD__, { x: __DX__, duration: __DUR__, ease: \"power3.inOut\" }, __T__);",
|
||||
"tl.fromTo(__NEW__, { x: __DXIN__, opacity: 1 }, { x: 0, duration: __DUR__, ease: \"power3.inOut\" }, __T__);"
|
||||
],
|
||||
"gsap_template_vertical": [
|
||||
"tl.to(__OLD__, { y: __DY__, duration: __DUR__, ease: \"power3.inOut\" }, __T__);",
|
||||
"tl.fromTo(__NEW__, { y: __DYIN__, opacity: 1 }, { y: 0, duration: __DUR__, ease: \"power3.inOut\" }, __T__);"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "zoom-through",
|
||||
"energy": "high",
|
||||
"default_duration_s": 0.4,
|
||||
"directions": [],
|
||||
"source": "css-scale.md",
|
||||
"gsap_template": [
|
||||
"tl.to(__OLD__, { scale: 2.5, opacity: 0, filter: \"blur(8px)\", duration: __DUR__, ease: \"power3.in\" }, __T__);",
|
||||
"tl.fromTo(__NEW__, { scale: 0.5, opacity: 0, filter: \"blur(8px)\" }, { scale: 1, opacity: 1, filter: \"blur(0px)\", duration: __DUR__, ease: \"power3.out\" }, __T__);"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "squeeze",
|
||||
"energy": "medium",
|
||||
"default_duration_s": 0.4,
|
||||
"directions": [],
|
||||
"source": "css-push.md",
|
||||
"note": "Old compresses to a vertical line on the left edge; new expands from the right edge. Incoming starts off (scaleX 0) so its higher-track stacking is harmless.",
|
||||
"gsap_template": [
|
||||
"tl.to(__OLD__, { scaleX: 0, transformOrigin: \"left center\", duration: __DUR__, ease: \"power3.inOut\" }, __T__);",
|
||||
"tl.fromTo(__NEW__, { scaleX: 0, transformOrigin: \"right center\", opacity: 1 }, { scaleX: 1, transformOrigin: \"right center\", duration: __DUR__, ease: \"power3.inOut\" }, __T__);"
|
||||
]
|
||||
}
|
||||
],
|
||||
"default_high_energy": "zoom-through",
|
||||
"default_calm": "blur-crossfade",
|
||||
"max_duration_s": 2.0
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
#!/usr/bin/env node
|
||||
// transitions.mjs — inter-frame transition injector + verifier for the video workflow.
|
||||
//
|
||||
// inject — read STORYBOARD frame order + each frame's transition_in, overlap
|
||||
// the frame clip wrappers in index.html, and stamp the GSAP template.
|
||||
// verify — deterministic gate over the injector's output.
|
||||
//
|
||||
// transition_in (written by story-design on the INCOMING frame) names a registry
|
||||
// type directly: crossfade | blur-crossfade | push-slide | zoom-through | squeeze,
|
||||
// optionally "<type> <DIR>" / "<type> <N>s" (e.g. "push-slide LEFT", "crossfade
|
||||
// 0.4s"). `cut` / `none` / empty ⇒ hard cut (no overlap, no stamp).
|
||||
//
|
||||
// Mechanics — EXTEND-OUTGOING-ONLY (keeps voice/SFX/captions synced; their timing
|
||||
// is keyed to the original frame start). At boundary i→i+1 (type = the incoming
|
||||
// frame's transition_in): extend ONLY the outgoing wrapper's data-duration by
|
||||
// `dur` so it holds its final frame across the window; do NOT move any data-start;
|
||||
// the incoming — already present from the cut on a higher track — fades/pushes in
|
||||
// over it. Then 0/1-ping-pong ALL frame clips' data-track-index (adjacent
|
||||
// overlapping wrappers never share a track — lint timeline_track_too_dense) and
|
||||
// stamp the token-substituted GSAP template into __timelines["main"] at T =
|
||||
// incoming start. captions(2)/voice(10)/bgm(11)/sfx(20+) are never touched.
|
||||
//
|
||||
// node transitions.mjs inject --storyboard ./STORYBOARD.md --hyperframes .
|
||||
// node transitions.mjs verify --storyboard ./STORYBOARD.md --index ./index.html
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { parseStoryboard } from "./lib/storyboard.mjs";
|
||||
import { parseFormat } from "./lib/dimensions.mjs";
|
||||
import { loadTransitionRegistry, transitionsByName } from "./lib/transition-registry.mjs";
|
||||
import { padFrameInternalDuration } from "./lib/pad-frame-duration.mjs";
|
||||
|
||||
const flag = (argv, name, def) => {
|
||||
const i = argv.indexOf(`--${name}`);
|
||||
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : def;
|
||||
};
|
||||
const NO_TRANSITION = new Set(["cut", "none", ""]);
|
||||
const r3 = (x) => Number(x.toFixed(3));
|
||||
|
||||
// transition_in → { type, direction?, dur? } | null (hard cut).
|
||||
function parseTransitionIn(raw) {
|
||||
const s = (raw ?? "").trim();
|
||||
if (NO_TRANSITION.has(s.toLowerCase())) return null;
|
||||
const parts = s.split(/\s+/);
|
||||
const spec = { type: parts[0].toLowerCase() };
|
||||
for (const p of parts.slice(1)) {
|
||||
const m = p.match(/^(\d+(?:\.\d+)?)s?$/);
|
||||
if (m) spec.dur = Number(m[1]);
|
||||
else spec.direction = p.toUpperCase();
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
// Mounted STORYBOARD frames present in index.html, in document order: { id, frame }.
|
||||
function mountedFramesInOrder(manifest, html) {
|
||||
const out = [];
|
||||
for (const f of manifest.frames) {
|
||||
if (!f.src) continue;
|
||||
const id = f.src
|
||||
.split("/")
|
||||
.pop()
|
||||
.replace(/\.html?$/i, "");
|
||||
if (html.includes(`id="el-${id}"`)) out.push({ id, frame: f });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Frame clip wrappers parsed out of index.html (ids carry hyphens; excludes
|
||||
// el-captions and audio by keying off the known frame-id set). The id is matched
|
||||
// from anywhere in the tag's attribute list — never assume it is the first attribute
|
||||
// (the index assembler emits data-hf-id before id, so an id-first regex finds nothing
|
||||
// and inject crashes on the empty clip map).
|
||||
function parseFrameClips(html, frameIds) {
|
||||
const clipRe = /<div\b([^>]*)><\/div>/g;
|
||||
const clips = new Map();
|
||||
let m;
|
||||
while ((m = clipRe.exec(html)) !== null) {
|
||||
const attrs = m[1];
|
||||
const idm = attrs.match(/\bid="el-([A-Za-z0-9_-]+)"/);
|
||||
if (!idm || !frameIds.has(idm[1])) continue;
|
||||
const num = (re) => {
|
||||
const x = attrs.match(re);
|
||||
return x ? Number(x[1]) : null;
|
||||
};
|
||||
clips.set(idm[1], {
|
||||
id: idm[1],
|
||||
block: m[0],
|
||||
start: num(/data-start="([\d.]+)"/),
|
||||
duration: num(/data-duration="([\d.]+)"/),
|
||||
track: num(/data-track-index="(\d+)"/) ?? 0,
|
||||
});
|
||||
}
|
||||
return clips;
|
||||
}
|
||||
|
||||
// The host wrapper is extended across an outgoing transition, so the mounted
|
||||
// frame must remain visually populated for the same local-time window. Extend
|
||||
// the frame root and every non-audio timed element that reached the original
|
||||
// storyboard boundary. This also repairs worker files whose root was already
|
||||
// inflated while their ground/content clips still ended at the synced duration.
|
||||
function extendFrameTail(hyperframesDir, frame, baseDuration, targetDuration, die) {
|
||||
if (!frame?.src || targetDuration <= baseDuration) return;
|
||||
const framePath = join(hyperframesDir, frame.src);
|
||||
let html;
|
||||
try {
|
||||
html = readFileSync(framePath, "utf8");
|
||||
} catch {
|
||||
die(`outgoing frame file not found at ${framePath}`);
|
||||
}
|
||||
|
||||
const compId = frame.src
|
||||
.split("/")
|
||||
.pop()
|
||||
.replace(/\.html?$/i, "");
|
||||
const EPS = 0.011;
|
||||
let foundRoot = false;
|
||||
let extended = 0;
|
||||
const rewritten = html.replace(/<([A-Za-z][\w:-]*)\b([^>]*)>/g, (tag, name, attrs) => {
|
||||
const durationMatch = attrs.match(/\bdata-duration="([\d.]+)"/);
|
||||
if (!durationMatch) return tag;
|
||||
const duration = Number(durationMatch[1]);
|
||||
if (!Number.isFinite(duration)) return tag;
|
||||
|
||||
const compositionMatch = attrs.match(/\bdata-composition-id="([^"]+)"/);
|
||||
if (compositionMatch?.[1] === compId && !foundRoot) {
|
||||
foundRoot = true;
|
||||
return tag.replace(/\bdata-duration="[\d.]+"/, `data-duration="${targetDuration}"`);
|
||||
}
|
||||
|
||||
if (name.toLowerCase() === "audio") return tag;
|
||||
const startMatch = attrs.match(/\bdata-start="([\d.]+)"/);
|
||||
if (!startMatch) return tag;
|
||||
const start = Number(startMatch[1]);
|
||||
const end = start + duration;
|
||||
if (end < baseDuration - EPS || end >= targetDuration - EPS) return tag;
|
||||
extended++;
|
||||
return tag.replace(/\bdata-duration="[\d.]+"/, `data-duration="${r3(targetDuration - start)}"`);
|
||||
});
|
||||
|
||||
if (!foundRoot) die(`${frame.src} has no data-composition-id="${compId}" root`);
|
||||
writeFileSync(framePath, rewritten);
|
||||
console.log(
|
||||
` ${compId}: extended root + ${extended} tail clip(s) ${baseDuration}s→${targetDuration}s`,
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve a transition_in spec to a registry record (calm default on unknown).
|
||||
function resolveRecord(spec, byName, reg, warn) {
|
||||
let rec = byName.get(spec.type);
|
||||
if (!rec) {
|
||||
rec = byName.get(reg.default_calm);
|
||||
warn(`transition_in "${spec.type}" not in registry — using ${reg.default_calm}`);
|
||||
}
|
||||
return rec;
|
||||
}
|
||||
function resolveDur(spec, rec, reg) {
|
||||
let dur = spec.dur ?? rec.default_duration_s ?? 0.5;
|
||||
return Math.min(dur, reg.max_duration_s ?? 2.0);
|
||||
}
|
||||
|
||||
// GSAP lines for one transition record (token substitution).
|
||||
function buildGsap(rec, fromId, toId, dur, T, direction, canvasW, canvasH, die) {
|
||||
const subs = {
|
||||
__OLD__: `"#el-${fromId}"`,
|
||||
__NEW__: `"#el-${toId}"`,
|
||||
__T__: String(T),
|
||||
__DUR__: String(dur),
|
||||
};
|
||||
let template;
|
||||
if (rec.directions && rec.directions.length > 0) {
|
||||
const dir = (direction || rec.default_direction || rec.directions[0]).toUpperCase();
|
||||
const vertical = dir === "UP" || dir === "DOWN";
|
||||
template = vertical ? rec.gsap_template_vertical : rec.gsap_template_horizontal;
|
||||
if (!template)
|
||||
die(`transition ${rec.name}: missing ${vertical ? "vertical" : "horizontal"} template`);
|
||||
if (vertical) {
|
||||
const dy = dir === "UP" ? -canvasH : canvasH;
|
||||
subs.__DY__ = String(dy);
|
||||
subs.__DYIN__ = String(-dy);
|
||||
} else {
|
||||
const dx = dir === "LEFT" ? -canvasW : canvasW;
|
||||
subs.__DX__ = String(dx);
|
||||
subs.__DXIN__ = String(-dx);
|
||||
}
|
||||
} else {
|
||||
template = rec.gsap_template;
|
||||
if (!template) die(`transition ${rec.name}: missing gsap_template`);
|
||||
}
|
||||
return template.map((line) => {
|
||||
let out = line;
|
||||
for (const [k, v] of Object.entries(subs)) out = out.split(k).join(v);
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
function runInject(argv) {
|
||||
const hyperframesDir = resolve(flag(argv, "hyperframes", "."));
|
||||
const storyboardPath = resolve(flag(argv, "storyboard", join(hyperframesDir, "STORYBOARD.md")));
|
||||
const indexPath = join(hyperframesDir, "index.html");
|
||||
const die = (msg) => {
|
||||
console.error(`✗ transitions inject: ${msg}`);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
if (!existsSync(storyboardPath)) die(`STORYBOARD.md not found at ${storyboardPath}`);
|
||||
|
||||
const manifest = parseStoryboard(readFileSync(storyboardPath, "utf8"));
|
||||
const { width: CW, height: CH } = parseFormat(manifest.globals.format);
|
||||
const reg = loadTransitionRegistry();
|
||||
const byName = transitionsByName();
|
||||
|
||||
// Read directly and handle ENOENT here, rather than an existsSync precheck —
|
||||
// the check→write pair (write-back below) is a TOCTOU race CodeQL flags.
|
||||
let html = "";
|
||||
try {
|
||||
html = readFileSync(indexPath, "utf8");
|
||||
} catch {
|
||||
die(`index.html not found at ${indexPath} — run assemble-index.mjs first`);
|
||||
}
|
||||
const order = mountedFramesInOrder(manifest, html);
|
||||
if (order.length === 0) die("no frame clips found in index.html");
|
||||
const frameIds = new Set(order.map((x) => x.id));
|
||||
const clips = parseFrameClips(html, frameIds);
|
||||
|
||||
const gsapLines = [];
|
||||
const applied = [];
|
||||
for (let i = 1; i < order.length; i++) {
|
||||
const spec = parseTransitionIn(order[i].frame.transitionIn);
|
||||
if (!spec) continue; // hard cut
|
||||
const incoming = clips.get(order[i].id);
|
||||
const outgoing = clips.get(order[i - 1].id);
|
||||
const rec = resolveRecord(spec, byName, reg, (m) =>
|
||||
console.error(` ! frame ${order[i].id}: ${m}`),
|
||||
);
|
||||
const dur = resolveDur(spec, rec, reg);
|
||||
const T = r3(incoming.start); // cut = incoming start (frames tile)
|
||||
const baseDuration = outgoing.duration;
|
||||
outgoing.duration = r3(baseDuration + dur); // extend outgoing only
|
||||
extendFrameTail(hyperframesDir, order[i - 1].frame, baseDuration, outgoing.duration, die);
|
||||
padFrameInternalDuration(
|
||||
hyperframesDir,
|
||||
order[i - 1].frame.src,
|
||||
outgoing.id,
|
||||
outgoing.duration,
|
||||
);
|
||||
gsapLines.push(
|
||||
...buildGsap(rec, outgoing.id, incoming.id, dur, T, spec.direction, CW, CH, die),
|
||||
);
|
||||
applied.push({ from: outgoing.id, to: incoming.id, type: rec.name, dur, T });
|
||||
}
|
||||
|
||||
if (applied.length === 0) {
|
||||
console.log(`✓ transitions inject: 0 transitions (all cuts) — index.html unchanged`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 0/1 ping-pong all frame clips in play order.
|
||||
const ordered = [...clips.values()].sort((a, b) => a.start - b.start || a.id.localeCompare(b.id));
|
||||
ordered.forEach((c, i) => {
|
||||
c.track = i % 2;
|
||||
});
|
||||
|
||||
// rewrite each clip block: start unchanged; duration possibly extended; track ping-ponged.
|
||||
for (const c of clips.values()) {
|
||||
const nb = c.block
|
||||
.replace(/data-duration="[\d.]+"/, `data-duration="${c.duration}"`)
|
||||
.replace(/data-track-index="\d+"/, `data-track-index="${c.track}"`);
|
||||
html = html.replace(c.block, nb);
|
||||
}
|
||||
|
||||
// stamp the GSAP after the master timeline anchor.
|
||||
const anchor = 'window.__timelines["main"] = gsap.timeline({ paused: true });';
|
||||
if (!html.includes(anchor)) die("master timeline anchor not found in index.html");
|
||||
// The transition tweens alone leave window.__timelines["main"] spanning only the
|
||||
// last transition (e.g. 24.7s), shorter than the real composition. The Studio
|
||||
// reads main.duration() as its master duration and parses clips against it, so a
|
||||
// short master collapses its timeline (clips dropped, duration wrong, blank stage)
|
||||
// — the render engine is unaffected (it trusts the root data-duration attr). Stamp
|
||||
// a full-span anchor so main.duration() == composition total. Mirrors the
|
||||
// `tl.to({}, { duration })` anchor captions.html already uses.
|
||||
const rootDurMatch = html.match(/data-composition-id="main"[^>]*?data-duration="([\d.]+)"/);
|
||||
const totalDur = rootDurMatch ? Number(rootDurMatch[1]) : null;
|
||||
const block = [
|
||||
anchor,
|
||||
" // ── frame transitions (injected by transitions.mjs) ──",
|
||||
' (function () { var tl = window.__timelines["main"];',
|
||||
...gsapLines.map((l) => " " + l),
|
||||
...(totalDur
|
||||
? [
|
||||
` tl.to({}, { duration: ${totalDur} }, 0); // full-span anchor — main.duration() == composition total (Studio master duration)`,
|
||||
]
|
||||
: []),
|
||||
" })();",
|
||||
].join("\n");
|
||||
html = html.replace(anchor, block);
|
||||
|
||||
writeFileSync(indexPath, html);
|
||||
console.log(`✓ transitions inject: ${applied.length} transition(s) stamped into index.html`);
|
||||
for (const a of applied) console.log(` ${a.from}→${a.to}: ${a.type} ${a.dur}s @ T=${a.T}s`);
|
||||
const tracks = ordered
|
||||
.map((c) => `${c.id}[t${c.track} ${c.start}→${r3(c.start + c.duration)}]`)
|
||||
.join(" ");
|
||||
console.log(` tracks: ${tracks}`);
|
||||
}
|
||||
|
||||
function runVerify(argv) {
|
||||
const hyperframesDir = resolve(flag(argv, "hyperframes", "."));
|
||||
const storyboardPath = resolve(flag(argv, "storyboard", join(hyperframesDir, "STORYBOARD.md")));
|
||||
const indexPath = resolve(flag(argv, "index", join(hyperframesDir, "index.html")));
|
||||
const bail = (msg) => {
|
||||
console.error(`✗ transitions verify: ${msg}`);
|
||||
process.exit(1);
|
||||
};
|
||||
|
||||
if (!existsSync(storyboardPath)) bail("STORYBOARD.md not found");
|
||||
if (!existsSync(indexPath)) bail("index.html not found");
|
||||
|
||||
const manifest = parseStoryboard(readFileSync(storyboardPath, "utf8"));
|
||||
const html = readFileSync(indexPath, "utf8");
|
||||
const order = mountedFramesInOrder(manifest, html);
|
||||
const frameIds = new Set(order.map((x) => x.id));
|
||||
const clips = parseFrameClips(html, frameIds);
|
||||
|
||||
const EPS = 0.011;
|
||||
const overlaps = (a, b) =>
|
||||
a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS;
|
||||
const fail = [];
|
||||
|
||||
// (4) global no same-track overlap (the lint invariant).
|
||||
const all = [...clips.values()];
|
||||
for (let i = 0; i < all.length; i++)
|
||||
for (let j = i + 1; j < all.length; j++) {
|
||||
const a = all[i];
|
||||
const b = all[j];
|
||||
if (a.track === b.track && overlaps(a, b))
|
||||
fail.push(`same-track overlap: ${a.id}[t${a.track}] & ${b.id}[t${b.track}]`);
|
||||
}
|
||||
|
||||
const bm = html.match(/frame transitions \(injected[\s\S]*?\}\)\(\);/);
|
||||
const txBlock = bm ? bm[0] : "";
|
||||
|
||||
let expected = 0;
|
||||
for (let i = 1; i < order.length; i++) {
|
||||
const spec = parseTransitionIn(order[i].frame.transitionIn);
|
||||
if (!spec) continue;
|
||||
expected++;
|
||||
const to = clips.get(order[i].id);
|
||||
const from = clips.get(order[i - 1].id);
|
||||
if (!to || !from) {
|
||||
fail.push(`boundary ${order[i - 1].id}→${order[i].id}: wrapper missing`);
|
||||
continue;
|
||||
}
|
||||
if (!txBlock.includes(`"#el-${from.id}"`) || !txBlock.includes(`"#el-${to.id}"`))
|
||||
fail.push(`boundary ${from.id}→${to.id}: injected block does not reference both ids`);
|
||||
const overlapAmt = r3(from.start + from.duration - to.start);
|
||||
if (overlapAmt <= 0) fail.push(`boundary ${from.id}→${to.id}: no overlap (${overlapAmt}s)`);
|
||||
if (from.track === to.track)
|
||||
fail.push(`boundary ${from.id}→${to.id}: both on track ${from.track}`);
|
||||
}
|
||||
if (expected > 0 && !txBlock)
|
||||
fail.push(`${expected} transition(s) expected but no injected block found`);
|
||||
|
||||
if (fail.length) {
|
||||
console.error(`✗ transitions verify: ${fail.length} failure(s):`);
|
||||
for (const f of fail) console.error(` - ${f}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`✓ transitions verify: ${expected} transition(s) verified (cross-track, overlap>0, both ids referenced, no same-track overlap)`,
|
||||
);
|
||||
}
|
||||
|
||||
const sub = process.argv[2];
|
||||
const rest = process.argv.slice(3);
|
||||
if (sub === "inject") runInject(rest);
|
||||
else if (sub === "verify") runVerify(rest);
|
||||
else {
|
||||
console.error("usage: node transitions.mjs <inject|verify> [args...]");
|
||||
process.exit(2);
|
||||
}
|
||||
Reference in New Issue
Block a user