main
John Lauer Publish 0.7.0 8ff960b 9h ago
//! 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()), &notes);
        }
        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);
        }
    }
}