app
Adom Video Post-Production
Public Made by Adomby adom
AI-driven studio for finishing demo videos: review every captured clip, flag and re-record the weak ones, speed up dead air, narrate, mux and auto-level, validate the final cut, and publish straight to the wiki, driven end to end by your AI.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190
//! adom-video-post: post-process screen recordings with speedup overlays.
//!
//! See SKILL.md for the full feature description.
mod app;
mod ctrl;
mod ffmpeg;
mod install;
mod markers;
mod publish;
mod storyboard;
mod voiceover;
mod webapp;
use clap::{Args, Parser, Subcommand};
use std::io::IsTerminal;
use std::path::PathBuf;
use std::process::ExitCode;
// ANSI color constants. The internal status prints (filter graph
// info, etc.) use these directly in format strings because those are
// always terminal output. The four USER-FACING helpers below (ok, err,
// hint, warn) are gated on isatty so when the CLI is called via a pipe
// or from a shell tool, the "OK:" / "ERROR:" / "Hint:" / "WARN:" lines
// emit plain text without ANSI escape soup.
use std::sync::OnceLock;
static STDOUT_IS_TTY: OnceLock<bool> = OnceLock::new();
static STDERR_IS_TTY: OnceLock<bool> = OnceLock::new();
fn stdout_is_tty() -> bool { *STDOUT_IS_TTY.get_or_init(|| std::io::stdout().is_terminal()) }
fn stderr_is_tty() -> bool { *STDERR_IS_TTY.get_or_init(|| std::io::stderr().is_terminal()) }
const GREEN: &str = "\x1b[32m";
const RED: &str = "\x1b[31m";
const YELLOW: &str = "\x1b[33m";
const CYAN: &str = "\x1b[36m";
const BOLD: &str = "\x1b[1m";
const DIM: &str = "\x1b[2m";
const RESET: &str = "\x1b[0m";
/// Strip ANSI escape sequences from a string. Used when emitting to a
/// non-TTY stdout/stderr so callers that embed {CYAN}...{RESET} inside
/// their message format strings don't leak escape codes.
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' && chars.peek() == Some(&'[') {
chars.next(); // consume '['
for c2 in chars.by_ref() {
if c2.is_ascii_alphabetic() { break; }
}
continue;
}
out.push(c);
}
out
}
fn ok(msg: impl std::fmt::Display) {
if stdout_is_tty() {
println!("{GREEN}{BOLD}OK:{RESET} {msg}");
} else {
println!("OK: {}", strip_ansi(&msg.to_string()));
}
}
fn err(msg: impl std::fmt::Display) {
if stderr_is_tty() {
eprintln!("{RED}{BOLD}ERROR:{RESET} {msg}");
} else {
eprintln!("ERROR: {}", strip_ansi(&msg.to_string()));
}
}
fn hint(msg: impl std::fmt::Display) {
if stderr_is_tty() {
eprintln!("{DIM}Hint:{RESET} {msg}");
} else {
eprintln!("Hint: {}", strip_ansi(&msg.to_string()));
}
}
fn warn(msg: impl std::fmt::Display) {
if stderr_is_tty() {
eprintln!("{YELLOW}{BOLD}WARN:{RESET} {msg}");
} else {
eprintln!("WARN: {}", strip_ansi(&msg.to_string()));
}
}
/// Progress `println!` that strips ANSI escape codes when stdout is
/// not a TTY. Every internal progress line (filter graph info,
/// "starting X server on port Y", "running ffmpeg...", per-segment
/// summary) goes through this instead of raw `println!` so piped
/// output doesn't leak `[36m` garbage into log files. This is the
/// rule from the adom-cli-design skill §"Colored output" — colors
/// are for humans only, never for pipes.
macro_rules! note {
($($arg:tt)*) => {{
let __s = format!($($arg)*);
if $crate::stdout_is_tty() {
println!("{}", __s);
} else {
println!("{}", $crate::strip_ansi(&__s));
}
}};
}
macro_rules! enote {
($($arg:tt)*) => {{
let __s = format!($($arg)*);
if $crate::stderr_is_tty() {
eprintln!("{}", __s);
} else {
eprintln!("{}", $crate::strip_ansi(&__s));
}
}};
}
#[derive(Parser)]
#[command(name = "adom-video-post", version, about = "AI-driven demo-finishing studio: review every captured clip, flag & re-record the weak ones, speed up dead air, narrate, mux + auto-level, validate the final cut, and publish to the wiki")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Verify ffmpeg + ffprobe are installed
Health,
/// Drop a speedup_start, speedup_end, or recording_start event in the markers file
#[command(subcommand)]
Mark(MarkCmd),
/// Wrap a command with start/end markers and execute it
Wrap(WrapArgs),
/// Print a human-readable summary of the markers file
Inspect(InspectArgs),
/// Speedup post-process: read markers + input video, produce sped-up output
Process(ProcessArgs),
/// Unified Adom Video Post app: opens one Hydrogen tab with all three
/// post-production phases (Review / Narrate / Publish) behind a
/// phase selector. Takes a manifest like `storyboard` does. This
/// is the recommended entrypoint — the standalone `storyboard`
/// and `voiceover` subcommands still exist for direct launches
/// but the unified app is what most users should use.
App(AppArgs),
/// Voiceover overlay: open a Hydrogen webview, narrate over the video, mux audio
Voiceover(VoiceoverArgs),
/// Storyboard review: open a Hydrogen webview pointed at a clip
/// manifest. Live-watches the manifest file by default so the UI
/// updates as the demo script appends new clips.
Storyboard(StoryboardArgs),
/// Manifest CRUD: init / add / inspect / remove. Demo scripts
/// call `manifest add` once per completed step.
#[command(subcommand)]
Manifest(ManifestCmd),
/// Append a still image (held for `--duration` seconds) to the
/// end of a video. Used by demo workflows for data-heavy steps
/// where a sped-up clip blurs through the actual exported values.
AppendStill(AppendStillArgs),
/// Concat all `--kind raw` or `--kind fast` clips from a manifest
/// into a single output webm and write the path back into the
/// manifest's `final_<kind>_path` field.
Concat(ConcatArgs),
/// Publish a final video to a wiki page (wraps adom-wiki asset upload)
Publish(PublishArgs),
/// Run a JavaScript snippet inside a running adom-video-post web UI and
/// print the result. Uses the /eval hot-patch channel from the
/// app-creator skill — works against voiceover, storyboard, and
/// any future webapp subcommand without a rebuild.
Eval(EvalArgs),
/// List every running adom-video-post web UI (reads the instance
/// registry in `~/.adom/adom-video-post/instances/`, filters out stale
/// PIDs). Shows PID, port, subcommand, title, age.
List,
/// One-stop control surface for a running adom-video-post web UI.
/// Target can be `latest`, a subcommand name (`voiceover`,
/// `storyboard`), a port, `pid:<pid>`, or a bare pid. Actions
/// include `state`, `console [--tail --since --follow]`,
/// `eval <code>`, `shutdown`, and `call <METHOD> <PATH>`.
Ctrl(CtrlArgs),
/// Deploy the embedded SKILL.md to ~/.claude/skills/adom-video-post/
Install,
}
#[derive(Subcommand)]
enum MarkCmd {
/// Clear the markers file and write a recording_start event
Init {
#[arg(long, default_value_t = markers::default_path())]
file: String,
},
/// Drop a speedup_start event
Start {
#[arg(long)]
speed: u32,
#[arg(long)]
label: String,
#[arg(long, default_value_t = markers::default_path())]
file: String,
},
/// Drop a speedup_end event
End {
#[arg(long, default_value_t = markers::default_path())]
file: String,
},
}
#[derive(Args)]
struct WrapArgs {
#[arg(long)]
speed: u32,
#[arg(long)]
label: String,
#[arg(long, default_value_t = markers::default_path())]
file: String,
/// Command to run after the markers (everything after `--`)
#[arg(trailing_var_arg = true, allow_hyphen_values = true, num_args = 1..)]
command: Vec<String>,
}
#[derive(Args)]
struct InspectArgs {
#[arg(long, default_value_t = markers::default_path())]
file: String,
}
#[derive(Args)]
struct ProcessArgs {
#[arg(long)]
input: PathBuf,
#[arg(long, default_value_t = markers::default_path())]
markers: String,
/// Output path. Default: <input-stem>-fast.<ext>
#[arg(long)]
output: Option<PathBuf>,
/// Cap the speed multiplier (defensive, anything above 100 looks like a glitch)
#[arg(long, default_value_t = 100)]
max_speed: u32,
/// Trim dead air from the start of the video. Values:
/// "auto" = trim to first speedup marker minus 2s buffer
/// "<float>" = trim the first N seconds literally
/// (unset) = do not trim anything
/// The "auto" mode is the most useful: it cuts the pre-first-caption
/// setup time off the front so the final video starts right where
/// the first meaningful content is.
#[arg(long)]
trim_start: Option<String>,
/// Trim dead air from the end of the video. Values:
/// "auto" = trim to last speedup_end marker plus 4s buffer
/// "<float>" = keep only the first N seconds from the (post trim-start) video
/// (unset) = do not trim anything
/// The "auto" mode handles the common case of the AI doing cleanup
/// work after the final caption, which is boring dead air the viewer
/// does not care about.
#[arg(long)]
trim_end: Option<String>,
/// Apply a global base speed multiplier to the ENTIRE video, on top
/// of any per-segment speedup markers. Normal (unmarked) sections
/// run at this rate; marked sections still run at their own marker
/// speed (the marker dominates). Use this to crunch out the AI's
/// natural slow pacing across the whole recording. 2.0 is a good
/// default that stays readable. Set to 1.0 (or omit) to disable.
#[arg(long, default_value_t = 1.0)]
base_speed: f64,
/// Disable the red "Nx SPEEDUP: label" drawtext overlay on speedup
/// sections. Useful when the caller is already burning in its own
/// captions (e.g. desktop_caption step markers) during recording,
/// so the adom-video-post overlay would just cover them up. When set,
/// the speedup still happens — only the drawtext caption is skipped.
#[arg(long, default_value_t = false)]
no_overlay: bool,
/// Downscale output to this max width in pixels. Default 1280.
/// Set to 0 to keep original resolution. Wiki demos don't need
/// 2546px wide video; 1280 encodes ~4x faster and looks fine.
#[arg(long, default_value_t = 1280)]
max_width: u32,
}
#[derive(Args)]
struct AppArgs {
/// Path to the manifest JSON file (typically /tmp/storyboard-manifest.json).
manifest: PathBuf,
/// HTTP server port. Defaults to 8798 for the unified app (8796 = voiceover, 8797 = storyboard).
#[arg(long, default_value_t = 8798)]
port: u16,
/// Hydrogen webview tab name.
#[arg(long, default_value_t = String::from("Adom Video Post"))]
tab_name: String,
/// Force a specific Hydrogen pane id. Omit to auto-pick a pane
/// that does NOT contain VS Code.
#[arg(long)]
panel_id: Option<String>,
/// Disable the manifest file-watcher.
#[arg(long)]
no_watch: bool,
/// Run HTTP server only — skip opening a Hydrogen webview tab.
/// Use when reviewing in pup or driving via browser_eval.
#[arg(long)]
no_open: bool,
}
#[derive(Args)]
struct StoryboardArgs {
/// Path to the manifest JSON file (typically /tmp/storyboard-manifest.json).
manifest: PathBuf,
/// HTTP server port. Defaults to 8797 (storyboard); voiceover lives on 8796.
#[arg(long, default_value_t = 8797)]
port: u16,
/// Hydrogen webview tab name.
#[arg(long, default_value_t = String::from("Storyboard"))]
tab_name: String,
/// Force a specific Hydrogen pane id. Omit to auto-pick a pane
/// that does NOT contain VS Code.
#[arg(long)]
panel_id: Option<String>,
/// Disable the file-watcher (which by default polls the manifest
/// every 500ms and live-updates the UI when it changes).
#[arg(long)]
no_watch: bool,
/// Run HTTP server only — skip opening a Hydrogen webview tab.
/// Use when reviewing in pup or driving via browser_eval.
#[arg(long)]
no_open: bool,
}
#[derive(Subcommand)]
enum ManifestCmd {
/// Create an empty manifest file.
Init {
file: PathBuf,
#[arg(long, default_value_t = String::from("Untitled"))]
title: String,
},
/// Append a clip entry to the manifest. Auto-creates the file
/// if it doesn't exist (so the demo script doesn't have to
/// call `init` first).
Add {
file: PathBuf,
#[arg(long)]
id: String,
#[arg(long)]
title: String,
#[arg(long)]
description: Option<String>,
#[arg(long)]
raw: Option<PathBuf>,
#[arg(long)]
fast: Option<PathBuf>,
#[arg(long)]
image: Option<PathBuf>,
#[arg(long = "console-png")]
console_png: Option<PathBuf>,
/// `SPEED:LABEL`, repeatable. e.g. `--speedup "20:Exporting gerbers"`
#[arg(long = "speedup")]
speedups: Vec<String>,
/// Free-form bullet for the action log, repeatable.
#[arg(long = "action")]
actions: Vec<String>,
/// Per-clip warning shown in the UI, repeatable.
#[arg(long = "warning")]
warnings: Vec<String>,
/// TTS narration script (the spoken text that was muxed into the
/// clip's audio track). Surfaced in the storyboard UI as a
/// collapsible disclosure, distinct from `--description` (which
/// captures the demo author's INTENT for the clip).
#[arg(long)]
narration: Option<String>,
},
/// Print a human-readable summary of the manifest.
Inspect { file: PathBuf },
/// Remove a clip by id (for re-shoots).
Remove {
file: PathBuf,
#[arg(long)]
id: String,
},
}
#[derive(Args)]
struct AppendStillArgs {
/// Source video.
#[arg(long)]
video: PathBuf,
/// Image to append (PNG, JPG, etc.).
#[arg(long)]
image: PathBuf,
/// How long to hold the still, in seconds.
#[arg(long, default_value_t = 3.0)]
duration: f64,
/// Output webm path.
#[arg(long)]
output: PathBuf,
}
#[derive(Args)]
struct ConcatArgs {
/// Manifest to read clips from.
#[arg(long)]
manifest: PathBuf,
/// Output webm.
#[arg(long)]
output: PathBuf,
/// `raw` or `fast` — which path on each clip to concat.
#[arg(long, default_value_t = String::from("fast"))]
kind: String,
}
#[derive(Args)]
struct VoiceoverArgs {
/// Input video (typically the -fast.webm from `process`)
#[arg(long)]
input: PathBuf,
/// Output path. Default: <input-stem>-narrated.<ext>
#[arg(long)]
output: Option<PathBuf>,
/// HTTP server port for the voiceover UI
#[arg(long, default_value_t = 8796)]
port: u16,
/// Hydrogen webview tab name
#[arg(long, default_value_t = String::from("Adom Video Post-Processing"))]
tab_name: String,
/// Hydrogen panel ID to host the tab. Defaults to the focused panel.
/// Use this to host the tab on a specific pane instead of whatever is
/// currently focused.
#[arg(long)]
panel_id: Option<String>,
/// Optional markers file from an earlier `adom-video-post process` run.
/// When present, the app shows a Speedup Timeline card with the
/// source video timeline, highlighted speedup regions, and summary
/// stats (input vs output duration, % saved).
#[arg(long)]
markers: Option<PathBuf>,
/// Run HTTP server only — skip opening a Hydrogen webview tab.
/// Use when reviewing in pup or driving via browser_eval.
#[arg(long)]
no_open: bool,
}
#[derive(Args)]
struct EvalArgs {
/// JavaScript snippet. Evaluated inside the UI as the body of an
/// `async function (ctx) { ... }`, so `await` works and `return`
/// at the top level is valid. `ctx` is the UI's `window.__appCtx`.
code: String,
/// Port of the running adom-video-post web UI. Defaults to 8796
/// (voiceover). For storyboard, pass `--port 8797`.
#[arg(long, default_value_t = 8796)]
port: u16,
/// Give up after N seconds waiting for the snippet's result.
#[arg(long, default_value_t = 30)]
timeout: u64,
}
#[derive(Args)]
struct CtrlArgs {
/// Target instance: `latest`, a subcommand name (`voiceover`,
/// `storyboard`), a port number (e.g. `8796`), a bare pid, or
/// `pid:<pid>`.
target: String,
/// Action to perform (see `adom-video-post ctrl --help` or the
/// subcommand-level --help for this variant).
#[command(subcommand)]
action: CtrlActionArgs,
}
#[derive(Subcommand)]
enum CtrlActionArgs {
/// GET /state — print the target's current state JSON.
State,
/// GET /console — print the in-app log buffer.
Console {
/// Only print the last N messages.
#[arg(long)]
tail: Option<usize>,
/// Only print messages whose `seq` is strictly greater than this.
#[arg(long)]
since: Option<u64>,
/// Long-poll the console endpoint and stream new messages as
/// they arrive (blocks until Ctrl-C).
#[arg(long)]
follow: bool,
},
/// POST /eval + poll result — run a JS snippet in the target UI.
Eval {
code: String,
#[arg(long, default_value_t = 30)]
timeout: u64,
},
/// POST /shutdown — exit the target instance cleanly.
Shutdown,
/// Raw HTTP call against an app-specific endpoint (GET/POST/
/// PUT/DELETE). Use for handlers not covered by the built-in
/// actions above (e.g. voiceover's /start-recording).
Call {
method: String,
path: String,
/// Optional JSON request body.
#[arg(long)]
body: Option<String>,
},
}
#[derive(Args)]
struct PublishArgs {
/// Final video to upload
#[arg(long)]
input: PathBuf,
/// Wiki page ref like apps/adom-desktop, skills/symbol-creator, molecules/drv8411a-breakout
#[arg(long)]
page: String,
/// Caption shown next to the video on the wiki page
#[arg(long)]
caption: Option<String>,
/// Asset type. Default: video. Other valid: hero_image, screenshot
#[arg(long, default_value_t = String::from("video"))]
asset_type: String,
}
fn main() -> ExitCode {
let cli = Cli::parse();
let result = match cli.cmd {
Cmd::Health => cmd_health(),
Cmd::Mark(MarkCmd::Init { file }) => cmd_mark_init(&file),
Cmd::Mark(MarkCmd::Start { speed, label, file }) => cmd_mark_start(&file, speed, &label),
Cmd::Mark(MarkCmd::End { file }) => cmd_mark_end(&file),
Cmd::Wrap(args) => return cmd_wrap(args),
Cmd::Inspect(args) => cmd_inspect(&args.file),
Cmd::Process(args) => cmd_process(args),
Cmd::App(args) => cmd_app(args),
Cmd::Voiceover(args) => cmd_voiceover(args),
Cmd::Storyboard(args) => cmd_storyboard(args),
Cmd::Manifest(cmd) => cmd_manifest(cmd),
Cmd::AppendStill(args) => cmd_append_still(args),
Cmd::Concat(args) => cmd_concat(args),
Cmd::Publish(args) => cmd_publish(args),
Cmd::Eval(args) => cmd_eval(args),
Cmd::List => ctrl::cmd_list(),
Cmd::Ctrl(args) => {
let action = match args.action {
CtrlActionArgs::State => ctrl::CtrlAction::State,
CtrlActionArgs::Console { tail, since, follow } => {
ctrl::CtrlAction::Console { tail, since, follow }
}
CtrlActionArgs::Eval { code, timeout } => {
ctrl::CtrlAction::Eval { code, timeout }
}
CtrlActionArgs::Shutdown => ctrl::CtrlAction::Shutdown,
CtrlActionArgs::Call { method, path, body } => {
ctrl::CtrlAction::Call { method, path, body }
}
};
ctrl::cmd_ctrl(&args.target, &action)
}
Cmd::Install => cmd_install(),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(msg) => {
err(&msg);
// If the message contains a "Hint:" segment, print it as a hint line.
if let Some((_, h)) = msg.split_once("Hint: ") {
hint(h);
}
ExitCode::from(1)
}
}
}
fn cmd_health() -> Result<(), String> {
let (ffmpeg_v, ffprobe_v) = ffmpeg::check_versions()?;
ok(format!("ffmpeg {} + ffprobe {} ready.", ffmpeg_v, ffprobe_v));
Ok(())
}
fn cmd_mark_init(file: &str) -> Result<(), String> {
let epoch = markers::init(file)?;
ok(format!("markers initialized at {CYAN}{file}{RESET} (recording_start epoch={epoch:.3})"));
Ok(())
}
fn cmd_mark_start(file: &str, speed: u32, label: &str) -> Result<(), String> {
if !(2..=100).contains(&speed) {
return Err(format!("speed must be in [2, 100], got {}", speed));
}
let event = markers::Event::SpeedupStart {
epoch: markers::now_epoch(),
speed,
label: label.to_string(),
};
markers::append(file, &event)?;
ok(format!("dropped speedup_start (speed={CYAN}{speed}x{RESET}, label={CYAN}{label}{RESET})"));
Ok(())
}
fn cmd_mark_end(file: &str) -> Result<(), String> {
let event = markers::Event::SpeedupEnd {
epoch: markers::now_epoch(),
};
markers::append(file, &event)?;
ok("dropped speedup_end");
Ok(())
}
fn cmd_wrap(args: WrapArgs) -> ExitCode {
if !(2..=100).contains(&args.speed) {
err(format!("speed must be in [2, 100], got {}", args.speed));
return ExitCode::from(1);
}
if args.command.is_empty() {
err("wrap requires a command after `--`");
hint("Example: adom-video-post wrap --speed 20 --label 'Exporting' -- adom-desktop fusion_export_gerbers");
return ExitCode::from(1);
}
// Show a live Hydrogen caption so the USER watching the recording
// knows which sections will be sped up later. Without this the
// recording operator has no live feedback that a speedup marker
// is in effect, so they can't tell which slow parts got caught.
// Best-effort: if adom-cli isn't available or the command fails,
// we silently proceed (CLI keeps working outside Hydrogen too).
let live_caption = format!("{}x SPEEDUP: {}", args.speed, args.label);
let _ = std::process::Command::new("adom-cli")
.args([
"hydrogen", "caption", "show", &live_caption,
"-d", "0", // indefinite, we'll hide explicitly on completion
"-s", "medium", // safe size per the ralph-test rules
"-p", "top",
])
.output();
// Drop start marker
let start_event = markers::Event::SpeedupStart {
epoch: markers::now_epoch(),
speed: args.speed,
label: args.label.clone(),
};
if let Err(e) = markers::append(&args.file, &start_event) {
err(format!("failed to write start marker: {}", e));
let _ = std::process::Command::new("adom-cli")
.args(["hydrogen", "caption", "hide"])
.output();
return ExitCode::from(1);
}
// Run the command
let mut cmd = std::process::Command::new(&args.command[0]);
if args.command.len() > 1 {
cmd.args(&args.command[1..]);
}
let status = cmd.status();
// Drop end marker (always, even if the command failed)
let end_event = markers::Event::SpeedupEnd {
epoch: markers::now_epoch(),
};
if let Err(e) = markers::append(&args.file, &end_event) {
warn(format!("failed to write end marker: {} (the command did finish)", e));
}
// Hide the live caption now that the wrapped command is done.
let _ = std::process::Command::new("adom-cli")
.args(["hydrogen", "caption", "hide"])
.output();
match status {
Ok(s) => {
if s.success() {
ExitCode::SUCCESS
} else {
ExitCode::from(s.code().unwrap_or(1) as u8)
}
}
Err(e) => {
err(format!("failed to spawn '{}': {}", args.command[0], e));
ExitCode::from(1)
}
}
}
fn cmd_inspect(file: &str) -> Result<(), String> {
let events = markers::read_all(file)?;
let parsed = markers::parse(&events)?;
let now = markers::now_epoch();
let elapsed = now - parsed.recording_start;
let total_source: f64 = parsed.segments.iter().map(|s| s.duration()).sum();
let total_sped: f64 = parsed.segments.iter().map(|s| s.sped_duration()).sum();
let savings_pct = if total_source > 0.0 {
100.0 * (1.0 - total_sped / total_source)
} else {
0.0
};
ok(format!(
"Recording started {CYAN}{:.1}s{RESET} ago, {CYAN}{}{RESET} speedup segments, ~{CYAN}{:.1}s{RESET} of source video → ~{CYAN}{:.1}s{RESET} after speedup ({:.1}% reduction)",
elapsed,
parsed.segments.len(),
total_source,
total_sped,
savings_pct
));
for seg in &parsed.segments {
note!(
" - {DIM}{:6.1}s{RESET} @ {CYAN}{:>3}x{RESET}: \"{}\" → {DIM}{:.1}s{RESET}",
seg.duration(),
seg.speed,
seg.label,
seg.sped_duration()
);
}
if !parsed.warnings.is_empty() {
for w in &parsed.warnings {
warn(w);
}
}
if !parsed.unclosed.is_empty() {
warn(format!("{} unclosed speedup_start markers (will be ended at end-of-video on process)", parsed.unclosed.len()));
}
Ok(())
}
fn cmd_process(args: ProcessArgs) -> Result<(), String> {
if !args.input.exists() {
return Err(format!("input file not found: {}. Hint: check the path with ls", args.input.display()));
}
let events = markers::read_all(&args.markers)?;
let mut parsed = markers::parse(&events)?;
if !parsed.warnings.is_empty() {
for w in &parsed.warnings {
warn(w);
}
}
// Cap speeds to max_speed
for seg in &mut parsed.segments {
if seg.speed > args.max_speed {
warn(format!("capping segment '{}' speed from {}x to {}x", seg.label, seg.speed, args.max_speed));
seg.speed = args.max_speed;
}
}
let duration = ffmpeg::probe_duration(&args.input)?;
let (width, height) = ffmpeg::probe_dimensions(&args.input)?;
let font_size = ffmpeg::compute_font_size(width);
note!(
"{DIM}input: {}x{} {:.1}s, caption font {}px{RESET}",
width, height, duration, font_size
);
// Inject implicit ends for unclosed segments at end-of-video
for (start_offset, speed, label) in &parsed.unclosed {
warn(format!("unclosed speedup_start '{}' will end at end-of-video ({:.1}s)", label, duration));
parsed.segments.push(markers::Segment {
start_offset: *start_offset,
end_offset: duration,
speed: *speed,
label: label.clone(),
});
}
parsed.segments.sort_by(|a, b| a.start_offset.partial_cmp(&b.start_offset).unwrap());
// Resolve --trim-start into an absolute offset (in seconds) that will
// be dropped from the start of the output.
let trim_offset: f64 = match args.trim_start.as_deref() {
None => 0.0,
Some("auto") => {
match parsed.segments.first() {
Some(seg) => (seg.start_offset - 2.0).max(0.0),
None => 0.0,
}
}
Some(s) => s.parse::<f64>().map_err(|e| {
format!("--trim-start must be 'auto' or a number of seconds, got '{}': {}", s, e)
})?,
};
if trim_offset > 0.0 {
note!(
"{DIM}--trim-start {}: cutting {:.1}s off the front of the video{RESET}",
args.trim_start.as_deref().unwrap_or(""), trim_offset
);
parsed.segments.retain(|s| s.end_offset > trim_offset + 0.001);
for seg in &mut parsed.segments {
if seg.start_offset < trim_offset {
seg.start_offset = trim_offset;
}
}
}
// Resolve --trim-end into an absolute end offset. Anything after
// this offset is clipped off. The intent is to cut the AI's post-demo
// cleanup time that happens between the last meaningful caption and
// the user hitting Stop.
let trim_end: f64 = match args.trim_end.as_deref() {
None => duration,
Some("auto") => {
// Use the last speedup_end + 4s buffer. If no segments, leave alone.
match parsed.segments.last() {
Some(seg) => (seg.end_offset + 4.0).min(duration),
None => duration,
}
}
Some(s) => s.parse::<f64>().map_err(|e| {
format!("--trim-end must be 'auto' or a number of seconds, got '{}': {}", s, e)
}).map(|n: f64| n.min(duration))?,
};
let effective_duration = trim_end;
if trim_end < duration - 0.001 {
note!(
"{DIM}--trim-end {}: cutting {:.1}s off the end of the video (ends at {:.1}s){RESET}",
args.trim_end.as_deref().unwrap_or(""), duration - trim_end, trim_end
);
parsed.segments.retain(|s| s.start_offset < trim_end - 0.001);
for seg in &mut parsed.segments {
if seg.end_offset > trim_end {
seg.end_offset = trim_end;
}
}
}
if parsed.segments.is_empty() {
note!("{YELLOW}no speedup segments, copying input to output unchanged{RESET}");
} else {
note!("{DIM}{} speedup segments to process{RESET}", parsed.segments.len());
for seg in &parsed.segments {
println!(
" - {:6.1}s @ {:>3}x: \"{}\" -> {:.1}s",
seg.duration(),
seg.speed,
seg.label,
seg.sped_duration()
);
}
}
// Compute output path
let output = args.output.unwrap_or_else(|| {
let stem = args.input.file_stem().unwrap_or_default().to_string_lossy().to_string();
let ext = args.input.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_else(|| "webm".to_string());
let parent = args.input.parent().unwrap_or_else(|| std::path::Path::new("."));
parent.join(format!("{}-fast.{}", stem, ext))
});
if args.base_speed < 0.5 || args.base_speed > 10.0 {
return Err(format!("--base-speed must be in [0.5, 10], got {}", args.base_speed));
}
if (args.base_speed - 1.0).abs() > 0.001 {
note!(
"{DIM}--base-speed {:.2}: applying global {:.2}x speedup to all unmarked sections{RESET}",
args.base_speed, args.base_speed
);
}
let filter = ffmpeg::build_filter_complex_full(&parsed.segments, effective_duration, font_size, trim_offset, args.base_speed, args.no_overlay);
note!("{DIM}filter graph: {} chars, {} segments{RESET}", filter.len(), parsed.segments.len() + 1);
if args.max_width > 0 && width > args.max_width {
note!("{DIM}downscaling from {}px to {}px wide (~{:.0}x faster encode){RESET}",
width, args.max_width, (width as f64 / args.max_width as f64).powi(2));
}
note!("{CYAN}running ffmpeg...{RESET}");
ffmpeg::run_process_full(&args.input, &output, &filter, Some(effective_duration), args.max_width)?;
let out_dur = ffmpeg::probe_duration(&output)?;
let total_savings = duration - out_dur;
let pct = if duration > 0.0 { 100.0 * total_savings / duration } else { 0.0 };
ok(format!(
"{CYAN}{}{RESET}\n input: {:.1}s → output: {:.1}s ({:.1}% shorter, {:.1}s saved)",
output.display(),
duration,
out_dur,
pct,
total_savings
));
Ok(())
}
fn cmd_voiceover(args: VoiceoverArgs) -> Result<(), String> {
if !args.input.exists() {
return Err(format!(
"input video not found: {}. Hint: produce it with `adom-video-post process` first.",
args.input.display()
));
}
let output = args.output.unwrap_or_else(|| {
let stem = args
.input
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let ext = args
.input
.extension()
.map(|e| e.to_string_lossy().to_string())
.unwrap_or_else(|| "webm".to_string());
let parent = args.input.parent().unwrap_or_else(|| std::path::Path::new("."));
parent.join(format!("{}-narrated.{}", stem, ext))
});
let cfg = voiceover::VoiceoverConfig {
input: args.input,
output: output.clone(),
port: args.port,
tab_name: args.tab_name,
panel_id_override: args.panel_id,
markers_file: args.markers,
no_open: args.no_open,
};
note!(
"{CYAN}starting voiceover server on port {}{RESET}",
cfg.port
);
let result = voiceover::run(cfg)?;
ok(format!(
"narrated video saved to {CYAN}{}{RESET}",
result.display()
));
Ok(())
}
fn cmd_publish(args: PublishArgs) -> Result<(), String> {
let cfg = publish::PublishConfig {
input: args.input,
page: args.page,
caption: args.caption,
asset_type: args.asset_type,
};
let url = publish::run(cfg)?;
ok(format!("uploaded to wiki: {CYAN}{}{RESET}", url));
Ok(())
}
fn cmd_install() -> Result<(), String> {
let path = install::install()?;
ok(format!("SKILL.md deployed to {CYAN}{}{RESET}", path.display()));
Ok(())
}
fn cmd_app(args: AppArgs) -> Result<(), String> {
let cfg = app::AppConfig {
manifest_path: args.manifest,
port: args.port,
tab_name: args.tab_name,
panel_id_override: args.panel_id,
watch: !args.no_watch,
no_open: args.no_open,
};
note!(
"{CYAN}starting adom-video-post app on port {}{RESET}",
cfg.port
);
app::run(cfg)?;
ok("app session ended");
Ok(())
}
fn cmd_storyboard(args: StoryboardArgs) -> Result<(), String> {
let cfg = storyboard::StoryboardConfig {
manifest_path: args.manifest,
port: args.port,
tab_name: args.tab_name,
panel_id_override: args.panel_id,
watch: !args.no_watch,
no_open: args.no_open,
};
note!(
"{CYAN}starting storyboard server on port {}{RESET}",
cfg.port
);
storyboard::run(cfg)?;
ok("storyboard session ended");
Ok(())
}
fn cmd_manifest(cmd: ManifestCmd) -> Result<(), String> {
use storyboard::manifest_cli;
match cmd {
ManifestCmd::Init { file, title } => {
manifest_cli::init(&file, &title)?;
ok(format!("manifest initialized at {CYAN}{}{RESET} (title: {})", file.display(), title));
}
ManifestCmd::Add {
file,
id,
title,
description,
raw,
fast,
image,
console_png,
speedups,
actions,
warnings,
narration,
} => {
manifest_cli::add(
&file,
&id,
&title,
description,
raw,
fast,
image,
console_png,
speedups,
actions,
warnings,
narration,
)?;
ok(format!("added clip {CYAN}{}{RESET} to {}", id, file.display()));
}
ManifestCmd::Inspect { file } => {
manifest_cli::inspect(&file)?;
}
ManifestCmd::Remove { file, id } => {
manifest_cli::remove(&file, &id)?;
ok(format!("removed clip {CYAN}{}{RESET} from {}", id, file.display()));
}
}
Ok(())
}
fn cmd_append_still(args: AppendStillArgs) -> Result<(), String> {
if !args.video.exists() {
return Err(format!("video not found: {}", args.video.display()));
}
if !args.image.exists() {
return Err(format!("image not found: {}", args.image.display()));
}
note!(
"{DIM}appending {:.1}s still ({}) to {}{RESET}",
args.duration,
args.image.display(),
args.video.display()
);
ffmpeg::append_still(&args.video, &args.image, args.duration, &args.output)?;
ok(format!("wrote {CYAN}{}{RESET}", args.output.display()));
Ok(())
}
fn cmd_concat(args: ConcatArgs) -> Result<(), String> {
let mut manifest = storyboard::manifest::Manifest::load(&args.manifest)?;
let inputs: Vec<PathBuf> = manifest
.clips
.iter()
.filter_map(|c| match args.kind.as_str() {
"raw" => c.raw_path.clone(),
"fast" => c.fast_path.clone(),
_ => None,
})
.filter(|p| p.exists())
.collect();
if inputs.is_empty() {
return Err(format!(
"no usable {} clips in manifest. Hint: every clip needs a {}_path field pointing at an existing file.",
args.kind, args.kind
));
}
note!(
"{DIM}concatenating {} clips → {}{RESET}",
inputs.len(),
args.output.display()
);
ffmpeg::concat_videos(&inputs, &args.output)?;
// Write the result back into the manifest so the storyboard UI
// picks it up via /final-video/<kind>.
match args.kind.as_str() {
"raw" => manifest.final_raw_path = Some(args.output.clone()),
"fast" => manifest.final_fast_path = Some(args.output.clone()),
_ => {}
}
manifest.save(&args.manifest)?;
ok(format!(
"wrote {CYAN}{}{RESET} ({} clips, --kind {})",
args.output.display(),
inputs.len(),
args.kind
));
Ok(())
}
/// Run `code` against a live adom-video-post web UI via the /eval hot-patch
/// channel. Shells out to curl so we don't need an HTTP-client dep —
/// both Docker and macOS always have curl. The flow is:
///
/// 1. POST /eval with `{"code":"..."}` → parse `{"id":"..."}`
/// 2. GET /eval/:id/result (long-polls up to 25s server-side)
/// until a result appears OR the overall --timeout expires.
/// 3. Print the returned value (or error) as JSON to stdout and
/// exit 0 on `ok:true` / 1 on `ok:false`.
fn cmd_eval(args: EvalArgs) -> Result<(), String> {
use std::process::Command;
let base = format!("http://127.0.0.1:{}", args.port);
let post_body = serde_json::json!({ "code": args.code }).to_string();
// Queue the snippet.
let post = Command::new("curl")
.args([
"-s",
"-X",
"POST",
"-H",
"Content-Type: application/json",
"-d",
&post_body,
"--max-time",
"5",
&format!("{}/eval", base),
])
.output()
.map_err(|e| format!("curl spawn failed: {}. Hint: curl is required for `adom-video-post eval`.", e))?;
if !post.status.success() {
return Err(format!(
"POST /eval failed: {}. Hint: is a adom-video-post web UI running on port {}? Run `adom-video-post voiceover --input ...` or `adom-video-post storyboard ...` first.",
String::from_utf8_lossy(&post.stderr),
args.port
));
}
let post_body = String::from_utf8_lossy(&post.stdout);
let parsed: serde_json::Value = serde_json::from_str(&post_body)
.map_err(|e| format!("parse /eval response: {} ({})", e, post_body))?;
let id = parsed
.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| format!("/eval response missing 'id' field: {}", post_body))?
.to_string();
// Poll for the result. Each curl call long-polls up to 25s on the
// server side. The outer loop enforces `--timeout` at the client
// side, retrying if the server returned 204.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(args.timeout);
loop {
let get = Command::new("curl")
.args([
"-s",
"-w",
"\n%{http_code}",
"--max-time",
"30",
&format!("{}/eval/{}/result", base, id),
])
.output()
.map_err(|e| format!("curl spawn failed: {}", e))?;
let raw = String::from_utf8_lossy(&get.stdout);
// curl -w appends "\nHTTP_CODE" after the body
let (body, status_line) = raw
.rsplit_once('\n')
.map(|(b, s)| (b.to_string(), s.to_string()))
.unwrap_or_else(|| (raw.to_string(), "000".to_string()));
let status: u16 = status_line.trim().parse().unwrap_or(0);
if status == 200 {
let result: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| format!("parse result JSON: {} ({})", e, body))?;
println!("{}", serde_json::to_string_pretty(&result).unwrap_or(body));
let ok_flag = result.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);
if ok_flag {
return Ok(());
} else {
return Err(result
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("eval returned ok:false")
.to_string());
}
}
if status != 204 {
return Err(format!(
"unexpected status {} from /eval/{}/result: {}",
status, id, body
));
}
if std::time::Instant::now() >= deadline {
return Err(format!(
"timed out after {}s waiting for eval result. Hint: is the UI page open in the Hydrogen webview? The snippet only runs when the UI's poll loop is alive.",
args.timeout
));
}
}
}