← Commit history
Cargo.lockadded+249
@@ -0,0 +1,249 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adom-aiflow-video"+version = "0.1.0"+dependencies = [+ "clap",+ "serde",+ "serde_json",+]++[[package]]+name = "anstream"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"+dependencies = [+ "anstyle",+ "anstyle-parse",+ "anstyle-query",+ "anstyle-wincon",+ "colorchoice",+ "is_terminal_polyfill",+ "utf8parse",+]++[[package]]+name = "anstyle"+version = "1.0.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"++[[package]]+name = "anstyle-parse"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"+dependencies = [+ "utf8parse",+]++[[package]]+name = "anstyle-query"+version = "1.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"+dependencies = [+ "windows-sys",+]++[[package]]+name = "anstyle-wincon"+version = "3.0.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"+dependencies = [+ "anstyle",+ "once_cell_polyfill",+ "windows-sys",+]++[[package]]+name = "clap"+version = "4.6.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"+dependencies = [+ "clap_builder",+ "clap_derive",+]++[[package]]+name = "clap_builder"+version = "4.6.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"+dependencies = [+ "anstream",+ "anstyle",+ "clap_lex",+ "strsim",+]++[[package]]+name = "clap_derive"+version = "4.6.7"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"+dependencies = [+ "heck",+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "clap_lex"+version = "1.1.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"++[[package]]+name = "colorchoice"+version = "1.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"++[[package]]+name = "heck"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"++[[package]]+name = "is_terminal_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"++[[package]]+name = "itoa"+version = "1.0.19"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4"++[[package]]+name = "memchr"+version = "2.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"++[[package]]+name = "once_cell_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"++[[package]]+name = "proc-macro2"+version = "1.0.107"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.47"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "serde"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"+dependencies = [+ "serde_core",+ "serde_derive",+]++[[package]]+name = "serde_core"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "serde_json"+version = "1.0.154"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"+dependencies = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "strsim"+version = "0.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"++[[package]]+name = "syn"+version = "3.0.10"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "unicode-ident"+version = "1.0.27"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"++[[package]]+name = "utf8parse"+version = "0.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"++[[package]]+name = "windows-link"+version = "0.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"++[[package]]+name = "windows-sys"+version = "0.61.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"+dependencies = [+ "windows-link",+]++[[package]]+name = "zmij"+version = "1.0.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Cargo.tomladded+25
@@ -0,0 +1,25 @@+# A flow app: one later-stage step of making a board (ordering, probing, ...) as its own Linux CLI.+# Copied from adom/adom-aiflow template/flow-app. This copy is yours: change anything, never depend on aiflow.+[package]+name = "adom-aiflow-video"+version = "0.1.0"+edition = "2021"+license = "MIT"++[[bin]]+name = "adom-aiflow-video"+path = "src/main.rs"++[dependencies]+serde = { version = "1", features = ["derive"] }+serde_json = "1"+clap = { version = "4", features = ["derive"] }++[profile.release]+opt-level = 3+lto = true+codegen-units = 1+strip = true++# standalone: not a member of any surrounding workspace+[workspace]
LICENSEadded+9
@@ -0,0 +1,9 @@+MIT License++Copyright (c) 2026 Adom Industries Inc.++Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:++The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
README.mdadded+55
@@ -0,0 +1,55 @@+# AI Flow: Video++**Open source under the [MIT License](LICENSE).** The full Rust source is on this page; build it with `./build.sh`.++Turn a board build in Adom Hydrogen into a narrated walkthrough video: on the project's wiki page, and on YouTube when you want it there. Ask for it when you start a build, leave it running, and the video is ready to review when you are back.++```+adom-wiki pkg install adom/adom-aiflow-video+adom-aiflow-video --help+```++![The board walkthrough: each part highlighted and labelled while the narration describes it](docs/screenshots/walkthrough-board.png)++![The enclosure built up part by part](docs/screenshots/walkthrough-case.png)++## A dynamic skill++AI Flow: Video is a dynamic skill, like AI Flow itself: a small Rust binary that holds the AI's hand. The AI does about 97 % of the work; the binary is about 3 %, the guide and the checker. It suggests the next step, records clips, ships the routine parts as ready-made code and gives feedback as gentle suggestions. It is never a gate: every command completes, and the AI and the human decide.++## What the film shows++- **The starting prompt**, typed into Hydrogen.+- **The build's highlights**, a few seconds each: parts being found, placement, routing, checks, silkscreen.+- **A 3D walkthrough** in the Adom 3D viewer: the enclosure part by part, then the board's major parts on the top and the underside, each one highlighted, labelled and described.+- **How it was made**: which Adom tools did each step, with a few "ask your AI to ..." lines so the viewer can do the same.+- **The parts story and the cost**: how many unique parts, where they came from, what one board costs and what a hundred cost each.+- **Where the files are**: the project's wiki page.++## What it helps the AI do++| Step | What happens |+|---|---|+| intake | Asks whether you want a video, says what recording needs (disk space, a screen to itself) and suggests a second machine on your Adom Mesh |+| plan, capture | Short highlight clips of the moments worth showing, never a continuous recording |+| walkthrough | The part-by-part 3D walkthrough, with the ready-made director (`adom-aiflow-video director`, `adom-aiflow-video guide`) |+| script, cut | Narration with adom-tts, footage fitted to it, captions and chapters as separate files |+| privacy | Looks for email addresses, keys, file paths and other personal details before anything is published |+| review | You watch the draft in the Adom video player and ask for changes |+| publish | The `::adom-video` player at the top of your wiki page; YouTube through adom-google, unlisted unless you say otherwise |+| cleanup | Removes the raw footage and scratch files and says how much space came back |++## Commands++```+init [--release <board-release.json>] [--target <machine>]    next    steps    ledger+disk --clips N --seconds S      clip start|stop      mark <step> --note "..."+script <script.json>            scan <files...>      track <path>      clean [--yes]+director --out <file.js>        guide+```++Every command answers `OK:` with `Hint:` lines. The skill is in [skills/adom-aiflow-video/SKILL.md](skills/adom-aiflow-video/SKILL.md); the walkthrough guide is in [docs/walkthrough.md](docs/walkthrough.md).++## Status++0.1.0, first cut. It grew out of the two Ghost Hand films ([john/ghost-hand](https://wiki.adom.inc/john/ghost-hand)). Working today: the flow and its hints, clip recording, the disk estimate, script feedback, the text privacy scan, file tracking and cleanup, and the walkthrough director. Still done by the AI by hand, with the skill as the guide: the cut itself, the frame-by-frame privacy look, the wiki publish and the YouTube upload. AI Flow does not suggest this app at intake yet.
assets/walkthrough-director.jsadded+65
@@ -0,0 +1,65 @@+// walkthrough-director.js: drive the Adom 3D viewer through a narrated part-by-part walkthrough.+// Inject it into a page showing the Adom 3D viewer (pup_evaluate), then:+//   GHW.CASE_PARTS=[...]; GHW.setupCase('<root node>', '^(scene_|hand)');   // enclosure, built up part by part+//   await GHW.setupBoard('<board .glb url>'); await GHW.mergeBoard();        // the board alone, top and underside+//   await GHW.run(steps, '<root node>' | 'WALK_BOARD');                      // steps: see docs/walkthrough.md+// Every step flies the camera to its parts, tints and outlines them, shows a label, holds for the narration,+// and logs its start time in GHW.log so the cut can line the narration up. MIT.+window.GHW = window.GHW || {};+(function(G){+const sc=()=>BABYLON.EngineStore.LastCreatedScene;+const V3=(a)=>new BABYLON.Vector3(a[0],a[1],a[2]);+const ease=t=>t<.5?4*t*t*t:1-Math.pow(-2*t+2,3)/2;+const sleep=ms=>new Promise(r=>setTimeout(r,ms));+G.log=[];+function nodeByName(n){const s=sc();return s.getTransformNodeByName(n)||s.getMeshByName(n)||s.getNodeByName(n);}+function under(node,rootName){let p=node;while(p){if(p.name===rootName)return true;p=p.parent}return false;}+G.meshesOf=(a,b)=>meshesOf(a,b);function meshesOf(names,rootName){const out=[];for(const nm of names){const s=sc();const cands=s.getNodes().filter(x=>x.name===nm&&(!rootName||under(x,rootName)));for(const n of cands){if(n.getChildMeshes)out.push(...n.getChildMeshes(false));if(n instanceof BABYLON.AbstractMesh)out.push(n)}}return out.filter(m=>m.getTotalVertices&&m.getTotalVertices()>0);}+function bbox(ms){let mn=[1e9,1e9,1e9],mx=[-1e9,-1e9,-1e9];for(const m of ms){m.computeWorldMatrix(true);const b=m.getBoundingInfo().boundingBox;for(let i=0;i<3;i++){mn[i]=Math.min(mn[i],b.minimumWorld.asArray()[i]);mx[i]=Math.max(mx[i],b.maximumWorld.asArray()[i])}}return{mn,mx,c:mn.map((v,i)=>(v+mx[i])/2),d:Math.hypot(mx[0]-mn[0],mx[1]-mn[1],mx[2]-mn[2])};}+function label(text){let el=document.getElementById('ghw-label');if(!el){el=document.createElement('div');el.id='ghw-label';el.style.cssText='position:fixed;left:40px;bottom:96px;z-index:99999;padding:12px 22px;border-radius:12px;background:rgba(10,18,20,.78);color:#e8fffb;font:600 30px/1.2 "Familjen Grotesk",system-ui,sans-serif;border:2px solid #14c4b8;box-shadow:0 6px 24px rgba(0,0,0,.35);transition:opacity .35s;opacity:0;pointer-events:none';document.body.appendChild(el)}+ if(!text){el.style.opacity=0;return}el.textContent=text;el.style.opacity=1;}+let HL=null;const tinted=[];function highlight(ms){const s=sc();if(!HL){HL=new BABYLON.HighlightLayer('ghw-hl2',s,{blurHorizontalSize:1.6,blurVerticalSize:1.6,mainTextureRatio:0.5});HL.innerGlow=false;HL.outerGlow=true;}+ HL.removeAllMeshes();while(tinted.length){const [m,mat]=tinted.pop();m.material=mat;}+ for(const m of ms){try{HL.addMesh(m,new BABYLON.Color3(0.0,0.9,0.82))}catch(e){}+  if(m.material){const E=new BABYLON.Color3(0.0,0.45,0.40);const tint=x=>{const c=x.clone(x.name+'_ghw');if('emissiveColor' in c)c.emissiveColor=E;return c;};let k;+   if(m.material.subMaterials){k=m.material.clone(m.material.name+'_ghw',true);for(const x of k.subMaterials){if(x&&'emissiveColor' in x)x.emissiveColor=E;}}else k=tint(m.material);+   tinted.push([m,m.material]);m.material=k;}}}+function cam(){return sc().activeCamera;}+async function tween(to,secs){const c=cam();const f={a:c.alpha,b:c.beta,r:c.radius,t:c.target.clone()};const t0=performance.now();const ms=secs*1000;+ while(true){const u=Math.min(1,(performance.now()-t0)/ms),e=ease(u);c.setTarget(BABYLON.Vector3.Lerp(f.t,V3(to.target),e));c.alpha=f.a+(to.alpha-f.a)*e;c.beta=f.b+(to.beta-f.b)*e;c.radius=f.r+(to.radius-f.r)*e;if(u>=1)break;await sleep(16);}}+async function drift(secs,dAlpha){const c=cam();const a0=c.alpha,t0=performance.now();while(performance.now()-t0<secs*1000){c.alpha=a0+dAlpha*((performance.now()-t0)/(secs*1000));await sleep(16);}}+G.CASE_PARTS=G.CASE_PARTS||[];   // set by the caller: the node names of the enclosure parts, bottom to top+G.bottomLight=function(on){const b=[...document.querySelectorAll('button')].find(x=>/Bottom light/.test(x.textContent));if(!b)return;const pressed=!!G._bl;if(on!==pressed){b.click();G._bl=on;}};+G.setupCase=function(rootName,hide){G.ROOT=rootName;const hideRx=hide?new RegExp(hide):null;const s=sc();const wb=nodeByName('WALK_BOARD');if(wb)wb.setEnabled(false);+ if(hideRx)for(const n of s.getNodes()){if(hideRx.test(n.name))n.setEnabled(false)}+ const hero=nodeByName(rootName);if(hero){hero.setEnabled(true);for(const m of s.meshes)if(under(m,rootName))m.setEnabled(true);}+ for(const g of s.animationGroups){g.stop();g.start(false,1,g.from,g.to);g.goToFrame(g.to);g.pause();}+ for(const n of G.CASE_PARTS){const x=nodeByName(n);if(x)x.setEnabled(false)}+ const c=cam();c.enforceBounds=false;c.lowerRadiusLimit=0.001;c.upperBetaLimit=Math.PI;c.minZ=0.0005;G.ground(false);return 'case ready';};+G.ground=function(on){for(const m of sc().meshes){if(/ground|shadowCatcher|backdrop/i.test(m.name)&&!(G.ROOT&&under(m,G.ROOT))&&!under(m,'WALK_BOARD'))m.setEnabled(on)}};+G.setupBoard=async function(boardGlbUrl){const s=sc();let wb=nodeByName('WALK_BOARD');+ if(!wb){const ct=await BABYLON.LoadAssetContainerAsync(boardGlbUrl,s);ct.addAllToScene();wb=ct.rootNodes[0];wb.name='WALK_BOARD';}+ for(const m of s.meshes){if(!under(m,'WALK_BOARD'))m.setEnabled(false)}wb.setEnabled(true);+ const c=cam();c.enforceBounds=false;c.lowerRadiusLimit=0.001;c.upperBetaLimit=Math.PI;c.lowerBetaLimit=0.0001;c.minZ=0.0005;G.ground(false);return 'board ready';};+G.frameFor=function(st,root){const ms=meshesOf(st.frame||(st.nodes.length?st.nodes:[root]),root);const b=bbox(ms);+ const bottom=st.side==='bottom';const r=Math.max(st.nodes.length?b.d*(st.rmul||2.6):b.d*(st.rmul||1.25),root==="WALK_BOARD"?0.03:0.055);+ return{target:b.c,radius:r,alpha:st.alpha??(bottom?(-Math.PI/2+0.45):(Math.PI/2+0.45)),beta:st.beta??(bottom?Math.PI-0.75:0.85),ms};};+G.run=async function(steps,root){G.log=[];G.t0=performance.now();G.wall0=Date.now();G.running=true;+ for(const st of steps){const f=G.frameFor(st,root);G.log.push({id:st.id,t:(performance.now()-G.t0)/1000});+  if(st.show){for(const n of G.CASE_PARTS){const x=nodeByName(n);if(x)x.setEnabled(st.show.includes(n))}}+  if(st.reveal)for(const n of st.reveal){const x=nodeByName(n);if(x)x.setEnabled(true)}+  if(st.hide)for(const n of st.hide){const x=nodeByName(n);if(x)x.setEnabled(false)}+  G.bottomLight(st.side==='bottom');label(st.label);highlight(st.nodes.length?G.meshesOf(st.nodes,root):[]);+  const move=Math.min(1.4,st.dur*0.35);await tween(f,move);await drift(Math.max(0.6,st.dur+0.5-move),0.18);}+ highlight([]);label('');G.log.push({id:'end',t:(performance.now()-G.t0)/1000});G.running=false;return G.log;};++// A KiCad board GLB can hold 20,000+ tiny meshes (3 fps). Merge each part's meshes into one (60 fps).+G.mergeBoard=async function(refRx){const s=sc();const wb=s.getNodeByName('WALK_BOARD');const rx=refRx?new RegExp(refRx):/^(U|J|D|PD|Q|MP|MC|C|R|L|TP|SW|Y|FB|BT)\d+$/;+ const parts={};for(const m of s.meshes){if(!under(m,'WALK_BOARD'))continue;let p=m,ref=null;while(p&&p!==wb){if(rx.test(p.name))ref=p.name;p=p.parent}(parts[ref||'BOARD']=parts[ref||'BOARD']||[]).push(m)}+ const holder=new BABYLON.TransformNode('WALK_BOARD_M',s);G.merge={done:0,total:Object.keys(parts).length};+ for(const k of Object.keys(parts)){const ms=parts[k].filter(m=>m.getTotalVertices()>0);const node=new BABYLON.TransformNode(k,s);node.parent=holder;+  for(let i=0;i<ms.length;i+=300){const m=BABYLON.Mesh.MergeMeshes(ms.slice(i,i+300),true,true,undefined,false,true);if(m){m.name=k+'_m'+i;m.parent=node}await sleep(0)}+  G.merge.done++;await sleep(0)}+ wb.dispose();holder.name='WALK_BOARD';return s.meshes.length;};+})(window.GHW);+'director loaded'
bin/adom-aiflow-videoadded
⋯ 1 unchanged line ⋯
build.shadded+9
@@ -0,0 +1,9 @@+#!/bin/bash+# Build the Linux binary from source and put it where the package ships it (bin/). Anyone can run this.+set -e+HERE="$(cd "$(dirname "$0")" && pwd)"; cd "$HERE"+NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' Cargo.toml | head -1)"+cargo test --release+cargo build --release+mkdir -p bin && cp "target/release/$NAME" "bin/$NAME"+echo "OK: bin/$NAME ($(bin/$NAME --version))"
docs/example-walkthrough-steps.jsonadded+487
@@ -0,0 +1,487 @@+{+ "case": [+  {+   "id": "c00",+   "nodes": [],+   "label": "Rev 2, from the bottom up",+   "text": "Here is rev two, built up from the bottom.",+   "dur": 2.736,+   "show": [+    "foot",+    "base",+    "inserts",+    "battery_pouch",+    "battery_tape",+    "battery_jst_plug",+    "battery_lead_red",+    "battery_lead_black",+    "board",+    "board_design_frame",+    "gasket",+    "ir_window",+    "usb_plug",+    "lightpipe_status",+    "lightpipe_25",+    "lightpipe_50",+    "lightpipe_75",+    "lightpipe_100",+    "cover",+    "laser_engraving",+    "laser_engraving_base",+    "screws",+    "screw_seals"+   ],+   "frame": [+    "base",+    "cover"+   ],+   "beta": 0.95+  },+  {+   "id": "c01",+   "nodes": [+    "foot"+   ],+   "label": "Non-slip foot, TPU",+   "text": "A soft TPU ring underneath keeps it from sliding on a wet counter.",+   "dur": 4.32,+   "show": [+    "foot"+   ],+   "frame": [+    "foot"+   ],+   "beta": 1.1+  },+  {+   "id": "c02",+   "nodes": [+    "base"+   ],+   "label": "Base with pin sockets",+   "text": "The printed base has four split sockets that squeeze the board's machine pins, the same pins that seat it in an Adom workcell.",+   "dur": 7.536,+   "show": [+    "foot",+    "base",+    "inserts"+   ],+   "frame": [+    "base"+   ],+   "beta": 0.9+  },+  {+   "id": "c03",+   "nodes": [+    "battery_pouch",+    "battery_tape",+    "battery_jst_plug",+    "battery_lead_red",+    "battery_lead_black"+   ],+   "label": "150 mAh LiPo, Adafruit 1317",+   "text": "The hundred and fifty milliamp-hour battery lies flat underneath, its leads plugged into the board.",+   "dur": 5.664,+   "show": [+    "foot",+    "base",+    "inserts",+    "battery_pouch",+    "battery_tape",+    "battery_jst_plug",+    "battery_lead_red",+    "battery_lead_black"+   ],+   "frame": [+    "battery_pouch",+    "battery_tape",+    "battery_jst_plug",+    "battery_lead_red",+    "battery_lead_black"+   ],+   "beta": 0.8,+   "rmul": 1.7+  },+  {+   "id": "c04",+   "nodes": [+    "board"+   ],+   "label": "Board, tilted 5 degrees",+   "text": "The board sits above it, tilted five degrees up toward the faucet's sensor.",+   "dur": 4.536,+   "show": [+    "foot",+    "base",+    "inserts",+    "battery_pouch",+    "battery_tape",+    "battery_jst_plug",+    "battery_lead_red",+    "battery_lead_black",+    "board",+    "board_design_frame"+   ],+   "frame": [+    "board"+   ],+   "beta": 0.8+  },+  {+   "id": "c05",+   "nodes": [+    "gasket"+   ],+   "label": "Seam gasket, TPU",+   "text": "A TPU gasket seals the seam.",+   "dur": 2.328,+   "show": [+    "foot",+    "base",+    "inserts",+    "battery_pouch",+    "battery_tape",+    "battery_jst_plug",+    "battery_lead_red",+    "battery_lead_black",+    "board",+    "board_design_frame",+    "gasket"+   ],+   "frame": [+    "gasket"+   ],+   "beta": 0.85+  },+  {+   "id": "c06",+   "nodes": [+    "ir_window"+   ],+   "label": "IR window",+   "text": "The dark front window passes infrared to and from the faucet, with transmit and receive kept apart.",+   "dur": 6.816,+   "show": [+    "foot",+    "base",+    "inserts",+    "battery_pouch",+    "battery_tape",+    "battery_jst_plug",+    "battery_lead_red",+    "battery_lead_black",+    "board",+    "board_design_frame",+    "gasket",+    "ir_window"+   ],+   "frame": [+    "ir_window"+   ],+   "beta": 1.25,+   "rmul": 1.8+  },+  {+   "id": "c07",+   "nodes": [+    "usb_plug"+   ],+   "label": "USB-C seal",+   "text": "A tethered stopper seals the USB-C port.",+   "dur": 2.88,+   "show": [+    "foot",+    "base",+    "inserts",+    "battery_pouch",+    "battery_tape",+    "battery_jst_plug",+    "battery_lead_red",+    "battery_lead_black",+    "board",+    "board_design_frame",+    "gasket",+    "ir_window",+    "usb_plug"+   ],+   "frame": [+    "usb_plug"+   ],+   "beta": 1.1,+   "rmul": 4.5+  },+  {+   "id": "c08",+   "nodes": [+    "lightpipe_status",+    "lightpipe_25",+    "lightpipe_50",+    "lightpipe_75",+    "lightpipe_100"+   ],+   "label": "Clear-resin light pipes",+   "text": "Five clear-resin light pipes carry the status dot and the charge gauge to the top.",+   "dur": 5.088,+   "show": [+    "foot",+    "base",+    "inserts",+    "battery_pouch",+    "battery_tape",+    "battery_jst_plug",+    "battery_lead_red",+    "battery_lead_black",+    "board",+    "board_design_frame",+    "gasket",+    "ir_window",+    "usb_plug",+    "lightpipe_status",+    "lightpipe_25",+    "lightpipe_50",+    "lightpipe_75",+    "lightpipe_100"+   ],+   "frame": [+    "lightpipe_status",+    "lightpipe_25",+    "lightpipe_50",+    "lightpipe_75",+    "lightpipe_100"+   ],+   "beta": 0.7+  },+  {+   "id": "c09",+   "nodes": [+    "laser_engraving"+   ],+   "label": "Cover, UV laser engraved",+   "text": "And a UV fiber laser engraves each light's name into the cover.",+   "dur": 4.512,+   "show": [+    "foot",+    "base",+    "inserts",+    "battery_pouch",+    "battery_tape",+    "battery_jst_plug",+    "battery_lead_red",+    "battery_lead_black",+    "board",+    "board_design_frame",+    "gasket",+    "ir_window",+    "usb_plug",+    "lightpipe_status",+    "lightpipe_25",+    "lightpipe_50",+    "lightpipe_75",+    "lightpipe_100",+    "cover",+    "laser_engraving",+    "laser_engraving_base"+   ],+   "frame": [+    "cover"+   ],+   "beta": 0.42,+   "rmul": 1.15+  },+  {+   "id": "c10",+   "nodes": [+    "screws",+    "screw_seals",+    "inserts"+   ],+   "label": "Screws from below",+   "text": "Four screws go in from underneath through their own seals, so nothing on top can leak.",+   "dur": 4.752,+   "show": [+    "foot",+    "base",+    "inserts",+    "battery_pouch",+    "battery_tape",+    "battery_jst_plug",+    "battery_lead_red",+    "battery_lead_black",+    "board",+    "board_design_frame",+    "gasket",+    "ir_window",+    "usb_plug",+    "lightpipe_status",+    "lightpipe_25",+    "lightpipe_50",+    "lightpipe_75",+    "lightpipe_100",+    "cover",+    "laser_engraving",+    "laser_engraving_base",+    "screws",+    "screw_seals"+   ],+   "frame": [+    "screws",+    "base"+   ],+   "beta": 2.391592653589793,+   "rmul": 1.5+  }+ ],+ "board": [+  {+   "id": "b00",+   "nodes": [],+   "side": "top",+   "label": "The board, 30 x 22 mm",+   "text": "Now the board itself, thirty by twenty-two millimetres.",+   "dur": 3.504+  },+  {+   "id": "b01",+   "nodes": [+    "U1"+   ],+   "side": "top",+   "label": "U1  ATtiny1616",+   "text": "The ATtiny sixteen-sixteen echoes the faucet's own infrared pulse in hardware, in under a microsecond.",+   "dur": 7.296+  },+  {+   "id": "b02",+   "nodes": [+    "PD1",+    "PD2",+    "U5"+   ],+   "side": "top",+   "label": "PD1, PD2  VEMD2023 and U5  TLV9062",+   "text": "Two photodiodes and an amplifier hear the faucet's flash.",+   "dur": 3.768,+   "rmul": 1.7+  },+  {+   "id": "b03",+   "nodes": [+    "D1",+    "D2"+   ],+   "side": "top",+   "label": "D1 850 nm, D2 940 nm emitters",+   "text": "An eight-fifty and a nine-forty nanometre emitter answer it.",+   "dur": 3.624,+   "rmul": 1.9+  },+  {+   "id": "b04",+   "nodes": [+    "D4",+    "D5",+    "D6",+    "D7",+    "D8"+   ],+   "side": "top",+   "label": "D4 STATUS, D5 to D8 gauge  WS2812B-2020",+   "text": "Five tiny RGB lights: status, and the charge gauge, each named in the silkscreen.",+   "dur": 6.192,+   "rmul": 1.5+  },+  {+   "id": "b05",+   "nodes": [+    "U4"+   ],+   "side": "top",+   "label": "U4  LIS2DW12 accelerometer",+   "text": "An accelerometer stops everything the moment it is picked up.",+   "dur": 3.648+  },+  {+   "id": "b06",+   "nodes": [+    "J1",+    "U2"+   ],+   "side": "top",+   "label": "J1 USB-C, U2 MCP73831 charger",+   "text": "USB-C feeds the charger at seventy-seven milliamps.",+   "dur": 3.84,+   "rmul": 1.35+  },+  {+   "id": "b07",+   "nodes": [+    "J2",+    "U3"+   ],+   "side": "top",+   "label": "J2 battery, U3 3.3 V regulator",+   "text": "The battery plugs into J2, plus on pin one, and a twenty-five nanoamp regulator makes three point three volts.",+   "dur": 7.392,+   "rmul": 1.4+  },+  {+   "id": "b08",+   "nodes": [+    "MP1",+    "MP2",+    "MP3",+    "MP4",+    "MC1",+    "MC2",+    "MC3",+    "MC4"+   ],+   "side": "top",+   "label": "Machine pins and contacts",+   "text": "Four corner machine pins and four contacts, for the case and for programming.",+   "dur": 4.92,+   "rmul": 1.0+  },+  {+   "id": "b09",+   "nodes": [],+   "side": "bottom",+   "label": "Bottom: 47 parts",+   "text": "Underneath, forty-seven small parts.",+   "dur": 2.712+  },+  {+   "id": "b10",+   "nodes": [+    "Q1",+    "Q2",+    "Q3"+   ],+   "side": "bottom",+   "label": "Q1, Q2, Q3  MOSFETs",+   "text": "MOSFETs drive the emitters and switch the lights.",+   "dur": 3.24,+   "rmul": 1.5+  },+  {+   "id": "b11",+   "nodes": [+    "D10"+   ],+   "side": "bottom",+   "label": "D10  SMF5.0A TVS",+   "text": "A TVS diode guards the USB input.",+   "dur": 3.024,+   "rmul": 2.6+  },+  {+   "id": "b12",+   "nodes": [],+   "side": "bottom",+   "label": "GHOST HAND rev 2",+   "text": "And the board's name, date and wiki address, so anyone holding it can find its files.",+   "dur": 5.28+  }+ ]+}\ No newline at end of file
docs/hero.pngadded
⋯ 1 unchanged line ⋯
docs/screenshots/walkthrough-board.pngadded
⋯ 1 unchanged line ⋯
docs/screenshots/walkthrough-case.pngadded
⋯ 1 unchanged line ⋯
docs/showcase-src.pngadded
⋯ 1 unchanged line ⋯
docs/walkthrough.mdadded+34
@@ -0,0 +1,34 @@+# The 3D walkthrough++The part-by-part walkthrough is the centre of the film: the enclosure built up from the bottom, then the board's major parts on the top and the underside, each one highlighted and labelled while the narration says what it does. `assets/walkthrough-director.js` drives the Adom 3D viewer through it; you write the steps.++## Steps file++`walkthrough/steps.json`, one list per segment (see `docs/example-walkthrough-steps.json`, from the Ghost Hand rev 2 film):++```json+{"case":  [{"id": "c03", "nodes": ["battery_pouch", "battery_jst_plug"], "show": ["foot", "base", "battery_pouch"],+            "frame": ["battery_pouch"], "beta": 0.8, "rmul": 1.7, "label": "150 mAh LiPo", "text": "The battery lies flat underneath.", "dur": 5.7}],+ "board": [{"id": "b01", "nodes": ["U1"], "side": "top", "label": "U1  ATtiny1616", "text": "The microcontroller echoes the pulse.", "dur": 7.3}]}+```++- `nodes`: the GLB node names to highlight (a board exported from KiCad names its parts by reference: U1, J2, D4).+- `show` (enclosure): everything visible at this step, so the model builds up part by part.+- `side`: `top` or `bottom` for board parts; the underside turns the bottom light on and views the board so its text reads the right way up.+- `label`: the on-screen name. `text`: the narration line. `dur`: that line's length in seconds (narrate first with adom-tts, then measure).+- `frame`, `beta`, `rmul`: optional framing (what to centre on, the camera's tilt, how far back).++## Recording a take++1. Open the project's 3D viewer page in a pup window on the recording machine, sized to about 90 percent over a full-screen Hydrogen.+2. Inject the director (`pup_evaluate`), set up the segment, start a desktop recording, call `GHW.run(steps, root)`, wait for `GHW.running` to go false, stop the recording.+3. Read `GHW.log` and `GHW.wall0`: each step's start time, to line the narration up in the cut.++## What we learned making the first ones++- Merge the board first (`GHW.mergeBoard()`): a KiCad board model can be 20,000 small meshes and drop the viewer to 3 frames a second; merged per part it runs at 60.+- Keep the highlight light: an outline glow plus a tint. A heavy glow froze the page.+- Set the camera's target before its angle and distance, or the viewer recomputes them and the shot drifts.+- Evaluations that wait more than about 20 s time out: start long jobs, then poll.+- Look at a frame from the middle of every step before using the take. Toasts, a part hidden behind another, and upside-down text all show up there.+- The walkthrough plays at real speed (1.0x). Do not speed 3D up.
flows/flow.jsonadded+16
@@ -0,0 +1,16 @@+{+ "name": "video",+ "description": "From a board build (running or finished) to a narrated walkthrough video on the project's wiki page and, when the human wants it, on YouTube. Every step is a suggestion: take another route when you have a better one, and say what you did.",+ "steps": [+  {"name": "intake", "done": ["state.json"], "do": "Ask the human once, in plain words: do they want a narrated video of this build, ready to review when they are back? Tell them recording takes over the screen of the machine it records, and suggest a second machine on their Adom Mesh so their own stays free. Run `disk` on the recording machine and share the estimate. Then `init` (with `--release <board-release.json>` when the board flow wrote one, `--target <machine>` for the recording machine)."},+  {"name": "plan", "done": ["plan.json"], "do": "Write plan.json: the moments worth a short clip for THIS build (the prompt being typed, each AI Flow step landing, parts search, a pup window fetching a part, new wiki part pages, Fields views, the silkscreen landing, the finished board in 3D, the wiki page). Highlights only, 5 to 30 s each, never continuous recording, and leave AI Flow's own full-length step recording off. If the build is already finished, plan the showcase shots instead and say the build footage does not exist."},+  {"name": "capture", "done": ["clips/*.mp4"], "do": "Record the planned moments as they happen: `clip start --name <moment> --hwnd <window>` then `clip stop`. Before each take look at the screen: Hydrogen full screen with the taskbar showing, other apps floating at about 90 percent, no dialogs, toasts, mail, chat or other personal windows. Motion in every clip. `track` every file you create on any machine so `clean` can find it later."},+  {"name": "walkthrough", "done": ["walkthrough/steps.json"], "do": "Build the 3D walkthrough in the Adom 3D viewer (assets/walkthrough-director.js is ready-made; docs/walkthrough.md explains it): the case or enclosure part by part when the project has one, then the board's major parts on the top and the underside. Highlight each part, label it with its reference and part number, and narrate what it does. Write walkthrough/steps.json (one entry per part: nodes, label, text) and record the take in real time."},+  {"name": "script", "done": ["script.json"], "do": "Write script.json: one narration line per scene. Open on the real starting prompt. Name the Adom tool that did each step while it is on screen (AI Flow, the KiCad, Fusion or Altium bridge, parts search, the wiki, Fields, webviews, pup), about one mention per 20 to 30 s, and add a few 'ask your AI to ...' lines so the viewer learns something. Include the parts story (unique parts, where they came from, cost to build one and each at 100) and any custom mechanical work. Close with one line that it was built in Adom Hydrogen and where the files are. Then `script script.json` for feedback."},+  {"name": "cut", "done": ["out/*.mp4"], "do": "Narrate with adom-tts, fit each clip to its line (1x to 2x, never dead air, never a flashed shot), add teal circles where the eye should go, and write captions (.vtt, .srt) and chapters as sidecar files, never burned in. Render 1080p one section at a time. Make a contact sheet and look at every scene before calling it a draft."},+  {"name": "privacy", "done": ["privacy.json"], "do": "Run `scan <script, captions, description files>` and look through the contact sheet for anything personal on screen: email addresses, names, phone numbers, keys, file paths with a user name, other people's windows. Prefer dropping or reframing a clip to blurring it. Tell the human what you found and what you did about it."},+  {"name": "review", "done": ["review.json"], "do": "Show the draft to the human in the Adom video player with chapters (`adom-video play <file> --chapters <json>`), with the list of what the AI did and anything it flagged. Take their changes, re-cut, and when they are happy record it: `mark review --note \"<their words>\"`."},+  {"name": "publish", "done": ["publish.json"], "do": "With the human's OK: push the film, poster, captions and a video.json manifest (captions and chapters inlined) to the project's wiki page and put `::adom-video{src=...}` under the title; check the live player. For YouTube, suggest setting up adom-google (a YouTube-only account) if it is not there, upload unlisted unless they ask for public, with the chapters, the starting prompt, the cost line and a 'Built with' list of the Adom tools in the description. `mark publish --note \"<links>\"`."},+  {"name": "cleanup", "done": ["cleanup.json"], "do": "Once the human has approved the final film, run `clean` to see what is left on every machine (raw clips, temp recordings, staging copies, render scratch), then `clean --yes` to remove it. Keep the finished film, captions, chapters and the edit plan. Tell the human how much space was freed."}+ ]+}
install.shadded+18
@@ -0,0 +1,18 @@+#!/bin/bash+# adom-wiki package install hook: the binary to ~/.local/bin, the skill to ~/.claude/skills (and ~/.codex/skills).+# Builds from source when no prebuilt binary ships or it does not run here.+set -e+HERE="$(cd "$(dirname "$0")" && pwd)"+NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' "$HERE/Cargo.toml" | head -1)"+if ! "$HERE/bin/$NAME" --version >/dev/null 2>&1; then+  command -v cargo >/dev/null || { echo "ERROR: no prebuilt binary for this machine and no cargo to build one (https://rustup.rs)"; exit 1; }+  (cd "$HERE" && cargo build --release && mkdir -p bin && cp "target/release/$NAME" "bin/$NAME")+fi+mkdir -p "$HOME/.local/bin"+install -m 755 "$HERE/bin/$NAME" "$HOME/.local/bin/.$NAME-new" && mv -f "$HOME/.local/bin/.$NAME-new" "$HOME/.local/bin/$NAME"+for skill in "$HERE"/skills/*; do+  [ -f "$skill/SKILL.md" ] || continue+  for consumer in .claude .codex; do mkdir -p "$HOME/$consumer/skills/$(basename "$skill")"; cp "$skill/SKILL.md" "$HOME/$consumer/skills/$(basename "$skill")/SKILL.md"; done+done+command -v adom-bridge >/dev/null 2>&1 || echo "Hint: desktop steps need adom-bridge (the Adom Bridge CLI)"+echo "OK: $NAME installed. Run '$NAME --help'."
package.jsonadded+57
@@ -0,0 +1,57 @@+{+  "slug": "adom-aiflow-video",+  "type": "app",+  "version": "0.1.0",+  "title": "AI Flow: Video",+  "description": "Make a narrated walkthrough video of a board built in Adom Hydrogen, put it on the project's wiki page and help the human upload it to YouTube. A dynamic skill: a small Rust binary that suggests each step, records clips, ships a ready-made 3D walkthrough director and gives gentle feedback; the AI does the work. Open source (MIT): the full Rust source ships on this page; build it yourself with ./build.sh.",+  "category": "Developer Tools",+  "license": "MIT",+  "bin": {+    "adom-aiflow-video": "bin/adom-aiflow-video"+  },+  "dependencies": {},+  "scripts": {+    "install": "./install.sh",+    "uninstall": "./uninstall.sh"+  },+  "bundled": true,+  "hero": {+    "path": "docs/hero.png"+  },+  "brief": "Turn a board build into a narrated walkthrough video on your wiki page and YouTube.",+  "discovery_pitch": "Reach for this when someone wants a video of their board or its build: it guides the AI from capture to a narrated 3D walkthrough, the wiki page and YouTube.",+  "discovery_triggers": [+    "make a video of my board",+    "video of the build",+    "board walkthrough video",+    "film my board build",+    "3d walkthrough of my pcb",+    "have a video ready in the morning",+    "upload my board video to youtube",+    "put a video on my project page",+    "narrated build video"+  ],+  "sample_prompts": [+    {+      "label": "Video with the build",+      "prompt": "Build this board and have a narrated video of the build ready for me in the morning."+    },+    {+      "label": "Walkthrough of a finished board",+      "prompt": "Make a 3D walkthrough video of my finished board and its case, and put it at the top of my wiki page."+    },+    {+      "label": "YouTube",+      "prompt": "Upload my board's walkthrough video to my YouTube channel, unlisted."+    }+  ],+  "tags": [+    "video",+    "aiflow",+    "pcb",+    "walkthrough",+    "youtube",+    "dynamic-skill",+    "cli"+  ]+}\ No newline at end of file
page.jsonadded+64
@@ -0,0 +1,64 @@+{+  "slug": "adom-aiflow-video",+  "type": "app",+  "version": "0.1.0",+  "title": "AI Flow: Video",+  "description": "Make a narrated walkthrough video of a board built in Adom Hydrogen, put it on the project's wiki page and help the human upload it to YouTube. A dynamic skill: a small Rust binary that suggests each step, records clips, ships a ready-made 3D walkthrough director and gives gentle feedback; the AI does the work. Open source (MIT): the full Rust source ships on this page; build it yourself with ./build.sh.",+  "category": "Developer Tools",+  "license": "MIT",+  "bin": {+    "adom-aiflow-video": "bin/adom-aiflow-video"+  },+  "dependencies": {},+  "scripts": {+    "install": "./install.sh",+    "uninstall": "./uninstall.sh"+  },+  "bundled": true,+  "hero": {+    "type": "image",+    "path": "docs/hero.png"+  },+  "brief": "Turn a board build into a narrated walkthrough video on your wiki page and YouTube.",+  "discovery_pitch": "Reach for this when someone wants a video of their board or its build: it guides the AI from capture to a narrated 3D walkthrough, the wiki page and YouTube.",+  "discovery_triggers": [+    "make a video of my board",+    "video of the build",+    "board walkthrough video",+    "film my board build",+    "3d walkthrough of my pcb",+    "have a video ready in the morning",+    "upload my board video to youtube",+    "put a video on my project page",+    "narrated build video"+  ],+  "sample_prompts": [+    {+      "label": "Video with the build",+      "prompt": "Build this board and have a narrated video of the build ready for me in the morning."+    },+    {+      "label": "Walkthrough of a finished board",+      "prompt": "Make a 3D walkthrough video of my finished board and its case, and put it at the top of my wiki page."+    },+    {+      "label": "YouTube",+      "prompt": "Upload my board's walkthrough video to my YouTube channel, unlisted."+    }+  ],+  "tags": [+    "video",+    "aiflow",+    "pcb",+    "walkthrough",+    "youtube",+    "dynamic-skill",+    "cli"+  ],+  "visibility": "private",+  "org": "adom",+  "author": {+    "name": "John Lauer",+    "email": "[email protected]"+  }+}\ No newline at end of file
schema/board-release.schema.jsonadded+25
@@ -0,0 +1,25 @@+{+ "$schema": "https://json-schema.org/draft/2020-12/schema",+ "$id": "adom/board-release@1",+ "title": "Board release: the handoff from the board flow to every later stage",+ "description": "Written when a board is finished. Every later flow app (ordering, probing, ...) starts from this file and checks the sha256 of each file it names, so it never builds a board that changed after it was finished.",+ "type": "object",+ "required": ["schema", "board", "bom"],+ "properties": {+  "schema": {"const": "adom/board-release@1"},+  "name": {"type": "string", "description": "board name, e.g. buck-12v5v-molecule"},+  "page": {"type": ["string", "null"], "description": "the board's wiki page (owner/slug), if it has one"},+  "board": {"$ref": "#/$defs/file", "description": "the finished .kicad_pcb (or .brd)"},+  "schematic": {"$ref": "#/$defs/file"},+  "bom": {"$ref": "#/$defs/file", "description": "BOM csv: ref, mpn, value, plus vendor columns (lcsc, mouser...) and fit (post|dnp|hand for parts fitted after assembly)"},+  "spec": {"$ref": "#/$defs/file"},+  "sourcingProfile": {"type": "string", "description": "the profile the BOM was checked against (fab, jlcpcb, ...)"},+  "finished": {"type": "string", "format": "date-time"},+  "producedBy": {"type": "string", "description": "app and version that wrote this, e.g. adom-aiflow 0.1.52"},+  "checks": {"type": "object", "description": "what the board flow proved: drc, routed, analyses, with their results"},+  "notes": {"type": "array", "items": {"type": "string"}, "description": "anything a later stage must know"}+ },+ "$defs": {+  "file": {"type": "object", "required": ["path", "sha256"], "properties": {"path": {"type": "string"}, "sha256": {"type": "string"}}}+ }+}
skills/adom-aiflow-video/SKILL.mdadded+62
@@ -0,0 +1,62 @@+---+name: adom-aiflow-video+description: >-+  Make a narrated walkthrough video of a board built in Adom Hydrogen, put it on the project's wiki page and help the human upload it to YouTube. A dynamic skill: it suggests the next step, records clips, ships a ready-made 3D walkthrough director and gives gentle feedback on the script, privacy and leftover files; you do the work. Trigger words: video of my board, build video, walkthrough video, make a video of the build, film my board, board walkthrough, 3D walkthrough video, video ready in the morning, upload my board video to youtube, put the video on my wiki page, narrated video, adom-aiflow-video.+---++# adom-aiflow-video++You make the video. This binary is the guide and the checker: about 3 % of the job to your 97 %. `next` suggests a step, the checks report what they saw with a suggestion, and nothing here is a gate: every command completes, and you may take a better route when you see one (say what you did). The only error is "I could not run".++```+adom-aiflow-video init [--release <board-release.json>] [--target <recording machine>] --ai-thread <you>+adom-aiflow-video next                     # the suggested step and what to do in it+adom-aiflow-video steps                    # every step with its state+adom-aiflow-video disk --clips 60 --seconds 15      # free space and what the plan needs+adom-aiflow-video clip start --name <moment> --hwnd <window> --ai-thread <you>   /   clip stop+adom-aiflow-video script script.json       # feedback on the narration+adom-aiflow-video scan <text files>        # personal details in text that will be published+adom-aiflow-video mark <step> --note "..." --ai-thread <you>     # record a step done in your own words+adom-aiflow-video track <path> [--machine <name>] [--keep] --ai-thread <you>+adom-aiflow-video clean [--yes]            # what the run left behind; --yes removes it+```++## Start at the first prompt++A good build video is decided when the build starts, not when it ends. When a human asks for a board, ask once whether they want a narrated video of the build, ready to review when they are back. If the build is already finished you can still make the walkthrough and the finished-board shots; say that the build footage does not exist.++Before recording, tell the human three things: it needs disk space (`disk` gives the estimate; check the recording machine's own disk), recording takes over that machine's screen for the whole build, and a second machine on their Adom Mesh keeps their own free.++## What makes the film good++- **No title cards, no logo stings.** Open on the prompt being typed. Every shot is real software: Hydrogen full screen with the Windows taskbar showing, KiCad, the 3D viewer or Fields floating at about 90 percent over it.+- **Highlights only.** 5 to 30 s per moment, motion in every clip. Leave AI Flow's full-length step recording off.+- **No dead air and no flashed shots.** Narration sets the length; footage plays at 1x to 2x. 3D plays at 1x.+- **The 3D walkthrough** (docs/walkthrough.md, assets/walkthrough-director.js): the enclosure part by part, then the board top and underside, each part highlighted, labelled and described. Finish the board first: routed, poured, checked, with good silkscreen.+- **Captions and chapters are sidecar files**, never burned in.+- **Look at it.** A contact sheet of every scene before you call it a draft, and the rendered wiki page after you publish.++## What the script says++- **The starting prompt**, read out in the opening (shortened if long; the full prompt goes in the description).+- **Which Adom tool did each step, while it is on screen**: AI Flow, the KiCad, Fusion or Altium bridge, Adom parts search, the wiki and its AI-made part pages, Adom's KiCad service, Adom Fields, Hydrogen webviews, pup, the Adom 3D viewer. Only tools the run really used, about one mention per 20 to 30 s; the rest go in the description's "Built with" list.+- **A few "ask your AI to ..." lines**, so the viewer learns how to do the same thing.+- **The parts story**: how many unique parts and how many placed, what they cost (to build one, and each at 100, saying what the number covers), and where they came from: already on the wiki, new pages made by Adom's AI, found with parts search, fetched in pup because a vendor site wanted a login or a captcha, or redrawn from the datasheet. Report it as it happened; leave out a sentence you cannot back with the run's records.+- **Custom mechanical work**, when there is some: what was designed and printed, in what material, how it was checked against the real board, and with which tools.+- **One closing line** that it was built in Adom Hydrogen and that the files are on the wiki page, over the project page itself.+- Facts and numbers, no superlatives. A fab that is not public is "a 3rd party fab".++## Privacy++A screen recording shows whatever was on the screen. Record only Hydrogen and the build's own windows; a clean second machine is the best protection. `scan` reads the script, captions and description for email addresses, phone numbers, keys and file paths with a user name. Frames need your eyes: look through the contact sheet for mail, chat, names, avatars, other people's windows and toasts. Prefer another take or a tighter crop to a blur, and tell the human what you found.++## Publishing++Show the human the draft in the Adom video player (`adom-video play <file> --chapters <json>`) and take their changes first. Publishing is theirs to approve.++- **Wiki**: push the film, a poster, the .vtt and .srt, and a `video.json` manifest with the captions and chapters inlined; put `::adom-video{src="/<owner>/<slug>/render/docs/videos/<name>.video.json"}` on its own line under the page title with a plain download link; use a new file name when replacing a film. Check the live player (duration, chapters, captions, a few seconds of muted playback).+- **YouTube**: if adom-google is not set up, suggest it (a YouTube-only account, then pick the channel). Upload unlisted unless they ask for public: `adom-google --account <name> youtube upload <file> --privacy unlisted --title ... --description ... --thumbnail <png>`. The description carries the chapter timestamps, the starting prompt, the cost line, the "Built with" list and the project page link. If an API upload is not possible for their account, fill in YouTube Studio in their own signed-in browser and leave the Publish click to them.++## Clean up++Raw footage is large. `track` every file you create, on every machine. When the human has approved the final film, `clean` lists what is left and `clean --yes` removes it; remove the files on other machines there, and look for the desktop recorder's temp folder and any full-length step recordings in the AI Flow run folder. Keep the film, the captions, the chapters and the edit plan, and tell the human how much space came back.
src/bridge.rsadded+82
@@ -0,0 +1,82 @@+#![allow(dead_code)] // template helpers: keep the ones your stage uses+//! Adom Bridge from a flow app: every desktop action (a browser through pup, a native app, a window+//! recording, files to and from the desktop) is one `adom-bridge` call. The app runs on Linux; the+//! desktop it drives is whatever `--target` names. Copied from aiflow-bridge; keep what you use.+use serde_json::{json, Value};+use std::path::{Path, PathBuf};+use std::process::Command;++#[derive(Clone)]+pub struct Bridge { pub target: String, pub ai_thread: String }++pub struct ClipStop { pub remote: Option<String>, pub local: Option<PathBuf>, pub pulled: bool, pub reply: Value }++impl Bridge {+    /// One verb with JSON args. Gated verbs need a per-call `reason` inside `args`.+    pub fn call(&self, verb: &str, args: &Value) -> Value {+        let out = Command::new("adom-bridge").args(["--ai-thread", &self.ai_thread, "--target", &self.target, verb, &args.to_string()]).output();+        match out {+            Ok(o) => {+                let text = String::from_utf8_lossy(&o.stdout).to_string();+                let v: Value = serde_json::from_str(&text).unwrap_or_else(|_| json!({"status": "parse_error", "raw": text.chars().take(400).collect::<String>()}));+                // some verbs wrap their answer as a JSON string in `output`+                match v.get("output").and_then(|o| o.as_str()).and_then(|s| serde_json::from_str::<Value>(s).ok()) { Some(inner) => inner, None => v }+            }+            Err(e) => json!({"status": "error", "error": format!("adom-bridge not runnable: {e}")}),+        }+    }++    /// A field from a reply, at the top level or under `data` (verbs answer both ways).+    pub fn field<'a>(v: &'a Value, k: &str) -> Option<&'a Value> {+        v.get(k).or_else(|| v.get("data").and_then(|d| d.get(k)))+    }++    pub fn ok(v: &Value) -> bool {+        v.get("success").and_then(|s| s.as_bool()) == Some(true) || (v.get("status").and_then(|s| s.as_str()) == Some("ok") && v.get("success").is_none())+    }++    /// Record ONE window (Windows Graphics Capture: works in the background, nobody at the desk is+    /// disturbed). Capped at an hour whatever the AI forgets. Returns the recording id.+    pub fn record_window_start(&self, hwnd: i64, reason: &str) -> Result<String, Value> {+        let r = self.call("desktop_record_window_start", &json!({"hwnd": hwnd, "fps": 30, "reason": reason, "maxDurationMs": 3_600_000}));+        Self::field(&r, "recordingId").or_else(|| Self::field(&r, "id")).and_then(|v| v.as_str()).map(str::to_string).ok_or(r)+    }++    /// Stop a recording and pull the file into `save_to`, checked: the local copy must exist and be+    /// non-empty. The desktop cleans its recordings folder, so pull now or lose the clip.+    pub fn record_stop(&self, id: &str, save_to: &Path) -> ClipStop {+        let r = self.call("desktop_record_stop", &json!({"recordingId": id}));+        let remote = Self::field(&r, "filePath").or_else(|| Self::field(&r, "path"))+            .and_then(|v| v.as_str()).map(|s| s.replace('\\', "/"));+        match remote {+            Some(p) => { let (pulled, local) = self.pull(&p, save_to, 3); ClipStop { remote: Some(p), local: Some(local), pulled, reply: r } }+            None => ClipStop { remote: None, local: None, pulled: false, reply: r },+        }+    }++    /// pull_file one desktop file into `save_to` until it lands non-empty (at most `attempts`).+    pub fn pull(&self, remote: &str, save_to: &Path, attempts: usize) -> (bool, PathBuf) {+        let local = save_to.join(remote.rsplit(['/', '\\']).next().unwrap_or(remote));+        for k in 0..attempts.max(1) {+            self.call("pull_file", &json!({"filePaths": [remote], "saveTo": save_to.display().to_string(), "reason": "flow app: bring a desktop file into the project"}));+            if std::fs::metadata(&local).map(|m| m.len() > 0).unwrap_or(false) { return (true, local); }+            if k + 1 < attempts { std::thread::sleep(std::time::Duration::from_secs(3)); }+        }+        (false, local)+    }++    /// A background screenshot of one window, copied to `dest`.+    pub fn screenshot_window(&self, hwnd: i64, dest: &Path) -> Option<PathBuf> {+        let r = self.call("desktop_screenshot_window", &json!({"hwnd": hwnd, "reason": "flow app: evidence screenshot"}));+        let d = r.get("data").cloned().unwrap_or(r.clone());+        let shots = d.get("screenshots").and_then(|s| s.as_array()).cloned().unwrap_or_else(|| vec![d.clone()]);+        let local = shots.iter().find_map(|s| s.get("localPath").or_else(|| s.get("savedTo")).and_then(|v| v.as_str()).map(str::to_string))?;+        std::fs::copy(&local, dest).ok()?;+        Some(dest.to_path_buf())+    }++    /// JavaScript in a pup (Puppeteer) browser window. No OS cursor moves, so recordings stay clean.+    pub fn pup_eval(&self, window: &str, expression: &str, reason: &str) -> Value {+        self.call("pup_eval", &json!({"window": window, "expression": expression, "reason": reason}))+    }+}
src/checks.rsadded+200
@@ -0,0 +1,200 @@+//! The checks. Each one REPORTS what it saw and SUGGESTS a next action. None of them decides+//! anything: a finding is feedback for the AI and the human to weigh, never a failure.+use serde_json::{json, Value};+use std::path::Path;++/// Measured on real 4K full-screen takes (Ghost Hand rev 2): about 4.5 MB per second of clip.+const MB_PER_SECOND: f64 = 4.5;+/// adom-tts speaks about 2.6 words a second.+const WORDS_PER_SECOND: f64 = 2.6;++/// Adom tools a script may credit, with the words that count as a mention.+const TOOLS: [(&str, &[&str]); 11] = [+    ("Adom Hydrogen", &["hydrogen"]),+    ("AI Flow", &["ai flow", "aiflow"]),+    ("a CAD bridge", &["kicad bridge", "fusion bridge", "altium bridge"]),+    ("Adom parts search", &["parts search"]),+    ("the Adom wiki", &["adom wiki", "the wiki", "wiki page"]),+    ("Adom Fields", &["adom fields", "fields "]),+    ("Adom's KiCad service", &["kicad service"]),+    ("webviews", &["webview"]),+    ("pup", &[" pup", "its own browser"]),+    ("the Adom 3D viewer", &["3d viewer"]),+    ("adom-tts", &["adom-tts"]),+];++fn words(t: &str) -> usize { t.split_whitespace().count() }++pub fn disk(dir: &Path, clips: u64, seconds: u64) -> (String, Vec<String>) {+    let need_gb = clips as f64 * seconds as f64 * MB_PER_SECOND / 1024.0;+    let out = std::process::Command::new("df").args(["-Pk"]).arg(dir).output();+    let free_gb = out.ok().and_then(|o| String::from_utf8_lossy(&o.stdout).lines().nth(1).and_then(|l| l.split_whitespace().nth(3).and_then(|k| k.parse::<f64>().ok()))).map(|k| k / 1024.0 / 1024.0);+    let mut hints = vec![+        format!("{clips} clips of about {seconds} s at 4K come to roughly {need_gb:.1} GB of raw footage, plus about the same again while cutting; all of it goes away at `clean`."),+        "This number is for THIS machine. Clips are recorded on the machine named at init (--target): check its disk too, e.g. with adom-bridge shell_execute on that machine.".to_string(),+        "Keep clips short (5 to 30 s) and leave AI Flow's own full-length step recording off: on one build those recordings reached 45 GB.".to_string(),+        "Tell the human that recording takes over that machine's screen, and suggest a second machine on their Adom Mesh.".to_string(),+    ];+    let msg = match free_gb {+        Some(f) => {+            if f < need_gb * 2.5 { hints.insert(0, format!("Space looks tight here ({f:.0} GB free for about {:.0} GB of work): consider fewer or shorter clips, 1080p capture, or another machine.", need_gb * 2.0)); }+            format!("{f:.0} GB free here; this plan needs about {:.0} GB while working", need_gb * 2.0)+        }+        None => format!("could not read free space here; this plan needs about {:.0} GB while working", need_gb * 2.0),+    };+    (msg, hints)+}++/// script.json: [{"id", "text", "a"?, "b"? (seconds of footage), ...}]+pub fn script(text: &str) -> (String, Vec<String>) {+    let scenes: Vec<Value> = match serde_json::from_str::<Value>(text) {+        Ok(Value::Array(a)) => a,+        Ok(v) => v.get("scenes").and_then(|s| s.as_array()).cloned().unwrap_or_default(),+        Err(e) => return (format!("the script is not JSON yet ({e})"), vec!["Expected a list of scenes: [{\"id\": \"s01\", \"text\": \"...\"}, ...]".into()]),+    };+    let mut hints = vec![];+    let mut total = 0.0;+    let mut mentions: Vec<(String, &str)> = vec![];+    for (i, s) in scenes.iter().enumerate() {+        let id = s["id"].as_str().map(str::to_string).unwrap_or(format!("#{}", i + 1));+        let t = s["text"].as_str().unwrap_or("");+        let secs = words(t) as f64 / WORDS_PER_SECOND;+        total += secs;+        if words(t) > 34 { hints.push(format!("{id}: {} words (about {secs:.0} s) is a long hold for one shot; two shorter scenes may read better.", words(t))); }+        if t.contains('\u{2014}') { hints.push(format!("{id}: has an em-dash; a comma or a new sentence narrates more cleanly.")); }+        let low = format!(" {} ", t.to_lowercase());+        let here: Vec<&str> = TOOLS.iter().filter(|(_, keys)| keys.iter().any(|k| low.contains(k))).map(|(n, _)| *n).collect();+        if here.len() > 2 { hints.push(format!("{id}: names {} Adom tools in one scene ({}); one per scene lands better, the rest can go in the description.", here.len(), here.join(", "))); }+        for n in here { mentions.push((id.clone(), n)); }+        if let (Some(a), Some(b)) = (s["a"].as_f64(), s["b"].as_f64()) {+            let speed = (b - a) / (secs + 0.35);+            if speed > 2.5 { hints.push(format!("{id}: {:.0} s of footage for {secs:.0} s of narration plays at {speed:.1}x; a shorter range (or a longer line) keeps it watchable.", b - a)); }+            if speed < 0.8 { hints.push(format!("{id}: only {:.0} s of footage for {secs:.0} s of narration; the shot will slow down or freeze unless it is longer.", b - a)); }+        }+    }+    let all = scenes.iter().map(|s| s["text"].as_str().unwrap_or("")).collect::<Vec<_>>().join(" ").to_lowercase();+    let first = scenes.first().and_then(|s| s["text"].as_str()).unwrap_or("").to_lowercase();+    if !first.contains("prompt") { hints.push("The opening does not mention the starting prompt; viewers like to see the one request the build began with.".into()); }+    if !all.contains("hydrogen") { hints.push("Nothing says the build was made in Adom Hydrogen; one spoken line near the end is enough.".into()); }+    if !(all.contains("dollar") || all.contains('$') || all.contains("cost")) { hints.push("No cost line yet: what it costs to build one, and each at 100, is one of the most useful facts in the film.".into()); }+    if !all.contains("ask your ai") && !all.contains("just say") { hints.push("No 'ask your AI to ...' line yet; one or two teach the viewer how to do the same thing.".into()); }+    if !(all.contains("parts") && (all.contains("unique") || all.contains("wiki"))) { hints.push("Consider the parts story: how many unique parts, where they came from (the wiki, new pages, fetched in pup, redrawn) and their cost.".into()); }+    let budget = (total / 25.0).ceil().max(1.0) as usize;+    if mentions.len() > budget + 2 { hints.push(format!("{} Adom mentions in about {:.0} s; around {budget} (one per 20 to 30 s) keeps it from sounding like a list.", mentions.len(), total)); }+    if mentions.is_empty() && !scenes.is_empty() { hints.push("No Adom tool is named; say which tool did each step while it is on screen.".into()); }+    let mut used: Vec<&str> = mentions.iter().map(|m| m.1).collect(); used.sort(); used.dedup();+    if !used.is_empty() { hints.push(format!("tools credited: {}", used.join(", "))); }+    hints.push("These are suggestions; the script is yours. Show it to the human with the draft.".into());+    (format!("{} scenes, about {:.0} s of narration ({}:{:02})", scenes.len(), total, total as u64 / 60, total as u64 % 60), hints)+}++fn looks_like_email(w: &str) -> bool {+    let w = w.trim_matches(|c: char| !c.is_alphanumeric() && c != '@' && c != '.' && c != '_' && c != '-' && c != '+');+    match w.split_once('@') {+        Some((a, b)) => !a.is_empty() && b.contains('.') && !b.starts_with('.') && !b.ends_with('.') && b.chars().all(|c| c.is_alphanumeric() || c == '.' || c == '-'),+        None => false,+    }+}++fn looks_like_phone(w: &str) -> bool {+    let d = w.chars().filter(|c| c.is_ascii_digit()).count();+    (10..=12).contains(&d) && w.chars().all(|c| c.is_ascii_digit() || "+-.()".contains(c)) && w.contains(|c| "-.()".contains(c))+}++/// Text that will be published: the script, captions, the description. Frames need the AI's eyes.+pub fn scan(files: &[String]) -> (Vec<Value>, Vec<String>) {+    let mut found = vec![];+    let mut hints = vec![];+    for f in files {+        let Ok(text) = std::fs::read_to_string(f) else { hints.push(format!("could not read {f}; skipped.")); continue };+        for (n, line) in text.lines().enumerate() {+            let mut add = |kind: &str, what: &str| found.push(json!({"file": f, "line": n + 1, "kind": kind, "text": what}));+            for w in line.split_whitespace() {+                if looks_like_email(w) { add("email address", w); }+                if looks_like_phone(w) { add("phone number", w); }+                for p in ["/home/", "/Users/", "C:\\Users\\", "C:/Users/"] { if w.contains(p) { add("file path with a user name", w); } }+                let bare = w.trim_matches(|c: char| !c.is_alphanumeric() && c != '_' && c != '-');+                if ["sk-", "ghp_", "gho_", "AKIA", "xoxb-", "xoxp-", "AIza"].iter().any(|k| bare.starts_with(k)) && bare.len() >= 20 { add("what looks like a key or token", &format!("{}...", &bare[..8])); }+            }+        }+    }+    for x in found.iter().take(12) { hints.push(format!("{}:{} {}: {}", x["file"].as_str().unwrap_or(""), x["line"], x["kind"].as_str().unwrap_or(""), x["text"].as_str().unwrap_or(""))); }+    if found.len() > 12 { hints.push(format!("... and {} more in privacy.json", found.len() - 12)); }+    hints.push("This reads text only. Look through the contact sheet too: mail, chat, names, account avatars, other people's windows and notification toasts show up in frames, not in files.".into());+    hints.push("When a clip shows something personal, a different take or a tighter crop usually beats a blur. Tell the human what you found and what you did.".into());+    (found, hints)+}++fn size_of(p: &Path) -> u64 {+    if p.is_file() { return p.metadata().map(|m| m.len()).unwrap_or(0); }+    std::fs::read_dir(p).map(|rd| rd.flatten().map(|e| size_of(&e.path())).sum()).unwrap_or(0)+}++/// What the run left behind. With `yes`, local files not marked keep are removed; files on other+/// machines are listed for the AI to remove there.+pub fn clean(flow_dir: &Path, yes: bool) -> (String, Vec<String>, Value) {+    let list: Vec<Value> = std::fs::read_to_string(flow_dir.join("files.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();+    let mut items: Vec<(String, Option<String>, bool)> = list.iter().map(|x| (x["path"].as_str().unwrap_or("").to_string(), x["machine"].as_str().map(str::to_string), x["keep"].as_bool().unwrap_or(false))).collect();+    for sub in ["clips", "work", "ovl"] {+        let d = flow_dir.join(sub);+        if d.exists() { items.push((d.display().to_string(), None, false)); }+    }+    let (mut hints, mut freed, mut left, mut remote) = (vec![], 0u64, 0u64, vec![]);+    for (path, machine, keep) in &items {+        if let Some(m) = machine { remote.push(format!("{m}: {path}")); continue; }+        let p = Path::new(path);+        if !p.exists() { continue; }+        let sz = size_of(p);+        if *keep { hints.push(format!("keeping {path} ({:.1} MB)", sz as f64 / 1e6)); continue; }+        if yes {+            let r = if p.is_dir() { std::fs::remove_dir_all(p) } else { std::fs::remove_file(p) };+            match r { Ok(_) => freed += sz, Err(e) => hints.push(format!("could not remove {path}: {e}")) }+        } else { left += sz; hints.push(format!("would remove {path} ({:.1} MB)", sz as f64 / 1e6)); }+    }+    for r in &remote { hints.push(format!("on another machine, remove it there: {r}")); }+    if items.is_empty() { hints.push("Nothing tracked. `track <path>` each file as you create it; look also for the desktop recorder's temp folder and AI Flow's own step recordings in the run folder.".into()); }+    if !yes { hints.push("Run `clean --yes` once the human has approved the final film. Keep the film, captions, chapters and the edit plan.".into()); }+    let msg = if yes { format!("freed {:.2} GB here; {} item(s) on other machines listed", freed as f64 / 1e9, remote.len()) } else { format!("{:.2} GB here can go; {} item(s) on other machines", left as f64 / 1e9, remote.len()) };+    (msg, hints, json!({"freedBytes": freed, "remote": remote, "items": items.len()}))+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn script_feedback_is_suggestions() {+        let s = r#"[{"id":"s01","text":"This board started as one prompt in Adom Hydrogen.","a":0,"b":6},+                    {"id":"s02","text":"AI Flow placed and routed it in KiCad through the KiCad bridge, about twenty-nine dollars a board.","a":0,"b":60}]"#;+        let (msg, hints) = script(s);+        assert!(msg.starts_with("2 scenes"));+        assert!(hints.iter().any(|h| h.contains("s02") && h.contains("x;")));+        assert!(!hints.iter().any(|h| h.contains("No cost line")));+        assert!(hints.iter().any(|h| h.contains("ask your AI")));+    }++    #[test]+    fn scan_finds_personal_details_in_text() {+        let f = std::env::temp_dir().join(format!("scan-test-{}.txt", std::process::id()));+        std::fs::write(&f, "Built by [email protected]\nSee /home/sam/project/board\nCall 817-555-0100\nthirty by twenty-two millimetres\n").unwrap();+        let (found, _) = scan(&[f.display().to_string()]);+        let kinds: Vec<&str> = found.iter().map(|x| x["kind"].as_str().unwrap()).collect();+        assert!(kinds.contains(&"email address") && kinds.contains(&"file path with a user name") && kinds.contains(&"phone number"));+        assert_eq!(found.len(), 3);+        std::fs::remove_file(&f).ok();+    }++    #[test]+    fn clean_lists_before_it_removes() {+        let d = std::env::temp_dir().join(format!("clean-test-{}", std::process::id()));+        std::fs::create_dir_all(d.join("clips")).unwrap();+        std::fs::write(d.join("clips/a.mp4"), vec![0u8; 2048]).unwrap();+        let (_, _, r) = clean(&d, false);+        assert_eq!(r["freedBytes"], 0);+        assert!(d.join("clips/a.mp4").exists());+        let (_, _, r) = clean(&d, true);+        assert_eq!(r["freedBytes"], 2048);+        assert!(!d.join("clips").exists());+        std::fs::remove_dir_all(&d).ok();+    }+}
src/flow.rsadded+52
@@ -0,0 +1,52 @@+//! The flow file (flows/flow.json, compiled in): the ordered steps, the artifacts that prove each one+//! is done, and what the AI must do in it. `next` names the first step whose artifacts are missing.+//! The binary does the deterministic parts; the AI does the thinking the `do` text asks for.+use serde::Deserialize;+use std::path::Path;++#[derive(Deserialize, Clone)]+pub struct Step { pub name: String, pub done: Vec<String>, #[serde(rename = "do")] pub todo: String }++#[derive(Deserialize)]+#[allow(dead_code)]+pub struct Flow { pub name: String, pub description: String, pub steps: Vec<Step> }++pub fn load() -> Flow { serde_json::from_str(include_str!("../flows/flow.json")).expect("flows/flow.json is valid") }++/// One artifact pattern, relative to the flow folder; `*` matches within the last path segment.+pub fn exists(flow_dir: &Path, pat: &str) -> bool {+    let full = flow_dir.join(pat);+    let name = full.file_name().and_then(|n| n.to_str()).unwrap_or("");+    if !name.contains('*') { return full.exists(); }+    let (pre, post) = name.split_once('*').unwrap_or((name, ""));+    let dir = full.parent().unwrap_or(flow_dir);+    std::fs::read_dir(dir).map(|rd| rd.flatten().any(|e| {+        let n = e.file_name().to_string_lossy().to_string();+        n.starts_with(pre) && n.ends_with(post) && n.len() >= pre.len() + post.len()+    })).unwrap_or(false)+}++pub fn current<'a>(flow: &'a Flow, flow_dir: &Path) -> Option<&'a Step> {+    flow.steps.iter().find(|s| !s.done.iter().all(|p| exists(flow_dir, p)))+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn current_step_is_the_first_with_missing_artifacts() {+        let d = std::env::temp_dir().join(format!("flow-test-{}", std::process::id()));+        std::fs::create_dir_all(d.join("clips")).unwrap();+        let f = load();+        assert_eq!(current(&f, &d).map(|s| s.name.as_str()), Some("intake"));+        std::fs::write(d.join("state.json"), "{}").unwrap();+        assert_eq!(current(&f, &d).map(|s| s.name.as_str()), Some("plan"));+        std::fs::write(d.join("plan.json"), "{}").unwrap();+        assert_eq!(current(&f, &d).map(|s| s.name.as_str()), Some("capture"));+        std::fs::write(d.join("clips/a.mp4"), "x").unwrap();+        assert!(exists(&d, "clips/*.mp4"));+        assert_eq!(current(&f, &d).map(|s| s.name.as_str()), Some("walkthrough"));+        std::fs::remove_dir_all(&d).ok();+    }+}
src/ledger.rsadded+50
@@ -0,0 +1,50 @@+//! The project's flow folder (`<project>/<flow>/`): state.json plus run.jsonl, one line per command+//! with the time, the calling AI thread and what it did. Never rewrite earlier lines.+use serde_json::{json, Value};+use std::io::Write;+use std::path::{Path, PathBuf};++pub fn now() -> String {+    let out = std::process::Command::new("date").args(["-u", "+%Y-%m-%dT%H:%M:%SZ"]).output();+    out.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default()+}++#[allow(dead_code)]+pub struct Project { pub dir: PathBuf, pub flow_dir: PathBuf, pub state: Value }++impl Project {+    /// The nearest folder at or above `start` that holds `<flow>/state.json`.+    pub fn find(start: &Path, flow: &str) -> Option<Project> {+        let mut d = Some(start.to_path_buf());+        while let Some(p) = d {+            let s = p.join(flow).join("state.json");+            if s.is_file() {+                let state = std::fs::read_to_string(&s).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}));+                return Some(Project { flow_dir: p.join(flow), dir: p, state });+            }+            d = p.parent().map(Path::to_path_buf);+        }+        None+    }++    pub fn create(dir: &Path, flow: &str, state: Value) -> std::io::Result<Project> {+        let flow_dir = dir.join(flow);+        std::fs::create_dir_all(&flow_dir)?;+        let p = Project { dir: dir.to_path_buf(), flow_dir, state };+        p.save()?;+        Ok(p)+    }++    pub fn save(&self) -> std::io::Result<()> {+        std::fs::write(self.flow_dir.join("state.json"), serde_json::to_string_pretty(&self.state)? + "\n")+    }++    pub fn path(&self, rel: &str) -> PathBuf { self.flow_dir.join(rel) }++    pub fn log(&self, thread: &str, ev: &str, data: Value) {+        let line = json!({"t": now(), "aiThread": thread, "ev": ev, "data": data});+        if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(self.flow_dir.join("run.jsonl")) {+            let _ = writeln!(f, "{line}");+        }+    }+}
src/main.rsadded+209
@@ -0,0 +1,209 @@+//! adom-aiflow-video: a dynamic skill. The AI makes the video (about 97 % of the work); this binary is+//! the guide and the checker (about 3 %): it suggests the next step, records clips, and its checks+//! report findings with suggestions. It is never a gate: every command completes, and the only+//! error is "I could not run".+mod bridge;+mod flow;+mod ledger;+mod out;+mod checks;++use clap::{Parser, Subcommand};+use out::{err, ok};+use serde_json::{json, Value};+use std::path::{Path, PathBuf};++/// The folder this flow keeps inside the project (state.json, run.jsonl, clips/, evidence).+const FLOW_DIR: &str = "video-flow";++#[derive(Parser)]+#[command(name = "adom-aiflow-video", version, about = "AI Flow video: guides your AI from a board build to a narrated walkthrough video on the wiki and YouTube. Suggestions only, never a gate.")]+struct Cli {+    /// The name of the AI conversation calling (required for commands that change state)+    #[arg(long, global = true)]+    ai_thread: Option<String>,+    /// Project folder (default: the nearest one above the current folder that has this flow's state)+    #[arg(long, global = true)]+    project: Option<String>,+    #[command(subcommand)]+    cmd: Cmd,+}++#[derive(Subcommand)]+enum Cmd {+    /// Start the flow on a finished board: `init --release <board-release.json>` (written by the board flow at finish) or `init --board <B.kicad_pcb> --bom <csv>`+    Init { #[arg(long)] release: Option<String>, #[arg(long)] board: Option<String>, #[arg(long)] bom: Option<String>, #[arg(long)] target: Option<String> },+    /// What to do now: the first step whose artifacts are missing, and what the AI must do in it+    Next,+    /// Every step with its state+    Steps,+    /// The run ledger, one line per command+    Ledger,+    /// `clip start --name <step> --hwnd <window>` / `clip stop`: record one desktop window for the video+    Clip { what: String, #[arg(long)] name: Option<String>, #[arg(long)] hwnd: Option<i64> },+    /// Mark a step done in your own words: `mark review --note "John: looks good"` writes <step>.json+    Mark { step: String, #[arg(long)] note: Option<String> },+    /// Free space here and a size estimate for the planned clips: `disk --clips 60 --seconds 15`+    Disk { #[arg(long, default_value_t = 60)] clips: u64, #[arg(long, default_value_t = 15)] seconds: u64 },+    /// Feedback on the narration script (pace, Adom mentions, opening prompt, cost, closing): `script script.json`+    Script { file: String },+    /// Look for personal details in text that will be published (script, captions, description): `scan <files...>`+    Scan { files: Vec<String> },+    /// Remember a file this run created so `clean` can find it: `track <path> [--machine crr] [--keep]`+    Track { path: String, #[arg(long)] machine: Option<String>, #[arg(long)] keep: bool },+    /// What this run left behind, and with --yes remove the local files not marked keep+    Clean { #[arg(long)] yes: bool },+    /// Write the ready-made 3D walkthrough director (JavaScript for the Adom 3D viewer): `director --out walkthrough-director.js`+    Director { #[arg(long, default_value = "walkthrough-director.js")] out: String },+    /// Print the walkthrough guide: the steps file, how to record a take, and what we learned+    Guide,+}++fn thread(cli: &Cli) -> String {+    cli.ai_thread.clone().unwrap_or_else(|| err("this command changes state: pass --ai-thread \"<your thread name>\"", &[]))+}++fn project(cli: &Cli) -> ledger::Project {+    let start = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());+    ledger::Project::find(&start, FLOW_DIR).unwrap_or_else(|| err(&format!("no {FLOW_DIR}/ project here"), &["Start one: adom-aiflow-video init --release <board-release.json> --ai-thread <you>".into()]))+}++fn sha256(p: &Path) -> String {+    let o = std::process::Command::new("sha256sum").arg(p).output();+    o.ok().and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().next().map(str::to_string)).unwrap_or_default()+}++fn main() {+    let cli = Cli::parse();+    let fl = flow::load();+    match &cli.cmd {+        Cmd::Init { release, board, bom, target } => {+            let th = thread(&cli);+            let dir = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());+            // the handoff: a board release from the board flow, or the board and BOM named directly+            let mut notes: Vec<String> = vec![];+            let rel: Value = match (release, board) {+                (Some(r), _) => match std::fs::read_to_string(r).ok().and_then(|t| serde_json::from_str(&t).ok()) {+                    Some(v) => v,+                    None => { notes.push(format!("{r} could not be read as JSON; carrying on without a board release. The board flow writes board-release.json at finish.")); json!({}) }+                },+                (None, Some(b)) => json!({"schema": "adom/board-release@1", "board": {"path": b, "sha256": sha256(Path::new(b))}, "bom": {"path": bom, "sha256": bom.as_ref().map(|m| sha256(Path::new(m)))}}),+                (None, None) => { notes.push("no board release named: fine for a video of a finished project or a wiki page; name the board later in plan.json so the parts story has its numbers.".into()); json!({}) }+            };+            for k in ["board", "bom"] {+                let p = rel[k]["path"].as_str().unwrap_or("");+                if p.is_empty() { continue; }+                if !Path::new(p).is_file() { notes.push(format!("the release's {k} is not at {p} on this machine; point plan.json at where it is.")); continue; }+                let want = rel[k]["sha256"].as_str().unwrap_or("");+                if !want.is_empty() && want != sha256(Path::new(p)) { notes.push(format!("{p} changed since the release was written; check the video shows the board you mean.")); }+            }+            let p = ledger::Project::create(&dir, FLOW_DIR, json!({"flow": fl.name, "release": rel, "target": target, "started": ledger::now()})).unwrap_or_else(|e| err(&format!("cannot write {FLOW_DIR}/: {e}"), &[]));+            p.log(&th, "init", json!({"release": release}));+            let next = flow::current(&fl, &p.flow_dir).map(|s| format!("next step: {}: {}", s.name, s.todo)).unwrap_or_default();+            notes.push(next);+            if target.is_none() { notes.push("no --target: clips record through Adom Bridge on a named machine; a second machine on the human's Adom Mesh keeps their own free.".into()); }+            ok(&format!("{} flow started in {}", fl.name, p.flow_dir.display()), &notes);+        }+        Cmd::Next => {+            let p = project(&cli);+            match flow::current(&fl, &p.flow_dir) {+                Some(s) => ok(&format!("step {}", s.name), &[s.todo.clone()]),+                None => ok("every step has its artifacts: the flow is done", &[]),+            }+        }+        Cmd::Steps => {+            let p = project(&cli);+            let cur = flow::current(&fl, &p.flow_dir).map(|s| s.name.clone());+            for s in &fl.steps {+                let done = s.done.iter().all(|x| flow::exists(&p.flow_dir, x));+                println!("{} {}", if done { "done" } else if Some(&s.name) == cur.as_ref() { "NOW " } else { "    " }, s.name);+            }+        }+        Cmd::Ledger => {+            let p = project(&cli);+            print!("{}", std::fs::read_to_string(p.path("run.jsonl")).unwrap_or_default());+        }+        Cmd::Clip { what, name, hwnd } => {+            let th = thread(&cli);+            let mut p = project(&cli);+            let target = p.state["target"].as_str().map(str::to_string).unwrap_or_else(|| err("no desktop target: init with --target <box>", &[]));+            let br = bridge::Bridge { target, ai_thread: th.clone() };+            match what.as_str() {+                "start" => {+                    let (Some(n), Some(h)) = (name, hwnd) else { err("clip start needs --name <step> --hwnd <window>", &["Find the window with `adom-bridge desktop_list_windows`. Drive browsers with pup (no OS cursor), so the clip shows no moving mouse.".into()]) };+                    if p.state["clip"].is_object() { err("a clip is already recording: clip stop first", &[]); }+                    let id = br.record_window_start(*h, &format!("{FLOW_DIR}: record step {n}")).unwrap_or_else(|r| err(&format!("recording did not start: {r}"), &[]));+                    p.state["clip"] = json!({"id": id, "name": n, "hwnd": h, "started": ledger::now()});+                    p.save().ok(); p.log(&th, "clip-start", p.state["clip"].clone());+                    ok(&format!("recording {n} (window {h})"), &[]);+                }+                "stop" => {+                    let c = p.state["clip"].clone();+                    let id = c["id"].as_str().unwrap_or_else(|| err("no clip is recording", &[]));+                    let dir = p.path("clips"); std::fs::create_dir_all(&dir).ok();+                    let s = br.record_stop(id, &dir);+                    p.state["clip"] = Value::Null; p.save().ok();+                    p.log(&th, "clip-stop", json!({"name": c["name"], "remote": s.remote, "local": s.local, "pulled": s.pulled}));+                    if !s.pulled { err(&format!("the clip did not land locally: {}", s.reply), &["Pull it by hand with pull_file before the desktop cleans its recordings folder.".into()]); }+                    ok(&format!("clip {} saved to {}", c["name"].as_str().unwrap_or(""), s.local.unwrap().display()), &[]);+                }+                _ => err("clip start|stop", &[]),+            }+        }+        Cmd::Mark { step, note } => {+            let th = thread(&cli);+            let p = project(&cli);+            let known = fl.steps.iter().any(|s| &s.name == step);+            let f = p.path(&format!("{step}.json"));+            let _ = std::fs::write(&f, serde_json::to_string_pretty(&json!({"step": step, "note": note, "t": ledger::now(), "aiThread": th})).unwrap_or_default() + "\n");+            p.log(&th, "mark", json!({"step": step, "note": note}));+            let mut h = vec![];+            if !known { h.push(format!("'{step}' is not a step in the flow file; recorded anyway. Steps: {}", fl.steps.iter().map(|s| s.name.as_str()).collect::<Vec<_>>().join(", "))); }+            if let Some(s) = flow::current(&fl, &p.flow_dir) { h.push(format!("next suggestion: {}: {}", s.name, s.todo)); }+            ok(&format!("{step} marked ({})", f.display()), &h);+        }+        Cmd::Disk { clips, seconds } => {+            let dir = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());+            let (msg, hints) = checks::disk(&dir, *clips, *seconds);+            ok(&msg, &hints);+        }+        Cmd::Script { file } => {+            let text = std::fs::read_to_string(file).unwrap_or_else(|e| err(&format!("could not read {file}: {e}"), &["Write script.json first: [{\"id\": \"s01\", \"text\": \"...\", \"a\": 1.0, \"b\": 12.0}, ...]".into()]));+            let (msg, hints) = checks::script(&text);+            ok(&msg, &hints);+        }+        Cmd::Scan { files } => {+            let (found, mut hints) = checks::scan(files);+            if let Some(p) = cli.project.as_ref().map(PathBuf::from).or_else(|| std::env::current_dir().ok()).and_then(|d| ledger::Project::find(&d, FLOW_DIR)) {+                let _ = std::fs::write(p.path("privacy.json"), serde_json::to_string_pretty(&json!({"t": ledger::now(), "files": files, "findings": found})).unwrap_or_default() + "\n");+                hints.push(format!("written to {}", p.path("privacy.json").display()));+            }+            ok(&format!("{} thing(s) worth a look in {} file(s)", found.len(), files.len()), &hints);+        }+        Cmd::Track { path, machine, keep } => {+            let th = thread(&cli);+            let p = project(&cli);+            let f = p.path("files.json");+            let mut list: Vec<Value> = std::fs::read_to_string(&f).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();+            list.push(json!({"path": path, "machine": machine, "keep": keep, "t": ledger::now()}));+            let _ = std::fs::write(&f, serde_json::to_string_pretty(&list).unwrap_or_default() + "\n");+            p.log(&th, "track", json!({"path": path, "machine": machine, "keep": keep}));+            ok(&format!("tracking {path} ({} file(s) so far)", list.len()), &[]);+        }+        Cmd::Director { out } => {+            std::fs::write(out, include_str!("../assets/walkthrough-director.js")).unwrap_or_else(|e| err(&format!("could not write {out}: {e}"), &[]));+            ok(&format!("walkthrough director written to {out}"), &["Inject it into the project's Adom 3D viewer page with pup_evaluate, then GHW.setupCase / GHW.setupBoard / GHW.mergeBoard / GHW.run. `guide` explains the steps file and the take.".into()]);+        }+        Cmd::Guide => print!("{}", include_str!("../docs/walkthrough.md")),+        Cmd::Clean { yes } => {+            let p = project(&cli);+            let (msg, hints, report) = checks::clean(&p.flow_dir, *yes);+            if *yes {+                let th = thread(&cli);+                let _ = std::fs::write(p.path("cleanup.json"), serde_json::to_string_pretty(&report).unwrap_or_default() + "\n");+                p.log(&th, "clean", report);+            }+            ok(&msg, &hints);+        }+    }+}
src/out.rsadded+13
@@ -0,0 +1,13 @@+//! Every command answers the AI the same way: `OK: <what happened>` or `ERROR: <what is wrong>`,+//! then `Hint:` lines that depend on this project and this run. Exit 1 on ERROR.++pub fn ok(msg: &str, hints: &[String]) {+    println!("OK: {msg}");+    for h in hints.iter().filter(|h| !h.is_empty()) { println!("Hint: {h}"); }+}++pub fn err(msg: &str, hints: &[String]) -> ! {+    println!("ERROR: {msg}");+    for h in hints.iter().filter(|h| !h.is_empty()) { println!("Hint: {h}"); }+    std::process::exit(1)+}
uninstall.shadded+7
@@ -0,0 +1,7 @@+#!/bin/bash+# adom-wiki package uninstall hook: remove the binary and the skills this app installed.+HERE="$(cd "$(dirname "$0")" && pwd)"+NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' "$HERE/Cargo.toml" | head -1)"+rm -f "$HOME/.local/bin/$NAME"+for skill in "$HERE"/skills/*; do for consumer in .claude .codex; do rm -rf "$HOME/$consumer/skills/$(basename "$skill")"; done; done+echo "OK: $NAME removed"