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}
106
107impl ScreencastStartOptions {
108    /// Output video size.
109    pub fn size(mut self, size: ScreencastSize) -> Self {
110        self.size = Some(size);
111        self
112    }
113    /// JPEG quality, `0..=100`.
114    pub fn quality(mut self, quality: u8) -> Self {
115        self.quality = Some(quality);
116        self
117    }
118    /// Output file path.
119    pub fn path(mut self, path: PathBuf) -> Self {
120        self.path = Some(path);
121        self
122    }
123}
124
125/// Pixel dimensions for a screencast frame.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub struct ScreencastSize {
128    pub width: u32,
129    pub height: u32,
130}
131
132/// Position for the action-label overlay.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134#[non_exhaustive]
135pub enum ActionPosition {
136    TopLeft,
137    Top,
138    TopRight,
139    BottomLeft,
140    Bottom,
141    BottomRight,
142}
143
144impl ActionPosition {
145    pub(crate) fn as_str(self) -> &'static str {
146        match self {
147            ActionPosition::TopLeft => "top-left",
148            ActionPosition::Top => "top",
149            ActionPosition::TopRight => "top-right",
150            ActionPosition::BottomLeft => "bottom-left",
151            ActionPosition::Bottom => "bottom",
152            ActionPosition::BottomRight => "bottom-right",
153        }
154    }
155}
156
157/// Pointer-cursor decoration for action overlays.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159#[non_exhaustive]
160pub enum ActionCursor {
161    /// No cursor decoration.
162    None,
163    /// Draw a pointer cursor at each action point.
164    Pointer,
165}
166
167impl ActionCursor {
168    pub(crate) fn as_str(self) -> &'static str {
169        match self {
170            ActionCursor::None => "none",
171            ActionCursor::Pointer => "pointer",
172        }
173    }
174}
175
176/// Options for [`Screencast::show_actions`].
177#[derive(Debug, Default, Clone)]
178#[non_exhaustive]
179pub struct ShowActionsOptions {
180    /// How long each action label stays on screen (milliseconds).
181    pub duration: Option<f64>,
182    /// Where the label appears.
183    pub position: Option<ActionPosition>,
184    /// Label font size, pixels.
185    pub font_size: Option<i32>,
186    /// Pointer-cursor decoration at action points.
187    pub cursor: Option<ActionCursor>,
188}
189
190impl ShowActionsOptions {
191    /// How long to show each action, in milliseconds.
192    pub fn duration(mut self, duration: f64) -> Self {
193        self.duration = Some(duration);
194        self
195    }
196    /// Where to render the action labels.
197    pub fn position(mut self, position: ActionPosition) -> Self {
198        self.position = Some(position);
199        self
200    }
201    /// Label font size in pixels.
202    pub fn font_size(mut self, font_size: i32) -> Self {
203        self.font_size = Some(font_size);
204        self
205    }
206    /// Pointer-cursor decoration at action points.
207    pub fn cursor(mut self, cursor: ActionCursor) -> Self {
208        self.cursor = Some(cursor);
209        self
210    }
211}
212
213/// Options for [`Screencast::show_chapter`].
214#[derive(Debug, Default, Clone)]
215#[non_exhaustive]
216pub struct ChapterOptions {
217    /// Optional second line under the chapter title.
218    pub description: Option<String>,
219    /// How long the chapter card stays on screen (milliseconds).
220    pub duration: Option<f64>,
221}
222
223impl ChapterOptions {
224    /// Chapter description text.
225    pub fn description(mut self, description: impl Into<String>) -> Self {
226        self.description = Some(description.into());
227        self
228    }
229    /// Chapter duration, in milliseconds.
230    pub fn duration(mut self, duration: f64) -> Self {
231        self.duration = Some(duration);
232        self
233    }
234}
235
236/// Options for [`Screencast::show_overlay`].
237#[derive(Debug, Default, Clone)]
238#[non_exhaustive]
239pub struct ShowOverlayOptions {
240    /// How long the overlay stays on screen (milliseconds).
241    pub duration: Option<f64>,
242}
243
244impl ShowOverlayOptions {
245    /// How long to show the overlay, in milliseconds.
246    pub fn duration(mut self, duration: f64) -> Self {
247        self.duration = Some(duration);
248        self
249    }
250}
251
252/// Identifier for an active HTML overlay; pass to
253/// [`Screencast::remove_overlay`] to dismiss the overlay before its
254/// duration expires.
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub struct OverlayId(pub String);
257
258/// Live frame-streaming entry point. Obtained from
259/// [`Page::screencast`](crate::protocol::Page::screencast).
260#[derive(Clone)]
261pub struct Screencast {
262    page: Page,
263}
264
265impl Screencast {
266    pub(crate) fn new(page: Page) -> Self {
267        Self { page }
268    }
269
270    /// Begin streaming. Frames arrive on handlers registered via
271    /// [`on_frame`](Self::on_frame); register them before calling
272    /// `start` so no frames are missed.
273    ///
274    /// If `options.path` is set, the screencast is also recorded to
275    /// disk; the file is written when [`stop`](Self::stop) is called.
276    #[tracing::instrument(level = "info", skip_all, fields(page_guid = %self.page.guid()))]
277    pub async fn start(&self, options: ScreencastStartOptions) -> Result<()> {
278        self.page.screencast_start(options).await
279    }
280
281    /// Stop the screencast. If `start` was called with a `path`, the
282    /// recorded file is written to that path before this call returns.
283    #[tracing::instrument(level = "info", skip_all, fields(page_guid = %self.page.guid()))]
284    pub async fn stop(&self) -> Result<()> {
285        self.page.screencast_stop().await
286    }
287
288    /// Register a handler for incoming frames. Multiple handlers may be
289    /// registered; they fire in order for each frame.
290    pub fn on_frame<F, Fut>(&self, handler: F)
291    where
292        F: Fn(ScreencastFrame) -> Fut + Send + Sync + 'static,
293        Fut: std::future::Future<Output = Result<()>> + Send + 'static,
294    {
295        self.page.screencast_on_frame(handler);
296    }
297
298    /// Overlay action labels on the streamed frames as actions occur.
299    /// Pair with [`hide_actions`](Self::hide_actions) to stop.
300    #[tracing::instrument(level = "debug", skip_all, fields(page_guid = %self.page.guid()))]
301    pub async fn show_actions(&self, options: ShowActionsOptions) -> Result<()> {
302        self.page.screencast_show_actions(options).await
303    }
304
305    /// Stop overlaying action labels. No-op if not currently shown.
306    #[tracing::instrument(level = "debug", skip_all, fields(page_guid = %self.page.guid()))]
307    pub async fn hide_actions(&self) -> Result<()> {
308        self.page.screencast_hide_actions().await
309    }
310
311    /// Show a chapter card with the given title (and optional
312    /// description). Useful for splitting a session into named phases
313    /// for an agent's video log.
314    #[tracing::instrument(level = "debug", skip_all, fields(page_guid = %self.page.guid(), title = %title))]
315    pub async fn show_chapter(&self, title: &str, options: ChapterOptions) -> Result<()> {
316        self.page.screencast_chapter(title, options).await
317    }
318
319    /// Render arbitrary HTML as an overlay. Returns an [`OverlayId`]
320    /// you can pass to [`remove_overlay`](Self::remove_overlay) to
321    /// dismiss it early; otherwise it dismisses itself after
322    /// `options.duration` (if set) or stays until removed.
323    #[tracing::instrument(level = "debug", skip_all, fields(page_guid = %self.page.guid()))]
324    pub async fn show_overlay(&self, html: &str, options: ShowOverlayOptions) -> Result<OverlayId> {
325        self.page.screencast_show_overlay(html, options).await
326    }
327
328    /// Remove an overlay previously created via
329    /// [`show_overlay`](Self::show_overlay). Idempotent.
330    #[tracing::instrument(level = "debug", skip_all, fields(page_guid = %self.page.guid()))]
331    pub async fn remove_overlay(&self, id: OverlayId) -> Result<()> {
332        self.page.screencast_remove_overlay(id).await
333    }
334
335    /// Toggle visibility of all currently-shown overlays without
336    /// removing them. Useful for hiding overlays during a section the
337    /// agent considers "noise" and re-showing them later.
338    #[tracing::instrument(level = "debug", skip_all, fields(page_guid = %self.page.guid(), visible))]
339    pub async fn set_overlay_visible(&self, visible: bool) -> Result<()> {
340        self.page.screencast_set_overlay_visible(visible).await
341    }
342}
343
344#[cfg(test)]
345mod options_tests {
346    use super::{
347        ActionCursor, ActionPosition, ScreencastSize, ScreencastStartOptions, ShowActionsOptions,
348    };
349
350    #[test]
351    fn start_builder_sets_quality_and_size() {
352        let o = ScreencastStartOptions::default()
353            .quality(80)
354            .size(ScreencastSize {
355                width: 1280,
356                height: 720,
357            });
358        assert_eq!(o.quality, Some(80));
359        assert_eq!(o.size.map(|s| (s.width, s.height)), Some((1280, 720)));
360    }
361
362    #[test]
363    fn action_cursor_as_str_maps_each_variant() {
364        assert_eq!(ActionCursor::None.as_str(), "none");
365        assert_eq!(ActionCursor::Pointer.as_str(), "pointer");
366    }
367
368    #[test]
369    fn action_position_as_str_maps_each_variant() {
370        assert_eq!(ActionPosition::TopLeft.as_str(), "top-left");
371        assert_eq!(ActionPosition::Top.as_str(), "top");
372        assert_eq!(ActionPosition::TopRight.as_str(), "top-right");
373        assert_eq!(ActionPosition::BottomLeft.as_str(), "bottom-left");
374        assert_eq!(ActionPosition::Bottom.as_str(), "bottom");
375        assert_eq!(ActionPosition::BottomRight.as_str(), "bottom-right");
376    }
377
378    #[test]
379    fn show_actions_builder_sets_cursor() {
380        let o = ShowActionsOptions::default().cursor(ActionCursor::Pointer);
381        assert_eq!(o.cursor, Some(ActionCursor::Pointer));
382    }
383}