Setting the file. One moment.
Preview Frames · Embedded Captions · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page 14.42
Hershey Script1
scripts/ preview-frames.cjs
JavaScript · 293 lines · 11 KB
15 * Use it BEFORE rendering: eyeball placement, occlusion, washout, text-on-text —
16 * the failure classes the geometric gates can't judge. The QA checklist lives in
17 * SKILL.md § Visual QA. (Video elements show poster/first-frame in the screenshot;
18 * the REAL a-roll pixels come from frames_bg, so previews stay accurate.)
19 *
20 * Sample-time default: climax/groups midpoints from standard.json or plan.json,
21 * else 25/50/75% of the clip.
22 */
23 const path = require ( "path" );
24 const fs = require ( "fs" );
25 const os = require ( "os" );
26 const crypto = require ( "crypto" );
27 const { pathToFileURL } = require ( "url" );
28
29 function sriSha384 ( source ) {
30 return `sha384-${ crypto . createHash ( "sha384" ). update ( source ). digest ( "base64" ) }` ;
31 }
32
33 function withPreviewGsapSri ( html , gsapSource ) {
34 const integrity = sriSha384 (gsapSource);
35 return html. replace ( /<script \b (?= [ ^ >] *\b src= ["'][ ^ "'] * gsap [ ^ "'] * ["'] ) [ ^ >] * >/ gi , ( tag ) =>
36 tag
37 . replace ( / \s + integrity \s * = \s * (?:" [ ^ "] * " | ' [ ^ '] * ')/ gi , "" )
38 . replace ( /> $ / , ` integrity="${ integrity }">` ),
39 );
40 }
41
42 const HF_ROOTS = [
43 process.env. HYPERFRAMES_ROOT ,
44 path. resolve (__dirname, "../../.." ),
45 path. join (os. homedir (), "Downloads" , "hyperframes" ),
46 ]. filter (Boolean);
47 function findInBun ( root , pkg , sub ) {
48 const cands = [path. join (root, "node_modules" , pkg)];
49 const bunDir = path. join (root, "node_modules" , ".bun" );
50 try {
51 if (fs. existsSync (bunDir))
52 for ( const d of fs. readdirSync (bunDir))
53 if (d. startsWith (pkg + "@" )) cands. push (path. join (bunDir, d, "node_modules" , pkg));
54 } catch {}
55 for ( const c of cands) {
56 const p = sub ? path. join (c, sub) : c;
57 if (fs. existsSync (p)) return p;
58 }
59 return null ;
60 }
61 let puppeteer = null ,
62 sharp = null ,
63 gsapSource = null ;
64 for ( const r of HF_ROOTS ) {
65 if ( ! puppeteer) {
66 const p = findInBun (r, "puppeteer" );
67 if (p)
68 try {
69 puppeteer = require (p);
70 } catch {}
71 }
72 if ( ! sharp) {
73 const p = findInBun (r, "sharp" );
74 if (p)
75 try {
76 sharp = require (p);
77 } catch {}
78 }
79 if ( ! gsapSource) {
80 const g = findInBun (r, "gsap" , path. join ( "dist" , "gsap.min.js" ));
81 if (g) gsapSource = fs. readFileSync (g);
82 }
83 }
84 if (require.main === module && ( ! puppeteer || ! sharp)) {
85 console. error ( "[preview] need puppeteer+sharp — set HYPERFRAMES_ROOT" );
86 process. exit ( 0 );
87 }
88
89 async function shotAt ( browser , file , W , H , t ) {
90 const page = await browser. newPage ();
91 try {
92 await page. setViewport ({ width: W , height: H , deviceScaleFactor: 1 });
93 // Serve the page's own CDN <script src=gsap> request from the local bundle
94 // (offline-safe) instead of injecting gsap at document-start: a document-start
95 // evaluation runs while document.head is still null, and gsap's init then
96 // throws "appendChild of null" — which killed previews for theme projects.
97 await page. setRequestInterception ( true );
98 const documentUrl = pathToFileURL (file).href;
99 const documentHtml = gsapSource
100 ? withPreviewGsapSri (fs. readFileSync (file, "utf8" ), gsapSource)
101 : fs. readFileSync (file, "utf8" );
102 page. on ( "request" , ( req ) => {
103 const u = req. url ();
104 if (req. isNavigationRequest () && u === documentUrl) {
105 req. respond ({ status: 200 , contentType: "text/html" , body: documentHtml });
106 } else if (req. resourceType () === "script" && /gsap/ i . test (u) && / ^ https ? :/ i . test (u)) {
107 if (gsapSource)
108 req. respond ({ status: 200 , contentType: "application/javascript" , body: gsapSource });
109 else req. continue (); // no local bundle — let the CDN load (online machines)
110 } else if (req. resourceType () === "media" )
111 req. abort (); // a-roll pixels come from frames_bg
112 else req. continue ();
113 });
114 await page. goto (documentUrl, { waitUntil: "load" , timeout: 15000 });
115 const t0 = Date. now ();
116 let tlReady = false ;
117 while (Date. now () - t0 < 15000 ) {
118 tlReady = await page. evaluate (() => !! (window.__timelines && window.__timelines.main));
119 if (tlReady) break ;
120 await new Promise (( r ) => setTimeout (r, 120 ));
121 }
122 if ( ! tlReady) throw new Error ( `timeline never registered in ${ path . basename ( file ) }` );
123 // bundled @font-face → previews show the REAL faces (same set the renderer embeds)
124 try {
125 const fontsCss = path. join (__dirname, ".." , "modes" , "standard" , "fonts" , "fonts.css" );
126 if (fs. existsSync (fontsCss))
127 await page. addStyleTag ({ content: fs. readFileSync (fontsCss, "utf8" ) });
128 } catch {}
129 await page. evaluate ( async () => {
130 try {
131 await document.fonts.ready;
132 } catch {}
133 });
134 await page. evaluate (( t ) => {
135 const v = document. getElementById ( "a-roll" );
136 if (v) v.style.display = "none" ; // transparent hole for the bg frame
137 document.body.style.background = "transparent" ;
138 document.documentElement.style.background = "transparent" ;
139 window.__timelines.main. seek (t);
140 void document.body.offsetHeight;
141 }, t);
142 await new Promise (( r ) => setTimeout (r, 60 ));
143 return await page. screenshot ({ omitBackground: true }); // RGBA png of caption layer only
144 } finally {
145 await page. close (). catch (() => {});
146 }
147 }
148
149 async function main () {
150 const project = path. resolve (process.argv[ 2 ] || "" );
151 if ( ! process.argv[ 2 ]) {
152 console. error ( "usage: preview-frames.cjs <project-dir> [times...]" );
153 process. exit ( 1 );
154 }
155 const idx = path. join (project, "index.html" );
156 if ( ! fs. existsSync (idx)) {
157 console. error ( "[preview] no index.html — compile first" );
158 process. exit ( 1 );
159 }
160 const railP = path. join (project, "rail.html" );
161 const hasRail = fs. existsSync (railP);
162 const fgP = path. join (project, "index_fg.html" );
163 const hasFg = fs. existsSync (fgP); // hybrid: fg caps render ABOVE the matte (like the real composite)
164
165 let fps = 24 ;
166 try {
167 const f = parseFloat (
168 String (fs. readFileSync (path. join (project, "matte.fps" ), "utf8" )). replace ( / [ ^ \d.] / g , "" ),
169 );
170 if (f > 0 ) fps = f;
171 } catch {}
172
173 // sample times: explicit > climax window + line midpoints > thirds
174 let globalFg = false ;
175 try {
176 globalFg =
177 JSON . parse (fs. readFileSync (path. join (project, "plan.json" ), "utf8" )).caption_layer === "fg" ;
178 } catch {}
179 let times = process.argv. slice ( 3 ). map (Number). filter (Number.isFinite);
180 if ( ! times. length ) {
181 try {
182 const plan = JSON . parse (fs. readFileSync (path. join (project, "plan.json" ), "utf8" ));
183 // heroes get 2 samples each (entrance + hold); every OTHER group gets at least
184 // a shot at one midpoint — the old 2-per-group list truncated at 12 and silently
185 // dropped whole narration blocks from the sheet (cold-start agents missed bugs there)
186 const gs = plan.groups || [];
187 const heroes = gs. filter (( g ) => g.hero === true ),
188 rest = gs. filter (( g ) => ! g.hero);
189 for ( const g of heroes) {
190 const span = g.out - g.in;
191 times. push ( + (g.in + span * 0.25 ). toFixed ( 2 ), + (g.in + span * 0.7 ). toFixed ( 2 ));
192 }
193 const mids = rest. map (( g ) => + ((g.in + g.out) / 2 ). toFixed ( 2 ));
194 const budget = Math. max ( 2 , 16 - times. length );
195 const step = Math. max ( 1 , Math. ceil (mids. length / budget));
196 for ( let i = 0 ; i < mids. length ; i += step) times. push (mids[i]);
197 } catch {}
198 }
199 if ( ! times. length ) {
200 const n = fs. existsSync (path. join (project, "frames_bg" ))
201 ? fs. readdirSync (path. join (project, "frames_bg" )). length
202 : 0 ;
203 const dur = n / fps || 10 ;
204 times = [dur * 0.25 , dur * 0.5 , dur * 0.75 ]. map (( t ) => + t. toFixed ( 2 ));
205 }
206 times = [ ...new Set (times)]. slice ( 0 , 16 ). sort (( a , b ) => a - b);
207
208 const meta = await sharp (
209 path. join (
210 project,
211 "frames_bg" ,
212 fs
213 . readdirSync (path. join (project, "frames_bg" ))
214 . filter (( f ) => f. endsWith ( ".png" ))
215 . sort ()[ 0 ],
216 ),
217 ). metadata ();
218 const W = meta.width,
219 H = meta.height;
220 const outDir = path. join (project, "preview" );
221 fs. mkdirSync (outDir, { recursive: true });
222
223 const exe =
224 process.platform === "darwin"
225 ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
226 : "/usr/bin/google-chrome" ;
227 const browser = await puppeteer. launch ({
228 headless: "new" ,
229 executablePath: fs. existsSync (exe) ? exe : undefined ,
230 args: [ "--disable-web-security" , "--allow-file-access-from-files" , "--disable-dev-shm-usage" ],
231 });
232 const outs = [];
233 try {
234 for ( const t of times) {
235 const fi = Math. max ( 1 , Math. round (t * fps));
236 const bg = path. join (project, "frames_bg" , `f_${ String ( fi ). padStart ( 4 , "0" ) }.png` );
237 const fg = path. join (project, "frames_fg" , `f_${ String ( fi ). padStart ( 4 , "0" ) }.png` );
238 if ( ! fs. existsSync (bg)) {
239 console. error ( `[preview] no bg frame for t=${ t }` );
240 continue ;
241 }
242 const layers = [{ input: await shotAt (browser, idx, W , H , t) }]; // embed captions
243 // global caption_layer:"fg" → captions sit ON TOP of the subject; the matte
244 // must NOT be stacked over them (the render skips the overlay too).
245 if ( ! globalFg && fs. existsSync (fg)) layers. push ({ input: fg }); // subject occludes embed
246 if (hasFg) layers. push ({ input: await shotAt (browser, fgP, W , H , t), blend: "screen" }); // hybrid fg caps in front (screen, like the real ffmpeg pass)
247 if (hasRail) layers. push ({ input: await shotAt (browser, railP, W , H , t) }); // rail in front
248 const out = path. join (outDir, `t${ String ( t ). replace ( "." , "_" ) }.png` );
249 await sharp (bg). composite (layers). png (). toFile (out);
250 outs. push ({ t, out });
251 console. log ( `[preview] t=${ t }s → ${ out }` );
252 }
253 // contact sheet
254 if (outs. length ) {
255 const TW = 480 ,
256 TH = Math. round (( TW * H ) / W );
257 const comps = [];
258 for ( let i = 0 ; i < outs. length ; i ++ )
259 comps. push ({
260 input: await sharp (outs[i].out). resize ( TW , TH ). toBuffer (),
261 left: (i % 4 ) * TW ,
262 top: Math. floor (i / 4 ) * TH ,
263 });
264 const rows = Math. ceil (outs. length / 4 );
265 await sharp ({
266 create: {
267 width: 4 * TW ,
268 height: rows * TH ,
269 channels: 3 ,
270 background: { r: 16 , g: 16 , b: 16 },
271 },
272 })
273 . composite (comps)
274 . png ()
275 . toFile (path. join (outDir, "sheet.png" ));
276 console. log (
277 `[preview] contact sheet → ${ path . join ( outDir , "sheet.png" ) } (${ outs . length } frames @ ${ times . join ( ", " ) }s)` ,
278 );
279 }
280 } finally {
281 await Promise . race ([browser. close (). catch (() => {}), new Promise (( r ) => setTimeout (r, 8000 ))]);
282 }
283 }
284 if (require.main === module ) {
285 main ()
286 . then (() => process. exit ( 0 ))
287 . catch (( e ) => {
288 console. error ( "[preview]" , e.message);
289 process. exit ( 1 );
290 });
291 }
292
293 module . exports = { sriSha384, withPreviewGsapSri };