app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
← Commit history
AI Flow 0.2.53: the final video is sharp 1080p; the 10 MB limit is gone
Co-Authored-By: Claude Opus 5.5 <[email protected]>
7 files changed
+75−31
Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.52"+version = "0.2.53" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.52"+version = "0.2.53" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.52"+version = "0.2.53" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.52"+version = "0.2.53" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.52"+version = "0.2.53" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.52"+version = "0.2.53" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.52"+version = "0.2.53" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.52"+version = "0.2.53" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.52"+version = "0.2.53" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.52"+version = "0.2.53" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.52"+version = "0.2.53" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
crates/adom-aiflow/src/main.rs+45−17@@ -1178,23 +1178,26 @@ fn usage_line(r: &Run) -> String { fn overlay_filters(dir: &Path, title: &str, engine_board: &str, offset: f64, step_offset: f64, run_has_hours: bool) -> String { let (font_display, font_text) = run_fonts(dir); let off = format!("{offset:.0}");- let m = 16.0; let big = 26.0; let small = 11.0; let lab = 13.0; let pad = 12.0; let gap = 12.0; let colw = 38.0;- let h_box = pad + 16.0 + 6.0 + 14.0 + 6.0 + big + pad;+ let k = OVERLAY_SCALE;+ let (m, big, small, lab, pad, gap, colw) = (16.0 * k, 26.0 * k, 11.0 * k, 13.0 * k, 12.0 * k, 12.0 * k, 38.0 * k);+ let (hdr_h, lab_h, line) = (16.0 * k, 14.0 * k, 6.0 * k);+ let h_box = pad + hdr_h + line + lab_h + line + big + pad; let run_secs = format!("(t+{off})"); let (run_cols, run_w): (Vec<(String, String)>, f64) = if run_has_hours { (vec![("HR".into(), format!("%{{eif\\:{run_secs}/3600\\:d}}")), ("MIN".into(), format!("%{{eif\\:mod({run_secs}/60\\,60)\\:d\\:2}}")), ("SEC".into(), format!("%{{eif\\:mod({run_secs}\\,60)\\:d\\:2}}"))], 3.0 * colw + 2.0 * gap) } else { (vec![("MIN".into(), format!("%{{eif\\:{run_secs}/60\\:d}}")), ("SEC".into(), format!("%{{eif\\:mod({run_secs}\\,60)\\:d\\:2}}"))], 2.0 * colw + gap) };- let run_inner = run_w.max(162.0);+ let run_inner = run_w.max(162.0 * k); let step_secs = format!("(t+{step_offset:.0})"); let step_cols: Vec<(String, String)> = vec![("MIN".into(), format!("%{{eif\\:{step_secs}/60\\:d}}")), ("SEC".into(), format!("%{{eif\\:mod({step_secs}\\,60)\\:d\\:2}}"))];- let step_inner = (2.0 * colw + gap).max(62.0);- let mut ov: Vec<String> = Vec::new();+ let step_inner = (2.0 * colw + gap).max(62.0 * k);+ // every frame is brought to one width first, so the overlays are the same size on every clip+ let mut ov: Vec<String> = vec![format!("scale={FRAME_W}:-2")]; let run_box_w = run_inner + 2.0 * pad; let step_box_w = step_inner + 2.0 * pad; ov.push(format!("drawbox=x=iw-{}:y=ih-{}:w={run_box_w}:h={h_box}:[email protected]:t=fill", m + run_box_w, m + h_box)); ov.push(format!("drawbox=x=iw-{}:y=ih-{}:w={step_box_w}:h={h_box}:[email protected]:t=fill", m + run_box_w + m + step_box_w, m + h_box));- let y_lab = format!("h-{}", m + h_box - pad); let y_hdr = format!("h-{}", m + h_box - pad - 16.0 - 6.0); let y_num = format!("h-{}", m + h_box - pad - 16.0 - 6.0 - 14.0 - 6.0);+ let y_lab = format!("h-{}", m + h_box - pad); let y_hdr = format!("h-{}", m + h_box - pad - hdr_h - line); let y_num = format!("h-{}", m + h_box - pad - hdr_h - line - lab_h - line); let run_left = m + pad + run_inner; let step_left = m + run_box_w + m + pad + step_inner; ov.push(format!("drawtext=fontfile={ft}:text='AI TIME, THIS RUN':x=w-{run_left}:y={y_lab}:fontsize={lab}:fontcolor=0xDB8B58", ft = font_text)); ov.push(format!("drawtext=fontfile={ft}:text='THIS STEP':x=w-{step_left}:y={y_lab}:fontsize={lab}:fontcolor=0xDB8B58", ft = font_text));@@ -1208,11 +1211,21 @@ fn overlay_filters(dir: &Path, title: &str, engine_board: &str, offset: f64, ste ov.push(format!("drawtext=fontfile={ft}:text='{hdr}':x={cx}-tw/2:y={y_hdr}:fontsize={small}:fontcolor=0xA0AFBE", ft = font_text)); ov.push(format!("drawtext=fontfile={fd}:text='{expr}':x={cx}-tw/2:y={y_num}:fontsize={big}:fontcolor=0xFFFFFF", fd = font_display)); }- ov.push(format!("drawtext=fontfile={fd}:text='{}':x={m}:y={m}:fontsize=24:fontcolor=0xF2E9E1:box=1:[email protected]:boxborderw=10", dt(title), fd = font_display));- ov.push(format!("drawtext=fontfile={ft}:text='{}':x=w-tw-{m}:y={m}:fontsize=15:fontcolor=0xD9E1E8:box=1:[email protected]:boxborderw=8", dt(engine_board), ft = font_text));+ ov.push(format!("drawtext=fontfile={fd}:text='{}':x={m}:y={m}:fontsize={}:fontcolor=0xF2E9E1:box=1:[email protected]:boxborderw={}", dt(title), 24.0 * k, (10.0 * k) as u32, fd = font_display));+ ov.push(format!("drawtext=fontfile={ft}:text='{}':x=w-tw-{m}:y={m}:fontsize={}:fontcolor=0xD9E1E8:box=1:[email protected]:boxborderw={}", dt(engine_board), 15.0 * k, (8.0 * k) as u32, ft = font_text)); ov.join(",") } +/// The size every clip cut is kept at: 1080p wide, never enlarged. The final video is 1920 x 1080, so+/// a smaller cut leaves its text soft however many bits the video gets (issue #146).+const CUT_SCALE: &str = "scale='min(1920,iw)':-2";+/// The width overlays are drawn on, and their sizes against the 1280 they were designed on.+const FRAME_W: u32 = 1920;+const OVERLAY_SCALE: f64 = 1.5;+/// The final video is encoded for quality and stays this size only if it would not fit on a wiki page,+/// which refuses files over about 100 MB (issue #146: a 10 MB limit left a three-minute video's text unreadable).+const VIDEO_CEILING_BYTES: u64 = 90_000_000;+ /// The action cut with overlays: the 10x cut stretched back to real time (so the clocks are /// honest), near-duplicate frames dropped, the overlays drawn, then the kept frames packed at /// `rate` frames per second (4 is the pace of one step's clip; compose raises it to fit the budget).@@ -1559,7 +1572,7 @@ fn clip_artifacts_with(r: &mut Run, step: &Value, file: &str, speed: u32, tools: // of both cuts (never more than five seconds; longer black is a real clip problem, kept) let lead = leading_black(file); let ss = format!("{lead:.3}");- let cut = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-ss", &ss, "-i", file, "-an", "-vf", &format!("setpts=PTS/{speed},scale=1280:-2"), "-r", "30", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", &out]).status().map(|s| s.success()).unwrap_or(false);+ let cut = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-ss", &ss, "-i", file, "-an", "-vf", &format!("setpts=PTS/{speed},{CUT_SCALE}"), "-r", "30", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", &out]).status().map(|s| s.success()).unwrap_or(false); if lead > 0.0 { r.log("marker", json!({"label": format!("clip {tag}: {lead:.2} s of black recorder startup cut off the head of its cuts")})); } if cut { let fast = media_seconds(&out);@@ -1702,7 +1715,7 @@ fn compose_run(r: &mut Run, dir: &Path, outp: &Path, max_seconds: f64, still_sec let (vlen, tiers): (f64, Vec<String>) = match piece { Piece::Library{file,caption,at,seconds}=>{ let offset=r.active_offset(*at) as f64;- let vf=format!("scale=1280:-2,{},{fit}",overlay_filters(dir,"Component library",&engine_board,offset,0.0,run_has_hours));+ let vf=format!("{},{fit}",overlay_filters(dir,"Component library",&engine_board,offset,0.0,run_has_hours)); let good=ffmpeg_ok(std::process::Command::new("ffmpeg").args(["-v","error","-y","-i",file,"-t",&format!("{seconds:.3}"),"-vf",&vf,"-r","30","-an","-pix_fmt","yuv420p","-c:v","libx264","-preset","veryfast","-crf","20",seg.to_str().unwrap()])); if !good{err("component overview failed to render", &["Check the reviewed clip, then compose again.".into()]);} n_clips+=1;(media_seconds(seg.to_str().unwrap()),vec![caption.clone(),"The reviewed component library.".into()])@@ -1734,7 +1747,7 @@ fn compose_run(r: &mut Run, dir: &Path, outp: &Path, max_seconds: f64, still_sec let step_offset = r.active_between(visit_start(step, *visit), *at) as f64; let title = format!("{} · the AI's drawing", step_shown(step)); // the drawing is shown at the clips' scale so the overlays match, then fit to 1080p- let vf = format!("scale=1280:-2,{},{fit}", overlay_filters(dir, &title, &engine_board, offset, step_offset, run_has_hours));+ let vf = format!("{},{fit}", overlay_filters(dir, &title, &engine_board, offset, step_offset, run_has_hours)); let oks = ffmpeg_ok(std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-loop", "1", "-framerate", "4", "-t", &format!("{still_seconds:.2}"), "-i", file, "-vf", &vf, "-r", "30", "-pix_fmt", "yuv420p", "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()])); if !oks { eprintln!("still {file}: could not be rendered; skipped"); continue; } let vlen = media_seconds(seg.to_str().unwrap());@@ -1752,8 +1765,7 @@ fn compose_run(r: &mut Run, dir: &Path, outp: &Path, max_seconds: f64, still_sec if oka { parts.push((seg, aseg)); } } if parts.is_empty() { compose_fail("nothing to compose: every segment failed to render", outp); }- // the page takes files up to 10 MB: when the cut is bigger, assemble re-encodes it once at the- // bitrate that fits (screen captures are mostly still, so the picture survives it)+ // a wiki page refuses files over about 100 MB: only a cut past the ceiling is re-encoded to fit assemble(&workdir, &parts, outp); let secs = media_seconds(outp.to_str().unwrap()); if secs > max_seconds + 2.0 { eprintln!("WARNING the video runs {secs:.0} s, over the {max_seconds:.0} s cap; the pace ({rate:.1} frames per second) did not hold"); }@@ -2020,7 +2032,7 @@ fn compose_pair(a: &mut Run, b: &Run, dir: &Path, outp: &Path, max_seconds: f64, let col = workdir.join(format!("still-{si}-{k}-{side}.mp4")); let okc = match d { Some((file, _, off)) => {- let vf = format!("scale=1280:-2,{},{fit_col}", overlay_filters(dir, &format!("{} · the AI's drawing", step_shown(step)), &format!("{name} · {board}"), *off, 0.0, hours));+ let vf = format!("{},{fit_col}", overlay_filters(dir, &format!("{} · the AI's drawing", step_shown(step)), &format!("{name} · {board}"), *off, 0.0, hours)); run_ffmpeg(&["-loop", "1", "-framerate", "4", "-t", &format!("{still_len:.2}"), "-i", file, "-vf", &vf, "-r", "30", "-pix_fmt", "yuv420p", "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]) } None => run_ffmpeg(&["-f", "lavfi", "-i", &format!("color=c=0x0B1420:s={cw}x{ch}:r=30:d={still_len:.3}"), "-vf", &format!("drawtext=fontfile={ft}:text='{}':x=(w-tw)/2:y=(h-th)/2:fontsize=22:fontcolor=0xA0AFBE", dt(&format!("{name}: no drawing for this step"))), "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]),@@ -2055,7 +2067,7 @@ fn audio_for(workdir: &Path, k: usize, tiers: &[String], vlen: f64, has_tts: boo } } -/// Concatenate the segments with their narration into the final file, re-encoded under the page's 10 MB cap when needed.+/// Concatenate the segments with their narration into the final file, re-encoded only when it is past VIDEO_CEILING_BYTES. fn assemble(workdir: &Path, parts: &[(PathBuf, PathBuf)], outp: &Path) { let vlist = workdir.join("video.txt"); let alist = workdir.join("audio.txt");@@ -2067,14 +2079,14 @@ fn assemble(workdir: &Path, parts: &[(PathBuf, PathBuf)], outp: &Path) { let oka = run_ffmpeg(&["-f", "concat", "-safe", "0", "-i", alist.to_str().unwrap(), "-c", "copy", acat.to_str().unwrap()]); let okf = okv && oka && run_ffmpeg(&["-i", vcat.to_str().unwrap(), "-i", acat.to_str().unwrap(), "-c:v", "copy", "-c:a", "aac", "-shortest", outp.to_str().unwrap()]); if !okf { compose_fail("ffmpeg could not assemble the video and its narration", outp); }- let cap_bytes: u64 = 9_800_000;+ let cap_bytes = VIDEO_CEILING_BYTES; if std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0) > cap_bytes { let secs = media_seconds(outp.to_str().unwrap()).max(1.0); let kbps = (((cap_bytes as f64 * 8.0 / secs) / 1000.0) - 140.0).max(300.0).floor(); let fitted = workdir.join("fitted.mp4"); if run_ffmpeg(&["-i", outp.to_str().unwrap(), "-c:v", "libx264", "-preset", "slow", "-b:v", &format!("{kbps:.0}k"), "-maxrate", &format!("{:.0}k", kbps * 1.3), "-bufsize", &format!("{:.0}k", kbps * 2.0), "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", fitted.to_str().unwrap()]) { let _ = std::fs::copy(&fitted, outp);- eprintln!("the cut was over the page's 10 MB cap; re-encoded at {kbps:.0} kbit/s to {} bytes", std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0));+ eprintln!("the cut was over the {} MB ceiling; re-encoded at {kbps:.0} kbit/s to {} bytes", cap_bytes / 1_000_000, std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0)); } } }@@ -6653,6 +6665,22 @@ mod media_tools_tests { mod compose_out_tests { use super::*; + /// Issue #146: the final video is 1080p, so nothing on the way to it may be smaller, the overlays+ /// keep their proportions on the bigger frame, and only a file a page would refuse is squeezed.+ #[test]+ fn the_video_is_kept_at_1080p_and_its_overlays_keep_their_size() {+ assert_eq!(CUT_SCALE, "scale='min(1920,iw)':-2", "a cut is never enlarged and never below the video's width");+ assert!(VIDEO_CEILING_BYTES > 50_000_000 && VIDEO_CEILING_BYTES < 100_000_000, "under the wiki's limit, far over the old 10 MB");+ let d = std::env::temp_dir().join(format!("aiflow-146-{}", std::process::id()));+ std::fs::create_dir_all(&d).unwrap();+ let f = overlay_filters(&d, "ROUTING", "claude · board", 0.0, 0.0, false);+ assert!(f.starts_with("scale=1920:-2,"), "{}", &f[..40]);+ assert_eq!(FRAME_W as f64 / 1280.0, OVERLAY_SCALE, "the overlays were laid out on a 1280 frame");+ assert!(f.contains("fontsize=36:") && f.contains("fontsize=22.5:") && f.contains("boxborderw=15"), "title and label sizes scale with the frame: {f}");+ assert!(!f.contains("fontsize=24:"));+ let _ = std::fs::remove_dir_all(&d);+ }+ #[test] fn out_resolves_against_the_working_directory_and_defaults_into_the_run() { let (run, cwd) = (Path::new("/p/aiflow/run-1mm"), Path::new("/p/aiflow"));
docs/release-0.2.53.mdadded+16@@ -0,0 +1,16 @@+# AI Flow 0.2.53 (2026-11-05)++**The final video is sharp 1080p (#146).** It came out soft for two reasons: the clip cuts were 1280 wide, and `compose` squeezed any video over 10 MB to fit, which left about 265 kbit/s on a three-minute video. The wiki doesn't have a 10 MB limit.++- The 10x and action cuts are now up to 1920 wide, and a smaller recording isn't enlarged.+- The video is encoded for quality. Only a file over 90 MB, near the 100 MB limit on one upload, is re-encoded to fit.+- The overlays keep the size and place they had, because they now scale with the frame.+- Cuts from an older run still compose. `recut` makes them again at the new size if the raw clips are still there.++**Files get bigger.** A real run's 117 s video went from 7.6 MB to 37.5 MB. The 10x cuts about double: 12.5 MB to 23.9 MB on that run's largest recording. A run page or clips page that pushes its cuts grows too.++**Checked:** that run composed from start to finish with the old build and this one, with three of its recordings cut again at the new size. Frames from the old and new videos were compared by eye. Before the change, one real clip was also compared at several bitrates: at the old limit its labels were unreadable.++**Not checked:** pushing a video over about 13 MB to a wiki page. The 100 MB figure comes from the wiki's own docs. A real run page with the larger video and a side-by-side comparison video (`compose --with`) haven't been run on this build.++**Not in this release:** the Fields viewer's spoken explanations in the final video. The recorder captures video only, so #146 stays open for that.
docs/video.md+1−1@@ -23,7 +23,7 @@ Window capture writes H.264 from the box's GPU encoder as it records (no raw fra ## The output spec -The final video is 1920 x 1080, 16:9, 30 fps, H.264 (yuv420p) with AAC audio, whatever the recorded window was. A clip is scaled to fit with its aspect kept and letterboxed or pillarboxed on the dark ground (0x0B1420); it is never stretched. The per-step cuts keep the window's own shape (they are evidence); `compose` normalises them.+The final video is 1920 x 1080, 16:9, 30 fps, H.264 (yuv420p) with AAC audio, whatever the recorded window was. A clip is scaled to fit with its aspect kept and letterboxed or pillarboxed on the dark ground (0x0B1420); it is never stretched. The per-step cuts keep the window's own shape (they are evidence); `compose` normalises them. The 10x and action cuts are kept at up to 1920 wide, so text stays sharp; a cut made before 0.2.53 is 1280 wide, and `recut` makes it again from the raw clip. The video is encoded for quality, not to a size: only a file over 90 MB, close to the roughly 100 MB a wiki page refuses, is re-encoded to fit. ## Plan usage is per account
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.52",+ "version": "0.2.53", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.52",+ "version": "0.2.53", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",