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::CdpConnection;
15use viewpoint_cdp::protocol::{
16    ScreencastFormat, ScreencastFrameAckParams, ScreencastFrameEvent, StartScreencastParams,
17    StopScreencastParams,
18};
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, Default)]
87pub(super) struct VideoState {
88    /// Whether recording is active.
89    pub(super) recording: bool,
90    /// Recorded frames.
91    pub(super) frames: Vec<RecordedFrame>,
92    /// Start time for timing.
93    pub(super) start_time: Option<Instant>,
94    /// Video options.
95    pub(super) options: VideoOptions,
96    /// Generated video path (set when recording stops).
97    pub(super) video_path: Option<PathBuf>,
98}
99
100/// Video recording controller for a page.
101///
102/// Videos are recorded using CDP's screencast feature which captures
103/// compressed frames from the browser. These frames are then assembled
104/// into a video file.
105///
106/// # Example
107///
108/// ```
109/// # #[cfg(feature = "integration")]
110/// # tokio_test::block_on(async {
111/// # use viewpoint_core::Browser;
112/// use viewpoint_core::page::VideoOptions;
113/// # let browser = Browser::launch().headless(true).launch().await.unwrap();
114///
115/// // Recording is usually started via context options
116/// let context = browser.new_context_builder()
117///     .record_video(VideoOptions::new("./videos"))
118///     .build()
119///     .await.unwrap();
120///
121/// let page = context.new_page().await.unwrap();
122/// page.goto("https://example.com").goto().await.unwrap();
123///
124/// // Get the video path after recording
125/// if let Some(video) = page.video() {
126///     // Video operations available here
127///     // let path = video.path().await?;
128/// }
129/// # });
130/// ```
131#[derive(Debug)]
132pub struct Video {
133    /// CDP connection.
134    connection: Arc<CdpConnection>,
135    /// Session ID.
136    session_id: String,
137    /// Internal state.
138    pub(super) state: Arc<RwLock<VideoState>>,
139}
140
141impl Video {
142    /// Create a new video controller.
143    pub(crate) fn new(connection: Arc<CdpConnection>, session_id: String) -> Self {
144        Self {
145            connection,
146            session_id,
147            state: Arc::new(RwLock::new(VideoState::default())),
148        }
149    }
150
151    /// Create a new video controller with options.
152    pub(crate) fn with_options(
153        connection: Arc<CdpConnection>,
154        session_id: String,
155        options: VideoOptions,
156    ) -> Self {
157        Self {
158            connection,
159            session_id,
160            state: Arc::new(RwLock::new(VideoState {
161                options,
162                ..Default::default()
163            })),
164        }
165    }
166
167    /// Start recording video.
168    ///
169    /// This starts the CDP screencast which captures frames from the page.
170    pub(crate) async fn start_recording(&self) -> Result<(), PageError> {
171        let mut state = self.state.write().await;
172        if state.recording {
173            return Ok(()); // Already recording
174        }
175
176        // Ensure video directory exists
177        tokio::fs::create_dir_all(&state.options.dir)
178            .await
179            .map_err(|e| {
180                PageError::EvaluationFailed(format!("Failed to create video directory: {e}"))
181            })?;
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) =
233                            serde_json::from_value::<ScreencastFrameEvent>(params.clone())
234                        {
235                            // Check if we're still recording
236                            let is_recording = {
237                                let s = state.read().await;
238                                s.recording
239                            };
240
241                            if !is_recording {
242                                break;
243                            }
244
245                            // Decode the frame data
246                            if let Ok(data) = base64::Engine::decode(
247                                &base64::engine::general_purpose::STANDARD,
248                                &frame_event.data,
249                            ) {
250                                let timestamp = frame_event.metadata.timestamp.unwrap_or(0.0);
251
252                                // Store the frame
253                                {
254                                    let mut s = state.write().await;
255                                    s.frames.push(RecordedFrame { data, timestamp });
256                                }
257
258                                // Acknowledge the frame
259                                let _ = connection
260                                    .send_command::<_, serde_json::Value>(
261                                        "Page.screencastFrameAck",
262                                        Some(ScreencastFrameAckParams {
263                                            session_id: frame_event.session_id,
264                                        }),
265                                        Some(&session_id),
266                                    )
267                                    .await;
268                            }
269                        }
270                    }
271                }
272            }
273        });
274    }
275
276    /// Stop recording and save the video.
277    ///
278    /// Returns the path to the saved video file.
279    pub(crate) async fn stop_recording(&self) -> Result<PathBuf, PageError> {
280        let mut state = self.state.write().await;
281        if !state.recording {
282            if let Some(ref path) = state.video_path {
283                return Ok(path.clone());
284            }
285            return Err(PageError::EvaluationFailed(
286                "Video recording not started".to_string(),
287            ));
288        }
289
290        // Stop screencast
291        self.connection
292            .send_command::<_, serde_json::Value>(
293                "Page.stopScreencast",
294                Some(StopScreencastParams {}),
295                Some(&self.session_id),
296            )
297            .await?;
298
299        state.recording = false;
300
301        // Generate video file
302        let video_path = self.generate_video(&state).await?;
303        state.video_path = Some(video_path.clone());
304
305        info!("Stopped video recording, saved to {:?}", video_path);
306
307        Ok(video_path)
308    }
309
310    /// Generate the video file from recorded frames.
311    async fn generate_video(&self, state: &VideoState) -> Result<PathBuf, PageError> {
312        if state.frames.is_empty() {
313            return Err(PageError::EvaluationFailed(
314                "No frames recorded".to_string(),
315            ));
316        }
317
318        // Generate a unique filename
319        let filename = format!(
320            "video-{}.webm",
321            uuid::Uuid::new_v4()
322                .to_string()
323                .split('-')
324                .next()
325                .unwrap_or("unknown")
326        );
327        let video_path = state.options.dir.join(&filename);
328
329        // For now, we save frames as individual images and create a simple container
330        // A full WebM encoder would require additional dependencies (ffmpeg, vpx, etc.)
331        // This is a simplified implementation that saves frames to a directory
332
333        // Create a frames directory
334        let frames_dir = state.options.dir.join(format!(
335            "frames-{}",
336            uuid::Uuid::new_v4()
337                .to_string()
338                .split('-')
339                .next()
340                .unwrap_or("unknown")
341        ));
342        tokio::fs::create_dir_all(&frames_dir).await.map_err(|e| {
343            PageError::EvaluationFailed(format!("Failed to create frames directory: {e}"))
344        })?;
345
346        // Save each frame
347        for (i, frame) in state.frames.iter().enumerate() {
348            let frame_path = frames_dir.join(format!("frame-{i:05}.jpg"));
349            tokio::fs::write(&frame_path, &frame.data)
350                .await
351                .map_err(|e| PageError::EvaluationFailed(format!("Failed to write frame: {e}")))?;
352        }
353
354        // Create a simple metadata file indicating this is a video
355        let metadata = serde_json::json!({
356            "type": "viewpoint-video",
357            "format": "jpeg-sequence",
358            "frame_count": state.frames.len(),
359            "frames_dir": frames_dir.to_string_lossy(),
360        });
361
362        tokio::fs::write(
363            &video_path,
364            serde_json::to_string_pretty(&metadata).unwrap(),
365        )
366        .await
367        .map_err(|e| PageError::EvaluationFailed(format!("Failed to write video metadata: {e}")))?;
368
369        debug!("Saved {} frames to {:?}", state.frames.len(), frames_dir);
370
371        Ok(video_path)
372    }
373
374    /// Get the path to the recorded video.
375    ///
376    /// Returns `None` if recording hasn't started or hasn't stopped yet.
377    ///
378    /// # Example
379    ///
380    /// ```no_run
381    /// use viewpoint_core::page::Page;
382    ///
383    /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
384    /// if let Some(video) = page.video() {
385    ///     let path = video.path().await?;
386    ///     println!("Video at: {}", path.display());
387    /// }
388    /// # Ok(())
389    /// # }
390    /// ```
391    pub async fn path(&self) -> Result<PathBuf, PageError> {
392        let state = self.state.read().await;
393
394        if let Some(ref path) = state.video_path {
395            return Ok(path.clone());
396        }
397
398        if state.recording {
399            return Err(PageError::EvaluationFailed(
400                "Video is still recording. Call stop_recording() first.".to_string(),
401            ));
402        }
403
404        Err(PageError::EvaluationFailed("No video recorded".to_string()))
405    }
406
407    // save_as and delete methods are in video_io.rs
408
409    /// Check if video is currently being recorded.
410    pub async fn is_recording(&self) -> bool {
411        let state = self.state.read().await;
412        state.recording
413    }
414}
415
416// Page impl for video recording methods
417impl super::Page {
418    /// Get the video recording controller if video recording is enabled.
419    ///
420    /// Returns `None` if the page was created without video recording options.
421    ///
422    /// # Example
423    ///
424    /// ```no_run
425    /// use viewpoint_core::browser::Browser;
426    /// use viewpoint_core::page::VideoOptions;
427    ///
428    /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
429    /// let browser = Browser::launch().headless(true).launch().await?;
430    /// // Video recording is enabled via context options
431    /// let context = browser.new_context_builder()
432    ///     .record_video(VideoOptions::new("./videos"))
433    ///     .build()
434    ///     .await?;
435    ///
436    /// let page = context.new_page().await?;
437    /// page.goto("https://example.com").goto().await?;
438    ///
439    /// // Access the video after actions
440    /// if let Some(video) = page.video() {
441    ///     let path = video.path().await?;
442    ///     println!("Video saved to: {}", path.display());
443    /// }
444    /// # Ok(())
445    /// # }
446    /// ```
447    pub fn video(&self) -> Option<&Video> {
448        self.video_controller
449            .as_ref()
450            .map(std::convert::AsRef::as_ref)
451    }
452
453    /// Start video recording (internal use).
454    pub(crate) async fn start_video_recording(&self) -> Result<(), PageError> {
455        if let Some(ref video) = self.video_controller {
456            video.start_recording().await
457        } else {
458            Ok(())
459        }
460    }
461
462    /// Stop video recording and get the path (internal use).
463    pub(crate) async fn stop_video_recording(
464        &self,
465    ) -> Result<Option<std::path::PathBuf>, PageError> {
466        if let Some(ref video) = self.video_controller {
467            Ok(Some(video.stop_recording().await?))
468        } else {
469            Ok(None)
470        }
471    }
472}