mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
* fix(capture,audio): three defects found running product-launch-video end to end
Found while running the full product-launch-video workflow twice against a real
site (linear.app) to verify PRs #2880/#2881/#2882. All three are independent of
those PRs.
**Scraped SVGs were unusable as files.** `assetDownloader` wrote an inline
`<svg>`'s `outerHTML` straight to `assets/svgs/*.svg`. An inline SVG inherits its
namespace from the HTML parser, so `outerHTML` omits `xmlns` — valid pasted back
into HTML, but not a standalone document, and `<img src="logo-abc.svg">` renders a
broken-image icon. That is exactly how these assets get consumed. `toStandaloneSvg`
now declares the namespace on the way to disk (plus `xmlns:xlink`, but only when an
`xlink:` attribute is actually used). The filename hash moved to the bytes that
land on disk so it still cannot drift from content.
**`sfx: none` became a cue named "none".** `fetch-sfx` split the storyboard's
`sfx:` list and dropped only empty strings, so the absence marker reached the
engine as a real cue that could not resolve. The absence spellings are part of the
storyboard vocabulary; drop them.
**`bgm_pending` was lost translating neutral meta to product-launch meta.** A
detached Lyria/MusicGen generate leaves `bgm: null, bgm_pending: true` until the
track lands. `toProductLaunchMeta` returned only `{bgm, voices, sfx}`, so "not
ready yet" became indistinguishable from "silent by design" — and because
`fetch-sfx` rewrites `audio_meta.json` from the sidecar, a still-generating bed was
snapshotted away with nothing to signal it. The flag now survives, and `fetch-sfx`
warns when it snapshots a pending bed instead of leaving a silent film that the
storyboard claims has music.
Not included, deliberately: `assemble-index.mjs` rewrites `index.html` wholesale
and so discards the block `transitions.mjs inject` wrote, meaning any Step 6 rework
silently loses transitions. Fixing that means deciding whether assemble preserves an
injected block or inject becomes re-appliable — it touches both scripts and the
Step 5/6 ordering in SKILL.md, so it deserves its own change.
Validation: `node --test skills/product-launch-video/scripts/audio.test.mjs`
(13 pass, 5 new) · `vitest run src/capture` (85 pass, 5 new) · `bun run lint:skills`
· oxlint/oxfmt clean · `tsc --noEmit` clean
* feat(capture): re-add the full-page plate a scroll shot needs, at 1x
`product-launch-video` tells a scroll shot to animate a viewport over a full-page
capture. No such file existed: capture emits 15 viewport-sized scroll-position
tiles, and a plate is not substitutable by tiles — a viewport travelling down one
continuous image is the whole point.
An earlier `full-page.png` was dropped in 62b55171e because 1/8 agents read it and
the contact sheet covered the same ground. That measured it as a *comprehension*
artifact, on an eval where nothing was building scroll shots. The scroll shot is a
different consumer, so this brings the plate back — but not as it was, because two
things have to hold for it to be worth having:
- **Taken last.** After the scroll traversal, so lazy images have loaded and
scroll-triggered reveals have fired. A plate shot on arrival is full of blank
bands, which is a good reason for an agent to look once and never again.
- **Sticky chrome neutralised.** `fullPage` bakes a fixed header in at one
position, freezing a nav across the middle of the plate. The viewport tiles keep
sticky on purpose (natural browsing state); the plate cannot. Positions are
recorded and restored in a `finally`, so the extraction passes that run afterwards
see an unmodified DOM.
**1x, deliberately.** 2x is what you'd want to push in without softening text, but
doubling a long marketing page passes Chrome's 16384px screenshot cap precisely on
the pages that most want a scroll shot (linear.app: 10962 CSS px → 21924 at 2x). At
1x a 1920-wide plate is pixel-exact for a 1920x1080 viewport. A frame that needs
headroom captures its own region at 2x instead. Pages over the cap get no plate
rather than a silently clipped one, and the caller falls back to the tiles.
Validation: `vitest run src/capture` — 90 pass (5 new) · oxlint/oxfmt clean ·
`tsc --noEmit` clean
* docs(product-launch-video): point the scroll shot at the plate, make handoff fields binding
Two follow-ups from the same end-to-end runs, now that #2880 and #2881 have landed and
their sentences exist to edit.
**The scroll shot pointed at an artifact that did not exist.** #2881 said "use a 2x
full-page capture and animate the viewport over it". Neither half held: capture emitted
no full-page image, and 2x on a long marketing page passes Chrome's 16384px screenshot
cap precisely on the pages that most want a scroll shot. Both runs watched the agent go
looking, not find it, and improvise — once by re-capturing 2x strips per section, once by
using the native 1920x1080 tiles full-bleed. This PR's capture commit adds the 1x plate,
so the sentence can now name something real: the plate, its absence on pages too tall to
capture in one piece, the tile fallback, and why pushing in past 1:1 still wants a region
capture of its own.
**A constant field was being read as an absent one.** #2880 asks for x/y, scale, opacity
and direction/speed on every handoff. Across two runs on the same model, `opacity` went
0/12 then 12/12 — when the value never changes, leaving it out is a reasonable reading of
the instruction. But downstream an omission and "there is no handoff here" are the same
thing, so the field set has to be stated as binding even when constant. Same clause added
to the worker's side of the contract.
Validation: `bun run lint:skills`
* fix(capture,audio): close the three contract gaps raised in review
Review on #2892 (Rames, Magi) found the fixes correct inside the changed files but
incomplete at the contract level. All three hold up against source; two of the three
were reachable in production, and the plate one was self-inflicted by this PR.
**The plate guard checked a stale height.** `scrollHeight` was measured before the scroll
traversal and handed to the guard, but the plate is deliberately shot *after* it so lazy
content has loaded — and lazy loading grows the document. The guard's input therefore read
low on exactly the long pages it exists for, letting the check pass and a clipped plate
through, undetectable downstream because the skill only teaches the tile fallback when the
file is *absent*. `captureFullPagePlate` now measures the height itself at call time, and
verifies what Chrome actually produced by reading the PNG's IHDR before writing, since the
capture can trigger another round of loading. Over the cap, nothing is emitted.
**Assembly dropped the flag again.** `bgm_pending` survived into `audio_meta.json` but
`assemble-index.mjs` rebuilt its audio object from three named keys, so at the step that
actually builds the film "not ready yet" still looked like "silent by design" — this PR's
own framing of the defect, one layer further down. The flag rides along now, and a pending
bed with no file raises an anomaly instead of quietly assembling a silent cut against a
storyboard that promises music.
**The sibling adapters had both audio bugs, and there were two of them.** The review named
`faceless-explainer`; `pr-to-video` carries the same file. Its own test asserts the two are
byte-identical ("intentionally identical across the reusing skills"), so fixing one alone
broke that test — which is what caught the second copy. Both now carry the absence-sentinel
filter and the surviving `bgm_pending`, and `faceless-explainer` gets the same five
regression tests.
Also from review (Miga): the sticky-restore in `finally` is wrapped, so a page that broke
mid-capture cannot replace the real error with a cleanup one.
Validation: `vitest run src/capture` — 95 pass (5 new) · product-launch audio 13 pass ·
faceless-explainer audio 10 pass (5 new, incl. the byte-identity contract) ·
`bun run lint:skills` · oxlint/oxfmt clean · `tsc --noEmit` clean
* fix(capture,audio): meet the two review asks I under-delivered on
Follow-up to 194fb6995. Re-read Magi's review body rather than working from the summary,
and two of the three blockers were addressed in spirit but not to the letter.
**The plate probed before neutralisation, not after.** 194fb6995 moved the measurement off
the caller's stale value and into the function, but took it before forcing fixed/sticky
elements to `static`. The review called this out specifically and is right: dropping those
elements back into flow grows the document, so the probe could still read under the cap on a
page that is over it once neutralised. The probe now runs after neutralisation and before the
shot, inside the same `try` so restoration still happens on the early return. Added the exact
case asked for — initial height under the cap, final height over it — asserting no
screenshot is taken, no file is written, and the page is still handed back unmodified.
**Assembly warned where the review asked it to refuse.** An anomaly in a list is not
enforcement: assemble is re-run on Step 6 rework, long after the audio step's warning
scrolled past, and a warning still lets a silent film out the door over a snapshot whose own
JSON says the bed is generating. `assemble-index.mjs` now dies on `bgm_pending && !bgm`, with
`--allow-pending-bgm` as the deliberate escape for previewing mid-generate. Pinned with three
tests in a new `assemble-index.test.mjs`: refusal writes no index.html, the escape assembles
and says so, and a film that is silent *by design* still assembles untouched — the
distinction the flag exists to make.
Validation: `vitest run src/capture` — 96 pass (6 new) · product-launch audio 13 pass ·
assemble-index 3 pass (new file) · faceless-explainer audio 10 pass ·
`bun run lint:skills` · oxlint/oxfmt clean · `tsc --noEmit` clean
* fix(audio): carry the bgm_pending gate into the sibling assemblers
The remaining blocker, and one this PR created: the previous commit made all three copies of
the audio adapter *emit* bgm_pending, but only product-launch-video's assembler *reads* it.
So faceless-explainer and pr-to-video would do exactly what this PR set out to stop — parse
an audio_meta.json that says the bed is still generating and assemble the silent film without
a word. Producer fixed in three places, consumer in one, is worse than neither: before this
PR there was no flag to drop.
Both siblings now get the same three changes product-launch-video got — the flag carried
through the audio object, `die` on `bgm_pending && !bgm`, and `--allow-pending-bgm` as the
deliberate escape — plus the same three tests: refusal writes no index.html, the escape
assembles and says so, and a film that is silent *by design* still assembles untouched. That
last one is the one worth having; it proves the flag restored a distinction rather than just
adding a gate.
Applied as three separate patches rather than a file copy: these assemblers have diverged
(pr-to-video validates a bare `<template>` fragment where product-launch takes a `<div>`
root, which its fixture reflects).
`music-to-video` has the fourth copy of this assembler and is deliberately untouched: it has
no audio producer, and its assembler reads `{ voices: [] }` with no bgm path at all, so the
flag can never reach it.
Validation: product-launch / faceless-explainer / pr-to-video assemble-index — 3 pass each ·
product-launch audio 13 pass · faceless-explainer audio 10 pass · `vitest run src/capture`
96 pass · `bun run lint:skills` · oxlint/oxfmt clean · `tsc --noEmit` clean
292 lines
13 KiB
JavaScript
292 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
||
// audio.mjs — product-launch audio ADAPTER. 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, rmSync, 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;
|
||
// bgm_pending must survive the neutral → PL translation. A detached generate (Lyria/MusicGen)
|
||
// leaves `bgm: null, bgm_pending: true` until the track lands; dropping the flag made
|
||
// "not ready yet" indistinguishable from "silent by design", so a later `fetch-sfx` snapshot
|
||
// turned a still-generating bed into no music at all with nothing to signal it.
|
||
const bgmPending = !!neutral.bgm_pending;
|
||
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, bgm_pending: bgmPending, 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 provider = flag(argv, "provider", process.env.HF_TTS_PROVIDER || "auto");
|
||
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,
|
||
}))
|
||
: [];
|
||
// The canonical fully-silent marker (SKILL.md Step 3.1): `music: none` in
|
||
// the storyboard's top YAML block turns BGM off; combined with no SCRIPT.md
|
||
// the project is fully silent — generate nothing and remove any stale meta
|
||
// from a previous run (assemble treats an absent audio_meta.json as silent).
|
||
const bgmOff =
|
||
String(g.extra?.music ?? "")
|
||
.trim()
|
||
.toLowerCase() === "none";
|
||
if (bgmOff && !lines.length) {
|
||
rmSync(outPath, { force: true });
|
||
rmSync(neutralPath(outPath), { force: true });
|
||
console.log(
|
||
"✓ audio generate: project marked silent (music: none, no SCRIPT.md) — nothing to generate",
|
||
);
|
||
return;
|
||
}
|
||
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,
|
||
speed,
|
||
lines,
|
||
bgm: bgmOff
|
||
? { mode: "none" }
|
||
: { 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.
|
||
// `filter(Boolean)` alone is not enough: a storyboard that spells "no SFX here" as
|
||
// `sfx: none` used to reach the engine as a cue literally NAMED "none", which then failed
|
||
// to resolve. The absence sentinels are part of the storyboard vocabulary, so drop them.
|
||
const SFX_NONE = new Set(["none", "no", "n/a", "na", "skip", "-", "—", "–"]);
|
||
const lines = [];
|
||
for (const f of manifest.frames) {
|
||
const names = (f.extra?.sfx ?? "")
|
||
.split(",")
|
||
.map((s) => s.trim())
|
||
.filter((s) => s && !SFX_NONE.has(s.toLowerCase()));
|
||
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}`);
|
||
// This pass rewrites audio_meta.json from the neutral sidecar. If a detached BGM generate is
|
||
// still running, the bed it eventually writes is NOT folded back in — the snapshot we just
|
||
// took has no music. Say so instead of leaving a silent film that the storyboard claims has a
|
||
// bed (observed live: the caller had to notice on its own and rebuild the entry).
|
||
if (meta.bgm_pending && !meta.bgm) {
|
||
console.warn(
|
||
"⚠ audio fetch-sfx: a detached BGM generate is still pending, so this snapshot has no bed. " +
|
||
"Re-run `fetch-sfx` (or re-point audio_meta.json at the track) once it lands, before assembling.",
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── 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
|