Files
heygen-com--hyperframes/skills/faceless-explainer/scripts/captions.mjs
T
wehub-resource-sync 85453da49f
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
chore: import upstream snapshot with attribution
2026-07-13 12:58:35 +08:00

509 lines
24 KiB
JavaScript

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