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.5
10 files changed
+72−21
Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow-video"-version = "0.1.4"+version = "0.1.5" 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.4"+version = "0.1.5" 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.4, 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.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.
assets/narrate.html+61−12@@ -21,7 +21,7 @@ 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 { width: 260px; height: 16px; 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; }@@ -48,8 +48,11 @@ <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>+ <button id="cal" class="ghost" title="Two seconds of quiet, then read one sentence: the boost is set for your voice">Calibrate</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="ai" id="heard" style="min-width:150px">mic not tested yet</span>+ <label class="ai" style="color:var(--dim)" title="Mic boost: raise it if you are quiet; Calibrate sets it for you">Boost <input id="boost" type="range" min="1" max="16" step="0.1" value="1" style="vertical-align:middle;width:120px;accent-color:var(--teal)"> <b id="boostv" style="color:var(--text)">1.0x</b></label> <span class="spacer"></span> <div class="takes" id="takes">No takes yet.</div> </div>@@ -59,6 +62,8 @@ 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;+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; @@ -74,27 +79,67 @@ open = async function () { 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();+ if (ctx.state !== 'running') await ctx.resume();+ // microphone -> boost -> limiter -> what gets recorded; the meter shows the boosted level+ const src = ctx.createMediaStreamSource(stream);+ rawAn = ctx.createAnalyser(); rawAn.fftSize = 1024; src.connect(rawAn);+ gainNode = ctx.createGain(); gainNode.gain.value = boost;+ const lim = ctx.createDynamicsCompressor(); lim.threshold.value = -3; lim.knee.value = 0; lim.ratio.value = 20; lim.attack.value = 0.002; lim.release.value = 0.1; const an = ctx.createAnalyser(); an.fftSize = 1024;- ctx.createMediaStreamSource(stream).connect(an);- const buf = new Float32Array(an.fftSize);+ const dest = ctx.createMediaStreamDestination();+ src.connect(gainNode); gainNode.connect(lim); lim.connect(an); lim.connect(dest);+ recStream = dest.stream;+ const buf = new Float32Array(an.fftSize), raw = new Float32Array(rawAn.fftSize); cancelAnimationFrame(raf); (function loop() {- an.getFloatTimeDomainData(buf);+ an.getFloatTimeDomainData(buf); rawAn.getFloatTimeDomainData(raw); 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) + '%';+ let r = 0; for (const x of raw) r = Math.max(r, Math.abs(x));+ rawLevel = r; if (sampler) sampler(r);+ peak = Math.max(peak, m); level = m;+ const db = m > 0 ? 20 * Math.log10(m) : -100; // 0 dB = full scale+ $('lvl').style.width = Math.max(0, Math.min(100, (db + 54) / 54 * 100)) + '%'; $('lvl').classList.toggle('hot', m > 0.95);+ if (m > 0.03) heardAt = performance.now();+ const live = performance.now() - heardAt < 700;+ $('heard').textContent = m > 0.95 ? 'too loud: back off a little' : live ? (m < 0.12 ? 'I hear you (a bit quiet)' : 'I hear you: good level') : 'listening... say something';+ $('heard').style.color = m > 0.95 ? 'var(--red)' : live ? 'var(--teal)' : 'var(--dim)'; raf = requestAnimationFrame(loop); })(); };+function setBoost(x) {+ boost = Math.max(1, Math.min(16, +x || 1));+ if (gainNode) gainNode.gain.value = boost;+ $('boost').value = boost; $('boostv').textContent = boost.toFixed(1) + 'x';+ try { localStorage.setItem('narrate.boost', String(boost)); } catch (_) {}+}+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); }+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.');+} $('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.'));+$('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.')); }; $('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 = new MediaRecorder(recStream, 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;@@ -110,7 +155,7 @@ 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) };+ 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), boost: +boost.toFixed(2), calibration: cal }; say('Saving the take...'); try { const r = await (await fetch('/take', { method: 'POST', headers: { 'X-Take-Meta': encodeURIComponent(JSON.stringify(meta)) }, body: blob })).json();@@ -120,19 +165,21 @@ async function save() { 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.'); } }+$('boost').oninput = e => setBoost(e.target.value);+$('cal').onclick = calibrate;+setBoost(boost); navigator.mediaDevices.addEventListener('devicechange', mics); mics(); // ---- the live channel: the AI drives this page through the app's CLI, and the page reports back-let level = 0, micAccess = 'not asked yet', lastStatus = '';+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), 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, takes: takes.length, status: lastStatus }; fetch('/state', { method: 'POST', body: JSON.stringify(body) }).catch(() => {}); } setInterval(report, 1000); const _open = open; open = async () => { try { await _open(); micAccess = 'allowed'; } catch (e) { micAccess = 'blocked: ' + e.message; report(); throw e; } report(); };-(function meter() { const w = parseFloat($('lvl').style.width) || 0; level = w / 140; requestAnimationFrame(meter); })(); function toast(text) { const el = $('toast'); el.textContent = text; el.classList.add('on'); clearTimeout(toast.t); toast.t = setTimeout(() => el.classList.remove('on'), 4000); } async function cursorTo(el, label) {@@ -148,6 +195,8 @@ on('record', d => aiClick($('rec'), (d.by || 'AI') + ': start recording')); on('stop', d => aiClick($('stop'), (d.by || 'AI') + ': stop and save')); on('test', d => aiClick($('test'), (d.by || 'AI') + ': test the microphone')); on('mic', async d => { const o = [...$('mic').options].find(o => o.textContent.toLowerCase().includes(String(d.name || '').toLowerCase())); if (!o) return toast('No microphone matching "' + d.name + '"'); await cursorTo($('mic'), (d.by || 'AI') + ': pick ' + o.textContent); $('mic').value = o.value; $('mic').onchange(); setTimeout(() => $('aicur').classList.remove('on'), 900); });+on('calibrate', d => aiClick($('cal'), (d.by || 'AI') + ': calibrate the microphone'));+on('gain', async d => { await cursorTo($('boost'), (d.by || 'AI') + ': boost ' + d.value + 'x'); setBoost(d.value); report(); setTimeout(() => $('aicur').classList.remove('on'), 900); }); on('seek', d => { v.currentTime = Math.max(0, +d.seconds || 0); report(); }); on('play', () => v.play()); on('pause', () => v.pause());
bin/adom-aiflow-video⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow-video", "type": "app",- "version": "0.1.4",+ "version": "0.1.5", "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.4",+ "version": "0.1.5", "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. The browser's own microphone permission is theirs to click.+ - **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. 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+3−1@@ -59,7 +59,7 @@ enum Cmd { /// 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- /// With an action it drives the open page: `narrate status|test|record|stop|play|pause|reload`, `narrate mic <name>`, `narrate seek <seconds>`, `narrate toast "<text>"`, `narrate click|reveal|cursor <selector>`+ /// With an action it drives the open page: `narrate status|test|calibrate|record|stop|play|pause|reload`, `narrate gain <x>`, `narrate mic <name>`, `narrate seek <seconds>`, `narrate toast "<text>"`, `narrate click|reveal|cursor <selector>` Narrate { action: Option<String>, arg: Option<String>, #[arg(long)] video: Option<String>, #[arg(long)] out: Option<String>, #[arg(long, default_value_t = 0)] port: u16 }, } @@ -217,6 +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!("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"])); }@@ -226,6 +227,7 @@ fn main() { let body = match (act, arg) { ("seek", Some(a)) => json!({"seconds": a.parse::<f64>().unwrap_or(0.0)}), ("mic", Some(a)) => json!({"name": a}),+ ("gain", Some(a)) => json!({"value": a.parse::<f64>().unwrap_or(1.0)}), ("toast", Some(a)) => json!({"text": a, "kind": "info"}), ("click" | "reveal" | "cursor", Some(a)) => json!({"selector": a}), _ => json!({}),
src/narrate.rs+2−2@@ -123,8 +123,8 @@ fn handle(mut s: TcpStream, video: PathBuf, out: PathBuf, sh: Arc<Mutex<Shared>> let mut data: Value = serde_json::from_slice(&body).unwrap_or(json!({})); if who.is_empty() { who = data["aiThread"].as_str().unwrap_or("").to_string(); } if who.is_empty() { return respond(&mut s, "400 Bad Request", "application/json", "", json!({"error": "caller_identity_required", "hint": "say who is driving the page: pass --ai-thread \"<your thread name>\" (header X-AI-Thread)"}).to_string().as_bytes()); }- if !["record", "stop", "test", "mic", "seek", "toast", "click", "reveal", "cursor", "reload", "play", "pause"].contains(&verb.as_str()) {- return respond(&mut s, "404 Not Found", "application/json", "", json!({"error": "unknown directive", "hint": "record, stop, test, mic, seek, play, pause, toast, click, reveal, cursor, reload"}).to_string().as_bytes());+ if !["record", "stop", "test", "calibrate", "gain", "mic", "seek", "toast", "click", "reveal", "cursor", "reload", "play", "pause"].contains(&verb.as_str()) {+ return respond(&mut s, "404 Not Found", "application/json", "", json!({"error": "unknown directive", "hint": "record, stop, test, calibrate, gain, mic, seek, play, pause, toast, click, reveal, cursor, reload"}).to_string().as_bytes()); } data["by"] = who.clone().into(); let pages = sh.lock().unwrap().clients.len();