app
AI Flow: Video
Public Made by Adomby adom
Turn a board build into a narrated walkthrough video on your wiki page and YouTube.
← Commit history
Publish 0.4.0
16 files changed
+162−6
Cargo.lock+1−1Cargo.toml+1−1README.md+42−1bin/adom-aiflow-videodocs/screenshots/example-thumbnail.jpgdocs/screenshots/narrate-page.jpgdocs/screenshots/picture-controls.jpgdocs/screenshots/result-board.jpgdocs/screenshots/result-drawing.jpgdocs/screenshots/result-picture.jpgflows/flow.json+1−1package.json+1−1page.json+1−1skills/adom-aiflow-video/SKILL.md+8src/main.rs+14src/youtube.rs+93Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow-video"-version = "0.3.1"+version = "0.4.0" dependencies = [ "clap", "serde",
Cargo.toml+1−1@@ -2,7 +2,7 @@ # Copied from adom/adom-aiflow template/flow-app. This copy is yours: change anything, never depend on aiflow. [package] name = "adom-aiflow-video"-version = "0.3.1"+version = "0.4.0" edition = "2021" license = "MIT"
README.md+42−1@@ -9,6 +9,46 @@ adom-wiki pkg install adom/adom-aiflow-video adom-aiflow-video --help ``` +Or just say it to your AI: **"use aiflow-video to make a video of my board."**++## An example, made with it++::adom-video{src="/john/ghost-hand/render/docs/videos/ghost-hand-rev2-film.video.json"}++John Lauer narrating his Ghost Hand board ([project page](https://wiki.adom.inc/john/ghost-hand), [on YouTube](https://youtu.be/lDCjjvVKzLY)). The film was cut by the AI, then John recorded the narration on the narration page below: his face in the corner, circles drawn live while he talked, sound from a wireless mic lined up afterwards, captions written from his own words.++## Narrate it yourself++The AI voiceover is fine for a draft. A film people want to watch has a person in it. `narrate` opens a page where you record your own voice over the film, and the AI puts it all together afterwards.++++- **Your face in the corner, if you want it.** Turn the camera on and you see yourself on the film, exactly where you will appear. Drag it anywhere, drag a corner to resize it, and pick a shape: landscape, portrait, square or circle. Zoom and crop sliders keep your face and drop the room behind you. Turn it off for voice only. Nobody has to show their face.++++- **Draw while you talk.** Drag on the film to circle or point at something. The stroke is teal, fades out after two seconds (Space wipes it sooner), and is drawn into the final film at the moment you drew it.++++- **Two microphones, best one wins.** The page records your computer's microphone. If you also hold a wireless mic that records on the device (or a phone), say so on the page: afterwards plug it in and ask your AI to fetch the clip. It lines the better recording up with the page's take and uses it, so the film gets the best sound you made.+- **Microphone check.** Calibrate reads a line from your own film for about 15 seconds, sets the level for your voice and tells you what it changed. A clock, a progress bar with chapters and a wrap-up warning keep you on pace.+- **The film cannot stall on you.** Before a take the page loads the whole film into memory and test-plays it out of sight. If your computer stalls on the film as it is, the page switches to a copy in another format that plays smoothly. Every take also logs film time against recording time, so a take is never lost to a hiccup.+- **Your AI keeps you posted.** A "From your AI" box on the page says what the AI is doing with your take: fetching your recorder's sound, lining it up, putting your voice, picture and drawings on the film, writing captions from what you said, and where to watch the result.+- **Review before anything leaves your computer.** Play a take back with the film, record again, or start over. Nothing is published until you say so.++++## YouTube++- **Title and thumbnail, together with you.** The AI offers a handful of titles and `youtube title` gives feedback on each; it composes two or three thumbnails from a frame of the film, a frame of you and a few large words, and `youtube thumbnail` checks the size YouTube wants. You pick.+- **The description.** What the film shows, chapters, and a link back to the project's wiki page.+- **The upload.** `youtube check` says which channel each of your adom-google accounts uploads to, and how to set one up if you have none. `youtube upload` sends the film as private or unlisted first, so you can watch it on YouTube before the world does. When adom-google cannot reach your channel, the AI uploads through YouTube Studio in a browser it drives.++++## The 3D walkthrough+  @@ -48,6 +88,7 @@ disk --clips N --seconds S clip start|stop mark <step> --note "..." script <script.json> scan <files...> track <path> clean [--yes] director --out <file.js> guide narrate --video <draft.mp4> brand [--video <final.mp4> --out <branded.mp4>]+youtube check | title "..." | thumbnail <file> | upload --file f.mp4 --title T --description-file d.txt --thumbnail t.jpg --privacy unlisted ``` Every command answers `OK:` with `Hint:` lines. The skill is in [skills/adom-aiflow-video/SKILL.md](skills/adom-aiflow-video/SKILL.md); the walkthrough guide is in [docs/walkthrough.md](docs/walkthrough.md).@@ -58,4 +99,4 @@ Your film is yours: it carries no Adom logo. The Adom logo sting is only for off ## Status -0.3.1. It grew out of the two Ghost Hand films ([john/ghost-hand](https://wiki.adom.inc/john/ghost-hand)). Working today: the flow and its hints, clip recording, the disk estimate, script feedback, the text privacy scan, file tracking and cleanup, the walkthrough director, and the narration page for recording your own voice. Still done by the AI by hand, with the skill as the guide: the cut itself, the frame-by-frame privacy look, the wiki publish and the YouTube upload. AI Flow does not suggest this app at intake yet.+0.4.0. It grew out of the Ghost Hand films ([john/ghost-hand](https://wiki.adom.inc/john/ghost-hand)). Working today: the flow and its hints, clip recording, the disk estimate, script feedback, the text privacy scan, file tracking and cleanup, the walkthrough director, the narration page (voice, camera picture, drawing, playback check, AI notes), and the YouTube channel check, title and thumbnail feedback and upload. Still done by the AI by hand, with the skill as the guide: the cut itself, putting the voice, picture and drawings on the film, lining up a second recorder, composing thumbnails, and the frame-by-frame privacy check.
bin/adom-aiflow-video⋯ 1 unchanged line ⋯
docs/screenshots/example-thumbnail.jpgadded⋯ 1 unchanged line ⋯
docs/screenshots/narrate-page.jpgadded⋯ 1 unchanged line ⋯
docs/screenshots/picture-controls.jpgadded⋯ 1 unchanged line ⋯
docs/screenshots/result-board.jpgadded⋯ 1 unchanged line ⋯
docs/screenshots/result-drawing.jpgadded⋯ 1 unchanged line ⋯
docs/screenshots/result-picture.jpgadded⋯ 1 unchanged line ⋯
flows/flow.json+1−1@@ -70,7 +70,7 @@ "done": [ "publish.json" ],- "do": "With the human's OK: push the film, poster, captions and a video.json manifest (captions and chapters inlined) to the project's wiki page and put `::adom-video{src=...}` under the title; check the live player. For YouTube, suggest setting up adom-google (a YouTube-only account) if it is not there, then upload as PRIVATE first so they can watch it on YouTube itself (compression, thumbnail, captions, chapter markers); switch it to unlisted or public only when they say so, and delete it if they do not like it. `mark publish --note \"<links>\"`."+ "do": "With the human's OK: push the film, poster, captions and a video.json manifest (captions and chapters inlined) to the project's wiki page and put `::adom-video{src=...}` under the title; check the live player. For YouTube: brainstorm about five titles with them (`youtube title \"...\" \"...\"` gives feedback) and show two or three thumbnails composed from a frame of the film, a frame of them and 3 to 5 large words (`youtube thumbnail <file>` checks it); write a description with what the film shows, chapters (`m:ss Title` from 0:00, each at least 10 s) and a link back to the wiki page. Run `youtube check` to see which channel each adom-google account reaches, say the channel's name back to them, and if none reaches it suggest setting adom-google up (the hint says how) or upload through YouTube Studio in a browser you drive. `youtube upload ... --privacy private` (or unlisted) first, so they can watch it on YouTube itself; make it public only when they say so. `mark publish --note \"<links>\"`." }, { "name": "cleanup",
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow-video", "type": "app",- "version": "0.3.1",+ "version": "0.4.0", "title": "AI Flow: Video", "description": "Make a narrated walkthrough video of a board built in Adom Hydrogen, put it on the project's wiki page and help the human upload it to YouTube. A dynamic skill: a small Rust binary that suggests each step, records clips, ships a ready-made 3D walkthrough director and gives gentle feedback; the AI does the work. Open source (MIT): the full Rust source ships on this page; build it yourself with ./build.sh.", "category": "Developer Tools",
page.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow-video", "type": "app",- "version": "0.3.1",+ "version": "0.4.0", "title": "AI Flow: Video", "description": "Make a narrated walkthrough video of a board built in Adom Hydrogen, put it on the project's wiki page and help the human upload it to YouTube. A dynamic skill: a small Rust binary that suggests each step, records clips, ships a ready-made 3D walkthrough director and gives gentle feedback; the AI does the work. Open source (MIT): the full Rust source ships on this page; build it yourself with ./build.sh.", "category": "Developer Tools",
skills/adom-aiflow-video/SKILL.md+8@@ -115,3 +115,11 @@ A narration is only as good as the film it is spoken to: if the film pauses, the ## Keep the human posted on the page After they press Use this take they are looking at the narration page, not at your chat. The page says straight away that you have the take; from then on it is yours to keep current. Run `narrate note "Step 2 of 4: lining your recorder's sound up with the take"` as you start each stage (fetching their recorder's clip, lining it up, putting voice, picture and drawings on the film, writing captions) and again when the film is ready to watch, with how to watch it. A note that still says "Ready to record" while you are muxing reads as if nothing is happening.++## YouTube: the title, the thumbnail, the upload++The title and the thumbnail decide whether anyone presses play, so do them with the human, not for them. Offer about five titles, best first (`youtube title "A" "B" ...` gives feedback on each): what was made, for whom or why, and the tool, in words a person would search for. Compose two or three thumbnails at 1280 x 720: one clean frame of the finished board or product, a frame of the narrator from their camera take if they were on camera, and 3 to 5 large words that do not repeat the title; `youtube thumbnail <file>` checks size and weight. Let them choose. Adom's own thumbnail style and logo are for official Adom videos only (see `brand`).++Write the description in their voice: the hook in the first two lines, the link to the project's wiki page, what the film shows, chapters as `m:ss Title` lines starting at 0:00 with every chapter at least 10 seconds long, and what it was built with.++`youtube check` lists each adom-google account and the channel it uploads to. A person often has several channels (personal, family, company): ask which one, and say its name back before uploading. YouTube access cannot be requested together with other Google access, so it needs its own adom-google account, authorised with only the two YouTube scopes and with the right channel picked on Google's "Choose your account or a brand account" screen; the hint spells out the steps. If no account reaches their channel (no adom-google, or the Google app their account signs in with has the YouTube API switched off), tell them plainly, offer to set adom-google up, and otherwise upload through YouTube Studio in a browser you drive: Create, Upload videos, the file, title, description, thumbnail, Not made for kids, Visibility. Upload private or unlisted first with `youtube upload`; public is their call after they have watched it on YouTube.
src/main.rs+14@@ -9,6 +9,7 @@ mod out; mod checks; mod narrate; mod brand;+mod youtube; use clap::{Parser, Subcommand}; use out::{err, ok};@@ -55,6 +56,8 @@ enum Cmd { 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 = "private")] 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`@@ -176,6 +179,17 @@ fn main() { 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); }+ "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, h)) => ok(&m, &h), Err((m, h)) => err(&m, &h) }+ }+ _ => err("youtube takes: check, title, thumbnail, upload", &[]),+ }, 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) },
src/youtube.rsadded+93@@ -0,0 +1,93 @@+//! YouTube, for any Adom user: find out which channel an upload would land on, give feedback on the+//! title and the description, and upload through adom-google (private or unlisted first). When+//! adom-google is not set up, say how to set it up; when it cannot reach the channel, say why and+//! name the other way (YouTube Studio in a browser the AI drives).++use std::process::Command;++fn run(args: &[&str]) -> Option<(bool, String)> {+ Command::new("adom-google").args(args).output().ok().map(|o| (o.status.success(), format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))))+}++const SETUP: &str = "Set up adom-google for YouTube: `adom-wiki pkg install adom/adom-google`, then `adom-google --account youtube auth --url-file /tmp/yt.url`. Before opening the link, cut its `scope` down to youtube.upload and youtube only (YouTube access cannot be asked for together with other Google access), open it in a browser, and on Google's 'Choose your account or a brand account' screen pick the CHANNEL the film belongs on. Keep the auth command running until Allow is clicked.";+const STUDIO: &str = "The other way, when adom-google cannot reach their channel: sign in to studio.youtube.com in a browser you drive (a pup window keeps their login), press Create, Upload videos, give it the file, paste the title and description, add the thumbnail, set Visibility to Private or Unlisted, and Save. Slower, but it always works.";++/// Which channel each adom-google account uploads to.+pub fn check() -> (String, Vec<String>) {+ let Some((ok, accounts)) = run(&["accounts"]) else {+ return ("adom-google is not installed, so there is no direct way to upload yet".into(), vec![SETUP.into(), STUDIO.into()]);+ };+ if !ok { return ("adom-google is installed but not authorized".into(), vec![SETUP.into(), STUDIO.into()]); }+ let names: Vec<String> = accounts.lines().filter_map(|l| l.trim_start_matches('*').split_whitespace().next().map(str::to_string)).filter(|n| !n.is_empty() && !n.starts_with("Select") && n.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')).collect();+ let mut hints = vec![]; let mut found = 0;+ for n in &names {+ match run(&["--account", n, "youtube", "channels"]) {+ Some((true, out)) => { let first = out.lines().next().unwrap_or("").trim().to_string(); if first.is_empty() { hints.push(format!("account `{n}`: signed in, but that Google identity has no YouTube channel")); } else { found += 1; hints.push(format!("account `{n}` uploads to: {first}")); } }+ Some((false, out)) if out.contains("API not enabled") || out.contains("switched OFF") => hints.push(format!("account `{n}`: the YouTube API is switched off for the Google app this account signs in with, so it cannot upload. That is fixed by whoever runs that app (for Adom's own apps: [email protected]), not by the user.")),+ Some((false, out)) if out.contains("scope") => hints.push(format!("account `{n}`: no YouTube permission on this sign-in (it was made for other Google services)")),+ _ => hints.push(format!("account `{n}`: could not read its YouTube channel")),+ }+ }+ hints.push("Ask the human which channel the film belongs on and say the channel's name back to them before uploading. A person often has several (personal, family, company).".into());+ if found == 0 { hints.push(SETUP.into()); }+ hints.push(STUDIO.into());+ (format!("{found} of {} adom-google account(s) can upload to a YouTube channel", names.len()), hints)+}++/// Gentle feedback on title candidates: `youtube title "A" "B" ...`.+pub fn title(cands: &[String]) -> (String, Vec<String>) {+ let mut hints = vec![];+ let hype = ["insane", "mind-blowing", "you won't believe", "game changer", "revolutionary", "ultimate", "secret", "shocking"];+ for t in cands {+ let n = t.chars().count(); let low = t.to_lowercase(); let mut notes = vec![];+ if n > 60 { notes.push(format!("{n} characters: search results and phones cut titles near 55 to 60")); } else if n < 25 { notes.push(format!("{n} characters: there is room to say what was made")); } else { notes.push(format!("{n} characters: fits")); }+ if t.contains('|') || t.contains(" - ") { notes.push("drop the separator and the channel or brand name: YouTube already shows the channel".into()); }+ if t.ends_with('!') || t.ends_with('.') { notes.push("no closing punctuation".into()); }+ if hype.iter().any(|h| low.contains(h)) { notes.push("hype words read as clickbait; say the concrete thing instead".into()); }+ if t.chars().filter(|c| c.is_uppercase()).count() > n / 2 { notes.push("all caps reads as shouting".into()); }+ if !low.split_whitespace().take(4).any(|w| ["ai", "pcb", "board", "i", "my", "how", "building", "designing"].contains(&w.trim_matches(|c: char| !c.is_alphanumeric()))) { notes.push("put the searched-for words (AI, PCB, the thing made, the tool) near the front".into()); }+ hints.push(format!("\"{t}\": {}", notes.join("; ")));+ }+ hints.push("Offer the human about five titles, best first, and let them pick or reword: say what was made, for whom or why, and the tool, in the words a person would search for. A personal story (\"for my daughter's dorm\") beats a product name nobody searches for yet.".into());+ hints.push("The thumbnail and the title work as a pair: the thumbnail carries 3 to 5 big words and one picture (the finished board or the person), the title carries the rest. Do not repeat the title in the thumbnail.".into());+ (format!("{} title(s) looked at", cands.len()), hints)+}++/// A thumbnail that YouTube will take: 1280 x 720 (or larger 16:9), JPG or PNG, under 2 MB.+pub fn thumbnail(file: &str) -> (String, Vec<String>) {+ let probe = Command::new("ffprobe").args(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", file]).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();+ let size = std::fs::metadata(file).map(|m| m.len()).unwrap_or(0);+ let (w, h) = probe.split_once(',').map(|(a, b)| (a.parse::<u32>().unwrap_or(0), b.parse::<u32>().unwrap_or(0))).unwrap_or((0, 0));+ let mut hints = vec![];+ if w == 0 { hints.push("Could not read the picture: make a 1280 x 720 JPG or PNG.".into()); }+ else { if w * 9 != h * 16 { hints.push(format!("{w} x {h} is not 16:9: YouTube will add bars or crop it; 1280 x 720 is the standard.")); } if w < 1280 { hints.push(format!("{w} px wide is small: 1280 x 720 or larger stays sharp.")); } }+ if size > 2_000_000 { hints.push(format!("{:.1} MB is over YouTube's 2 MB limit: save it as a JPG at a lower quality.", size as f64 / 1e6)); }+ hints.push("How to compose one: take a clean frame of the finished board or product from the film (`ffmpeg -ss <t> -i film.mp4 -frames:v 1 frame.png`), a frame of the narrator from their camera take if they were on camera, and 3 to 5 large words on the left. Keep faces and words out of the bottom-right corner (the duration badge) and the bottom-left (link previews on social sites cover it). View it at 320 px wide: if the words are not readable there, they are too small.".into());+ hints.push("Show the human two or three versions and let them choose. It is their face and their channel.".into());+ (format!("thumbnail {w} x {h}, {:.2} MB", size as f64 / 1e6), hints)+}++pub struct Upload<'a> { pub file: &'a str, pub title: &'a str, pub description_file: Option<&'a str>, pub thumbnail: Option<&'a str>, pub privacy: &'a str, pub account: Option<&'a str>, pub tags: Option<&'a str> }++pub fn upload(u: &Upload) -> Result<(String, Vec<String>), (String, Vec<String>)> {+ if !std::path::Path::new(u.file).is_file() { return Err((format!("no film at {}", u.file), vec![])); }+ let desc = match u.description_file { Some(f) => std::fs::read_to_string(f).map_err(|e| (format!("could not read {f}: {e}"), vec![]))?, None => String::new() };+ let mut args: Vec<String> = vec![];+ if let Some(a) = u.account { args.push("--account".into()); args.push(a.into()); }+ args.extend(["youtube", "upload", u.file, "--title", u.title, "--description", &desc, "--privacy", u.privacy, "--category", "28", "--json"].iter().map(|s| s.to_string()));+ if let Some(t) = u.thumbnail { args.push("--thumbnail".into()); args.push(t.into()); }+ if let Some(t) = u.tags { args.push("--tags".into()); args.push(t.into()); }+ let refs: Vec<&str> = args.iter().map(String::as_str).collect();+ match run(&refs) {+ None => Err(("adom-google is not installed".into(), vec![SETUP.into(), STUDIO.into()])),+ Some((false, out)) => Err(("the upload did not go through".into(), vec![out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("").chars().take(300).collect(), "Run `youtube check` to see which channel each account reaches.".into(), STUDIO.into()])),+ Some((true, out)) => {+ let url = out.split('"').find(|s| s.contains("youtube.com/watch") || s.contains("youtu.be/")).unwrap_or("").to_string();+ let mut hints = vec![format!("It is {}: only people with the link can find it. Give the human the link and the channel name, and have them watch it on YouTube itself (picture quality, thumbnail, captions, chapter markers) before anything is made public.", u.privacy)];+ if u.privacy == "public" { hints[0] = "It is public. Next time upload as private or unlisted first so the human can check it on YouTube before the world sees it.".into(); }+ hints.push("Chapters come from the description: a list of `m:ss Title` lines that starts at 0:00, with every chapter at least 10 seconds long. Captions: upload the film's .srt in YouTube Studio (Subtitles), or leave YouTube's automatic ones.".into());+ hints.push("Then `mark publish --note \"<link>\"`.".into());+ Ok((format!("uploaded: {}", if url.is_empty() { out.lines().last().unwrap_or("").to_string() } else { url }), hints))+ }+ }+}