← Commit history
Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow-video"-version = "0.1.2"+version = "0.1.3" 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.1.2"+version = "0.1.3" edition = "2021" license = "MIT" 
README.md+2−2
@@ -46,11 +46,11 @@ AI Flow: Video is a dynamic skill, like AI Flow itself: a small Rust binary that 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+director --out <file.js>        guide                narrate --video <teleprompter.mp4> ```  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.2, 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.+0.1.3, 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, 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.htmladded+117
@@ -0,0 +1,117 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width, initial-scale=1">+<title>Narrate your video</title>+<style>+  :root { --bg:#0b1114; --panel:#121b20; --line:#22323a; --text:#e8f1f3; --dim:#8fa3ab; --teal:#14c4b8; --red:#ff5d5d; }+  * { box-sizing: border-box; }+  html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text); font: 16px/1.45 system-ui, "Segoe UI", sans-serif; }+  body { display: grid; grid-template-rows: auto 1fr auto; gap: 12px; padding: 16px; }+  header { display: flex; align-items: center; gap: 16px; white-space: nowrap; }+  h1 { font-size: 20px; margin: 0; font-weight: 650; }+  .state { color: var(--dim); overflow: hidden; text-overflow: ellipsis; }+  .state b { color: var(--text); }+  .stage { min-height: 0; display: flex; align-items: center; justify-content: center; background: #000; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }+  video { max-width: 100%; max-height: 100%; }+  .bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; }+  button { font: inherit; font-weight: 600; color: var(--bg); background: var(--teal); border: 0; border-radius: 8px; padding: 9px 16px; cursor: pointer; }+  button.ghost { background: transparent; color: var(--text); border: 1px solid var(--line); }+  button.rec { background: var(--red); color: #fff; }+  button:disabled { opacity: .45; cursor: default; }+  select { font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; max-width: 320px; }+  .meter { width: 180px; height: 10px; background: var(--bg); border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }+  .meter i { display: block; height: 100%; width: 0; background: var(--teal); transition: width .06s linear; }+  .meter i.hot { background: var(--red); }+  .spacer { flex: 1; }+  .takes { color: var(--dim); font-size: 14px; }+  .takes a { color: var(--teal); }+  .dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: var(--red); margin-right: 6px; animation: pulse 1s infinite; }+  @keyframes pulse { 50% { opacity: .25; } }+</style>+</head>+<body>+<header>+  <h1>Narrate your video</h1>+  <div class="state" id="state">Pick your microphone, press <b>Record</b>, and read each line as it appears, in your own words.</div>+</header>+<div class="stage"><video id="v" src="/video" preload="auto" playsinline muted></video></div>+<div class="bar">+  <button id="rec" title="Start the teleprompter from the top and record your voice with it">Record</button>+  <button id="stop" class="ghost" disabled title="Stop and save this take">Stop and save</button>+  <button id="test" class="ghost" title="Check your microphone level without recording">Test mic</button>+  <select id="mic" title="Which microphone to record"></select>+  <div class="meter" title="Microphone level: aim for the bar to reach about two thirds when you speak"><i id="lvl"></i></div>+  <span class="spacer"></span>+  <div class="takes" id="takes">No takes yet.</div>+</div>+<script>+const $ = id => document.getElementById(id);+const v = $('v'), state = $('state');+let stream = null, rec = null, chunks = [], ctx = null, raf = 0, t = {}, peak = 0, takes = [];+const say = html => state.innerHTML = html;++async function mics() {+  const devs = (await navigator.mediaDevices.enumerateDevices()).filter(d => d.kind === 'audioinput');+  const keep = $('mic').value;+  $('mic').innerHTML = devs.map((d, i) => `<option value="${d.deviceId}">${d.label || 'Microphone ' + (i + 1)}</option>`).join('');+  if (keep) $('mic').value = keep;+}+async function open() {+  if (stream) stream.getTracks().forEach(x => x.stop());+  const id = $('mic').value;+  stream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: id ? { exact: id } : undefined, echoCancellation: false, noiseSuppression: false, autoGainControl: false, channelCount: 1 } });+  await mics();+  ctx = ctx || new AudioContext();+  const an = ctx.createAnalyser(); an.fftSize = 1024;+  ctx.createMediaStreamSource(stream).connect(an);+  const buf = new Float32Array(an.fftSize);+  cancelAnimationFrame(raf);+  (function loop() {+    an.getFloatTimeDomainData(buf);+    let m = 0; for (const x of buf) m = Math.max(m, Math.abs(x));+    peak = Math.max(peak, m);+    $('lvl').style.width = Math.min(100, m * 140) + '%';+    $('lvl').classList.toggle('hot', m > 0.95);+    raf = requestAnimationFrame(loop);+  })();+}+$('mic').onchange = () => open().catch(e => say('Could not open that microphone: ' + e.message));+$('test').onclick = () => open().then(() => say('Speak normally: the bar should reach about two thirds. Red means too loud.')).catch(e => say('The browser did not give access to a microphone (' + e.message + '). Allow it in the address bar and try again.'));++$('rec').onclick = async () => {+  try { await open(); } catch (e) { return say('The browser did not give access to a microphone (' + e.message + '). Allow it in the address bar and try again.'); }+  chunks = []; peak = 0; t = {};+  const type = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4'].find(x => MediaRecorder.isTypeSupported(x)) || '';+  rec = new MediaRecorder(stream, type ? { mimeType: type, audioBitsPerSecond: 192000 } : undefined);+  rec.ondataavailable = e => e.data.size && chunks.push(e.data);+  rec.onstart = () => { t.rec = performance.now(); v.currentTime = 0; v.play(); };+  rec.onstop = save;+  v.onplaying = () => { if (t.rec && t.play === undefined) t.play = performance.now(); };+  rec.start(1000);+  $('rec').disabled = true; $('stop').disabled = false; $('mic').disabled = true;+  say('<span class="dot"></span><b>Recording.</b> Read each line as it appears. It stops by itself at the end.');+};+$('stop').onclick = () => rec && rec.state !== 'inactive' && rec.stop();+v.onended = () => rec && rec.state !== 'inactive' && rec.stop();++async function save() {+  v.pause();+  $('rec').disabled = false; $('stop').disabled = true; $('mic').disabled = false;+  const blob = new Blob(chunks, { type: rec.mimeType || 'audio/webm' });+  const meta = { mime: blob.type, mic: $('mic').selectedOptions[0]?.textContent || '', videoStartsAtMs: Math.round((t.play ?? t.rec) - t.rec), recordedMs: Math.round(performance.now() - t.rec), videoStoppedAtS: +v.currentTime.toFixed(2), videoDurationS: +v.duration.toFixed(2), peak: +peak.toFixed(3) };+  say('Saving the take...');+  try {+    const r = await (await fetch('/take', { method: 'POST', headers: { 'X-Take-Meta': encodeURIComponent(JSON.stringify(meta)) }, body: blob })).json();+    takes.push(r);+    $('takes').innerHTML = takes.map((x, i) => `Take ${i + 1}: ${(x.seconds / 60 | 0)}:${String(Math.round(x.seconds % 60)).padStart(2, '0')} <a href="/takes/${x.file}" target="_blank">listen</a>`).join(' &nbsp;ยท&nbsp; ');+    const note = meta.peak > 0.98 ? ' It clipped in places (too loud): move back a little and try another take.' : meta.peak < 0.08 ? ' It is very quiet: check the right microphone is picked.' : '';+    say(`<b>Take ${takes.length} saved.</b> Tell your AI it is ready, or press Record for another take.` + note);+  } catch (e) { say('The take could not be saved (' + e.message + '). It is still in this page: do not close it, and tell your AI.'); }+}+navigator.mediaDevices.addEventListener('devicechange', mics);+mics();+</script>+</body>+</html>
bin/adom-aiflow-video
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow-video",   "type": "app",-  "version": "0.1.2",+  "version": "0.1.3",   "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.1.2",+  "version": "0.1.3",   "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",
skills/adom-aiflow-video/SKILL.md+2−1
@@ -18,6 +18,7 @@ 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 narrate --video <teleprompter.mp4>   # a page that records the human's voice in step with the film adom-aiflow-video clean [--yes]            # what the run left behind; --yes removes it ``` @@ -66,7 +67,7 @@ Let the human choose, and make either easy.    Ask which microphone they want to use; a better mic is worth it:   - **A recorder of their own** (a wireless lavalier or handheld that records on the device, a field recorder, a phone): they record while watching the teleprompter copy, plug the recorder in or send the file, and you fetch the newest recording. Start the teleprompter with a countdown and ask them to say "start" as it ends, so you can line the recording up; then match each scene by what they said.-  - **The computer's microphone**: record it in sync with the teleprompter copy in a page they open, so the timing is known.+  - **The computer's microphone** (built in, or any USB mic the computer sees): `adom-aiflow-video narrate --video <teleprompter.mp4>` serves a page that plays the teleprompter copy and records them in step with it. Open its address in their own browser on the machine with the microphone (a pup window or their default browser), they pick the microphone, check the level, press Record and read along. Each take lands in `video-flow/voice/take-N.webm` with `take-N.json` beside it: which microphone, the peak level, and how many milliseconds into the recording the video started, so every word lines up with the film.   Either way, listen to the result before you cut: check for clipping, room noise and missed scenes, and offer a retake of single scenes rather than the whole film.  ## Privacy
src/main.rs+9
@@ -7,6 +7,7 @@ mod flow; mod ledger; mod out; mod checks;+mod narrate;  use clap::{Parser, Subcommand}; use out::{err, ok};@@ -57,6 +58,8 @@ enum Cmd {     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,+    /// Let the human narrate with their computer's microphone: `narrate --video teleprompter.mp4` serves a page that plays it and records them in step+    Narrate { #[arg(long)] video: String, #[arg(long)] out: Option<String>, #[arg(long, default_value_t = 0)] port: u16 }, }  fn thread(cli: &Cli) -> String {@@ -195,6 +198,12 @@ fn main() {             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::Narrate { video, out, port } => {+            if !Path::new(video).is_file() { err(&format!("could not find the teleprompter video: {video}"), &["Make a working copy of the draft with the captions showing and a short countdown at the start, then pass it with --video.".into()]); }+            let start = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());+            let dir = out.as_ref().map(PathBuf::from).or_else(|| ledger::Project::find(&start, FLOW_DIR).map(|p| p.path("voice"))).unwrap_or_else(|| start.join("voice"));+            if let Err(e) = narrate::serve(Path::new(video), &dir, *port) { err(&e, &["Another narration page may still be running on that port; pass --port <other>.".into()]); }+        }         Cmd::Clean { yes } => {             let p = project(&cli);             let (msg, hints, report) = checks::clean(&p.flow_dir, *yes);
src/narrate.rsadded+113
@@ -0,0 +1,113 @@+//! `narrate`: a small local page that plays the teleprompter copy and records the human's microphone+//! in step with it, so the timing of every word against the video is known. Takes land in+//! `<out>/take-N.<ext>` with a `take-N.json` beside each (which mic, when the video started inside+//! the recording, the peak level). Standard library only: one thread per connection.+use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};+use std::net::{TcpListener, TcpStream};+use std::path::{Path, PathBuf};++const PAGE: &str = include_str!("../assets/narrate.html");++fn respond(s: &mut TcpStream, status: &str, ctype: &str, extra: &str, body: &[u8]) {+    let _ = write!(s, "HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n{extra}\r\n", body.len());+    let _ = s.write_all(body);+}++/// A file with HTTP Range support, so the browser can seek in the video.+fn serve_file(s: &mut TcpStream, path: &Path, range: Option<&str>) {+    let Ok(mut f) = std::fs::File::open(path) else { return respond(s, "404 Not Found", "text/plain", "", b"not found") };+    let len = f.metadata().map(|m| m.len()).unwrap_or(0);+    let ctype = match path.extension().and_then(|e| e.to_str()).unwrap_or("") { "mp4" | "m4v" => "video/mp4", "webm" => "video/webm", "m4a" => "audio/mp4", "wav" => "audio/wav", _ => "application/octet-stream" };+    let (start, end, status) = match range.and_then(|r| r.trim().strip_prefix("bytes=")).and_then(|r| r.split_once('-')) {+        Some((a, b)) if len > 0 => {+            let a: u64 = a.parse().unwrap_or(0);+            let b: u64 = b.parse().unwrap_or(len - 1).min(len - 1);+            if a > b { return respond(s, "416 Range Not Satisfiable", "text/plain", "", b""); }+            (a, b, "206 Partial Content")+        }+        _ => (0, len.saturating_sub(1), "200 OK"),+    };+    let n = if len == 0 { 0 } else { end - start + 1 };+    let _ = write!(s, "HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {n}\r\nAccept-Ranges: bytes\r\nContent-Range: bytes {start}-{end}/{len}\r\nConnection: close\r\n\r\n");+    if f.seek(SeekFrom::Start(start)).is_err() { return; }+    let mut left = n;+    let mut buf = vec![0u8; 256 * 1024];+    while left > 0 {+        let want = buf.len().min(left as usize);+        match f.read(&mut buf[..want]) { Ok(0) | Err(_) => break, Ok(k) => { if s.write_all(&buf[..k]).is_err() { break; } left -= k as u64; } }+    }+}++fn pct_decode(t: &str) -> String {+    let b = t.as_bytes();+    let (mut out, mut i) = (Vec::with_capacity(b.len()), 0);+    while i < b.len() {+        if b[i] == b'%' && i + 2 < b.len() {+            if let Ok(v) = u8::from_str_radix(&t[i + 1..i + 3], 16) { out.push(v); i += 3; continue; }+        }+        out.push(b[i]); i += 1;+    }+    String::from_utf8_lossy(&out).to_string()+}++fn seconds_of(path: &Path) -> f64 {+    std::process::Command::new("ffprobe").args(["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0"]).arg(path).output().ok()+        .and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok()).unwrap_or(0.0)+}++fn handle(mut s: TcpStream, video: PathBuf, out: PathBuf) {+    let mut rd = BufReader::new(s.try_clone().expect("stream"));+    let mut line = String::new();+    if rd.read_line(&mut line).unwrap_or(0) == 0 { return; }+    let mut it = line.split_whitespace();+    let (method, target) = (it.next().unwrap_or("").to_string(), it.next().unwrap_or("/").to_string());+    let (mut len, mut range, mut meta) = (0usize, None, String::new());+    loop {+        let mut h = String::new();+        if rd.read_line(&mut h).unwrap_or(0) == 0 || h == "\r\n" || h == "\n" { break; }+        if let Some((k, v)) = h.split_once(':') {+            match k.to_ascii_lowercase().as_str() { "content-length" => len = v.trim().parse().unwrap_or(0), "range" => range = Some(v.trim().to_string()), "x-take-meta" => meta = pct_decode(v.trim()), _ => {} }+        }+    }+    let path = target.split('?').next().unwrap_or("/");+    match (method.as_str(), path) {+        ("GET", "/") => respond(&mut s, "200 OK", "text/html; charset=utf-8", "", PAGE.as_bytes()),+        ("GET", "/video") => serve_file(&mut s, &video, range.as_deref()),+        ("GET", p) if p.starts_with("/takes/") => {+            let name = p.trim_start_matches("/takes/");+            if name.contains('/') || name.contains("..") { return respond(&mut s, "400 Bad Request", "text/plain", "", b""); }+            serve_file(&mut s, &out.join(name), range.as_deref())+        }+        ("POST", "/take") => {+            let mut body = vec![0u8; len];+            if rd.read_exact(&mut body).is_err() { return respond(&mut s, "400 Bad Request", "application/json", "", b"{\"error\":\"short body\"}"); }+            let _ = std::fs::create_dir_all(&out);+            let n = std::fs::read_dir(&out).map(|d| d.flatten().filter(|e| e.file_name().to_string_lossy().starts_with("take-") && !e.file_name().to_string_lossy().ends_with(".json")).count()).unwrap_or(0) + 1;+            let ext = if meta.contains("audio/mp4") { "m4a" } else { "webm" };+            let file = format!("take-{n}.{ext}");+            if std::fs::write(out.join(&file), &body).is_err() { return respond(&mut s, "500 Internal Server Error", "application/json", "", b"{\"error\":\"could not write the take\"}"); }+            let secs = seconds_of(&out.join(&file));+            let mut m: serde_json::Value = serde_json::from_str(&meta).unwrap_or(serde_json::json!({}));+            m["file"] = file.clone().into(); m["bytes"] = body.len().into(); m["seconds"] = secs.into(); m["video"] = video.display().to_string().into();+            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"]);+            println!("Hint: listen to it before you cut (clipping, room noise, missed scenes); the video starts {} ms into the recording.", m["videoStartsAtMs"]);+            respond(&mut s, "200 OK", "application/json", "", serde_json::json!({"file": file, "seconds": secs}).to_string().as_bytes())+        }+        _ => respond(&mut s, "404 Not Found", "text/plain", "", b"not found"),+    }+}++/// Serve until stopped. Returns only when the port cannot be bound.+pub fn serve(video: &Path, out: &Path, port: u16) -> Result<(), String> {+    let l = TcpListener::bind(("0.0.0.0", port)).map_err(|e| format!("could not listen on port {port}: {e}"))?;+    let port = l.local_addr().map(|a| a.port()).unwrap_or(port);+    println!("OK: narration page at http://localhost:{port}/ (teleprompter: {})", video.display());+    println!("Hint: open it in the human's own browser on the machine with the microphone (a pup window or their default browser; a browser allows the microphone on localhost). They pick the mic, press Record and read along; each take is saved in {}.", out.display());+    println!("Hint: leave this running while they record; stop it (Ctrl-C) when they are done.");+    for s in l.incoming().flatten() {+        let (v, o) = (video.to_path_buf(), out.to_path_buf());+        std::thread::spawn(move || handle(s, v, o));+    }+    Ok(())+}