use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Instant;
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
use chromiumoxide::cdp::browser_protocol::emulation::SetDeviceMetricsOverrideParams;
use chromiumoxide::cdp::browser_protocol::page::{
EventScreencastFrame, ScreencastFrameAckParams, StartScreencastFormat, StartScreencastParams,
StopScreencastParams,
};
use chromiumoxide::Page;
use futures::StreamExt;
use tokio::sync::Mutex;
use crate::backend::BrowserError;
#[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,
stop: Arc<AtomicBool>,
frames: Arc<Mutex<Vec<(PathBuf, f64)>>>,
started: Instant,
page: Page,
task: tokio::task::JoinHandle<()>,
}
impl RecordingHandle {
pub async fn stop(self) -> Result<Recording, BrowserError> {
self.stop.store(true, Ordering::SeqCst);
let _ = self.page.execute(StopScreencastParams::default()).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,
manifest: manifest_path,
frame_count: frames.len(),
duration_seconds: elapsed,
})
}
}
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 mut events = page
.event_listener::<EventScreencastFrame>()
.await
.map_err(|e| BrowserError::ScreenshotFailed(format!("screencast listener: {e}")))?;
page.execute(
SetDeviceMetricsOverrideParams::builder()
.width(max_width as i64)
.height(max_height as i64)
.device_scale_factor(1.0)
.mobile(false)
.build()
.map_err(|e| BrowserError::ScreenshotFailed(format!("device metrics params: {e}")))?,
)
.await
.map_err(|e| BrowserError::ScreenshotFailed(format!("setDeviceMetricsOverride: {e}")))?;
page.execute(
StartScreencastParams::builder()
.format(StartScreencastFormat::Jpeg)
.quality(quality.clamp(1, 100))
.every_nth_frame(every_nth_frame.max(1))
.max_width(max_width as i64)
.max_height(max_height as i64)
.build(),
)
.await
.map_err(|e| BrowserError::ScreenshotFailed(format!("startScreencast: {e}")))?;
let stop = Arc::new(AtomicBool::new(false));
let frames: Arc<Mutex<Vec<(PathBuf, f64)>>> = Arc::new(Mutex::new(Vec::new()));
let started = Instant::now();
let task = {
let stop = Arc::clone(&stop);
let frames = Arc::clone(&frames);
let dir = dir.to_path_buf();
let page = page.clone();
tokio::spawn(async move {
let mut n = 0usize;
while let Some(frame) = events.next().await {
if stop.load(Ordering::SeqCst) {
break;
}
let _ = page
.execute(ScreencastFrameAckParams::new(frame.session_id))
.await;
let Ok(bytes) = BASE64.decode(AsRef::<str>::as_ref(&frame.data)) else {
continue;
};
let path = dir.join(format!("frame-{n:06}.jpg"));
if std::fs::write(&path, &bytes).is_ok() {
frames
.lock()
.await
.push((path, started.elapsed().as_secs_f64()));
n += 1;
}
}
})
};
Ok(RecordingHandle {
dir: dir.to_path_buf(),
stop,
frames,
started,
page: page.clone(),
task,
})
}
#[cfg(test)]
mod tests {
#[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);
}
}