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 /// ```no_run
362 /// use viewpoint_core::page::Page;
363 ///
364 /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
365 /// if let Some(video) = page.video() {
366 /// let path = video.path().await?;
367 /// println!("Video at: {}", path.display());
368 /// }
369 /// # Ok(())
370 /// # }
371 /// ```
372 pub async fn path(&self) -> Result<PathBuf, PageError> {
373 let state = self.state.read().await;
374
375 if let Some(ref path) = state.video_path {
376 return Ok(path.clone());
377 }
378
379 if state.recording {
380 return Err(PageError::EvaluationFailed(
381 "Video is still recording. Call stop_recording() first.".to_string()
382 ));
383 }
384
385 Err(PageError::EvaluationFailed("No video recorded".to_string()))
386 }
387
388 // save_as and delete methods are in video_io.rs
389
390 /// Check if video is currently being recorded.
391 pub async fn is_recording(&self) -> bool {
392 let state = self.state.read().await;
393 state.recording
394 }
395}
396
397// Page impl for video recording methods
398impl super::Page {
399 /// Get the video recording controller if video recording is enabled.
400 ///
401 /// Returns `None` if the page was created without video recording options.
402 ///
403 /// # Example
404 ///
405 /// ```no_run
406 /// use viewpoint_core::browser::Browser;
407 /// use viewpoint_core::page::VideoOptions;
408 ///
409 /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
410 /// let browser = Browser::launch().headless(true).launch().await?;
411 /// // Video recording is enabled via context options
412 /// let context = browser.new_context_builder()
413 /// .record_video(VideoOptions::new("./videos"))
414 /// .build()
415 /// .await?;
416 ///
417 /// let page = context.new_page().await?;
418 /// page.goto("https://example.com").goto().await?;
419 ///
420 /// // Access the video after actions
421 /// if let Some(video) = page.video() {
422 /// let path = video.path().await?;
423 /// println!("Video saved to: {}", path.display());
424 /// }
425 /// # Ok(())
426 /// # }
427 /// ```
428 pub fn video(&self) -> Option<&Video> {
429 self.video_controller.as_ref().map(std::convert::AsRef::as_ref)
430 }
431
432 /// Start video recording (internal use).
433 pub(crate) async fn start_video_recording(&self) -> Result<(), PageError> {
434 if let Some(ref video) = self.video_controller {
435 video.start_recording().await
436 } else {
437 Ok(())
438 }
439 }
440
441 /// Stop video recording and get the path (internal use).
442 pub(crate) async fn stop_video_recording(&self) -> Result<Option<std::path::PathBuf>, PageError> {
443 if let Some(ref video) = self.video_controller {
444 Ok(Some(video.stop_recording().await?))
445 } else {
446 Ok(None)
447 }
448 }
449}
450
451