Setting the file. One moment.
Transitions · Product Launch Video · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page function runVerify
— line 308
This file
Number 31.31
Position 31 of 33
Type JavaScript
Size 15 KB
Lines 383 scripts/ transitions.mjs
JavaScript · 383 lines · 15 KB
// is keyed to the original frame start). At boundary i→i+1 (type = the incoming
15 // frame's transition_in): extend ONLY the outgoing wrapper's data-duration by
16 // `dur` so it holds its final frame across the window; do NOT move any data-start;
17 // the incoming — already present from the cut on a higher track — fades/pushes in
18 // over it. Then 0/1-ping-pong ALL frame clips' data-track-index (adjacent
19 // overlapping wrappers never share a track — lint timeline_track_too_dense) and
20 // stamp the token-substituted GSAP template into __timelines["main"] at T =
21 // incoming start. captions(2)/voice(10)/bgm(11)/sfx(20+) are never touched.
22 //
23 // node transitions.mjs inject --storyboard ./STORYBOARD.md --hyperframes .
24 // node transitions.mjs verify --storyboard ./STORYBOARD.md --index ./index.html
25
26 import { existsSync, readFileSync, writeFileSync } from "node:fs" ;
27 import { join, resolve } from "node:path" ;
28 import { parseStoryboard } from "./lib/storyboard.mjs" ;
29 import { parseFormat } from "./lib/dimensions.mjs" ;
30 import { loadTransitionRegistry, transitionsByName } from "./lib/transition-registry.mjs" ;
31 import { padFrameInternalDuration } from "./lib/pad-frame-duration.mjs" ;
32
33 const flag = ( argv , name , def ) => {
34 const i = argv. indexOf ( `--${ name }` );
35 return i >= 0 && i + 1 < argv. length ? argv[i + 1 ] : def;
36 };
37 const NO_TRANSITION = new Set ([ "cut" , "none" , "" ]);
38 const r3 = ( x ) => Number (x. toFixed ( 3 ));
39
40 // transition_in → { type, direction?, dur? } | null (hard cut).
41 function parseTransitionIn ( raw ) {
42 const s = (raw ?? "" ). trim ();
43 if ( NO_TRANSITION . has (s. toLowerCase ())) return null ;
44 const parts = s. split ( / \s + / );
45 const spec = { type: parts[ 0 ]. toLowerCase () };
46 for ( const p of parts. slice ( 1 )) {
47 const m = p. match ( / ^ ( \d + (?: \. \d + ) ? )s ?$ / );
48 if (m) spec.dur = Number (m[ 1 ]);
49 else spec.direction = p. toUpperCase ();
50 }
51 return spec;
52 }
53
54 // Mounted STORYBOARD frames present in index.html, in document order: { id, frame }.
55 function mountedFramesInOrder ( manifest , html ) {
56 const out = [];
57 for ( const f of manifest.frames) {
58 if ( ! f.src) continue ;
59 const id = f.src
60 . split ( "/" )
61 . pop ()
62 . replace ( / \. html ?$ / i , "" );
63 if (html. includes ( `id="el-${ id }"` )) out. push ({ id, frame: f });
64 }
65 return out;
66 }
67
68 // Frame clip wrappers parsed out of index.html (ids carry hyphens; excludes
69 // el-captions and audio by keying off the known frame-id set). The id is matched
70 // from anywhere in the tag's attribute list — never assume it is the first attribute
71 // (the index assembler emits data-hf-id before id, so an id-first regex finds nothing
72 // and inject crashes on the empty clip map).
73 function parseFrameClips ( html , frameIds ) {
74 const clipRe = /<div \b ( [ ^ >] * )>< \/ div>/ g ;
75 const clips = new Map ();
76 let m;
77 while ((m = clipRe. exec (html)) !== null ) {
78 const attrs = m[ 1 ];
79 const idm = attrs. match ( / \b id="el-( [A-Za-z0-9_-] + )"/ );
80 if ( ! idm || ! frameIds. has (idm[ 1 ])) continue ;
81 const num = ( re ) => {
82 const x = attrs. match (re);
83 return x ? Number (x[ 1 ]) : null ;
84 };
85 clips. set (idm[ 1 ], {
86 id: idm[ 1 ],
87 block: m[ 0 ],
88 start: num ( /data-start="( [\d.] + )"/ ),
89 duration: num ( /data-duration="( [\d.] + )"/ ),
90 track: num ( /data-track-index="( \d + )"/ ) ?? 0 ,
91 });
92 }
93 return clips;
94 }
95
96 // The host wrapper is extended across an outgoing transition, so the mounted
97 // frame must remain visually populated for the same local-time window. Extend
98 // the frame root and every non-audio timed element that reached the original
99 // storyboard boundary. This also repairs worker files whose root was already
100 // inflated while their ground/content clips still ended at the synced duration.
101 function extendFrameTail ( hyperframesDir , frame , baseDuration , targetDuration , die ) {
102 if ( ! frame?.src || targetDuration <= baseDuration) return ;
103 const framePath = join (hyperframesDir, frame.src);
104 let html;
105 try {
106 html = readFileSync (framePath, "utf8" );
107 } catch {
108 die ( `outgoing frame file not found at ${ framePath }` );
109 }
110
111 const compId = frame.src
112 . split ( "/" )
113 . pop ()
114 . replace ( / \. html ?$ / i , "" );
115 const EPS = 0.011 ;
116 let foundRoot = false ;
117 let extended = 0 ;
118 const rewritten = html. replace ( /<( [A-Za-z][\w:-] * ) \b ( [ ^ >] * )>/ g , ( tag , name , attrs ) => {
119 const durationMatch = attrs. match ( / \b data-duration="( [\d.] + )"/ );
120 const compositionMatch = attrs. match ( / \b data-composition-id="( [ ^ "] + )"/ );
121 if (compositionMatch?.[ 1 ] === compId && ! foundRoot) {
122 foundRoot = true ;
123 return durationMatch
124 ? tag. replace ( / \b data-duration=" [\d.] + "/ , `data-duration="${ targetDuration }"` )
125 : tag. replace ( /( \s * \/ ? >) $ / , ` data-duration="${ targetDuration }"$1` );
126 }
127
128 if ( ! durationMatch) return tag;
129 const duration = Number (durationMatch[ 1 ]);
130 if ( ! Number. isFinite (duration)) return tag;
131
132 if (name. toLowerCase () === "audio" ) return tag;
133 const startMatch = attrs. match ( / \b data-start="( [\d.] + )"/ );
134 if ( ! startMatch) return tag;
135 const start = Number (startMatch[ 1 ]);
136 const end = start + duration;
137 if (end < baseDuration - EPS || end >= targetDuration - EPS ) return tag;
138 extended ++ ;
139 return tag. replace ( / \b data-duration=" [\d.] + "/ , `data-duration="${ r3 ( targetDuration - start ) }"` );
140 });
141
142 if ( ! foundRoot) die ( `${ frame . src } has no data-composition-id="${ compId }" root` );
143 writeFileSync (framePath, rewritten);
144 console. log (
145 ` ${ compId }: extended root + ${ extended } tail clip(s) ${ baseDuration }s→${ targetDuration }s` ,
146 );
147 }
148
149 // Resolve a transition_in spec to a registry record (calm default on unknown).
150 function resolveRecord ( spec , byName , reg , warn ) {
151 let rec = byName. get (spec.type);
152 if ( ! rec) {
153 rec = byName. get (reg.default_calm);
154 warn ( `transition_in "${ spec . type }" not in registry — using ${ reg . default_calm }` );
155 }
156 return rec;
157 }
158 function resolveDur ( spec , rec , reg ) {
159 let dur = spec.dur ?? rec.default_duration_s ?? 0.5 ;
160 return Math. min (dur, reg.max_duration_s ?? 2.0 );
161 }
162
163 // GSAP lines for one transition record (token substitution).
164 function buildGsap ( rec , fromId , toId , dur , T , direction , canvasW , canvasH , die ) {
165 const subs = {
166 __OLD__: `"#el-${ fromId }"` ,
167 __NEW__: `"#el-${ toId }"` ,
168 __T__: String ( T ),
169 __DUR__: String (dur),
170 };
171 let template;
172 if (rec.directions && rec.directions. length > 0 ) {
173 const dir = (direction || rec.default_direction || rec.directions[ 0 ]). toUpperCase ();
174 const vertical = dir === "UP" || dir === "DOWN" ;
175 template = vertical ? rec.gsap_template_vertical : rec.gsap_template_horizontal;
176 if ( ! template)
177 die ( `transition ${ rec . name }: missing ${ vertical ? "vertical" : "horizontal"} template` );
178 if (vertical) {
179 const dy = dir === "UP" ? - canvasH : canvasH;
180 subs.__DY__ = String (dy);
181 subs.__DYIN__ = String ( - dy);
182 } else {
183 const dx = dir === "LEFT" ? - canvasW : canvasW;
184 subs.__DX__ = String (dx);
185 subs.__DXIN__ = String ( - dx);
186 }
187 } else {
188 template = rec.gsap_template;
189 if ( ! template) die ( `transition ${ rec . name }: missing gsap_template` );
190 }
191 return template. map (( line ) => {
192 let out = line;
193 for ( const [ k , v ] of Object. entries (subs)) out = out. split (k). join (v);
194 return out;
195 });
196 }
197
198 function runInject ( argv ) {
199 const hyperframesDir = resolve ( flag (argv, "hyperframes" , "." ));
200 const storyboardPath = resolve ( flag (argv, "storyboard" , join (hyperframesDir, "STORYBOARD.md" )));
201 const indexPath = join (hyperframesDir, "index.html" );
202 const die = ( msg ) => {
203 console. error ( `✗ transitions inject: ${ msg }` );
204 process. exit ( 1 );
205 };
206
207 if ( ! existsSync (storyboardPath)) die ( `STORYBOARD.md not found at ${ storyboardPath }` );
208
209 const manifest = parseStoryboard ( readFileSync (storyboardPath, "utf8" ));
210 const { width : CW , height : CH } = parseFormat (manifest.globals.format);
211 const reg = loadTransitionRegistry ();
212 const byName = transitionsByName ();
213
214 // Read directly and handle ENOENT here, rather than an existsSync precheck —
215 // the check→write pair (write-back below) is a TOCTOU race CodeQL flags.
216 let html = "" ;
217 try {
218 html = readFileSync (indexPath, "utf8" );
219 } catch {
220 die ( `index.html not found at ${ indexPath } — run assemble-index.mjs first` );
221 }
222 const order = mountedFramesInOrder (manifest, html);
223 if (order. length === 0 ) die ( "no frame clips found in index.html" );
224 const frameIds = new Set (order. map (( x ) => x.id));
225 const clips = parseFrameClips (html, frameIds);
226
227 const gsapLines = [];
228 const applied = [];
229 for ( let i = 1 ; i < order. length ; i ++ ) {
230 const spec = parseTransitionIn (order[i].frame.transitionIn);
231 if ( ! spec) continue ; // hard cut
232 const incoming = clips. get (order[i].id);
233 const outgoing = clips. get (order[i - 1 ].id);
234 const rec = resolveRecord (spec, byName, reg, ( m ) =>
235 console. error ( ` ! frame ${ order [ i ]. id }: ${ m }` ),
236 );
237 const dur = resolveDur (spec, rec, reg);
238 const T = r3 (incoming.start); // cut = incoming start (frames tile)
239 const baseDuration = outgoing.duration;
240 outgoing.duration = r3 (baseDuration + dur); // extend outgoing only
241 extendFrameTail (hyperframesDir, order[i - 1 ].frame, baseDuration, outgoing.duration, die);
242 padFrameInternalDuration (
243 hyperframesDir,
244 order[i - 1 ].frame.src,
245 outgoing.id,
246 outgoing.duration,
247 );
248 gsapLines. push (
249 ... buildGsap (rec, outgoing.id, incoming.id, dur, T , spec.direction, CW , CH , die),
250 );
251 applied. push ({ from: outgoing.id, to: incoming.id, type: rec.name, dur, T });
252 }
253
254 if (applied. length === 0 ) {
255 console. log ( `✓ transitions inject: 0 transitions (all cuts) — index.html unchanged` );
256 return ;
257 }
258
259 // 0/1 ping-pong all frame clips in play order.
260 const ordered = [ ... clips. values ()]. sort (( a , b ) => a.start - b.start || a.id. localeCompare (b.id));
261 ordered. forEach (( c , i ) => {
262 c.track = i % 2 ;
263 });
264
265 // rewrite each clip block: start unchanged; duration possibly extended; track ping-ponged.
266 for ( const c of clips. values ()) {
267 const nb = c.block
268 . replace ( /data-duration=" [\d.] + "/ , `data-duration="${ c . duration }"` )
269 . replace ( /data-track-index=" \d + "/ , `data-track-index="${ c . track }"` );
270 html = html. replace (c.block, nb);
271 }
272
273 // stamp the GSAP after the master timeline anchor.
274 const anchor = 'window.__timelines["main"] = gsap.timeline({ paused: true });' ;
275 if ( ! html. includes (anchor)) die ( "master timeline anchor not found in index.html" );
276 // The transition tweens alone leave window.__timelines["main"] spanning only the
277 // last transition (e.g. 24.7s), shorter than the real composition. The Studio
278 // reads main.duration() as its master duration and parses clips against it, so a
279 // short master collapses its timeline (clips dropped, duration wrong, blank stage)
280 // — the render engine is unaffected (it trusts the root data-duration attr). Stamp
281 // a full-span anchor so main.duration() == composition total. Mirrors the
282 // `tl.to({}, { duration })` anchor captions.html already uses.
283 const rootDurMatch = html. match ( /data-composition-id="main" [ ^ >] *? data-duration="( [\d.] + )"/ );
284 const totalDur = rootDurMatch ? Number (rootDurMatch[ 1 ]) : null ;
285 const block = [
286 anchor,
287 " // ── frame transitions (injected by transitions.mjs) ──" ,
288 ' (function () { var tl = window.__timelines["main"];' ,
289 ... gsapLines. map (( l ) => " " + l),
290 ... (totalDur
291 ? [
292 ` tl.to({}, { duration: ${ totalDur } }, 0); // full-span anchor — main.duration() == composition total (Studio master duration)` ,
293 ]
294 : []),
295 " })();" ,
296 ]. join ( " \n " );
297 html = html. replace (anchor, block);
298
299 writeFileSync (indexPath, html);
300 console. log ( `✓ transitions inject: ${ applied . length } transition(s) stamped into index.html` );
301 for ( const a of applied) console. log ( ` ${ a . from }→${ a . to }: ${ a . type } ${ a . dur }s @ T=${ a . T }s` );
302 const tracks = ordered
303 . map (( c ) => `${ c . id }[t${ c . track } ${ c . start }→${ r3 ( c . start + c . duration ) }]` )
304 . join ( " " );
305 console. log ( ` tracks: ${ tracks }` );
306 }
307
308 function runVerify ( argv ) {
309 const hyperframesDir = resolve ( flag (argv, "hyperframes" , "." ));
310 const storyboardPath = resolve ( flag (argv, "storyboard" , join (hyperframesDir, "STORYBOARD.md" )));
311 const indexPath = resolve ( flag (argv, "index" , join (hyperframesDir, "index.html" )));
312 const bail = ( msg ) => {
313 console. error ( `✗ transitions verify: ${ msg }` );
314 process. exit ( 1 );
315 };
316
317 if ( ! existsSync (storyboardPath)) bail ( "STORYBOARD.md not found" );
318 if ( ! existsSync (indexPath)) bail ( "index.html not found" );
319
320 const manifest = parseStoryboard ( readFileSync (storyboardPath, "utf8" ));
321 const html = readFileSync (indexPath, "utf8" );
322 const order = mountedFramesInOrder (manifest, html);
323 const frameIds = new Set (order. map (( x ) => x.id));
324 const clips = parseFrameClips (html, frameIds);
325
326 const EPS = 0.011 ;
327 const overlaps = ( a , b ) =>
328 a.start < b.start + b.duration - EPS && b.start < a.start + a.duration - EPS ;
329 const fail = [];
330
331 // (4) global no same-track overlap. This is this workflow's own lane convention,
332 // not a lint rule: nothing in the framework rejects same-track overlap.
333 const all = [ ... clips. values ()];
334 for ( let i = 0 ; i < all. length ; i ++ )
335 for ( let j = i + 1 ; j < all. length ; j ++ ) {
336 const a = all[i];
337 const b = all[j];
338 if (a.track === b.track && overlaps (a, b))
339 fail. push ( `same-track overlap: ${ a . id }[t${ a . track }] & ${ b . id }[t${ b . track }]` );
340 }
341
342 const bm = html. match ( /frame transitions \( injected [\s\S] *? \}\)\(\) ;/ );
343 const txBlock = bm ? bm[ 0 ] : "" ;
344
345 let expected = 0 ;
346 for ( let i = 1 ; i < order. length ; i ++ ) {
347 const spec = parseTransitionIn (order[i].frame.transitionIn);
348 if ( ! spec) continue ;
349 expected ++ ;
350 const to = clips. get (order[i].id);
351 const from = clips. get (order[i - 1 ].id);
352 if ( ! to || ! from) {
353 fail. push ( `boundary ${ order [ i - 1 ]. id }→${ order [ i ]. id }: wrapper missing` );
354 continue ;
355 }
356 if ( ! txBlock. includes ( `"#el-${ from . id }"` ) || ! txBlock. includes ( `"#el-${ to . id }"` ))
357 fail. push ( `boundary ${ from . id }→${ to . id }: injected block does not reference both ids` );
358 const overlapAmt = r3 (from.start + from.duration - to.start);
359 if (overlapAmt <= 0 ) fail. push ( `boundary ${ from . id }→${ to . id }: no overlap (${ overlapAmt }s)` );
360 if (from.track === to.track)
361 fail. push ( `boundary ${ from . id }→${ to . id }: both on track ${ from . track }` );
362 }
363 if (expected > 0 && ! txBlock)
364 fail. push ( `${ expected } transition(s) expected but no injected block found` );
365
366 if (fail. length ) {
367 console. error ( `✗ transitions verify: ${ fail . length } failure(s):` );
368 for ( const f of fail) console. error ( ` - ${ f }` );
369 process. exit ( 1 );
370 }
371 console. log (
372 `✓ transitions verify: ${ expected } transition(s) verified (cross-track, overlap>0, both ids referenced, no same-track overlap)` ,
373 );
374 }
375
376 const sub = process.argv[ 2 ];
377 const rest = process.argv. slice ( 3 );
378 if (sub === "inject" ) runInject (rest);
379 else if (sub === "verify" ) runVerify (rest);
380 else {
381 console. error ( "usage: node transitions.mjs <inject|verify> [args...]" );
382 process. exit ( 2 );
383 }