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/// ```ignore
111/// // Recording is usually started via context options
112/// let context = browser.new_context()
113///     .record_video(VideoOptions::new("./videos"))
114///     .build()
115///     .await?;
116///
117/// let page = context.new_page().await?;
118/// page.goto("https://example.com").goto().await?;
119///
120/// // Get the video path after recording
121/// if let Some(video) = page.video() {
122///     let path = video.path().await?;
123///     println!("Video saved to: {}", path.display());
124///     
125///     // Or save to a specific location
126///     video.save_as("my-test.webm").await?;
127/// }
128/// ```
129#[derive(Debug)]
130pub struct Video {
131    /// CDP connection.
132    connection: Arc<CdpConnection>,
133    /// Session ID.
134    session_id: String,
135    /// Internal state.
136    pub(super) state: Arc<RwLock<VideoState>>,
137}
138
139impl Video {
140    /// Create a new video controller.
141    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    /// Create a new video controller with options.
150    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    /// Start recording video.
166    ///
167    /// This starts the CDP screencast which captures frames from the page.
168    pub(crate) async fn start_recording(&self) -> Result<(), PageError> {
169        let mut state = self.state.write().await;
170        if state.recording {
171            return Ok(()); // Already recording
172        }
173
174        // Ensure video directory exists
175        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        // Build screencast params
180        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        // Start screencast
192        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        // Start the frame listener
207        self.start_frame_listener();
208
209        Ok(())
210    }
211
212    /// Start listening for screencast frames.
213    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                // Filter for this session
222                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                            // Check if we're still recording
230                            let is_recording = {
231                                let s = state.read().await;
232                                s.recording
233                            };
234
235                            if !is_recording {
236                                break;
237                            }
238
239                            // Decode the frame data
240                            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                                // Store the frame
247                                {
248                                    let mut s = state.write().await;
249                                    s.frames.push(RecordedFrame { data, timestamp });
250                                }
251
252                                // Acknowledge the frame
253                                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    /// Stop recording and save the video.
271    ///
272    /// Returns the path to the saved video file.
273    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        // Stop screencast
283        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        // Generate video file
294        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    /// Generate the video file from recorded frames.
303    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        // Generate a unique filename
309        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        // For now, we save frames as individual images and create a simple container
316        // A full WebM encoder would require additional dependencies (ffmpeg, vpx, etc.)
317        // This is a simplified implementation that saves frames to a directory
318        
319        // Create a frames directory
320        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        // Save each frame
327        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        // Create a simple metadata file indicating this is a video
335        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    /// Get the path to the recorded video.
352    ///
353    /// Returns `None` if recording hasn't started or hasn't stopped yet.
354    ///
355    /// # Example
356    ///
357    /// ```ignore
358    /// if let Some(video) = page.video() {
359    ///     let path = video.path().await?;
360    ///     println!("Video at: {}", path.display());
361    /// }
362    /// ```
363    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    // save_as and delete methods are in video_io.rs
380
381    /// Check if video is currently being recorded.
382    pub async fn is_recording(&self) -> bool {
383        let state = self.state.read().await;
384        state.recording
385    }
386}
387
388// Page impl for video recording methods
389impl super::Page {
390    /// Get the video recording controller if video recording is enabled.
391    ///
392    /// Returns `None` if the page was created without video recording options.
393    ///
394    /// # Example
395    ///
396    /// ```ignore
397    /// // Video recording is enabled via context options
398    /// let context = browser.new_context()
399    ///     .record_video(VideoOptions::new("./videos"))
400    ///     .build()
401    ///     .await?;
402    ///
403    /// let page = context.new_page().await?;
404    /// page.goto("https://example.com").goto().await?;
405    ///
406    /// // Access the video after actions
407    /// if let Some(video) = page.video() {
408    ///     let path = video.path().await?;
409    ///     println!("Video saved to: {}", path.display());
410    /// }
411    /// ```
412    pub fn video(&self) -> Option<&Video> {
413        self.video_controller.as_ref().map(std::convert::AsRef::as_ref)
414    }
415
416    /// Start video recording (internal use).
417    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    /// Stop video recording and get the path (internal use).
426    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