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.2.1
11 files changed
+64−10
Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow-video"-version = "0.2.0"+version = "0.2.1" 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.0"+version = "0.2.1" 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.2.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.+0.2.1. 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.css+7@@ -25,6 +25,10 @@ aside { overflow: auto; scrollbar-width: thin; } .steps-list { list-style: none; margin: 0 0 14px; padding: 0; } .steps-list li { display: flex; gap: 10px; align-items: flex-start; padding: 7px 8px; border-radius: 8px; border: 1px solid transparent; }+.steps-list li { cursor: pointer; transition: background .15s ease, border-color .15s ease; }+.steps-list li:hover { background: var(--elevated); border-color: var(--border); }+.steps-list li:active { transform: translateY(1px); }+.steps-list li:focus-visible { outline: 2px solid var(--accent-bright); outline-offset: 2px; } .steps-list li.now { border-color: var(--accent); background: var(--elevated); } .steps-list .n { flex: none; width: 22px; height: 22px; border-radius: 50%; border: 1px solid var(--border); display: inline-flex; align-items: center; justify-content: center; font: 500 12px var(--font-mono); color: var(--muted); } .steps-list li.done .n { background: var(--green); border-color: var(--green); color: #04130a; }@@ -110,3 +114,6 @@ aside { overflow: auto; scrollbar-width: thin; } .drawbar .pen { width: 12px; height: 12px; border-radius: 50%; background: var(--accent-bright); box-shadow: 0 0 0 3px rgba(0,230,220,.2); } .drawbar .btn { font-size: 12px; padding: 4px 10px; border-radius: 999px; } kbd { font: 500 11px var(--font-mono); border: 1px solid var(--border); border-radius: 4px; padding: 0 5px; margin-left: 4px; color: var(--text-2); }++.startover { width: 100%; margin: -4px 0 16px; font-size: 13px; padding: 6px 10px; }+.startover.armed { border-color: var(--red); color: var(--red); }
assets/narrate.html+4−3@@ -44,9 +44,9 @@ <aside> <h2>Steps</h2> <ol class="steps-list" id="steps">- <li id="st-mic"><span class="n">1</span><div><b>Check your microphone</b><small id="st-mic-s">Not checked yet</small></div></li>- <li id="st-rec"><span class="n">2</span><div><b>Record your narration</b><small id="st-rec-s">No takes yet</small></div></li>- <li id="st-rev"><span class="n">3</span><div><b>Review and send to your AI</b><small id="st-rev-s">Nothing to review yet</small></div></li>+ <li id="st-mic" role="button" tabindex="0" data-tooltip="Calibrate your microphone: about 15 seconds. You can do this again at any time."><span class="n">1</span><div><b>Check your microphone</b><small id="st-mic-s">Not checked yet</small></div></li>+ <li id="st-rec" role="button" tabindex="0" data-tooltip="Start a new take from the top. A countdown gives you time to get ready, and earlier takes are kept."><span class="n">2</span><div><b>Record your narration</b><small id="st-rec-s">No takes yet</small></div></li>+ <li id="st-rev" role="button" tabindex="0" data-tooltip="Play the film with your latest take on it. You can send it to your AI from there."><span class="n">3</span><div><b>Review and send to your AI</b><small id="st-rev-s">Nothing to review yet</small></div></li> </ol> <h2>Microphone</h2>@@ -63,6 +63,7 @@ <h2>Takes</h2> <ul class="takes" id="takes"><li class="empty">Your recordings will be listed here.</li></ul>+ <button class="btn ghost startover" id="startover" data-tooltip="Go back to step 1 with a clean page. Nothing is deleted: your takes are moved into a dated folder in your project, and you can undo this.">Start over</button> <h2>From your AI</h2> <div class="ainote" id="ainote">Nothing yet. Your AI can see this page and will tell you here what it is doing with your recording.</div>
assets/narrate.js+26−1@@ -351,6 +351,31 @@ steps(); } + // ---------------- the steps are buttons, and Start over ----------------+ const stepDo = { 'st-mic': () => calibrate(), 'st-rec': () => (mode === 'idle' ? record() : toast('Stop first, then start a new take.', 'info')),+ 'st-rev': () => (takes.length ? review(chosen || takes[takes.length - 1]) : toast('Nothing to review yet: record a take first.', 'info')) };+ for (const [id, fn] of Object.entries(stepDo)) { $(id).onclick = fn; $(id).onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fn(); } }; }+ async function refreshTakes() {+ const st = await api('state').catch(() => ({}));+ takes = (st.takes || []).map((t, i) => ({ ...t, n: i + 1, strokes: t.strokesData || [] }));+ chosen = st.chosen ? takes.find((t) => t.file === st.chosen) || null : null; setNote(st.note || '');+ renderTakes(); header();+ }+ let armed = false, armT;+ async function startOver() {+ if (mode !== 'idle') stop();+ const r = await api('reset', {}).catch((e) => ({ error: e.message }));+ if (r.error) return toast('Could not start over: ' + r.error, 'error');+ cal = null; strokes = []; peak = 0; closeModal(); await refreshTakes(); p.seekTo(0); steps(); say('Started over');+ toast(r.moved ? `Started over. Your ${r.moved} earlier ${r.moved === 1 ? 'take was' : 'takes were'} kept in ${r.folder}.` : 'Started over.', 'success',+ r.moved ? { label: 'Undo', run: async () => { const u = await api('reset/undo', {}).catch((e) => ({ error: e.message })); if (u.error) return toast('Could not undo: ' + u.error, 'error'); await refreshTakes(); toast('Your takes are back.', 'success'); } } : undefined);+ }+ $('startover').onclick = () => {+ const b = $('startover');+ if (!armed) { armed = true; b.classList.add('armed'); b.textContent = 'Click again to start over'; armT = setTimeout(() => { armed = false; b.classList.remove('armed'); b.textContent = 'Start over'; }, 4000); return; }+ clearTimeout(armT); armed = false; b.classList.remove('armed'); b.textContent = 'Start over'; startOver();+ };+ // ---------------- the live channel: the AI drives this page ---------------- const act = async (id, label) => { const el = await glide('#' + id, label); if (el && !el.disabled) el.click(); setTimeout(() => document.querySelector('.ai-cursor')?.classList.remove('on'), 900); }; function setNote(text) { note = text || ''; $('ainote').textContent = note || 'Nothing yet. Your AI can see this page and will tell you here what it is doing with your recording.'; $('ainote').classList.toggle('live', !!note); }@@ -364,7 +389,7 @@ seek: (d) => p.seekTo(Math.max(0, +d.seconds || 0)), play: () => v().play(), pause: () => v().pause(), review: (d) => { const tk = takes[(+d.take || takes.length) - 1]; tk ? review(tk) : toast('No such take', 'error'); }, use: (d) => { const tk = takes[(+d.take || takes.length) - 1]; tk ? choose(tk) : toast('No such take', 'error'); },- wipe: () => wipe(), note: (d) => { setNote(d.text); if (d.text) toast('Your AI: ' + d.text, 'info'); },+ reset: () => startOver(), wipe: () => wipe(), note: (d) => { setNote(d.text); if (d.text) toast('Your AI: ' + d.text, 'info'); }, }); es.onopen = () => $('led').className = 'led ok'; es.addEventListener('error', () => $('led').className = 'led bad');
bin/adom-aiflow-video⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow-video", "type": "app",- "version": "0.2.0",+ "version": "0.2.1", "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.0",+ "version": "0.2.1", "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@@ -73,6 +73,7 @@ Let the human choose, and make either easy. - **Drawing**: they can circle or point at things on the film while they talk. A drawing fades after two seconds and Space wipes it at once. - **Review**: the film with their voice on it, with the player's own pause and timeline. - **Use this take**: the hand-off to you.+ - **Start over**: the three steps on the page are buttons (calibrate, record, review), and Start over returns to step 1. It deletes nothing: their takes move into a dated `earlier-...` folder beside the others, and the page offers Undo. Your side: run `narrate wait` (it returns when they press Use this take, with the file and its facts), and keep them informed on the page with `narrate note "..."`, which shows in its From your AI box; never leave them wondering whether their recording was used. `narrate status` shows what the page is doing, `narrate console` its log; you can also press its buttons (`narrate calibrate|rehearse|record|stop|review <n>|use <n>|wipe`) and they see your cursor do it.
src/narrate.rs+21−1@@ -190,6 +190,26 @@ fn handle(mut s: TcpStream, video: PathBuf, out: PathBuf, sh: Arc<Mutex<Shared>> respond(&mut s, "200 OK", "application/json", "", b"{\"ok\":true}") } ("GET", "/console") => { let body = json!({"lines": sh.lock().unwrap().console}).to_string(); respond(&mut s, "200 OK", "application/json", "", body.as_bytes()) }+ // start over: nothing is deleted, the takes move into a dated folder (and can come back)+ ("POST", "/reset") => {+ let stamp = std::process::Command::new("date").arg("+%Y-%m-%d-%H%M%S").output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "takes".into());+ let folder = out.join(format!("earlier-{stamp}"));+ let files: Vec<PathBuf> = std::fs::read_dir(&out).map(|d| d.flatten().map(|e| e.path()).filter(|p| p.is_file() && p.file_name().map(|f| f.to_string_lossy().starts_with("take-")).unwrap_or(false)).collect()).unwrap_or_default();+ let moved = takes_in(&out).len();+ if !files.is_empty() { let _ = std::fs::create_dir_all(&folder); for f in &files { let _ = std::fs::rename(f, folder.join(f.file_name().unwrap())); } }+ { let mut g = sh.lock().unwrap(); g.state["chosen"] = Value::Null; g.state["note"] = "".into(); g.state["lastReset"] = if files.is_empty() { Value::Null } else { folder.display().to_string().into() }; }+ println!("OK: the human started over; {moved} earlier take(s) kept in {}", folder.display());+ respond(&mut s, "200 OK", "application/json", "", json!({"ok": true, "moved": moved, "folder": folder.file_name().map(|f| f.to_string_lossy().to_string())}).to_string().as_bytes())+ }+ ("POST", "/reset/undo") => {+ let folder = sh.lock().unwrap().state["lastReset"].as_str().map(PathBuf::from);+ let Some(folder) = folder else { return respond(&mut s, "404 Not Found", "application/json", "", b"{\"error\":\"nothing to undo\"}") };+ if !takes_in(&out).is_empty() { return respond(&mut s, "409 Conflict", "application/json", "", b"{\"error\":\"you have recorded new takes since; the earlier ones are still in their folder\"}"); }+ for e in std::fs::read_dir(&folder).map(|d| d.flatten().collect::<Vec<_>>()).unwrap_or_default() { let _ = std::fs::rename(e.path(), out.join(e.file_name())); }+ let _ = std::fs::remove_dir(&folder);+ sh.lock().unwrap().state["lastReset"] = Value::Null;+ respond(&mut s, "200 OK", "application/json", "", b"{\"ok\":true}")+ } // the human picked a take: this is the hand-off to the AI ("POST", "/choose") => { let mut body = vec![0u8; len];@@ -250,7 +270,7 @@ 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", "rehearse", "stop", "test", "calibrate", "gain", "mic", "seek", "review", "use", "wipe", "note", "toast", "click", "reveal", "cursor", "reload", "play", "pause"].contains(&verb.as_str()) {+ if !["record", "rehearse", "stop", "test", "calibrate", "gain", "mic", "seek", "review", "use", "wipe", "reset", "note", "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, rehearse, stop, test, calibrate, gain, mic, seek, play, pause, review, use, wipe, note, toast, click, reveal, cursor, reload"}).to_string().as_bytes()); } data["by"] = who.clone().into();