viewpoint_core/page/video/
mod.rs1use 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#[derive(Debug, Clone)]
24pub struct VideoOptions {
25 pub dir: PathBuf,
27 pub width: Option<i32>,
29 pub height: Option<i32>,
31}
32
33impl VideoOptions {
34 pub fn new(dir: impl Into<PathBuf>) -> Self {
36 Self {
37 dir: dir.into(),
38 width: None,
39 height: None,
40 }
41 }
42
43 #[must_use]
45 pub fn width(mut self, width: i32) -> Self {
46 self.width = Some(width);
47 self
48 }
49
50 #[must_use]
52 pub fn height(mut self, height: i32) -> Self {
53 self.height = Some(height);
54 self
55 }
56
57 #[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#[derive(Debug, Clone)]
78pub(super) struct RecordedFrame {
79 data: Vec<u8>,
81 timestamp: f64,
83}
84
85#[derive(Debug)]
87#[derive(Default)]
88pub(super) struct VideoState {
89 pub(super) recording: bool,
91 pub(super) frames: Vec<RecordedFrame>,
93 pub(super) start_time: Option<Instant>,
95 pub(super) options: VideoOptions,
97 pub(super) video_path: Option<PathBuf>,
99}
100
101
102#[derive(Debug)]
134pub struct Video {
135 connection: Arc<CdpConnection>,
137 session_id: String,
139 pub(super) state: Arc<RwLock<VideoState>>,
141}
142
143impl Video {
144 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 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 pub(crate) async fn start_recording(&self) -> Result<(), PageError> {
173 let mut state = self.state.write().await;
174 if state.recording {
175 return Ok(()); }
177
178 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 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 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 self.start_frame_listener();
212
213 Ok(())
214 }
215
216 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 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 let is_recording = {
235 let s = state.read().await;
236 s.recording
237 };
238
239 if !is_recording {
240 break;
241 }
242
243 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 {
252 let mut s = state.write().await;
253 s.frames.push(RecordedFrame { data, timestamp });
254 }
255
256 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 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 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 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 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 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 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 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 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 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 pub async fn is_recording(&self) -> bool {
387 let state = self.state.read().await;
388 state.recording
389 }
390}
391
392impl super::Page {
394 pub fn video(&self) -> Option<&Video> {
417 self.video_controller.as_ref().map(std::convert::AsRef::as_ref)
418 }
419
420 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 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