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