← Commit history
Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow-video"-version = "0.2.2"+version = "0.2.3" 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.2.2"+version = "0.2.3" edition = "2021" license = "MIT" 
README.md+6−1
@@ -47,10 +47,15 @@ init [--release <board-release.json>] [--target <machine>]    next    steps    l 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>] ```  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). +## Branding++Your film is yours: it carries no Adom logo. The Adom logo sting is only for official Adom videos made by Adom employees, and `brand` tells the AI which case applies before it offers one.+ ## Status -0.2.2. 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.2.3. 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.
bin/adom-aiflow-video
⋯ 1 unchanged line ⋯
flows/flow.json+2−2
@@ -7,7 +7,7 @@    "done": [     "state.json"    ],-   "do": "Ask the human once, in plain words: do they want a narrated video of this build, ready to review when they are back? Tell them recording takes over the screen of the machine it records, and suggest a second machine on their Adom Mesh so their own stays free. Run `disk` on the recording machine and share the estimate. Then `init` (with `--release <board-release.json>` when the board flow wrote one, `--target <machine>` for the recording machine)."+   "do": "Ask the human once, in plain words: do they want a narrated video of this build, ready to review when they are back? Tell them recording takes over the screen of the machine it records, and suggest a second machine on their Adom Mesh so their own stays free. Run `disk` on the recording machine and share the estimate. Then `init` (with `--release <board-release.json>` when the board flow wrote one, `--target <machine>` for the recording machine). Run `brand` too: it says whether this account may put the Adom logo sting on an official Adom video (Adom employees only); if it may, ask the human whether this film is one."   },   {    "name": "plan",@@ -49,7 +49,7 @@    "done": [     "voice.json"    ],-   "do": "Offer the human two ways to finish the sound, and let them pick. (1) Keep the AI voiceover and go on. (2) Narrate it themselves: `narrate --video <draft.mp4>` serves a page where they calibrate their microphone, rehearse, record their own words while the film plays (with a clock, a wrap-up warning and drawing on the film), review it and press Use this take. Run `narrate wait` for the hand-off, tell them on the page what you are doing (`narrate note \"...\"`), then put their voice on the film, write new captions and chapters from what they said and draw their strokes in. A recorder of their own (a wireless mic that records on the device, a phone) works too. `mark voice --note \"ai voiceover\"` or `mark voice --note \"own narration\"`."+   "do": "Offer the human two ways to finish the sound, and let them pick. (1) Keep the AI voiceover and go on. (2) Narrate it themselves: `narrate --video <draft.mp4>` serves a page where they calibrate their microphone, rehearse, record their own words while the film plays (with a clock, a wrap-up warning and drawing on the film), review it and press Use this take. Run `narrate wait` for the hand-off, tell them on the page what you are doing (`narrate note \"...\"`), then put their voice on the film, write new captions and chapters from what they said and draw their strokes in. A recorder of their own (a wireless mic that records on the device, a phone) works too. `mark voice --note \"ai voiceover\"` or `mark voice --note \"own narration\"`. When `brand` said yes at intake and the human said this is an official Adom video, finish the sound with `brand --video <final.mp4> --out <branded.mp4>`; otherwise the film carries no Adom branding."   },   {    "name": "privacy",
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow-video",   "type": "app",-  "version": "0.2.2",+  "version": "0.2.3",   "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.2.2",+  "version": "0.2.3",   "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+1−1
@@ -31,7 +31,7 @@ Before recording, tell the human three things: it needs disk space (`disk` gives  ## What makes the film good -- **No title cards, no logo stings.** Open on the prompt being typed. Every shot is real software: Hydrogen full screen with the Windows taskbar showing, KiCad, the 3D viewer or Fields floating at about 90 percent over it.+- **No title cards, and Adom branding only on official Adom videos.** Run `adom-aiflow-video brand` once at intake. For an Adom employee it says the film can carry the Adom logo sting: ask them once whether this is an official Adom video, and if it is, keep the first 5 seconds free of speech and finish with `brand --video <final.mp4> --out <branded.mp4>` after the voice is on (transparent sting over the opening shot, black-background sting after the last line). For everyone else, and for an employee's personal film, add no Adom logo, sting, sound or end card: it would pass for an official Adom video. Saying it was made in Adom Hydrogen is always fine. - **Highlights only.** 5 to 30 s per moment, motion in every clip. Leave AI Flow's full-length step recording off. - **No dead air and no flashed shots.** Narration sets the length; footage plays at 1x to 2x. 3D plays at 1x. - **The 3D walkthrough** (docs/walkthrough.md, assets/walkthrough-director.js): the enclosure part by part, then the board top and underside, each part highlighted, labelled and described. Finish the board first: routed, poured, checked, with good silkscreen.
src/brand.rsadded+79
@@ -0,0 +1,79 @@+//! Adom's own logo sting, for official Adom videos only.+//! The brand kit lives on a wiki page only members of the Adom org can read, so being able to+//! read it is the test: an Adom employee gets the sting offered (and applied on request), and+//! everyone else's film stays free of Adom branding, because a logo on it would make it look+//! like an official Adom video.++use std::path::{Path, PathBuf};+use std::process::Command;++const KIT: &str = "adom/adom-video-brand";+const OVERLAY: &str = "media/alpha/logo-sting-teal-1080p.mov";+const CLOSER: &str = "media/dark-intro-16x9.mp4";++fn cache() -> PathBuf {+    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());+    PathBuf::from(home).join(".adom/adom-aiflow-video/brand")+}++/// True when this account can read the Adom brand kit (an Adom org member).+pub fn is_adom_member() -> bool {+    Command::new("adom-wiki").args(["repo", "show", KIT, "page.json"]).output().map(|o| o.status.success()).unwrap_or(false)+}++fn kit() -> Result<PathBuf, String> {+    let dir = cache();+    let page = dir.join("adom-video-brand");+    if page.join(OVERLAY).exists() && page.join(CLOSER).exists() { return Ok(page); }+    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;+    let _ = std::fs::remove_dir_all(&page);+    let o = Command::new("adom-wiki").args(["repo", "clone", KIT]).current_dir(&dir).output().map_err(|e| e.to_string())?;+    if !o.status.success() || !page.join(OVERLAY).exists() { return Err("could not fetch the Adom brand kit".into()); }+    Ok(page)+}++fn has_audio(f: &str) -> bool {+    Command::new("ffprobe").args(["-v", "error", "-select_streams", "a", "-show_entries", "stream=index", "-of", "csv=p=0", f]).output()+        .map(|o| !String::from_utf8_lossy(&o.stdout).trim().is_empty()).unwrap_or(false)+}++pub fn status() -> (String, Vec<String>) {+    if is_adom_member() {+        ("this account is in the Adom org: an official Adom film can carry the Adom logo sting".into(), vec![+            "Ask the human once whether this is an official Adom video. If it is, plan the script so nobody speaks during the first 5 seconds, and finish with `brand --video <final.mp4> --out <branded.mp4>` after the voice is on.".into(),+            "That puts the transparent logo sting over the film's own first 5 seconds (with its sound) and the black-background sting after the last line. Captions and chapters keep their times.".into(),+            "A personal film by the same person gets no Adom branding: leave it out when they say it is not an Adom video.".into(),+        ])+    } else {+        ("no Adom branding for this film".into(), vec![+            "Adom's logo, sting, sound and end card are for official Adom videos made by Adom employees. Leave them out of this film; saying it was made in Adom Hydrogen is fine.".into(),+        ])+    }+}++pub fn apply(video: &str, out: &str) -> Result<(String, Vec<String>), (String, Vec<String>)> {+    if !is_adom_member() {+        return Ok(("left the film as it is: no Adom branding".into(), vec!["Adom's logo sting is for official Adom videos made by Adom employees. Use the film without it.".into()]));+    }+    if !Path::new(video).exists() { return Err((format!("no film at {video}"), vec![])); }+    let page = kit().map_err(|e| (e, vec!["Check `adom-wiki repo show adom/adom-video-brand page.json`, then try again.".to_string()]))?;+    let (ov, cl) = (page.join(OVERLAY), page.join(CLOSER));+    let a0 = if has_audio(video) { "[0:a]aresample=48000,aformat=channel_layouts=stereo[a0];".to_string() }+             else { "anullsrc=r=48000:cl=stereo[an];[an]atrim=0:5[a0];".to_string() };+    let amix = if has_audio(video) { "[a0][a1]amix=inputs=2:duration=first:normalize=0[ma];" } else { "[a0][a1]amix=inputs=2:duration=longest:normalize=0[ma];" };+    let fc = format!(+        "[0:v]fps=30,scale=1920:1080:flags=lanczos,setsar=1[m];[1:v]format=yuva444p10le,scale=1920:1080[s];\+         [m][s]overlay=0:0:eof_action=pass:format=auto,format=yuv420p[mv];[2:v]scale=1920:1080,fps=30,setsar=1,format=yuv420p[e];\+         [mv][e]concat=n=2:v=1:a=0[v];{a0}[1:a]aresample=48000,aformat=channel_layouts=stereo[a1];{amix}\+         [2:a]aresample=48000,aformat=channel_layouts=stereo[ea];[ma][ea]concat=n=2:v=0:a=1[a]");+    let o = Command::new("ffmpeg").args(["-nostdin", "-v", "error", "-y", "-i", video, "-i"]).arg(&ov).arg("-i").arg(&cl)+        .args(["-filter_complex", &fc, "-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-g", "30",+               "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", out]).output().map_err(|e| (format!("could not run ffmpeg: {e}"), vec![]))?;+    if !o.status.success() {+        return Err(("ffmpeg could not add the sting".into(), vec![String::from_utf8_lossy(&o.stderr).lines().last().unwrap_or("").to_string()]));+    }+    Ok((format!("Adom logo sting added: {out}"), vec![+        "Overlay sting on the first 5 seconds, black-background sting after the last frame (5 seconds longer). Captions and chapters keep their times.".into(),+        "Watch the first and last 10 seconds before showing the human: the logo should not sit on top of speech.".into(),+    ]))+}
src/main.rs+10
@@ -8,6 +8,7 @@ mod ledger; mod out; mod checks; mod narrate;+mod brand;  use clap::{Parser, Subcommand}; use out::{err, ok};@@ -54,6 +55,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 },+    /// 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 },     /// Print the walkthrough guide: the steps file, how to record a take, and what we learned@@ -171,6 +174,13 @@ fn main() {             let (msg, hints) = checks::disk(&dir, *clips, *seconds);             ok(&msg, &hints);         }+        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);