Setting the file. One moment.
Make Composition · Embedded Captions · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page 14.42
Hershey Script1
function buildGroupsJson
— line 219
This file
Number 14.30
Position 30 of 95
Type JavaScript
Size 16 KB
Lines 405 scripts/ make-composition.cjs
JavaScript · 405 lines · 16 KB
15 */
16 const path = require ( "path" );
17 const fs = require ( "fs" );
18 const cp = require ( "child_process" );
19 const dnaLib = require ( "./lib-dna.cjs" );
20
21 const SKILL_ROOT = path. resolve (__dirname, ".." );
22 const TEMPLATES = path. join ( SKILL_ROOT , "modes" , "cinematic" );
23
24 // Source clip duration (seconds) via ffprobe. The COMPOSITION/background length must
25 // follow the SOURCE, not the last caption — see the Bug-1 note in main().
26 function sourceDurationSec ( project ) {
27 let cands = [ "source.mp4" ];
28 try {
29 cands = cands. concat (
30 fs
31 . readdirSync (project)
32 . filter (
33 ( f ) =>
34 / \. (mp4 | mov | webm | mkv | m4v) $ / i . test (f) &&
35 ! / ^ (final | bg_plus_caps | fg_caps | rail | index)/ . test (f),
36 ),
37 );
38 } catch {}
39 for ( const c of cands) {
40 const p = path. isAbsolute (c) ? c : path. join (project, c);
41 if ( ! fs. existsSync (p)) continue ;
42 try {
43 const out = cp
44 . execFileSync (
45 "ffprobe" ,
46 [
47 "-v" ,
48 "error" ,
49 "-show_entries" ,
50 "format=duration" ,
51 "-of" ,
52 "default=nokey=1:noprint_wrappers=1" ,
53 "--" ,
54 p,
55 ],
56 { encoding: "utf8" },
57 )
58 . trim ();
59 const d = parseFloat (out);
60 if (d > 0 ) return d;
61 } catch {}
62 }
63 return null ;
64 }
65
66 function hexLum ( hex ) {
67 let h = String (hex). replace ( "#" , "" );
68 if (h. length === 3 ) h = [ ... h]. map (( c ) => c + c). join ( "" );
69 const r = parseInt (h. slice ( 0 , 2 ), 16 ) / 255 ,
70 g = parseInt (h. slice ( 2 , 4 ), 16 ) / 255 ,
71 b = parseInt (h. slice ( 4 , 6 ), 16 ) / 255 ;
72 if ([r, g, b]. some (Number.isNaN)) return 0.9 ;
73 return 0.299 * r + 0.587 * g + 0.114 * b;
74 }
75 const defaultTextShadow = ( c ) =>
76 hexLum (c) < 0.45
77 ? "0 2px 6px rgba(0, 0, 0, 0.28)"
78 : "0 0 18px rgba(255, 220, 170, 0.55), 0 3px 8px rgba(0, 0, 0, 0.85)" ;
79 const defaultTextFilter = ( c ) =>
80 hexLum (c) < 0.45 ? "contrast(1.08)" : "brightness(1.1) contrast(1.05)" ;
81
82 function findTemplate ( name ) {
83 const p = path. join ( TEMPLATES , name, "template.html" );
84 if (fs. existsSync (p)) return p;
85 const avail = fs
86 . readdirSync ( TEMPLATES )
87 . filter (( d ) => fs. existsSync (path. join ( TEMPLATES , d, "template.html" )))
88 . sort ();
89 console. error ( `[compile] unknown template: ${ name }. Available: ${ avail . join ( ", " ) }` );
90 process. exit ( 1 );
91 }
92 function escBr ( t ) {
93 const e = String (t)
94 . replace ( /&/ g , "&" )
95 . replace ( /</ g , "<" )
96 . replace ( />/ g , ">" )
97 . replace ( /"/ g , """ );
98 return e
99 . replace ( /<br>/ g , "<br>" )
100 . replace ( /<br \/ >/ g , "<br>" )
101 . replace ( /<br \/ >/ g , "<br>" );
102 }
103 // hero words get per-letter inner spans (<span class="l">) so the engine can stagger
104 // letters; .w keeps wrapping the word so every gate (occlusion / overflow / layout)
105 // measures exactly what it measured before.
106 function wordSpan ( w , i , perLetter ) {
107 if ( ! perLetter) return `<span class="w" data-i="${ i }">${ escBr ( w . text ) }</span>` ;
108 const t = String (w.text);
109 if ( /<br \s * \/ ? >/ i . test (t)) return `<span class="w" data-i="${ i }">${ escBr ( t ) }</span>` ; // explicit <br> words stay whole
110 const letters = [ ... t]
111 . map (( ch ) => (ch === " " ? " " : `<span class="l">${ escBr ( ch ) }</span>` ))
112 . join ( "" );
113 return `<span class="w" data-i="${ i }">${ letters }</span>` ;
114 }
115 function renderCap ( g , heroCfg ) {
116 const slot = g.slot ?? g.style ?? "" ;
117 const layerAttr = g.layer ? ` data-layer="${ g . layer }"` : "" ;
118 const isHero = heroCfg && g.hero === true ;
119 const perLetter = isHero && heroCfg.perLetter;
120 const cls = slot ? `cap cap-${ slot }` : "cap" ;
121 const spans = g.words. map (( w , i ) => wordSpan (w, i, perLetter)). join ( " \n " );
122 const capDiv = `<div id="${ g . id }" class="${ cls }"${ layerAttr }> \n ${ spans } \n </div>` ;
123 if ( ! isHero) return capDiv;
124 // fx siblings render UNDER the hero (earlier sibling = beneath): glow first, then
125 // echoes, then the hero itself. Plain .w spans; none are in plan.groups → gates skip.
126 const plainSpans = g.words
127 . map (( w , i ) => `<span class="w" data-i="${ i }">${ escBr ( w . text ) }</span>` )
128 . join ( " \n " );
129 const parts = [];
130 if (heroCfg.glow)
131 parts. push (
132 `<div id="${ g . id }-glow" class="${ cls } hero-glow" aria-hidden="true"${ layerAttr }> \n ${ plainSpans } \n </div>` ,
133 );
134 (heroCfg.echoes || []). forEach (( ec , k ) => {
135 parts. push (
136 `<div id="${ g . id }-echo-${ k }" class="${ cls } hero-echo" aria-hidden="true"${ layerAttr }> \n ${ plainSpans } \n </div>` ,
137 );
138 });
139 parts. push (capDiv);
140 return parts. join ( " \n " );
141 }
142 function buildGroupsHtml ( groups , planes , heroCfg ) {
143 if ( ! planes) return groups. map (( g ) => renderCap (g, heroCfg)). join ( " \n " );
144 const order = Object. keys (planes);
145 const grouped = {};
146 order. forEach (( p ) => (grouped[p] = []));
147 const free = [];
148 for ( const g of groups) {
149 const pid = g.plane;
150 if (pid && pid in grouped) grouped[pid]. push (g);
151 else free. push (g);
152 }
153 const parts = [];
154 for ( const pid of order)
155 parts. push (
156 `<div id="plane-${ pid }" class="plane plane-${ pid }"> \n ${ grouped [ pid ]. map (( g ) => renderCap ( g , heroCfg )). join ( " \n " ) } \n </div>` ,
157 );
158 for ( const g of free) parts. push ( renderCap (g, heroCfg));
159 return parts. join ( " \n " );
160 }
161 function buildPlanesCss ( planes ) {
162 if ( ! planes) return "" ;
163 return Object. entries (planes)
164 . map (([ pid , p ]) => {
165 // Accept BOTH plane shapes: { body: { css: "..." } } and the bare-string
166 // { body: "..." } (matches how groups write css). Reading only p.css silently
167 // dropped the string form → empty CSS → planes collapsed to (0,0).
168 let css = ( typeof p === "string" ? p : (p || {}).css || "" ). trim ();
169 if ( ! css) return null ;
170 if ( ! css. endsWith ( ";" )) css += ";" ;
171 return ` .plane-${ pid } { ${ css } }` ;
172 })
173 . filter (Boolean)
174 . join ( " \n " );
175 }
176 function buildPerGroupCss ( groups , tokens ) {
177 return groups
178 . map (( g ) => {
179 const parts = [];
180 if (g.scale != null ) parts. push ( `--s: ${ g . scale };` );
181 let css = (g.css || "" ). trim ();
182 if (css) parts. push (css. endsWith ( ";" ) ? css : css + ";" );
183 const isHero = tokens && g.hero === true ;
184 if (isHero) {
185 // DNA hero treatment (case / tracking / extra css) — authored css still wins (it comes first? no: later rules win; DNA goes first so the author can override)
186 const dnaHero = [];
187 if (tokens.heroCase && tokens.heroCase !== "none" )
188 dnaHero. push ( `text-transform: ${ tokens . heroCase };` );
189 if (tokens.heroTracking && tokens.heroTracking !== "0" )
190 dnaHero. push ( `letter-spacing: ${ tokens . heroTracking };` );
191 if (tokens.heroCss)
192 dnaHero. push (tokens.heroCss. endsWith ( ";" ) ? tokens.heroCss : tokens.heroCss + ";" );
193 if (tokens.hero && tokens.hero.heroColor) dnaHero. push ( `color: ${ tokens . hero . heroColor };` );
194 parts. unshift ( ... dnaHero);
195 }
196 const rules = [];
197 if (parts. length ) rules. push ( ` #${ g . id } { ${ parts . join ( " " ) } }` );
198 // fx duplicates mirror the hero's geometry exactly (same slot/absolute CSS) so a
199 // flex/slot plane can't displace them; glow blur comes from .hero-glow (em-based),
200 // echo colors from their own rules (they must NOT inherit a transparent text fill).
201 if (isHero && tokens.hero) {
202 // word-level hero treatment (e.g. chrome's gradient clip — background-clip:text
203 // can't clip through composited children, so it must live ON the .w span)
204 if (tokens.hero.wordCss) rules. push ( ` #${ g . id } .w { ${ tokens . hero . wordCss } }` );
205 if (tokens.hero.glow > 0 && parts. length )
206 rules. push ( ` #${ g . id }-glow { ${ parts . join ( " " ) } }` );
207 (tokens.hero.echoes || []). forEach (( ec , k ) => {
208 const extra =
209 `color: ${ ec . color }; -webkit-text-fill-color: ${ ec . color }; background: none;` +
210 (ec.blur ? ` filter: blur(${ ec . blur }px);` : "" );
211 rules. push ( ` #${ g . id }-echo-${ k } { ${ parts . join ( " " ) } ${ extra } }` );
212 });
213 }
214 return rules. length ? rules. join ( " \n " ) : null ;
215 })
216 . filter (Boolean)
217 . join ( " \n " );
218 }
219 function buildGroupsJson ( groups ) {
220 return JSON . stringify (
221 groups. map (( g ) => ({
222 id: g.id,
223 in: g.in,
224 out: g.out,
225 tone: g.tone ?? "soft" ,
226 ... (g.hero === true ? { hero: true } : {}),
227 ... (g.minor === true ? { minor: true } : {}),
228 ... (g.plane ? { plane: g.plane } : {}),
229 words: g.words. map (( w ) => ({ text: w.text, start: w.start, end: w.end })),
230 })),
231 null ,
232 10 ,
233 );
234 }
235
236 function main () {
237 const project = path. resolve (process.argv[ 2 ] || "" );
238 if ( ! process.argv[ 2 ]) {
239 console. error ( "usage: make-composition.cjs <project-dir>" );
240 process. exit ( 1 );
241 }
242 const planPath = path. join (project, "plan.json" );
243 if ( ! fs. existsSync (planPath)) {
244 console. error ( `[compile] missing ${ planPath }` );
245 process. exit ( 1 );
246 }
247 const plan = JSON . parse (fs. readFileSync (planPath, "utf8" ));
248 if (plan.mode === "custom" ) {
249 console. error ( "[compile] mode=custom — skip this script and hand-write index.html." );
250 process. exit ( 1 );
251 }
252 if (plan.mode === "standard" ) {
253 console. error (
254 "[compile] mode=standard — this plan.json is DERIVED by make-standard.cjs; compile Standard projects with make-standard.cjs (from standard.json), not this script." ,
255 );
256 process. exit ( 1 );
257 }
258 // ── DNA resolution: plan.dna (canonical) or a legacy template name ──────────
259 const dnaName = plan.dna || dnaLib. LEGACY [plan.template] || null ;
260 let dna = null ,
261 tokens = null ;
262 if (dnaName) {
263 try {
264 dna = dnaLib. load (dnaName);
265 } catch (e) {
266 console. error ( `[compile] ${ e . message }` );
267 process. exit ( 1 );
268 }
269 }
270 const heroGroups = (plan.groups || []). filter (( x ) => x.hero === true );
271 if (dna) {
272 tokens = dnaLib. resolveTokens (dna, project, { heroGroups });
273 console. log (
274 `[compile] DNA "${ dna . name }" (${ tokens . register }) — accent ${ tokens . accent }` +
275 `${ tokens . blurPx ? ` · depth-match blur ${ tokens . blurPx }px` : ""}` +
276 `${ tokens . heroes . length ? ` · hero amp ${ tokens . heroes . map (( h ) => h . amp ). join ( "/" ) } (RMS-coupled)` : ""}` ,
277 );
278 }
279 let src = dna
280 ? fs. readFileSync (path. join ( TEMPLATES , "engine.html" ), "utf8" )
281 : fs. readFileSync ( findTemplate (plan.template), "utf8" );
282 // Bug-1: the canvas/background length = SOURCE clip length, NOT the last-caption time.
283 // If plan.duration < source, the bg video ends before the matte → the tail shows only
284 // the foreground subject on black. Caption groups keep their own in/out (they may end
285 // earlier); only the composition duration follows the source.
286 const srcDur = sourceDurationSec (project);
287 const renderDur = srcDur && srcDur > 0 ? srcDur : plan.duration;
288 if (srcDur && Math. abs (srcDur - (plan.duration || 0 )) > 0.2 )
289 console. log (
290 `[compile] canvas duration → source length ${ renderDur . toFixed ( 2 ) }s (plan.duration=${ plan . duration }); captions keep their own times.` ,
291 );
292 const plane = plan.plane || {},
293 header = plan.header || {},
294 crown = plan.crown || {};
295 // LOCKED DNA — the plan can NOT override colour / blend / shadow / filter. Picking a
296 // DNA commits to its visual identity; only layout + per-group typography stay
297 // agent-authored. If a scene's luminance fights the look, pick a DIFFERENT DNA
298 // (bright scene → "ink") — never recolour this one. (plan.cap_color / blend_mode /
299 // text_shadow / text_filter are intentionally ignored.)
300 const capColor = tokens ? tokens.capColor : "#fff5df" ;
301 const g = ( o , k , d ) => (o && o[k] != null ? o[k] : d);
302 const heroCfg =
303 tokens && tokens.heroes && tokens.heroes. length
304 ? {
305 perLetter: tokens.heroes[ 0 ].perLetter,
306 glow: tokens.heroes[ 0 ].glow,
307 echoes: tokens.heroes[ 0 ].echoes || [],
308 }
309 : null ;
310 const subs = {
311 DURATION: `${ renderDur }` ,
312 FPS: `${ plan . fps ?? 24 }` ,
313 WIDTH: `${ plan . width }` ,
314 HEIGHT: `${ plan . height }` ,
315 FONT_SCALE: `${ plan . font_scale ?? 1.0 }` ,
316 PLANE_TOP: `${ g ( plane , "top" , 0 ) }` ,
317 PLANE_LEFT: `${ g ( plane , "left" , "" ) }` ,
318 PLANE_RIGHT: `${ g ( plane , "right" , "" ) }` ,
319 PLANE_WIDTH: `${ g ( plane , "width" , 0 ) }` ,
320 PLANE_HEIGHT: `${ g ( plane , "height" , 0 ) }` ,
321 ROTATE_Y: `${ g ( plane , "rotateY" , 0 ) }` ,
322 ROTATE_X: `${ g ( plane , "rotateX" , 0 ) }` ,
323 HEADER_TOP: `${ g ( header , "top" , 0 ) }` ,
324 HEADER_HEIGHT: `${ g ( header , "height" , 0 ) }` ,
325 CROWN_TOP: `${ g ( crown , "top" , plan . crown_top ?? g ( plan . crown_position || {}, "top" , 440 )) }` ,
326 CROWN_LEFT: `${ g ( crown , "left" , 0 ) }` ,
327 CROWN_RIGHT: `${ g ( crown , "right" , 0 ) }` ,
328 CROWN_ALIGN: `${ g ( crown , "align" , "center" ) }` ,
329 CROWN_SCALE: `${ g ( crown , "scale" , 1.0 ) }` ,
330 BLEND_MODE: tokens ? tokens.blend : "screen" ,
331 CAP_COLOR: capColor,
332 TEXT_SHADOW: tokens ? tokens.textShadow : defaultTextShadow (capColor),
333 TEXT_FILTER: tokens ? tokens.filterBg : defaultTextFilter (capColor),
334 // engine-only placeholders (legacy templates simply don't contain them)
335 FONT_FAMILY: tokens ? tokens.fontFamily : "Inter" ,
336 ACCENT: tokens ? tokens.accent : "#e3c06a" ,
337 TEXT_FILTER_BG: tokens ? tokens.filterBg : defaultTextFilter (capColor),
338 TEXT_FILTER_FG: tokens ? tokens.filterFg : defaultTextFilter (capColor),
339 DNA_CSS: tokens && tokens.dnaCss ? tokens.dnaCss : "" ,
340 MOTION_JSON: JSON . stringify (
341 tokens
342 ? tokens.motion
343 : {
344 soft: { y: 8 , dur: 0.45 , ease: "power2.out" },
345 present: { y: 6 , scale: 1.04 , dur: 0.22 , ease: "power3.out" },
346 },
347 ),
348 HEROES_JSON: tokens && tokens.heroes ? JSON . stringify (tokens.heroes) : "[]" ,
349 HERO_JSON: tokens && tokens.hero ? JSON . stringify (tokens.hero) : "null" ,
350 GROUPS_HTML: buildGroupsHtml (plan.groups, plan.planes, heroCfg),
351 PLANES_CSS: buildPlanesCss (plan.planes),
352 CUSTOM_CSS: buildPerGroupCss (plan.groups, tokens),
353 GROUPS_JSON: buildGroupsJson (plan.groups),
354 };
355 const cg = plan.crown_group;
356 if (cg) {
357 const layerAttr = cg.layer ? ` data-layer="${ cg . layer }"` : "" ;
358 const spans = cg.words
359 . map (( w , i ) => `<span class="w" data-i="${ i }">${ escBr ( w . text ) }</span>` )
360 . join ( " \n " );
361 subs. CROWN_HTML = `<div id="${ cg . id }" class="cap cap-crown"${ layerAttr }> \n ${ spans } \n </div>` ;
362 subs. CROWN_JSON = JSON . stringify (
363 {
364 id: cg.id,
365 in: cg.in,
366 out: cg.out,
367 tone: cg.tone ?? "present" ,
368 words: cg.words. map (( w ) => ({ text: w.text, start: w.start, end: w.end })),
369 },
370 null ,
371 10 ,
372 );
373 } else {
374 subs. CROWN_HTML = "" ;
375 subs. CROWN_JSON = "null" ;
376 }
377
378 for ( const [ k , v ] of Object. entries (subs)) src = src. split ( `{{${ k }}}` ). join (v);
379 fs. writeFileSync (path. join (project, "index.html" ), src);
380 console. log (
381 `[compile] ${ dna ? `dna=${ dna . name } (engine)` : `template=${ plan . template }`} → ${ path . join ( project , "index.html" ) }` ,
382 );
383
384 // per-group FG → index_fg.html (same strategy as the Python version)
385 const fgGroups = plan.groups. filter (( x ) => x.layer === "fg" );
386 if (fgGroups. length || (plan.crown_group || {}).layer === "fg" ) {
387 const fgCss = `
388 <style>
389 html.fg-only body { background: #000 !important; }
390 html.fg-only #fg-cover { position: absolute; inset: 0; background: #000; z-index: 1; pointer-events: none; }
391 /* visibility (not display:none): GSAP's transform parser needs a laid-out box at
392 construction time — display:none here crashed timeline build in headless preview */
393 html.fg-only .cap:not([data-layer="fg"]) { visibility: hidden !important; }
394 </style>
395 ` ;
396 let fg = src. replace ( "<html " , '<html class="fg-only" ' );
397 fg = fg. replace ( "</head>" , fgCss + " </head>" );
398 fg = fg. replace ( '<div id="stage"' , '<div id="fg-cover"></div> \n <div id="stage"' );
399 fs. writeFileSync (path. join (project, "index_fg.html" ), fg);
400 console. log (
401 `[compile] ${ fgGroups . length } fg group(s) → ${ path . join ( project , "index_fg.html" ) }` ,
402 );
403 }
404 }
405 main ();