use crate::config::Target;
use crate::error::RecordError;
use crate::frame::Rect;
use chrono::{DateTime, Utc};
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
#[cfg(windows)]
mod audio;
#[cfg(windows)]
mod ffmpeg;
#[cfg(windows)]
mod video;
#[derive(Debug, Clone)]
pub struct RecordConfig {
pub target: Target,
pub crop: Option<Rect>,
pub fps: u32,
pub mic: Option<String>,
pub capture_audio: bool,
pub video_out: PathBuf,
pub audio_out: PathBuf,
pub work_dir: PathBuf,
pub wait_ms: u64,
pub stop: Arc<AtomicBool>,
}
#[derive(Debug, Clone)]
pub struct RecordOutcome {
pub width: u32,
pub height: u32,
pub fps: f32,
pub frames_written: u64,
pub video_duration_ms: u64,
pub audio: Option<AudioInfo>,
pub codec: String,
pub container: String,
pub window_title: String,
pub window_exe: String,
pub ended_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct AudioInfo {
pub sample_rate: u32,
pub channels: u16,
pub duration_ms: u64,
}
#[cfg(windows)]
const FIRST_FRAME_WAIT_MS: u64 = 10_000;
#[cfg(windows)]
pub fn record(cfg: RecordConfig) -> Result<RecordOutcome, RecordError> {
record_inner(cfg, None)
}
#[cfg(windows)]
pub fn record_with_duration(
cfg: RecordConfig,
duration: std::time::Duration,
) -> Result<RecordOutcome, RecordError> {
record_inner(cfg, Some(duration))
}
#[cfg(windows)]
fn record_inner(
cfg: RecordConfig,
duration: Option<std::time::Duration>,
) -> Result<RecordOutcome, RecordError> {
use crate::capture::CaptureBackend;
use std::sync::atomic::Ordering;
use std::sync::{Condvar, Mutex};
use std::time::{Duration, Instant};
if !ffmpeg::ffmpeg_available() {
return Err(RecordError::FfmpegNotFound);
}
let fps = cfg.fps.clamp(1, 60);
let backend = video::resolve_wgc(&cfg.target, cfg.wait_ms)?;
let window = backend.window().clone();
let wgc_stop = backend
.stop_signal()
.expect("WGC backend exposes a stop signal");
let mut audio = if cfg.capture_audio {
match audio::AudioRecorder::start(cfg.mic.as_deref(), &cfg.audio_out) {
Ok(a) => Some(a),
Err(e) => {
tracing::warn!("framewatch: microphone unavailable ({e}); recording video only");
None
}
}
} else {
None
};
let mailbox: video::FrameMailbox = Arc::new(Mutex::new(None));
let dims: video::DimsCell = Arc::new((Mutex::new(None), Condvar::new()));
let v0: Arc<Mutex<Option<Instant>>> = Arc::new(Mutex::new(None));
let capture = {
let (mailbox, dims, v0, stop) =
(mailbox.clone(), dims.clone(), v0.clone(), cfg.stop.clone());
let crop = cfg.crop;
std::thread::spawn(move || video::run_capture(backend, crop, mailbox, dims, v0, stop))
};
let locked = video::wait_for_dims(&dims, &cfg.stop, cfg.wait_ms + FIRST_FRAME_WAIT_MS);
let (width, height) = match locked {
Some(d) => d,
None => {
cfg.stop.store(true, Ordering::Relaxed);
wgc_stop.store(true, Ordering::Relaxed);
let capture_result = join_capture(capture);
if let Some(a) = audio.take() {
let _ = a.finish();
}
capture_result?;
return Err(RecordError::Capture(crate::error::CaptureError::Backend(
"the target window produced no frames to record (is it visible and rendering?)"
.into(),
)));
}
};
let temp_video = cfg.work_dir.join(".framewatch-video.tmp.mp4");
let mut encoder = match ffmpeg::VideoEncoder::spawn(width, height, fps, &temp_video) {
Ok(encoder) => encoder,
Err(error) => {
cfg.stop.store(true, Ordering::Relaxed);
wgc_stop.store(true, Ordering::Relaxed);
let _ = join_capture(capture);
if let Some(a) = audio.take() {
let _ = a.finish();
}
let _ = std::fs::remove_file(&temp_video);
return Err(error);
}
};
let pacing_start = Instant::now();
let stop_at = duration.map(|duration| pacing_start + duration);
let interval_ns = 1_000_000_000u64 / fps as u64;
let mut k: u64 = 0;
let mut frames_written: u64 = 0;
while !cfg.stop.load(Ordering::Relaxed) {
if stop_at.is_some_and(|deadline| Instant::now() >= deadline) {
break;
}
let mut deadline = pacing_start + Duration::from_nanos(k.saturating_mul(interval_ns));
if let Some(stop_at) = stop_at {
deadline = deadline.min(stop_at);
}
let now = Instant::now();
if now < deadline {
std::thread::sleep(deadline - now);
}
if cfg.stop.load(Ordering::Relaxed)
|| stop_at.is_some_and(|deadline| Instant::now() >= deadline)
{
break;
}
let frame = mailbox.lock().unwrap().clone();
if let Some(buf) = frame {
if encoder.write_frame(&buf).is_err() {
break; }
frames_written += 1;
}
k += 1;
}
cfg.stop.store(true, Ordering::Relaxed);
wgc_stop.store(true, Ordering::Relaxed);
let encoder_result = encoder.finish();
let capture_result = join_capture(capture);
let audio_result = match audio.take() {
Some(audio) => audio.finish().map(Some),
None => Ok(None),
};
if let Err(error) = encoder_result {
let _ = std::fs::remove_file(&temp_video);
return Err(error);
}
if let Err(error) = capture_result {
let _ = std::fs::remove_file(&temp_video);
return Err(error);
}
let audio_stats = match audio_result {
Ok(stats) => stats,
Err(error) => {
let _ = std::fs::remove_file(&temp_video);
return Err(error);
}
};
let audio_info = match audio_stats {
Some(stats) => {
let v0_inst = *v0.lock().unwrap();
let audio_offset_s = match (v0_inst, stats.first_sample_at) {
(Some(v), Some(a)) if a >= v => (a - v).as_secs_f64(),
(Some(v), Some(a)) => -(v - a).as_secs_f64(),
_ => 0.0,
};
let mux = ffmpeg::run_mux(&cfg.audio_out, &temp_video, audio_offset_s, &cfg.video_out);
let _ = std::fs::remove_file(&temp_video);
mux?;
Some(AudioInfo {
sample_rate: stats.sample_rate,
channels: stats.channels,
duration_ms: stats.duration_ms,
})
}
None => {
std::fs::rename(&temp_video, &cfg.video_out).or_else(|_| {
std::fs::copy(&temp_video, &cfg.video_out)
.and_then(|_| std::fs::remove_file(&temp_video))
})?;
None
}
};
Ok(RecordOutcome {
width,
height,
fps: fps as f32,
frames_written,
video_duration_ms: frames_written * 1000 / fps as u64,
audio: audio_info,
codec: "h264".into(),
container: "mp4".into(),
window_title: window.title,
window_exe: window.exe,
ended_at: Utc::now(),
})
}
#[cfg(windows)]
fn join_capture(
capture: std::thread::JoinHandle<Result<(), crate::error::CaptureError>>,
) -> Result<(), RecordError> {
capture.join().map_err(|_| {
RecordError::Capture(crate::error::CaptureError::Backend(
"capture thread panicked".into(),
))
})??;
Ok(())
}
#[cfg(not(windows))]
pub fn record(_cfg: RecordConfig) -> Result<RecordOutcome, RecordError> {
Err(RecordError::Unsupported)
}
#[cfg(not(windows))]
pub fn record_with_duration(
_cfg: RecordConfig,
_duration: std::time::Duration,
) -> Result<RecordOutcome, RecordError> {
Err(RecordError::Unsupported)
}