#![warn(missing_docs)]
pub mod capture;
pub mod clock;
pub mod config;
pub mod detect;
pub mod engine;
pub mod error;
pub mod event;
pub mod frame;
pub mod recording;
pub mod session;
pub mod sink;
pub mod transcript;
#[cfg(feature = "record")]
mod whisper;
mod util;
#[cfg(feature = "record")]
mod audioutil;
#[cfg(feature = "gui")]
pub mod gui;
#[cfg(feature = "record")]
pub mod record;
pub use capture::{enumerate_windows, CaptureBackend, ControlFlow, MockBackend};
pub use clock::{Clock, MockClock, SystemClock};
pub use config::{Config, ConfigBuilder, ImageOpts, RoiHint, RoiKind, Rotation, Target};
pub use engine::Engine;
pub use error::{CaptureError, Error, RecordError, SinkError, TranscribeError};
pub use event::{CaptureEvent, CaptureMeta, EncodedImage, EventKind, ImageFormat, SaveMask};
pub use frame::{RawFrame, Rect, WindowInfo};
#[cfg(feature = "record")]
pub use record::{record, record_with_duration, RecordConfig, RecordOutcome};
pub use recording::{PackageWriter, Recording, RecordingManifest};
pub use sink::{ChannelSink, CompositeSink, DirectorySink, Sink};
pub use transcript::{Transcriber, Transcript, TranscriptSegment};
pub use util::tokenize;
#[cfg(feature = "record")]
pub use whisper::{ensure_managed_whisper, ManagedWhisper, WHISPER_MODEL, WHISPER_VERSION};
pub fn default_backend(config: &Config) -> Result<Box<dyn CaptureBackend>, Error> {
#[cfg(all(windows, feature = "wgc"))]
{
let backend = capture::windows::wgc::WgcBackend::for_target(&config.target)?;
Ok(Box::new(backend))
}
#[cfg(not(all(windows, feature = "wgc")))]
{
let _ = config;
Err(Error::NoBackend(
"live capture requires building on Windows with the `wgc` feature".into(),
))
}
}
pub fn watch(config: Config, sink: impl Sink) -> Result<(), Error> {
config.validate()?;
let backend = resolve_backend_waiting(&config)?;
watch_with(config, backend, sink)
}
pub fn watch_with<B: CaptureBackend, S: Sink>(
config: Config,
mut backend: B,
mut sink: S,
) -> Result<(), Error> {
config.validate()?;
if config.stop_after_ms > 0 {
if let Some(signal) = backend.stop_signal() {
let ms = config.stop_after_ms;
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(ms));
signal.store(true, std::sync::atomic::Ordering::Relaxed);
});
}
}
let stop_after_ms = config.stop_after_ms;
let stop_after_images = config.stop_after_images;
let stop_after_settled = config.stop_after_settled;
let crop = config.crop;
let mut engine = Engine::new(config, SystemClock);
let mut images: u64 = 0;
let start = std::time::Instant::now();
backend.run(&mut |frame| {
let frame = match crop {
Some(rect) => frame.crop(rect),
None => frame,
};
for event in engine.process(&frame, frame.captured_at) {
if event.image.is_some() {
images += 1;
}
let is_settled = event.kind() == EventKind::Settled;
if sink.on_event(&event).is_err() {
return ControlFlow::Stop;
}
if stop_after_settled && is_settled {
return ControlFlow::Stop;
}
if stop_after_images > 0 && images >= stop_after_images {
return ControlFlow::Stop;
}
}
if stop_after_ms > 0 && start.elapsed() >= std::time::Duration::from_millis(stop_after_ms) {
return ControlFlow::Stop;
}
ControlFlow::Continue
})?;
sink.flush()?;
Ok(())
}
fn resolve_backend_waiting(config: &Config) -> Result<Box<dyn CaptureBackend>, Error> {
wait_for_ok(
std::time::Duration::from_millis(config.wait_ms),
std::time::Duration::from_millis(250),
|| default_backend(config),
)
}
fn wait_for_ok<T>(
timeout: std::time::Duration,
poll: std::time::Duration,
mut attempt: impl FnMut() -> Result<T, Error>,
) -> Result<T, Error> {
let deadline = std::time::Instant::now() + timeout;
let mut waited = false;
loop {
match attempt() {
Ok(value) => return Ok(value),
Err(e) => {
let retryable = matches!(&e, Error::Capture(CaptureError::TargetNotFound(_)));
if retryable && std::time::Instant::now() < deadline {
if !waited {
tracing::info!("framewatch: waiting for target window to appear...");
waited = true;
}
std::thread::sleep(poll);
continue;
}
return Err(e);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
use std::time::Duration;
#[test]
fn wait_for_ok_no_wait_fails_fast() {
let calls = Cell::new(0);
let r: Result<(), Error> = wait_for_ok(Duration::ZERO, Duration::ZERO, || {
calls.set(calls.get() + 1);
Err(Error::Capture(CaptureError::TargetNotFound("x".into())))
});
assert!(r.is_err());
assert_eq!(calls.get(), 1, "no wait => exactly one attempt");
}
#[test]
fn wait_for_ok_retries_then_succeeds() {
let calls = Cell::new(0);
let r = wait_for_ok(Duration::from_secs(5), Duration::from_millis(1), || {
calls.set(calls.get() + 1);
if calls.get() < 3 {
Err(Error::Capture(CaptureError::TargetNotFound("x".into())))
} else {
Ok(42)
}
});
assert_eq!(r.unwrap(), 42);
assert_eq!(calls.get(), 3);
}
#[test]
fn wait_for_ok_does_not_retry_non_target_errors() {
let calls = Cell::new(0);
let r: Result<(), Error> = wait_for_ok(Duration::from_secs(5), Duration::ZERO, || {
calls.set(calls.get() + 1);
Err(Error::NoBackend("nope".into()))
});
assert!(r.is_err());
assert_eq!(calls.get(), 1, "non-retryable error returns immediately");
}
#[cfg(not(all(windows, feature = "wgc")))]
#[test]
fn no_backend_without_wgc() {
let cfg = Config::builder()
.target(Target::ByExe("x.exe".into()))
.build()
.unwrap();
assert!(matches!(default_backend(&cfg), Err(Error::NoBackend(_))));
let (sink, _rx) = ChannelSink::unbounded();
assert!(matches!(watch(cfg, sink), Err(Error::NoBackend(_))));
}
#[test]
fn watch_with_drives_mock_backend_through_crop() {
use crate::frame::{RawFrame, WindowInfo};
use std::time::Instant;
let mk = |v: u8| {
RawFrame::from_bgra(
vec![v; 40 * 30 * 4],
40,
30,
Instant::now(),
chrono::Utc::now(),
WindowInfo::synthetic("t", 40, 30),
)
};
let backend = MockBackend::new(vec![mk(10), mk(200)]);
let cfg = Config::builder()
.target(Target::ByExe("x.exe".into()))
.crop_xywh(0, 0, 20, 15) .stop_after_images(1)
.build()
.unwrap();
let (sink, rx) = ChannelSink::unbounded();
watch_with(cfg, backend, sink).unwrap();
let evs: Vec<_> = rx.try_iter().collect();
assert_eq!(evs.first().map(|e| e.kind()), Some(EventKind::Initial));
}
#[test]
fn watch_with_rejects_invalid_config() {
let mut cfg = Config::builder()
.target(Target::ByExe("x.exe".into()))
.build()
.unwrap();
cfg.tile_grid = (0, 0);
let (sink, _rx) = ChannelSink::unbounded();
assert!(matches!(
watch_with(cfg, MockBackend::new(vec![]), sink),
Err(Error::Config(_))
));
}
}