app
AI Flow: Video
Public Made by Adomby adom
Turn a board build into a narrated walkthrough video on your wiki page and YouTube.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
//! `narrate`: a small local page that plays the teleprompter copy and records the human's microphone
//! in step with it, so the timing of every word against the video is known. Takes land in
//! `<out>/take-N.<ext>` with a `take-N.json` beside each (which mic, when the video started inside
//! the recording, the peak level). Standard library only: one thread per connection.
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use serde_json::{json, Value};
/// What every connection shares: the open pages' event streams and the state they sync to.
pub struct Shared { clients: Vec<TcpStream>, state: Value, console: Vec<String> }
/// Push one event to every open page; a page that has gone away is dropped.
fn broadcast(sh: &Arc<Mutex<Shared>>, event: &str, data: &Value) {
let msg = format!("event: {event}\ndata: {data}\n\n");
let mut g = sh.lock().unwrap();
g.clients.retain_mut(|c| c.write_all(msg.as_bytes()).and_then(|_| c.flush()).is_ok());
}
fn takes_in(out: &Path) -> Vec<Value> {
let mut v: Vec<Value> = std::fs::read_dir(out).map(|d| d.flatten().filter(|e| { let f = e.file_name().to_string_lossy().to_string(); f.strip_prefix("take-").and_then(|r| r.strip_suffix(".json")).map(|d| !d.is_empty() && d.chars().all(|c| c.is_ascii_digit())).unwrap_or(false) })
.filter_map(|e| std::fs::read_to_string(e.path()).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).map(|mut v| {
let d = e.path().with_extension("").with_extension("draw.json");
if let Some(s) = std::fs::read_to_string(&d).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) { v["strokesData"] = s; }
v
})).collect()).unwrap_or_default();
v.sort_by_key(|x: &Value| x["file"].as_str().unwrap_or("").trim_start_matches("take-").split('.').next().and_then(|d| d.parse::<u32>().ok()).unwrap_or(0));
v
}
/// The text lines of a .srt or .vtt file, in order.
fn caption_lines(video: &Path) -> Vec<String> {
let text = std::fs::read_to_string(video).unwrap_or_default();
let mut out: Vec<String> = vec![];
for l in text.lines().map(str::trim) {
if l.is_empty() || l == "WEBVTT" || l.contains("-->") || l.chars().all(|c| c.is_ascii_digit()) { continue; }
if out.last().map(|p| p == l).unwrap_or(false) { continue; }
out.push(l.to_string());
}
out
}
const PAGE: &str = include_str!("../assets/narrate.html");
/// The page's own files and the vendored house UI layer (assets/vendor/VENDORED.md), all compiled in.
fn asset(name: &str) -> Option<(&'static str, &'static [u8])> {
Some(match name {
"narrate.js" => ("text/javascript; charset=utf-8", include_bytes!("../assets/narrate.js")),
"narrate.css" => ("text/css; charset=utf-8", include_bytes!("../assets/narrate.css")),
"adom-video.js" => ("text/javascript; charset=utf-8", include_bytes!("../assets/vendor/adom-video.js")),
"ui.js" => ("text/javascript; charset=utf-8", include_bytes!("../assets/vendor/ui.js")),
"icons.js" => ("text/javascript; charset=utf-8", include_bytes!("../assets/vendor/icons.js")),
"base.css" => ("text/css; charset=utf-8", include_bytes!("../assets/vendor/base.css")),
"settings-cog-outline.svg" => ("image/svg+xml", include_bytes!("../assets/vendor/settings-cog-outline.svg")),
"fonts/familjen-grotesk-700.woff2" => ("font/woff2", include_bytes!("../assets/vendor/fonts/familjen-grotesk-700.woff2")),
"fonts/satoshi-400.woff2" => ("font/woff2", include_bytes!("../assets/vendor/fonts/satoshi-400.woff2")),
"fonts/satoshi-500.woff2" => ("font/woff2", include_bytes!("../assets/vendor/fonts/satoshi-500.woff2")),
"fonts/jetbrains-mono-400.woff2" => ("font/woff2", include_bytes!("../assets/vendor/fonts/jetbrains-mono-400.woff2")),
"fonts/jetbrains-mono-500.woff2" => ("font/woff2", include_bytes!("../assets/vendor/fonts/jetbrains-mono-500.woff2")),
_ => return None,
})
}
/// The settings a person can change in the page's Settings window; stored for every film.
fn schema() -> Value {
json!({
"preRoll": {"type": "number", "min": 0, "max": 10, "default": 5, "label": "Countdown before recording", "help": "Seconds to get ready after you press Record, before the film starts and your voice is recorded."},
"wrapUp": {"type": "number", "min": 0, "max": 30, "default": 10, "label": "Wrap-up warning", "help": "How many seconds before the end of the film the page tells you to wrap up. 0 turns the warning off."},
"drawFade": {"type": "number", "min": 1, "max": 10, "default": 2, "label": "Drawings fade after", "help": "Seconds a circle or line you draw on the film stays before it fades. Space wipes it at once."}
})
}
fn settings_file() -> PathBuf { PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".adom/adom-aiflow-video/settings.json") }
fn load_settings() -> Value {
let mut s: Value = std::fs::read_to_string(settings_file()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(json!({}));
for (k, d) in schema().as_object().unwrap() { if !s[k].is_number() { s[k] = d["default"].clone(); } }
s
}
fn sibling(video: &Path, exts: &[&str]) -> Option<PathBuf> { exts.iter().map(|e| video.with_extension(e)).find(|p| p.is_file()) }
fn respond(s: &mut TcpStream, status: &str, ctype: &str, extra: &str, body: &[u8]) {
let _ = write!(s, "HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n{extra}\r\n", body.len());
let _ = s.write_all(body);
}
/// A file with HTTP Range support, so the browser can seek in the video.
fn serve_file(s: &mut TcpStream, path: &Path, range: Option<&str>) {
let Ok(mut f) = std::fs::File::open(path) else { return respond(s, "404 Not Found", "text/plain", "", b"not found") };
let len = f.metadata().map(|m| m.len()).unwrap_or(0);
let ctype = match path.extension().and_then(|e| e.to_str()).unwrap_or("") { "mp4" | "m4v" => "video/mp4", "webm" => "video/webm", "m4a" => "audio/mp4", "wav" => "audio/wav", _ => "application/octet-stream" };
let (start, end, status) = match range.and_then(|r| r.trim().strip_prefix("bytes=")).and_then(|r| r.split_once('-')) {
Some((a, b)) if len > 0 => {
let a: u64 = a.parse().unwrap_or(0);
let b: u64 = b.parse().unwrap_or(len - 1).min(len - 1);
if a > b { return respond(s, "416 Range Not Satisfiable", "text/plain", "", b""); }
(a, b, "206 Partial Content")
}
_ => (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\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];
while left > 0 {
let want = buf.len().min(left as usize);
match f.read(&mut buf[..want]) { Ok(0) | Err(_) => break, Ok(k) => { if s.write_all(&buf[..k]).is_err() { break; } left -= k as u64; } }
}
}
fn pct_decode(t: &str) -> String {
let b = t.as_bytes();
let (mut out, mut i) = (Vec::with_capacity(b.len()), 0);
while i < b.len() {
if b[i] == b'%' && i + 2 < b.len() {
if let Ok(v) = u8::from_str_radix(&t[i + 1..i + 3], 16) { out.push(v); i += 3; continue; }
}
out.push(b[i]); i += 1;
}
String::from_utf8_lossy(&out).to_string()
}
fn seconds_of(path: &Path) -> f64 {
std::process::Command::new("ffprobe").args(["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0"]).arg(path).output().ok()
.and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok()).unwrap_or(0.0)
}
fn handle(mut s: TcpStream, video: PathBuf, out: PathBuf, sh: Arc<Mutex<Shared>>) {
let mut rd = BufReader::new(s.try_clone().expect("stream"));
let mut line = String::new();
if rd.read_line(&mut line).unwrap_or(0) == 0 { return; }
let mut it = line.split_whitespace();
let (method, target) = (it.next().unwrap_or("").to_string(), it.next().unwrap_or("/").to_string());
let (mut len, mut range, mut meta, mut who) = (0usize, None, String::new(), String::new());
loop {
let mut h = String::new();
if rd.read_line(&mut h).unwrap_or(0) == 0 || h == "\r\n" || h == "\n" { break; }
if let Some((k, v)) = h.split_once(':') {
match k.to_ascii_lowercase().as_str() { "content-length" => len = v.trim().parse().unwrap_or(0), "range" => range = Some(v.trim().to_string()), "x-take-meta" => meta = pct_decode(v.trim()), "x-ai-thread" => who = pct_decode(v.trim()), _ => {} }
}
}
let path = target.split('?').next().unwrap_or("/");
match (method.as_str(), path) {
("GET", "/") => respond(&mut s, "200 OK", "text/html; charset=utf-8", "", PAGE.as_bytes()),
("GET", "/favicon.svg") => respond(&mut s, "200 OK", "image/svg+xml", "", include_bytes!("../assets/favicon.svg")),
("GET", p) if p.starts_with("/static/") => match asset(p.trim_start_matches("/static/")) {
Some((ctype, body)) => respond(&mut s, "200 OK", ctype, "", body),
None => respond(&mut s, "404 Not Found", "text/plain", "", b"not found"),
},
// the film's chapters and captions, for the player
("GET", "/chapters.json") => {
let f = sh.lock().unwrap().state["chaptersFile"].as_str().map(PathBuf::from);
let body = f.and_then(|f| std::fs::read(f).ok()).unwrap_or_else(|| b"[]".to_vec());
respond(&mut s, "200 OK", "application/json", "", &body)
}
("GET", "/captions.vtt") => {
let f = sh.lock().unwrap().state["captionsFile"].as_str().map(PathBuf::from);
let body = f.and_then(|f| std::fs::read(f).ok()).unwrap_or_else(|| b"WEBVTT\n\n".to_vec());
respond(&mut s, "200 OK", "text/vtt; charset=utf-8", "", &body)
}
("GET", "/settings") => {
let st = sh.lock().unwrap().state["settings"].clone();
respond(&mut s, "200 OK", "application/json", "", json!({"ok": true, "settings": st, "schema": schema()}).to_string().as_bytes())
}
("POST", "/settings") => {
let mut body = vec![0u8; len];
let _ = rd.read_exact(&mut body);
let req: Value = serde_json::from_slice(&body).unwrap_or(json!({}));
let sc = schema();
let mut st = if req["restoreDefaults"].as_bool().unwrap_or(false) { json!({}) } else { sh.lock().unwrap().state["settings"].clone() };
for (k, d) in sc.as_object().unwrap() {
if let Some(x) = req[k].as_f64() { st[k] = json!(x.clamp(d["min"].as_f64().unwrap_or(0.0), d["max"].as_f64().unwrap_or(1e9))); }
if !st[k].is_number() { st[k] = d["default"].clone(); }
}
let _ = std::fs::create_dir_all(settings_file().parent().unwrap());
let _ = std::fs::write(settings_file(), serde_json::to_string_pretty(&st).unwrap_or_default());
sh.lock().unwrap().state["settings"] = st.clone();
broadcast(&sh, "state", &json!({"settings": st}));
respond(&mut s, "200 OK", "application/json", "", json!({"ok": true, "settings": st}).to_string().as_bytes())
}
// the page's console, so the AI can read it without DevTools
("POST", "/console") => {
let mut body = vec![0u8; len];
let _ = rd.read_exact(&mut body);
if let Ok(v) = serde_json::from_slice::<Value>(&body) {
let mut g = sh.lock().unwrap();
for l in v["lines"].as_array().cloned().unwrap_or_default() { g.console.push(format!("{} {}", l["level"].as_str().unwrap_or("log"), l["msg"].as_str().unwrap_or(""))); }
let extra = g.console.len().saturating_sub(300); g.console.drain(..extra);
}
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];
let _ = rd.read_exact(&mut body);
let req: Value = serde_json::from_slice(&body).unwrap_or(json!({}));
let file = req["file"].as_str().unwrap_or("").to_string();
if file.is_empty() || file.contains('/') || !out.join(&file).is_file() { return respond(&mut s, "404 Not Found", "application/json", "", b"{\"error\":\"no such take\"}"); }
sh.lock().unwrap().state["chosen"] = file.clone().into();
// never leave the human looking at a stale note: the page says at once that the AI has the take
let ext = std::fs::read_to_string(out.join(Path::new(&file).with_extension("json"))).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).map(|m| m["audioPlan"] == "external").unwrap_or(false);
let auto = if ext { "Your AI has your take. Next: it fetches the sound from your recorder (plug it in if you have not), lines it up, puts your voice on the film and writes new captions. It will update this box at each step." } else { "Your AI has your take. Next: it puts your voice on the film and writes new captions from what you said. It will update this box at each step." };
sh.lock().unwrap().state["note"] = auto.into();
broadcast(&sh, "note", &json!({"text": auto}));
println!("Hint: the page now tells the human you have the take. Keep that box current: run `narrate note \"Step 2 of 4: ...\"` as you start each stage (fetching their recorder's sound, lining it up, muxing voice, picture and drawings, writing captions) and when the film is ready to watch. They are looking at that page, not at your chat.");
println!("OK: the human chose {}", out.join(&file).display());
println!("Hint: put their voice on the film (the take's json says how far into the recording the film started), write new captions from what they said, render any drawings (take-N.draw.json), and tell them on the page what you are doing: `narrate note \"...\"`. Show them the result before anything is published.");
respond(&mut s, "200 OK", "application/json", "", json!({"ok": true, "file": file}).to_string().as_bytes())
}
// the drawings made during a take: [{t0, end, wiped, pts: [[x, y, dt], ...]}], x and y as 0..1 of the picture
("POST", p) if p == "/draw" => {
let mut body = vec![0u8; len];
let _ = rd.read_exact(&mut body);
let file = target.split_once("file=").map(|x| pct_decode(x.1)).unwrap_or_default();
if file.is_empty() || file.contains('/') { return respond(&mut s, "400 Bad Request", "application/json", "", b"{\"error\":\"which take\"}"); }
let stem = Path::new(&file).with_extension("");
let _ = std::fs::write(out.join(stem).with_extension("draw.json"), &body);
respond(&mut s, "200 OK", "application/json", "", b"{\"ok\":true}")
}
// the narrator's camera for a take: saved beside it as take-N.cam.webm and named in take-N.json
("POST", p) if p == "/cam" => {
let mut body = vec![0u8; len];
if rd.read_exact(&mut body).is_err() { return respond(&mut s, "400 Bad Request", "application/json", "", b"{\"error\":\"short body\"}"); }
let file = target.split_once("file=").map(|x| pct_decode(x.1)).unwrap_or_default();
if file.is_empty() || file.contains('/') { return respond(&mut s, "400 Bad Request", "application/json", "", b"{\"error\":\"which take\"}"); }
let stem = Path::new(&file).with_extension("").to_string_lossy().to_string();
let cam = format!("{stem}.cam.{}", if body.len() > 8 && &body[4..8] == b"ftyp" { "mp4" } else { "webm" });
if std::fs::write(out.join(&cam), &body).is_err() { return respond(&mut s, "500 Internal Server Error", "application/json", "", b"{\"error\":\"could not write the camera take\"}"); }
let mj = out.join(format!("{stem}.json"));
if let Some(mut m) = std::fs::read_to_string(&mj).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) {
m["cam"] = cam.clone().into(); m["camBytes"] = body.len().into();
let _ = std::fs::write(&mj, serde_json::to_string_pretty(&m).unwrap_or_default() + "\n");
}
println!("OK: camera for {stem} saved: {} ({:.0} MB)", out.join(&cam).display(), body.len() as f64 / 1e6);
println!("Hint: put it in a corner of the film as a small picture, in step with the voice (camStartsAtMs in the take's json is its offset from the voice recording).");
broadcast(&sh, "state", &json!({"takes": takes_in(&out)}));
respond(&mut s, "200 OK", "application/json", "", json!({"cam": cam}).to_string().as_bytes())
}
// the title and thumbnail ideas the AI brought: pictures by index, and the human's pick
("GET", p) if p.starts_with("/pitch-thumb/") => {
let i: usize = p.trim_start_matches("/pitch-thumb/").parse().unwrap_or(usize::MAX);
let f = sh.lock().unwrap().state["pitch"]["thumbs"][i].as_str().map(PathBuf::from);
match f { Some(f) if f.is_file() => serve_file(&mut s, &f, None), _ => respond(&mut s, "404 Not Found", "text/plain", "", b"") }
}
("POST", "/pitch/choose") => {
let mut body = vec![0u8; len];
let _ = rd.read_exact(&mut body);
let req: Value = serde_json::from_slice(&body).unwrap_or(json!({}));
let thumb = sh.lock().unwrap().state["pitch"]["thumbs"][req["thumb"].as_u64().unwrap_or(0) as usize].clone();
if req["more"] == true {
let why = req["why"].as_str().unwrap_or("");
println!("OK: the human wants other title and thumbnail ideas{}", if why.is_empty() { String::new() } else { format!(": {why}") });
println!("Hint: bring a new set with `narrate pitch --titles ... --thumbs ...` and tell them on the page (`narrate note`) what you changed.");
let n = "Your AI is making new title and thumbnail ideas."; sh.lock().unwrap().state["note"] = n.into(); broadcast(&sh, "note", &json!({"text": n}));
return respond(&mut s, "200 OK", "application/json", "", b"{\"ok\":true}");
}
let pick = json!({"title": req["title"], "thumbnail": thumb, "t": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)});
let _ = std::fs::create_dir_all(&out);
let _ = std::fs::write(out.join("pitch.json"), serde_json::to_string_pretty(&pick).unwrap_or_default() + "\n");
sh.lock().unwrap().state["pitchChoice"] = pick.clone();
println!("OK: the human picked the title \"{}\" and the thumbnail {} ({})", pick["title"].as_str().unwrap_or(""), pick["thumbnail"].as_str().unwrap_or(""), out.join("pitch.json").display());
println!("Hint: the thumbnail's words are a promise. Make the first lines of the film pay them off: adjust the opening of the script (and the line they read first) before they record, and use exactly this title and thumbnail at upload.");
broadcast(&sh, "state", &json!({"pitchChoice": pick}));
respond(&mut s, "200 OK", "application/json", "", b"{\"ok\":true}")
}
// a still of the narrator from their camera, posed for the thumbnail: saved as pose-N.jpg in the takes folder
("GET", "/pose-product") => { let f = sh.lock().unwrap().state["poseProduct"].as_str().map(PathBuf::from); match f { Some(f) if f.is_file() => serve_file(&mut s, &f, None), _ => respond(&mut s, "404 Not Found", "text/plain", "", b"") } }
("POST", "/pose") => {
let mut body = vec![0u8; len];
if rd.read_exact(&mut body).is_err() { return respond(&mut s, "400 Bad Request", "application/json", "", b"{\"error\":\"short body\"}"); }
let _ = std::fs::create_dir_all(&out);
let n = (1..).find(|i| !out.join(format!("pose-{i}.jpg")).exists()).unwrap_or(1);
let f = out.join(format!("pose-{n}.jpg"));
if std::fs::write(&f, &body).is_err() { return respond(&mut s, "500 Internal Server Error", "application/json", "", b"{\"error\":\"could not write\"}"); }
println!("OK: pose shot saved: {} ({:.1} MB)", f.display(), body.len() as f64 / 1e6);
println!("Hint: it is saved the way they saw it (mirrored), so what they pointed at is where they point. Cut them out of the background, put them in the thumbnail pointing at the product, and show the result in the picker (`narrate pitch ...`).");
respond(&mut s, "200 OK", "application/json", "", json!({"file": format!("pose-{n}.jpg")}).to_string().as_bytes())
}
// a page says the human is using it: it takes control of the microphone, camera and recording directives
("POST", "/lead") => {
let mut body = vec![0u8; len];
let _ = rd.read_exact(&mut body);
let id = serde_json::from_slice::<Value>(&body).ok().and_then(|v| v["id"].as_u64()).unwrap_or(0);
if id > 0 { sh.lock().unwrap().state["leader"] = id.into(); broadcast(&sh, "state", &json!({"leader": id})); }
respond(&mut s, "200 OK", "application/json", "", b"{\"ok\":true}")
}
("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);
let lines = f.map(|f| caption_lines(&f)).unwrap_or_default();
respond(&mut s, "200 OK", "application/json", "", json!({"lines": lines}).to_string().as_bytes())
}
// the live channel: the page opens this once and every directive arrives on it
("GET", "/events") => {
let _ = write!(s, "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: keep-alive\r\nX-Accel-Buffering: no\r\n\r\n:{}\n\n", " ".repeat(2048));
let mut g = sh.lock().unwrap();
// every open copy of the page gets a number; the newest one, or the one the human last touched, is in control
let id = g.state["pageSeq"].as_u64().unwrap_or(0) + 1;
g.state["pageSeq"] = id.into(); g.state["leader"] = id.into();
let _ = write!(s, "event: hello\ndata: {{\"id\":{id}}}\n\nevent: state\ndata: {}\n\n", g.state);
let _ = s.flush();
g.clients.push(s);
}
("GET", "/state") => {
let mut g = sh.lock().unwrap();
g.state["takes"] = takes_in(&out).into();
g.state["pagesOpen"] = g.clients.len().into();
let body = g.state.to_string();
drop(g);
respond(&mut s, "200 OK", "application/json", "", body.as_bytes())
}
// the page reports what it is doing (mics, level, recording, where the video is)
("POST", "/state") => {
let mut body = vec![0u8; len];
let _ = rd.read_exact(&mut body);
if let Ok(v) = serde_json::from_slice::<Value>(&body) { sh.lock().unwrap().state["page"] = v; }
respond(&mut s, "200 OK", "application/json", "", b"{\"ok\":true}")
}
// drive the open page: record, stop, test, mic, seek, toast, click, reveal, cursor, reload
("POST", p) if p.starts_with("/ui/") => {
let verb = p.trim_start_matches("/ui/").to_string();
let mut body = vec![0u8; len];
let _ = rd.read_exact(&mut body);
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", "cam", "pip", "seek", "review", "use", "wipe", "reset", "note", "pitch", "pose", "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();
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 == "pose" { let mut g = sh.lock().unwrap(); if data["product"].is_string() && !data["product"].as_str().unwrap_or("").is_empty() { g.state["poseProduct"] = data["product"].clone(); } data["hasProduct"] = g.state["poseProduct"].is_string().into(); data["stamp"] = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0).into(); }
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(); }
broadcast(&sh, "ui", &data);
} else { broadcast(&sh, &verb, &data); }
sh.lock().unwrap().state["lastDirective"] = json!({"verb": verb, "by": who, "data": data});
respond(&mut s, "200 OK", "application/json", "", json!({"ok": true, "verb": verb, "pagesOpen": pages}).to_string().as_bytes())
}
("GET", p) if p.starts_with("/takes/") => {
let name = p.trim_start_matches("/takes/");
if name.contains('/') || name.contains("..") { return respond(&mut s, "400 Bad Request", "text/plain", "", b""); }
serve_file(&mut s, &out.join(name), range.as_deref())
}
("POST", "/take") => {
let mut body = vec![0u8; len];
if rd.read_exact(&mut body).is_err() { return respond(&mut s, "400 Bad Request", "application/json", "", b"{\"error\":\"short body\"}"); }
let _ = std::fs::create_dir_all(&out);
let n = takes_in(&out).len() + 1;
let ext = if meta.contains("audio/mp4") { "m4a" } else { "webm" };
let file = format!("take-{n}.{ext}");
if std::fs::write(out.join(&file), &body).is_err() { return respond(&mut s, "500 Internal Server Error", "application/json", "", b"{\"error\":\"could not write the take\"}"); }
let mut m: serde_json::Value = serde_json::from_str(&meta).unwrap_or(serde_json::json!({}));
let mut secs = seconds_of(&out.join(&file));
if secs < 0.5 { secs = m["recordedMs"].as_f64().unwrap_or(0.0) / 1000.0; }
m["file"] = file.clone().into(); m["bytes"] = body.len().into(); m["seconds"] = secs.into(); m["video"] = video.display().to_string().into();
let _ = std::fs::write(out.join(format!("take-{n}.json")), serde_json::to_string_pretty(&m).unwrap_or_default() + "\n");
println!("OK: take {n} saved: {} ({:.0} s, peak {})", out.join(&file).display(), secs, m["peak"]);
if m["audioPlan"] == "external" { println!("Hint: the human is also recording on a separate recorder, so this take is the guide track. When they plug it in, fetch the newest clip from it, line it up with this take (cross-correlate the two; trim the offset), check the two stay in step to the end, and use the recorder's sound in the film."); }
if m["stalls"].as_array().map(|a| !a.is_empty()).unwrap_or(false) { println!("Hint: the film paused {} time(s) while they recorded, so the voice is not a constant offset from the film: use the take's `sync` list ([ms into the recording, film seconds]) to line it up piece by piece.", m["stalls"].as_array().map(|a| a.len()).unwrap_or(0)); }
println!("Hint: listen to it before you cut (clipping, room noise, missed scenes); the video starts {} ms into the recording.", m["videoStartsAtMs"]);
broadcast(&sh, "state", &json!({"takes": takes_in(&out)}));
respond(&mut s, "200 OK", "application/json", "", serde_json::json!({"file": file, "seconds": secs}).to_string().as_bytes())
}
_ => respond(&mut s, "404 Not Found", "text/plain", "", b"not found"),
}
}
fn registry() -> PathBuf { PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".adom/instances/adom-aiflow-video") }
/// The port of a running narration page: the newest registered one that still answers.
pub fn find_port() -> Option<u16> {
let mut ports: Vec<(std::time::SystemTime, u16)> = std::fs::read_dir(registry()).ok()?.flatten().filter_map(|e| {
let p: u16 = e.path().file_stem()?.to_str()?.strip_prefix("narrate-")?.parse().ok()?;
Some((e.metadata().ok()?.modified().ok()?, p))
}).collect();
ports.sort(); ports.reverse();
ports.into_iter().map(|x| x.1).find(|p| TcpStream::connect(("127.0.0.1", *p)).is_ok())
}
/// One request to a running narration page; returns (status code, body).
pub fn call(port: u16, method: &str, path: &str, ai_thread: Option<&str>, body: &str) -> Result<(u16, String), String> {
let mut s = TcpStream::connect(("127.0.0.1", port)).map_err(|e| format!("no narration page answers on port {port}: {e}"))?;
let who = ai_thread.map(|t| format!("X-AI-Thread: {t}\r\n")).unwrap_or_default();
write!(s, "{method} {path} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\n{who}Connection: close\r\n\r\n{body}", body.len()).map_err(|e| e.to_string())?;
let mut t = String::new();
s.read_to_string(&mut t).map_err(|e| e.to_string())?;
let code = t.split_whitespace().nth(1).and_then(|c| c.parse().ok()).unwrap_or(0);
Ok((code, t.split_once("\r\n\r\n").map(|x| x.1.to_string()).unwrap_or_default()))
}
/// 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);
let caps = captions.map(Path::to_path_buf).or_else(|| sibling(video, &["vtt", "srt"]));
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, "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());
let _ = std::fs::write(registry().join(format!("narrate-{port}.json")), json!({"port": port, "pid": std::process::id(), "video": video.display().to_string(), "out": out.display().to_string()}).to_string());
println!("OK: narration page at http://localhost:{port}/ (teleprompter: {})", video.display());
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));
let mut g = hb.lock().unwrap();
g.clients.retain_mut(|c| c.write_all(b": hb\n\n").and_then(|_| c.flush()).is_ok());
});
for s in l.incoming().flatten() {
let (v, o, h) = (video.to_path_buf(), out.to_path_buf(), sh.clone());
std::thread::spawn(move || handle(s, v, o, h));
}
Ok(())
}