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}