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}