Setting the file. One moment.
Assemble Index · Music To Video · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page Gsap Min
scripts/assemble-index.mjs
scripts/ assemble-index.mjs
JavaScript · 220 lines · 8 KB
14
//
15 // Track lanes:
16 // 1 frame clips (sequential, gap-free, hard cut between)
17 // 10 optional per-frame VO <audio> (deferred; mounted only if audio_meta has it)
18 // 11 BGM <audio> (full duration)
19 //
20 // Reads: --storyboard STORYBOARD.md, --hyperframes <root>, [--audiomap audiomap.json],
21 // [--bgm assets/bgm.mp3], [--audio-meta audio_meta.json]. On disk: each frame's src html.
22 // Writes: <project>/index.html
23 //
24 // Exit 0 = index.html written + summary. Exit 1 = fatal contract break (no
25 // frames, a frame missing/empty/with-no-duration, an inner id mismatch).
26
27 import { existsSync, readFileSync, writeFileSync } from "node:fs" ;
28 import { basename, join, resolve } from "node:path" ;
29 import { parseStoryboard } from "./lib/storyboard.mjs" ;
30
31 const argv = process.argv. slice ( 2 );
32 const flag = ( name , def ) => {
33 const i = argv. indexOf ( `--${ name }` );
34 return i >= 0 && i + 1 < argv. length ? argv[i + 1 ] : def;
35 };
36 function die ( msg ) {
37 console. error ( `✗ assemble-index.mjs: ${ msg }` );
38 process. exit ( 1 );
39 }
40 const r3 = ( x ) => Math. round (x * 1000 ) / 1000 ;
41 const anomalies = [];
42
43 const hyperframesDir = resolve ( flag ( "hyperframes" , "." ));
44 const storyboardPath = resolve ( flag ( "storyboard" , join (hyperframesDir, "STORYBOARD.md" )));
45 const audiomapPath = resolve ( flag ( "audiomap" , join (hyperframesDir, "audiomap.json" )));
46 const audioMetaPath = resolve ( flag ( "audio-meta" , join (hyperframesDir, "audio_meta.json" )));
47 const bgmRel = flag ( "bgm" , "assets/bgm.mp3" );
48 const outPath = resolve ( flag ( "out" , join (hyperframesDir, "index.html" )));
49
50 // ---------- parse storyboard ----------
51 if ( ! existsSync (storyboardPath)) die ( `STORYBOARD.md not found at ${ storyboardPath }` );
52 const manifest = parseStoryboard ( readFileSync (storyboardPath, "utf8" ));
53 const G = manifest.globals.extra ?? {};
54
55 // canvas from frontmatter `canvas: {"w":1920,"h":1080,"fps":30}` (key lowercased by parser)
56 let WIDTH = 1920 ,
57 HEIGHT = 1080 ;
58 if ( G .canvas) {
59 try {
60 const c = JSON . parse ( G .canvas);
61 if (Number. isFinite (c.w)) WIDTH = c.w;
62 if (Number. isFinite (c.h)) HEIGHT = c.h;
63 } catch {
64 anomalies. push (
65 `could not JSON.parse canvas frontmatter: ${ G . canvas } — using ${ WIDTH }×${ HEIGHT }` ,
66 );
67 }
68 }
69
70 // audio duration is the spine truth
71 let audioDur = null ;
72 if ( existsSync (audiomapPath)) {
73 try {
74 audioDur = JSON . parse ( readFileSync (audiomapPath, "utf8" ))?.audio?.duration_sec ?? null ;
75 } catch (e) {
76 anomalies. push ( `audiomap.json parse failed (${ e . message }) — using frame sum for duration` );
77 }
78 }
79
80 // ---------- resolve mountable frames in document order ----------
81 const mounted = [];
82 for ( const f of manifest.frames) {
83 const label = `frame ${ f . number ?? f . index }${ f . title ? ` (${ f . title })` : ""}` ;
84 if ( ! f.src) die ( `${ label } has no \` src \` — the planner must write it in STORYBOARD.md` );
85 const compAbs = join (hyperframesDir, f.src);
86 if ( ! existsSync (compAbs))
87 die ( `${ label }: src ${ f . src } is not on disk — re-dispatch its frame-worker before assembling` );
88 if ( ! Number. isFinite (f.durationSeconds) || f.durationSeconds <= 0 )
89 die ( `${ label }: no positive \` duration \` (got ${ JSON . stringify ( f . duration ) })` );
90 const compId = basename (f.src). replace ( / \. html ?$ / i , "" );
91 const inner = readFileSync (compAbs, "utf8" );
92 if ( ! inner. trim () || ! /< \w / . test (inner))
93 die (
94 `${ label }: ${ f . src } is empty/blank — the frame-worker wrote a partial file. Re-dispatch it.` ,
95 );
96 if (
97 ! inner. includes ( `data-composition-id="${ compId }"` ) &&
98 ! inner. includes ( `data-composition-id='${ compId }'` )
99 )
100 die ( `${ label }: ${ f . src } has no data-composition-id="${ compId }" (host/inner id must match)` );
101 mounted. push ({ frame: f, compId, durationSeconds: r3 (f.durationSeconds) });
102 }
103 if (mounted. length === 0 ) die ( "no mountable frames (none with an on-disk src)" );
104
105 // cumulative starts — start[i] + duration[i] == start[i+1] exactly (gap-free hard cuts)
106 let acc = 0 ;
107 for ( const m of mounted) {
108 m.start = r3 (acc);
109 acc += m.durationSeconds;
110 }
111 const FRAME_SUM = r3 (acc);
112 const TOTAL = r3 (audioDur ?? FRAME_SUM );
113 if (audioDur != null && Math. abs ( FRAME_SUM - audioDur) > 0.1 )
114 anomalies. push (
115 `frames sum to ${ FRAME_SUM }s but audio is ${ audioDur }s (Δ${ r3 ( FRAME_SUM - audioDur ) }s) — frames should tile the track; check the plan` ,
116 );
117
118 // ---------- optional VO (deferred hook) ----------
119 let audio = { voices: [] };
120 if ( existsSync (audioMetaPath)) {
121 try {
122 audio = JSON . parse ( readFileSync (audioMetaPath, "utf8" ));
123 } catch (e) {
124 anomalies. push ( `audio_meta.json parse: ${ e . message }` );
125 }
126 }
127 const voiceByNum = new Map ();
128 for ( const v of audio.voices ?? []) if (v.frame != null ) voiceByNum. set (v.frame, v);
129
130 // ---------- build <body> ----------
131 const body = [];
132 let voiceCount = 0 ;
133 for ( const m of mounted) {
134 body. push (
135 ` <div` ,
136 ` id="el-${ m . compId }"` ,
137 ` class="frame"` ,
138 ` data-composition-id="${ m . compId }"` ,
139 ` data-composition-src="${ m . frame . src }"` ,
140 ` data-start="${ m . start }"` ,
141 ` data-duration="${ m . durationSeconds }"` ,
142 ` data-track-index="1"` ,
143 ` ></div>` ,
144 );
145 const v = m.frame.number != null ? voiceByNum. get (m.frame.number) : undefined ;
146 if (v?.path && existsSync ( join (hyperframesDir, v.path))) {
147 body. push (
148 ` <audio id="el-${ m . compId }-voice" src="${ v . path }" data-start="${ m . start }"` ,
149 ` data-duration="${ m . durationSeconds }" data-track-index="10" data-volume="1"></audio>` ,
150 );
151 voiceCount ++ ;
152 }
153 body. push ( "" );
154 }
155
156 // BGM (track 11) — full duration. Here the music IS the content (music-first
157 // skill), so it never drops to the explainer pipelines' narration-bed default
158 // (bgmDefaultVolume() 0.12 ≈ -18 dB): an incidental VO ducks it only slightly.
159 let bgmEmitted = false ;
160 if ( existsSync ( join (hyperframesDir, bgmRel))) {
161 const vol = voiceCount > 0 ? 0.8 : 0.9 ;
162 body. push (
163 ` <!-- BGM -->` ,
164 ` <audio id="el-bgm" src="${ bgmRel }" data-start="0" data-duration="${ TOTAL }"` ,
165 ` data-track-index="11" data-volume="${ vol }"></audio>` ,
166 );
167 bgmEmitted = true ;
168 } else {
169 anomalies. push ( `BGM not found at ${ bgmRel } — index has no music track` );
170 }
171
172 // ---------- head + emit ----------
173 const headStyle = [
174 " * { margin: 0; padding: 0; box-sizing: border-box; }" ,
175 ` html, body { width: ${ WIDTH }px; height: ${ HEIGHT }px; overflow: hidden; background: #000; }` ,
176 ` #root { position: relative; width: ${ WIDTH }px; height: ${ HEIGHT }px; overflow: hidden; }` ,
177 " .frame { position: absolute; inset: 0; width: 100%; height: 100%; }" ,
178 ]. join ( " \n " );
179
180 const html = `<!doctype html>
181 <html lang="en">
182 <head>
183 <meta charset="UTF-8" />
184 <meta name="viewport" content="width=${ WIDTH }, height=${ HEIGHT }" />
185 <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js" integrity="sha384-sG0Hv1tP1lZCk9KQmrIbY/XNwi+OY84GQqhMscbnsoBFqAz8KNCil1kvfL3Hbbk2" crossorigin="anonymous"></script>
186 <style>
187 ${ headStyle }
188 </style>
189 </head>
190 <body>
191 <div
192 id="root"
193 data-composition-id="main"
194 data-start="0"
195 data-duration="${ TOTAL }"
196 data-width="${ WIDTH }"
197 data-height="${ HEIGHT }"
198 >
199 ${ body . join ( " \n " ) }
200 </div>
201
202 <script>
203 window.__timelines = window.__timelines || {};
204 window.__timelines["main"] = gsap.timeline({ paused: true });
205 </script>
206 </body>
207 </html>
208 ` ;
209 writeFileSync (outPath, html);
210
211 console. log ( `✓ wrote ${ outPath }` );
212 console. log ( ` canvas: ${ WIDTH }×${ HEIGHT }` );
213 console. log ( ` frames (track 1): ${ mounted . length }` );
214 console. log ( ` bgm (track 11): ${ bgmEmitted ? bgmRel : "MISSING"}` );
215 console. log ( ` vo (track 10): ${ voiceCount }` );
216 console. log ( ` total duration: ${ TOTAL }s` + (audioDur != null ? ` (audio ${ audioDur }s)` : "" ));
217 if (anomalies. length ) {
218 console. log ( ` \n anomalies (non-fatal):` );
219 for ( const a of anomalies) console. log ( ` - ${ a }` );
220 }