use anyhow::{Context, Result};
use clap::{Args, Parser, Subcommand};
use framewatch::{
CaptureEvent, ChannelSink, Config, DirectorySink, EncodedImage, EventKind, Target,
};
use std::path::PathBuf;
#[cfg(feature = "record")]
const DEFAULT_RECORD_LAUNCH_WAIT_MS: u64 = 15_000;
#[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),
Record(RecordArgs),
Transcriber(TranscriberArgs),
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 RecordArgs {
#[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: Option<PathBuf>,
#[arg(long, value_name = "X,Y,W,H")]
roi: Option<String>,
#[arg(long)]
wait: Option<u64>,
#[arg(long, value_parser = clap::value_parser!(u64).range(1..))]
duration: Option<u64>,
#[arg(long, default_value_t = 30)]
fps: u32,
#[arg(long)]
mic: Option<String>,
#[arg(long)]
no_audio: bool,
#[arg(long, value_name = "CMD")]
transcribe_cmd: Option<String>,
#[arg(long, conflicts_with = "transcribe_cmd")]
no_transcribe: bool,
#[arg(long)]
config: Option<PathBuf>,
}
#[derive(Args)]
struct TranscriberArgs {
#[command(subcommand)]
command: TranscriberCommand,
}
#[derive(Subcommand)]
enum TranscriberCommand {
Setup,
}
#[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::Record(args) => cmd_record(args),
Command::Transcriber(args) => cmd_transcriber(args),
Command::Gui(args) => cmd_gui(args),
}
}
#[cfg(feature = "record")]
fn cmd_transcriber(args: TranscriberArgs) -> Result<()> {
match args.command {
TranscriberCommand::Setup => {
eprintln!(
"framewatch: installing managed whisper.cpp {} / {} (~150 MiB on first use)",
framewatch::WHISPER_VERSION,
framewatch::WHISPER_MODEL
);
let managed = framewatch::ensure_managed_whisper()
.context("preparing managed Whisper transcription")?;
println!("whisper_cli={}", managed.executable.display());
println!("model={}", managed.model.display());
Ok(())
}
}
}
#[cfg(not(feature = "record"))]
fn cmd_transcriber(_args: TranscriberArgs) -> Result<()> {
anyhow::bail!(
"managed transcription requires the `record` feature. Reinstall with \
`cargo install framewatch --features \"wgc record\"`."
)
}
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 child = match &args.launch {
Some(cmd) => Some(ChildGuard::new(
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);
drop(child);
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 = framewatch::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)
}
struct ChildGuard(Option<std::process::Child>);
impl ChildGuard {
fn new(child: std::process::Child) -> Self {
Self(Some(child))
}
fn id(&self) -> u32 {
self.0.as_ref().map(|c| c.id()).unwrap_or(0)
}
}
impl Drop for ChildGuard {
fn drop(&mut self) {
if let Some(mut c) = self.0.take() {
let _ = c.kill();
let _ = c.wait();
}
}
}
#[cfg(feature = "record")]
fn effective_record_wait_ms(
explicit_wait_seconds: Option<u64>,
config_wait_ms: u64,
launched: bool,
) -> u64 {
explicit_wait_seconds
.map(|seconds| seconds.saturating_mul(1000))
.unwrap_or_else(|| {
if launched && config_wait_ms == 0 {
DEFAULT_RECORD_LAUNCH_WAIT_MS
} else {
config_wait_ms
}
})
}
#[cfg(feature = "record")]
fn cmd_record(args: RecordArgs) -> Result<()> {
use framewatch::recording::{files, AudioMeta, VideoMeta};
use framewatch::{
record, record_with_duration, PackageWriter, RecordConfig, RecordingManifest, Transcriber,
};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
if args.duration == Some(0) {
anyhow::bail!("--duration must be at least 1 second");
}
let base = match &args.config {
Some(path) => Config::from_toml_path(path).context("loading config")?,
None => Config::default(),
};
let child = match &args.launch {
Some(cmd) => Some(ChildGuard::new(
spawn_launch(cmd).context("launching --launch command")?,
)),
None => None,
};
let target = if let Some(c) = &child {
Target::ByPid(c.id())
} else if let Some(t) = args.title {
Target::ByTitleRegex(t)
} else if let Some(e) = args.exe {
Target::ByExe(e)
} else if let Some(h) = args.hwnd {
Target::ByHwnd(h)
} else if let Some(p) = args.pid {
Target::ByPid(p)
} else {
base.target.clone()
};
if matches!(&target, Target::ByTitleRegex(s) | Target::ByExe(s) if s.is_empty()) {
anyhow::bail!("provide a selector (--title/--exe/--hwnd/--pid) or --launch");
}
let transcriber = if args.no_transcribe || args.no_audio {
Transcriber::Disabled
} else if let Some(cmd) = args.transcribe_cmd.clone() {
Transcriber::Command { template: cmd }
} else {
eprintln!(
"framewatch: preparing managed Whisper transcription (first use downloads ~150 MiB; \
pass --no-transcribe to opt out)."
);
let managed = framewatch::ensure_managed_whisper()
.context("preparing managed Whisper transcription")?;
eprintln!(
"framewatch: using whisper.cpp {} with model {}",
framewatch::WHISPER_VERSION,
framewatch::WHISPER_MODEL
);
Transcriber::ManagedWhisper {
executable: managed.executable,
model: managed.model,
}
};
let stop = Arc::new(AtomicBool::new(false));
{
let stop = stop.clone();
ctrlc::set_handler(move || stop.store(true, Ordering::SeqCst))
.context("installing Ctrl+C handler")?;
}
let out_dir = args.out.unwrap_or(base.out_dir);
let crop = match args.roi.as_deref() {
Some(spec) => Some(parse_roi(spec)?),
None => base.crop,
};
let started_at = chrono::Utc::now();
let hint = framewatch::session::target_hint(&target);
let writer = PackageWriter::new(&out_dir, started_at, &hint).context("creating package dir")?;
let dir = writer.recording().dir.clone();
println!("framewatch: recording to {}", dir.display());
match args.duration {
Some(secs) => println!("framewatch: will stop after {secs}s (or Ctrl+C)."),
None => println!("framewatch: press Ctrl+C to stop."),
}
let rcfg = RecordConfig {
target: target.clone(),
crop,
fps: args.fps,
mic: args.mic.clone(),
capture_audio: !args.no_audio,
video_out: writer.recording().video_path(),
audio_out: writer.recording().audio_path(),
work_dir: dir.clone(),
wait_ms: effective_record_wait_ms(args.wait, base.wait_ms, child.is_some()),
stop,
};
let outcome = match args.duration {
Some(secs) => record_with_duration(rcfg, std::time::Duration::from_secs(secs)),
None => record(rcfg),
};
drop(child);
let outcome = outcome.context("recording")?;
let (transcript, engine, model) = match &outcome.audio {
Some(_) => match transcriber.transcribe(&writer.recording().audio_path(), &dir) {
Ok(t) => {
let (engine, model) = transcriber.engine_meta();
(t, engine, model)
}
Err(e) => {
eprintln!(
"framewatch: transcription failed ({e}); writing package without a transcript."
);
(framewatch::Transcript::default(), "none", None)
}
},
None => {
eprintln!("framewatch: no audio was recorded; the package is video-only.");
(framewatch::Transcript::default(), "none", None)
}
};
writer
.write_transcript(&transcript)
.context("writing transcript")?;
let audio_meta = outcome.audio.as_ref().map(|a| AudioMeta {
path: files::AUDIO.to_string(),
sample_rate: a.sample_rate,
channels: a.channels,
duration_ms: a.duration_ms,
});
let mut manifest = RecordingManifest::new(
writer.recording(),
&target,
"cli",
VideoMeta {
path: files::VIDEO.to_string(),
container: outcome.container.clone(),
codec: outcome.codec.clone(),
fps: outcome.fps,
width: outcome.width,
height: outcome.height,
duration_ms: outcome.video_duration_ms,
},
audio_meta,
&transcript,
engine,
model,
outcome.ended_at,
);
if !outcome.window_title.is_empty() {
manifest.target.title = Some(outcome.window_title.clone());
}
if !outcome.window_exe.is_empty() {
manifest.target.exe = Some(outcome.window_exe.clone());
}
writer
.finalize(&manifest, &transcript)
.context("writing package")?;
println!(
"framewatch: wrote recording package to {} ({} transcript segment(s))",
dir.display(),
transcript.segments.len()
);
println!("{}", writer.recording().prompt_path().display());
Ok(())
}
#[cfg(not(feature = "record"))]
fn cmd_record(_args: RecordArgs) -> Result<()> {
anyhow::bail!(
"this build has no recording support. Reinstall with \
`cargo install framewatch --features \"cli wgc record\"` (Windows; needs ffmpeg on PATH)."
)
}
#[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 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());
}
#[test]
fn record_duration_rejects_zero() {
assert!(Cli::try_parse_from([
"framewatch",
"record",
"--title",
"test",
"--duration",
"0",
])
.is_err());
assert!(Cli::try_parse_from([
"framewatch",
"record",
"--title",
"test",
"--duration",
"1",
])
.is_ok());
}
#[cfg(feature = "record")]
#[test]
fn record_launch_waits_for_the_child_window_by_default() {
assert_eq!(effective_record_wait_ms(None, 0, true), 15_000);
assert_eq!(effective_record_wait_ms(None, 2_500, true), 2_500);
assert_eq!(effective_record_wait_ms(Some(7), 2_500, true), 7_000);
assert_eq!(effective_record_wait_ms(None, 0, false), 0);
}
#[test]
fn managed_transcriber_setup_command_parses() {
assert!(Cli::try_parse_from(["framewatch", "transcriber", "setup"]).is_ok());
}
#[cfg(windows)]
fn sleeper() -> std::process::Command {
let mut c = std::process::Command::new("ping");
c.args(["-n", "30", "127.0.0.1"])
.stdout(std::process::Stdio::null());
c
}
#[cfg(not(windows))]
fn sleeper() -> std::process::Command {
let mut c = std::process::Command::new("sleep");
c.arg("30");
c
}
#[test]
fn child_guard_kills_on_drop() {
let start = std::time::Instant::now();
{
let guard = ChildGuard::new(sleeper().spawn().expect("spawn sleeper"));
assert!(guard.id() > 0);
} assert!(
start.elapsed().as_secs() < 5,
"ChildGuard::drop should kill the child promptly"
);
}
}