← back to Wallpaper Paint Antics

scripts/production.cjs

71 lines

// Bounded local production and verification of the three Wallo & Pippa shorts.
const fs=require('node:fs');
const path=require('node:path');
const assert=require('node:assert/strict');
const {spawnSync}=require('node:child_process');
const {chromium}=require('/Users/macstudio3/Projects/Designer-Wallcoverings/node_modules/playwright');
const root=path.resolve(__dirname,'..');
const cli='/Users/macstudio3/.npm/_npx/702923228c2ce1e6/node_modules/hyperframes/bin/hyperframes.mjs';
const slugs=['01-runaway-roll','02-splash-of-personality','03-great-wall-off'];
const out=path.join(root,'verification');
const phase=process.argv[2]||'preview';
function exec(cmd,args,log){const r=spawnSync(cmd,args,{cwd:root,encoding:'utf8',maxBuffer:16*1024*1024,env:{...process.env,HYPERFRAMES_TELEMETRY_DISABLED:'1'}});if(log)fs.writeFileSync(path.join(out,log),r.stdout+'\n'+r.stderr);if(r.status!==0)throw new Error(cmd+' '+args.join(' ')+'\n'+r.stdout+'\n'+r.stderr);return r.stdout;}
(async()=>{
 if(phase.startsWith('series2-')){process.argv[2]=phase.slice(8);await import('../series2/production.cjs');return;}
 if(phase==='mcp'){await import('./mcp-proof.mjs');return;}
 if(phase==='render'){
   for(const slug of slugs){console.log('Rendering '+slug);exec('node',[cli,'render','.', '-c','episodes/'+slug+'.html','--quality','high','--fps','30','--workers','2','--output','renders/'+slug+'.mp4'],slug+'-render.txt');console.log('Rendered '+slug);}
   return;
 }
 if(phase==='check'){
   exec('node',[cli,'check','.','--snapshots','--json'],'hyperframes-check.json');console.log('HyperFrames check passed.');return;
 }
 const browser=await chromium.launch({executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',headless:true});
 console.log('Isolated preview browser ready.');
 const checks=[];let errors=[];
 try{
  const page=await browser.newPage({viewport:{width:1080,height:1920},deviceScaleFactor:1});page.on('pageerror',e=>errors.push(e.message));
  if(phase==='preview'){
   for(let ep=0;ep<3;ep++){
    console.log('Opening '+slugs[ep]);
    await page.goto('file://'+path.join(root,'episodes',slugs[ep]+'.html'),{waitUntil:'domcontentloaded',timeout:20000});
    await page.evaluate(async()=>{await Promise.race([document.fonts.ready,new Promise((_,reject)=>setTimeout(()=>reject(new Error('Font readiness timeout')),10000))]);return true;});
    console.log('Fonts ready; sampling animation.');
    const hashes=[];
    for(const t of [1,5.5,8,12.8,16,19]){
     await page.evaluate(t=>{window.__timelines.main.seek(t,false);},t);
     const png=await page.locator('#stage').screenshot({path:path.join(out,slugs[ep]+'-'+t+'.png')});
     hashes.push(require('node:crypto').createHash('sha256').update(png).digest('hex'));
    }
    assert.equal(new Set(hashes).size,6);checks.push({name:slugs[ep]+' animated timeline at 6 story beats',verdict:'PASS',sha256:hashes});
    await page.evaluate(()=>{window.__timelines.main.seek(8,false);});const first=await page.locator('#stage').screenshot();
    await page.evaluate(()=>{window.__timelines.main.seek(3,false);});await page.evaluate(()=>{window.__timelines.main.seek(8,false);});const second=await page.locator('#stage').screenshot();assert(first.equals(second));
    checks.push({name:slugs[ep]+' backward seek determinism',verdict:'PASS'});
   }
   for(const kind of ['wallo','pippa']){const url=await page.evaluate(kind=>window.exportCharacter(kind),kind);fs.writeFileSync(path.join(root,'assets',kind+'.png'),Buffer.from(url.split(',')[1],'base64'));}
   // Contact sheet is a browser-native display of authored frames, preserving assets.
   await page.setViewportSize({width:1440,height:1330});await page.setContent('<html><body style="margin:0;background:#243b42"><div style="display:grid;grid-template-columns:repeat(6,240px)">'+slugs.flatMap(slug=>[1,5.5,8,12.8,16,19].map(t=>'<div><img style="width:240px;display:block" src="file://'+path.join(out,slug+'-'+t+'.png')+'"><div style="font:16px monospace;color:#fff7e8;text-align:center">'+slug.slice(0,2)+' / '+t+'s</div></div>')).join('')+'</div></body></html>');
   await page.evaluate(()=>Promise.all([...document.images].map(i=>i.decode())));await page.screenshot({path:path.join(out,'contact-sheet.png'),fullPage:true});
  } else if(phase==='verify'){
   for(const slug of slugs){
    const file=path.join(root,'renders',slug+'.mp4');assert(fs.statSync(file).size>100000);
    const probe=JSON.parse(exec('ffprobe',['-v','error','-show_streams','-show_format','-of','json',file]));
    const video=probe.streams.find(s=>s.codec_type==='video'),audio=probe.streams.find(s=>s.codec_type==='audio');
    assert.equal(video.width,1080);assert.equal(video.height,1920);assert.equal(video.r_frame_rate,'30/1');assert(Math.abs(Number(probe.format.duration)-20)<.08);assert(audio);assert.equal(Number(video.nb_frames),600);
    exec('ffmpeg',['-v','error','-i',file,'-f','null','-'],slug+'-decode.txt');
    await page.goto('file://'+path.join(root,'watch.html'));
    assert.equal(await page.locator('h1').textContent(),'Wallo & Pippa.');
    assert.equal(await page.locator('.cast img').first().getAttribute('src'),'assets/wallo.png');
    const v=page.locator('video[data-slug="'+slug+'"]');
    await v.evaluate(async v=>{v.muted=true;await v.play();});
    await page.waitForFunction(slug=>document.querySelector('video[data-slug="'+slug+'"]').currentTime>.4,slug,{timeout:15000});
    const advancing=await v.evaluate(v=>v.currentTime);assert(advancing>.4);await v.evaluate(v=>v.pause());
    for(const t of [1,8,13,19]){await v.evaluate((v,t)=>new Promise(resolve=>{v.addEventListener('seeked',resolve,{once:true});v.currentTime=t;}),t);await v.screenshot({path:path.join(out,slug+'-mp4-'+t+'.png')});}
    const playback=await v.evaluate(v=>({duration:v.duration,width:v.videoWidth,height:v.videoHeight,error:v.error?.message||null}));assert.equal(playback.error,null);checks.push({name:slug+' delivered file, full decode and real browser playback',verdict:'PASS',file,bytes:fs.statSync(file).size,frames:video.nb_frames,duration:probe.format.duration,audio:audio.codec_name,playback,characterName:'Wallo'});
   }
  } else throw new Error('Unknown phase: '+phase);
  assert.deepEqual(errors,[]);checks.push({name:'No browser JavaScript errors',verdict:'PASS'});
 }finally{await browser.close();}
 fs.writeFileSync(path.join(out,phase+'-evidence.json'),JSON.stringify({phase,timestamp:new Date().toISOString(),checks},null,2));console.log(JSON.stringify(checks.map(c=>({name:c.name,verdict:c.verdict})),null,2));
})().catch(e=>{console.error(e.stack);process.exitCode=1;});