viewpoint_core/page/video/
mod.rs1use std::path::PathBuf;
9use std::sync::Arc;
10use std::time::Instant;
11
12use tokio::sync::RwLock;
13use tracing::{debug, info};
14use viewpoint_cdp::protocol::{
15 ScreencastFormat, ScreencastFrameAckParams, ScreencastFrameEvent,
16 StartScreencastParams, StopScreencastParams,
17};
18use viewpoint_cdp::CdpConnection;
19
20use crate::error::PageError;
21
22#[derive(Debug, Clone)]
24pub struct VideoOptions {
25 pub dir: PathBuf,
27 pub width: Option<i32>,
29 pub height: Option<i32>,
31}
32
33impl VideoOptions {
34 pub fn new(dir: impl Into<PathBuf>) -> Self {
36 Self {
37 dir: dir.into(),
38 width: None,
39 height: None,
40 }
41 }
42
43 #[must_use]
45 pub fn width(mut self, width: i32) -> Self {
46 self.width = Some(width);
47 self
48 }
49
50 #[must_use]
52 pub fn height(mut self, height: i32) -> Self {
53 self.height = Some(height);
54 self
55 }
56
57 #[must_use]
59 pub fn size(mut self, width: i32, height: i32) -> Self {
60 self.width = Some(width);
61 self.height = Some(height);
62 self
63 }
64}
65
66impl Default for VideoOptions {
67 fn default() -> Self {
68 Self {
69 dir: std::env::temp_dir().join("viewpoint-videos"),
70 width: None,
71 height: None,
72 }
73 }
74}
75
76#[derive(Debug, Clone)]
78pub(super) struct RecordedFrame {
79 data: Vec<u8>,
81 timestamp: f64,
83}
84
85#[derive(Debug)]
87#[derive(Default)]
88pub(super) struct VideoState {
89 pub(super) recording: bool,
91 pub(super) frames: Vec<RecordedFrame>,
93 pub(super) start_time: Option<Instant>,
95 pub(super) options: VideoOptions,
97 pub(super) video_path: Option<PathBuf>,
99}
100
101
102#[derive(Debug)]
130pub struct Video {
131 connection: Arc<CdpConnection>,
133 session_id: String,
135 pub(super) state: Arc<RwLock<VideoState>>,
137}
138
139impl Video {
140 pub(crate) fn new(connection: Arc<CdpConnection>, session_id: String) -> Self {
142 Self {
143 connection,
144 session_id,
145 state: Arc::new(RwLock::new(VideoState::default())),
146 }
147 }
148
149 pub(crate) fn with_options(
151 connection: Arc<CdpConnection>,
152 session_id: String,
153 options: VideoOptions,
154 ) -> Self {
155 Self {
156 connection,
157 session_id,
158 state: Arc::new(RwLock::new(VideoState {
159 options,
160 ..Default::default()
161 })),
162 }
163 }
164
165 pub(crate) async fn start_recording(&self) -> Result<(), PageError> {
169 let mut state = self.state.write().await;
170 if state.recording {
171 return Ok(()); }
173
174 tokio::fs::create_dir_all(&state.options.dir)
176 .await
177 .map_err(|e| PageError::EvaluationFailed(format!("Failed to create video directory: {e}")))?;
178
179 let mut params = StartScreencastParams::new()
181 .format(ScreencastFormat::Jpeg)
182 .quality(80);
183
184 if let Some(width) = state.options.width {
185 params = params.max_width(width);
186 }
187 if let Some(height) = state.options.height {
188 params = params.max_height(height);
189 }
190
191 self.connection
193 .send_command::<_, serde_json::Value>(
194 "Page.startScreencast",
195 Some(params),
196 Some(&self.session_id),
197 )
198 .await?;
199
200 state.recording = true;
201 state.start_time = Some(Instant::now());
202 state.frames.clear();
203
204 info!("Started video recording");
205
206 self.start_frame_listener();
208
209 Ok(())
210 }
211
212 fn start_frame_listener(&self) {
214 let mut events = self.connection.subscribe_events();
215 let session_id = self.session_id.clone();
216 let connection = self.connection.clone();
217 let state = self.state.clone();
218
219 tokio::spawn(async move {
220 while let Ok(event) = events.recv().await {
221 if event.session_id.as_deref() != Some(&session_id) {
223 continue;
224 }
225
226 if event.method == "Page.screencastFrame" {
227 if let Some(params) = &event.params {
228 if let Ok(frame_event) = serde_json::from_value::<ScreencastFrameEvent>(params.clone()) {
229 let is_recording = {
231 let s = state.read().await;
232 s.recording
233 };
234
235 if !is_recording {
236 break;
237 }
238
239 if let Ok(data) = base64::Engine::decode(
241 &base64::engine::general_purpose::STANDARD,
242 &frame_event.data,
243 ) {
244 let timestamp = frame_event.metadata.timestamp.unwrap_or(0.0);
245
246 {
248 let mut s = state.write().await;
249 s.frames.push(RecordedFrame { data, timestamp });
250 }
251
252 let _ = connection
254 .send_command::<_, serde_json::Value>(
255 "Page.screencastFrameAck",
256 Some(ScreencastFrameAckParams {
257 session_id: frame_event.session_id,
258 }),
259 Some(&session_id),
260 )
261 .await;
262 }
263 }
264 }
265 }
266 }
267 });
268 }
269
270 pub(crate) async fn stop_recording(&self) -> Result<PathBuf, PageError> {
274 let mut state = self.state.write().await;
275 if !state.recording {
276 if let Some(ref path) = state.video_path {
277 return Ok(path.clone());
278 }
279 return Err(PageError::EvaluationFailed("Video recording not started".to_string()));
280 }
281
282 self.connection
284 .send_command::<_, serde_json::Value>(
285 "Page.stopScreencast",
286 Some(StopScreencastParams {}),
287 Some(&self.session_id),
288 )
289 .await?;
290
291 state.recording = false;
292
293 let video_path = self.generate_video(&state).await?;
295 state.video_path = Some(video_path.clone());
296
297 info!("Stopped video recording, saved to {:?}", video_path);
298
299 Ok(video_path)
300 }
301
302 async fn generate_video(&self, state: &VideoState) -> Result<PathBuf, PageError> {
304 if state.frames.is_empty() {
305 return Err(PageError::EvaluationFailed("No frames recorded".to_string()));
306 }
307
308 let filename = format!(
310 "video-{}.webm",
311 uuid::Uuid::new_v4().to_string().split('-').next().unwrap_or("unknown")
312 );
313 let video_path = state.options.dir.join(&filename);
314
315 let frames_dir = state.options.dir.join(format!("frames-{}",
321 uuid::Uuid::new_v4().to_string().split('-').next().unwrap_or("unknown")));
322 tokio::fs::create_dir_all(&frames_dir)
323 .await
324 .map_err(|e| PageError::EvaluationFailed(format!("Failed to create frames directory: {e}")))?;
325
326 for (i, frame) in state.frames.iter().enumerate() {
328 let frame_path = frames_dir.join(format!("frame-{i:05}.jpg"));
329 tokio::fs::write(&frame_path, &frame.data)
330 .await
331 .map_err(|e| PageError::EvaluationFailed(format!("Failed to write frame: {e}")))?;
332 }
333
334 let metadata = serde_json::json!({
336 "type": "viewpoint-video",
337 "format": "jpeg-sequence",
338 "frame_count": state.frames.len(),
339 "frames_dir": frames_dir.to_string_lossy(),
340 });
341
342 tokio::fs::write(&video_path, serde_json::to_string_pretty(&metadata).unwrap())
343 .await
344 .map_err(|e| PageError::EvaluationFailed(format!("Failed to write video metadata: {e}")))?;
345
346 debug!("Saved {} frames to {:?}", state.frames.len(), frames_dir);
347
348 Ok(video_path)
349 }
350
351 pub async fn path(&self) -> Result<PathBuf, PageError> {
364 let state = self.state.read().await;
365
366 if let Some(ref path) = state.video_path {
367 return Ok(path.clone());
368 }
369
370 if state.recording {
371 return Err(PageError::EvaluationFailed(
372 "Video is still recording. Call stop_recording() first.".to_string()
373 ));
374 }
375
376 Err(PageError::EvaluationFailed("No video recorded".to_string()))
377 }
378
379 pub async fn is_recording(&self) -> bool {
383 let state = self.state.read().await;
384 state.recording
385 }
386}
387
388impl super::Page {
390 pub fn video(&self) -> Option<&Video> {
413 self.video_controller.as_ref().map(std::convert::AsRef::as_ref)
414 }
415
416 pub(crate) async fn start_video_recording(&self) -> Result<(), PageError> {
418 if let Some(ref video) = self.video_controller {
419 video.start_recording().await
420 } else {
421 Ok(())
422 }
423 }
424
425 pub(crate) async fn stop_video_recording(&self) -> Result<Option<std::path::PathBuf>, PageError> {
427 if let Some(ref video) = self.video_controller {
428 Ok(Some(video.stop_recording().await?))
429 } else {
430 Ok(None)
431 }
432 }
433}
434
435