Skip to main content

playwright_rs/protocol/
screencast.rs

1//! Live screencast frame streaming, optional disk recording, and
2//! action / chapter / HTML overlays.
3//!
4//! Available on every [`Page`] via
5//! [`screencast()`](crate::protocol::Page::screencast). Once started,
6//! the Playwright server streams JPEG frames as they're rendered,
7//! delivered to handlers registered with [`Screencast::on_frame`].
8//! Optionally records to disk via the [`Artifact`](crate::protocol::artifact::Artifact)
9//! save-on-stop pathway, and can overlay action labels, chapter cards,
10//! or arbitrary HTML on the streamed frames.
11//!
12//! The action / chapter / HTML overlay surfaces are useful for "agent
13//! receipts" — an LLM-driven flow can produce annotated video logs of
14//! what it did alongside the action log.
15//!
16//! # Disk recording vs the Video class
17//!
18//! [`Video`](crate::protocol::Video) and [`Screencast`] cover
19//! complementary lifecycles, both backed by the same underlying
20//! `Artifact` save mechanism:
21//!
22//! - **`Video`** — automatic, captures the entire page session from
23//!   open to close. Enabled with `BrowserContextOptions::record_video`.
24//!   Use when you want a continuous recording over the whole session.
25//! - **`Screencast::start({ path })`** — user-initiated, captures only
26//!   during the start/stop window, saves to `path` on stop. Use when
27//!   you want a recording that brackets a specific phase.
28//!
29//! # Example
30//!
31//! ```no_run
32//! use playwright_rs::{Playwright, ScreencastStartOptions};
33//!
34//! #[tokio::main]
35//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
36//!     let pw = Playwright::launch().await?;
37//!     let browser = pw.chromium().launch().await?;
38//!     let page = browser.new_page().await?;
39//!     let screencast = page.screencast();
40//!
41//!     // Stream frames live
42//!     screencast.on_frame(|frame| async move {
43//!         println!("got {} byte frame", frame.data.len());
44//!         Ok(())
45//!     });
46//!
47//!     screencast.start(ScreencastStartOptions::default()
48//!         .path(std::path::PathBuf::from("/tmp/run.webm"))).await?;
49//!
50//!     page.goto("https://example.com", None).await?;
51//!     screencast.show_chapter(
52//!         "Logged in",
53//!         Default::default(),
54//!     ).await?;
55//!
56//!     screencast.stop().await?; // saves /tmp/run.webm
57//!     browser.close().await?;
58//!     Ok(())
59//! }
60//! ```
61//!
62//! See: <https://playwright.dev/docs/api/class-page#page-screencast>
63
64use crate::error::Result;
65use crate::protocol::page::Page;
66use crate::server::channel_owner::ChannelOwner;
67use std::path::PathBuf;
68
69/// A single frame emitted while a screencast is active. Wire format is
70/// JPEG; `data` holds the raw bytes ready to write to disk or pass to
71/// an image decoder.
72///
73/// `data` is a [`bytes::Bytes`] handle so the decoded JPEG is allocated
74/// exactly once per frame and cloning into each registered handler is
75/// a refcount bump rather than a memcpy. `Bytes` implements
76/// `Deref<Target = [u8]>`, so existing reads (`frame.data.len()`,
77/// `&frame.data[..]`, `tokio::fs::write(path, &frame.data)`) compile
78/// unchanged from the previous `Vec<u8>` shape.
79#[derive(Debug, Clone)]
80#[non_exhaustive]
81pub struct ScreencastFrame {
82    /// JPEG-encoded frame bytes.
83    pub data: bytes::Bytes,
84    /// Frame presentation timestamp in seconds.
85    /// `None` if the driver did not supply one.
86    pub timestamp: Option<f64>,
87}
88
89/// Options for [`Screencast::start`].
90#[derive(Debug, Default, Clone)]
91#[non_exhaustive]
92pub struct ScreencastStartOptions {
93    /// Output frame size. When `None`, Playwright uses the page's
94    /// current viewport size.
95    pub size: Option<ScreencastSize>,
96    /// JPEG quality, `0..=100`. Server default is implementation-defined.
97    pub quality: Option<u8>,
98    /// When set, the screencast is also recorded to a file at this
99    /// path. The file is written on [`Screencast::stop`]. The recording
100    /// covers only the active start/stop window — for a continuous
101    /// "always-on" recording over the whole page session, use
102    /// `BrowserContextOptions::record_video` instead (the `Video`
103    /// class).
104    pub path: Option<PathBuf>,
105    /// Frame rate of the file recorded to [`path`](Self::path); it has no
106    /// effect without one. Must be positive. Firefox and WebKit capture at
107    /// most 25 frames per second.
108    pub fps: Option<u32>,
109}
110
111impl ScreencastStartOptions {
112    /// Output video size.
113    pub fn size(mut self, size: ScreencastSize) -> Self {
114        self.size = Some(size);
115        self
116    }
117    /// JPEG quality, `0..=100`.
118    pub fn quality(mut self, quality: u8) -> Self {
119        self.quality = Some(quality);
120        self
121    }
122    /// Output file path.
123    pub fn path(mut self, path: PathBuf) -> Self {
124        self.path = Some(path);
125        self
126    }
127    /// Frame rate of the recorded file.
128    pub fn fps(mut self, fps: u32) -> Self {
129        self.fps = Some(fps);
130        self
131    }
132
133    /// The `screencastStart` parameters: frames are sent only when a frame
134    /// handler is registered, and a file is recorded only when a path is set.
135    pub(crate) fn params(&self, send_frames: bool) -> serde_json::Value {
136        let mut params = serde_json::json!({});
137        if let Some(size) = self.size {
138            params["size"] = serde_json::json!({
139                "width": size.width,
140                "height": size.height,
141            });
142        }
143        if let Some(quality) = self.quality {
144            params["quality"] = serde_json::json!(quality);
145        }
146        if let Some(fps) = self.fps {
147            params["fps"] = serde_json::json!(fps);
148        }
149        params["sendFrames"] = serde_json::json!(send_frames);
150        params["record"] = serde_json::json!(self.path.is_some());
151        params
152    }
153}
154
155/// Pixel dimensions for a screencast frame.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub struct ScreencastSize {
158    pub width: u32,
159    pub height: u32,
160}
161
162/// Position for the action-label overlay.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164#[non_exhaustive]
165pub enum ActionPosition {
166    TopLeft,
167    Top,
168    TopRight,
169    BottomLeft,
170    Bottom,
171    BottomRight,
172}
173
174impl serde::Serialize for ActionPosition {
175    fn serialize<S: serde::Serializer>(
176        &self,
177        serializer: S,
178    ) -> std::result::Result<S::Ok, S::Error> {
179        serializer.serialize_str(self.as_str())
180    }
181}
182
183impl ActionPosition {
184    pub(crate) fn as_str(self) -> &'static str {
185        match self {
186            ActionPosition::TopLeft => "top-left",
187            ActionPosition::Top => "top",
188            ActionPosition::TopRight => "top-right",
189            ActionPosition::BottomLeft => "bottom-left",
190            ActionPosition::Bottom => "bottom",
191            ActionPosition::BottomRight => "bottom-right",
192        }
193    }
194}
195
196/// Pointer-cursor decoration for action overlays.
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198#[non_exhaustive]
199pub enum ActionCursor {
200    /// No cursor decoration.
201    None,
202    /// Draw a pointer cursor at each action point.
203    Pointer,
204}
205
206impl serde::Serialize for ActionCursor {
207    fn serialize<S: serde::Serializer>(
208        &self,
209        serializer: S,
210    ) -> std::result::Result<S::Ok, S::Error> {
211        serializer.serialize_str(self.as_str())
212    }
213}
214
215impl ActionCursor {
216    pub(crate) fn as_str(self) -> &'static str {
217        match self {
218            ActionCursor::None => "none",
219            ActionCursor::Pointer => "pointer",
220        }
221    }
222}
223
224/// CSS for the decorations [`ShowActionsOptions`] draws, each a list of
225/// declarations such as `"width: 20px; height: 20px; background: red"`.
226///
227/// See: <https://playwright.dev/docs/api/class-screencast#screencast-show-actions-option-style>
228#[derive(Debug, Default, Clone, serde::Serialize)]
229#[non_exhaustive]
230pub struct ShowActionsStyle {
231    /// The marker drawn at each action point.
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub point: Option<String>,
234    /// The outline drawn around the action's target.
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub highlight: Option<String>,
237    /// The action's title label; replaces the deprecated `font_size`.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub title: Option<String>,
240}
241
242impl ShowActionsStyle {
243    /// CSS for the action-point marker.
244    pub fn point(mut self, css: impl Into<String>) -> Self {
245        self.point = Some(css.into());
246        self
247    }
248    /// CSS for the target highlight.
249    pub fn highlight(mut self, css: impl Into<String>) -> Self {
250        self.highlight = Some(css.into());
251        self
252    }
253    /// CSS for the action title.
254    pub fn title(mut self, css: impl Into<String>) -> Self {
255        self.title = Some(css.into());
256        self
257    }
258}
259
260/// Options for [`Screencast::show_actions`], and for
261/// [`RecordVideo::show_actions`](crate::protocol::RecordVideo::show_actions)
262/// on a context's recorded video.
263#[derive(Debug, Default, Clone, serde::Serialize)]
264#[serde(rename_all = "camelCase")]
265#[non_exhaustive]
266pub struct ShowActionsOptions {
267    /// How long each action label stays on screen (milliseconds).
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub duration: Option<f64>,
270    /// Where the label appears.
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub position: Option<ActionPosition>,
273    /// Label font size, pixels.
274    #[deprecated(
275        since = "0.20.0",
276        note = "use `style` with a `title` such as `font-size: 16px`"
277    )]
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub font_size: Option<i32>,
280    /// Pointer-cursor decoration at action points.
281    #[serde(skip_serializing_if = "Option::is_none")]
282    pub cursor: Option<ActionCursor>,
283    /// CSS for the marker, the target highlight, and the title.
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub style: Option<ShowActionsStyle>,
286}
287
288impl ShowActionsOptions {
289    /// How long to show each action, in milliseconds.
290    pub fn duration(mut self, duration: f64) -> Self {
291        self.duration = Some(duration);
292        self
293    }
294    /// Where to render the action labels.
295    pub fn position(mut self, position: ActionPosition) -> Self {
296        self.position = Some(position);
297        self
298    }
299    /// Label font size in pixels.
300    #[deprecated(
301        since = "0.20.0",
302        note = "use `style` with a `title` such as `font-size: 16px`"
303    )]
304    pub fn font_size(mut self, font_size: i32) -> Self {
305        #[allow(deprecated)]
306        {
307            self.font_size = Some(font_size);
308        }
309        self
310    }
311    /// CSS for the action decorations.
312    pub fn style(mut self, style: ShowActionsStyle) -> Self {
313        self.style = Some(style);
314        self
315    }
316    /// Pointer-cursor decoration at action points.
317    pub fn cursor(mut self, cursor: ActionCursor) -> Self {
318        self.cursor = Some(cursor);
319        self
320    }
321}
322
323/// Options for [`Screencast::show_chapter`].
324#[derive(Debug, Default, Clone)]
325#[non_exhaustive]
326pub struct ChapterOptions {
327    /// Optional second line under the chapter title.
328    pub description: Option<String>,
329    /// How long the chapter card stays on screen (milliseconds).
330    pub duration: Option<f64>,
331}
332
333impl ChapterOptions {
334    /// Chapter description text.
335    pub fn description(mut self, description: impl Into<String>) -> Self {
336        self.description = Some(description.into());
337        self
338    }
339    /// Chapter duration, in milliseconds.
340    pub fn duration(mut self, duration: f64) -> Self {
341        self.duration = Some(duration);
342        self
343    }
344}
345
346/// Options for [`Screencast::show_overlay`].
347#[derive(Debug, Default, Clone)]
348#[non_exhaustive]
349pub struct ShowOverlayOptions {
350    /// How long the overlay stays on screen (milliseconds).
351    pub duration: Option<f64>,
352}
353
354impl ShowOverlayOptions {
355    /// How long to show the overlay, in milliseconds.
356    pub fn duration(mut self, duration: f64) -> Self {
357        self.duration = Some(duration);
358        self
359    }
360}
361
362/// Identifier for an active HTML overlay; pass to
363/// [`Screencast::remove_overlay`] to dismiss the overlay before its
364/// duration expires.
365#[derive(Debug, Clone, PartialEq, Eq)]
366pub struct OverlayId(pub String);
367
368/// Live frame-streaming entry point. Obtained from
369/// [`Page::screencast`](crate::protocol::Page::screencast).
370#[derive(Clone)]
371pub struct Screencast {
372    page: Page,
373}
374
375impl Screencast {
376    pub(crate) fn new(page: Page) -> Self {
377        Self { page }
378    }
379
380    /// Begin streaming. Frames arrive on handlers registered via
381    /// [`on_frame`](Self::on_frame); register them before calling
382    /// `start` so no frames are missed.
383    ///
384    /// If `options.path` is set, the screencast is also recorded to
385    /// disk; the file is written when [`stop`](Self::stop) is called.
386    #[tracing::instrument(level = "info", skip_all, fields(page_guid = %self.page.guid()))]
387    pub async fn start(&self, options: ScreencastStartOptions) -> Result<()> {
388        self.page.screencast_start(options).await
389    }
390
391    /// Stop the screencast. If `start` was called with a `path`, the
392    /// recorded file is written to that path before this call returns.
393    #[tracing::instrument(level = "info", skip_all, fields(page_guid = %self.page.guid()))]
394    pub async fn stop(&self) -> Result<()> {
395        self.page.screencast_stop().await
396    }
397
398    /// Register a handler for incoming frames. Multiple handlers may be
399    /// registered; they fire in order for each frame.
400    pub fn on_frame<F, Fut>(&self, handler: F)
401    where
402        F: Fn(ScreencastFrame) -> Fut + Send + Sync + 'static,
403        Fut: std::future::Future<Output = Result<()>> + Send + 'static,
404    {
405        self.page.screencast_on_frame(handler);
406    }
407
408    /// Overlay action labels on the streamed frames as actions occur.
409    /// Pair with [`hide_actions`](Self::hide_actions) to stop.
410    #[tracing::instrument(level = "debug", skip_all, fields(page_guid = %self.page.guid()))]
411    pub async fn show_actions(&self, options: ShowActionsOptions) -> Result<()> {
412        self.page.screencast_show_actions(options).await
413    }
414
415    /// Stop overlaying action labels. No-op if not currently shown.
416    #[tracing::instrument(level = "debug", skip_all, fields(page_guid = %self.page.guid()))]
417    pub async fn hide_actions(&self) -> Result<()> {
418        self.page.screencast_hide_actions().await
419    }
420
421    /// Show a chapter card with the given title (and optional
422    /// description). Useful for splitting a session into named phases
423    /// for an agent's video log.
424    #[tracing::instrument(level = "debug", skip_all, fields(page_guid = %self.page.guid(), title = %title))]
425    pub async fn show_chapter(&self, title: &str, options: ChapterOptions) -> Result<()> {
426        self.page.screencast_chapter(title, options).await
427    }
428
429    /// Render arbitrary HTML as an overlay. Returns an [`OverlayId`]
430    /// you can pass to [`remove_overlay`](Self::remove_overlay) to
431    /// dismiss it early; otherwise it dismisses itself after
432    /// `options.duration` (if set) or stays until removed.
433    #[tracing::instrument(level = "debug", skip_all, fields(page_guid = %self.page.guid()))]
434    pub async fn show_overlay(&self, html: &str, options: ShowOverlayOptions) -> Result<OverlayId> {
435        self.page.screencast_show_overlay(html, options).await
436    }
437
438    /// Remove an overlay previously created via
439    /// [`show_overlay`](Self::show_overlay). Idempotent.
440    #[tracing::instrument(level = "debug", skip_all, fields(page_guid = %self.page.guid()))]
441    pub async fn remove_overlay(&self, id: OverlayId) -> Result<()> {
442        self.page.screencast_remove_overlay(id).await
443    }
444
445    /// Toggle visibility of all currently-shown overlays without
446    /// removing them. Useful for hiding overlays during a section the
447    /// agent considers "noise" and re-showing them later.
448    #[tracing::instrument(level = "debug", skip_all, fields(page_guid = %self.page.guid(), visible))]
449    pub async fn set_overlay_visible(&self, visible: bool) -> Result<()> {
450        self.page.screencast_set_overlay_visible(visible).await
451    }
452}
453
454#[cfg(test)]
455mod options_tests {
456    use super::{
457        ActionCursor, ActionPosition, ScreencastSize, ScreencastStartOptions, ShowActionsOptions,
458        ShowActionsStyle,
459    };
460    use crate::protocol_spec::{
461        PAGE, command_parameters, keys, mixin_properties, nested_properties,
462    };
463    use serde_json::json;
464
465    #[test]
466    fn show_actions_options_serialize_to_the_driver_spelling() {
467        let options = ShowActionsOptions::default()
468            .duration(500.0)
469            .position(ActionPosition::TopLeft)
470            .cursor(ActionCursor::Pointer)
471            .style(
472                ShowActionsStyle::default()
473                    .point("width: 20px")
474                    .highlight("outline: 2px solid #333")
475                    .title("font-size: 16px"),
476            );
477        let sent = serde_json::to_value(&options).unwrap();
478        assert_eq!(
479            sent,
480            json!({
481                "duration": 500.0,
482                "position": "top-left",
483                "cursor": "pointer",
484                "style": {
485                    "point": "width: 20px",
486                    "highlight": "outline: 2px solid #333",
487                    "title": "font-size: 16px"
488                }
489            })
490        );
491        assert!(keys(&sent).is_subset(&mixin_properties("ShowActionsOptions")));
492        assert!(keys(&sent["style"]).is_subset(&nested_properties("ShowActionsOptions", "style")));
493        assert_eq!(
494            serde_json::to_value(ShowActionsOptions::default()).unwrap(),
495            json!({})
496        );
497    }
498
499    #[test]
500    #[allow(deprecated)]
501    fn the_deprecated_font_size_is_still_sent() {
502        let sent = serde_json::to_value(ShowActionsOptions::default().font_size(14)).unwrap();
503        assert_eq!(sent, json!({"fontSize": 14}));
504    }
505
506    #[test]
507    fn start_params_carry_fps_and_what_the_page_needs() {
508        let options = ScreencastStartOptions::default()
509            .size(ScreencastSize {
510                width: 640,
511                height: 480,
512            })
513            .quality(80)
514            .fps(60)
515            .path("out.webm".into());
516        let sent = options.params(true);
517        assert_eq!(
518            sent,
519            json!({
520                "size": {"width": 640, "height": 480},
521                "quality": 80,
522                "fps": 60,
523                "sendFrames": true,
524                "record": true
525            })
526        );
527        assert!(keys(&sent).is_subset(&command_parameters(PAGE, "screencastStart")));
528        assert_eq!(
529            ScreencastStartOptions::default().params(false),
530            json!({"sendFrames": false, "record": false})
531        );
532    }
533
534    #[test]
535    fn start_builder_sets_quality_and_size() {
536        let o = ScreencastStartOptions::default()
537            .quality(80)
538            .size(ScreencastSize {
539                width: 1280,
540                height: 720,
541            });
542        assert_eq!(o.quality, Some(80));
543        assert_eq!(o.size.map(|s| (s.width, s.height)), Some((1280, 720)));
544    }
545
546    #[test]
547    fn action_cursor_as_str_maps_each_variant() {
548        assert_eq!(ActionCursor::None.as_str(), "none");
549        assert_eq!(ActionCursor::Pointer.as_str(), "pointer");
550    }
551
552    #[test]
553    fn action_position_as_str_maps_each_variant() {
554        assert_eq!(ActionPosition::TopLeft.as_str(), "top-left");
555        assert_eq!(ActionPosition::Top.as_str(), "top");
556        assert_eq!(ActionPosition::TopRight.as_str(), "top-right");
557        assert_eq!(ActionPosition::BottomLeft.as_str(), "bottom-left");
558        assert_eq!(ActionPosition::Bottom.as_str(), "bottom");
559        assert_eq!(ActionPosition::BottomRight.as_str(), "bottom-right");
560    }
561
562    #[test]
563    fn show_actions_builder_sets_cursor() {
564        let o = ShowActionsOptions::default().cursor(ActionCursor::Pointer);
565        assert_eq!(o.cursor, Some(ActionCursor::Pointer));
566    }
567}