use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use chromiumoxide::Page;
use tokio::sync::Mutex;
use crate::backend::BrowserError;
use crate::screencast::ScreencastPump;
#[derive(Debug, Clone)]
pub struct Recording {
pub dir: PathBuf,
pub manifest: PathBuf,
pub frame_count: usize,
pub duration_seconds: f64,
}
pub struct RecordingHandle {
dir: PathBuf,
pump: Option<ScreencastPump>,
frames: Arc<Mutex<Vec<(PathBuf, f64)>>>,
started: Instant,
task: tokio::task::JoinHandle<()>,
}
impl RecordingHandle {
pub async fn stop(self) -> Result<Recording, BrowserError> {
if let Some(pump) = &self.pump {
pump.stop().await;
}
self.task.abort();
let frames = self.frames.lock().await.clone();
let elapsed = self.started.elapsed().as_secs_f64();
if frames.is_empty() {
return Err(BrowserError::ScreenshotFailed(
"screencast captured no frames — the page never changed, or the \
recording was stopped before Chrome emitted anything"
.to_string(),
));
}
let mut manifest = String::new();
for (i, (path, at)) in frames.iter().enumerate() {
let next = frames.get(i + 1).map(|(_, t)| *t).unwrap_or(elapsed);
let dur = (next - at).max(0.001);
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("frame.jpg");
manifest.push_str(&format!("file '{name}'\nduration {dur:.4}\n"));
}
if let Some((path, _)) = frames.last() {
if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
manifest.push_str(&format!("file '{name}'\n"));
}
}
let manifest_path = self.dir.join("frames.txt");
std::fs::write(&manifest_path, manifest)
.map_err(|e| BrowserError::ScreenshotFailed(format!("write concat manifest: {e}")))?;
Ok(Recording {
dir: self.dir.clone(),
manifest: manifest_path,
frame_count: frames.len(),
duration_seconds: elapsed,
})
}
}
impl Drop for RecordingHandle {
fn drop(&mut self) {
self.task.abort();
}
}
pub async fn start(
page: &Page,
dir: &Path,
quality: i64,
every_nth_frame: i64,
max_width: u32,
max_height: u32,
) -> Result<RecordingHandle, BrowserError> {
std::fs::create_dir_all(dir)
.map_err(|e| BrowserError::ScreenshotFailed(format!("create frame dir: {e}")))?;
let pump =
ScreencastPump::attach(page, quality, every_nth_frame, max_width, max_height).await?;
let (incoming, started) = pump.subscribe().await?;
let mut handle = start_with_frames(incoming, started, dir).await?;
handle.pump = Some(pump);
Ok(handle)
}
pub async fn start_with_frames(
mut incoming: crate::screencast::FrameReceiver,
started: Instant,
dir: &Path,
) -> Result<RecordingHandle, BrowserError> {
std::fs::create_dir_all(dir)
.map_err(|e| BrowserError::ScreenshotFailed(format!("create frame dir: {e}")))?;
let frames: Arc<Mutex<Vec<(PathBuf, f64)>>> = Arc::new(Mutex::new(Vec::new()));
let task = {
let frames = Arc::clone(&frames);
let dir = dir.to_path_buf();
tokio::spawn(async move {
let mut n = 0usize;
while let Some(frame) = incoming.recv().await {
let path = dir.join(format!("frame-{n:06}.jpg"));
if std::fs::write(&path, &frame.jpeg).is_ok() {
frames.lock().await.push((path, frame.captured_at));
n += 1;
}
}
})
};
Ok(RecordingHandle {
dir: dir.to_path_buf(),
pump: None,
frames,
started,
task,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn dropping_a_recording_handle_ends_its_frame_writer() {
let dir = tempfile::tempdir().expect("temp dir");
let (tx, rx) = tokio::sync::mpsc::channel(crate::screencast::FRAME_CHANNEL_CAP);
let handle = start_with_frames(rx, Instant::now(), dir.path())
.await
.expect("writer starts");
assert!(!tx.is_closed(), "the writer is draining frames");
drop(handle);
for _ in 0..200 {
if tx.is_closed() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
assert!(
tx.is_closed(),
"a dropped handle must not leave a detached task draining frames to disk"
);
}
#[test]
fn manifest_shape_holds_the_tail_frame() {
let frames = [("frame-000000.jpg", 0.0), ("frame-000001.jpg", 1.5)];
let elapsed = 4.0;
let mut manifest = String::new();
for (i, (name, at)) in frames.iter().enumerate() {
let next = frames.get(i + 1).map(|(_, t)| *t).unwrap_or(elapsed);
manifest.push_str(&format!("file '{name}'\nduration {:.4}\n", next - at));
}
manifest.push_str(&format!("file '{}'\n", frames.last().unwrap().0));
assert!(manifest.contains("duration 1.5000"));
assert!(manifest.contains("duration 2.5000"));
assert_eq!(manifest.matches("frame-000001.jpg").count(), 2);
}
}