use crate::cdp::session::CdpSession;
use crate::error::{Error, Result};
use base64::Engine;
use parking_lot::Mutex;
use serde_json::{json, Value};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use tokio::sync::broadcast;
#[derive(Debug, Clone, Default)]
#[non_exhaustive]
pub struct VideoStartOptions {
pub format: Option<String>,
pub quality: Option<i64>,
pub every_nth_frame: Option<i64>,
pub max_width: Option<i64>,
pub max_height: Option<i64>,
}
impl VideoStartOptions {
pub fn format(mut self, v: impl Into<String>) -> Self {
self.format = Some(v.into());
self
}
pub fn quality(mut self, v: i64) -> Self {
self.quality = Some(v);
self
}
pub fn every_nth_frame(mut self, v: i64) -> Self {
self.every_nth_frame = Some(v);
self
}
pub fn max_width(mut self, v: i64) -> Self {
self.max_width = Some(v);
self
}
pub fn max_height(mut self, v: i64) -> Self {
self.max_height = Some(v);
self
}
}
#[derive(Clone)]
pub struct Video {
inner: Arc<VideoInner>,
}
struct VideoInner {
session: Arc<CdpSession>,
path: Mutex<Option<PathBuf>>,
recording: AtomicBool,
frame_count: Arc<AtomicU64>,
}
impl Video {
pub(crate) fn new(session: Arc<CdpSession>) -> Self {
Self {
inner: Arc::new(VideoInner {
session,
path: Mutex::new(None),
recording: AtomicBool::new(false),
frame_count: Arc::new(AtomicU64::new(0)),
}),
}
}
pub(crate) fn with_path(session: Arc<CdpSession>, path: PathBuf) -> Self {
let v = Self::new(session);
*v.inner.path.lock() = Some(path);
v
}
pub fn path(&self) -> Option<PathBuf> {
self.inner.path.lock().clone()
}
pub async fn start(&self, options: Option<VideoStartOptions>) -> Result<()> {
let opts = options.unwrap_or_default();
if self.inner.recording.swap(true, Ordering::AcqRel) {
return Err(Error::InvalidArgument(
"Video::start called while already recording".into(),
));
}
self.inner.frame_count.store(0, Ordering::Release);
let mut params = json!({});
if let Some(fmt) = &opts.format {
params["format"] = json!(fmt);
}
if let Some(q) = opts.quality {
params["quality"] = json!(q);
}
if let Some(n) = opts.every_nth_frame {
params["everyNthFrame"] = json!(n);
}
if let Some(w) = opts.max_width {
params["maxWidth"] = json!(w);
}
if let Some(h) = opts.max_height {
params["maxHeight"] = json!(h);
}
let mut rx = self.inner.session.subscribe();
let path = self.inner.path.lock().clone();
let frame_count = Arc::clone(&self.inner.frame_count);
let session_for_ack = Arc::clone(&self.inner.session);
tokio::spawn(async move {
let engine = base64::engine::general_purpose::STANDARD;
loop {
let ev = match rx.recv().await {
Ok(ev) if ev.method == "Page.screencastFrame" => ev,
Ok(_) => continue,
Err(broadcast::error::RecvError::Closed) => break,
Err(broadcast::error::RecvError::Lagged(_)) => continue,
};
if let Some(id) = ev.params.get("sessionId").and_then(|v| v.as_i64()) {
let _ = session_for_ack
.send("Page.screencastFrameAck", json!({ "sessionId": id }))
.await;
}
let is_metadata = ev
.params
.get("metadata")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !is_metadata {
if let Some(data_str) = ev.params.get("data").and_then(|v| v.as_str()) {
if let Ok(bytes) = engine.decode(data_str) {
let idx = frame_count.fetch_add(1, Ordering::Relaxed);
let _ = write_frame(&path, idx, &bytes).await;
}
}
}
}
});
self.inner
.session
.send("Page.startScreencast", params)
.await
.map(|_: Value| ())?;
Ok(())
}
pub async fn stop(&self) -> Result<()> {
if !self.inner.recording.swap(false, Ordering::AcqRel) {
return Err(Error::InvalidArgument(
"Video::stop called without a preceding Video::start".into(),
));
}
self.inner
.session
.send("Page.stopScreencast", json!({}))
.await
.map(|_: Value| ())?;
Ok(())
}
pub fn frame_count(&self) -> u64 {
self.inner.frame_count.load(Ordering::Relaxed)
}
pub fn is_recording(&self) -> bool {
self.inner.recording.load(Ordering::Acquire)
}
}
async fn write_frame(base: &Option<PathBuf>, index: u64, bytes: &[u8]) -> std::io::Result<()> {
let Some(base) = base else {
return Ok(());
};
if base.is_dir() {
let ext = sniff_ext(bytes);
let name = format!("frame-{index:05}.{ext}");
tokio::fs::write(base.join(name), bytes).await
} else {
if let Some(parent) = base.parent() {
if !parent.as_os_str().is_empty() {
let _ = tokio::fs::create_dir_all(parent).await;
}
}
use tokio::io::AsyncWriteExt;
let mut f = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(base)
.await?;
f.write_all(bytes).await?;
Ok(())
}
}
fn sniff_ext(bytes: &[u8]) -> &'static str {
if bytes.starts_with(&[0x89, 0x50, 0x4e, 0x47]) {
"png"
} else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
"jpg"
} else {
"bin"
}
}