Setting the file. One moment.
Audio · Product Launch Video · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page function runSyncDurations
— line 238
This file
Number 31.7
Position 7 of 33
Type JavaScript
Size 13 KB
Lines 293 scripts/ audio.mjs
JavaScript · 293 lines · 13 KB
14
// workflow has no wait-bgm step). Runs in the background during Step 4.
15 // sync-durations — write real voice durations into STORYBOARD.md (local).
16 // fetch-sfx — engine --only sfx, merged into the existing meta (Step 5,
17 // after the frames' `sfx:` cues exist).
18 //
19 // node audio.mjs --script ./SCRIPT.md --storyboard ./STORYBOARD.md --hyperframes . --out ./audio_meta.json
20 // node audio.mjs sync-durations --audio-meta ./audio_meta.json --storyboard ./STORYBOARD.md
21 // node audio.mjs fetch-sfx --storyboard ./STORYBOARD.md --hyperframes .
22
23 import { spawnSync } from "node:child_process" ;
24 import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs" ;
25 import { dirname, join, resolve } from "node:path" ;
26 import { fileURLToPath } from "node:url" ;
27 import { parseStoryboard } from "./lib/storyboard.mjs" ;
28
29 const HERE = dirname ( fileURLToPath ( import . meta .url));
30 const DEFAULT_ENGINE = join ( HERE , ".." , ".." , "media-use" , "audio" , "scripts" , "audio.mjs" );
31
32 const flag = ( argv , name , def ) => {
33 const i = argv. indexOf ( `--${ name }` );
34 return i >= 0 && i + 1 < argv. length ? argv[i + 1 ] : def;
35 };
36 const pad2 = ( n ) => String (n). padStart ( 2 , "0" );
37
38 // SCRIPT.md → [{ frame, text }]. `## … (Frame N)` opens a line; `**key:**` rows
39 // are metadata; the indented block is the spoken text (the only TTS input).
40 function parseScript ( md ) {
41 const out = [];
42 let cur = null ;
43 const flush = () => {
44 if (cur && cur.text. trim ()) out. push ({ frame: cur.frame, text: cur.text. trim () });
45 cur = null ;
46 };
47 for ( const line of md. split ( / \r ? \n / )) {
48 const h = line. match ( / ^ # {2,3} \s + . *? \( frame \s + ( \d + ) \) / i );
49 if (h) {
50 flush ();
51 cur = { frame: Number (h[ 1 ]), text: "" };
52 continue ;
53 }
54 if ( ! cur) continue ;
55 if ( / ^ \s * \*\* / . test (line)) continue ;
56 const m = line. match ( / ^ (?: {4,}| \t )( . + ) $ / );
57 if (m) cur.text += (cur.text ? " " : "" ) + m[ 1 ]. trim ();
58 }
59 flush ();
60 return out;
61 }
62
63 // Path of the engine's neutral meta — a stable sidecar so `--only` merges
64 // (generate then fetch-sfx) accumulate, while audio_meta.json holds the PL shape.
65 const neutralPath = ( plOutPath ) => join ( dirname (plOutPath), "audio_engine_meta.json" );
66
67 // Run the shared engine. Returns nothing; dies on a non-zero exit.
68 function runEngine ({ request , hyperframesDir , neutral , only , extra = [] }, die ) {
69 const reqPath = join (hyperframesDir, "audio_request.json" );
70 writeFileSync (reqPath, JSON . stringify (request, null , 2 ));
71 const engine = process.env. HF_MEDIA_ENGINE || DEFAULT_ENGINE ;
72 if ( ! existsSync (engine)) die ( `media audio engine not found at ${ engine } (set $HF_MEDIA_ENGINE)` );
73 const args = [
74 engine,
75 "--request" ,
76 reqPath,
77 "--hyperframes" ,
78 hyperframesDir,
79 "--out" ,
80 neutral,
81 "--only" ,
82 only,
83 ... extra,
84 ];
85 const r = spawnSync ( "node" , args, { stdio: "inherit" });
86 if (r.status !== 0 ) die ( `media audio engine exited ${ r . status }` );
87 }
88
89 // Engine neutral meta (id-keyed) → product-launch meta (frame-keyed) consumed by
90 // captions.mjs / assemble-index.mjs. id is the zero-padded frame number.
91 function toProductLaunchMeta ( neutral ) {
92 const voices = (neutral.voices ?? []). map (( v ) => ({
93 frame: Number (v.id),
94 path: v.path,
95 duration_s: v.duration_s,
96 words: (v.words ?? []). map (( w ) => ({ id: w.id, text: w.text, start: w.start, end: w.end })),
97 }));
98 const bgm = neutral.bgm
99 ? {
100 path: neutral.bgm.path,
101 volume: neutral.bgm.volume,
102 query: neutral.bgm.query ?? null ,
103 duration_s: neutral.bgm.duration_s ?? null ,
104 }
105 : null ;
106 // bgm_pending must survive the neutral → PL translation. A detached generate (Lyria/MusicGen)
107 // leaves `bgm: null, bgm_pending: true` until the track lands; dropping the flag made
108 // "not ready yet" indistinguishable from "silent by design", so a later `fetch-sfx` snapshot
109 // turned a still-generating bed into no music at all with nothing to signal it.
110 const bgmPending = !! neutral.bgm_pending;
111 const sfx = (neutral.sfx ?? []). map (( s ) => ({
112 frame: Number (s.id),
113 file: s.file,
114 offset_s: s.offset_s ?? 0 ,
115 duration_s: s.duration_s ?? 1 ,
116 volume: s.volume ?? 0.35 ,
117 }));
118 return { bgm, bgm_pending: bgmPending, voices, sfx };
119 }
120
121 // ── generate (TTS + BGM) ────────────────────────────────────────────────────
122 function runGenerate ( argv ) {
123 const die = ( m ) => {
124 console. error ( `✗ audio generate: ${ m }` );
125 process. exit ( 1 );
126 };
127 const hyperframesDir = resolve ( flag (argv, "hyperframes" , "." ));
128 const storyboardPath = resolve ( flag (argv, "storyboard" , join (hyperframesDir, "STORYBOARD.md" )));
129 const scriptPath = resolve ( flag (argv, "script" , join (hyperframesDir, "SCRIPT.md" )));
130 const outPath = resolve ( flag (argv, "out" , join (hyperframesDir, "audio_meta.json" )));
131 const userVoice = flag (argv, "voice" , null );
132 const provider = flag (argv, "provider" , process.env. HF_TTS_PROVIDER || "auto" );
133 const speed = Number ( flag (argv, "speed" , "1.0" )) || 1.0 ;
134
135 if ( ! existsSync (storyboardPath)) die ( `STORYBOARD.md not found at ${ storyboardPath }` );
136 const manifest = parseStoryboard ( readFileSync (storyboardPath, "utf8" ));
137 const g = manifest.globals;
138
139 const lines = existsSync (scriptPath)
140 ? parseScript ( readFileSync (scriptPath, "utf8" )). map (( l ) => ({
141 id: pad2 (l.frame),
142 text: l.text,
143 }))
144 : [];
145 // The canonical fully-silent marker (SKILL.md Step 3.1): `music: none` in
146 // the storyboard's top YAML block turns BGM off; combined with no SCRIPT.md
147 // the project is fully silent — generate nothing and remove any stale meta
148 // from a previous run (assemble treats an absent audio_meta.json as silent).
149 const bgmOff =
150 String (g.extra?.music ?? "" )
151 . trim ()
152 . toLowerCase () === "none" ;
153 if (bgmOff && ! lines. length ) {
154 rmSync (outPath, { force: true });
155 rmSync ( neutralPath (outPath), { force: true });
156 console. log (
157 "✓ audio generate: project marked silent (music: none, no SCRIPT.md) — nothing to generate" ,
158 );
159 return ;
160 }
161 if ( ! lines. length ) console. error ( "· no SCRIPT.md — silent film (BGM only)" );
162
163 // BGM mood: storyboard `music:` → message → arc → default. `mode: retrieve` is
164 // strict here (no wait-bgm step downstream).
165 const query = (g.extra && g.extra.music) || g.message || g.arc || "calm cinematic underscore" ;
166 const request = {
167 provider,
168 speed,
169 lines,
170 bgm: bgmOff
171 ? { mode: "none" }
172 : { mode: "retrieve" , query, blob: g.message || "" , arc: g.arc || "" },
173 };
174 if (userVoice) request.voice = userVoice;
175 if ( flag (argv, "tts-model" , null )) request.tts_model = flag (argv, "tts-model" , null );
176 if ( flag (argv, "style" , null )) request.style = flag (argv, "style" , null );
177
178 const neutral = neutralPath (outPath);
179 runEngine ({ request, hyperframesDir, neutral, only: "tts,bgm" }, die);
180
181 const meta = toProductLaunchMeta ( JSON . parse ( readFileSync (neutral, "utf8" )));
182 writeFileSync (outPath, JSON . stringify (meta, null , 2 ));
183 console. log (
184 `✓ audio generate: ${ meta . voices . length } voice + ${ meta . bgm ? "1 bgm" : "no bgm"} → ${ outPath }` ,
185 );
186 }
187
188 // ── fetch-sfx ────────────────────────────────────────────────────────────────
189 function runFetchSfx ( argv ) {
190 const die = ( m ) => {
191 console. error ( `✗ audio fetch-sfx: ${ m }` );
192 process. exit ( 1 );
193 };
194 const hyperframesDir = resolve ( flag (argv, "hyperframes" , "." ));
195 const storyboardPath = resolve ( flag (argv, "storyboard" , join (hyperframesDir, "STORYBOARD.md" )));
196 const outPath = resolve ( flag (argv, "audio-meta" , join (hyperframesDir, "audio_meta.json" )));
197
198 if ( ! existsSync (storyboardPath)) die ( `STORYBOARD.md not found at ${ storyboardPath }` );
199 const manifest = parseStoryboard ( readFileSync (storyboardPath, "utf8" ));
200
201 // Per-frame `sfx:` cues (comma-separated) → engine lines carrying only sfx.
202 // `filter(Boolean)` alone is not enough: a storyboard that spells "no SFX here" as
203 // `sfx: none` used to reach the engine as a cue literally NAMED "none", which then failed
204 // to resolve. The absence sentinels are part of the storyboard vocabulary, so drop them.
205 const SFX_NONE = new Set ([ "none" , "no" , "n/a" , "na" , "skip" , "-" , "—" , "–" ]);
206 const lines = [];
207 for ( const f of manifest.frames) {
208 const names = (f.extra?.sfx ?? "" )
209 . split ( "," )
210 . map (( s ) => s. trim ())
211 . filter (( s ) => s && ! SFX_NONE . has (s. toLowerCase ()));
212 if (names. length && f.number != null ) lines. push ({ id: pad2 (f.number), sfx: names });
213 }
214
215 const neutral = neutralPath (outPath);
216 const request = { lines, bgm: { mode: "none" } };
217 // --only sfx is a MERGE, not an overwrite: the engine reads the existing neutral
218 // sidecar (audio_engine_meta.json) and recomputes only the sfx section, so the
219 // voices/bgm written by the earlier generate (--only tts,bgm) pass are preserved.
220 runEngine ({ request, hyperframesDir, neutral, only: "sfx" }, die);
221
222 const meta = toProductLaunchMeta ( JSON . parse ( readFileSync (neutral, "utf8" )));
223 writeFileSync (outPath, JSON . stringify (meta, null , 2 ));
224 console. log ( `✓ audio fetch-sfx: ${ meta . sfx . length } SFX cue(s) → ${ outPath }` );
225 // This pass rewrites audio_meta.json from the neutral sidecar. If a detached BGM generate is
226 // still running, the bed it eventually writes is NOT folded back in — the snapshot we just
227 // took has no music. Say so instead of leaving a silent film that the storyboard claims has a
228 // bed (observed live: the caller had to notice on its own and rebuild the entry).
229 if (meta.bgm_pending && ! meta.bgm) {
230 console. warn (
231 "⚠ audio fetch-sfx: a detached BGM generate is still pending, so this snapshot has no bed. " +
232 "Re-run `fetch-sfx` (or re-point audio_meta.json at the track) once it lands, before assembling." ,
233 );
234 }
235 }
236
237 // ── sync-durations (local; rewrites STORYBOARD.md) ────────────────────────────
238 function runSyncDurations ( argv ) {
239 const die = ( m ) => {
240 console. error ( `✗ audio sync-durations: ${ m }` );
241 process. exit ( 1 );
242 };
243 const hyperframesDir = resolve ( flag (argv, "hyperframes" , "." ));
244 const audioMetaPath = resolve ( flag (argv, "audio-meta" , join (hyperframesDir, "audio_meta.json" )));
245 const storyboardPath = resolve ( flag (argv, "storyboard" , join (hyperframesDir, "STORYBOARD.md" )));
246 if ( ! existsSync (audioMetaPath)) die ( `audio_meta.json not found at ${ audioMetaPath }` );
247
248 const meta = JSON . parse ( readFileSync (audioMetaPath, "utf8" ));
249 const durByFrame = new Map ();
250 for ( const v of meta.voices ?? []) {
251 if (v.frame != null && v.duration_s) durByFrame. set (v.frame, v.duration_s);
252 }
253
254 // Read directly and handle ENOENT here, rather than an existsSync precheck —
255 // the check→write pair (write-back below) is a TOCTOU race CodeQL flags.
256 let storyboardRaw = "" ;
257 try {
258 storyboardRaw = readFileSync (storyboardPath, "utf8" );
259 } catch {
260 die ( `STORYBOARD.md not found at ${ storyboardPath }` );
261 }
262 const lines = storyboardRaw. split ( / \r ? \n / );
263 const FRAME_RE = / ^ # {2,3} \s + (?:frame | beat | scene) \b . *? ( \d + )/ i ;
264 let curFrame = null ;
265 let updated = 0 ;
266 for ( let i = 0 ; i < lines. length ; i ++ ) {
267 const h = lines[i]. match ( FRAME_RE );
268 if (h) {
269 curFrame = Number (h[ 1 ]);
270 continue ;
271 }
272 if (curFrame != null && durByFrame. has (curFrame)) {
273 const m = lines[i]. match ( / ^ ( \s * [-*]\s + duration \s * : \s * ) . * / i );
274 if (m) {
275 lines[i] = `${ m [ 1 ] }${ durByFrame . get ( curFrame ) }s` ;
276 durByFrame. delete (curFrame);
277 updated ++ ;
278 }
279 }
280 }
281 writeFileSync (storyboardPath, lines. join ( " \n " ));
282 const missing = [ ... durByFrame. keys ()];
283 console. log (
284 `✓ audio sync-durations: ${ updated } frame duration(s) updated` +
285 (missing. length ? ` · no \` - duration: \` line for frame(s) ${ missing . join ( ", " ) }` : "" ),
286 );
287 }
288
289 // ── dispatch ──────────────────────────────────────────────────────────────────
290 const sub = process.argv[ 2 ];
291 if (sub === "sync-durations" ) runSyncDurations (process.argv. slice ( 3 ));
292 else if (sub === "fetch-sfx" ) runFetchSfx (process.argv. slice ( 3 ));
293 else runGenerate (process.argv. slice ( 2 )); // default: generate