Setting the file. One moment.
Assemble Index · Product Launch Video · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page function approvedVideoLayout
— line 198
This file
Number 31.5
Position 5 of 33
Type JavaScript
Size 32 KB
Lines 773 scripts/ assemble-index.mjs
JavaScript · 773 lines · 32 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 // ② APPROVED VIDEO HOIST — an explicitly marked frame video is moved to the host root;
44 // audio remains orchestrator-owned and unmarked media is still a hard failure.
45 // ③ HARD FAIL — a timed element (data-start+duration+track-index) that is not the root
46 // and lacks class="clip" (shows the whole frame), or two same-track clips that overlap.
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 (guards ②/③) — 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 function attrValueFrom ( attrs , name ) {
173 const escaped = name. replace ( / [.*+?^${}()|[ \]\\ ] / g , " \\ $&" );
174 const match = attrs. match ( new RegExp ( `(?:^| \\ s)${ escaped } \\ s*= \\ s*(?:"([^"]*)"|'([^']*)')` ));
175 return match ? (match[ 1 ] ?? match[ 2 ]) : null ;
176 }
177
178 function escapeHtmlAttr ( value ) {
179 return value
180 . replaceAll ( "&" , "&" )
181 . replaceAll ( '"' , """ )
182 . replaceAll ( "<" , "<" )
183 . replaceAll ( ">" , ">" );
184 }
185
186 function approvedVideoAttrs ( attrs ) {
187 const forwarded = [];
188 for ( const name of [ "id" , "src" , "poster" , "preload" , "aria-label" , "data-media-start" ]) {
189 const value = attrValueFrom (attrs, name);
190 if (value !== null ) forwarded. push ( `${ name }="${ escapeHtmlAttr ( value ) }"` );
191 }
192 for ( const name of [ "muted" , "playsinline" , "loop" ]) {
193 if ( attrPresent (attrs, name)) forwarded. push (name);
194 }
195 return forwarded. join ( " " );
196 }
197
198 function approvedVideoLayout ( attrs ) {
199 const names = [ "x" , "y" , "width" , "height" ];
200 const raw = Object. fromEntries (
201 names. map (( name ) => [name, attrValueFrom (attrs, `data-frame-video-${ name }` )]),
202 );
203 const rawFit = attrValueFrom (attrs, "data-frame-video-fit" );
204 const values = Object. fromEntries (names. map (( name ) => [name, Number (raw[name])]));
205 if (
206 names. some (
207 ( name ) => raw[name] === null || raw[name]. trim () === "" || ! Number. isFinite (values[name]),
208 ) ||
209 values.width <= 0 ||
210 values.height <= 0
211 ) {
212 return {
213 style: null ,
214 error:
215 "approved frame video layout data-frame-video-x/y/width/height must all be finite numeric values, with positive width and height" ,
216 };
217 }
218
219 const fit = rawFit ?? "cover" ;
220 if ( ! [ "cover" , "contain" , "fill" , "none" , "scale-down" ]. includes (fit)) {
221 return {
222 style: null ,
223 error:
224 'approved frame video layout data-frame-video-fit must be "cover", "contain", "fill", "none", or "scale-down"' ,
225 };
226 }
227
228 return {
229 style: `position:absolute;left:${ values . x }px;top:${ values . y }px;width:${ values . width }px;height:${ values . height }px;object-fit:${ fit }` ,
230 error: null ,
231 };
232 }
233
234 function hoistApprovedVideos ( html , label ) {
235 const videos = [];
236 const errors = [];
237 const scan = html
238 . replace ( /<!-- [\s\S] *? -->/ g , ( match ) => " " . repeat (match. length ))
239 . replace ( /<script \b [\s\S] *? < \/ script [ ^ >] * >/ gi , ( match ) => " " . repeat (match. length ))
240 . replace ( /<style \b [\s\S] *? < \/ style [ ^ >] * >/ gi , ( match ) => " " . repeat (match. length ));
241 const re = /<video \b ((?: [ ^ >"'] | " [ ^ "] * " | ' [ ^ '] * ') * )>( [\s\S] *? )< \/ video \s * >/ gi ;
242 const repaired = html. replace (re, ( full , attrs , inner , offset ) => {
243 if (scan[offset] !== "<" ) return full;
244 if ( attrValueFrom (attrs, "data-frame-video" ) !== "approved" ) return full;
245 const rawStart = attrValueFrom (attrs, "data-start" );
246 const rawDuration = attrValueFrom (attrs, "data-duration" );
247 const rawTrack = attrValueFrom (attrs, "data-track-index" );
248 if (rawStart === null || rawDuration === null || rawTrack === null ) {
249 errors. push (
250 `${ label }: approved frame video must declare quoted data-start, data-duration, and data-track-index` ,
251 );
252 return full;
253 }
254 const start = Number (rawStart);
255 const duration = Number (rawDuration);
256 const track = Number (rawTrack);
257 if (
258 ! Number. isFinite (start) ||
259 ! Number. isFinite (duration) ||
260 duration <= 0 ||
261 ! Number. isFinite (track)
262 ) {
263 errors. push (
264 `${ label }: approved frame video must declare finite data-start, positive data-duration, and data-track-index` ,
265 );
266 return full;
267 }
268 const layout = approvedVideoLayout (attrs);
269 if (layout.error) {
270 errors. push ( `${ label }: ${ layout . error }` );
271 return full;
272 }
273 videos. push ({
274 attrs: approvedVideoAttrs (attrs),
275 inner,
276 start,
277 duration,
278 track,
279 layoutStyle: layout.style,
280 });
281 return "<!-- approved frame video hoisted by assemble-index -->" ;
282 });
283 return { html: repaired, videos, errors };
284 }
285
286 // Returns { errors: string[], repairedHtml: string|null, repairNote: string|null }.
287 function guardFrame ( html , label ) {
288 const errors = [];
289 const originalHtml = html;
290 const approved = hoistApprovedVideos (html, label);
291 html = approved.html;
292 errors. push ( ... approved.errors);
293 // Scan a copy with comments + <script>/<style> bodies blanked, so a tag-like string
294 // in a comment (e.g. "<!-- match the host <video> coords -->") or in GSAP code can't
295 // trip ②/③. ① still splices into the ORIGINAL html, so its offsets stay correct.
296 const scan = html
297 . replace ( /<!-- [\s\S] *? -->/ g , " " )
298 . replace ( /<script \b [\s\S] *? < \/ script [ ^ >] * >/ gi , " " )
299 . replace ( /<style \b [\s\S] *? < \/ style [ ^ >] * >/ gi , " " );
300
301 // ② media inside a sub-comp — never driven by the runtime (renders blank/black).
302 const media = scan. match ( /<(video | audio)(?= [\s/>] )/ i );
303 if (media) {
304 errors. push (
305 `${ label }: has a <${ media [ 1 ]. toLowerCase () }> inside the sub-composition. This workflow hoists media to index.html so the frame injector owns it; the framework itself renders media inside a sub-composition identically to media at the host root (verified by render, and pinned by packages/producer/tests/sub-composition-video), so this is an assembly convention, not a runtime limit. Move the clip to index.html as a root-level <video>/<audio> and drive any per-scene motion on the main timeline (composition-patterns.md archetype B).` ,
306 );
307 }
308
309 // ③ timed-element checks: missing class="clip", and same-track window overlap.
310 const re = new RegExp ( OPEN_TAG , "g" );
311 const clips = [];
312 let m;
313 while ((m = re. exec (scan))) {
314 const attrs = m[ 2 ];
315 if (
316 ! attrPresent (attrs, "data-start" ) ||
317 ! attrPresent (attrs, "data-duration" ) ||
318 ! attrPresent (attrs, "data-track-index" )
319 )
320 continue ;
321 if ( isRootish (attrs)) continue ;
322 if ( ! /(?: ^| \s )class \s * = \s * ["'][ ^ "'] *\b clip \b [ ^ "'] * ["'] / . test (attrs)) {
323 errors. push (
324 `${ 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.` ,
325 );
326 }
327 const track = attrValue (attrs, "data-track-index" );
328 const start = parseFloat ( attrValue (attrs, "data-start" ));
329 const dur = parseFloat ( attrValue (attrs, "data-duration" ));
330 if (track != null && Number. isFinite (start) && Number. isFinite (dur))
331 clips. push ({ track, start, end: start + dur });
332 }
333 const EPS = 1e-3 ; // adjacent clips that merely touch are legal
334 const byTrack = new Map ();
335 for ( const c of clips) {
336 const arr = byTrack. get (c.track);
337 if (arr) arr. push (c);
338 else byTrack. set (c.track, [c]);
339 }
340 for ( const [ track , list ] of byTrack) {
341 list. sort (( a , b ) => a.start - b.start);
342 for ( let i = 1 ; i < list. length ; i ++ ) {
343 if (list[i].start < list[i - 1 ].end - EPS ) {
344 errors. push (
345 `${ 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.` ,
346 );
347 break ; // one report per track is enough
348 }
349 }
350 }
351
352 // ① auto-repair: ensure the root carries data-width / data-height.
353 let repairedHtml = approved.html !== originalHtml ? approved.html : null ;
354 let repairNote = null ;
355 const root = findRootTag (html);
356 if (root) {
357 const needW = ! attrPresent (root.attrs, "data-width" );
358 const needH = ! attrPresent (root.attrs, "data-height" );
359 if (needW || needH) {
360 const inject =
361 (needW ? ` data-width="${ WIDTH }"` : "" ) + (needH ? ` data-height="${ HEIGHT }"` : "" );
362 const newTag = root.full. replace ( /( \/ ? >) $ / , `${ inject }$1` );
363 repairedHtml = html. slice ( 0 , root.start) + newTag + html. slice (root.end);
364 repairNote = `${ label }: injected${ needW ? " data-width" : ""}${ needH ? " data-height" : ""} (${ WIDTH }×${ HEIGHT }) on the root — was missing (would lint root_missing_dimensions)` ;
365 }
366 }
367
368 return { errors, repairedHtml, repairNote, hoistedVideos: approved.videos };
369 }
370
371 // ---------- resolve mountable frames in document order ----------
372 // A frame mounts when its src html exists on disk. A built/animated frame
373 // missing its src/file is a contract break (die). An outline frame with no
374 // file is skipped (still a placeholder) with an anomaly note.
375 const mounted = [];
376 for ( const f of manifest.frames) {
377 const label = `frame ${ f . number ?? f . index }${ f . title ? ` (${ f . title })` : ""}` ;
378 const built = f.status === "built" || f.status === "animated" ;
379 if ( ! f.src) {
380 if (built) die ( `${ label } is ${ f . status } but has no \` src \` — the orchestrator must write it` );
381 anomalies. push ( `${ label }: status ${ f . status }, no src — skipped` );
382 continue ;
383 }
384 const compAbs = join (hyperframesDir, f.src);
385 // Read directly and handle ENOENT here rather than an existsSync precheck — the
386 // check→read/write pair is a TOCTOU race CodeQL flags (js/file-system-race).
387 let html;
388 try {
389 html = readFileSync (compAbs, "utf8" );
390 } catch {
391 if (built)
392 die ( `${ label } is ${ f . status } but its src ${ f . src } is not on disk — re-dispatch the worker` );
393 anomalies. push ( `${ label }: src ${ f . src } not on disk (status ${ f . status }) — skipped` );
394 continue ;
395 }
396 if ( ! Number. isFinite (f.durationSeconds) || f.durationSeconds <= 0 ) {
397 die (
398 `${ label }: no positive duration (got ${ JSON . stringify ( f . duration ) }) — run audio sync-durations` ,
399 );
400 }
401 // Host data-composition-id MUST equal the inner file's, or the runtime never
402 // finds the timeline. frame_id = src basename (frame-worker contract); verify
403 // the inner html actually declares it.
404 const compId = basename (f.src). replace ( / \. html ?$ / i , "" );
405 // Guard against blank/partial scene files: a worker that errors or is
406 // interrupted mid-write leaves an empty (or markup-less) file that exists but
407 // fails at render with "Composition HTML is empty or could not be parsed".
408 // Catch it here — before emitting data-composition-src — and re-dispatch.
409 if ( ! html. trim () || ! /< \w / . test (html)) {
410 die (
411 `${ label }: ${ f . src } is empty or has no HTML — the worker wrote a blank/partial file. Re-dispatch that worker before assembling.` ,
412 );
413 }
414 // pre-assembly guards: ① repair missing root dims in place, ②/③ collect fatal violations.
415 const guard = guardFrame (html, label);
416 if (guard.repairedHtml) {
417 writeFileSync (compAbs, guard.repairedHtml);
418 html = guard.repairedHtml;
419 repairs. push (guard.repairNote);
420 }
421 for ( const e of guard.errors) frameErrors. push (e);
422 if (
423 ! html. includes ( `data-composition-id="${ compId }"` ) &&
424 ! html. includes ( `data-composition-id='${ compId }'` )
425 ) {
426 die ( `${ label }: ${ f . src } has no data-composition-id="${ compId }" (host/inner id must match)` );
427 }
428 mounted. push ({
429 frame: f,
430 compId,
431 durationSeconds: r3 (f.durationSeconds),
432 hoistedVideos: guard.hoistedVideos,
433 });
434 }
435 if (frameErrors. length ) {
436 die (
437 `${ frameErrors . length } frame composition violation(s) — fix the worker output and re-assemble: \n ` +
438 frameErrors. map (( e ) => ` • ${ e }` ). join ( " \n " ),
439 );
440 }
441 if (mounted. length === 0 ) die ( "no mountable frames (none built with an on-disk src)" );
442
443 // cumulative starts — emitted data-start[i] + data-duration[i] == start[i+1] by
444 // construction (renderer computes end the same way), so adjacent clips touch
445 // exactly with no float-overlap.
446 let acc = 0 ;
447 for ( const m of mounted) {
448 m.start = acc;
449 acc += m.durationSeconds;
450 }
451 const TOTAL = r3 (acc);
452
453 // ---------- duration expectation (advisory) ----------
454 // Frontmatter `duration:` carries the brief's rough length expectation
455 // (storyboard-format.md § Frontmatter). Never blocks the build: report where
456 // the cut lands, and flag a large gap so the agent judges whether the drift
457 // serves the piece.
458 let durationNote = "" ;
459 const rawTarget = manifest.globals.extra?.duration;
460 if (rawTarget != null && String (rawTarget). trim () !== "" ) {
461 const targetMatch = String (rawTarget). match ( /( \d + (?: \. \d + ) ? )/ );
462 const target = targetMatch ? parseFloat (targetMatch[ 1 ]) : NaN ;
463 if ( ! Number. isFinite (target) || target <= 0 ) {
464 anomalies. push (
465 `frontmatter duration "${ rawTarget }" is not parseable (e.g. "22s") — skipped the expectation check` ,
466 );
467 } else {
468 const diff = r3 ( TOTAL - target);
469 durationNote = ` (expected ~${ target }s, ${ diff >= 0 ? "+" : ""}${ diff }s)` ;
470 const pct = Math. abs ((diff / target) * 100 );
471 if (pct > 10 ) {
472 anomalies. push (
473 `total ${ TOTAL }s lands ${ Math . round ( pct ) }% ${ diff > 0 ? "over" : "under"} the brief's ~${ target }s expectation — ` +
474 `judge whether the drift serves the piece (pacing, narration fit); re-pace, or update \` duration: \` if the new length is intended` ,
475 );
476 }
477 }
478 }
479 const startOfFrameNumber = new Map ();
480 for ( const m of mounted) if (m.frame.number != null ) startOfFrameNumber. set (m.frame.number, m);
481
482 // ---------- audio_meta (optional) ----------
483 let audio = { bgm: null , voices: [], sfx: [] };
484 if ( existsSync (audioMetaPath)) {
485 try {
486 const parsed = JSON . parse ( readFileSync (audioMetaPath, "utf8" ));
487 // bgm_pending rides along: without it this step cannot tell a detached generate that has
488 // not landed yet from a film that is silent by design, and it would build the silent one.
489 audio = {
490 bgm: parsed.bgm ?? null ,
491 bgm_pending: !! parsed.bgm_pending,
492 voices: parsed.voices ?? [],
493 sfx: parsed.sfx ?? [],
494 };
495 } catch (e) {
496 die ( `audio_meta.json parse: ${ e . message }` );
497 }
498 }
499 const voiceByFrame = new Map ();
500 for ( const v of audio.voices) if (v.frame != null ) voiceByFrame. set (v.frame, v);
501
502 // ---------- build <body> in track order ----------
503 const body = [];
504 let voiceCount = 0 ;
505
506 for ( const m of mounted) {
507 // (track 1) frame sub-comp clip — no class="clip" semantics needed; .scene CSS sizes it.
508 body. push (
509 ` <div` ,
510 ` id="el-${ m . compId }"` ,
511 ` class="scene"` ,
512 ` data-composition-id="${ m . compId }"` ,
513 ` data-composition-src="${ m . frame . src }"` ,
514 ` data-start="${ m . start }"` ,
515 ` data-duration="${ m . durationSeconds }"` ,
516 ` data-track-index="1"` ,
517 ` ></div>` ,
518 );
519 // (track 10) voice — only when the file is actually on disk.
520 const v = m.frame.number != null ? voiceByFrame. get (m.frame.number) : undefined ;
521 if (v?.path) {
522 if ( existsSync ( join (hyperframesDir, v.path))) {
523 body. push (
524 ` <audio` ,
525 ` id="el-${ m . compId }-voice"` ,
526 ` src="${ v . path }"` ,
527 ` data-start="${ m . start }"` ,
528 ` data-duration="${ m . durationSeconds }"` ,
529 ` data-track-index="10"` ,
530 ` data-volume="1"` ,
531 ` ></audio>` ,
532 );
533 voiceCount ++ ;
534 } else {
535 anomalies. push ( `${ m . compId }: voice ${ v . path } not on disk — skipped` );
536 }
537 }
538 body. push ( "" );
539 }
540
541 // Approved frame videos are mounted at the host root after frame clips. Translate
542 // frame-relative timing to the global timeline and keep them off audio/frame lanes.
543 for ( const [ frameIndex , m ] of mounted. entries ()) {
544 for ( const video of m.hoistedVideos ?? []) {
545 const globalStart = r3 (m.start + video.start);
546 const track = 1000 + frameIndex * 1000 + video.track;
547 const id = /(?: ^| \s )id \s * =/ . test (video.attrs) ? "" : ` id="el-${ m . compId }-video-${ frameIndex }"` ;
548 body. push (
549 ` <video${ id } ${ video . attrs }` ,
550 ` class="clip"` ,
551 ... (video.layoutStyle ? [ ` style="${ video . layoutStyle }"` ] : []),
552 ` data-start="${ globalStart }"` ,
553 ` data-duration="${ r3 ( video . duration ) }"` ,
554 ` data-track-index="${ track }"` ,
555 ` >${ video . inner }</video>` ,
556 "" ,
557 );
558 }
559 }
560
561 // (track 11) BGM — duck under narration when any voice is present. Loop-extend a short
562 // track to the full video length so the tail isn't silent (libraries return ~15–30s clips).
563 let bgmEmitted = false ;
564 let bgmNote = "" ;
565 if (audio.bgm?.path) {
566 if ( existsSync ( join (hyperframesDir, audio.bgm.path))) {
567 let bgmSrc = audio.bgm.path;
568 const cov = ensureBgmCovers (audio.bgm.path, hyperframesDir, TOTAL );
569 if (cov.looped) {
570 bgmSrc = cov.rel;
571 bgmNote = ` (looped ${ cov . from . toFixed ( 1 ) }s→${ TOTAL }s)` ;
572 } else if (cov.short) {
573 anomalies. push (
574 `bgm is ${ cov . dur ?. toFixed ?.( 1 ) ?? "?"}s (< ${ TOTAL }s) and could not be extended (${ cov . reason }) — the tail will be silent; install ffmpeg` ,
575 );
576 }
577 // An explicit volume from audio_meta always wins; otherwise the shared
578 // media-use default (bed ~ -18 dB under narration, forward for a silent film).
579 const vol = audio.bgm.volume != null ? audio.bgm.volume : bgmDefaultVolume (voiceCount > 0 );
580 body. push (
581 ` <!-- BGM -->` ,
582 ` <audio` ,
583 ` id="el-bgm"` ,
584 ` src="${ bgmSrc }"` ,
585 ` data-start="0"` ,
586 ` data-duration="${ TOTAL }"` ,
587 ` data-track-index="11"` ,
588 ` data-volume="${ vol }"` ,
589 ` ></audio>` ,
590 "" ,
591 );
592 bgmEmitted = true ;
593 } else {
594 anomalies. push ( `bgm ${ audio . bgm . path } not on disk — skipped` );
595 }
596 } else if (audio.bgm_pending) {
597 // The distinction the flag exists to make. A warning is not enough here: assemble is re-run
598 // on Step 6 rework, long after the audio step's own warning scrolled past, and it would
599 // happily build a silent film from a snapshot whose JSON says the bed is still generating.
600 // Refuse by default; --allow-pending-bgm is the deliberate escape for previewing mid-generate.
601 if ( ! allowPendingBgm) {
602 die (
603 "audio_meta.json says bgm_pending — the music bed is still generating and is NOT in this " +
604 "assembly. Wait for the track, re-run the audio step, then assemble again. To assemble a " +
605 "deliberately silent preview anyway, pass --allow-pending-bgm." ,
606 );
607 }
608 anomalies. push (
609 "bgm still generating (bgm_pending) — assembled without a bed per --allow-pending-bgm" ,
610 );
611 }
612
613 // (track 2) captions — captions.mjs writes this or legally skips; key off existence.
614 let captionsEmitted = false ;
615 if ( existsSync ( join (hyperframesDir, "compositions/captions.html" ))) {
616 body. push (
617 ` <!-- captions -->` ,
618 ` <div` ,
619 ` id="el-captions"` ,
620 ` class="scene"` ,
621 ` data-composition-id="captions"` ,
622 ` data-composition-src="compositions/captions.html"` ,
623 ` data-start="0"` ,
624 ` data-duration="${ TOTAL }"` ,
625 ` data-track-index="2"` ,
626 ` ></div>` ,
627 "" ,
628 );
629 captionsEmitted = true ;
630 }
631
632 // (track 20+i) SFX — placed at its frame's start + offset.
633 let sfxEmitted = 0 ;
634 audio.sfx. forEach (( cue , i ) => {
635 const host = cue.frame != null ? startOfFrameNumber. get (cue.frame) : undefined ;
636 if ( ! host) {
637 anomalies. push ( `sfx ${ cue . file }: frame ${ cue . frame } not mounted — skipped` );
638 return ;
639 }
640 const rel = cue.file;
641 if ( ! existsSync ( join (hyperframesDir, rel))) {
642 anomalies. push ( `sfx ${ rel } not on disk — skipped` );
643 return ;
644 }
645 const t = r3 (host.start + (cue.offset_s ?? 0 ));
646 const dur = r3 (cue.duration_s ?? 1 );
647 const vol = cue.volume != null ? cue.volume : 0.35 ;
648 if (sfxEmitted === 0 ) body. push ( ` <!-- SFX -->` );
649 body. push (
650 ` <audio` ,
651 ` id="el-sfx-${ i }"` ,
652 ` src="${ rel }"` ,
653 ` data-start="${ t }"` ,
654 ` data-duration="${ dur }"` ,
655 ` data-track-index="${ 20 + i }"` ,
656 ` data-volume="${ vol }"` ,
657 ` ></audio>` ,
658 );
659 sfxEmitted ++ ;
660 });
661
662 // ---------- stage frame-named assets: capture/ → assets/ (idempotent backstop) ----------
663 // Frame workers + the live preview reference assets/<basename>; stage-assets.mjs
664 // already ran this at Step 4 close. Re-run as a backstop so a late-named asset
665 // still lands. Shared logic: lib/assets.mjs (first-wins, safe to call twice).
666 const {
667 staged ,
668 wanted ,
669 anomalies : assetAnomalies ,
670 } = stageAssets ({
671 hyperframesDir,
672 frames: manifest.frames,
673 });
674 for ( const a of assetAnomalies) anomalies. push (a);
675
676 // ---------- <head> ----------
677 // ---------- ground color ----------
678 // Per-frame roots carry data-start/data-duration and get clip-gated against the
679 // global timeline in render (only the first frame's [0,dur] window overlaps global
680 // 0), so a frame's own full-bleed background can't be relied on as the video ground —
681 // every frame after the first would render on the bare body color (black). Paint the
682 // ground on the always-present root composition instead, using the project's frame.md
683 // canvas color (the same ground role the caption skin maps to --cap-canvas). Falls
684 // back to the body letterbox color when frame.md is absent or has no resolvable ground.
685 const framePath = join (hyperframesDir, "frame.md" );
686 let groundColor = null ;
687 if ( existsSync (framePath)) {
688 try {
689 const roles = semanticColors ( parseColors ( readFileSync (framePath, "utf8" )));
690 if (roles && roles.canvas) groundColor = roles.canvas;
691 } catch {
692 /* leave groundColor null — #root stays transparent over the body letterbox */
693 }
694 }
695
696 const headStyle = [
697 " * {" ,
698 " margin: 0;" ,
699 " padding: 0;" ,
700 " box-sizing: border-box;" ,
701 " }" ,
702 " html," ,
703 " body {" ,
704 ` width: ${ WIDTH }px;` ,
705 ` height: ${ HEIGHT }px;` ,
706 " overflow: hidden;" ,
707 " background: #000;" ,
708 " }" ,
709 " #root {" ,
710 " position: relative;" ,
711 ` width: ${ WIDTH }px;` ,
712 ` height: ${ HEIGHT }px;` ,
713 " overflow: hidden;" ,
714 ... (groundColor ? [ ` background: ${ groundColor };` ] : []),
715 " }" ,
716 " .scene {" ,
717 " position: absolute;" ,
718 " inset: 0;" ,
719 " width: 100%;" ,
720 " height: 100%;" ,
721 " }" ,
722 ]. join ( " \n " );
723
724 const html = `<!doctype html>
725 <html lang="en">
726 <head>
727 <meta charset="UTF-8" />
728 <meta name="viewport" content="width=${ WIDTH }, height=${ HEIGHT }" />
729 <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js" integrity="sha384-sG0Hv1tP1lZCk9KQmrIbY/XNwi+OY84GQqhMscbnsoBFqAz8KNCil1kvfL3Hbbk2" crossorigin="anonymous"></script>
730 <style>
731 ${ headStyle }
732 </style>
733 </head>
734 <body>
735 <div
736 id="root"
737 data-composition-id="main"
738 data-start="0"
739 data-duration="${ TOTAL }"
740 data-width="${ WIDTH }"
741 data-height="${ HEIGHT }"
742 >
743 ${ body . join ( " \n " ) }
744 </div>
745
746 <script>
747 window.__timelines = window.__timelines || {};
748 window.__timelines["main"] = gsap.timeline({ paused: true });
749 </script>
750 </body>
751 </html>
752 ` ;
753
754 writeFileSync (outPath, html);
755
756 // ---------- summary ----------
757 console. log ( `✓ wrote ${ outPath }` );
758 console. log ( ` canvas: ${ WIDTH }×${ HEIGHT }` );
759 console. log ( ` frames (track 1): ${ mounted . length }` );
760 console. log ( ` voice (track 10): ${ voiceCount }` );
761 console. log ( ` bgm (track 11): ${ bgmEmitted ? "yes" + bgmNote : "no"}` );
762 console. log ( ` captions (track 2): ${ captionsEmitted ? "yes" : "no"}` );
763 console. log ( ` sfx (track 20+): ${ sfxEmitted }` );
764 console. log ( ` assets staged: ${ staged }/${ wanted . size }` );
765 console. log ( ` total duration: ${ TOTAL }s${ durationNote }` );
766 if (repairs. length ) {
767 console. log ( ` \n repaired (frame files updated in place):` );
768 for ( const rp of repairs) console. log ( ` - ${ rp }` );
769 }
770 if (anomalies. length ) {
771 console. log ( ` \n anomalies (non-fatal):` );
772 for ( const a of anomalies) console. log ( ` - ${ a }` );
773 }