Skip to main content

playwright_cdp/
video.rs

1//! `Video` — page video capture via CDP `Page.startScreencast`.
2//!
3//! Playwright records a real video file (muxed by a bundled encoder). CDP has
4//! **no native video muxing**: the closest primitive is `Page.startScreencast`,
5//! which streams individual captured frames as base64-encoded images through
6//! the `Page.screencastFrame` event.
7//!
8//! This module implements capture on top of that primitive. [`Video::start`]
9//! subscribes to `Page.screencastFrame` and writes each incoming frame to the
10//! configured output `path`; [`Video::stop`] ends the cast.
11//!
12//! ## Scope / caveats
13//!
14//! - The output is **a sequence of captured frames, not a muxed video file**.
15//!   By default each frame is written as its own image file
16//!   (`<path>/frame-NNNNN.png` when `path` is a directory). When `path` points
17//!   at a single file, frames are appended as raw image chunks (a "filmstrip"),
18//!   which is still *not* a playable video.
19//! - Producing an actual video (`.webm`/`.mp4`) requires piping these frames to
20//!   an external encoder (e.g. ffmpeg); that encoding step is intentionally
21//!   **out of scope** here. Callers wanting a true video should stop the cast,
22//!   then feed the captured frames to an external encoder.
23//! - [`Video::path`] returns the configured output path (Playwright shape).
24//!
25//! The handle is cheaply cloneable (`Arc<Inner>`); clones share recording
26//! state, so any clone can `stop()` the in-progress cast.
27
28use crate::cdp::session::CdpSession;
29use crate::error::{Error, Result};
30use base64::Engine;
31use parking_lot::Mutex;
32use serde_json::{json, Value};
33use std::path::PathBuf;
34use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
35use std::sync::Arc;
36use tokio::sync::broadcast;
37
38/// Options for [`Video::start`].
39#[derive(Debug, Clone, Default)]
40#[non_exhaustive]
41pub struct VideoStartOptions {
42    /// Image format of each screencast frame. CDP supports `"jpeg"` and
43    /// `"png"`; defaults to `"jpeg"` (smaller on the wire — better for capture
44    /// throughput at the cost of lossy frames).
45    pub format: Option<String>,
46    /// Image compression quality (0–100). Only meaningful for `"jpeg"`.
47    pub quality: Option<i64>,
48    /// Capture every Nth frame. `1` = every frame. Defaults to `1`.
49    pub every_nth_frame: Option<i64>,
50    /// Maximum frame width. `None` lets CDP choose.
51    pub max_width: Option<i64>,
52    /// Maximum frame height. `None` lets CDP choose.
53    pub max_height: Option<i64>,
54}
55
56impl VideoStartOptions {
57    pub fn format(mut self, v: impl Into<String>) -> Self {
58        self.format = Some(v.into());
59        self
60    }
61    pub fn quality(mut self, v: i64) -> Self {
62        self.quality = Some(v);
63        self
64    }
65    pub fn every_nth_frame(mut self, v: i64) -> Self {
66        self.every_nth_frame = Some(v);
67        self
68    }
69    pub fn max_width(mut self, v: i64) -> Self {
70        self.max_width = Some(v);
71        self
72    }
73    pub fn max_height(mut self, v: i64) -> Self {
74        self.max_height = Some(v);
75        self
76    }
77}
78
79/// A page video capture handle.
80///
81/// See the [module docs](self) for the important distinction between captured
82/// frames and a muxed video file.
83#[derive(Clone)]
84pub struct Video {
85    inner: Arc<VideoInner>,
86}
87
88struct VideoInner {
89    /// The page-level CDP session (Page domain).
90    session: Arc<CdpSession>,
91    /// Configured output path. `None` until set via [`Video::with_path`] or
92    /// [`Video::start`].
93    path: Mutex<Option<PathBuf>>,
94    /// Whether a cast is currently in progress. Guards against double-start /
95    /// stop-without-start.
96    recording: AtomicBool,
97    /// Number of frames captured during the current (or most recent) cast.
98    /// Arc-wrapped so the spawned frame-writer task can observe the live count.
99    frame_count: Arc<AtomicU64>,
100}
101
102impl Video {
103    pub(crate) fn new(session: Arc<CdpSession>) -> Self {
104        Self {
105            inner: Arc::new(VideoInner {
106                session,
107                path: Mutex::new(None),
108                recording: AtomicBool::new(false),
109                frame_count: Arc::new(AtomicU64::new(0)),
110            }),
111        }
112    }
113
114    /// Like [`new`](Self::new), but pre-records the output `path` (Playwright
115    /// configures the video path up front, before any page interaction).
116    pub(crate) fn with_path(session: Arc<CdpSession>, path: PathBuf) -> Self {
117        let v = Self::new(session);
118        *v.inner.path.lock() = Some(path);
119        v
120    }
121
122    /// The configured output path, if any.
123    ///
124    /// Mirrors Playwright's `video.path()`. Here it returns the path as soon as
125    /// it has been set (via [`with_path`](Self::with_path) or
126    /// [`start`](Self::start)).
127    pub fn path(&self) -> Option<PathBuf> {
128        self.inner.path.lock().clone()
129    }
130
131    /// Begin capturing screencast frames.
132    ///
133    /// Subscribes to `Page.screencastFrame` **before** issuing
134    /// `Page.startScreencast` so the first frame is not missed, then spawns a
135    /// writer task that decodes each frame's base64 image and persists it.
136    /// Frames are written under `path` (see [module docs](self) for the output
137    /// shape). If `path` is `None`, capture still runs and counts frames via
138    /// [`frame_count`](Self::frame_count), but nothing is persisted.
139    pub async fn start(&self, options: Option<VideoStartOptions>) -> Result<()> {
140        let opts = options.unwrap_or_default();
141
142        if self.inner.recording.swap(true, Ordering::AcqRel) {
143            return Err(Error::InvalidArgument(
144                "Video::start called while already recording".into(),
145            ));
146        }
147        self.inner.frame_count.store(0, Ordering::Release);
148
149        // Build the CDP params.
150        let mut params = json!({});
151        if let Some(fmt) = &opts.format {
152            params["format"] = json!(fmt);
153        }
154        if let Some(q) = opts.quality {
155            params["quality"] = json!(q);
156        }
157        if let Some(n) = opts.every_nth_frame {
158            params["everyNthFrame"] = json!(n);
159        }
160        if let Some(w) = opts.max_width {
161            params["maxWidth"] = json!(w);
162        }
163        if let Some(h) = opts.max_height {
164            params["maxHeight"] = json!(h);
165        }
166
167        // Subscribe BEFORE Page.startScreencast to avoid missing the first frame.
168        let mut rx = self.inner.session.subscribe();
169
170        // Spawn the frame writer, then start the cast. Order matters: the
171        // writer must be draining the bus when the first frame arrives.
172        let path = self.inner.path.lock().clone();
173        let frame_count = Arc::clone(&self.inner.frame_count);
174        let session_for_ack = Arc::clone(&self.inner.session);
175        tokio::spawn(async move {
176            let engine = base64::engine::general_purpose::STANDARD;
177            loop {
178                let ev = match rx.recv().await {
179                    Ok(ev) if ev.method == "Page.screencastFrame" => ev,
180                    Ok(_) => continue,
181                    Err(broadcast::error::RecvError::Closed) => break,
182                    Err(broadcast::error::RecvError::Lagged(_)) => continue,
183                };
184
185                // Acknowledge the frame so CDP sends the next one. `sessionId`
186                // here is the screencast session id, not a CDP target session.
187                if let Some(id) = ev.params.get("sessionId").and_then(|v| v.as_i64()) {
188                    let _ = session_for_ack
189                        .send("Page.screencastFrameAck", json!({ "sessionId": id }))
190                        .await;
191                }
192
193                // A frame with `metadata: true` carries layout/offset info and
194                // no pixel `data`; skip persistence for it.
195                let is_metadata = ev
196                    .params
197                    .get("metadata")
198                    .and_then(|v| v.as_bool())
199                    .unwrap_or(false);
200
201                if !is_metadata {
202                    if let Some(data_str) = ev.params.get("data").and_then(|v| v.as_str()) {
203                        if let Ok(bytes) = engine.decode(data_str) {
204                            let idx = frame_count.fetch_add(1, Ordering::Relaxed);
205                            // Persisting a single frame failing should not abort
206                            // the whole cast; log via the ignored result.
207                            let _ = write_frame(&path, idx, &bytes).await;
208                        }
209                    }
210                }
211            }
212        });
213
214        self.inner
215            .session
216            .send("Page.startScreencast", params)
217            .await
218            .map(|_: Value| ())?;
219        Ok(())
220    }
221
222    /// Stop an in-progress cast.
223    ///
224    /// Sends `Page.stopScreencast`. The writer task winds down when the session
225    /// stops emitting `Page.screencastFrame`. The configured `path` is retained
226    /// so [`path`](Self::path) stays valid after recording.
227    pub async fn stop(&self) -> Result<()> {
228        if !self.inner.recording.swap(false, Ordering::AcqRel) {
229            return Err(Error::InvalidArgument(
230                "Video::stop called without a preceding Video::start".into(),
231            ));
232        }
233        self.inner
234            .session
235            .send("Page.stopScreencast", json!({}))
236            .await
237            .map(|_: Value| ())?;
238        Ok(())
239    }
240
241    /// Number of frames captured during the current (or most recent) cast.
242    pub fn frame_count(&self) -> u64 {
243        self.inner.frame_count.load(Ordering::Relaxed)
244    }
245
246    /// Whether a cast is currently in progress.
247    pub fn is_recording(&self) -> bool {
248        self.inner.recording.load(Ordering::Acquire)
249    }
250}
251
252/// Persist a single captured frame.
253///
254/// If `base` is a directory (or absent), frames are written as
255/// `frame-NNNNN.<ext>` inside it. If `base` is a single file, frames are
256/// appended to it as concatenated image chunks (a "filmstrip"), which is
257/// **not** a playable video — see the [module docs](self).
258async fn write_frame(base: &Option<PathBuf>, index: u64, bytes: &[u8]) -> std::io::Result<()> {
259    let Some(base) = base else {
260        // No path configured — capture is in-memory only.
261        return Ok(());
262    };
263
264    if base.is_dir() {
265        let ext = sniff_ext(bytes);
266        let name = format!("frame-{index:05}.{ext}");
267        tokio::fs::write(base.join(name), bytes).await
268    } else {
269        // Append to a single file (create the parent dir if needed).
270        if let Some(parent) = base.parent() {
271            if !parent.as_os_str().is_empty() {
272                let _ = tokio::fs::create_dir_all(parent).await;
273            }
274        }
275        use tokio::io::AsyncWriteExt;
276        let mut f = tokio::fs::OpenOptions::new()
277            .create(true)
278            .append(true)
279            .open(base)
280            .await?;
281        f.write_all(bytes).await?;
282        Ok(())
283    }
284}
285
286/// Sniff a frame's file extension from its magic bytes.
287fn sniff_ext(bytes: &[u8]) -> &'static str {
288    // PNG: 89 50 4E 47; JPEG: FF D8 FF.
289    if bytes.starts_with(&[0x89, 0x50, 0x4e, 0x47]) {
290        "png"
291    } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
292        "jpg"
293    } else {
294        "bin"
295    }
296}