← back to Wallpaper Paint Antics

series2/production.cjs

78 lines

const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict');
const {spawnSync}=require('node:child_process');
const crypto=require('node:crypto');
const root=__dirname,phase=process.argv[2],out=path.join(root,'verification');
const episodes=JSON.parse(fs.readFileSync(path.join(root,'episodes.json')));
const cli='/Users/macstudio3/.npm/_npx/702923228c2ce1e6/node_modules/hyperframes/bin/hyperframes.mjs';
function exec(cmd,args,log){const r=spawnSync(cmd,args,{cwd:root,encoding:'utf8',maxBuffer:20e6,env:{...process.env,HYPERFRAMES_TELEMETRY_DISABLED:'1',HYPERFRAMES_PYTHON:'/Users/macstudio3/.venvs/wallo-media/bin/python'}});if(log)fs.writeFileSync(path.join(out,log),(r.stdout+'\n'+r.stderr).split('\n').map(l=>l.trimEnd()).join('\n'));if(r.status!==0)throw Error(r.stderr+'\n'+r.stdout);return r.stdout;}
(async()=>{
 if(phase==='serve'){
  const http=require('node:http');
  const types={'.html':'text/html','.js':'text/javascript','.json':'application/json','.png':'image/png','.jpg':'image/jpeg','.ttf':'font/ttf','.wav':'audio/wav','.mp4':'video/mp4','.txt':'text/plain'};
  const server=http.createServer((req,res)=>{try{const name=decodeURIComponent(new URL(req.url,'http://localhost').pathname);const file=path.resolve(root,'.'+(name==='/'?'/watch.html':name));if(!file.startsWith(root+path.sep)||!fs.existsSync(file)||!fs.statSync(file).isFile()){res.writeHead(404);res.end('Not found');return;}const size=fs.statSync(file).size;res.setHeader('Content-Type',types[path.extname(file)]||'application/octet-stream');res.setHeader('Accept-Ranges','bytes');const match=req.headers.range?.match(/^bytes=(\d+)-(\d*)$/);if(match){const start=+match[1],end=Math.min(size-1,match[2]?+match[2]:size-1);if(start> end){res.writeHead(416);res.end();return;}res.writeHead(206,{'Content-Range':`bytes ${start}-${end}/${size}`,'Content-Length':end-start+1});fs.createReadStream(file,{start,end}).pipe(res);}else{res.writeHead(200,{'Content-Length':size});fs.createReadStream(file).pipe(res);}}catch(e){res.writeHead(500);res.end('Error');}});
  server.listen(0,'127.0.0.1',()=>{fs.writeFileSync(path.join(out,'server.json'),JSON.stringify({port:server.address().port,pid:process.pid}));console.log('Local verification URL http://127.0.0.1:'+server.address().port);});return;
 }
 if(phase==='audio'){console.log(exec('/Users/macstudio3/.venvs/wallo-media/bin/python',['audio.py'],'audio-production.txt'));return;}
 if(phase==='build'){console.log(exec('node',['build.mjs']));return;}
 if(phase==='check'){
  const checks=[],entry=path.join(root,'index.html'),saved=fs.readFileSync(entry);
  try{for(const ep of episodes){fs.writeFileSync(entry,fs.readFileSync(path.join(root,'episodes',ep.slug+'.html'),'utf8').replaceAll('../assets/','assets/'));const r=exec('node',[cli,'check','.','--json'],ep.slug+'-check.txt');const end=r.lastIndexOf('}');const data=JSON.parse(r.slice(0,end+1));assert(data.ok);checks.push({slug:ep.slug,ok:data.ok,lint:data.lint,runtime:data.runtime,layout:data.layout});console.log(ep.slug+' check PASS');}}finally{fs.writeFileSync(entry,saved);}
  fs.writeFileSync(path.join(out,'checks.json'),JSON.stringify(checks,null,2));return;
 }
 if(phase==='render'){
  for(const ep of episodes){console.log('Rendering '+ep.slug);exec('node',[cli,'render','.', '-c','episodes/'+ep.slug+'.html','--quality','high','--fps','30','--workers','2','-o','renders/'+ep.slug+'.mp4'],ep.slug+'-render.txt');console.log('Rendered '+ep.slug);}
  const list=episodes.map(ep=>"file '"+path.join(root,'renders',ep.slug+'.mp4')+"'").join('\n');fs.writeFileSync(path.join(out,'concat.txt'),list+'\n');
  exec('ffmpeg',['-v','error','-y','-f','concat','-safe','0','-i','verification/concat.txt','-c','copy','-movflags','+faststart','renders/wallo-wallcovering-presentation.mp4'],'concat.log');console.log('Combined presentation complete.');return;
 }
 if(phase==='mcp'){await import('./mcp-proof.mjs');return;}
 if(phase==='memes'){await import('./memes.mjs');return;}
 if(phase==='smoke'){
  const {port}=JSON.parse(fs.readFileSync(path.join(out,'server.json')));
  console.log(exec('node',['/Users/macstudio3/.agents/skills/3x/run.js','--url','http://127.0.0.1:'+port+'/watch.html','--expect','Wallo','--selector','#presentation','--click','[data-jump="80"]','--no-open'],'3x.txt'));return;
 }
 if(phase==='delivery'){await import('./delivery-proof.mjs');return;}
 const {chromium}=require('/Users/macstudio3/Projects/Designer-Wallcoverings/node_modules/playwright');
 const browser=await chromium.launch({executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',headless:true});
 const page=await browser.newPage({viewport:{width:1080,height:1920}});const errors=[],checks=[];page.on('pageerror',e=>errors.push(e.message));
 try{
 if(phase==='http-debug'){
  page.on('console',m=>{if(m.type()==='error')console.log('CONSOLE',m.text(),m.location());});
  page.on('response',r=>{if(r.status()>=400)console.log('HTTP',r.status(),r.url());});
  page.on('requestfailed',r=>console.log('REQUEST',r.url(),r.failure()));
  const {port}=JSON.parse(fs.readFileSync(path.join(out,'server.json')));
  await page.goto('http://127.0.0.1:'+port+'/watch.html');await page.locator('[data-jump="80"]').click();await page.waitForTimeout(1000);console.log('PAGE ERRORS',errors);return;
 }
 if(phase==='preview'){
  for(const ep of episodes){
   await page.goto('file://'+path.join(root,'episodes',ep.slug+'.html'));await page.evaluate(()=>window.__ready);
   const snapshots=[];const angles={wallo:[[],[]],pippa:[[],[]]};let maxError=0;
   for(const t of [1.3,3.5,6.2,8.2,11.6,16.5,19]){
    await page.evaluate(t=>window.__draw(t),t);const b=await page.locator('#stage').screenshot({path:path.join(out,ep.slug+'-'+t+'.png')});snapshots.push(crypto.createHash('sha256').update(b).digest('hex'));
    const rigs=await page.evaluate(()=>window.__rigState);
    for(const rig of rigs){for(const limb of [...rig.arms,...rig.legs]){maxError=Math.max(maxError,Math.abs(Math.hypot(limb.root.x-limb.joint.x,limb.root.y-limb.joint.y)-limb.l1),Math.abs(Math.hypot(limb.end.x-limb.joint.x,limb.end.y-limb.joint.y)-limb.l2));}rig.arms.forEach((arm,i)=>angles[rig.kind][i].push(Math.atan2(arm.end.y-arm.joint.y,arm.end.x-arm.joint.x)));}
   }
   assert.equal(new Set(snapshots).size,7);assert(maxError<.00001);
   const armAngleRanges=Object.fromEntries(Object.entries(angles).map(([kind,sides])=>[kind,sides.map(v=>Math.max(...v)-Math.min(...v))]));
   for(const kind of ['wallo','pippa'])assert(Math.max(...armAngleRanges[kind])>.15,kind+' must articulate');
   await page.evaluate(()=>window.__draw(7));const before=await page.locator('#stage').screenshot();await page.evaluate(()=>window.__draw(3));await page.evaluate(()=>window.__draw(7));assert(before.equals(await page.locator('#stage').screenshot()));
   checks.push({slug:ep.slug,verdict:'PASS',uniqueFrames:7,maxBoneLengthError:maxError,armAngleRanges,reverseSeekDeterministic:true});console.log(ep.slug+' articulated preview PASS');
  }
  await page.setViewportSize({width:1500,height:1420});await page.setContent('<body style="margin:0;background:#eeeae2"><div style="display:grid;grid-template-columns:repeat(5,300px)">'+[6.2,11.6].flatMap(t=>episodes.map(ep=>'<div><img width="300" src="file://'+path.join(out,ep.slug+'-'+t+'.png')+'"><p>'+ep.slug+' / '+t+'s</p></div>')).join('')+'</div></body>');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 ep of [...episodes,{slug:'wallo-wallcovering-presentation',combined:true}]){
   const file=path.join(root,'renders',ep.slug+'.mp4'),probe=JSON.parse(exec('ffprobe',['-v','error','-show_streams','-show_format','-of','json',file]));
   const v=probe.streams.find(s=>s.codec_type==='video'),a=probe.streams.find(s=>s.codec_type==='audio');const expected=ep.combined?100:20;
   assert.equal(v.width,1080);assert.equal(v.height,1920);assert.equal(+v.nb_frames,expected*30);assert.equal(v.r_frame_rate,'30/1');assert(Math.abs(+probe.format.duration-expected)<.15);assert(a);
   exec('ffmpeg',['-v','error','-i',file,'-f','null','-'],ep.slug+'-decode.txt');
   await page.goto('file://'+path.join(root,'watch.html'));const selector='video[data-slug="'+ep.slug+'"]';const video=page.locator(selector);
   await video.evaluate(async v=>{v.muted=true;await v.play();});await page.waitForFunction(sel=>document.querySelector(sel).currentTime>.4,selector,{timeout:15000});await video.evaluate(v=>v.pause());
   for(const t of (ep.combined?[1,21,41,61,81,99]:[1,7,12,19])){await video.evaluate((v,t)=>new Promise(resolve=>{v.addEventListener('seeked',resolve,{once:true});v.currentTime=t;}),t);await video.screenshot({path:path.join(out,ep.slug+'-mp4-'+t+'.png')});}
   const playback=await video.evaluate(v=>({duration:v.duration,error:v.error?.message||null}));assert.equal(playback.error,null);
   checks.push({slug:ep.slug,verdict:'PASS',duration:+probe.format.duration,frames:+v.nb_frames,width:v.width,height:v.height,audio:a.codec_name,bytes:fs.statSync(file).size,sha256:crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'),playback});console.log(ep.slug+' encoded playback PASS');
  }
 }else throw Error('Unknown series2 phase '+phase);
 assert.deepEqual(errors,[]);
 }finally{await browser.close();}
 fs.writeFileSync(path.join(out,phase+'-evidence.json'),JSON.stringify({at:new Date().toISOString(),checks,errors},null,2));
})().catch(e=>{console.error(e.stack);process.exitCode=1;});