diff --git a/rust-native/tests/video_recording.rs b/rust-native/tests/video_recording.rs index 50b4cd4..b551c90 100644 --- a/rust-native/tests/video_recording.rs +++ b/rust-native/tests/video_recording.rs @@ -63,3 +63,49 @@ fn page_screencast_records_webm() { page.close(Default::default()).expect("close page"); browser.close().expect("close browser"); } + +/// A recording of one interaction has exactly two paints: the state before +/// and the result. Both must survive, or the clip is a single frame that shows +/// nothing (every_nth_frame 2 used to drop the second one). +#[test] +fn page_screencast_keeps_a_single_paint_after_start() { + let Some(browser) = launch() else { + return; + }; + let page = browser.new_page().expect("new page"); + page.goto( + "data:text/html,once

before

", + GotoOptions::default().wait_until("load").timeout(10_000.0), + ) + .expect("goto"); + let output = std::env::temp_dir().join(format!( + "rustwright-video-once-{}-{}.webm", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time") + .as_nanos() + )); + page.start_video(output.to_string_lossy().as_ref(), VideoOptions::default()) + .expect("start video"); + std::thread::sleep(Duration::from_millis(800)); + page.evaluate( + "document.getElementById('ok').textContent = 'after'", + None, + ActionOptions::default(), + ) + .expect("one paint"); + std::thread::sleep(Duration::from_millis(800)); + let recording = page.stop_video().expect("stop video"); + assert!( + recording.frames >= 2, + "expected the frame before and the frame after the one paint, got {}", + recording.frames + ); + assert!( + recording.duration_ms >= 600, + "expected the clip to span the pause, got {} ms", + recording.duration_ms + ); + let _ = fs::remove_file(&output); +} diff --git a/src/video.rs b/src/video.rs index ab68219..e6f37ab 100644 --- a/src/video.rs +++ b/src/video.rs @@ -23,10 +23,21 @@ pub const DEFAULT_VIDEO_QUALITY: u32 = 80; /// Longest captured edge in CSS pixels. Chromium scales the screencast so /// the longer side is at most this (800 keeps a 16:9 clip around 800×450). pub const DEFAULT_VIDEO_MAX_WIDTH: u32 = 800; -/// CDP `Page.startScreencast` has no fps. Chromium emits on compositor paint -/// (often ~60 Hz when the page is animating). `2` keeps every other paint, -/// which is ~30 fps from a 60 Hz source. -pub const DEFAULT_VIDEO_EVERY_NTH_FRAME: u32 = 2; +/// CDP `Page.startScreencast` has no fps. Chromium emits a frame only when +/// the compositor paints, so a still page costs nothing however long the +/// recording runs, and an animating page runs at its own rate (often ~60 Hz). +/// Ask for every paint: skipping alternate frames here made a recording of +/// one interaction (a click, then one paint for the result) come back as a +/// single frame, because the result's paint was the skipped one. The rate is +/// capped by time instead, in [`FrameJournal`], which can tell a burst from +/// the only frame that matters. +pub const DEFAULT_VIDEO_EVERY_NTH_FRAME: u32 = 1; +/// Frames closer together than this are a burst (an animation, a scroll): +/// the journal keeps one per interval and the last one of the burst, so the +/// clip stays near 30 fps at most while a frame that follows a pause, or ends +/// a burst, is always kept. Every WebM frame is a keyframe, so this is what +/// bounds file size and encode time for animated pages. +pub const DEFAULT_VIDEO_MIN_FRAME_INTERVAL_US: u64 = 1_000_000 / 30; pub const MAX_VIDEO_FRAMES: u32 = 3_600; pub const MAX_VIDEO_JOURNAL_BYTES: u64 = 256 * 1024 * 1024; const DEFAULT_FALLBACK_WIDTH: u32 = 1280; @@ -108,6 +119,12 @@ pub struct FrameJournal { last_ts_us: Option, width: u32, height: u32, + /// Bursts are thinned to one frame per this many microseconds. + min_interval_us: u64, + /// The newest frame that arrived inside the current interval. Written + /// when the burst ends (the next frame is a full interval away) or when the + /// journal finishes, so the final state is never lost to thinning. + pending: Option<(u64, Vec)>, } #[derive(Debug)] @@ -137,15 +154,45 @@ impl FrameJournal { last_ts_us: None, width: 0, height: 0, + min_interval_us: DEFAULT_VIDEO_MIN_FRAME_INTERVAL_US, + pending: None, }) } - /// Append one JPEG frame. Returns `false` when the journal is at a cap and + /// Thin bursts to one frame per `min_interval_us`; `0` keeps every frame. + pub fn with_min_frame_interval_us(mut self, min_interval_us: u64) -> Self { + self.min_interval_us = min_interval_us; + self + } + + /// Offer one JPEG frame. The first frame, and any frame at least an + /// interval after the last kept one, is written at once. A frame inside the + /// interval is held as the burst's newest and written when the burst ends + /// or the journal finishes. Returns `false` when the journal is at a cap and /// the frame was dropped (the caller should still ACK the screencast). pub fn push(&mut self, timestamp_us: u64, jpeg: &[u8]) -> RwResult { if jpeg.is_empty() { return Ok(true); } + let Some(last_kept) = self.last_ts_us else { + return self.write_frame(timestamp_us, jpeg); + }; + if timestamp_us.saturating_sub(last_kept) < self.min_interval_us { + self.pending = Some((timestamp_us, jpeg.to_vec())); + return Ok(true); + } + // The burst is over. Its last frame was on screen from its own + // timestamp until now; keep it when that was long enough to see, drop + // it when this frame replaced it within an interval. + if let Some((pending_ts, pending_jpeg)) = self.pending.take() { + if timestamp_us.saturating_sub(pending_ts) >= self.min_interval_us { + self.write_frame(pending_ts, &pending_jpeg)?; + } + } + self.write_frame(timestamp_us, jpeg) + } + + fn write_frame(&mut self, timestamp_us: u64, jpeg: &[u8]) -> RwResult { if self.frames >= MAX_VIDEO_FRAMES || self.bytes >= MAX_VIDEO_JOURNAL_BYTES { return Ok(false); } @@ -155,9 +202,8 @@ impl FrameJournal { if self.bytes.saturating_add(framed) > MAX_VIDEO_JOURNAL_BYTES { return Ok(false); } - let frame_len = u32::try_from(jpeg.len()).map_err(|_| { - RwError::Message("video journal frame exceeds 4 GiB".to_string()) - })?; + let frame_len = u32::try_from(jpeg.len()) + .map_err(|_| RwError::Message("video journal frame exceeds 4 GiB".to_string()))?; if self.width == 0 || self.height == 0 { if let Some((width, height)) = jpeg_dimensions(jpeg) { self.width = width; @@ -179,6 +225,9 @@ impl FrameJournal { } pub fn finish(mut self) -> RwResult { + if let Some((pending_ts, pending_jpeg)) = self.pending.take() { + self.write_frame(pending_ts, &pending_jpeg)?; + } self.writer .flush() .map_err(|error| RwError::Message(format!("video journal flush failed: {error}")))?; @@ -257,7 +306,10 @@ fn video_container(output: &Path) -> RwResult { } } -fn prepare_video_output(journal: &FinishedJournal, output: &Path) -> RwResult<(u32, u32, u64, u32)> { +fn prepare_video_output( + journal: &FinishedJournal, + output: &Path, +) -> RwResult<(u32, u32, u64, u32)> { if journal.frames == 0 { return Err(RwError::Message( "video recording captured no frames".to_string(), @@ -391,16 +443,14 @@ fn rgb_to_vp8_frame(rgb: &[u8], width: u32, height: u32) -> Vp8Frame { } fn webm_duration_ms(frames: &[(u64, Vec)], fps: u32) -> f64 { - let last = frames.last().map(|(timestamp_ms, _)| *timestamp_ms).unwrap_or(0) as f64; + let last = frames + .last() + .map(|(timestamp_ms, _)| *timestamp_ms) + .unwrap_or(0) as f64; last + 1000.0 / f64::from(fps.max(1)) } -fn mux_webm( - width: u32, - height: u32, - frames: &[(u64, Vec)], - fps: u32, -) -> RwResult> { +fn mux_webm(width: u32, height: u32, frames: &[(u64, Vec)], fps: u32) -> RwResult> { let mut ebml_body = Vec::new(); ebml_body.extend(ebml_elem(&[0x42, 0x86], &[1])?); ebml_body.extend(ebml_elem(&[0x42, 0xF7], &[1])?); @@ -412,7 +462,10 @@ fn mux_webm( let ebml = ebml_elem(&[0x1A, 0x45, 0xDF, 0xA3], &ebml_body)?; let mut info = Vec::new(); - info.extend(ebml_elem(&[0x2A, 0xD7, 0xB1], &1_000_000u64.to_be_bytes()[4..])?); + info.extend(ebml_elem( + &[0x2A, 0xD7, 0xB1], + &1_000_000u64.to_be_bytes()[4..], + )?); info.extend(ebml_elem(&[0x4D, 0x80], b"rustwright")?); info.extend(ebml_elem(&[0x57, 0x41], b"rustwright")?); // HTML5