main
John Lauer 0.1.29: pin complete canonical library viewer and preserve unowned zones eef56c4
#!/usr/bin/env python3
"""Build/serve an actual moving 3D library tour; keep detail separate from final overview."""
import argparse,pathlib,json,hashlib,shutil,http.server,subprocess,urllib.request,time,os,sys,struct
p=argparse.ArgumentParser();p.add_argument('action',choices=['build','serve','record','verify']);p.add_argument('--ai-thread');p.add_argument('--run',type=pathlib.Path);p.add_argument('--manifest',type=pathlib.Path);p.add_argument('--out',type=pathlib.Path,required=True);p.add_argument('--port',type=int,default=8878);a=p.parse_args();a.out=a.out.resolve()
def digest(path):return hashlib.sha256(path.read_bytes()).hexdigest()
if a.action=='build':
 if not a.manifest:p.error('--manifest is required for build')
 src=json.loads(a.manifest.read_text());assert src.get('eda') in ['kicad','altium','fusion'],'explicit target EDA required';assert src.get('mpnMarking') in ['on','off'],'resolve the marking preference before building the selected library'
 vendor=pathlib.Path(__file__).resolve().parent.parent/'vendor/3d-viewer'
 if not (vendor/'adom-3d-viewer-babylon9.esm.js').exists():
  subprocess.run(['bash',str(pathlib.Path(__file__).with_name('fetch-library-viewer.sh'))],check=True)
 shutil.copytree(vendor,a.out/'vendor/3d-viewer',dirs_exist_ok=True)
 rows=src.get('components',[]);assert rows,'empty component library';seen=set();a.out.mkdir(parents=True,exist_ok=True);(a.out/'models').mkdir(exist_ok=True);result=[]
 for r in rows:
  assert r['mpn'] not in seen,'duplicate component identity';seen.add(r['mpn']);selected=r['selectedModel'];expected='mpn' if src['mpnMarking']=='on' else 'plain';assert selected['variant']==expected,f"{r['mpn']}: tour/board variant mismatch; no silent plain fallback"
  for k in ['step','glb']:
   f=pathlib.Path(selected[k]);f=f if f.is_absolute() else a.manifest.parent/f;assert f.is_file() and f.stat().st_size>0,f'missing {k}: {r["mpn"]}';assert digest(f)==selected[k+'Sha256'],f'stale {k} hash: {r["mpn"]}'
  raw=f.read_bytes();assert raw[:4]==b'glTF' and struct.unpack_from('<I',raw,8)[0]==len(raw) and raw[16:20]==b'JSON',f'invalid GLB bytes: {r["mpn"]}'
  assert r.get('wiki') and r.get('provenance'),'wiki/provenance link required';glb=pathlib.Path(selected['glb']);glb=glb if glb.is_absolute() else a.manifest.parent/glb;name=digest(glb)+'.glb';shutil.copy2(glb,a.out/'models'/name);result.append({'mpn':r['mpn'],'wiki':r['wiki'],'provenance':r['provenance'],'glb':'models/'+name,'variant':expected,'stepSha256':selected['stepSha256'],'glbSha256':selected['glbSha256']})
 obj={'schemaVersion':1,'eda':src['eda'],'mpnMarking':src['mpnMarking'],'components':result,'secondsPerComponent':2,'projectUrl':src.get('projectUrl'),'selectionSha256':digest(a.manifest),'nativeLibraryEvidence':src.get('nativeLibraryEvidence'),'finalVideoPolicy':{'overviewSeconds':5,'detailIsSeparate':True,'finalMaximumSeconds':120,'wikiScrollSnippets':'optional; separate from default overview'}};(a.out/'tour.json').write_text(json.dumps(obj,indent=2));shutil.copy2(pathlib.Path(__file__).with_suffix('.html'),a.out/'index.html');print(f'Built {len(rows)}-component tour for {src["eda"]}; selected variants {src["mpnMarking"]}; this does not certify native library installation.')
elif a.action=='serve':
 assert (a.out/'tour.json').exists(),'build first'
 class Handler(http.server.SimpleHTTPRequestHandler):
  def __init__(self,*args,**kwargs):super().__init__(*args,directory=str(a.out),**kwargs)
  def do_POST(self):
   if self.path not in ['/recordings/detail.webm','/recordings/overview.webm']:self.send_error(404);return
   size=int(self.headers.get('Content-Length','0'))
   if size<=0 or size>256*1024*1024:self.send_error(413);return
   dest=a.out/self.path.lstrip('/');dest.parent.mkdir(exist_ok=True);dest.write_bytes(self.rfile.read(size));self.send_response(200);self.end_headers();self.wfile.write(b'OK')
  def log_message(self,*args):pass
 print(f'Library tour serving on http://localhost:{a.port}/',flush=True);http.server.ThreadingHTTPServer(('127.0.0.1',a.port),Handler).serve_forever()
elif a.action=='record':
 control=pathlib.Path.home()/'.adom/hydrogen-control-url';assert control.exists(),'Hydrogen control unavailable; open the tour in Pup and use the recording controls, then verify';base=control.read_text().strip();url=f'http://localhost:{a.port}/';subprocess.run(['adom-cli','hydrogen','webview','open-or-refresh','--name','Component library tour','--url',url,'--ai-thread',a.ai_thread or 'AI Flow component library','--reason','Review and record the selected native component library','--restart',__import__('shlex').join([sys.executable,__file__,'serve','--out',str(a.out),'--port',str(a.port),'--ai-thread',a.ai_thread or 'AI Flow component library']), '--restart-cwd',str(a.out)],check=True)
 def evaluate(js,waitForFrame=False):
  q={'target':url,'js':js};r=json.load(urllib.request.urlopen(urllib.request.Request(base+'/eval-in',data=json.dumps(q).encode(),headers={'Content-Type':'application/json'}),timeout=20));
  if not r.get('ok') and waitForFrame and 'frame/context not found' in str(r.get('error')):return {'ready':False}
  assert r.get('ok'),r;return r.get('value')
 deadline=time.monotonic()+180
 while time.monotonic()<deadline:
  state=evaluate('window.libraryTour?.stats?.() || {ready:false,error:window.libraryTour?.error}',True)
  if state and state.get('error'):raise RuntimeError(state)
  if state and state.get('ready'):break
  time.sleep(2)
 else:raise RuntimeError('Tour did not load; no recording accepted')
 for kind in ['detail','overview']:
  target=a.out/'recordings'/(kind+'.webm');old=target.stat().st_mtime_ns if target.exists() else 0;evaluate(f"void window.libraryTour.record({json.dumps(kind)})");deadline=time.monotonic()+max(60,len(json.load(open(a.out/'tour.json'))['components'])*2+60)
  while time.monotonic()<deadline:
   time.sleep(2);state=evaluate('window.libraryTour.stats()')
   if state.get('error'):raise RuntimeError(state)
   if target.exists() and target.stat().st_mtime_ns!=old and not state['recording']:break
  else:raise RuntimeError('Recording did not complete')
  print('Recorded',kind,flush=True)
 subprocess.run([sys.executable,__file__,'verify','--out',str(a.out)],check=True)
elif a.action=='verify':
 src=json.load(open(a.out/'tour.json'));clips=[]
 for kind in ['detail','overview']:
  raw=a.out/'recordings'/(kind+'.webm');assert raw.exists(),f'missing {kind} recording';mp4=raw.with_suffix('.mp4');subprocess.run(['ffmpeg','-y','-v','error','-i',str(raw),'-an','-vf','fps=24,scale=1280:720','-c:v','libx264','-preset','veryfast','-crf','28','-pix_fmt','yuv420p','-movflags','+faststart',str(mp4)],check=True);meta=json.loads(subprocess.check_output(['ffprobe','-v','error','-show_format','-show_streams','-of','json',str(mp4)]));seconds=float(meta['format']['duration']);assert mp4.stat().st_size<10_000_000,'clip exceeds page cap; reduce bitrate before publishing';assert int(meta['streams'][0]['nb_frames'])<=seconds*25+2,'unexpected frame duplication';expected=5 if kind=='overview' else 2*len(src['components']);assert abs(seconds-expected)<3,f'{kind}: wrong duration {seconds}';sheet=raw.with_name(kind+'-sheet.png');subprocess.run(['ffmpeg','-y','-v','error','-i',str(mp4),'-vf',f'fps=1/{max(seconds/12,.1)},scale=320:-2,tile=4x3','-frames:v','1',str(sheet)],check=True);clips.append({'kind':kind,'raw':str(raw),'file':str(mp4),'seconds':seconds,'sha256':digest(mp4),'contactSheet':str(sheet),'visualReview':'pending','useInFinal':kind=='overview'})
 (a.out/'clips.json').write_text(json.dumps({'selectionSha256':src['selectionSha256'],'mpnMarking':src['mpnMarking'],'clips':clips,'finalMaximumSeconds':120,'notes':'Review both contact sheets and playback before registration; only the overview belongs in the final cut by default.'},indent=2));print(json.dumps(clips,indent=2))