← Commit history
Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow-video"-version = "0.2.8"+version = "0.2.9" dependencies = [  "clap",  "serde",
Cargo.toml+1−1
@@ -2,7 +2,7 @@ # 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.2.8"+version = "0.2.9" edition = "2021" license = "MIT" 
README.md+1−1
@@ -58,4 +58,4 @@ Your film is yours: it carries no Adom logo. The Adom logo sting is only for off  ## Status -0.2.8. 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, the walkthrough director, and the narration page for recording your own voice. 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.+0.2.9. 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, the walkthrough director, and the narration page for recording your own voice. 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/narrate.js+29−5
@@ -62,7 +62,7 @@   }   function report() {     const d = v() ? v().duration || 0 : 0, t = v() ? v().currentTime : 0, c = v() ? p._chapterAt?.(t) : null;-    const body = { mode, t: +t.toFixed(2), duration: +d.toFixed(2), left: +(d - t).toFixed(1), chapter: c ? c.title : '', mics: [...$('mic').options].map((o) => o.textContent), mic: $('mic').selectedOptions[0]?.textContent || '', micAccess, cams: [...$('cam').options].slice(1).map((o) => o.textContent), camera: $('cam').value ? $('cam').selectedOptions[0]?.textContent || '' : '', camAccess, audioPlan: $('plan').value, pip: $('cam').value || camStream ? PIP : null,+    const body = { mode, t: +t.toFixed(2), duration: +d.toFixed(2), left: +(d - t).toFixed(1), chapter: c ? c.title : '', mics: [...$('mic').options].map((o) => o.textContent), mic: $('mic').selectedOptions[0]?.textContent || '', micAccess, cams: [...$('cam').options].slice(1).map((o) => o.textContent), camera: $('cam').value ? $('cam').selectedOptions[0]?.textContent || '' : '', camAccess, filmInPage: filmLocal, audioPlan: $('plan').value, pip: $('cam').value || camStream ? PIP : null,       recording: mode === 'record', level: +level.toFixed(3), rawLevel: +rawLevel.toFixed(3), peak: +peak.toFixed(3), boost: +boost.toFixed(2), calibration: cal, calibrating: calPhase, takes: takes.length, chosen: chosen ? chosen.file : null, strokes: strokes.length, status: lastStatus };     fetch('state', { method: 'POST', body: JSON.stringify(body) }).catch(() => {});   }@@ -323,6 +323,23 @@     });   } +  // ---------------- the film, held whole in this page so it can never stall while you record ----------------+  let filmLocal = 'no', filmPct = 0, filmTry = null;+  function loadFilm() {+    if (filmTry) return filmTry;+    filmTry = (async () => {+      try {+        filmLocal = 'loading'; const r = await fetch('video'); const total = +r.headers.get('Content-Length') || 0; const rd = r.body.getReader(); const parts = []; let got = 0;+        for (;;) { const { done, value } = await rd.read(); if (done) break; parts.push(value); got += value.length; filmPct = total ? Math.round(got / total * 100) : 0; if (mode === 'idle') $('mode').textContent = `Loading film ${filmPct}%`; }+        const url = URL.createObjectURL(new Blob(parts, { type: r.headers.get('Content-Type') || 'video/mp4' }));+        const el = v(), t = el.currentTime, muted = el.muted; el.pause(); el.preload = 'auto';+        await new Promise((res, rej) => { el.addEventListener('loadeddata', res, { once: true }); el.addEventListener('error', rej, { once: true }); el.src = url; el.load(); });+        if (t > 0.2) el.currentTime = t; el.muted = muted; filmLocal = 'yes'; if (mode === 'idle') setMode('idle'); report();+      } catch (e) { filmLocal = 'failed: ' + (e && e.message || 'could not load'); filmTry = null; if (mode === 'idle') setMode('idle'); report(); }+    })();+    return filmTry;+  }+   async function preroll(text) {     const n = Math.max(0, Math.round(S.preRoll)); if (!n) return true;     $('preroll').classList.add('on'); $('pre-text').textContent = text;@@ -336,11 +353,16 @@     capsOn(); v().muted = false; p.seekTo(0); await v().play().catch(() => {});     toast('Rehearsing. Nothing is being recorded. Try drawing on the film.', 'info');   }+  let stalls = [], syncLog = [];+  setInterval(() => { if (mode === 'record' && tRec.rec) syncLog.push([Math.round(performance.now() - tRec.rec), +v().currentTime.toFixed(3)]); }, 500);+  const onStall = () => { if (mode === 'record' && tRec.rec) { stalls.push([Math.round(performance.now() - tRec.rec), +v().currentTime.toFixed(2)]); if (stalls.length === 1) toast('The film paused for a moment. Keep going: your AI can still line your voice up.', 'info'); } };   async function record() {     if (mode !== 'idle') return;     try { await openMic(); } catch (_) { return; }     if (!(cal && cal.result === 'ok')) toast('Tip: Calibrate first so your voice is recorded at a good level.', 'info');-    stopFlag = false; strokes = []; chunks = []; peak = 0; tRec = {};+    if (filmLocal !== 'yes') { modal(`<h2>Getting the film ready...</h2><p>Loading the whole film into this page so it cannot pause while you talk. <b id="film-pct"></b></p>`); const iv = setInterval(() => { const e = $('film-pct'); if (e) e.textContent = filmPct + '%'; }, 200); await loadFilm(); clearInterval(iv); closeModal();+      if (filmLocal !== 'yes') toast('The film could not be loaded into the page; it will play from your workspace instead.', 'info'); }+    stopFlag = false; strokes = []; chunks = []; peak = 0; tRec = {}; stalls = []; syncLog = [];     setMode('preroll'); say('Countdown before recording');     v().pause(); p.seekTo(0); capsOn(); v().muted = !info.cueAudio;     if (!(await preroll('Recording starts when this reaches zero. Read each line in your own words.'))) { setMode('idle'); say('Recording cancelled'); return; }@@ -349,8 +371,8 @@     rec.ondataavailable = (e) => e.data.size && chunks.push(e.data);     camRec = null; camChunks = [];     if (camStream) {-      const vt = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm', 'video/mp4'].find((x) => MediaRecorder.isTypeSupported(x)) || '';-      try { camRec = new MediaRecorder(camStream, vt ? { mimeType: vt, videoBitsPerSecond: 3000000 } : undefined); camRec.ondataavailable = (e) => e.data.size && camChunks.push(e.data); camRec.onstart = () => { tRec.cam = performance.now(); }; camRec.start(1000); }+      const vt = ['video/webm;codecs=h264', 'video/webm;codecs=vp8', 'video/webm;codecs=vp9', 'video/webm', 'video/mp4'].find((x) => MediaRecorder.isTypeSupported(x)) || '';+      try { camRec = new MediaRecorder(camStream, vt ? { mimeType: vt, videoBitsPerSecond: 2500000 } : undefined); camRec.ondataavailable = (e) => e.data.size && camChunks.push(e.data); camRec.onstart = () => { tRec.cam = performance.now(); }; camRec.start(1000); }       catch (e) { camRec = null; toast('The camera could not be recorded (' + e.message + '). Your voice is still being recorded.', 'error'); }     }     rec.onstart = () => { tRec.rec = performance.now(); v().play(); };@@ -373,7 +395,7 @@      const blob = new Blob(chunks, { type: rec.mimeType || 'audio/webm' });     const drawn = strokes.map((s) => ({ t0: s.t0, end: s.end ?? +v().currentTime.toFixed(3), wiped: s.wiped, pts: s.pts }));-    const meta = { mime: blob.type, mic: $('mic').selectedOptions[0]?.textContent || '', videoStartsAtMs: Math.round((tRec.play ?? tRec.rec) - tRec.rec), recordedMs: Math.round(performance.now() - tRec.rec), camStartsAtMs: camBlob && tRec.cam !== undefined ? Math.round(tRec.cam - tRec.rec) : null, audioPlan: $('plan').value, pip: camBlob ? { ...PIP } : null, camera: camBlob ? ($('cam').value ? $('cam').selectedOptions[0]?.textContent || '' : 'camera') : '', videoStoppedAtS: +v().currentTime.toFixed(2), videoDurationS: +v().duration.toFixed(2), whole, peak: +peak.toFixed(3), boost: +boost.toFixed(2), calibration: cal, strokes: drawn.length, drawFade: S.drawFade };+    const meta = { mime: blob.type, mic: $('mic').selectedOptions[0]?.textContent || '', videoStartsAtMs: Math.round((tRec.play ?? tRec.rec) - tRec.rec), recordedMs: Math.round(performance.now() - tRec.rec), camStartsAtMs: camBlob && tRec.cam !== undefined ? Math.round(tRec.cam - tRec.rec) : null, filmInPage: filmLocal, stalls, sync: syncLog.filter((_, i) => i % 4 === 0), audioPlan: $('plan').value, pip: camBlob ? { ...PIP } : null, camera: camBlob ? ($('cam').value ? $('cam').selectedOptions[0]?.textContent || '' : 'camera') : '', videoStoppedAtS: +v().currentTime.toFixed(2), videoDurationS: +v().duration.toFixed(2), whole, peak: +peak.toFixed(3), boost: +boost.toFixed(2), calibration: cal, strokes: drawn.length, drawFade: S.drawFade };     setMode('idle', 'Saving'); say('Saving the take');     modal(`<h2>Saving your recording...</h2><p>Keep this tab open.</p>`);     try {@@ -514,6 +536,8 @@     try { const want = localStorage.getItem('aiflow-video-cam'); if (want) { const o = [...$('cam').options].find((o) => o.textContent === want); if (o) { $('cam').value = o.value; openCam(); } else openCam(true); } } catch (_) {}     v().addEventListener('ended', () => { if (mode === 'record' && rec && rec.state !== 'inactive') rec.stop(); else if (mode === 'rehearse') { setMode('idle'); say('Rehearsal finished'); toast('That was the whole film. Press Record when you are ready.', 'success'); } });     v().addEventListener('durationchange', () => { segs(); header(); });+    v().addEventListener('waiting', onStall); v().addEventListener('stalled', onStall);+    setTimeout(loadFilm, 1500);     say('Ready');   })(); })();
bin/adom-aiflow-video
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow-video",   "type": "app",-  "version": "0.2.8",+  "version": "0.2.9",   "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",
page.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow-video",   "type": "app",-  "version": "0.2.8",+  "version": "0.2.9",   "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",
src/narrate.rs+1
@@ -323,6 +323,7 @@ fn handle(mut s: TcpStream, video: PathBuf, out: PathBuf, sh: Arc<Mutex<Shared>>             let _ = std::fs::write(out.join(format!("take-{n}.json")), serde_json::to_string_pretty(&m).unwrap_or_default() + "\n");             println!("OK: take {n} saved: {} ({:.0} s, peak {})", out.join(&file).display(), secs, m["peak"]);             if m["audioPlan"] == "external" { println!("Hint: the human is also recording on a separate recorder, so this take is the guide track. When they plug it in, fetch the newest clip from it, line it up with this take (cross-correlate the two; trim the offset), check the two stay in step to the end, and use the recorder's sound in the film."); }+            if m["stalls"].as_array().map(|a| !a.is_empty()).unwrap_or(false) { println!("Hint: the film paused {} time(s) while they recorded, so the voice is not a constant offset from the film: use the take's `sync` list ([ms into the recording, film seconds]) to line it up piece by piece.", m["stalls"].as_array().map(|a| a.len()).unwrap_or(0)); }             println!("Hint: listen to it before you cut (clipping, room noise, missed scenes); the video starts {} ms into the recording.", m["videoStartsAtMs"]);             broadcast(&sh, "state", &json!({"takes": takes_in(&out)}));             respond(&mut s, "200 OK", "application/json", "", serde_json::json!({"file": file, "seconds": secs}).to_string().as_bytes())