use anyhow::{Context, Result};
use clap::{Args, Parser, Subcommand};
use framewatch::{
CaptureEvent, ChannelSink, Config, DirectorySink, EncodedImage, EventKind, Target,
};
use std::path::PathBuf;
#[derive(Parser)]
#[command(
name = "framewatch",
version,
about = "Event-driven, change-triggered window capture for AI agents."
)]
struct Cli {
#[arg(short, long, action = clap::ArgAction::Count, global = true)]
verbose: u8,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
#[allow(clippy::large_enum_variant)]
enum Command {
Windows,
Watch(WatchArgs),
Shot(ShotArgs),
Gui(GuiArgs),
}
#[derive(Args)]
struct WatchArgs {
#[arg(long, group = "target")]
title: Option<String>,
#[arg(long, group = "target")]
exe: Option<String>,
#[arg(long, group = "target")]
hwnd: Option<isize>,
#[arg(long, group = "target")]
pid: Option<u32>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
settle_ms: Option<u64>,
#[arg(long)]
value_sample_ms: Option<u64>,
#[arg(long)]
wait: Option<u64>,
#[arg(long)]
duration: Option<u64>,
#[arg(long)]
frames: Option<u64>,
#[arg(long)]
until_settled: bool,
#[arg(long, value_name = "X,Y,W,H")]
roi: Option<String>,
}
fn parse_roi(spec: &str) -> Result<framewatch::Rect> {
let parts: Vec<&str> = spec.split(',').map(|s| s.trim()).collect();
if parts.len() != 4 {
anyhow::bail!("--roi must be X,Y,W,H (4 comma-separated integers), got: {spec:?}");
}
let x = parts[0].parse().context("--roi X")?;
let y = parts[1].parse().context("--roi Y")?;
let w = parts[2].parse().context("--roi W")?;
let h = parts[3].parse().context("--roi H")?;
Ok(framewatch::Rect::new(x, y, w, h))
}
#[derive(Args)]
struct ShotArgs {
#[arg(long, group = "target")]
title: Option<String>,
#[arg(long, group = "target")]
exe: Option<String>,
#[arg(long, group = "target")]
hwnd: Option<isize>,
#[arg(long, group = "target")]
pid: Option<u32>,
#[arg(long)]
launch: Option<String>,
#[arg(long)]
out_file: PathBuf,
#[arg(long, default_value_t = 20)]
timeout: u64,
#[arg(long)]
settle_ms: Option<u64>,
#[arg(long, value_name = "X,Y,W,H")]
roi: Option<String>,
#[arg(long)]
settle_best_effort: bool,
#[arg(long)]
config: Option<PathBuf>,
}
#[derive(Args)]
struct GuiArgs {
#[arg(long)]
config: Option<PathBuf>,
}
fn main() -> Result<()> {
let cli = Cli::parse();
init_tracing(cli.verbose);
match cli.command {
Command::Windows => cmd_windows(),
Command::Watch(args) => cmd_watch(args),
Command::Shot(args) => cmd_shot(args),
Command::Gui(args) => cmd_gui(args),
}
}
fn init_tracing(verbose: u8) {
use tracing_subscriber::EnvFilter;
let default = match verbose {
0 => "warn",
1 => "info",
2 => "debug",
_ => "trace",
};
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default));
let _ = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.try_init();
}
fn cmd_windows() -> Result<()> {
let windows = framewatch::enumerate_windows().context("enumerating windows")?;
if windows.is_empty() {
println!("(no capturable windows found)");
return Ok(());
}
#[allow(clippy::print_literal)]
{
println!("{:<10} {:<24} {}", "HWND", "EXE", "TITLE");
}
for w in windows {
let title: String = w.title.chars().take(70).collect();
println!("{:<10} {:<24} {}", w.hwnd, w.exe, title);
}
Ok(())
}
fn cmd_watch(args: WatchArgs) -> Result<()> {
let mut config = match &args.config {
Some(path) => Config::from_toml_path(path).context("loading config")?,
None => Config::default(),
};
if let Some(t) = args.title {
config.target = Target::ByTitleRegex(t);
} else if let Some(e) = args.exe {
config.target = Target::ByExe(e);
} else if let Some(h) = args.hwnd {
config.target = Target::ByHwnd(h);
} else if let Some(p) = args.pid {
config.target = Target::ByPid(p);
}
if let Some(out) = args.out {
config.out_dir = out;
}
if let Some(ms) = args.settle_ms {
config.settle_ms = ms;
}
if let Some(ms) = args.value_sample_ms {
config.value_sample_ms = ms;
}
if let Some(secs) = args.wait {
config.wait_ms = secs.saturating_mul(1000);
}
if let Some(secs) = args.duration {
config.stop_after_ms = secs.saturating_mul(1000);
}
if let Some(n) = args.frames {
config.stop_after_images = n;
}
if args.until_settled {
config.stop_after_settled = true;
}
if let Some(spec) = args.roi.as_deref() {
config.crop = Some(parse_roi(spec)?);
}
config.validate().context("invalid configuration")?;
let sink = DirectorySink::new(&config).context("creating output sink")?;
let dir = sink.session().dir.clone();
println!("framewatch: writing session to {}", dir.display());
println!("framewatch: press Ctrl+C to stop.");
framewatch::watch(config, sink).context("capture loop")?;
Ok(())
}
fn cmd_shot(args: ShotArgs) -> Result<()> {
let mut child = match &args.launch {
Some(cmd) => Some(spawn_launch(cmd).context("launching --launch command")?),
None => None,
};
let mut config = match &args.config {
Some(path) => Config::from_toml_path(path).context("loading config")?,
None => Config::default(),
};
if let Some(c) = &child {
config.target = Target::ByPid(c.id());
} else if let Some(p) = args.pid {
config.target = Target::ByPid(p);
} else if let Some(t) = args.title {
config.target = Target::ByTitleRegex(t);
} else if let Some(e) = args.exe {
config.target = Target::ByExe(e);
} else if let Some(h) = args.hwnd {
config.target = Target::ByHwnd(h);
} else {
anyhow::bail!("provide a selector (--title/--exe/--hwnd/--pid) or --launch");
}
let budget_ms = args.timeout.saturating_mul(1000);
config.wait_ms = budget_ms; config.stop_after_ms = budget_ms; config.stop_after_settled = true;
if let Some(ms) = args.settle_ms {
config.settle_ms = ms;
}
if let Some(spec) = args.roi.as_deref() {
config.crop = Some(parse_roi(spec)?);
}
config.validate().context("invalid configuration")?;
let (sink, rx) = ChannelSink::unbounded();
let capture = framewatch::watch(config, sink);
if let Some(c) = child.as_mut() {
let _ = c.kill();
let _ = c.wait();
}
capture.context("capture")?;
let events: Vec<CaptureEvent> = rx.try_iter().collect();
match select_shot_frame(&events, args.settle_best_effort) {
Some(img) => {
std::fs::write(&args.out_file, &img.bytes)
.with_context(|| format!("writing {}", args.out_file.display()))?;
println!("{}", args.out_file.display());
Ok(())
}
None => {
eprintln!(
"framewatch: no settled frame within {}s (use --settle-best-effort to write the latest frame anyway)",
args.timeout
);
std::process::exit(3);
}
}
}
fn select_shot_frame(events: &[CaptureEvent], best_effort: bool) -> Option<&EncodedImage> {
if let Some(ev) = events
.iter()
.rev()
.find(|e| e.kind() == EventKind::Settled && e.image.is_some())
{
return ev.image.as_ref();
}
if best_effort {
if let Some(ev) = events.iter().rev().find(|e| e.image.is_some()) {
return ev.image.as_ref();
}
}
None
}
fn spawn_launch(cmd: &str) -> Result<std::process::Child> {
let tokens = tokenize(cmd);
let (program, rest) = tokens
.split_first()
.ok_or_else(|| anyhow::anyhow!("--launch is empty"))?;
std::process::Command::new(program)
.args(rest)
.spawn()
.map_err(Into::into)
}
fn tokenize(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut in_quote = false;
for ch in s.chars() {
match ch {
'"' => in_quote = !in_quote,
c if c.is_whitespace() && !in_quote => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
c => cur.push(c),
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
#[cfg(feature = "gui")]
fn cmd_gui(args: GuiArgs) -> Result<()> {
let config = match &args.config {
Some(path) => Some(Config::from_toml_path(path).context("loading config")?),
None => None,
};
framewatch::gui::run(config).context("running gui")?;
Ok(())
}
#[cfg(not(feature = "gui"))]
fn cmd_gui(_args: GuiArgs) -> Result<()> {
anyhow::bail!(
"this build has no GUI. Reinstall with `cargo install framewatch --features gui` \
(and `wgc` on Windows for live capture)."
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tokenize_respects_quotes_and_whitespace() {
assert_eq!(
tokenize("game.exe --freecam --pos \"1 2 3\""),
vec!["game.exe", "--freecam", "--pos", "1 2 3"]
);
assert_eq!(tokenize(" a b "), vec!["a", "b"]);
assert!(tokenize(" ").is_empty());
}
#[test]
fn parse_roi_ok_and_errors() {
let r = parse_roi("10,20,300,200").unwrap();
assert_eq!((r.x, r.y, r.w, r.h), (10, 20, 300, 200));
assert!(parse_roi("1,2,3").is_err());
assert!(parse_roi("a,b,c,d").is_err());
}
}