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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
//! The checks. Each one REPORTS what it saw and SUGGESTS a next action. None of them decides
//! anything: a finding is feedback for the AI and the human to weigh, never a failure.
use serde_json::{json, Value};
use std::path::Path;
/// Measured on real 4K full-screen takes (Ghost Hand rev 2): about 4.5 MB per second of clip.
const MB_PER_SECOND: f64 = 4.5;
/// adom-tts speaks about 2.6 words a second.
const WORDS_PER_SECOND: f64 = 2.6;
/// Adom tools a script may credit, with the words that count as a mention.
const TOOLS: [(&str, &[&str]); 11] = [
("Adom Hydrogen", &["hydrogen"]),
("AI Flow", &["ai flow", "aiflow"]),
("a CAD bridge", &["kicad bridge", "fusion bridge", "altium bridge"]),
("Adom parts search", &["parts search"]),
("the Adom wiki", &["adom wiki", "the wiki", "wiki page"]),
("Adom Fields", &["adom fields", "fields "]),
("Adom's KiCad service", &["kicad service"]),
("webviews", &["webview"]),
("pup", &[" pup", "its own browser"]),
("the Adom 3D viewer", &["3d viewer"]),
("adom-tts", &["adom-tts"]),
];
/// Phrases that make narration sound machine-made: filler openers and hype.
const FILLER: [&str; 22] = ["let's dive", "dive in", "in today's video", "in this video", "welcome to", "without further ado", "game-changer", "game changer", "revolutionary", "cutting-edge", "cutting edge", "seamless", "effortless", "powerful", "amazing", "incredible", "stunning", "unleash", "next-level", "state-of-the-art", "sleek", "robust"];
fn words(t: &str) -> usize { t.split_whitespace().count() }
pub fn disk(dir: &Path, clips: u64, seconds: u64) -> (String, Vec<String>) {
let need_gb = clips as f64 * seconds as f64 * MB_PER_SECOND / 1024.0;
let out = std::process::Command::new("df").args(["-Pk"]).arg(dir).output();
let free_gb = out.ok().and_then(|o| String::from_utf8_lossy(&o.stdout).lines().nth(1).and_then(|l| l.split_whitespace().nth(3).and_then(|k| k.parse::<f64>().ok()))).map(|k| k / 1024.0 / 1024.0);
let mut hints = vec![
format!("{clips} clips of about {seconds} s at 4K come to roughly {need_gb:.1} GB of raw footage, plus about the same again while cutting; all of it goes away at `clean`."),
"This number is for THIS machine. Clips are recorded on the machine named at init (--target): check its disk too, e.g. with adom-bridge shell_execute on that machine.".to_string(),
"Keep clips short (5 to 30 s) and leave AI Flow's own full-length step recording off: on one build those recordings reached 45 GB.".to_string(),
"Tell the human that recording takes over that machine's screen, and suggest a second machine on their Adom Mesh.".to_string(),
];
let msg = match free_gb {
Some(f) => {
if f < need_gb * 2.5 { hints.insert(0, format!("Space looks tight here ({f:.0} GB free for about {:.0} GB of work): consider fewer or shorter clips, 1080p capture, or another machine.", need_gb * 2.0)); }
format!("{f:.0} GB free here; this plan needs about {:.0} GB while working", need_gb * 2.0)
}
None => format!("could not read free space here; this plan needs about {:.0} GB while working", need_gb * 2.0),
};
(msg, hints)
}
/// script.json: [{"id", "text", "a"?, "b"? (seconds of footage), ...}]
pub fn script(text: &str) -> (String, Vec<String>) {
let scenes: Vec<Value> = match serde_json::from_str::<Value>(text) {
Ok(Value::Array(a)) => a,
Ok(v) => v.get("scenes").and_then(|s| s.as_array()).cloned().unwrap_or_default(),
Err(e) => return (format!("the script is not JSON yet ({e})"), vec!["Expected a list of scenes: [{\"id\": \"s01\", \"text\": \"...\"}, ...]".into()]),
};
let mut hints = vec![];
let mut total = 0.0;
let mut mentions: Vec<(String, &str)> = vec![];
for (i, s) in scenes.iter().enumerate() {
let id = s["id"].as_str().map(str::to_string).unwrap_or(format!("#{}", i + 1));
let t = s["text"].as_str().unwrap_or("");
let secs = words(t) as f64 / WORDS_PER_SECOND;
total += secs;
if words(t) > 34 { hints.push(format!("{id}: {} words (about {secs:.0} s) is a long hold for one shot; two shorter scenes may read better.", words(t))); }
if t.contains('\u{2014}') { hints.push(format!("{id}: has an em-dash; a comma or a new sentence narrates more cleanly.")); }
let low = format!(" {} ", t.to_lowercase());
let fill: Vec<&str> = FILLER.iter().filter(|w| low.contains(*w)).copied().collect();
if !fill.is_empty() { hints.push(format!("{id}: \"{}\" reads as filler or hype; a number or a reason says more (what it measures, costs, or why it was done).", fill.join("\", \""))); }
if !t.is_empty() && !t.chars().any(|ch| ch.is_ascii_digit()) && words(t) > 14 && !["one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "twenty", "thirty", "forty", "fifty", "hundred", "half"].iter().any(|n| low.contains(n)) && s["label"].as_str().map(|l| low.contains(&l.to_lowercase())).unwrap_or(false) { hints.push(format!("{id}: the line mostly repeats the on-screen label; say what the picture cannot (a number, a reason, a trade-off).")); }
let here: Vec<&str> = TOOLS.iter().filter(|(_, keys)| keys.iter().any(|k| low.contains(k))).map(|(n, _)| *n).collect();
if here.len() > 2 { hints.push(format!("{id}: names {} Adom tools in one scene ({}); one per scene lands better, the rest can go in the description.", here.len(), here.join(", "))); }
for n in here { mentions.push((id.clone(), n)); }
if let (Some(a), Some(b)) = (s["a"].as_f64(), s["b"].as_f64()) {
let speed = (b - a) / (secs + 0.35);
if speed > 2.5 { hints.push(format!("{id}: {:.0} s of footage for {secs:.0} s of narration plays at {speed:.1}x; a shorter range (or a longer line) keeps it watchable.", b - a)); }
if speed < 0.8 { hints.push(format!("{id}: only {:.0} s of footage for {secs:.0} s of narration; the shot will slow down or freeze unless it is longer.", b - a)); }
}
}
let all = scenes.iter().map(|s| s["text"].as_str().unwrap_or("")).collect::<Vec<_>>().join(" ").to_lowercase();
let first = scenes.first().and_then(|s| s["text"].as_str()).unwrap_or("").to_lowercase();
if !first.contains("prompt") { hints.push("The opening does not mention the starting prompt; viewers like to see the one request the build began with.".into()); }
if !all.contains("hydrogen") { hints.push("Nothing says the build was made in Adom Hydrogen; one spoken line near the end is enough.".into()); }
if !(all.contains("dollar") || all.contains('$') || all.contains("cost")) { hints.push("No cost line yet: what it costs to build one, and each at 100, is one of the most useful facts in the film.".into()); }
if !all.contains("ask your ai") && !all.contains("just say") { hints.push("No 'ask your AI to ...' line yet; one or two teach the viewer how to do the same thing.".into()); }
if !(all.contains("parts") && (all.contains("unique") || all.contains("wiki"))) { hints.push("Consider the parts story: how many unique parts, where they came from (the wiki, new pages, fetched in pup, redrawn) and their cost.".into()); }
let budget = (total / 25.0).ceil().max(1.0) as usize;
if mentions.len() > budget + 2 { hints.push(format!("{} Adom mentions in about {:.0} s; around {budget} (one per 20 to 30 s) keeps it from sounding like a list.", mentions.len(), total)); }
if mentions.is_empty() && !scenes.is_empty() { hints.push("No Adom tool is named; say which tool did each step while it is on screen.".into()); }
let mut used: Vec<&str> = mentions.iter().map(|m| m.1).collect(); used.sort(); used.dedup();
if !used.is_empty() { hints.push(format!("tools credited: {}", used.join(", "))); }
hints.push("These are suggestions; the script is yours. Show it to the human with the draft.".into());
(format!("{} scenes, about {:.0} s of narration ({}:{:02})", scenes.len(), total, total as u64 / 60, total as u64 % 60), hints)
}
fn looks_like_email(w: &str) -> bool {
let w = w.trim_matches(|c: char| !c.is_alphanumeric() && c != '@' && c != '.' && c != '_' && c != '-' && c != '+');
match w.split_once('@') {
Some((a, b)) => !a.is_empty() && b.contains('.') && !b.starts_with('.') && !b.ends_with('.') && b.chars().all(|c| c.is_alphanumeric() || c == '.' || c == '-'),
None => false,
}
}
fn looks_like_phone(w: &str) -> bool {
let d = w.chars().filter(|c| c.is_ascii_digit()).count();
(10..=12).contains(&d) && w.chars().all(|c| c.is_ascii_digit() || "+-.()".contains(c)) && w.contains(|c| "-.()".contains(c))
}
/// Text that will be published: the script, captions, the description. Frames need the AI's eyes.
pub fn scan(files: &[String]) -> (Vec<Value>, Vec<String>) {
let mut found = vec![];
let mut hints = vec![];
for f in files {
let Ok(text) = std::fs::read_to_string(f) else { hints.push(format!("could not read {f}; skipped.")); continue };
for (n, line) in text.lines().enumerate() {
let mut add = |kind: &str, what: &str| found.push(json!({"file": f, "line": n + 1, "kind": kind, "text": what}));
for w in line.split_whitespace() {
if looks_like_email(w) { add("email address", w); }
if looks_like_phone(w) { add("phone number", w); }
for p in ["/home/", "/Users/", "C:\\Users\\", "C:/Users/"] { if w.contains(p) { add("file path with a user name", w); } }
let bare = w.trim_matches(|c: char| !c.is_alphanumeric() && c != '_' && c != '-');
if ["sk-", "ghp_", "gho_", "AKIA", "xoxb-", "xoxp-", "AIza"].iter().any(|k| bare.starts_with(k)) && bare.len() >= 20 { add("what looks like a key or token", &format!("{}...", &bare[..8])); }
}
}
}
for x in found.iter().take(12) { hints.push(format!("{}:{} {}: {}", x["file"].as_str().unwrap_or(""), x["line"], x["kind"].as_str().unwrap_or(""), x["text"].as_str().unwrap_or(""))); }
if found.len() > 12 { hints.push(format!("... and {} more in privacy.json", found.len() - 12)); }
hints.push("This reads text only. Look through the contact sheet too: mail, chat, names, account avatars, other people's windows and notification toasts show up in frames, not in files.".into());
hints.push("When a clip shows something personal, a different take or a tighter crop usually beats a blur. Tell the human what you found and what you did.".into());
(found, hints)
}
fn size_of(p: &Path) -> u64 {
if p.is_file() { return p.metadata().map(|m| m.len()).unwrap_or(0); }
std::fs::read_dir(p).map(|rd| rd.flatten().map(|e| size_of(&e.path())).sum()).unwrap_or(0)
}
/// What the run left behind. With `yes`, local files not marked keep are removed; files on other
/// machines are listed for the AI to remove there.
pub fn clean(flow_dir: &Path, yes: bool) -> (String, Vec<String>, Value) {
let list: Vec<Value> = std::fs::read_to_string(flow_dir.join("files.json")).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
let mut items: Vec<(String, Option<String>, bool)> = list.iter().map(|x| (x["path"].as_str().unwrap_or("").to_string(), x["machine"].as_str().map(str::to_string), x["keep"].as_bool().unwrap_or(false))).collect();
for sub in ["clips", "work", "ovl"] {
let d = flow_dir.join(sub);
if d.exists() { items.push((d.display().to_string(), None, false)); }
}
let (mut hints, mut freed, mut left, mut remote) = (vec![], 0u64, 0u64, vec![]);
for (path, machine, keep) in &items {
if let Some(m) = machine { remote.push(format!("{m}: {path}")); continue; }
let p = Path::new(path);
if !p.exists() { continue; }
let sz = size_of(p);
if *keep { hints.push(format!("keeping {path} ({:.1} MB)", sz as f64 / 1e6)); continue; }
if yes {
let r = if p.is_dir() { std::fs::remove_dir_all(p) } else { std::fs::remove_file(p) };
match r { Ok(_) => freed += sz, Err(e) => hints.push(format!("could not remove {path}: {e}")) }
} else { left += sz; hints.push(format!("would remove {path} ({:.1} MB)", sz as f64 / 1e6)); }
}
for r in &remote { hints.push(format!("on another machine, remove it there: {r}")); }
if items.is_empty() { hints.push("Nothing tracked. `track <path>` each file as you create it; look also for the desktop recorder's temp folder and AI Flow's own step recordings in the run folder.".into()); }
if !yes { hints.push("Run `clean --yes` once the human has approved the final film. Keep the film, captions, chapters and the edit plan.".into()); }
let msg = if yes { format!("freed {:.2} GB here; {} item(s) on other machines listed", freed as f64 / 1e9, remote.len()) } else { format!("{:.2} GB here can go; {} item(s) on other machines", left as f64 / 1e9, remote.len()) };
(msg, hints, json!({"freedBytes": freed, "remote": remote, "items": items.len()}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn script_feedback_is_suggestions() {
let s = r#"[{"id":"s01","text":"This board started as one prompt in Adom Hydrogen.","a":0,"b":6},
{"id":"s02","text":"AI Flow placed and routed it in KiCad through the KiCad bridge, about twenty-nine dollars a board.","a":0,"b":60}]"#;
let (msg, hints) = script(s);
assert!(msg.starts_with("2 scenes"));
assert!(hints.iter().any(|h| h.contains("s02") && h.contains("x;")));
assert!(!hints.iter().any(|h| h.contains("No cost line")));
assert!(hints.iter().any(|h| h.contains("ask your AI")));
let (_, hints) = script(r#"[{"id":"s01","text":"Let's dive in to this amazing board."}]"#);
assert!(hints.iter().any(|h| h.contains("filler or hype")));
}
#[test]
fn scan_finds_personal_details_in_text() {
let f = std::env::temp_dir().join(format!("scan-test-{}.txt", std::process::id()));
std::fs::write(&f, "Built by [email protected]\nSee /home/sam/project/board\nCall 817-555-0100\nthirty by twenty-two millimetres\n").unwrap();
let (found, _) = scan(&[f.display().to_string()]);
let kinds: Vec<&str> = found.iter().map(|x| x["kind"].as_str().unwrap()).collect();
assert!(kinds.contains(&"email address") && kinds.contains(&"file path with a user name") && kinds.contains(&"phone number"));
assert_eq!(found.len(), 3);
std::fs::remove_file(&f).ok();
}
#[test]
fn clean_lists_before_it_removes() {
let d = std::env::temp_dir().join(format!("clean-test-{}", std::process::id()));
std::fs::create_dir_all(d.join("clips")).unwrap();
std::fs::write(d.join("clips/a.mp4"), vec![0u8; 2048]).unwrap();
let (_, _, r) = clean(&d, false);
assert_eq!(r["freedBytes"], 0);
assert!(d.join("clips/a.mp4").exists());
let (_, _, r) = clean(&d, true);
assert_eq!(r["freedBytes"], 2048);
assert!(!d.join("clips").exists());
std::fs::remove_dir_all(&d).ok();
}
}