@@ -108,7 +108,7 @@ if (positionals[0] === 'export' || positionals[0] === 'spec') {
108108}
109109// Fingerprint of this code. A canvas left running from older code gets replaced (see ensureCanvas).
110110const VERSION = createHash ( 'sha1' )
111- . update ( [ 'bin/giotto.js' , 'public/app.html' , 'lib/edit.js' , 'lib/render.js' , 'lib/styles.js' , 'lib/blocks.js' , 'lib/scenes.js' , 'lib/animate.js' , 'lib/video.js' , 'lib/history.js' , 'lib/page.js' , 'public/index.html' ] . map ( ( f ) => readFileSync ( path . join ( root , f ) ) ) . join ( '\0' ) )
111+ . update ( [ 'bin/giotto.js' , 'public/app.html' , 'lib/edit.js' , 'lib/render.js' , 'lib/styles.js' , 'lib/blocks.js' , 'lib/scenes.js' , 'lib/animate.js' , 'lib/video.js' , 'lib/frame-worker.js' , 'lib/emoji.js' , 'lib/render-job.js' , 'lib/ history.js', 'lib/page.js' , 'public/index.html' ] . map ( ( f ) => readFileSync ( path . join ( root , f ) ) ) . join ( '\0' ) )
112112 . digest ( 'hex' )
113113 . slice ( 0 , 12 ) ;
114114const dir = path . resolve ( opts . dir ) ;
@@ -326,8 +326,7 @@ function serve() {
326326 const job = String ( nextJob ++ ) , file = path . join ( os . tmpdir ( ) , `giotto-${ process . pid } -${ job } .mp4` ) ;
327327 const rate = [ 24 , 30 , 60 ] . includes ( + fps ) ? + fps : 24 ;
328328 jobs . set ( job , { job, id, name : String ( name || id ) , size, fps : rate , seconds : timeline ( doc ) . total / 1000 , started : Date . now ( ) , progress : 0 , done : false , error : null , file } ) ;
329- import ( '../lib/video.js' )
330- . then ( ( { toMp4 } ) => toMp4 ( doc , style , file , { dark : ! ! dark , styles, size, fps : rate , onProgress : ( p ) => ( jobs . get ( job ) && ( jobs . get ( job ) . progress = p ) ) } ) )
329+ toMp4Isolated ( doc , style , file , { dark : ! ! dark , styles, size, fps : rate } , ( p ) => jobs . get ( job ) && ( jobs . get ( job ) . progress = p ) )
331330 . then ( async ( ) => { const j = jobs . get ( job ) ; if ( j ) Object . assign ( j , { done : true , progress : 1 , bytes : ( await fs . stat ( file ) ) . size , finished : Date . now ( ) } ) ; } ,
332331 ( e ) => jobs . get ( job ) && ( jobs . get ( job ) . error = e . message ) ) ;
333332 return send ( 200 , { job } ) ;
@@ -356,8 +355,9 @@ function serve() {
356355 return res . end ( png ) ;
357356 }
358357 if ( route === 'POST /api/png' ) {
358+ const png = await toPng ( ( await body ( ) ) . svg ) ; // rendered first, so a failure is a proper error
359359 res . writeHead ( 200 , { 'content-type' : 'image/png' } ) ;
360- return res . end ( await toPng ( ( await body ( ) ) . svg ) ) ;
360+ return res . end ( png ) ;
361361 }
362362 send ( 404 , { error : 'not found' } ) ;
363363 } catch ( e ) {
@@ -499,21 +499,49 @@ Pages export as png (drawn by Chrome, which must be installed) or html (one self
499499const linkTo = ( id ) => ( opts . cloud ? null : `${ url } /#${ id } ` ) ;
500500const seeIt = ( id ) => ( opts . cloud ? '' : ` Give the user this link: ${ id === undefined ? url : linkTo ( id ) } ` ) ;
501501
502- // PNG is drawn here from the exact SVG, so it never depends on a browser being open.
503- let Resvg ;
504- async function toPng ( svg ) {
505- if ( ! Resvg ) {
502+ // PNG and video are drawn from the exact SVG, so they never depend on a browser being open. Each runs in a
503+ // process of its own (lib/render-job.js): resvg aborts the process it runs in on some drawings, and that must
504+ // not take this server (the canvas, or the agent's MCP connection) down with it.
505+ let rendererReady = false ;
506+ async function renderJob ( job , onLine ) {
507+ if ( ! rendererReady ) {
506508 // Installs that updated from before this dependency existed won't have it yet: fetch it once.
507- const load = async ( ) => ( await import ( '@resvg/resvg-js' ) ) . Resvg ;
508- Resvg = await load ( ) . catch ( ( ) => {
509+ await import ( '@resvg/resvg-js' ) . catch ( ( ) => {
509510 log ( 'installing the PNG renderer (one time)...' ) ;
510511 execFileSync ( 'npm' , [ 'install' , '--omit=dev' , '--no-audit' , '--no-fund' , '--silent' ] , { cwd : root , stdio : [ 'ignore' , 'ignore' , 'inherit' ] } ) ;
511- return load ( ) ;
512512 } ) ;
513+ rendererReady = true ;
513514 }
514- const font = { loadSystemFonts : true } ;
515- const { emojify } = await import ( '../lib/emoji.js' ) ; // resvg can't draw color emoji: they go in as pictures
516- return new Resvg ( emojify ( svg , Resvg , font , 'Arial' ) , { fitTo : { mode : 'zoom' , value : 2 } , font } ) . render ( ) . asPng ( ) ;
515+ return new Promise ( ( resolve , reject ) => {
516+ const child = spawn ( process . execPath , [ path . join ( root , 'lib' , 'render-job.js' ) ] , { stdio : [ 'pipe' , 'pipe' , 'pipe' ] } ) ;
517+ const out = [ ] , err = [ ] ;
518+ let partial = '' ;
519+ child . stdout . on ( 'data' , ( d ) => {
520+ if ( ! onLine ) return out . push ( d ) ;
521+ partial += d ;
522+ const lines = partial . split ( '\n' ) ;
523+ partial = lines . pop ( ) ;
524+ lines . forEach ( onLine ) ;
525+ } ) ;
526+ child . stderr . on ( 'data' , ( d ) => err . push ( d ) ) ;
527+ child . on ( 'error' , reject ) ;
528+ child . on ( 'close' , ( code ) => {
529+ if ( code === 0 ) return resolve ( Buffer . concat ( out ) ) ;
530+ const why = Buffer . concat ( err ) . toString ( ) ;
531+ reject ( new Error ( / p a n i c k e d | f a t a l r u n t i m e e r r o r / . test ( why ) ? 'The renderer (resvg) crashed on this drawing; Giotto is fine. Something clipped may lie entirely outside the picture.' : why . trim ( ) . split ( '\n' ) . slice ( - 3 ) . join ( ' ' ) || `render failed (${ code } )` ) ) ;
532+ } ) ;
533+ child . stdin . end ( JSON . stringify ( job ) ) ;
534+ } ) ;
535+ }
536+ const toPng = ( svg ) => renderJob ( { kind : 'png' , svg } ) ;
537+ // A video, with progress (0..1) as it renders; resolves to toMp4's summary.
538+ async function toMp4Isolated ( doc , style , out , options , onProgress ) {
539+ let summary = null ;
540+ await renderJob ( { kind : 'mp4' , doc, style, out, options } , ( line ) => {
541+ if ( line . startsWith ( 'progress ' ) ) onProgress ?. ( + line . slice ( 9 ) ) ;
542+ else if ( line . startsWith ( 'done ' ) ) summary = JSON . parse ( line . slice ( 5 ) ) ;
543+ } ) ;
544+ return summary ;
517545}
518546
519547// Pages are HTML, so their PNG comes from a headless Chrome. First choice: chrome-headless-shell, if Playwright or
@@ -671,8 +699,7 @@ async function callTool(name, a = {}) {
671699 const out = path . resolve ( a . path || `${ a . id ? slugify ( doc . title || a . id , new Set ( ) ) : 'sample' } .${ ext } ` ) ;
672700 let made = '' ;
673701 if ( format === 'mp4' ) {
674- const { toMp4 } = await import ( '../lib/video.js' ) ;
675- const r = await toMp4 ( played , style , out , { dark, styles : await listStyles ( ) } ) ;
702+ const r = await toMp4Isolated ( played , style , out , { dark, styles : await listStyles ( ) } ) ;
676703 made = ` (${ r . seconds . toFixed ( 1 ) } s, ${ r . frames } frames)` ;
677704 } else await fs . writeFile ( out , format === 'svg' ? svg : format === 'png' ? png : toAnimatedSvg ( played , style , { dark, styles : await listStyles ( ) } ) ) ;
678705 const warnings = typeof a . style === 'object' && a . style ? styleWarnings ( a . style ) : [ ] ;
0 commit comments