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> {
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 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.join();
if let Some(a) = audio {
let _ = a.finish();
}
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 = ffmpeg::VideoEncoder::spawn(width, height, fps, &temp_video)?;
let pacing_start = Instant::now();
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) {
let deadline = pacing_start + Duration::from_nanos(k.saturating_mul(interval_ns));
let now = Instant::now();
if now < deadline {
std::thread::sleep(deadline - now);
}
if cfg.stop.load(Ordering::Relaxed) {
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);
encoder.finish()?;
let _ = capture.join();
let audio_info = match audio {
Some(audio) => {
let stats = audio.finish()?;
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,
};
ffmpeg::run_mux(&cfg.audio_out, &temp_video, audio_offset_s, &cfg.video_out)?;
let _ = std::fs::remove_file(&temp_video);
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(not(windows))]
pub fn record(_cfg: RecordConfig) -> Result<RecordOutcome, RecordError> {
Err(RecordError::Unsupported)
}