app
AI Flow: Video
Public Made by Adomby adom
Turn a board build into a narrated walkthrough video on your wiki page and YouTube.
← Commit history
Publish 0.1.6
10 files changed
+110−22
Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow-video"-version = "0.1.5"+version = "0.1.6" 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.5"+version = "0.1.6" edition = "2021" license = "MIT"
README.md+1−1@@ -53,4 +53,4 @@ Every command answers `OK:` with `Hint:` lines. The skill is in [skills/adom-aif ## Status -0.1.5, 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.+0.1.6, 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.html+86−15@@ -36,6 +36,30 @@ #aicur i { display: block; width: 16px; height: 16px; border-radius: 50%; background: var(--teal); box-shadow: 0 0 0 4px rgba(20,196,184,.25); } #aicur span { position: absolute; left: 20px; top: -4px; white-space: nowrap; font-size: 13px; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 2px 8px; } .ai { color: var(--teal); font-size: 13px; }+ #calbox { position: fixed; inset: 0; z-index: 15; display: none; align-items: center; justify-content: center; background: rgba(6,10,12,.86); }+ #calbox.on { display: flex; }+ .card { width: min(1500px, 78vw); background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 28px 32px; box-shadow: 0 20px 60px rgba(0,0,0,.5); }+ .steps { display: flex; gap: 8px; margin-bottom: 18px; white-space: nowrap; }+ .steps span { font-size: clamp(13px, 1vw, 20px); padding: 4px 12px; border-radius: 20px; border: 1px solid var(--line); color: var(--dim); }+ .steps span.now { border-color: var(--teal); color: var(--teal); }+ .steps span.done { color: var(--text); }+ .card h2 { margin: 0 0 8px; font-size: clamp(28px, 2.6vw, 52px); }+ .card p { margin: 0 0 14px; color: var(--dim); font-size: clamp(17px, 1.3vw, 26px); }+ .read { font-size: clamp(34px, 3.2vw, 64px); line-height: 1.3; font-weight: 600; color: var(--text); margin: 14px 0 18px; }+ .count { display: flex; align-items: center; gap: 16px; }+ .count b { font-size: clamp(54px, 5vw, 100px); min-width: 1.4em; font-variant-numeric: tabular-nums; }+ .prog { flex: 1; height: clamp(10px, .8vw, 16px); background: var(--bg); border-radius: 6px; overflow: hidden; border: 1px solid var(--line); }+ .prog i { display: block; height: 100%; width: 0; background: var(--teal); }+ .bigmeter { height: clamp(22px, 1.8vw, 36px); background: var(--bg); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; margin-top: 16px; position: relative; }+ .bigmeter i { display: block; height: 100%; width: 0; background: var(--teal); transition: width .06s linear; }+ .bigmeter em { position: absolute; left: 66%; top: 0; bottom: 0; border-left: 2px dashed var(--dim); }+ table.res { width: 100%; border-collapse: collapse; margin: 14px 0 18px; font-size: clamp(18px, 1.4vw, 28px); }+ table.res td { padding: 9px 8px; border-bottom: 1px solid var(--line); }+ table.res td:first-child { color: var(--dim); }+ table.res b.to { color: var(--teal); }+ .verdict { font-size: clamp(18px, 1.4vw, 28px); margin-bottom: 18px; }+ .card button { font-size: clamp(16px, 1.2vw, 24px); padding: .55em 1.1em; }+ .row { display: flex; gap: 10px; flex-wrap: wrap; } </style> </head> <body>@@ -56,13 +80,14 @@ <span class="spacer"></span> <div class="takes" id="takes">No takes yet.</div> </div>+<div id="calbox"><div class="card" id="calcard"></div></div> <div id="toast"></div> <div id="aicur"><i></i><span id="aicurlbl"></span></div> <script> const $ = id => document.getElementById(id); const v = $('v'), state = $('state'); let say, open;-let heardAt = -1e9, level = 0, rawLevel = 0, boost = 1, gainNode = null, rawAn = null, recStream = null, sampler = null, cal = null;+let heardAt = -1e9, level = 0, rawLevel = 0, boost = 1, gainNode = null, rawAn = null, recStream = null, sampler = null, cal = null, calPhase = null; try { boost = Math.max(1, Math.min(16, +localStorage.getItem('narrate.boost') || 1)); } catch (_) {} let stream = null, rec = null, chunks = [], ctx = null, raf = 0, t = {}, peak = 0, takes = []; say = html => state.innerHTML = html;@@ -116,21 +141,67 @@ function setBoost(x) { const dB = x => x > 0 ? 20 * Math.log10(x) : -100; const wait = ms => new Promise(r => setTimeout(r, ms)); async function listen(ms) { const xs = []; sampler = x => xs.push(x); await wait(ms); sampler = null; return xs.sort((p, q) => p - q); }+let lines = ['This is how I will sound when I narrate my video.'];+fetch('/captions').then(r => r.json()).then(d => { if (d.lines && d.lines.length) lines = d.lines; }).catch(() => {});+function sample() { // a real line or two from this video, about 6 s of reading+ let out = [], n = 0;+ for (const l of lines) { const w = l.split(/\s+/).length; if (out.length && n + w > 22) break; out.push(l); n += w; if (n >= 12) break; }+ return out.join(' ');+}+const stepsBar = k => `<div class="steps">${['1 Quiet', '2 Read', '3 Result'].map((s, i) => `<span class="${i < k ? 'done' : i === k ? 'now' : ''}">${s}</span>`).join('')}</div>`;+async function phase(k, title, sub, big, secs) { // one counted-down step with a live meter; returns the raw samples+ $('calcard').innerHTML = stepsBar(k) + `<h2>${title}</h2><p>${sub}</p>` + (big ? `<div class="read">${big}</div>` : '') ++ `<div class="count"><b id="cnum">${secs}</b><div class="prog"><i id="cprog"></i></div></div><div class="bigmeter"><i id="cmeter"></i><em title="aim for about here"></em></div>`;+ calPhase = { step: k + 1, title, secondsLeft: secs }; report();+ const xs = [], t0 = performance.now(), ms = secs * 1000;+ sampler = x => { const el = performance.now() - t0; if (el > 350) xs.push(x); const d = x * boost > 0 ? 20 * Math.log10(x * boost) : -100; const m = $('cmeter'); if (m) m.style.width = Math.max(0, Math.min(100, (d + 54) / 54 * 100)) + '%'; };+ while (performance.now() - t0 < ms) {+ const el = performance.now() - t0, left = Math.ceil((ms - el) / 1000);+ if ($('cnum')) { $('cnum').textContent = left; $('cprog').style.width = (el / ms * 100) + '%'; }+ if (calPhase.secondsLeft !== left) { calPhase.secondsLeft = left; report(); }+ await wait(60);+ }+ sampler = null;+ return xs.sort((p, q) => p - q);+}+const pct = (xs, p) => xs[Math.min(xs.length - 1, Math.floor(xs.length * p))] || 0; async function calibrate() { try { await open(); } catch (e) { return say('The browser did not give access to a microphone (' + e.message + ').'); }- $('cal').disabled = true;- say('<b>Calibrating, step 1 of 2.</b> Stay quiet for two seconds so I can hear the room...');- const room = await listen(2200); const noise = room[Math.floor(room.length * 0.9)] || 0;- say('<b>Step 2 of 2.</b> Read this out loud, the way you will narrate: <b>"Ghost Hand keeps a sensor faucet running while you wash your hands."</b>');- const talk = await listen(6000); const voice = talk[Math.floor(talk.length * 0.97)] || 0;- $('cal').disabled = false;- cal = { noiseDb: +dB(noise).toFixed(1), voiceDb: +dB(voice).toFixed(1), gapDb: +(dB(voice) - dB(noise)).toFixed(1) };- if (voice < Math.max(noise * 2, 0.004)) { cal.result = 'not heard'; return say('<b>I could not hear you over the room.</b> Check the right microphone is picked, move closer, and press Calibrate again.'); }- setBoost(0.5 / voice); // voice peaks land at about -6 dB- cal.boost = +boost.toFixed(2); cal.result = 'ok';- const capped = 0.5 / voice > 16 ? ' That is the most I will add: for a stronger signal move closer or raise the microphone level in your sound settings.' : '';- const noisy = cal.gapDb < 20 ? ' The room is loud next to your voice (' + cal.gapDb + ' dB apart): a closer microphone would sound cleaner.' : '';- say('<b>Calibrated: boost ' + boost.toFixed(1) + 'x.</b> Your voice peaked at ' + cal.voiceDb + ' dB and the room at ' + cal.noiseDb + ' dB (' + cal.gapDb + ' dB apart).' + capped + noisy + ' Press Record when you are ready.');+ $('calbox').classList.add('on'); $('cal').disabled = true;+ const before = boost, text = sample(), readSecs = Math.max(6, Math.ceil(text.split(/\s+/).length / 2.4) + 2);+ await phase(0, 'Get ready', 'Calibration takes about ' + (3 + 3 + readSecs) + ' seconds: a moment of quiet, then one line to read out loud.', '', 3);+ const room = await phase(0, 'Stay quiet', 'I am listening to the room. Do not talk or type.', '', 3);+ const talk = await phase(1, 'Read this out loud', 'Use the voice you will narrate with. Start now.', '“' + text + '”', readSecs);+ const noise = pct(room, 0.6), voice = pct(talk, 0.95);+ cal = { noiseDb: +dB(noise).toFixed(1), voiceDb: +dB(voice).toFixed(1), gapDb: +(dB(voice) - dB(noise)).toFixed(1), boostBefore: +before.toFixed(2) };+ const heard = voice >= Math.max(noise * 2.5, 0.004);+ let rows, verdict;+ if (heard) {+ setBoost(0.5 / voice);+ cal.boost = +boost.toFixed(2); cal.result = 'ok';+ const after = dB(voice * boost);+ rows = `<tr><td>Microphone boost</td><td>${before.toFixed(1)}x → <b class="to">${boost.toFixed(1)}x</b></td></tr>+ <tr><td>Your voice, loudest parts</td><td>${cal.voiceDb} dB → <b class="to">${after.toFixed(1)} dB</b> (the target is about -6 dB)</td></tr>+ <tr><td>The room when you were quiet</td><td>${cal.noiseDb} dB → ${dB(noise * boost).toFixed(1)} dB after the boost</td></tr>+ <tr><td>Your voice above the room</td><td><b>${cal.gapDb} dB</b></td></tr>`;+ verdict = (0.5 / voice > 16 ? 'That is the most boost I will add. For a stronger signal, move closer or raise the microphone level in your sound settings. ' : '') ++ (cal.gapDb >= 30 ? '<b style="color:var(--teal)">Good.</b> Your voice is well clear of the room.' : cal.gapDb >= 20 ? '<b style="color:var(--teal)">Fine.</b> A little room noise will be audible in the pauses.' : '<b style="color:var(--red)">Noisy.</b> The room is close to as loud as your voice. A closer microphone, or a quieter room, will sound much better.');+ } else {+ cal.result = 'not heard';+ rows = `<tr><td>Microphone boost</td><td>${before.toFixed(1)}x, <b>not changed</b></td></tr>+ <tr><td>While you read</td><td>${cal.voiceDb} dB</td></tr>+ <tr><td>While you were quiet</td><td>${cal.noiseDb} dB</td></tr>+ <tr><td>Difference</td><td><b>${cal.gapDb} dB</b> (I need at least 8 dB to tell your voice from the room)</td></tr>`;+ verdict = '<b style="color:var(--red)">I could not tell your voice from the room.</b> Either something made noise during the quiet part, or this microphone is not picking you up. Check the microphone picked below (' + ($('mic').selectedOptions[0]?.textContent || '') + '), move closer, and try again.';+ }+ $('calcard').innerHTML = stepsBar(2) + `<h2>${heard ? 'Calibrated' : 'Calibration did not work'}</h2><table class="res">${rows}</table><div class="verdict">${verdict}</div>+ <div class="row">${heard ? '<button id="cgo" class="rec">Record now</button>' : ''}<button id="cagain" class="${heard ? 'ghost' : ''}">Calibrate again</button><button id="cclose" class="ghost">Close</button></div>`;+ const close = () => { $('calbox').classList.remove('on'); calPhase = null; report(); };+ $('cclose').onclick = close;+ $('cagain').onclick = () => { close(); calibrate(); };+ if (heard) $('cgo').onclick = () => { close(); $('rec').click(); };+ $('cal').disabled = false; calPhase = { step: 3, title: 'result' };+ say(heard ? '<b>Calibrated: boost ' + before.toFixed(1) + 'x to ' + boost.toFixed(1) + 'x.</b> Voice ' + cal.voiceDb + ' dB, room ' + cal.noiseDb + ' dB (' + cal.gapDb + ' dB apart).' : '<b>Calibration did not work:</b> voice ' + cal.voiceDb + ' dB, room ' + cal.noiseDb + ' dB. Boost not changed.'); } $('mic').onchange = () => open().catch(e => say('Could not open that microphone: ' + e.message)); $('test').onclick = () => { say('Opening the microphone...'); return open().then(() => say('<b>Microphone on: ' + ($('mic').selectedOptions[0]?.textContent || '') + '.</b> Say something: 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.')); };@@ -175,7 +246,7 @@ mics(); let micAccess = 'not asked yet', lastStatus = ''; const _say = say; say = html => { _say(html); lastStatus = state.textContent; report(); }; function report() {- const body = { mics: [...$('mic').options].map(o => o.textContent), mic: $('mic').selectedOptions[0]?.textContent || '', micAccess, recording: !!rec && rec.state === 'recording', t: +v.currentTime.toFixed(2), duration: +(v.duration || 0).toFixed(2), level: +level.toFixed(3), peak: +peak.toFixed(3), rawLevel: +rawLevel.toFixed(3), boost: +boost.toFixed(2), calibration: cal, takes: takes.length, status: lastStatus };+ const body = { mics: [...$('mic').options].map(o => o.textContent), mic: $('mic').selectedOptions[0]?.textContent || '', micAccess, recording: !!rec && rec.state === 'recording', t: +v.currentTime.toFixed(2), duration: +(v.duration || 0).toFixed(2), level: +level.toFixed(3), peak: +peak.toFixed(3), rawLevel: +rawLevel.toFixed(3), boost: +boost.toFixed(2), calibration: cal, calibrating: calPhase, takes: takes.length, status: lastStatus }; fetch('/state', { method: 'POST', body: JSON.stringify(body) }).catch(() => {}); } setInterval(report, 1000);
bin/adom-aiflow-video⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow-video", "type": "app",- "version": "0.1.5",+ "version": "0.1.6", "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.5",+ "version": "0.1.6", "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+1−1@@ -67,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** (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. You can drive the open page yourself, and the human sees your cursor do it: `narrate status` (which microphones it sees, whether access was allowed, the level, where the video is, the takes), `narrate test`, `narrate mic <name>`, `narrate record`, `narrate stop`, `narrate seek <seconds>`, `narrate toast "..."` to tell them something on the page. Suggest `narrate calibrate` before the first take: two seconds of quiet, then they read one sentence, and the page sets the microphone boost so their voice peaks at a healthy level and reports how far it sits above the room noise (a small gap means a closer or better microphone would help). `narrate gain <x>` sets the boost by hand; a limiter keeps a boosted voice from clipping. The browser's own microphone permission is theirs to click. Bring the page's window to the front before a mic test or a take: a browser holds a microphone request from a window that is in the background, so nothing happens until it is visible.+ - **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. You can drive the open page yourself, and the human sees your cursor do it: `narrate status` (which microphones it sees, whether access was allowed, the level, where the video is, the takes), `narrate test`, `narrate mic <name>`, `narrate record`, `narrate stop`, `narrate seek <seconds>`, `narrate toast "..."` to tell them something on the page. Suggest `narrate calibrate` before the first take. The page walks them through it full-screen with a countdown for each part: three seconds to get ready, three of quiet, then a line from their own video to read out loud (keep the captions file beside the teleprompter copy, same name, so it has one). It ends on a result card that says what changed: the boost before and after, their voice level before and after, the room level, how far apart the two are, and whether that is good, fine or noisy; when it could not tell their voice from the room it says so, changes nothing and offers another go. `narrate status` shows the step and seconds left while it runs, and the numbers afterwards. `narrate gain <x>` sets the boost by hand; a limiter keeps a boosted voice from clipping. The browser's own microphone permission is theirs to click. Bring the page's window to the front before a mic test or a take: a browser holds a microphone request from a window that is in the background, so nothing happens until it is visible. 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+1−1@@ -217,7 +217,7 @@ fn main() { if pg.is_object() && !pg.as_object().unwrap().is_empty() { h.push(format!("microphone: {} ({} available: {}); access {}", pg["mic"].as_str().unwrap_or("not picked"), pg["mics"].as_array().map(|a| a.len()).unwrap_or(0), pg["mics"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(" | ")).unwrap_or_default(), pg["micAccess"].as_str().unwrap_or("not asked yet"))); h.push(format!("{}; video at {:.1} of {:.1} s; level now {:.2}, peak {:.2}", if pg["recording"].as_bool().unwrap_or(false) { "RECORDING" } else { "not recording" }, pg["t"].as_f64().unwrap_or(0.0), pg["duration"].as_f64().unwrap_or(0.0), pg["level"].as_f64().unwrap_or(0.0), pg["peak"].as_f64().unwrap_or(0.0)));- h.push(format!("boost {}x (raw microphone level {:.3}); calibration: {}", pg["boost"], pg["rawLevel"].as_f64().unwrap_or(0.0), if pg["calibration"].is_object() { pg["calibration"].to_string() } else { "not done yet (`narrate calibrate`)".into() }));+ h.push(format!("boost {}x (raw microphone level {:.3}); calibration: {}", pg["boost"], pg["rawLevel"].as_f64().unwrap_or(0.0), if pg["calibrating"].is_object() && pg["calibrating"]["step"] != 3 { format!("RUNNING, step {}: {} ({} s left)", pg["calibrating"]["step"], pg["calibrating"]["title"].as_str().unwrap_or(""), pg["calibrating"]["secondsLeft"]) } else if pg["calibration"].is_object() { pg["calibration"].to_string() } else { "not done yet (`narrate calibrate`)".into() })); h.push(format!("the page says: {}", pg["status"].as_str().unwrap_or(""))); } else { h.push("the page has not reported yet: open the address in the human's browser.".into()); } for t in st["takes"].as_array().cloned().unwrap_or_default() { h.push(format!("take {}: {:.0} s, peak {}, mic {}, video starts {} ms in", t["file"].as_str().unwrap_or(""), t["seconds"].as_f64().unwrap_or(0.0), t["peak"], t["mic"].as_str().unwrap_or("?"), t["videoStartsAtMs"])); }
src/narrate.rs+17@@ -25,6 +25,18 @@ fn takes_in(out: &Path) -> Vec<Value> { v } +/// The text lines of the .srt or .vtt that sits beside the video (same name), in order.+fn caption_lines(video: &Path) -> Vec<String> {+ let text = ["srt", "vtt"].iter().find_map(|e| std::fs::read_to_string(video.with_extension(e)).ok()).unwrap_or_default();+ let mut out: Vec<String> = vec![];+ for l in text.lines().map(str::trim) {+ if l.is_empty() || l == "WEBVTT" || l.contains("-->") || l.chars().all(|c| c.is_ascii_digit()) { continue; }+ if out.last().map(|p| p == l).unwrap_or(false) { continue; }+ out.push(l.to_string());+ }+ out+}+ const PAGE: &str = include_str!("../assets/narrate.html"); fn respond(s: &mut TcpStream, status: &str, ctype: &str, extra: &str, body: &[u8]) {@@ -92,6 +104,11 @@ fn handle(mut s: TcpStream, video: PathBuf, out: PathBuf, sh: Arc<Mutex<Shared>> 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()),+ // the spoken lines of this video, so the calibration reads a real one+ ("GET", "/captions") => {+ let lines = caption_lines(&video);+ respond(&mut s, "200 OK", "application/json", "", json!({"lines": lines}).to_string().as_bytes())+ } // the live channel: the page opens this once and every directive arrives on it ("GET", "/events") => { let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: keep-alive\r\nX-Accel-Buffering: no\r\n\r\n:{}\n\n", " ".repeat(2048));