Setting the file. One moment.
Assemble Index · Faceless Explainer · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page
Lines
625 scripts/ assemble-index.mjs
JavaScript · 625 lines · 26 KB
14
// Track lanes. Same-track time-overlap is this workflow's own assembly convention,
15 // not a framework rule: the render never reads data-track-index, and no lint rule
16 // checks overlap (timeline_track_too_dense counts elements per lane for readability).
17 // The convention exists because the frame injector below ping-pongs 0/1 for overlaps:
18 // 1 frame sub-comp clips (sequential; the injector 0/1-ping-pongs for overlaps)
19 // 2 captions sub-comp clip (full-duration overlay, on top of frames)
20 // 10 per-frame voice <audio>
21 // 11 BGM <audio>
22 // 20+i SFX <audio> (one lane each)
23 //
24 // audio_meta.json contract (produced by audio.mjs; OPTIONAL — absent ⇒ silent
25 // video, frames only). Durations come from STORYBOARD (audio sync-durations
26 // writes them), NOT from here; this file carries only media PATHS, keyed by
27 // frame number:
28 // { "bgm": { "path": "assets/bgm/x.mp3", "volume": 0.12 } | null,
29 // "voices":[ { "frame": 3, "path": "assets/voice/03.wav" } ],
30 // "sfx": [ { "frame": 3, "file": "assets/sfx/x.mp3", "offset_s": 0,
31 // "duration_s": 1.0, "volume": 0.35 } ] }
32 //
33 // Reads: --storyboard STORYBOARD.md, --hyperframes <project root>,
34 // [--audio-meta audio_meta.json]. On disk: each built frame's src html,
35 // capture/{assets,assets/videos,screenshots}/<basename> for staging, compositions/captions.html.
36 // Writes: <project>/index.html + stages assets/<basename> + (guard ① below)
37 // repairs a frame file in place when its root is missing data-width/height.
38 //
39 // Pre-assembly frame guards (run in the same pass that reads each frame, so common
40 // `lint` failures surface HERE instead of after assembly + a wasted render):
41 // ① AUTO-REPAIR — a sub-comp root missing data-width/data-height: inject the canvas
42 // dims (the renderer needs them on the cloned root; else lint root_missing_dimensions).
43 // ② HARD FAIL — a timed element (data-start+duration+track-index) that is not the root
44 // and lacks class="clip" (shows the whole frame), or two same-track clips that overlap.
45 // (Media inside a sub-comp is NOT a violation: the runtime seeks + decodes nested
46 // <video>/<audio> at any depth — see packages/core/src/runtime/{media,startResolver}.ts.)
47 //
48 // Exit 0 = index.html written + summary. Exit 1 = fatal contract break (no
49 // frames, a built/animated frame missing its src/file, a frame with no
50 // duration, an inner data-composition-id mismatch, or a guard ② violation).
51 // No backstop: fix upstream.
52
53 import { existsSync, readFileSync, writeFileSync } from "node:fs" ;
54 import { spawnSync } from "node:child_process" ;
55 import { basename, join, resolve } from "node:path" ;
56 import { parseStoryboard } from "./lib/storyboard.mjs" ;
57 import { parseFormat } from "./lib/dimensions.mjs" ;
58 import { stageAssets } from "./lib/assets.mjs" ;
59 import { parseColors, semanticColors } from "./lib/tokens.mjs" ;
60 import { bgmDefaultVolume } from "./lib/bgm-volume.mjs" ;
61
62 // ---------- argv ----------
63 const argv = process.argv. slice ( 2 );
64 const flag = ( name , def ) => {
65 const i = argv. indexOf ( `--${ name }` );
66 return i >= 0 && i + 1 < argv. length ? argv[i + 1 ] : def;
67 };
68 // Deliberate escape from the bgm_pending refusal below — for previewing while a detached
69 // generate is still running. Off by default so a silent film can't ship by accident.
70 const allowPendingBgm = argv. includes ( "--allow-pending-bgm" );
71 function die ( msg ) {
72 console. error ( `✗ assemble-index.mjs: ${ msg }` );
73 process. exit ( 1 );
74 }
75
76 // Ensure the BGM track is at least `total` seconds long. HeyGen (and most music
77 // libraries) return a short loopable clip (~15–30s); mounting it at data-duration=total
78 // would leave the video's TAIL SILENT. If the file is short, loop-extend it to `total`
79 // (with a 0.4s fade-in + 1.5s fade-out) into a sibling *.loop.mp3 and return that path.
80 // Needs ffprobe+ffmpeg (present in the render env); degrades to the original + a warning
81 // when they're absent, so assembly never hard-fails on audio tooling.
82 function ensureBgmCovers ( relPath , hyperframesDir , total ) {
83 const abs = join (hyperframesDir, relPath);
84 const probe = spawnSync (
85 "ffprobe" ,
86 [ "-v" , "error" , "-show_entries" , "format=duration" , "-of" , "csv=p=0" , "--" , abs],
87 { encoding: "utf8" },
88 );
89 if (probe.status !== 0 ) return { looped: false , short: false , reason: "ffprobe unavailable" };
90 const dur = parseFloat ( String (probe.stdout || "" ). trim ());
91 if ( ! Number. isFinite (dur) || dur <= 0 )
92 return { looped: false , short: false , reason: "unreadable duration" };
93 if (dur >= total - 0.1 ) return { looped: false , short: false , dur }; // already covers
94 // Always emit .mp3: the encode below is libmp3lame regardless of the source
95 // extension, so preserving relPath's own extension (e.g. "bgm.wav") would
96 // smuggle an MP3 stream into a .wav-named file (PRINFRA-309).
97 const relOut = relPath. replace ( / \. ( [ ^ ./] + ) $ / , ".loop.mp3" );
98 const absOut = join (hyperframesDir, relOut);
99 const fadeOut = Math. max ( 0 , total - 1.5 );
100 const ff = spawnSync (
101 "ffmpeg" ,
102 [
103 "-y" ,
104 "-stream_loop" ,
105 "-1" ,
106 "-i" ,
107 abs,
108 "-t" ,
109 String (total),
110 "-af" ,
111 `afade=t=in:st=0:d=0.4,afade=t=out:st=${ fadeOut }:d=1.5` ,
112 "-c:a" ,
113 "libmp3lame" ,
114 "-q:a" ,
115 "2" ,
116 absOut,
117 ],
118 { encoding: "utf8" },
119 );
120 if (ff.status !== 0 || ! existsSync (absOut))
121 return { looped: false , short: true , dur, reason: "ffmpeg unavailable" };
122 return { looped: true , rel: relOut, from: dur };
123 }
124
125 const hyperframesDir = resolve ( flag ( "hyperframes" , "." ));
126 const storyboardPath = resolve ( flag ( "storyboard" , join (hyperframesDir, "STORYBOARD.md" )));
127 const audioMetaPath = resolve ( flag ( "audio-meta" , join (hyperframesDir, "audio_meta.json" )));
128 const outPath = resolve ( flag ( "out" , join (hyperframesDir, "index.html" )));
129
130 const r3 = ( x ) => Math. round (x * 1000 ) / 1000 ;
131 const anomalies = [];
132 const frameErrors = []; // fatal per-frame composition violations (guard ②) — reported together
133 const repairs = []; // auto-repairs applied to frame files in place (guard ①)
134
135 // ---------- parse storyboard ----------
136 if ( ! existsSync (storyboardPath)) die ( `STORYBOARD.md not found at ${ storyboardPath }` );
137 const manifest = parseStoryboard ( readFileSync (storyboardPath, "utf8" ));
138 const { width : WIDTH , height : HEIGHT } = parseFormat (manifest.globals.format);
139
140 // ---------- per-frame composition guards (see header ①②) ----------
141 // String-level checks on each frame's HTML — no DOM parse, deterministic, run in
142 // the same pass that already reads the file. OPEN_TAG matches one opening tag while
143 // tolerating quoted attribute values that contain ">" (e.g. inline styles).
144 const OPEN_TAG = "<([a-zA-Z][a-zA-Z0-9-]*)((?:[^> \" ']| \" [^ \" ]* \" |'[^']*')*)>" ;
145 const attrPresent = ( attrs , name ) => new RegExp ( `(?:^| \\ s)${ name }(?:[ \\ s=]|$)` ). test (attrs);
146 const attrValue = ( attrs , name ) => {
147 const m = attrs. match ( new RegExp ( `(?:^| \\ s)${ name } \\ s*= \\ s*(?:"([^"]*)"|'([^']*)')` ));
148 return m ? (m[ 1 ] ?? m[ 2 ]) : null ;
149 };
150 // The root (or a nested-comp mount) legitimately carries timing without class="clip".
151 const isRootish = ( attrs ) =>
152 /(?: ^| \s )id \s * = \s * ["'] root ["'] / . test (attrs) ||
153 attrPresent (attrs, "data-composition-id" ) ||
154 attrPresent (attrs, "data-composition-src" );
155
156 // Locate the composition root opening tag: prefer id="root", else the first element
157 // carrying data-composition-id. Returns { start, end, full, attrs } or null.
158 function findRootTag ( html ) {
159 const re = new RegExp ( OPEN_TAG , "g" );
160 let m;
161 let firstCompId = null ;
162 while ((m = re. exec (html))) {
163 const attrs = m[ 2 ];
164 if ( /(?: ^| \s )id \s * = \s * ["'] root ["'] / . test (attrs))
165 return { start: m.index, end: m.index + m[ 0 ]. length , full: m[ 0 ], attrs };
166 if ( attrPresent (attrs, "data-composition-id" ) && ! firstCompId)
167 firstCompId = { start: m.index, end: m.index + m[ 0 ]. length , full: m[ 0 ], attrs };
168 }
169 return firstCompId;
170 }
171
172 // Returns { errors: string[], repairedHtml: string|null, repairNote: string|null }.
173 function guardFrame ( html , label ) {
174 const errors = [];
175 // Scan a copy with comments + <script>/<style> bodies blanked, so a tag-like string
176 // in a comment (e.g. "<!-- match the host <video> coords -->") or in GSAP code can't
177 // trip ②. ① still splices into the ORIGINAL html, so its offsets stay correct.
178 const scan = html
179 . replace ( /<!-- [\s\S] *? -->/ g , " " )
180 . replace ( /<script \b [\s\S] *? < \/ script [ ^ >] * >/ gi , " " )
181 . replace ( /<style \b [\s\S] *? < \/ style [ ^ >] * >/ gi , " " );
182
183 // ② timed-element checks: missing class="clip", and same-track window overlap.
184 // (Media inside a sub-comp is fine: the runtime's global media sweep seeks + decodes
185 // <video>/<audio> at any nesting depth, re-basing each clip's local data-start by its
186 // host composition's absolute start — no root-child requirement. See
187 // packages/core/src/runtime/{media,startResolver}.ts.)
188 const re = new RegExp ( OPEN_TAG , "g" );
189 const clips = [];
190 let m;
191 while ((m = re. exec (scan))) {
192 const attrs = m[ 2 ];
193 if (
194 ! attrPresent (attrs, "data-start" ) ||
195 ! attrPresent (attrs, "data-duration" ) ||
196 ! attrPresent (attrs, "data-track-index" )
197 )
198 continue ;
199 if ( isRootish (attrs)) continue ;
200 if ( ! /(?: ^| \s )class \s * = \s * ["'][ ^ "'] *\b clip \b [ ^ "'] * ["'] / . test (attrs)) {
201 errors. push (
202 `${ 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.` ,
203 );
204 }
205 const track = attrValue (attrs, "data-track-index" );
206 const start = parseFloat ( attrValue (attrs, "data-start" ));
207 const dur = parseFloat ( attrValue (attrs, "data-duration" ));
208 if (track != null && Number. isFinite (start) && Number. isFinite (dur))
209 clips. push ({ track, start, end: start + dur });
210 }
211 const EPS = 1e-3 ; // adjacent clips that merely touch are legal
212 const byTrack = new Map ();
213 for ( const c of clips) {
214 const arr = byTrack. get (c.track);
215 if (arr) arr. push (c);
216 else byTrack. set (c.track, [c]);
217 }
218 for ( const [ track , list ] of byTrack) {
219 list. sort (( a , b ) => a.start - b.start);
220 for ( let i = 1 ; i < list. length ; i ++ ) {
221 if (list[i].start < list[i - 1 ].end - EPS ) {
222 errors. push (
223 `${ label }: clips on track ${ track } overlap (one ends at ${ r3 ( list [ i - 1 ]. end ) }s, the next starts at ${ r3 ( list [ i ]. start ) }s). This workflow's injector assumes one clip per lane at a time. The render itself tolerates the overlap; put them on distinct data-track-index lanes or fix their windows.` ,
224 );
225 break ; // one report per track is enough
226 }
227 }
228 }
229
230 // ① auto-repair: ensure the root carries data-width / data-height.
231 let repairedHtml = null ;
232 let repairNote = null ;
233 const root = findRootTag (html);
234 if (root) {
235 const needW = ! attrPresent (root.attrs, "data-width" );
236 const needH = ! attrPresent (root.attrs, "data-height" );
237 if (needW || needH) {
238 const inject =
239 (needW ? ` data-width="${ WIDTH }"` : "" ) + (needH ? ` data-height="${ HEIGHT }"` : "" );
240 const newTag = root.full. replace ( /( \/ ? >) $ / , `${ inject }$1` );
241 repairedHtml = html. slice ( 0 , root.start) + newTag + html. slice (root.end);
242 repairNote = `${ label }: injected${ needW ? " data-width" : ""}${ needH ? " data-height" : ""} (${ WIDTH }×${ HEIGHT }) on the root — was missing (would lint root_missing_dimensions)` ;
243 }
244 }
245
246 return { errors, repairedHtml, repairNote };
247 }
248
249 // ---------- resolve mountable frames in document order ----------
250 // A frame mounts when its src html exists on disk. A built/animated frame
251 // missing its src/file is a contract break (die). An outline frame with no
252 // file is skipped (still a placeholder) with an anomaly note.
253 const mounted = [];
254 for ( const f of manifest.frames) {
255 const label = `frame ${ f . number ?? f . index }${ f . title ? ` (${ f . title })` : ""}` ;
256 const built = f.status === "built" || f.status === "animated" ;
257 if ( ! f.src) {
258 if (built) die ( `${ label } is ${ f . status } but has no \` src \` — the orchestrator must write it` );
259 anomalies. push ( `${ label }: status ${ f . status }, no src — skipped` );
260 continue ;
261 }
262 const compAbs = join (hyperframesDir, f.src);
263 // Read directly and handle ENOENT here rather than an existsSync precheck — the
264 // check→read/write pair is a TOCTOU race CodeQL flags (js/file-system-race).
265 let html;
266 try {
267 html = readFileSync (compAbs, "utf8" );
268 } catch {
269 if (built)
270 die ( `${ label } is ${ f . status } but its src ${ f . src } is not on disk — re-dispatch the worker` );
271 anomalies. push ( `${ label }: src ${ f . src } not on disk (status ${ f . status }) — skipped` );
272 continue ;
273 }
274 if ( ! Number. isFinite (f.durationSeconds) || f.durationSeconds <= 0 ) {
275 die (
276 `${ label }: no positive duration (got ${ JSON . stringify ( f . duration ) }) — run audio sync-durations` ,
277 );
278 }
279 // Host data-composition-id MUST equal the inner file's, or the runtime never
280 // finds the timeline. frame_id = src basename (frame-worker contract); verify
281 // the inner html actually declares it.
282 const compId = basename (f.src). replace ( / \. html ?$ / i , "" );
283 // Guard against blank/partial scene files: a worker that errors or is
284 // interrupted mid-write leaves an empty (or markup-less) file that exists but
285 // fails at render with "Composition HTML is empty or could not be parsed".
286 // Catch it here — before emitting data-composition-src — and re-dispatch.
287 if ( ! html. trim () || ! /< \w / . test (html)) {
288 die (
289 `${ label }: ${ f . src } is empty or has no HTML — the worker wrote a blank/partial file. Re-dispatch that worker before assembling.` ,
290 );
291 }
292 // pre-assembly guards: ① repair missing root dims in place, ② collects fatal violations.
293 const guard = guardFrame (html, label);
294 if (guard.repairedHtml) {
295 writeFileSync (compAbs, guard.repairedHtml);
296 html = guard.repairedHtml;
297 repairs. push (guard.repairNote);
298 }
299 for ( const e of guard.errors) frameErrors. push (e);
300 if (
301 ! html. includes ( `data-composition-id="${ compId }"` ) &&
302 ! html. includes ( `data-composition-id='${ compId }'` )
303 ) {
304 die ( `${ label }: ${ f . src } has no data-composition-id="${ compId }" (host/inner id must match)` );
305 }
306 mounted. push ({ frame: f, compId, durationSeconds: r3 (f.durationSeconds) });
307 }
308 if (frameErrors. length ) {
309 die (
310 `${ frameErrors . length } frame composition violation(s) — fix the worker output and re-assemble: \n ` +
311 frameErrors. map (( e ) => ` • ${ e }` ). join ( " \n " ),
312 );
313 }
314 if (mounted. length === 0 ) die ( "no mountable frames (none built with an on-disk src)" );
315
316 // cumulative starts — emitted data-start[i] + data-duration[i] == start[i+1] by
317 // construction (renderer computes end the same way), so adjacent clips touch
318 // exactly with no float-overlap.
319 let acc = 0 ;
320 for ( const m of mounted) {
321 m.start = acc;
322 acc += m.durationSeconds;
323 }
324 const TOTAL = r3 (acc);
325
326 // ---------- duration expectation (advisory) ----------
327 // Frontmatter `duration:` carries the brief's rough length expectation
328 // (storyboard-format.md § Frontmatter). Never blocks the build: report where
329 // the cut lands, and flag a large gap so the agent judges whether the drift
330 // serves the piece.
331 let durationNote = "" ;
332 const rawTarget = manifest.globals.extra?.duration;
333 if (rawTarget != null && String (rawTarget). trim () !== "" ) {
334 const targetMatch = String (rawTarget). match ( /( \d + (?: \. \d + ) ? )/ );
335 const target = targetMatch ? parseFloat (targetMatch[ 1 ]) : NaN ;
336 if ( ! Number. isFinite (target) || target <= 0 ) {
337 anomalies. push (
338 `frontmatter duration "${ rawTarget }" is not parseable (e.g. "22s") — skipped the expectation check` ,
339 );
340 } else {
341 const diff = r3 ( TOTAL - target);
342 durationNote = ` (expected ~${ target }s, ${ diff >= 0 ? "+" : ""}${ diff }s)` ;
343 const pct = Math. abs ((diff / target) * 100 );
344 if (pct > 10 ) {
345 anomalies. push (
346 `total ${ TOTAL }s lands ${ Math . round ( pct ) }% ${ diff > 0 ? "over" : "under"} the brief's ~${ target }s expectation — ` +
347 `judge whether the drift serves the piece (pacing, narration fit); re-pace, or update \` duration: \` if the new length is intended` ,
348 );
349 }
350 }
351 }
352 const startOfFrameNumber = new Map ();
353 for ( const m of mounted) if (m.frame.number != null ) startOfFrameNumber. set (m.frame.number, m);
354
355 // ---------- audio_meta (optional) ----------
356 let audio = { bgm: null , voices: [], sfx: [] };
357 if ( existsSync (audioMetaPath)) {
358 try {
359 const parsed = JSON . parse ( readFileSync (audioMetaPath, "utf8" ));
360 // bgm_pending rides along: without it this step cannot tell a detached generate that has
361 // not landed yet from a film that is silent by design, and it would build the silent one.
362 audio = {
363 bgm: parsed.bgm ?? null ,
364 bgm_pending: !! parsed.bgm_pending,
365 voices: parsed.voices ?? [],
366 sfx: parsed.sfx ?? [],
367 };
368 } catch (e) {
369 die ( `audio_meta.json parse: ${ e . message }` );
370 }
371 }
372 const voiceByFrame = new Map ();
373 for ( const v of audio.voices) if (v.frame != null ) voiceByFrame. set (v.frame, v);
374
375 // ---------- build <body> in track order ----------
376 const body = [];
377 let voiceCount = 0 ;
378
379 for ( const m of mounted) {
380 // (track 1) frame sub-comp clip — no class="clip" semantics needed; .scene CSS sizes it.
381 body. push (
382 ` <div` ,
383 ` id="el-${ m . compId }"` ,
384 ` class="scene"` ,
385 ` data-composition-id="${ m . compId }"` ,
386 ` data-composition-src="${ m . frame . src }"` ,
387 ` data-start="${ m . start }"` ,
388 ` data-duration="${ m . durationSeconds }"` ,
389 ` data-track-index="1"` ,
390 ` ></div>` ,
391 );
392 // (track 10) voice — only when the file is actually on disk.
393 const v = m.frame.number != null ? voiceByFrame. get (m.frame.number) : undefined ;
394 if (v?.path) {
395 if ( existsSync ( join (hyperframesDir, v.path))) {
396 body. push (
397 ` <audio` ,
398 ` id="el-${ m . compId }-voice"` ,
399 ` src="${ v . path }"` ,
400 ` data-start="${ m . start }"` ,
401 ` data-duration="${ m . durationSeconds }"` ,
402 ` data-track-index="10"` ,
403 ` data-volume="1"` ,
404 ` ></audio>` ,
405 );
406 voiceCount ++ ;
407 } else {
408 anomalies. push ( `${ m . compId }: voice ${ v . path } not on disk — skipped` );
409 }
410 }
411 body. push ( "" );
412 }
413
414 // (track 11) BGM — duck under narration when any voice is present. Loop-extend a short
415 // track to the full video length so the tail isn't silent (libraries return ~15–30s clips).
416 let bgmEmitted = false ;
417 let bgmNote = "" ;
418 if (audio.bgm?.path) {
419 if ( existsSync ( join (hyperframesDir, audio.bgm.path))) {
420 let bgmSrc = audio.bgm.path;
421 const cov = ensureBgmCovers (audio.bgm.path, hyperframesDir, TOTAL );
422 if (cov.looped) {
423 bgmSrc = cov.rel;
424 bgmNote = ` (looped ${ cov . from . toFixed ( 1 ) }s→${ TOTAL }s)` ;
425 } else if (cov.short) {
426 anomalies. push (
427 `bgm is ${ cov . dur ?. toFixed ?.( 1 ) ?? "?"}s (< ${ TOTAL }s) and could not be extended (${ cov . reason }) — the tail will be silent; install ffmpeg` ,
428 );
429 }
430 // An explicit volume from audio_meta always wins; otherwise the shared
431 // media-use default (bed ~ -18 dB under narration, forward for a silent film).
432 const vol = audio.bgm.volume != null ? audio.bgm.volume : bgmDefaultVolume (voiceCount > 0 );
433 body. push (
434 ` <!-- BGM -->` ,
435 ` <audio` ,
436 ` id="el-bgm"` ,
437 ` src="${ bgmSrc }"` ,
438 ` data-start="0"` ,
439 ` data-duration="${ TOTAL }"` ,
440 ` data-track-index="11"` ,
441 ` data-volume="${ vol }"` ,
442 ` ></audio>` ,
443 "" ,
444 );
445 bgmEmitted = true ;
446 } else {
447 anomalies. push ( `bgm ${ audio . bgm . path } not on disk — skipped` );
448 }
449 } else if (audio.bgm_pending) {
450 // The distinction the flag exists to make. A warning is not enough: assemble is re-run on
451 // rework long after the audio step's own warning scrolled past, and it would happily build a
452 // silent film from a snapshot whose JSON says the bed is still generating.
453 if ( ! allowPendingBgm) {
454 die (
455 "audio_meta.json says bgm_pending — the music bed is still generating and is NOT in this " +
456 "assembly. Wait for the track, re-run the audio step, then assemble again. To assemble a " +
457 "deliberately silent preview anyway, pass --allow-pending-bgm." ,
458 );
459 }
460 anomalies. push (
461 "bgm still generating (bgm_pending) — assembled without a bed per --allow-pending-bgm" ,
462 );
463 }
464
465 // (track 2) captions — captions.mjs writes this or legally skips; key off existence.
466 let captionsEmitted = false ;
467 if ( existsSync ( join (hyperframesDir, "compositions/captions.html" ))) {
468 body. push (
469 ` <!-- captions -->` ,
470 ` <div` ,
471 ` id="el-captions"` ,
472 ` class="scene"` ,
473 ` data-composition-id="captions"` ,
474 ` data-composition-src="compositions/captions.html"` ,
475 ` data-start="0"` ,
476 ` data-duration="${ TOTAL }"` ,
477 ` data-track-index="2"` ,
478 ` ></div>` ,
479 "" ,
480 );
481 captionsEmitted = true ;
482 }
483
484 // (track 20+i) SFX — placed at its frame's start + offset.
485 let sfxEmitted = 0 ;
486 audio.sfx. forEach (( cue , i ) => {
487 const host = cue.frame != null ? startOfFrameNumber. get (cue.frame) : undefined ;
488 if ( ! host) {
489 anomalies. push ( `sfx ${ cue . file }: frame ${ cue . frame } not mounted — skipped` );
490 return ;
491 }
492 const rel = cue.file;
493 if ( ! existsSync ( join (hyperframesDir, rel))) {
494 anomalies. push ( `sfx ${ rel } not on disk — skipped` );
495 return ;
496 }
497 const t = r3 (host.start + (cue.offset_s ?? 0 ));
498 const dur = r3 (cue.duration_s ?? 1 );
499 const vol = cue.volume != null ? cue.volume : 0.35 ;
500 if (sfxEmitted === 0 ) body. push ( ` <!-- SFX -->` );
501 body. push (
502 ` <audio` ,
503 ` id="el-sfx-${ i }"` ,
504 ` src="${ rel }"` ,
505 ` data-start="${ t }"` ,
506 ` data-duration="${ dur }"` ,
507 ` data-track-index="${ 20 + i }"` ,
508 ` data-volume="${ vol }"` ,
509 ` ></audio>` ,
510 );
511 sfxEmitted ++ ;
512 });
513
514 // ---------- stage frame-named assets: capture/ → assets/ (idempotent backstop) ----------
515 // Frame workers + the live preview reference assets/<basename>; stage-assets.mjs
516 // already ran this at Step 4 close. Re-run as a backstop so a late-named asset
517 // still lands. Shared logic: lib/assets.mjs (first-wins, safe to call twice).
518 const {
519 staged ,
520 wanted ,
521 anomalies : assetAnomalies ,
522 } = stageAssets ({
523 hyperframesDir,
524 frames: manifest.frames,
525 });
526 for ( const a of assetAnomalies) anomalies. push (a);
527
528 // ---------- <head> ----------
529 // ---------- ground color ----------
530 // Per-frame roots carry data-start/data-duration and get clip-gated against the
531 // global timeline in render (only the first frame's [0,dur] window overlaps global
532 // 0), so a frame's own full-bleed background can't be relied on as the video ground —
533 // every frame after the first would render on the bare body color (black). Paint the
534 // ground on the always-present root composition instead, using the project's frame.md
535 // canvas color (the same ground role the caption skin maps to --cap-canvas). Falls
536 // back to the body letterbox color when frame.md is absent or has no resolvable ground.
537 const framePath = join (hyperframesDir, "frame.md" );
538 let groundColor = null ;
539 if ( existsSync (framePath)) {
540 try {
541 const roles = semanticColors ( parseColors ( readFileSync (framePath, "utf8" )));
542 if (roles && roles.canvas) groundColor = roles.canvas;
543 } catch {
544 /* leave groundColor null — #root stays transparent over the body letterbox */
545 }
546 }
547
548 const headStyle = [
549 " * {" ,
550 " margin: 0;" ,
551 " padding: 0;" ,
552 " box-sizing: border-box;" ,
553 " }" ,
554 " html," ,
555 " body {" ,
556 ` width: ${ WIDTH }px;` ,
557 ` height: ${ HEIGHT }px;` ,
558 " overflow: hidden;" ,
559 " background: #000;" ,
560 " }" ,
561 " #root {" ,
562 " position: relative;" ,
563 ` width: ${ WIDTH }px;` ,
564 ` height: ${ HEIGHT }px;` ,
565 " overflow: hidden;" ,
566 ... (groundColor ? [ ` background: ${ groundColor };` ] : []),
567 " }" ,
568 " .scene {" ,
569 " position: absolute;" ,
570 " inset: 0;" ,
571 " width: 100%;" ,
572 " height: 100%;" ,
573 " }" ,
574 ]. join ( " \n " );
575
576 const html = `<!doctype html>
577 <html lang="en">
578 <head>
579 <meta charset="UTF-8" />
580 <meta name="viewport" content="width=${ WIDTH }, height=${ HEIGHT }" />
581 <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js" integrity="sha384-sG0Hv1tP1lZCk9KQmrIbY/XNwi+OY84GQqhMscbnsoBFqAz8KNCil1kvfL3Hbbk2" crossorigin="anonymous"></script>
582 <style>
583 ${ headStyle }
584 </style>
585 </head>
586 <body>
587 <div
588 id="root"
589 data-composition-id="main"
590 data-start="0"
591 data-duration="${ TOTAL }"
592 data-width="${ WIDTH }"
593 data-height="${ HEIGHT }"
594 >
595 ${ body . join ( " \n " ) }
596 </div>
597
598 <script>
599 window.__timelines = window.__timelines || {};
600 window.__timelines["main"] = gsap.timeline({ paused: true });
601 </script>
602 </body>
603 </html>
604 ` ;
605
606 writeFileSync (outPath, html);
607
608 // ---------- summary ----------
609 console. log ( `✓ wrote ${ outPath }` );
610 console. log ( ` canvas: ${ WIDTH }×${ HEIGHT }` );
611 console. log ( ` frames (track 1): ${ mounted . length }` );
612 console. log ( ` voice (track 10): ${ voiceCount }` );
613 console. log ( ` bgm (track 11): ${ bgmEmitted ? "yes" + bgmNote : "no"}` );
614 console. log ( ` captions (track 2): ${ captionsEmitted ? "yes" : "no"}` );
615 console. log ( ` sfx (track 20+): ${ sfxEmitted }` );
616 console. log ( ` assets staged: ${ staged }/${ wanted . size }` );
617 console. log ( ` total duration: ${ TOTAL }s${ durationNote }` );
618 if (repairs. length ) {
619 console. log ( ` \n repaired (frame files updated in place):` );
620 for ( const rp of repairs) console. log ( ` - ${ rp }` );
621 }
622 if (anomalies. length ) {
623 console. log ( ` \n anomalies (non-fatal):` );
624 for ( const a of anomalies) console. log ( ` - ${ a }` );
625 }