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.3.0
10 files changed
+92−19
Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow-video"-version = "0.2.9"+version = "0.3.0" 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.9"+version = "0.3.0" 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.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.+0.3.0. 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+59−13@@ -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, filmInPage: filmLocal, 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, playback, 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,19 +323,60 @@ }); } - // ---------------- the film, held whole in this page so it can never stall while you record ----------------- let filmLocal = 'no', filmPct = 0, filmTry = null;+ // ---------------- the film: held whole in this page, and proven to play smoothly on THIS computer ----------------+ // Some computers stall on one kind of video and play another perfectly, so before a take the page plays a few+ // seconds out of sight and measures it. If the film does not keep time, it switches to the smooth copy the+ // server makes (a different codec) and measures again.+ let filmLocal = 'no', filmPct = 0, filmTry = null, filmMsg = 'Loading the film', playback = { source: 'not tested' };+ async function pull(url, label) {+ const r = await fetch(url); if (!r.ok) throw new Error(label + ' is not available');+ const total = +r.headers.get('Content-Length') || 0, rd = r.body.getReader(), 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; filmMsg = `${label}: ${filmPct}%`; if (mode === 'idle') $('mode').textContent = filmMsg; }+ return URL.createObjectURL(new Blob(parts, { type: r.headers.get('Content-Type') || 'video/mp4' }));+ }+ async function smooth(url) {+ const x = document.createElement('video'); x.muted = true; x.playsInline = true; x.style.cssText = 'position:fixed;left:0;bottom:0;width:240px;opacity:.01;pointer-events:none;z-index:1';+ document.body.appendChild(x);+ try {+ await new Promise((res, rej) => { x.onloadeddata = res; x.onerror = () => rej(new Error('this browser cannot play it')); x.src = url; });+ const at = Math.min((x.duration || 30) * 0.3, 90); if (at > 1) { await new Promise((res) => { x.onseeked = res; x.currentTime = at; }); }+ let w = 0; x.addEventListener('waiting', () => w++); await x.play();+ const t0 = performance.now(), c0 = x.currentTime; await wait(4000);+ const wall = (performance.now() - t0) / 1000, film = x.currentTime - c0, q = x.getVideoPlaybackQuality ? x.getVideoPlaybackQuality() : {};+ return { ok: film > wall * 0.94 && w === 0 && (q.droppedVideoFrames || 0) < 12, film: +film.toFixed(2), wall: +wall.toFixed(2), waiting: w, dropped: q.droppedVideoFrames || 0 };+ } catch (e) { return { ok: false, error: e.message }; } finally { x.pause(); x.removeAttribute('src'); x.load(); x.remove(); }+ }+ async function useFilm(url) {+ 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;+ } 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(); }+ filmLocal = 'loading'; const url = await pull('video', 'Loading the film');+ filmMsg = 'Checking that the film plays smoothly on this computer'; if (mode === 'idle') $('mode').textContent = 'Checking playback';+ const first = await smooth(url);+ const altNow = (await api('state').catch(() => ({}))).alt;+ if (first.ok && altNow !== 'ready') { await useFilm(url); playback = { source: 'film', test: first }; }+ else if (first.ok) { // both exist: the smooth copy is the easier one to play, so use it when it measures well+ const url2 = await pull('video-alt', 'Loading the smooth copy'); filmMsg = 'Checking the smooth copy'; const second = await smooth(url2);+ await useFilm(second.ok ? url2 : url); playback = second.ok ? { source: 'smooth copy', test: second, filmTest: first } : { source: 'film', test: first, smoothCopyTest: second };+ }+ else {+ playback = { source: 'film', test: first, note: 'the film does not keep time in this browser' }; report();+ filmMsg = 'This computer stalls on the film as it is. Making a copy that plays smoothly (about a minute)';+ let alt = 'building'; for (let i = 0; i < 200 && alt === 'building'; i++) { alt = (await api('state').catch(() => ({}))).alt || 'none'; if (alt === 'building') await wait(2000); }+ if (alt !== 'ready') throw new Error('the film stalls here and no smooth copy could be made (' + alt + ')');+ const url2 = await pull('video-alt', 'Loading the smooth copy');+ filmMsg = 'Checking the smooth copy'; const second = await smooth(url2);+ await useFilm(second.ok ? url2 : url);+ playback = second.ok ? { source: 'smooth copy', test: second, filmTest: first } : { source: 'film', test: first, smoothCopyTest: second, note: 'neither the film nor the smooth copy keeps time here' };+ if (!second.ok) toast('This computer is not playing video smoothly right now. Close other heavy apps or try another browser; tell your AI.', 'error');+ }+ filmLocal = 'yes'; if (mode === 'idle') setMode('idle'); report();+ } catch (e) { filmLocal = 'failed: ' + (e && e.message || 'could not load'); playback = { ...playback, error: filmLocal }; filmTry = null; if (mode === 'idle') setMode('idle'); report(); } })(); return filmTry; }@@ -355,13 +396,18 @@ } let stalls = [], syncLog = []; setInterval(() => { if (mode === 'record' && tRec.rec) syncLog.push([Math.round(performance.now() - tRec.rec), +v().currentTime.toFixed(3)]); }, 500);+ let lastT = -1, lastMove = 0;+ setInterval(() => { if (mode !== 'record' || !tRec.rec || v().paused) { lastT = -1; return; } const t = v().currentTime, now = performance.now();+ if (t !== lastT) { lastT = t; lastMove = now; return; }+ if (now - lastMove > 1200) { stalls.push([Math.round(now - tRec.rec), +t.toFixed(2), 'nudged']); lastMove = now; try { v().currentTime = t + 0.04; v().play().catch(() => {}); } catch (_) {} }+ }, 300); 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');- 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'); }+ if (filmLocal !== 'yes') { modal(`<h2>Getting the film ready...</h2><p>The whole film is loaded into this page and test-played, so it cannot pause while you talk.</p><p><b id="film-pct"></b></p>`); const iv = setInterval(() => { const e = $('film-pct'); if (e) e.textContent = filmMsg; }, 200); await loadFilm(); clearInterval(iv); closeModal();+ if (filmLocal !== 'yes') toast('The film could not be prepared (' + filmLocal + '). It may pause while you record; tell your AI.', 'error'); } 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;@@ -395,7 +441,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, 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 };+ 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, playback, 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 {
bin/adom-aiflow-video⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow-video", "type": "app",- "version": "0.2.9",+ "version": "0.3.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",
page.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow-video", "type": "app",- "version": "0.2.9",+ "version": "0.3.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",
skills/adom-aiflow-video/SKILL.md+4@@ -107,3 +107,7 @@ Placement and crop are the human's choice: on the page they pick the shape (land Under the microphone the human says how they are set up. **This microphone is my final sound**: use the take as it is. **I am also recording on a separate recorder** (a wireless mic that records on the device, a phone): the page's take is the guide track. Afterwards they plug the recorder in and ask you to fetch the clip; take the newest recording from it, line it up with the guide by cross-correlation, check they stay in step to the end, and use the recorder's sound. `take-N.json` has `audioPlan` so you know which to expect, and the finish card tells them what to do. The film plays silent while they record, so they never hear the AI voiceover. If the film has a sound they must not talk over (an opening or closing sting), serve a copy whose sound track holds only those cues and start the page with `--cue-audio`: they hear the cue, and you mute those stretches of their recording afterwards because the microphone will have picked it up.++## The film must play perfectly on their computer++A narration is only as good as the film it is spoken to: if the film pauses, the voice drifts out of step. So the page loads the whole film into memory, plays a few seconds of it out of sight and measures it, before every first take. Some computers stall on one kind of video (we met a laptop that stalled on 1080p H.264 and played VP9 perfectly), so `narrate` also makes a VP9 copy beside the film (`<name>.smooth.webm`) and the page switches to it when the film does not keep time. `narrate status` shows `film playback on their computer` with what was measured and which copy is in use. If neither keeps time, the page tells the human and you: have them close heavy apps, or re-encode the film smaller and serve that. Every take's json also records `stalls` and a `sync` list (recording time against film time), so a take with a pause can still be lined up piece by piece instead of being thrown away.
src/main.rs+1@@ -251,6 +251,7 @@ fn main() { let pg = &st["page"]; let mut h = vec![format!("page open in {} browser tab(s); teleprompter {}", st["pagesOpen"], st["video"].as_str().unwrap_or(""))]; if pg.is_object() && !pg.as_object().unwrap().is_empty() {+ if !pg["playback"].is_null() { h.push(format!("film playback on their computer: {}", pg["playback"])); } 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!("mode {}; chapter \"{}\"; {} s left; chosen take: {}", pg["mode"].as_str().unwrap_or("?"), pg["chapter"].as_str().unwrap_or(""), pg["left"], st["chosen"].as_str().unwrap_or("none 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)));
src/narrate.rs+23−1@@ -252,6 +252,8 @@ fn handle(mut s: TcpStream, video: PathBuf, out: PathBuf, sh: Arc<Mutex<Shared>> respond(&mut s, "200 OK", "application/json", "", json!({"cam": cam}).to_string().as_bytes()) } ("GET", "/video") => serve_file(&mut s, &video, range.as_deref()),+ // the same film in another codec, for a computer that stalls on the original+ ("GET", "/video-alt") => serve_file(&mut s, &smooth_copy(&video), range.as_deref()), // the spoken lines of this video, so the calibration reads a real one ("GET", "/captions") => { let f = sh.lock().unwrap().state["captionsFile"].as_str().map(PathBuf::from);@@ -356,6 +358,25 @@ pub fn call(port: u16, method: &str, path: &str, ai_thread: Option<&str>, body: } /// Serve until stopped. Returns only when the port cannot be bound.+/// Where the smooth copy of a film lives: beside it, as <name>.smooth.webm.+fn smooth_copy(video: &Path) -> PathBuf { video.with_extension("smooth.webm") }++/// Make (or reuse) a VP9 copy of the film. A browser that stalls on the film as it is usually plays this one+/// perfectly; the page measures both and picks the one that keeps time.+fn build_smooth_copy(video: PathBuf, sh: Arc<Mutex<Shared>>) {+ let alt = smooth_copy(&video);+ let newer = |a: &Path, b: &Path| a.metadata().and_then(|m| m.modified()).ok().zip(b.metadata().and_then(|m| m.modified()).ok()).map(|(x, y)| x >= y).unwrap_or(false);+ let set = |v: &str| { sh.lock().unwrap().state["alt"] = v.into(); broadcast(&sh, "state", &json!({"alt": v})); };+ if alt.is_file() && newer(&alt, &video) { return set("ready"); }+ set("building");+ let tmp = alt.with_extension("part.webm");+ let ok = std::process::Command::new("ffmpeg").args(["-nostdin", "-v", "error", "-y", "-i"]).arg(&video)+ .args(["-c:v", "libvpx-vp9", "-b:v", "2500k", "-maxrate", "3500k", "-bufsize", "7M", "-deadline", "realtime", "-cpu-used", "6", "-row-mt", "1", "-g", "30", "-pix_fmt", "yuv420p", "-c:a", "libopus", "-b:a", "128k"]).arg(&tmp)+ .status().map(|s| s.success()).unwrap_or(false);+ if ok && std::fs::rename(&tmp, &alt).is_ok() { println!("OK: smooth copy ready: {}", alt.display()); set("ready"); }+ else { let _ = std::fs::remove_file(&tmp); println!("Hint: could not make a smooth copy of the film (is ffmpeg installed?). If the page reports that the film stalls on the human's computer, re-encode the film yourself (VP9, or H.264 at 720p) and serve that."); set("failed: ffmpeg could not make it"); }+}+ pub fn serve(video: &Path, out: &Path, port: u16, captions: Option<&Path>, chapters: Option<&Path>, title: Option<&str>, cue_audio: bool) -> 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);@@ -363,7 +384,7 @@ pub fn serve(video: &Path, out: &Path, port: u16, captions: Option<&Path>, chapt let chaps = chapters.map(Path::to_path_buf).or_else(|| sibling(video, &["chapters.json"])).or_else(|| video.parent().map(|d| d.join("chapters.json")).filter(|p| p.is_file())); let name = title.map(str::to_string).unwrap_or_else(|| video.file_stem().map(|x| x.to_string_lossy().replace(['-', '_'], " ")).unwrap_or_default()); let sh = Arc::new(Mutex::new(Shared { clients: vec![], console: vec![], state: json!({"app": "adom-aiflow-video narrate", "version": env!("CARGO_PKG_VERSION"), "port": port, "title": name, "video": video.display().to_string(), "out": out.display().to_string(),- "captionsFile": caps.as_ref().map(|p| p.display().to_string()), "chaptersFile": chaps.as_ref().map(|p| p.display().to_string()), "settings": load_settings(), "cueAudio": cue_audio, "note": "", "chosen": null, "takes": takes_in(out), "page": {}}) }));+ "captionsFile": caps.as_ref().map(|p| p.display().to_string()), "chaptersFile": chaps.as_ref().map(|p| p.display().to_string()), "settings": load_settings(), "cueAudio": cue_audio, "alt": "building", "note": "", "chosen": null, "takes": takes_in(out), "page": {}}) })); if caps.is_none() { println!("Hint: no captions file beside the film (same name, .vtt or .srt), so the human has no lines to read: pass --captions <file>."); } if chaps.is_none() { println!("Hint: no chapters file (same name, .chapters.json): pass --chapters <file> so the timeline shows where each part starts."); } let _ = std::fs::create_dir_all(registry());@@ -372,6 +393,7 @@ pub fn serve(video: &Path, out: &Path, port: u16, captions: Option<&Path>, chapt 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: you can drive the open page yourself: `narrate status`, `narrate calibrate`, `narrate rehearse`, `narrate record`, `narrate stop`, `narrate review <n>`, `narrate note \"...\"` (each with --ai-thread). `narrate wait` returns when they press Use this take."); println!("Hint: leave this running while they record; stop it (Ctrl-C) when they are done.");+ { let (v, h) = (video.to_path_buf(), sh.clone()); std::thread::spawn(move || build_smooth_copy(v, h)); } let hb = sh.clone(); std::thread::spawn(move || loop { std::thread::sleep(std::time::Duration::from_secs(15));