← Commit history

Merge PR #143: Automate silkscreen dashboard recording and retain clips in the run

John Lauer ·be00dc3c7f · ·parent 3111719
3 files changed +31−8
tools/silkscreen-dashboard/SKILL.md+4
@@ -21,3 +21,7 @@ Record replay exports a WebM of the 2D canvas with a persistent REPLAY label and ## Acceptance  Audit the whole board on both faces: fitted body projections including the area between pads, every via/hole, actual mask openings, labels and leader paths. Missing Fab geometry is not empty space. Keep each label associated with its own component. Unresolved labels remain visible and block acceptance. The independent inventory in `silkscreen-audit` must be derived from the whole board, not from the same potentially incomplete obstacle list.++## Automated recording++Use `adom-aiflow silkscreen-dashboard control --json '{"record":"overview"}' --run <run> --ai-thread "<name>"` (or `walkthrough`). An open dashboard records through the same UI path and uploads the completed WebM into `<run>/silkscreen-dashboard/recordings/`; read `state.lastRecording` for the path. A queued request is not a completed clip. Verify nonempty decoded frames and visible replay/native-review status. Normalize WebM with ffmpeg `fps=30` before H.264 composition; MediaRecorder can report a 1000 Hz nominal rate. Keep each wiki asset below its cap.
tools/silkscreen-dashboard/index.html+3−3
@@ -2,7 +2,7 @@ :root{color-scheme:dark;font:14px system-ui;color:#e7edef;background:#141a20}*{box-sizing:border-box}body{margin:0;height:100vh;display:grid;grid-template-rows:48px 1fr 66px}header,footer{display:flex;align-items:center;gap:12px;padding:8px 18px;background:#1d252d;border-bottom:1px solid #35424d}header b{font-size:17px}button,select,input{font:inherit;color:inherit;background:#26333e;border:1px solid #536371;border-radius:5px;padding:7px}button{cursor:pointer}button:hover{border-color:#51cdc2}button.active{background:#24504f}main{display:grid;grid-template-columns:1fr 300px;min-height:0}#stage{position:relative;overflow:hidden;background:#111a1f}canvas{display:block;width:100%;height:100%;touch-action:none}#three{position:absolute;inset:0;display:none}aside{padding:18px;overflow:auto;background:#1a222a}h2{font-size:23px;margin:8px 0}p{line-height:1.5;color:#b5c4cd}.badge{border:1px solid #648076;border-radius:12px;padding:3px 10px;color:#7be0c7;font-size:12px}#native{color:#ffd293}.muted{color:#93a5b1;font-size:12px}#scrub{flex:1}#progress{height:5px;background:#33444f;margin:14px 0}#progress span{display:block;background:#48cdbb;height:100%;width:0}#legend{display:grid;gap:8px;margin-top:24px}.swatch{display:inline-block;width:12px;height:12px;margin-right:8px;border-radius:2px}#settings{position:absolute;right:20px;top:55px;z-index:8;background:#26313b;border:1px solid #566676;padding:18px;border-radius:8px}#settings label{display:block;margin:12px 0}#hud{position:absolute;left:16px;top:14px;pointer-events:none;background:#142028de;border:1px solid #41535e;padding:10px 14px;border-radius:6px}#error{color:#ff9999}a{color:#67dacc}footer{border-top:1px solid #35424d}#fit{margin-left:auto}@media(max-width:800px){main{grid-template-columns:1fr 220px}header{gap:5px;padding:7px}header b{font-size:14px}} </style></head><body><header><b>Adom AI Flow · Silkscreen</b><span id="mode" class="badge">Connecting</span><select id="face" aria-label="Board face"><option value="F.SilkS">Top</option><option value="B.SilkS">Bottom</option></select><button id="two" class="active">2D layout</button><button id="threeBtn">3D inspection</button><button id="fit">Fit board</button><button id="gear" aria-label="Settings">⚙</button></header><main><div id="stage"><canvas id="board"></canvas><div id="three"></div><div id="hud"><div id="headline">Loading board geometry</div><div class="muted" id="geometry"></div></div></div><aside><div class="muted">Placement decision</div><h2 id="part">Waiting</h2><p id="reason">No solver events yet.</p><div id="progress"><span></span></div><div id="count"></div><p id="native">Native CAD review pending</p><label>Inspect component <select id="pick"><option value="">Whole board</option></select></label><div id="legend"><div><i class="swatch" style="background:#516a78"></i>Fitted body / reserved envelope</div><div><i class="swatch" style="background:#cbad4c"></i>Via / mask opening</div><div><i class="swatch" style="background:#ffb963"></i>Sampled candidate</div><div><i class="swatch" style="background:#57dcc4"></i>Selected proposal</div><div><i class="swatch" style="background:#f18389"></i>Existing collision</div></div><p class="muted">Selection is a proposal, not an edit to the EDA board. Native text bounds drive collision checks. Preview text uses the browser font.</p><div id="error"></div></aside></main><footer><button id="live">Follow live</button><button id="play">Replay</button><input type="range" id="scrub" min="-1" max="0" value="-1" aria-label="Event timeline"><span id="stamp" class="muted">0 / 0</span><button id="record">Record walkthrough</button><button id="short">Record 5s overview</button></footer><div id="settings" hidden><b>Playback & display</b><label>Events per second <input id="speed" type="number" min="0.5" max="60" step="0.5"></label><label><input id="obstacles" type="checkbox"> Show obstacles</label><label><input id="follow" type="checkbox" checked> Follow selected component</label><label>Idle shutdown, hours <input id="ttl" type="number" min="1" max="168"></label><p class="muted">No AI/provider calls. Solver runs independently of playback.</p></div> <script type="module">-const $=s=>document.querySelector(s),canvas=$('#board'),ctx=canvas.getContext('2d');let data,history=[],state={},selected='',bounds,view,drag=null,viewer=null,scene=null,labelTexture=null,threeGroups=[],recording=false;const api=async(path,body)=>{const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({...body,aiThread:state.aiThread||'Silkscreen dashboard user'})});const j=await r.json();if(!r.ok)throw Error(j.error);if(j.state)await apply(j.state);return j};window.silkscreen={ready:false};+const $=s=>document.querySelector(s),canvas=$('#board'),ctx=canvas.getContext('2d');let data,history=[],state={},selected='',bounds,view,drag=null,viewer=null,scene=null,labelTexture=null,threeGroups=[],recording=false,handledRecording=null,recordReplay;const api=async(path,body)=>{const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({...body,aiThread:state.aiThread||'Silkscreen dashboard user'})});const j=await r.json();if(!r.ok)throw Error(j.error);if(j.state)await apply(j.state);return j};window.silkscreen={ready:false}; let cameraAnimation=0;function frame(b){cancelAnimationFrame(cameraAnimation);if(recording&&!state.summary&&view){const from=[...view],start=performance.now();const tick=now=>{const t=Math.min(1,(now-start)/220),ease=t*t*(3-2*t);view=from.map((v,i)=>v+(b[i]-v)*ease);draw();if(t<1)cameraAnimation=requestAnimationFrame(tick)};cameraAnimation=requestAnimationFrame(tick)}else{view=[...b];draw()}} function fitPart(ref){const os=data.obstacles.filter(o=>o.reference===ref&&o.layer===state.layer);if(!os.length)return;const b=[Math.min(...os.map(o=>o.box[0]))-3,Math.min(...os.map(o=>o.box[1]))-3,Math.max(...os.map(o=>o.box[2]))+3,Math.max(...os.map(o=>o.box[3]))+3];frame(b);if(viewer){const cam=viewer.getCamera();cam.setTarget(new window.BABYLON.Vector3((b[0]+b[2])/2,-(b[1]+b[3])/2,0));cam.radius=Math.max(b[2]-b[0],b[3]-b[1])*1.8}} function proposals(){const map=new Map();let start=0;for(let k=Math.min(state.cursor,history.length-1);k>=0;k--)if(history[k].kind==='start'){start=k;break}for(let k=start;k<=state.cursor&&k<history.length;k++){const e=history[k];if(e.kind==='placement')map.set(e.id,e.candidate)}return map}@@ -18,11 +18,11 @@ function draw(){if(!data||!view)return;const r=canvas.getBoundingClientRect();ca async function show3d(){if(viewer)return;$('#headline').textContent='Loading 3D geometry';try{await import('https://wiki.adom.inc/static/vendor/adom-3d-viewer-babylon9/adom-3d-viewer-babylon9.esm.js?v=20260915a');const B=window.BABYLON;viewer=window.Adom3DViewerBabylon9.init($('#three'),{zUp:true,showGround:false,showViewCube:false});scene=viewer.getScene();const b=data.bounds,cx=(b[0]+b[2])/2,cy=(b[1]+b[3])/2;const board=B.MeshBuilder.CreateBox('Board envelope',{width:b[2]-b[0],height:b[3]-b[1],depth:1.6},scene);board.position.set(cx,-cy,-.8);const bm=new B.PBRMaterial('board',scene);bm.albedoColor=B.Color3.FromHexString('#24563b');bm.metallic=0;bm.roughness=.85;board.material=bm; for(const o of data.obstacles.filter(o=>o.kind.startsWith('fitted'))){const b=o.box;const mesh=B.MeshBuilder.CreateBox(o.reference,{width:b[2]-b[0],height:b[3]-b[1],depth:o.height||1},scene);mesh.position.set((b[0]+b[2])/2,-(b[1]+b[3])/2,(o.height||1)/2+.03);const mat=new B.StandardMaterial('reserved envelope',scene);mat.diffuseColor=B.Color3.FromHexString('#5b707d');mat.alpha=.72;mesh.material=mat;threeGroups.push({mesh,o});if(o.glb){try{const imported=await B.SceneLoader.ImportMeshAsync('',o.glb,'',scene),group=new B.TransformNode('fitted_'+o.reference,scene);for(const m of imported.meshes)if(!m.parent)m.parent=group;group.scaling.setAll(1000);const theta=(o.at[2]||0)*Math.PI/180,dx=o.offset?.[0]||0,dy=o.offset?.[1]||0;group.position.set(o.at[0]+dx*Math.cos(theta)-dy*Math.sin(theta),-o.at[1]+dx*Math.sin(theta)+dy*Math.cos(theta),o.offset?.[2]||0);group.rotation.z=theta;mesh.setEnabled(false);const buckets=new Map();for(const m of imported.meshes){if(!m.getTotalVertices())continue;m.computeWorldMatrix(true);const mat=m.material,key=JSON.stringify([mat?.albedoColor?.asArray(),mat?.diffuseColor?.asArray(),mat?.emissiveColor?.asArray(),mat?.alpha,mat?.metallic,mat?.roughness]);if(!buckets.has(key))buckets.set(key,[]);buckets.get(key).push(m)}for(const ms of buckets.values()){const mat=ms[0].material;for(const m of ms)m.material=mat;if(ms.length>1)B.Mesh.MergeMeshes(ms,true,true,undefined,false,false)}}catch(error){$('#error').textContent='Model fallback for '+o.reference+': '+error.message}}} labelTexture=new B.DynamicTexture('silkscreen proposals',{width:2048,height:2048},scene,false);labelTexture.hasAlpha=true;const plane=B.MeshBuilder.CreatePlane('Silkscreen preview',{width:b[2]-b[0],height:b[3]-b[1]},scene);plane.position.set(cx,-cy,.015);const pm=new B.StandardMaterial('silkscreen',scene);pm.diffuseTexture=labelTexture;pm.opacityTexture=labelTexture;pm.useAlphaFromDiffuseTexture=true;pm.emissiveColor=B.Color3.White();pm.disableLighting=true;pm.backFaceCulling=false;plane.material=pm;viewer.getCamera().setTarget(new B.Vector3(cx,-cy,0));viewer.getCamera().radius=100;viewer.getCamera().beta=.65;viewer.getCamera().alpha=-1.6;viewer.getCamera().minZ=.01;viewer.getCamera().upperRadiusLimit=300;draw();$('#geometry').textContent='Fitted component models · rectangular board preview · native CAD review still required';}catch(e){$('#error').textContent=e.message}}-async function apply(s){const oldTotal=history.length;if(s.events)history=s.events;else if(s.total!==oldTotal){const full=await(await fetch('state')).json();history=full.events}state={...state,...s};window.silkscreen.state=state;const e=history[state.cursor];$('#mode').textContent=state.mode==='live'?'Live solver feed':'Recorded-event replay';$('#part').textContent=e?.id||'Whole board';$('#reason').textContent=e?.reason|| (e?.kind==='complete'?`Search complete; ${e.unresolved.length} unresolved groups.`:'Waiting for the next solver event.');$('#headline').textContent=e?.kind==='complete'?'Candidate layout · review pending':e?.id?`${e.id} · ${e.kind}`:'ESC silkscreen placement';$('#face').value=state.layer;$('#stamp').textContent=`${Math.max(0,state.cursor+1)} / ${history.length}`;$('#scrub').max=Math.max(0,history.length-1);$('#scrub').value=state.cursor;$('#count').textContent=`${proposals().size} selected groups`;$('#progress span').style.width=(history.length?(state.cursor+1)/history.length*100:0)+'%';$('#play').textContent=state.playing?'Pause':'Replay';$('#speed').value=state.settings.speed;$('#ttl').value=state.settings.ttlHours;$('#obstacles').checked=state.settings.showObstacles;if(e?.id&&selected!==e.id&&$('#follow').checked){selected=e.id;fitPart(selected)}$('#three').style.display=state.view==='3d'?'block':'none';$('#board').style.visibility=state.view==='3d'?'hidden':'visible';if(state.view==='3d'){await show3d();if(scene){const plane=scene.getMeshByName('Silkscreen preview');if(plane)plane.position.z=state.layer==='B.SilkS'?-1.615:.015;if(s.layer)viewer.getCamera().beta=state.layer==='B.SilkS'?Math.PI-.65:.65}}draw()}+async function apply(s){const oldTotal=history.length;if(s.events)history=s.events;else if(s.total!==oldTotal){const full=await(await fetch('state')).json();history=full.events}state={...state,...s};window.silkscreen.state=state;const e=history[state.cursor];$('#mode').textContent=state.mode==='live'?'Live solver feed':'Recorded-event replay';$('#part').textContent=e?.id||'Whole board';$('#reason').textContent=e?.reason|| (e?.kind==='complete'?`Search complete; ${e.unresolved.length} unresolved groups.`:'Waiting for the next solver event.');$('#headline').textContent=e?.kind==='complete'?'Candidate layout · review pending':e?.id?`${e.id} · ${e.kind}`:'ESC silkscreen placement';$('#face').value=state.layer;$('#stamp').textContent=`${Math.max(0,state.cursor+1)} / ${history.length}`;$('#scrub').max=Math.max(0,history.length-1);$('#scrub').value=state.cursor;$('#count').textContent=`${proposals().size} selected groups`;$('#progress span').style.width=(history.length?(state.cursor+1)/history.length*100:0)+'%';$('#play').textContent=state.playing?'Pause':'Replay';$('#speed').value=state.settings.speed;$('#ttl').value=state.settings.ttlHours;$('#obstacles').checked=state.settings.showObstacles;if(e?.id&&selected!==e.id&&$('#follow').checked){selected=e.id;fitPart(selected)}$('#three').style.display=state.view==='3d'?'block':'none';$('#board').style.visibility=state.view==='3d'?'hidden':'visible';if(state.view==='3d'){await show3d();if(scene){const plane=scene.getMeshByName('Silkscreen preview');if(plane)plane.position.z=state.layer==='B.SilkS'?-1.615:.015;if(s.layer)viewer.getCamera().beta=state.layer==='B.SilkS'?Math.PI-.65:.65}}draw();if(state.recordRequest&&state.recordRequest.id!==handledRecording){handledRecording=state.recordRequest.id;setTimeout(()=>recordReplay(state.recordRequest.kind==='overview').catch(e=>$('#error').textContent=e.message),0)}} try{data=await(await fetch('board.json')).json();bounds=data.bounds;view=[...bounds];const refs=[...new Set(data.obstacles.filter(o=>o.kind.startsWith('fitted')).map(o=>o.reference))].sort();for(const r of refs){const o=document.createElement('option');o.value=r;o.textContent=r;$('#pick').append(o)}$('#geometry').textContent=`${refs.length} fitted bodies · ${data.viaCount} vias · native measured text bounds`; await apply(await(await fetch('state')).json());const stream=new EventSource('stream');stream.addEventListener('state',e=>apply(JSON.parse(e.data)).catch(e=>$('#error').textContent=e.message));stream.onerror=()=>$('#mode').textContent='Reconnecting';window.silkscreen.ready=true; $('#live').onclick=()=>api('control',{mode:'live'});$('#play').onclick=()=>api('control',{mode:'replay',summary:false,playing:!state.playing,cursor:state.cursor>=history.length-1?-1:state.cursor});$('#scrub').oninput=e=>api('control',{mode:'replay',playing:false,cursor:+e.target.value});$('#face').onchange=e=>api('control',{layer:e.target.value});$('#two').onclick=()=>api('control',{view:'2d'});$('#threeBtn').onclick=()=>api('control',{view:'3d'});$('#fit').onclick=()=>{selected='';frame(bounds);if(viewer){const b=bounds;viewer.getCamera().setTarget(new window.BABYLON.Vector3((b[0]+b[2])/2,-(b[1]+b[3])/2,0));viewer.getCamera().radius=100}};$('#pick').onchange=e=>{selected=e.target.value;$('#follow').checked=false;selected?fitPart(selected):frame(bounds)};$('#gear').onclick=()=>$('#settings').hidden=!$('#settings').hidden;$('#speed').onchange=e=>api('settings',{speed:+e.target.value});$('#ttl').onchange=e=>api('settings',{ttlHours:+e.target.value});$('#obstacles').onchange=e=>api('settings',{showObstacles:e.target.checked}); canvas.onwheel=e=>{e.preventDefault();const f=e.deltaY>0?1.15:.87;const r=canvas.getBoundingClientRect(),px=(e.clientX-r.left)/r.width,py=(e.clientY-r.top)/r.height,w=view[2]-view[0],h=view[3]-view[1];view=[view[0]+w*px*(1-f),view[1]+h*py*(1-f),view[0]+w*px*(1-f)+w*f,view[1]+h*py*(1-f)+h*f];draw()};canvas.onpointerdown=e=>{drag={x:e.clientX,y:e.clientY,v:[...view]};canvas.setPointerCapture(e.pointerId)};canvas.onpointermove=e=>{if(!drag)return;const r=canvas.getBoundingClientRect(),dx=(e.clientX-drag.x)/r.width*(drag.v[2]-drag.v[0]),dy=(e.clientY-drag.y)/r.height*(drag.v[3]-drag.v[1]);view=[drag.v[0]-dx,drag.v[1]-dy,drag.v[2]-dx,drag.v[3]-dy];draw()};canvas.onpointerup=()=>drag=null;-async function recordReplay(summary=false){if(recording)return;const latest=history.map((e,i)=>e.kind==='start'?i:-1).filter(i=>i>=0).pop()||0;await api('control',{mode:'replay',playing:false,summary,cursor:latest-1,view:'2d'});$('#follow').checked=!summary;frame(bounds);recording=true;const chunks=[],rec=new MediaRecorder(canvas.captureStream(30),{mimeType:'video/webm'});rec.ondataavailable=e=>chunks.push(e.data);rec.onstop=()=>{recording=false;const blob=new Blob(chunks,{type:'video/webm'}),a=document.createElement('a');window.silkscreen.recordingBlob=blob;a.href=URL.createObjectURL(blob);a.download='silkscreen-replay.webm';a.click();$('#record').textContent='Record replay';draw()};rec.start();$('#record').textContent='Recording replay…';await api('control',{playing:true});const timer=setInterval(()=>{draw();if(!state.playing&&state.cursor>=history.length-1){clearInterval(timer);rec.stop()}},100)};$('#record').onclick=()=>recordReplay(false);$('#short').onclick=()=>recordReplay(true);+recordReplay=async function(summary=false){if(recording)return;const latest=history.map((e,i)=>e.kind==='start'?i:-1).filter(i=>i>=0).pop()||0;await api('control',{mode:'replay',playing:false,summary,cursor:latest-1,view:'2d'});$('#follow').checked=!summary;frame(bounds);recording=true;const chunks=[],rec=new MediaRecorder(canvas.captureStream(30),{mimeType:'video/webm'});rec.ondataavailable=e=>chunks.push(e.data);rec.onstop=()=>{recording=false;const blob=new Blob(chunks,{type:'video/webm'}),a=document.createElement('a');window.silkscreen.recordingBlob=blob;const reader=new FileReader();reader.onload=()=>api('recording',{kind:summary?'overview':'walkthrough',video:reader.result.split(',')[1]}).catch(e=>$('#error').textContent=e.message);reader.readAsDataURL(blob);a.href=URL.createObjectURL(blob);a.download='silkscreen-replay.webm';a.click();$('#record').textContent='Record replay';draw()};rec.start();$('#record').textContent='Recording replay…';await api('control',{playing:true});const timer=setInterval(()=>{draw();if(!state.playing&&state.cursor>=history.length-1){clearInterval(timer);rec.stop()}},100)};$('#record').onclick=()=>recordReplay(false);$('#short').onclick=()=>recordReplay(true); new ResizeObserver(draw).observe($('#stage'));draw();}catch(e){$('#error').textContent=e.stack} </script></body></html>
tools/silkscreen-dashboard/server.py+24−5
@@ -1,6 +1,6 @@ #!/usr/bin/env python3 """AI Flow silkscreen observer: persisted solver events, no provider calls or CAD edits."""-import argparse,hashlib,json,os,pathlib,subprocess,sys,time,threading,http.server,urllib.request,urllib.parse,signal+import argparse,base64,hashlib,json,os,pathlib,subprocess,sys,time,threading,http.server,urllib.request,urllib.parse,signal P=pathlib.Path;ROOT=P(__file__).resolve().parent;REG=P.home()/'.adom/instances/aiflow-silkscreen';REG.mkdir(parents=True,exist_ok=True) def atomic(p,d):  q=p.with_suffix('.tmp');q.write_text(json.dumps(d,indent=2));q.replace(p)@@ -70,6 +70,10 @@ class Handler(http.server.BaseHTTPRequestHandler):    return   if path in ['/','/index.html']:return self.response((ROOT/'index.html').read_bytes(),ctype='text/html')   if path=='/board.json':return self.response(manifest.read_bytes() if manifest.exists() else b'{"obstacles":[],"labels":[],"bounds":[0,0,100,100]}')+  if path.startswith('/recordings/'):+   f=(folder/path.lstrip('/')).resolve()+   if not f.is_relative_to((folder/'recordings').resolve()) or not f.is_file():return self.response({'error':'not found'},404)+   return self.response(f.read_bytes(),ctype='video/webm')   if path.startswith('/models/'):    f=(folder/path.lstrip('/')).resolve()    if not f.is_relative_to((folder/'models').resolve()) or not f.is_file():return self.response({'error':'not found'},404)@@ -79,10 +83,14 @@ class Handler(http.server.BaseHTTPRequestHandler):   global last   last=time.time()   try:-   if int(self.headers.get('Content-Length','0'))>65536:raise ValueError('request too large')+   if int(self.headers.get('Content-Length','0'))>(40_000_000 if self.path=='/recording' else 65536):raise ValueError('request too large')    body=json.loads(self.rfile.read(int(self.headers.get('Content-Length','0'))))    if not body.get('aiThread'):return self.response(dict(errorCode='caller_identity_required',hint='Pass aiThread or CLI --ai-thread.'),400)    with lock:+    if self.path=='/recording':+     video=base64.b64decode(body['video'],validate=True)+     if len(video)<1024 or video[:4]!=bytes.fromhex('1a45dfa3'):raise ValueError('Refused empty or invalid WebM recording')+     clips=folder/'recordings';clips.mkdir(exist_ok=True);name='replay-'+str(time.time_ns())+'.webm';(clips/name).write_bytes(video);state['lastRecording']={'file':str(clips/name),'url':'recordings/'+name,'kind':body.get('kind','walkthrough'),'bytes':len(video),'replay':True};state['recordRequest']=None;save();broadcast();self.response({'ok':True,'recording':state['lastRecording']});return     if self.path=='/shutdown':      if not body.get('reason'):raise ValueError('reason_required')      self.response({'ok':True});threading.Thread(target=shutdown,daemon=True).start();return@@ -97,7 +105,8 @@ class Handler(http.server.BaseHTTPRequestHandler):     elif self.path=='/control':      for k,v in body.items():       if k in ['aiThread','reason']:continue-      if k=='mode' and v in ['live','replay']:state[k]=v+      if k=='record' and v in ['overview','walkthrough']:state['recordRequest']={'id':time.time_ns(),'kind':v}+      elif k=='mode' and v in ['live','replay']:state[k]=v       elif k=='layer' and v in ['F.SilkS','B.SilkS']:state[k]=v       elif k=='view' and v in ['2d','3d']:state[k]=v       elif k in ['playing','summary'] and isinstance(v,bool):state[k]=v@@ -106,9 +115,19 @@ class Handler(http.server.BaseHTTPRequestHandler):      if state['mode']=='live':state['cursor']=len(history)-1;state['playing']=False     else:return self.response({'error':'not found'},404)     save();broadcast();self.response({'ok':True,'state':state})-  except (ValueError,TypeError) as ex:self.response({'error':str(ex)},400)+  except (ValueError,TypeError,KeyError) as ex:self.response({'error':str(ex)},400) http.server.ThreadingHTTPServer.allow_reuse_address=True-server=http.server.ThreadingHTTPServer(('127.0.0.1',0 if a.port=='auto' else int(a.port)),Handler);server.daemon_threads=True+import fcntl+lease=open(REG/(key+'.lock'),'w')+try:fcntl.flock(lease,fcntl.LOCK_EX|fcntl.LOCK_NB)+except BlockingIOError:raise SystemExit('OK: another instance for this run/thread is starting or running')+for attempt in range(30):+ try:+  server=http.server.ThreadingHTTPServer(('127.0.0.1',0 if a.port=='auto' else int(a.port)),Handler);break+ except OSError:+  if attempt==29:raise+  time.sleep(.1)+server.daemon_threads=True atomic(reg,dict(pid=os.getpid(),port=server.server_port,thread=a.ai_thread,run=str(run),version=version,startedAt=time.time())) def shutdown():  save();current=json.loads(reg.read_text()) if reg.exists() else {};reg.unlink(missing_ok=True)