← Commit history
Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow-video"-version = "0.6.0"+version = "0.6.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.6.0"+version = "0.6.1" edition = "2021" license = "MIT" 
README.md+1−1
@@ -104,4 +104,4 @@ Your film is yours: it carries no Adom logo. The Adom logo sting is only for off  ## Status -0.6.0. 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.6.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.
assets/narrate.css+2
@@ -178,3 +178,5 @@ kbd { font: 500 11px var(--font-mono); border: 1px solid var(--border); border-r .pose .count { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font: 700 clamp(80px, 14vw, 220px) var(--font-head); color: #fff; text-shadow: 0 4px 24px rgba(0,0,0,.6); pointer-events: none; } .pose .flash { position: absolute; inset: 0; background: #fff; opacity: 0; pointer-events: none; transition: opacity .25s; } .pose-shots { display: flex; gap: 8px; margin: 0 0 12px; } .pose-shots img { width: 32%; border-radius: 8px; border: 1px solid var(--border); }++.esc-hint { align-self: center; color: var(--muted); font-size: 13px; }
assets/narrate.js+19−5
@@ -150,12 +150,15 @@   const pills = (names, k) => `<div class="pills">${names.map((s, i) => `<span class="${i < k ? 'done' : i === k ? 'now' : ''}">${s}</span>`).join('')}</div>`;   const CAL = ['1 Quiet', '2 Read', '3 Result'];   function sample() { 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(' ') || 'This is how I will sound when I narrate my film.'; }+  let calCancel = false, calRunning = false;   async function phase(k, title, sub, big, secs) {-    modal(pills(CAL, 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></em></div>`);+    modal(pills(CAL, 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></em></div><div class="row"><button class="btn ghost lg" id="c-cancel">Cancel</button><span class="esc-hint">or press Esc</span></div>`);+    $('c-cancel').onclick = () => { calCancel = true; };     calPhase = { step: k + 1, title, secondsLeft: secs }; report();     const xs = [], t0 = performance.now(), ms = secs * 1000;     sampler = (x) => { if (performance.now() - t0 > 350) xs.push(x); const m = $('cmeter'); if (m) m.style.width = Math.max(0, Math.min(100, (dB(x * boost) + 54) / 54 * 100)) + '%'; };     while (performance.now() - t0 < ms) {+      if (calCancel) { sampler = null; return null; }       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(); }@@ -167,11 +170,15 @@   async function calibrate() {     if (mode !== 'idle') return toast('Stop the film first, then calibrate.', 'info');     try { await openMic(); } catch (_) { return; }-    $('cal').disabled = true;+    $('cal').disabled = true; calCancel = false; calRunning = true;+    const stopCal = () => { calRunning = false; $('cal').disabled = false; closeModal(); say('Calibration cancelled'); toast('Calibration cancelled. Nothing was changed.', 'info'); };     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);+    if (await phase(0, 'Get ready', `Calibration takes about ${3 + 3 + readSecs} seconds: a moment of quiet, then one line to read out loud.`, '', 3) === null) return stopCal();     const room = await phase(0, 'Stay quiet', 'I am listening to the room. Do not talk or type.', '', 3);+    if (room === null) return stopCal();     const talk = await phase(1, 'Read this out loud', 'Use the voice you will narrate with. Start now.', '&ldquo;' + esc(text) + '&rdquo;', readSecs);+    if (talk === null) return stopCal();+    calRunning = false;     const noise = pct(room, 0.6), voice = pct(talk, 0.95), heard = voice >= Math.max(noise * 2.5, 0.004);     cal = { noiseDb: +dB(noise).toFixed(1), voiceDb: +dB(voice).toFixed(1), gapDb: +(dB(voice) - dB(noise)).toFixed(1), boostBefore: +before.toFixed(2) };     let rows, verdict;@@ -525,7 +532,6 @@   async function refreshTakes() {     const st = await api('state').catch(() => ({}));     takes = (st.takes || []).map((t, i) => ({ ...t, n: i + 1, strokes: t.strokesData || [] }));-    if (st.pitch && st.pitch.thumbs) { pitch = st.pitch; pitchChoice = st.pitchChoice || null; pitchBox(); }     chosen = st.chosen ? takes.find((t) => t.file === st.chosen) || null : null; setNote(st.note || '');     renderTakes(); header();   }@@ -609,6 +615,13 @@     $('pose-done').onclick = () => { if (own) st.getTracks().forEach((x) => x.stop()); closeModal(); };   } +  addEventListener('keydown', (e) => {+    if (e.key !== 'Escape' || !$('modal').classList.contains('on')) return;+    if (calRunning) { calCancel = true; return; }+    if (/Saving your/.test($('card').textContent)) return;+    const c = $('pp-close') || $('pose-done') || $('c-close') || $('f-close') || $('s-ok'); c ? c.click() : closeModal();+  });+   // ---------------- 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); }@@ -648,7 +661,8 @@   // ---------------- start ----------------   (async () => {     const st = await api('state').catch(() => ({}));-    info = st; S = { ...S, ...(st.settings || {}) }; $('fade-s').textContent = S.drawFade; setNote(st.note || '');+    info = st; S = { ...S, ...(st.settings || {}) };+    if (st.pitch && st.pitch.thumbs) { pitch = st.pitch; pitchChoice = st.pitchChoice || null; pitchBox(); } $('fade-s').textContent = S.drawFade; setNote(st.note || '');     lines = (await fetch('captions').then((r) => r.json()).catch(() => ({ lines: [] }))).lines || [];     await p.adomDrive.ready(); await wait(300);     takes = (st.takes || []).map((t, i) => ({ ...t, n: i + 1, strokes: t.strokesData || [] }));
bin/adom-aiflow-video
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow-video",   "type": "app",-  "version": "0.6.0",+  "version": "0.6.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.6.0",+  "version": "0.6.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",
src/narrate.rs+5−1
@@ -354,7 +354,7 @@ fn handle(mut s: TcpStream, video: PathBuf, out: PathBuf, sh: Arc<Mutex<Shared>>             data["leader"] = sh.lock().unwrap().state["leader"].clone();             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(); 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 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}); let _ = std::fs::create_dir_all(&out); let _ = std::fs::write(out.join("pitch-ideas.json"), serde_json::to_string_pretty(&g.state["pitch"]).unwrap_or_default() + "\n"); 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(); }@@ -443,6 +443,10 @@ pub fn serve(video: &Path, out: &Path, port: u16, captions: Option<&Path>, chapt     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, "alt": "building", "note": "", "chosen": null, "takes": takes_in(out), "page": {}}) }));+    // the title and thumbnail ideas, and the human's pick, survive a restart of this server+    { let mut g = sh.lock().unwrap();+      if let Some(v) = std::fs::read_to_string(out.join("pitch-ideas.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) { g.state["pitch"] = v; }+      if let Some(v) = std::fs::read_to_string(out.join("pitch.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) { g.state["pitchChoice"] = v; } }     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());