22/**
33 * Regenerates the README movement GIFs from the real renderer.
44 *
5- * Boots the playground with Vite, drives the live viewer headlessly with
6- * Playwright (seek → captureFrame per animation frame), and encodes looping
7- * GIFs with gifenc. Frames are composed to the target size inside the page
8- * (cover-crop of the viewer canvas), so no image tooling is needed in node.
9- *
10- * Not wired into `npm run build`: run it manually when the figure or a
11- * showcased movement changes, and commit the output.
5+ * Thin wrapper over scripts/lib/gif-capture.mjs (the shared capture core, also
6+ * used by capture-launch-gifs.mjs). Not wired into `npm run build`: run it
7+ * manually when the figure or a showcased movement changes, and commit output.
128 *
139 * Usage:
1410 * node scripts/capture-gifs.mjs # all README gifs
1511 * node scripts/capture-gifs.mjs squat # just one
16- *
17- * The Chromium binary is resolved from PLAYWRIGHT_BROWSERS_PATH/chromium or
18- * POSECODE_CHROMIUM.
1912 */
20- import { createServer } from "vite" ;
21- import { chromium } from "playwright-core" ;
22- import gifencPkg from "gifenc" ; // CJS: no named ESM exports
23- const { GIFEncoder, quantize, applyPalette } = gifencPkg ;
24- import { writeFile } from "node:fs/promises" ;
25- import { existsSync } from "node:fs" ;
2613import { fileURLToPath } from "node:url" ;
2714import { dirname , resolve } from "node:path" ;
15+ import { captureGifs } from "./lib/gif-capture.mjs" ;
2816
2917const here = dirname ( fileURLToPath ( import . meta. url ) ) ;
3018const repoRoot = resolve ( here , ".." ) ;
@@ -37,99 +25,11 @@ const TARGETS = [
3725 { id : "lateral" , out : "lateral-raise" , size : [ 420 , 582 ] , fps : 14 } ,
3826] ;
3927
40- function chromiumPath ( ) {
41- if ( process . env . POSECODE_CHROMIUM ) return process . env . POSECODE_CHROMIUM ;
42- const base = process . env . PLAYWRIGHT_BROWSERS_PATH ;
43- if ( base && existsSync ( `${ base } /chromium` ) ) return `${ base } /chromium` ;
44- return chromium . executablePath ( ) ;
45- }
46-
4728const only = process . argv [ 2 ] ;
4829const targets = TARGETS . filter ( ( t ) => ! only || t . id === only || t . out === only ) ;
4930if ( targets . length === 0 ) {
5031 console . error ( `no such target: ${ only } ` ) ;
5132 process . exit ( 1 ) ;
5233}
5334
54- const server = await createServer ( {
55- configFile : resolve ( repoRoot , "playground/vite.config.ts" ) ,
56- server : { port : 0 , host : "127.0.0.1" } ,
57- logLevel : "error" ,
58- } ) ;
59- await server . listen ( ) ;
60- // Use the ACTUAL bound port: with `port: 0`, config.server.port stays 0 (and
61- // `0 ?? …` keeps 0), so read the listening socket instead.
62- const port = server . httpServer . address ( ) . port ;
63- const origin = `http://127.0.0.1:${ port } ` ;
64-
65- const browser = await chromium . launch ( { executablePath : chromiumPath ( ) } ) ;
66- const page = await browser . newPage ( { viewport : { width : 1600 , height : 1000 } } ) ;
67- page . on ( "pageerror" , ( e ) => console . error ( "[page]" , e . message ) ) ;
68-
69- for ( const t of targets ) {
70- const [ w , h ] = t . size ;
71- // `/play/<id>` is a production rewrite (vercel); in the vite dev server used
72- // here it doesn't resolve, so use the SPA entry + hash which loads the doc.
73- await page . goto ( `${ origin } /play.html#doc=${ t . id } ` , { waitUntil : "load" } ) ;
74- await page . reload ( { waitUntil : "load" } ) ;
75- await page . waitForFunction ( ( ) => window . __posecodeViewer ?. duration > 0 , null , {
76- timeout : 60000 ,
77- } ) ;
78- await page . waitForFunction ( ( ) => window . __posecodeViewer . characterActive === true , null , {
79- timeout : 60000 ,
80- } ) ;
81- await page . waitForTimeout ( 1600 ) ; // let the auto-framing camera settle
82-
83- const duration = await page . evaluate ( ( ) => {
84- const v = window . __posecodeViewer ;
85- v . pause ( ) ;
86- return v . duration ;
87- } ) ;
88- const frameCount = Math . round ( duration * t . fps ) ;
89- const delayMs = Math . round ( 1000 / t . fps ) ;
90-
91- const gif = GIFEncoder ( ) ;
92- let palette = null ;
93- for ( let i = 0 ; i < frameCount ; i ++ ) {
94- const time = ( i / t . fps ) % duration ;
95- const b64 = await page . evaluate (
96- ( { time, w, h } ) => {
97- const v = window . __posecodeViewer ;
98- v . seek ( time ) ;
99- v . captureFrame ( ) ;
100- const src = document . getElementById ( "canvas" ) ;
101- // Cover-crop the viewer canvas into the target frame.
102- const scale = Math . max ( w / src . width , h / src . height ) ;
103- const sw = w / scale ;
104- const sh = h / scale ;
105- const sx = ( src . width - sw ) / 2 ;
106- const sy = ( src . height - sh ) / 2 ;
107- const out = new OffscreenCanvas ( w , h ) ;
108- const ctx = out . getContext ( "2d" ) ;
109- ctx . drawImage ( src , sx , sy , sw , sh , 0 , 0 , w , h ) ;
110- const data = ctx . getImageData ( 0 , 0 , w , h ) . data ;
111- let bin = "" ;
112- for ( let j = 0 ; j < data . length ; j += 8192 ) {
113- bin += String . fromCharCode . apply ( null , data . subarray ( j , j + 8192 ) ) ;
114- }
115- return btoa ( bin ) ;
116- } ,
117- { time, w, h } ,
118- ) ;
119- const rgba = Uint8Array . from ( Buffer . from ( b64 , "base64" ) ) ;
120- // One palette for the whole clip keeps the loop flicker-free (the scene
121- // lighting is static; only the figure moves).
122- if ( ! palette ) palette = quantize ( rgba , 256 ) ;
123- const indexed = applyPalette ( rgba , palette ) ;
124- gif . writeFrame ( indexed , w , h , { palette : i === 0 ? palette : undefined , delay : delayMs } ) ;
125- }
126- gif . finish ( ) ;
127-
128- const outName = `${ t . out ?? t . id } .gif` ;
129- const outPath = resolve ( repoRoot , "docs/media" , outName ) ;
130- await writeFile ( outPath , gif . bytes ( ) ) ;
131- console . log ( `wrote docs/media/${ outName } (${ frameCount } frames @ ${ t . fps } fps, ${ w } x${ h } )` ) ;
132- }
133-
134- await browser . close ( ) ;
135- await server . close ( ) ;
35+ await captureGifs ( targets , { repoRoot, outDir : "docs/media" } ) ;
0 commit comments