viewpoint_core/page/video/
mod.rs

1//! Video recording for pages.
2//!
3//! This module provides video recording functionality using CDP's screencast feature.
4//! Videos are recorded as a sequence of JPEG frames and can be saved as `WebM` files.
5
6// Allow dead code for video recording scaffolding (spec: video-recording)
7
8use 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/// Options for video recording.
23#[derive(Debug, Clone)]
24pub struct VideoOptions {
25    /// Directory to save videos in.
26    pub dir: PathBuf,
27    /// Video width (max).
28    pub width: Option<i32>,
29    /// Video height (max).
30    pub height: Option<i32>,
31}
32
33impl VideoOptions {
34    /// Create new video options with a directory.
35    pub fn new(dir: impl Into<PathBuf>) -> Self {
36        Self {
37            dir: dir.into(),
38            width: None,
39            height: None,
40        }
41    }
42
43    /// Set the maximum width for the video.
44    #[must_use]
45    pub fn width(mut self, width: i32) -> Self {
46        self.width = Some(width);
47        self
48    }
49
50    /// Set the maximum height for the video.
51    #[must_use]
52    pub fn height(mut self, height: i32) -> Self {
53        self.height = Some(height);
54        self
55    }
56
57    /// Set the video size.
58    #[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/// A recorded frame from the screencast.
77#[derive(Debug, Clone)]
78pub(super) struct RecordedFrame {
79    /// JPEG image data.
80    data: Vec<u8>,
81    /// Timestamp when the frame was captured.
82    timestamp: f64,
83}
84
85/// Internal state for video recording.
86#[derive(Debug)]
87#[derive(Default)]
88pub(super) struct VideoState {
89    /// Whether recording is active.
90    pub(super) recording: bool,
91    /// Recorded frames.
92    pub(super) frames: Vec<RecordedFrame>,
93    /// Start time for timing.
94    pub(super) start_time: Option<Instant>,
95    /// Video options.
96    pub(super) options: VideoOptions,
97    /// Generated video path (set when recording stops).
98    pub(super) video_path: Option<PathBuf>,
99}
100
101
102/// Video recording controller for a page.
103///
104/// Videos are recorded using CDP's screencast feature which captures
105/// compressed frames from the browser. These frames are then assembled
106/// into a video file.
107///
108/// # Example
109///
110/// ```
111/// # #[cfg(feature = "integration")]
112/// # tokio_test::block_on(async {
113/// # use viewpoint_core::Browser;
114/// use viewpoint_core::page::VideoOptions;
115/// # let browser = Browser::launch().headless(true).launch().await.unwrap();
116///
117/// // Recording is usually started via context options
118/// let context = browser.new_context_builder()
119///     .record_video(VideoOptions::new("./videos"))
120///     .build()
121///     .await.unwrap();
122///
123/// let page = context.new_page().await.unwrap();
124/// page.goto("https://example.com").goto().await.unwrap();
125///
126/// // Get the video path after recording
127/// if let Some(video) = page.video() {
128///     // Video operations available here
129///     // let path = video.path().await?;
130/// }
131/// # });
132/// ```
133#[derive(Debug)]
134pub struct Video {
135    /// CDP connection.
136    connection: Arc<CdpConnection>,
137    /// Session ID.
138    session_id: String,
139    /// Internal state.
140    pub(super) state: Arc<RwLock<VideoState>>,
141}
142
143impl Video {
144    /// Create a new video controller.
145    pub(crate) fn new(connection: Arc<CdpConnection>, session_id: String) -> Self {
146        Self {
147            connection,
148            session_id,
149            state: Arc::new(RwLock::new(VideoState::default())),
150        }
151    }
152
153    /// Create a new video controller with options.
154    pub(crate) fn with_options(
155        connection: Arc<CdpConnection>,
156        session_id: String,
157        options: VideoOptions,
158    ) -> Self {
159        Self {
160            connection,
161            session_id,
162            state: Arc::new(RwLock::new(VideoState {
163                options,
164                ..Default::default()
165            })),
166        }
167    }
168
169    /// Start recording video.
170    ///
171    /// This starts the CDP screencast which captures frames from the page.
172    pub(crate) async fn start_recording(&self) -> Result<(), PageError> {
173        let mut state = self.state.write().await;
174        if state.recording {
175            return Ok(()); // Already recording
176        }
177
178        // Ensure video directory exists
179        tokio::fs::create_dir_all(&state.options.dir)
180            .await
181            .map_err(|e| PageError::EvaluationFailed(format!("Failed to create video directory: {e}")))?;
182
183        // Build screencast params
184        let mut params = StartScreencastParams::new()
185            .format(ScreencastFormat::Jpeg)
186            .quality(80);
187
188        if let Some(width) = state.options.width {
189            params = params.max_width(width);
190        }
191        if let Some(height) = state.options.height {
192            params = params.max_height(height);
193        }
194
195        // Start screencast
196        self.connection
197            .send_command::<_, serde_json::Value>(
198                "Page.startScreencast",
199                Some(params),
200                Some(&self.session_id),
201            )
202            .await?;
203
204        state.recording = true;
205        state.start_time = Some(Instant::now());
206        state.frames.clear();
207
208        info!("Started video recording");
209        
210        // Start the frame listener
211        self.start_frame_listener();
212
213        Ok(())
214    }
215
216    /// Start listening for screencast frames.
217    fn start_frame_listener(&self) {
218        let mut events = self.connection.subscribe_events();
219        let session_id = self.session_id.clone();
220        let connection = self.connection.clone();
221        let state = self.state.clone();
222
223        tokio::spawn(async move {
224            while let Ok(event) = events.recv().await {
225                // Filter for this session
226                if event.session_id.as_deref() != Some(&session_id) {
227                    continue;
228                }
229
230                if event.method == "Page.screencastFrame" {
231                    if let Some(params) = &event.params {
232                        if let Ok(frame_event) = serde_json::from_value::<ScreencastFrameEvent>(params.clone()) {
233                            // Check if we're still recording
234                            let is_recording = {
235                                let s = state.read().await;
236                                s.recording
237                            };
238
239                            if !is_recording {
240                                break;
241                            }
242
243                            // Decode the frame data
244                            if let Ok(data) = base64::Engine::decode(
245                                &base64::engine::general_purpose::STANDARD,
246                                &frame_event.data,
247                            ) {
248                                let timestamp = frame_event.metadata.timestamp.unwrap_or(0.0);
249
250                                // Store the frame
251                                {
252                                    let mut s = state.write().await;
253                                    s.frames.push(RecordedFrame { data, timestamp });
254                                }
255
256                                // Acknowledge the frame
257                                let _ = connection
258                                    .send_command::<_, serde_json::Value>(
259                                        "Page.screencastFrameAck",
260                                        Some(ScreencastFrameAckParams {
261                                            session_id: frame_event.session_id,
262                                        }),
263                                        Some(&session_id),
264                                    )
265                                    .await;
266                            }
267                        }
268                    }
269                }
270            }
271        });
272    }
273
274    /// Stop recording and save the video.
275    ///
276    /// Returns the path to the saved video file.
277    pub(crate) async fn stop_recording(&self) -> Result<PathBuf, PageError> {
278        let mut state = self.state.write().await;
279        if !state.recording {
280            if let Some(ref path) = state.video_path {
281                return Ok(path.clone());
282            }
283            return Err(PageError::EvaluationFailed("Video recording not started".to_string()));
284        }
285
286        // Stop screencast
287        self.connection
288            .send_command::<_, serde_json::Value>(
289                "Page.stopScreencast",
290                Some(StopScreencastParams {}),
291                Some(&self.session_id),
292            )
293            .await?;
294
295        state.recording = false;
296
297        // Generate video file
298        let video_path = self.generate_video(&state).await?;
299        state.video_path = Some(video_path.clone());
300
301        info!("Stopped video recording, saved to {:?}", video_path);
302
303        Ok(video_path)
304    }
305
306    /// Generate the video file from recorded frames.
307    async fn generate_video(&self, state: &VideoState) -> Result<PathBuf, PageError> {
308        if state.frames.is_empty() {
309            return Err(PageError::EvaluationFailed("No frames recorded".to_string()));
310        }
311
312        // Generate a unique filename
313        let filename = format!(
314            "video-{}.webm",
315            uuid::Uuid::new_v4().to_string().split('-').next().unwrap_or("unknown")
316        );
317        let video_path = state.options.dir.join(&filename);
318
319        // For now, we save frames as individual images and create a simple container
320        // A full WebM encoder would require additional dependencies (ffmpeg, vpx, etc.)
321        // This is a simplified implementation that saves frames to a directory
322        
323        // Create a frames directory
324        let frames_dir = state.options.dir.join(format!("frames-{}", 
325            uuid::Uuid::new_v4().to_string().split('-').next().unwrap_or("unknown")));
326        tokio::fs::create_dir_all(&frames_dir)
327            .await
328            .map_err(|e| PageError::EvaluationFailed(format!("Failed to create frames directory: {e}")))?;
329
330        // Save each frame
331        for (i, frame) in state.frames.iter().enumerate() {
332            let frame_path = frames_dir.join(format!("frame-{i:05}.jpg"));
333            tokio::fs::write(&frame_path, &frame.data)
334                .await
335                .map_err(|e| PageError::EvaluationFailed(format!("Failed to write frame: {e}")))?;
336        }
337
338        // Create a simple metadata file indicating this is a video
339        let metadata = serde_json::json!({
340            "type": "viewpoint-video",
341            "format": "jpeg-sequence",
342            "frame_count": state.frames.len(),
343            "frames_dir": frames_dir.to_string_lossy(),
344        });
345        
346        tokio::fs::write(&video_path, serde_json::to_string_pretty(&metadata).unwrap())
347            .await
348            .map_err(|e| PageError::EvaluationFailed(format!("Failed to write video metadata: {e}")))?;
349
350        debug!("Saved {} frames to {:?}", state.frames.len(), frames_dir);
351
352        Ok(video_path)
353    }
354
355    /// Get the path to the recorded video.
356    ///
357    /// Returns `None` if recording hasn't started or hasn't stopped yet.
358    ///
359    /// # Example
360    ///
361    /// ```ignore
362    /// if let Some(video) = page.video() {
363    ///     let path = video.path().await?;
364    ///     println!("Video at: {}", path.display());
365    /// }
366    /// ```
367    pub async fn path(&self) -> Result<PathBuf, PageError> {
368        let state = self.state.read().await;
369        
370        if let Some(ref path) = state.video_path {
371            return Ok(path.clone());
372        }
373        
374        if state.recording {
375            return Err(PageError::EvaluationFailed(
376                "Video is still recording. Call stop_recording() first.".to_string()
377            ));
378        }
379        
380        Err(PageError::EvaluationFailed("No video recorded".to_string()))
381    }
382
383    // save_as and delete methods are in video_io.rs
384
385    /// Check if video is currently being recorded.
386    pub async fn is_recording(&self) -> bool {
387        let state = self.state.read().await;
388        state.recording
389    }
390}
391
392// Page impl for video recording methods
393impl super::Page {
394    /// Get the video recording controller if video recording is enabled.
395    ///
396    /// Returns `None` if the page was created without video recording options.
397    ///
398    /// # Example
399    ///
400    /// ```ignore
401    /// // Video recording is enabled via context options
402    /// let context = browser.new_context()
403    ///     .record_video(VideoOptions::new("./videos"))
404    ///     .build()
405    ///     .await?;
406    ///
407    /// let page = context.new_page().await?;
408    /// page.goto("https://example.com").goto().await?;
409    ///
410    /// // Access the video after actions
411    /// if let Some(video) = page.video() {
412    ///     let path = video.path().await?;
413    ///     println!("Video saved to: {}", path.display());
414    /// }
415    /// ```
416    pub fn video(&self) -> Option<&Video> {
417        self.video_controller.as_ref().map(std::convert::AsRef::as_ref)
418    }
419
420    /// Start video recording (internal use).
421    pub(crate) async fn start_video_recording(&self) -> Result<(), PageError> {
422        if let Some(ref video) = self.video_controller {
423            video.start_recording().await
424        } else {
425            Ok(())
426        }
427    }
428
429    /// Stop video recording and get the path (internal use).
430    pub(crate) async fn stop_video_recording(&self) -> Result<Option<std::path::PathBuf>, PageError> {
431        if let Some(ref video) = self.video_controller {
432            Ok(Some(video.stop_recording().await?))
433        } else {
434            Ok(None)
435        }
436    }
437}
438
439