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
            ));
        }
    }
}