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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
//! adom-aiflow-video: a dynamic skill. The AI makes the video (about 97 % of the work); this binary is
//! the guide and the checker (about 3 %): it suggests the next step, records clips, and its checks
//! report findings with suggestions. It is never a gate: every command completes, and the only
//! error is "I could not run".
mod bridge;
mod flow;
mod ledger;
mod out;
mod checks;
mod narrate;
mod brand;
mod youtube;
use clap::{Parser, Subcommand};
use out::{err, ok};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
/// The folder this flow keeps inside the project (state.json, run.jsonl, clips/, evidence).
const FLOW_DIR: &str = "video-flow";
#[derive(Parser)]
#[command(name = "adom-aiflow-video", version, about = "AI Flow video: guides your AI from a board build to a narrated walkthrough video on the wiki and YouTube. Suggestions only, never a gate.")]
struct Cli {
/// The name of the AI conversation calling (required for commands that change state)
#[arg(long, global = true)]
ai_thread: Option<String>,
/// Project folder (default: the nearest one above the current folder that has this flow's state)
#[arg(long, global = true)]
project: Option<String>,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Start the flow on a finished board: `init --release <board-release.json>` (written by the board flow at finish) or `init --board <B.kicad_pcb> --bom <csv>`
Init { #[arg(long)] release: Option<String>, #[arg(long)] board: Option<String>, #[arg(long)] bom: Option<String>, #[arg(long)] target: Option<String> },
/// What to do now: the first step whose artifacts are missing, and what the AI must do in it
Next,
/// Every step with its state
Steps,
/// The run ledger, one line per command
Ledger,
/// `clip start --name <step> --hwnd <window>` / `clip stop`: record one desktop window for the video
Clip { what: String, #[arg(long)] name: Option<String>, #[arg(long)] hwnd: Option<i64> },
/// Mark a step done in your own words: `mark review --note "John: looks good"` writes <step>.json
Mark { step: String, #[arg(long)] note: Option<String> },
/// Free space here and a size estimate for the planned clips: `disk --clips 60 --seconds 15`
Disk { #[arg(long, default_value_t = 60)] clips: u64, #[arg(long, default_value_t = 15)] seconds: u64 },
/// Feedback on the narration script (pace, Adom mentions, opening prompt, cost, closing): `script script.json`
Script { file: String },
/// Look for personal details in text that will be published (script, captions, description): `scan <files...>`
Scan { files: Vec<String> },
/// Remember a file this run created so `clean` can find it: `track <path> [--machine crr] [--keep]`
Track { path: String, #[arg(long)] machine: Option<String>, #[arg(long)] keep: bool },
/// What this run left behind, and with --yes remove the local files not marked keep
Clean { #[arg(long)] yes: bool },
/// YouTube for any user: `youtube check` (which channel each adom-google account reaches, and how to set one up), `youtube title "A" "B"` (feedback on titles), `youtube thumbnail <file>`, `youtube upload --file f.mp4 --title T --description-file d.txt [--thumbnail t.jpg] [--privacy private|unlisted] [--account name]`
Youtube { action: String, items: Vec<String>, #[arg(long)] file: Option<String>, #[arg(long)] title: Option<String>, #[arg(long)] description_file: Option<String>, #[arg(long)] thumbnail: Option<String>, #[arg(long, default_value = "unlisted")] privacy: String, #[arg(long)] account: Option<String>, #[arg(long)] tags: Option<String> },
/// Adom's logo sting, for official Adom videos by Adom employees only: `brand` says whether it applies; `brand --video final.mp4 --out branded.mp4` adds it
Brand { #[arg(long)] video: Option<String>, #[arg(long)] out: Option<String> },
/// Write the ready-made 3D walkthrough director (JavaScript for the Adom 3D viewer): `director --out walkthrough-director.js`
Director { #[arg(long, default_value = "walkthrough-director.js")] out: String },
/// Write the thumbnail helpers (cut a person out of a photo, compose them pointing at the product): `thumbkit --out thumbkit/`
Thumbkit { #[arg(long, default_value = "thumbkit")] out: String },
/// Print the walkthrough guide: the steps file, how to record a take, and what we learned
Guide,
/// Let the human narrate with their computer's microphone: `narrate --video teleprompter.mp4` serves a page that plays it and records them in step
/// With an action it drives the open page: `narrate status|wait|console|test|calibrate|rehearse|record|stop|play|pause|wipe|reload`, `narrate review|use <take number>`, `narrate note "<text>"`, `narrate gain <x>`, `narrate mic <name>`, `narrate seek <seconds>`, `narrate toast "<text>"`, `narrate click|reveal|cursor <selector>`
Narrate { action: Option<String>, arg: Option<String>, #[arg(long)] video: Option<String>, #[arg(long)] captions: Option<String>, #[arg(long)] chapters: Option<String>, #[arg(long)] title: Option<String>, #[arg(long)] out: Option<String>, #[arg(long, default_value_t = 0)] port: u16, #[arg(long, default_value_t = 3600)] timeout: u64,
/// Play the film's own sound while the human records (use a film whose sound holds only cues they must hear, such as an opening sting, never a voiceover)
#[arg(long)] cue_audio: bool,
/// With `narrate pitch`: the title ideas to show the human (one or more, quoted)
#[arg(long, num_args = 1..)] titles: Vec<String>,
/// With `narrate pitch`: the thumbnail pictures to show beside them
#[arg(long, num_args = 1..)] thumbs: Vec<String> },
}
fn thread(cli: &Cli) -> String {
cli.ai_thread.clone().unwrap_or_else(|| err("this command changes state: pass --ai-thread \"<your thread name>\"", &[]))
}
fn project(cli: &Cli) -> ledger::Project {
let start = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());
ledger::Project::find(&start, FLOW_DIR).unwrap_or_else(|| err(&format!("no {FLOW_DIR}/ project here"), &["Start one: adom-aiflow-video init --release <board-release.json> --ai-thread <you>".into()]))
}
fn sha256(p: &Path) -> String {
let o = std::process::Command::new("sha256sum").arg(p).output();
o.ok().and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().next().map(str::to_string)).unwrap_or_default()
}
fn main() {
let cli = Cli::parse();
let fl = flow::load();
match &cli.cmd {
Cmd::Init { release, board, bom, target } => {
let th = thread(&cli);
let dir = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());
// the handoff: a board release from the board flow, or the board and BOM named directly
let mut notes: Vec<String> = vec![];
let rel: Value = match (release, board) {
(Some(r), _) => match std::fs::read_to_string(r).ok().and_then(|t| serde_json::from_str(&t).ok()) {
Some(v) => v,
None => { notes.push(format!("{r} could not be read as JSON; carrying on without a board release. The board flow writes board-release.json at finish.")); json!({}) }
},
(None, Some(b)) => json!({"schema": "adom/board-release@1", "board": {"path": b, "sha256": sha256(Path::new(b))}, "bom": {"path": bom, "sha256": bom.as_ref().map(|m| sha256(Path::new(m)))}}),
(None, None) => { notes.push("no board release named: fine for a video of a finished project or a wiki page; name the board later in plan.json so the parts story has its numbers.".into()); json!({}) }
};
for k in ["board", "bom"] {
let p = rel[k]["path"].as_str().unwrap_or("");
if p.is_empty() { continue; }
if !Path::new(p).is_file() { notes.push(format!("the release's {k} is not at {p} on this machine; point plan.json at where it is.")); continue; }
let want = rel[k]["sha256"].as_str().unwrap_or("");
if !want.is_empty() && want != sha256(Path::new(p)) { notes.push(format!("{p} changed since the release was written; check the video shows the board you mean.")); }
}
let p = ledger::Project::create(&dir, FLOW_DIR, json!({"flow": fl.name, "release": rel, "target": target, "started": ledger::now()})).unwrap_or_else(|e| err(&format!("cannot write {FLOW_DIR}/: {e}"), &[]));
p.log(&th, "init", json!({"release": release}));
let next = flow::current(&fl, &p.flow_dir).map(|s| format!("next step: {}: {}", s.name, s.todo)).unwrap_or_default();
notes.push(next);
if target.is_none() { notes.push("no --target: clips record through Adom Bridge on a named machine; a second machine on the human's Adom Mesh keeps their own free.".into()); }
ok(&format!("{} flow started in {}", fl.name, p.flow_dir.display()), ¬es);
}
Cmd::Next => {
let p = project(&cli);
match flow::current(&fl, &p.flow_dir) {
Some(s) => ok(&format!("step {}", s.name), &[s.todo.clone()]),
None => ok("every step has its artifacts: the flow is done", &[]),
}
}
Cmd::Steps => {
let p = project(&cli);
let cur = flow::current(&fl, &p.flow_dir).map(|s| s.name.clone());
for s in &fl.steps {
let done = s.done.iter().all(|x| flow::exists(&p.flow_dir, x));
println!("{} {}", if done { "done" } else if Some(&s.name) == cur.as_ref() { "NOW " } else { " " }, s.name);
}
}
Cmd::Ledger => {
let p = project(&cli);
print!("{}", std::fs::read_to_string(p.path("run.jsonl")).unwrap_or_default());
}
Cmd::Clip { what, name, hwnd } => {
let th = thread(&cli);
let mut p = project(&cli);
let target = p.state["target"].as_str().map(str::to_string).unwrap_or_else(|| err("no desktop target: init with --target <box>", &[]));
let br = bridge::Bridge { target, ai_thread: th.clone() };
match what.as_str() {
"start" => {
let (Some(n), Some(h)) = (name, hwnd) else { err("clip start needs --name <step> --hwnd <window>", &["Find the window with `adom-bridge desktop_list_windows`. Drive browsers with pup (no OS cursor), so the clip shows no moving mouse.".into()]) };
if p.state["clip"].is_object() { err("a clip is already recording: clip stop first", &[]); }
let id = br.record_window_start(*h, &format!("{FLOW_DIR}: record step {n}")).unwrap_or_else(|r| err(&format!("recording did not start: {r}"), &[]));
p.state["clip"] = json!({"id": id, "name": n, "hwnd": h, "started": ledger::now()});
p.save().ok(); p.log(&th, "clip-start", p.state["clip"].clone());
ok(&format!("recording {n} (window {h})"), &[]);
}
"stop" => {
let c = p.state["clip"].clone();
let id = c["id"].as_str().unwrap_or_else(|| err("no clip is recording", &[]));
let dir = p.path("clips"); std::fs::create_dir_all(&dir).ok();
let s = br.record_stop(id, &dir);
p.state["clip"] = Value::Null; p.save().ok();
p.log(&th, "clip-stop", json!({"name": c["name"], "remote": s.remote, "local": s.local, "pulled": s.pulled}));
if !s.pulled { err(&format!("the clip did not land locally: {}", s.reply), &["Pull it by hand with pull_file before the desktop cleans its recordings folder.".into()]); }
ok(&format!("clip {} saved to {}", c["name"].as_str().unwrap_or(""), s.local.unwrap().display()), &[]);
}
_ => err("clip start|stop", &[]),
}
}
Cmd::Mark { step, note } => {
let th = thread(&cli);
let p = project(&cli);
let known = fl.steps.iter().any(|s| &s.name == step);
let f = p.path(&format!("{step}.json"));
let _ = std::fs::write(&f, serde_json::to_string_pretty(&json!({"step": step, "note": note, "t": ledger::now(), "aiThread": th})).unwrap_or_default() + "\n");
p.log(&th, "mark", json!({"step": step, "note": note}));
let mut h = vec![];
if !known { h.push(format!("'{step}' is not a step in the flow file; recorded anyway. Steps: {}", fl.steps.iter().map(|s| s.name.as_str()).collect::<Vec<_>>().join(", "))); }
if let Some(s) = flow::current(&fl, &p.flow_dir) { h.push(format!("next suggestion: {}: {}", s.name, s.todo)); }
ok(&format!("{step} marked ({})", f.display()), &h);
}
Cmd::Disk { clips, seconds } => {
let dir = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());
let (msg, hints) = checks::disk(&dir, *clips, *seconds);
ok(&msg, &hints);
}
Cmd::Youtube { action, items, file, title, description_file, thumbnail, privacy, account, tags } => match action.as_str() {
"check" => { let (m, h) = youtube::check(); ok(&m, &h); }
"title" | "titles" => { if items.is_empty() { err("give one or more titles in quotes", &["youtube title \"AI Designed a PCB for My Dorm Faucet\" \"...\"".into()]); } let (m, h) = youtube::title(items); ok(&m, &h); }
"thumbnail" => { let f = items.first().cloned().or(thumbnail.clone()).unwrap_or_else(|| err("which picture?", &["youtube thumbnail thumb.jpg".into()])); let (m, h) = youtube::thumbnail(&f); ok(&m, &h); }
"open" | "captions" => {
let v = items.first().cloned().unwrap_or_else(|| err("which video? give its link or id", &["youtube open https://youtu.be/<id>".into()]));
let (m, h) = youtube::open(&v, action == "captions", file.as_deref(), cli.ai_thread.as_deref().unwrap_or("adom-aiflow-video")); ok(&m, &h);
}
"upload" => {
let (Some(f), Some(t)) = (file, title) else { err("upload needs --file and --title", &["youtube upload --file film.mp4 --title \"...\" --description-file description.txt --thumbnail thumb.jpg --privacy unlisted".into()]) };
if !["private", "unlisted", "public"].contains(&privacy.as_str()) { err("--privacy is private, unlisted or public", &[]); }
match youtube::upload(&youtube::Upload { file: f, title: t, description_file: description_file.as_deref(), thumbnail: thumbnail.as_deref(), privacy, account: account.as_deref(), tags: tags.as_deref() }) { Ok((m, mut h)) => { if let Some(u) = m.split_whitespace().find(|w| w.contains("youtu")) { let (m2, h2) = youtube::open(u, false, None, cli.ai_thread.as_deref().unwrap_or("adom-aiflow-video")); h.insert(0, m2); h.extend(h2.into_iter().take(1)); } ok(&m, &h) }, Err((m, h)) => err(&m, &h) }
}
_ => err("youtube takes: check, title, thumbnail, upload, open, captions", &[]),
},
Cmd::Thumbkit { out } => {
let _ = std::fs::create_dir_all(out);
for (n, body) in [("cutout.py", include_str!("../assets/thumbkit/cutout.py")), ("compose.py", include_str!("../assets/thumbkit/compose.py"))] { let _ = std::fs::write(Path::new(out).join(n), body); }
ok(&format!("thumbnail helpers written to {out}/ (cutout.py, compose.py)"), &[
"1. `narrate pose --thumbs <product picture>` : the human points at the product on their camera view; three stills land as pose-N.jpg in the takes folder.".into(),
"2. `python3 cutout.py pose-1.jpg pose-1-cut.png` cuts them out of their background (the script's first lines say how to fetch the model once).".into(),
"3. `python3 compose.py --person pose-1-cut.png --product product.jpg --line1 \"...\" --line2 \"...\" --tip <x,y of the fingertip> --out thumb.jpg`, then look at it, adjust --scale and --tip, and show two or three in the picker (`narrate pitch`).".into(),
"The scripts are a starting point: change the layout freely. They add no logo; the film is the human's own.".into()]);
}
Cmd::Brand { video, out } => {
match (video, out) {
(Some(v), Some(o)) => match brand::apply(v, o) { Ok((m, h)) => ok(&m, &h), Err((m, h)) => err(&m, &h) },
(None, None) => { let (m, h) = brand::status(); ok(&m, &h); }
_ => err("give both --video and --out, or neither", &["`brand` alone says whether Adom branding applies to this film.".into()]),
}
}
Cmd::Script { file } => {
let text = std::fs::read_to_string(file).unwrap_or_else(|e| err(&format!("could not read {file}: {e}"), &["Write script.json first: [{\"id\": \"s01\", \"text\": \"...\", \"a\": 1.0, \"b\": 12.0}, ...]".into()]));
let (msg, hints) = checks::script(&text);
ok(&msg, &hints);
}
Cmd::Scan { files } => {
let (found, mut hints) = checks::scan(files);
if let Some(p) = cli.project.as_ref().map(PathBuf::from).or_else(|| std::env::current_dir().ok()).and_then(|d| ledger::Project::find(&d, FLOW_DIR)) {
let _ = std::fs::write(p.path("privacy.json"), serde_json::to_string_pretty(&json!({"t": ledger::now(), "files": files, "findings": found})).unwrap_or_default() + "\n");
hints.push(format!("written to {}", p.path("privacy.json").display()));
}
ok(&format!("{} thing(s) worth a look in {} file(s)", found.len(), files.len()), &hints);
}
Cmd::Track { path, machine, keep } => {
let th = thread(&cli);
let p = project(&cli);
let f = p.path("files.json");
let mut list: Vec<Value> = std::fs::read_to_string(&f).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
list.push(json!({"path": path, "machine": machine, "keep": keep, "t": ledger::now()}));
let _ = std::fs::write(&f, serde_json::to_string_pretty(&list).unwrap_or_default() + "\n");
p.log(&th, "track", json!({"path": path, "machine": machine, "keep": keep}));
ok(&format!("tracking {path} ({} file(s) so far)", list.len()), &[]);
}
Cmd::Director { out } => {
std::fs::write(out, include_str!("../assets/walkthrough-director.js")).unwrap_or_else(|e| err(&format!("could not write {out}: {e}"), &[]));
ok(&format!("walkthrough director written to {out}"), &["Inject it into the project's Adom 3D viewer page with pup_evaluate, then GHW.setupCase / GHW.setupBoard / GHW.mergeBoard / GHW.run. `guide` explains the steps file and the take.".into()]);
}
Cmd::Guide => print!("{}", include_str!("../docs/walkthrough.md")),
Cmd::Narrate { action, arg, video, captions, chapters, title, out, port, timeout, cue_audio, titles, thumbs } => match action.as_deref() {
None | Some("serve") => {
let Some(video) = video else { err("narrate needs the teleprompter copy: --video <teleprompter.mp4>", &["Make a working copy of the draft with the captions showing and a short countdown at the start.".into(), "To drive a page that is already open: narrate status | test | record | stop | mic <name> | seek <seconds> | toast \"...\"".into()]) };
if !Path::new(video).is_file() { err(&format!("could not find the teleprompter video: {video}"), &["Make a working copy of the draft with the captions showing and a short countdown at the start, then pass it with --video.".into()]); }
let start = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());
let dir = out.as_ref().map(PathBuf::from).or_else(|| ledger::Project::find(&start, FLOW_DIR).map(|p| p.path("voice"))).unwrap_or_else(|| start.join("voice"));
if let Err(e) = narrate::serve(Path::new(video), &dir, *port, captions.as_ref().map(Path::new), chapters.as_ref().map(Path::new), title.as_deref(), *cue_audio) { err(&e, &["Another narration page may still be running on that port; leave --port off to take a free one.".into()]); }
}
Some(act) => {
let p = if *port != 0 { *port } else { narrate::find_port().unwrap_or_else(|| err("no narration page is running", &["Start one: adom-aiflow-video narrate --video <teleprompter.mp4>, then open its address in the human's browser.".into()])) };
if act == "wait" {
// block until the human presses Use this take (or the timeout): the hand-off to the AI
let t0 = std::time::Instant::now();
loop {
let (_, body) = narrate::call(p, "GET", "/state", None, "").unwrap_or_else(|e| err(&e, &["The narration page's server stopped.".into()]));
let st: Value = serde_json::from_str(&body).unwrap_or(json!({}));
if let Some(f) = st["chosen"].as_str() {
let dir = st["out"].as_str().unwrap_or("");
let tk = st["takes"].as_array().and_then(|a| a.iter().find(|t| t["file"] == f).cloned()).unwrap_or(json!({}));
ok(&format!("the human chose {dir}/{f}"), &[
format!("{:.0} s, peak {}, microphone {}; the film starts {} ms into the recording; {} drawing(s){}", tk["seconds"].as_f64().unwrap_or(0.0), tk["peak"], tk["mic"].as_str().unwrap_or("?"), tk["videoStartsAtMs"], tk["strokes"], if tk["strokes"].as_u64().unwrap_or(0) > 0 { format!(" in {dir}/{}", Path::new(f).with_extension("draw.json").display()) } else { String::new() }),
"Tell them on the page what you are doing (`narrate note \"...\"`), put their voice on the film, write new captions from what they said, render their drawings, and show them the result before anything is published.".into()]);
return;
}
if t0.elapsed().as_secs() >= *timeout { ok("still waiting: no take chosen yet", &[format!("{} take(s) recorded so far; run `narrate wait` again, or `narrate status`.", st["takes"].as_array().map(|a| a.len()).unwrap_or(0))]); return; }
std::thread::sleep(std::time::Duration::from_secs(2));
}
}
if act == "console" {
let (_, body) = narrate::call(p, "GET", "/console", None, "").unwrap_or_else(|e| err(&e, &[]));
let v: Value = serde_json::from_str(&body).unwrap_or(json!({}));
for l in v["lines"].as_array().cloned().unwrap_or_default() { println!("{}", l.as_str().unwrap_or("")); }
return;
}
if act == "status" {
let (_, body) = narrate::call(p, "GET", "/state", None, "").unwrap_or_else(|e| err(&e, &[]));
let st: Value = serde_json::from_str(&body).unwrap_or(json!({}));
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)));
h.push(format!("boost {}x (raw microphone level {:.3}); calibration: {}", pg["boost"], pg["rawLevel"].as_f64().unwrap_or(0.0), if pg["calibrating"].is_object() && pg["calibrating"]["step"] != 3 { format!("RUNNING, step {}: {} ({} s left)", pg["calibrating"]["step"], pg["calibrating"]["title"].as_str().unwrap_or(""), pg["calibrating"]["secondsLeft"]) } else if pg["calibration"].is_object() { pg["calibration"].to_string() } else { "not done yet (`narrate calibrate`)".into() }));
h.push(format!("the page says: {}", pg["status"].as_str().unwrap_or("")));
} else { h.push("the page has not reported yet: open the address in the human's browser.".into()); }
for t in st["takes"].as_array().cloned().unwrap_or_default() { h.push(format!("take {}: {:.0} s, peak {}, mic {}, video starts {} ms in", t["file"].as_str().unwrap_or(""), t["seconds"].as_f64().unwrap_or(0.0), t["peak"], t["mic"].as_str().unwrap_or("?"), t["videoStartsAtMs"])); }
ok(&format!("narration page on port {p}"), &h);
} else {
let th = thread(&cli);
let body = match (act, arg) {
("seek", Some(a)) => json!({"seconds": a.parse::<f64>().unwrap_or(0.0)}),
("mic", Some(a)) => json!({"name": a}),
("pip", Some(a)) => json!({"spec": a}),
("pose", a) => json!({"say": a.clone().unwrap_or_default(), "product": thumbs.first().map(|t| std::fs::canonicalize(t).map(|p| p.display().to_string()).unwrap_or_default())}),
("pitch", _) => {
if titles.is_empty() || thumbs.is_empty() { err("pitch needs --titles \"A\" \"B\" ... and --thumbs a.jpg b.jpg ...", &["Bring about five titles and two or three thumbnails (1280 x 720). `youtube title` and `youtube thumbnail` give feedback on them first.".into()]); }
let abs: Vec<String> = thumbs.iter().map(|t| std::fs::canonicalize(t).map(|p| p.display().to_string()).unwrap_or_else(|_| err(&format!("no picture at {t}"), &[]))).collect();
json!({"titles": titles, "thumbs": abs})
}
("cam", a) => json!({"name": a.clone().unwrap_or_else(|| "on".to_string())}),
("gain", Some(a)) => json!({"value": a.parse::<f64>().unwrap_or(1.0)}),
("toast", Some(a)) => json!({"text": a, "kind": "info"}),
("note", a) => json!({"text": a.clone().unwrap_or_default()}),
("review" | "use", Some(a)) => json!({"take": a.parse::<u64>().unwrap_or(0)}),
("click" | "reveal" | "cursor", Some(a)) => json!({"selector": a}),
_ => json!({}),
};
let (code, reply) = narrate::call(p, "POST", &format!("/ui/{act}"), Some(&th), &body.to_string()).unwrap_or_else(|e| err(&e, &[]));
let r: Value = serde_json::from_str(&reply).unwrap_or(json!({}));
if code != 200 { err(&format!("the page did not take '{act}': {}", r["error"].as_str().unwrap_or(&reply)), &[r["hint"].as_str().unwrap_or("").to_string()]); }
let mut h = vec!["`narrate status` shows what the page did with it.".to_string()];
if r["pagesOpen"].as_u64() == Some(0) { h.insert(0, "no page is open to hear it: open the narration address in the human's browser first.".into()); }
ok(&format!("{act} sent to {} open page(s)", r["pagesOpen"]), &h);
}
}
},
Cmd::Clean { yes } => {
let p = project(&cli);
let (msg, hints, report) = checks::clean(&p.flow_dir, *yes);
if *yes {
let th = thread(&cli);
let _ = std::fs::write(p.path("cleanup.json"), serde_json::to_string_pretty(&report).unwrap_or_default() + "\n");
p.log(&th, "clean", report);
}
ok(&msg, &hints);
}
}
}