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.5.2
8 files changed
+11−11
Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow-video"-version = "0.5.1"+version = "0.5.2" 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.5.1"+version = "0.5.2" edition = "2021" license = "MIT"
README.md+1−1@@ -102,4 +102,4 @@ Your film is yours: it carries no Adom logo. The Adom logo sting is only for off ## Status -0.5.1. It grew out of the 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, the narration page (voice, camera picture, drawing, playback check, AI notes), and the YouTube channel check, title and thumbnail feedback and upload. Still done by the AI by hand, with the skill as the guide: the cut itself, putting the voice, picture and drawings on the film, lining up a second recorder, composing thumbnails, and the frame-by-frame privacy check.+0.5.2. It grew out of the 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, the narration page (voice, camera picture, drawing, playback check, AI notes), and the YouTube channel check, title and thumbnail feedback and upload. Still done by the AI by hand, with the skill as the guide: the cut itself, putting the voice, picture and drawings on the film, lining up a second recorder, composing thumbnails, and the frame-by-frame privacy check.
assets/narrate.js+4−4@@ -549,7 +549,7 @@ function pitchBox() { const on = !!(pitch && pitch.thumbs && pitch.thumbs.length); $('pitchbox').classList.toggle('on', on); if (!on) return; const i = pitchChoice ? Math.max(0, pitch.thumbs.indexOf(pitchChoice.thumbnail)) : 0;- $('pitch-img').src = 'pitch-thumb/' + i + '?v=' + pitch.thumbs.length;+ $('pitch-img').src = 'pitch-thumb/' + i + '?v=' + (pitch.stamp || 0); $('pitch-title').textContent = pitchChoice ? pitchChoice.title : 'Title and thumbnail'; $('pitch-sub').textContent = pitchChoice ? 'Your pick for YouTube. Click to change it.' : `Your AI has ${pitch.titles.length} titles and ${pitch.thumbs.length} thumbnails for you`; }@@ -559,10 +559,10 @@ modal(`<h2>How it will look on YouTube</h2> <p>Pick the thumbnail and the title before you record. What they promise is what the first lines of your narration should deliver.</p> <div class="pitch-preview"><div class="th"><img id="pp-img" alt=""><span class="dur">${fmt(d)}</span></div><div><div class="tt" id="pp-title"></div><div class="ch">Your channel</div></div></div>- <div class="pitch-thumbs">${pitch.thumbs.map((_, i) => `<button data-h="${i}" aria-label="Thumbnail ${i + 1}"><img src="pitch-thumb/${i}?v=${pitch.thumbs.length}" alt="Thumbnail ${i + 1}"></button>`).join('')}</div>+ <div class="pitch-thumbs">${pitch.thumbs.map((_, i) => `<button data-h="${i}" aria-label="Thumbnail ${i + 1}"><img src="pitch-thumb/${i}?v=${pitch.stamp || 0}" alt="Thumbnail ${i + 1}"></button>`).join('')}</div> <div class="pitch-titles">${pitch.titles.map((t, i) => `<button data-t="${i}">${esc(t)}</button>`).join('')}</div> <div class="row"><button class="btn lg" id="pp-use">Use this pair</button><button class="btn ghost lg" id="pp-more">Ask my AI for other ideas</button><button class="btn ghost lg" id="pp-close">Close</button></div>`);- const paint = () => { $('pp-img').src = 'pitch-thumb/' + hi + '?v=' + pitch.thumbs.length; $('pp-title').textContent = pitch.titles[ti];+ const paint = () => { $('pp-img').src = 'pitch-thumb/' + hi + '?v=' + (pitch.stamp || 0); $('pp-title').textContent = pitch.titles[ti]; document.querySelectorAll('.pitch-thumbs button').forEach((b) => b.classList.toggle('is-on', +b.dataset.h === hi)); document.querySelectorAll('.pitch-titles button').forEach((b) => b.classList.toggle('is-on', +b.dataset.t === ti)); }; document.querySelectorAll('.pitch-thumbs button').forEach((b) => b.onclick = () => { hi = +b.dataset.h; paint(); }); document.querySelectorAll('.pitch-titles button').forEach((b) => b.onclick = () => { ti = +b.dataset.t; paint(); });@@ -591,7 +591,7 @@ for (const kv of String(d.spec || '').split(/\s+/)) { const [k, val] = kv.split('='); if (!val) continue; if (k === 'shape' && ASPECT[val]) PIP.shape = val; else if (k === 'corner') { PIP.x = /r/.test(val) ? 0.99 : 0.01; PIP.y = /b/.test(val) ? 0.75 : 0.01; } else if (['size', 'x', 'y', 'zoom', 'ox', 'oy'].includes(k) && isFinite(+val)) PIP[k] = +val; } PIP.size = Math.max(SMIN, Math.min(SMAX, PIP.size)); PIP.zoom = Math.max(1, Math.min(2.5, PIP.zoom)); layout(true); },- pitch: (d) => { pitch = { titles: d.titles || [], thumbs: d.thumbs || [] }; pitchChoice = null; pitchBox(); if (mode === 'idle') showPitch(); else toast('Your AI has title and thumbnail ideas for you: see the box at the top right.', 'info'); },+ pitch: (d) => { pitch = { titles: d.titles || [], thumbs: d.thumbs || [], stamp: d.stamp || Date.now() }; pitchChoice = null; pitchBox(); if (mode === 'idle') showPitch(); else toast('Your AI has title and thumbnail ideas for you: see the box at the top right.', 'info'); }, 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'); },
bin/adom-aiflow-video⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow-video", "type": "app",- "version": "0.5.1",+ "version": "0.5.2", "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.5.1",+ "version": "0.5.2", "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",
src/narrate.rs+2−2@@ -98,7 +98,7 @@ fn serve_file(s: &mut TcpStream, path: &Path, range: Option<&str>) { _ => (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");+ let _ = write!(s, "HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {n}\r\nAccept-Ranges: bytes\r\nCache-Control: no-store\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];@@ -330,7 +330,7 @@ fn handle(mut s: TcpStream, video: PathBuf, out: PathBuf, sh: Arc<Mutex<Shared>> data["by"] = who.clone().into(); let pages = sh.lock().unwrap().clients.len(); if verb == "note" { sh.lock().unwrap().state["note"] = data["text"].clone(); }- if verb == "pitch" { let mut g = sh.lock().unwrap(); g.state["pitch"] = json!({"titles": data["titles"], "thumbs": data["thumbs"]}); g.state["pitchChoice"] = Value::Null; }+ if verb == "pitch" { let mut g = sh.lock().unwrap(); let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0); data["stamp"] = stamp.into(); g.state["pitch"] = json!({"titles": data["titles"], "thumbs": data["thumbs"], "stamp": stamp}); g.state["pitchChoice"] = Value::Null; } if ["toast", "click", "reveal", "cursor", "reload"].contains(&verb.as_str()) { data["type"] = verb.clone().into(); if data["label"].is_null() { data["label"] = who.clone().into(); }