use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::RwLock;
use tracing::{debug, info};
use viewpoint_cdp::CdpConnection;
use viewpoint_cdp::protocol::{
ScreencastFormat, ScreencastFrameAckParams, ScreencastFrameEvent, StartScreencastParams,
StopScreencastParams,
};
use crate::error::PageError;
#[derive(Debug, Clone)]
pub struct VideoOptions {
pub dir: PathBuf,
pub width: Option<i32>,
pub height: Option<i32>,
}
impl VideoOptions {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self {
dir: dir.into(),
width: None,
height: None,
}
}
#[must_use]
pub fn width(mut self, width: i32) -> Self {
self.width = Some(width);
self
}
#[must_use]
pub fn height(mut self, height: i32) -> Self {
self.height = Some(height);
self
}
#[must_use]
pub fn size(mut self, width: i32, height: i32) -> Self {
self.width = Some(width);
self.height = Some(height);
self
}
}
impl Default for VideoOptions {
fn default() -> Self {
Self {
dir: std::env::temp_dir().join("viewpoint-videos"),
width: None,
height: None,
}
}
}
#[derive(Debug, Clone)]
pub(super) struct RecordedFrame {
data: Vec<u8>,
timestamp: f64,
}
#[derive(Debug, Default)]
pub(super) struct VideoState {
pub(super) recording: bool,
pub(super) frames: Vec<RecordedFrame>,
pub(super) start_time: Option<Instant>,
pub(super) options: VideoOptions,
pub(super) video_path: Option<PathBuf>,
}
#[derive(Debug)]
pub struct Video {
connection: Arc<CdpConnection>,
session_id: String,
pub(super) state: Arc<RwLock<VideoState>>,
}
impl Video {
pub(crate) fn new(connection: Arc<CdpConnection>, session_id: String) -> Self {
Self {
connection,
session_id,
state: Arc::new(RwLock::new(VideoState::default())),
}
}
pub(crate) fn with_options(
connection: Arc<CdpConnection>,
session_id: String,
options: VideoOptions,
) -> Self {
Self {
connection,
session_id,
state: Arc::new(RwLock::new(VideoState {
options,
..Default::default()
})),
}
}
pub(crate) async fn start_recording(&self) -> Result<(), PageError> {
let mut state = self.state.write().await;
if state.recording {
return Ok(()); }
tokio::fs::create_dir_all(&state.options.dir)
.await
.map_err(|e| {
PageError::EvaluationFailed(format!("Failed to create video directory: {e}"))
})?;
let mut params = StartScreencastParams::new()
.format(ScreencastFormat::Jpeg)
.quality(80);
if let Some(width) = state.options.width {
params = params.max_width(width);
}
if let Some(height) = state.options.height {
params = params.max_height(height);
}
self.connection
.send_command::<_, serde_json::Value>(
"Page.startScreencast",
Some(params),
Some(&self.session_id),
)
.await?;
state.recording = true;
state.start_time = Some(Instant::now());
state.frames.clear();
info!("Started video recording");
self.start_frame_listener();
Ok(())
}
fn start_frame_listener(&self) {
let mut events = self.connection.subscribe_events();
let session_id = self.session_id.clone();
let connection = self.connection.clone();
let state = self.state.clone();
tokio::spawn(async move {
while let Ok(event) = events.recv().await {
if event.session_id.as_deref() != Some(&session_id) {
continue;
}
if event.method == "Page.screencastFrame" {
if let Some(params) = &event.params {
if let Ok(frame_event) =
serde_json::from_value::<ScreencastFrameEvent>(params.clone())
{
let is_recording = {
let s = state.read().await;
s.recording
};
if !is_recording {
break;
}
if let Ok(data) = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
&frame_event.data,
) {
let timestamp = frame_event.metadata.timestamp.unwrap_or(0.0);
{
let mut s = state.write().await;
s.frames.push(RecordedFrame { data, timestamp });
}
let _ = connection
.send_command::<_, serde_json::Value>(
"Page.screencastFrameAck",
Some(ScreencastFrameAckParams {
session_id: frame_event.session_id,
}),
Some(&session_id),
)
.await;
}
}
}
}
}
});
}
pub(crate) async fn stop_recording(&self) -> Result<PathBuf, PageError> {
let mut state = self.state.write().await;
if !state.recording {
if let Some(ref path) = state.video_path {
return Ok(path.clone());
}
return Err(PageError::EvaluationFailed(
"Video recording not started".to_string(),
));
}
self.connection
.send_command::<_, serde_json::Value>(
"Page.stopScreencast",
Some(StopScreencastParams {}),
Some(&self.session_id),
)
.await?;
state.recording = false;
let video_path = self.generate_video(&state).await?;
state.video_path = Some(video_path.clone());
info!("Stopped video recording, saved to {:?}", video_path);
Ok(video_path)
}
async fn generate_video(&self, state: &VideoState) -> Result<PathBuf, PageError> {
if state.frames.is_empty() {
return Err(PageError::EvaluationFailed(
"No frames recorded".to_string(),
));
}
let filename = format!(
"video-{}.webm",
uuid::Uuid::new_v4()
.to_string()
.split('-')
.next()
.unwrap_or("unknown")
);
let video_path = state.options.dir.join(&filename);
let frames_dir = state.options.dir.join(format!(
"frames-{}",
uuid::Uuid::new_v4()
.to_string()
.split('-')
.next()
.unwrap_or("unknown")
));
tokio::fs::create_dir_all(&frames_dir).await.map_err(|e| {
PageError::EvaluationFailed(format!("Failed to create frames directory: {e}"))
})?;
for (i, frame) in state.frames.iter().enumerate() {
let frame_path = frames_dir.join(format!("frame-{i:05}.jpg"));
tokio::fs::write(&frame_path, &frame.data)
.await
.map_err(|e| PageError::EvaluationFailed(format!("Failed to write frame: {e}")))?;
}
let metadata = serde_json::json!({
"type": "viewpoint-video",
"format": "jpeg-sequence",
"frame_count": state.frames.len(),
"frames_dir": frames_dir.to_string_lossy(),
});
tokio::fs::write(
&video_path,
serde_json::to_string_pretty(&metadata).unwrap(),
)
.await
.map_err(|e| PageError::EvaluationFailed(format!("Failed to write video metadata: {e}")))?;
debug!("Saved {} frames to {:?}", state.frames.len(), frames_dir);
Ok(video_path)
}
pub async fn path(&self) -> Result<PathBuf, PageError> {
let state = self.state.read().await;
if let Some(ref path) = state.video_path {
return Ok(path.clone());
}
if state.recording {
return Err(PageError::EvaluationFailed(
"Video is still recording. Call stop_recording() first.".to_string(),
));
}
Err(PageError::EvaluationFailed("No video recorded".to_string()))
}
pub async fn is_recording(&self) -> bool {
let state = self.state.read().await;
state.recording
}
}
impl super::Page {
pub fn video(&self) -> Option<&Video> {
self.video_controller
.as_ref()
.map(std::convert::AsRef::as_ref)
}
pub(crate) async fn start_video_recording(&self) -> Result<(), PageError> {
if let Some(ref video) = self.video_controller {
video.start_recording().await
} else {
Ok(())
}
}
pub(crate) async fn stop_video_recording(
&self,
) -> Result<Option<std::path::PathBuf>, PageError> {
if let Some(ref video) = self.video_controller {
Ok(Some(video.stop_recording().await?))
} else {
Ok(None)
}
}
}