use std::{
fmt, fs,
io::Cursor,
path::{Path, PathBuf},
sync::Arc,
time::{Duration, Instant},
};
use base64::{Engine, engine::general_purpose::STANDARD as B64};
use chromiumoxide::{
Page as CdpPage,
cdp::browser_protocol::page::{
EventScreencastFrame, ScreencastFrameAckParams, StartScreencastFormat,
StartScreencastParams, StopScreencastParams,
},
};
use futures::{Stream, StreamExt};
use image::{
DynamicImage, GenericImageView, ImageFormat, Rgba, RgbaImage, codecs::jpeg::JpegEncoder,
imageops, load_from_memory_with_format,
};
use serde::Serialize;
use tokio::{
sync::{Mutex as AsyncMutex, OwnedMutexGuard, oneshot},
task::{JoinHandle, spawn_blocking},
time::sleep,
};
use crate::{
error::{Result, VoidCrawlError},
page::{Bbox, Page},
selector::{SelectorEntry, SelectorResolution},
viewport::{ScrollTarget, Viewport},
};
pub const DEFAULT_FPS: u8 = 10;
pub const DEFAULT_MAX_DURATION: Duration = Duration::from_secs(30);
pub const DEFAULT_MAX_FRAMES: usize = 900;
pub const DEFAULT_QUALITY: u8 = 80;
pub const DEFAULT_MASK_PAD: u32 = 2;
pub const MASK_TRACK_HZ: u8 = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum FrameFormat {
Jpeg,
Png,
}
impl FrameFormat {
fn as_cdp(self) -> StartScreencastFormat {
match self {
Self::Jpeg => StartScreencastFormat::Jpeg,
Self::Png => StartScreencastFormat::Png,
}
}
fn as_image(self) -> ImageFormat {
match self {
Self::Jpeg => ImageFormat::Jpeg,
Self::Png => ImageFormat::Png,
}
}
pub fn extension(self) -> &'static str {
match self {
Self::Jpeg => "jpg",
Self::Png => "png",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Encoding {
Gif,
Mp4,
WebM,
}
impl Encoding {
pub fn extension(self) -> &'static str {
match self {
Self::Gif => "gif",
Self::Mp4 => "mp4",
Self::WebM => "webm",
}
}
}
#[derive(Debug, Clone)]
pub enum MaskRegion {
Fixed(Bbox),
Selector(SelectorEntry),
}
#[derive(Debug, Clone)]
pub struct MaskSpec {
pub region: MaskRegion,
pub track: bool,
pub label: Option<String>,
}
impl MaskSpec {
pub fn bbox(bbox: Bbox) -> Self {
Self { region: MaskRegion::Fixed(bbox), track: false, label: None }
}
pub fn selector(entry: SelectorEntry) -> Self {
Self { region: MaskRegion::Selector(entry), track: true, label: None }
}
pub fn with_track(mut self, track: bool) -> Self {
self.track = track;
self
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
fn is_tracked(&self) -> bool {
self.track && matches!(self.region, MaskRegion::Selector(_))
}
}
#[derive(Debug, Clone, Serialize)]
pub struct MaskReport {
pub label: String,
pub bbox: Bbox,
pub tracked: bool,
pub unresolved_ticks: usize,
pub stale_frames: usize,
}
#[derive(Debug, Clone)]
pub struct RecordingOptions {
pub dir: Option<PathBuf>,
pub bbox: Option<Bbox>,
pub selectors: Vec<SelectorEntry>,
pub masks: Vec<MaskSpec>,
pub mask_pad: u32,
pub viewport: Option<Viewport>,
pub scroll: Option<ScrollTarget>,
pub fps: u8,
pub max_duration: Duration,
pub max_frames: usize,
pub format: FrameFormat,
pub quality: u8,
pub write_frames: bool,
pub foreground: Option<bool>,
pub encode: Vec<Encoding>,
}
impl Default for RecordingOptions {
fn default() -> Self {
Self {
dir: None,
bbox: None,
selectors: Vec::new(),
masks: Vec::new(),
mask_pad: DEFAULT_MASK_PAD,
viewport: None,
scroll: None,
fps: DEFAULT_FPS,
max_duration: DEFAULT_MAX_DURATION,
max_frames: DEFAULT_MAX_FRAMES,
format: FrameFormat::Jpeg,
quality: DEFAULT_QUALITY,
write_frames: false,
foreground: None,
encode: Vec::new(),
}
}
}
impl RecordingOptions {
pub fn with_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.dir = Some(dir.into());
self
}
pub fn with_bbox(mut self, bbox: Bbox) -> Self {
self.bbox = Some(bbox);
self
}
pub fn with_selector(mut self, selector: SelectorEntry) -> Self {
self.selectors.push(selector);
self
}
pub fn with_selectors(mut self, selectors: impl IntoIterator<Item = SelectorEntry>) -> Self {
self.selectors.extend(selectors);
self
}
pub fn with_mask(mut self, mask: MaskSpec) -> Self {
self.masks.push(mask);
self
}
pub fn with_masks(mut self, masks: impl IntoIterator<Item = MaskSpec>) -> Self {
self.masks.extend(masks);
self
}
pub fn with_mask_pad(mut self, pad: u32) -> Self {
self.mask_pad = pad;
self
}
pub fn with_viewport(mut self, viewport: Viewport) -> Self {
self.viewport = Some(viewport);
self
}
pub fn with_scroll(mut self, scroll: ScrollTarget) -> Self {
self.scroll = Some(scroll);
self
}
pub fn with_fps(mut self, fps: u8) -> Self {
self.fps = fps;
self
}
pub fn with_max_duration(mut self, max_duration: Duration) -> Self {
self.max_duration = max_duration;
self
}
pub fn with_format(mut self, format: FrameFormat) -> Self {
self.format = format;
self
}
pub fn with_foreground(mut self, foreground: bool) -> Self {
self.foreground = Some(foreground);
self
}
pub fn with_encoding(mut self, encoding: Encoding) -> Self {
self.encode.push(encoding);
self
}
fn validate(&self) -> Result<()> {
if self.bbox.is_some() && !self.selectors.is_empty() {
return Err(VoidCrawlError::RecordingError(
"RecordingOptions: `bbox` and `selectors` are mutually exclusive".into(),
));
}
if self.fps == 0 {
return Err(VoidCrawlError::RecordingError(
"RecordingOptions: `fps` must be at least 1".into(),
));
}
if self.max_duration.is_zero() {
return Err(VoidCrawlError::RecordingError(
"RecordingOptions: `max_duration` must be greater than zero".into(),
));
}
if self.max_frames == 0 {
return Err(VoidCrawlError::RecordingError(
"RecordingOptions: `max_frames` must be at least 1".into(),
));
}
if !self.encode.is_empty() && self.dir.is_none() {
return Err(VoidCrawlError::RecordingError(
"RecordingOptions: `encode` requires `dir` to be set".into(),
));
}
if self.write_frames && self.dir.is_none() {
return Err(VoidCrawlError::RecordingError(
"RecordingOptions: `write_frames` requires `dir` to be set".into(),
));
}
Ok(())
}
}
#[derive(Clone, Serialize)]
pub struct Frame {
pub index: usize,
pub offset: Duration,
#[serde(skip)]
pub data: Vec<u8>,
}
impl fmt::Debug for Frame {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Frame")
.field("index", &self.index)
.field("offset", &self.offset)
.field("bytes", &self.data.len())
.finish()
}
}
#[derive(Debug, Clone, Serialize)]
pub struct RecordedRegion {
pub label: String,
pub bbox: Option<Bbox>,
pub frames: Vec<Frame>,
pub outputs: Vec<PathBuf>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Recording {
pub regions: Vec<RecordedRegion>,
pub masks: Vec<MaskReport>,
pub format: FrameFormat,
pub duration: Duration,
pub frames_captured: usize,
pub frames_dropped: usize,
pub device_pixel_ratio: f64,
pub foregrounded: bool,
}
impl Recording {
pub fn effective_fps(&self) -> f64 {
let secs = self.duration.as_secs_f64();
if secs <= 0.0 {
return 0.0;
}
#[expect(clippy::cast_precision_loss, reason = "frame counts are small")]
let frames = self.frames_captured as f64;
frames / secs
}
}
#[derive(Debug)]
pub struct RecordingHandle {
collector: JoinHandle<CollectedFrames>,
stop_tx: Option<oneshot::Sender<()>>,
mask_state: Option<Arc<AsyncMutex<MaskState>>>,
mask_tracker: Option<JoinHandle<()>>,
mask_stop_tx: Option<oneshot::Sender<()>>,
cdp: CdpPage,
capture_guard: Option<Arc<AsyncMutex<Option<OwnedMutexGuard<()>>>>>,
started: Instant,
regions: Vec<(String, Option<Bbox>)>,
restore_scroll: Option<(f64, f64)>,
#[expect(clippy::option_option, reason = "outer = did we override, inner = what was there")]
restore_viewport: Option<Option<Viewport>>,
foregrounded: bool,
opts: RecordingOptions,
}
struct ScreencastStart {
collector: JoinHandle<CollectedFrames>,
stop_tx: oneshot::Sender<()>,
capture_guard: Option<Arc<AsyncMutex<Option<OwnedMutexGuard<()>>>>>,
regions: Vec<(String, Option<Bbox>)>,
mask_state: Option<Arc<AsyncMutex<MaskState>>>,
mask_tracker: Option<JoinHandle<()>>,
mask_stop_tx: Option<oneshot::Sender<()>>,
restore_scroll: Option<(f64, f64)>,
started_at: Instant,
foregrounded: bool,
}
#[derive(Debug)]
struct CollectedFrames {
frames: Vec<RawFrame>,
dropped: usize,
}
#[derive(Debug)]
struct MaskState {
entries: Vec<MaskEntry>,
}
#[derive(Debug)]
struct MaskEntry {
label: String,
initial: Bbox,
current: Bbox,
previous: Bbox,
tracked: bool,
unresolved_ticks: usize,
stale_frames: usize,
stale: bool,
}
impl MaskState {
fn new(entries: Vec<MaskEntry>) -> Self {
Self { entries }
}
fn stamp(&mut self) -> Vec<Bbox> {
self.entries
.iter_mut()
.map(|e| {
if e.stale {
e.stale_frames += 1;
}
union(e.current, e.previous)
})
.collect()
}
fn record_resolved(&mut self, index: usize, bbox: Bbox) {
if let Some(e) = self.entries.get_mut(index) {
e.previous = e.current;
e.current = bbox;
e.stale = false;
}
}
fn record_unresolved(&mut self, index: usize) {
if let Some(e) = self.entries.get_mut(index) {
e.unresolved_ticks += 1;
e.stale = true;
}
}
fn reports(&self) -> Vec<MaskReport> {
self.entries
.iter()
.map(|e| MaskReport {
label: e.label.clone(),
bbox: e.initial,
tracked: e.tracked,
unresolved_ticks: e.unresolved_ticks,
stale_frames: e.stale_frames,
})
.collect()
}
}
fn union(a: Bbox, b: Bbox) -> Bbox {
let x = a.x.min(b.x);
let y = a.y.min(b.y);
let right = a.x.saturating_add(a.width).max(b.x.saturating_add(b.width));
let bottom = a.y.saturating_add(a.height).max(b.y.saturating_add(b.height));
Bbox { x, y, width: right - x, height: bottom - y }
}
#[derive(Debug)]
struct RawFrame {
offset: Duration,
data: Vec<u8>,
masks: Vec<Bbox>,
device_width: f64,
}
async fn release_capture_guard(
capture_guard: Option<Arc<AsyncMutex<Option<OwnedMutexGuard<()>>>>>,
) {
if let Some(capture_guard) = capture_guard {
capture_guard.lock().await.take();
}
}
impl RecordingHandle {
pub async fn stop(mut self, page: &Page) -> Result<Recording> {
let duration = self.started.elapsed();
let capture_guard = self.capture_guard.take();
if let Some(tx) = self.stop_tx.take() {
let _ = tx.send(());
}
if let Some(tx) = self.mask_stop_tx.take() {
let _ = tx.send(());
}
let _ = self.cdp.execute(StopScreencastParams::default()).await;
let collected = self
.collector
.await
.map_err(|e| VoidCrawlError::RecordingError(format!("collector task: {e}")))?;
if let Some(tracker) = self.mask_tracker.take() {
let _ = tracker.await;
}
let mask_reports = match &self.mask_state {
Some(state) => state.lock().await.reports(),
None => Vec::new(),
};
if let Some((x, y)) = self.restore_scroll {
let _ = page.evaluate_js(&format!("window.scrollTo({x}, {y})")).await;
}
if let Some(prev) = self.restore_viewport.take() {
let _ = match prev {
Some(v) => page.set_viewport(v).await,
None => page.clear_viewport().await,
};
}
let dpr = page
.evaluate_js("window.devicePixelRatio")
.await
.ok()
.and_then(|v| v.as_f64())
.unwrap_or(1.0);
release_capture_guard(capture_guard).await;
let mut recording = build_regions(
collected,
&self.regions,
mask_reports,
&self.opts,
duration,
dpr,
self.foregrounded,
)
.await?;
if let Some(dir) = self.opts.dir.clone() {
write_artifacts(&mut recording, &dir, &self.opts).await?;
}
Ok(recording)
}
}
impl Page {
pub async fn record(&self, opts: RecordingOptions) -> Result<Recording> {
let duration = opts.max_duration;
let handle = self.start_recording(opts).await?;
sleep(duration).await;
handle.stop(self).await
}
pub async fn start_recording(&self, opts: RecordingOptions) -> Result<RecordingHandle> {
opts.validate()?;
let restore_viewport = if let Some(ref viewport) = opts.viewport {
let prev = self.current_viewport();
self.set_viewport(viewport.clone()).await?;
Some(prev)
} else {
None
};
let started = self.begin_screencast(&opts).await;
match started {
Ok(started) => Ok(RecordingHandle {
collector: started.collector,
stop_tx: Some(started.stop_tx),
mask_state: started.mask_state,
mask_tracker: started.mask_tracker,
mask_stop_tx: started.mask_stop_tx,
cdp: self.cdp().clone(),
capture_guard: started.capture_guard,
started: started.started_at,
regions: started.regions,
restore_scroll: started.restore_scroll,
restore_viewport,
foregrounded: started.foregrounded,
opts,
}),
Err(e) => {
if let Some(prev) = restore_viewport {
let _ = match prev {
Some(v) => self.set_viewport(v).await,
None => self.clear_viewport().await,
};
}
Err(e)
}
}
}
async fn begin_screencast(&self, opts: &RecordingOptions) -> Result<ScreencastStart> {
let restore_scroll = match opts.scroll {
Some(target) => {
let prev = self.scroll_position().await?;
self.scroll_to(target).await?;
Some(prev)
}
None => None,
};
let regions = self.resolve_regions(opts).await?;
let mask_state = self.resolve_masks(opts).await?;
let foreground = match opts.foreground {
Some(explicit) => explicit,
None => !self.alone_in_window().await.unwrap_or(false),
};
let capture_guard = if foreground {
let guard = self.capture_lock().lock_owned().await;
self.cdp()
.bring_to_front()
.await
.map_err(|e| VoidCrawlError::RecordingError(format!("bring to front: {e}")))?;
Some(Arc::new(AsyncMutex::new(Some(guard))))
} else {
None
};
let events = self
.cdp()
.event_listener::<EventScreencastFrame>()
.await
.map_err(|e| VoidCrawlError::RecordingError(format!("screencast listener: {e}")))?;
let mut params = StartScreencastParams::builder().format(opts.format.as_cdp());
if matches!(opts.format, FrameFormat::Jpeg) {
params = params.quality(i64::from(opts.quality));
}
self.cdp()
.execute(params.build())
.await
.map_err(|e| VoidCrawlError::RecordingError(format!("startScreencast: {e}")))?;
let started_at = Instant::now();
let (stop_tx, stop_rx) = oneshot::channel();
let collector = spawn_collector(
self.cdp().clone(),
events,
stop_rx,
started_at,
opts.fps,
opts.max_frames,
opts.max_duration,
mask_state.clone(),
capture_guard.clone(),
);
let tracked: Vec<(usize, SelectorEntry)> = opts
.masks
.iter()
.enumerate()
.filter(|(_, m)| m.is_tracked())
.filter_map(|(i, m)| match &m.region {
MaskRegion::Selector(entry) => Some((i, entry.clone())),
MaskRegion::Fixed(_) => None,
})
.collect();
let (mask_tracker, mask_stop_tx) = match (&mask_state, tracked.is_empty()) {
(Some(state), false) => {
let (tx, rx) = oneshot::channel();
let interval = Duration::from_secs_f64(1.0 / f64::from(MASK_TRACK_HZ.max(1)));
let task = spawn_mask_tracker(
self.clone_handle(),
tracked,
Arc::clone(state),
interval,
rx,
opts.max_duration,
);
(Some(task), Some(tx))
}
_ => (None, None),
};
Ok(ScreencastStart {
collector,
stop_tx,
capture_guard,
regions,
mask_state,
mask_tracker,
mask_stop_tx,
restore_scroll,
started_at,
foregrounded: foreground,
})
}
async fn resolve_masks(
&self,
opts: &RecordingOptions,
) -> Result<Option<Arc<AsyncMutex<MaskState>>>> {
if opts.masks.is_empty() {
return Ok(None);
}
let mut entries = Vec::with_capacity(opts.masks.len());
for (i, mask) in opts.masks.iter().enumerate() {
let (label, bbox) = match &mask.region {
MaskRegion::Fixed(bbox) => {
(mask.label.clone().unwrap_or_else(|| format!("mask{i}")), *bbox)
}
MaskRegion::Selector(entry) => {
let label = mask
.label
.clone()
.unwrap_or_else(|| format!("mask_{}", region_label(entry, i)));
let bbox = match self.resolve_selector(entry).await? {
SelectorResolution::Resolved { bbox } => bbox,
SelectorResolution::Empty { reason } => {
return Err(VoidCrawlError::ElementNotVisible(format!(
"recording mask {label:?}: {reason}"
)));
}
SelectorResolution::Ambiguous { reason, .. } => {
return Err(VoidCrawlError::AmbiguousSelector(format!(
"recording mask {label:?}: {reason}"
)));
}
};
(label, bbox)
}
};
entries.push(MaskEntry {
label,
initial: bbox,
current: bbox,
previous: bbox,
tracked: mask.is_tracked(),
unresolved_ticks: 0,
stale_frames: 0,
stale: false,
});
}
Ok(Some(Arc::new(AsyncMutex::new(MaskState::new(entries)))))
}
async fn resolve_regions(
&self,
opts: &RecordingOptions,
) -> Result<Vec<(String, Option<Bbox>)>> {
if let Some(bbox) = opts.bbox {
return Ok(vec![("bbox".to_string(), Some(bbox))]);
}
if opts.selectors.is_empty() {
return Ok(vec![("viewport".to_string(), None)]);
}
let mut regions = Vec::with_capacity(opts.selectors.len());
for (i, entry) in opts.selectors.iter().enumerate() {
let label = region_label(entry, i);
match self.resolve_selector(entry).await? {
SelectorResolution::Resolved { bbox } => regions.push((label, Some(bbox))),
SelectorResolution::Empty { reason } => {
return Err(VoidCrawlError::ElementNotVisible(format!(
"recording region {label:?}: {reason}"
)));
}
SelectorResolution::Ambiguous { reason, .. } => {
return Err(VoidCrawlError::AmbiguousSelector(format!(
"recording region {label:?}: {reason}"
)));
}
}
}
Ok(regions)
}
}
fn region_label(entry: &SelectorEntry, index: usize) -> String {
let raw =
entry.name.as_deref().filter(|s| !s.trim().is_empty()).unwrap_or(entry.value.as_str());
let cleaned: String = raw
.trim()
.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect();
let trimmed = cleaned.trim_matches('_');
if trimmed.is_empty() {
format!("region{index}")
} else {
let short: String = trimmed.chars().take(48).collect();
format!("{index}_{short}")
}
}
#[allow(clippy::too_many_arguments)]
fn spawn_collector(
cdp: CdpPage,
mut events: impl Stream<Item = Arc<EventScreencastFrame>> + Unpin + Send + 'static,
stop_rx: oneshot::Receiver<()>,
started: Instant,
fps: u8,
max_frames: usize,
max_duration: Duration,
mask_state: Option<Arc<AsyncMutex<MaskState>>>,
capture_guard: Option<Arc<AsyncMutex<Option<OwnedMutexGuard<()>>>>>,
) -> JoinHandle<CollectedFrames> {
let min_gap = Duration::from_secs_f64(1.0 / f64::from(fps));
tokio::spawn(async move {
let mut frames: Vec<RawFrame> = Vec::new();
let mut dropped = 0usize;
let mut last_kept: Option<Instant> = None;
let mut stop_rx = stop_rx;
let deadline = sleep(max_duration);
tokio::pin!(deadline);
loop {
let event = tokio::select! {
biased;
_ = &mut stop_rx => break,
() = &mut deadline => {
let _ = cdp.execute(StopScreencastParams::default()).await;
if let Some(guard) = &capture_guard {
guard.lock().await.take();
}
break;
}
event = events.next() => event,
};
let Some(event) = event else { break };
let _ = cdp.execute(ScreencastFrameAckParams::new(event.session_id)).await;
let now = Instant::now();
if frames.len() >= max_frames {
dropped += 1;
continue;
}
if let Some(last) = last_kept
&& now.duration_since(last) < min_gap
{
dropped += 1;
continue;
}
let encoded: &str = event.data.as_ref();
match B64.decode(encoded) {
Ok(data) => {
last_kept = Some(now);
let masks = match &mask_state {
Some(state) => state.lock().await.stamp(),
None => Vec::new(),
};
frames.push(RawFrame {
offset: now.duration_since(started),
data,
masks,
device_width: event.metadata.device_width,
});
}
Err(_) => dropped += 1,
}
}
CollectedFrames { frames, dropped }
})
}
fn spawn_mask_tracker(
page: Page,
tracked: Vec<(usize, SelectorEntry)>,
state: Arc<AsyncMutex<MaskState>>,
interval: Duration,
stop_rx: oneshot::Receiver<()>,
max_duration: Duration,
) -> JoinHandle<()> {
tokio::spawn(async move {
let mut stop_rx = stop_rx;
let deadline = sleep(max_duration);
tokio::pin!(deadline);
loop {
tokio::select! {
biased;
_ = &mut stop_rx => break,
() = &mut deadline => break,
() = sleep(interval) => {}
}
for (index, entry) in &tracked {
match page.resolve_selector(entry).await {
Ok(SelectorResolution::Resolved { bbox }) => {
state.lock().await.record_resolved(*index, bbox);
}
_ => state.lock().await.record_unresolved(*index),
}
}
}
})
}
#[allow(clippy::too_many_arguments)]
async fn build_regions(
collected: CollectedFrames,
regions: &[(String, Option<Bbox>)],
masks: Vec<MaskReport>,
opts: &RecordingOptions,
duration: Duration,
device_pixel_ratio: f64,
foregrounded: bool,
) -> Result<Recording> {
let frames_captured = collected.frames.len();
let frames_dropped = collected.dropped;
let regions_spec: Vec<(String, Option<Bbox>)> = regions.to_vec();
let format = opts.format;
let quality = opts.quality;
let mask_pad = opts.mask_pad;
let built = spawn_blocking(move || {
let mut frames = collected.frames;
mask_raw_frames(&mut frames, mask_pad, format, quality)?;
crop_regions(&frames, ®ions_spec, format, quality)
})
.await
.map_err(|e| VoidCrawlError::RecordingError(format!("crop task: {e}")))??;
Ok(Recording {
regions: built,
masks,
format,
duration,
frames_captured,
frames_dropped,
device_pixel_ratio,
foregrounded,
})
}
fn mask_raw_frames(raw: &mut [RawFrame], pad: u32, format: FrameFormat, quality: u8) -> Result<()> {
for frame in raw.iter_mut() {
if frame.masks.is_empty() {
continue;
}
let img = load_from_memory_with_format(&frame.data, format.as_image()).map_err(|e| {
VoidCrawlError::RecordingError(format!("decode frame for masking: {e}"))
})?;
let scale = if frame.device_width > 0.0 {
f64::from(img.width()) / frame.device_width
} else {
1.0
};
let mut rgba = img.to_rgba8();
mask_image(&mut rgba, &frame.masks, scale, pad);
frame.data = encode_image(&DynamicImage::ImageRgba8(rgba), format, quality)?;
}
Ok(())
}
fn mask_image(img: &mut RgbaImage, masks: &[Bbox], scale: f64, pad: u32) {
let (img_w, img_h) = img.dimensions();
let black = Rgba([0, 0, 0, 255]);
for mask in masks {
let padded = Bbox {
x: mask.x.saturating_sub(pad),
y: mask.y.saturating_sub(pad),
width: mask.width.saturating_add(pad.saturating_mul(2)),
height: mask.height.saturating_add(pad.saturating_mul(2)),
};
let Some((x, y, w, h)) = scale_rect(padded, scale, img_w, img_h) else {
continue;
};
for yy in y..y + h {
for xx in x..x + w {
img.put_pixel(xx, yy, black);
}
}
}
}
fn scale_rect(bbox: Bbox, scale: f64, img_w: u32, img_h: u32) -> Option<(u32, u32, u32, u32)> {
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "scaled pixel coordinates, clamped to the frame below"
)]
let (x, y, w, h) = (
(f64::from(bbox.x) * scale).round() as u32,
(f64::from(bbox.y) * scale).round() as u32,
(f64::from(bbox.width) * scale).round() as u32,
(f64::from(bbox.height) * scale).round() as u32,
);
if x >= img_w || y >= img_h || w == 0 || h == 0 {
return None;
}
Some((x, y, w.min(img_w - x), h.min(img_h - y)))
}
fn crop_regions(
raw: &[RawFrame],
regions: &[(String, Option<Bbox>)],
format: FrameFormat,
quality: u8,
) -> Result<Vec<RecordedRegion>> {
let mut out = Vec::with_capacity(regions.len());
for (label, bbox) in regions {
let frames = match bbox {
None => raw
.iter()
.enumerate()
.map(|(index, f)| Frame { index, offset: f.offset, data: f.data.clone() })
.collect(),
Some(bbox) => {
let mut frames = Vec::with_capacity(raw.len());
for (index, f) in raw.iter().enumerate() {
let data = crop_frame(f, *bbox, format, quality)?;
frames.push(Frame { index, offset: f.offset, data });
}
frames
}
};
out.push(RecordedRegion { label: label.clone(), bbox: *bbox, frames, outputs: Vec::new() });
}
Ok(out)
}
fn crop_frame(raw: &RawFrame, bbox: Bbox, format: FrameFormat, quality: u8) -> Result<Vec<u8>> {
let img = load_from_memory_with_format(&raw.data, format.as_image())
.map_err(|e| VoidCrawlError::RecordingError(format!("decode frame: {e}")))?;
let scale =
if raw.device_width > 0.0 { f64::from(img.width()) / raw.device_width } else { 1.0 };
let (img_w, img_h) = img.dimensions();
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "scaled pixel coordinates, clamped to the frame below"
)]
let (x, y, w, h) = (
(f64::from(bbox.x) * scale).round() as u32,
(f64::from(bbox.y) * scale).round() as u32,
(f64::from(bbox.width) * scale).round() as u32,
(f64::from(bbox.height) * scale).round() as u32,
);
let x = x.min(img_w.saturating_sub(1));
let y = y.min(img_h.saturating_sub(1));
let w = w.min(img_w - x).max(1);
let h = h.min(img_h - y).max(1);
let cropped = imageops::crop_imm(&img, x, y, w, h).to_image();
encode_image(&DynamicImage::ImageRgba8(cropped), format, quality)
}
fn encode_image(img: &DynamicImage, format: FrameFormat, quality: u8) -> Result<Vec<u8>> {
let mut buf = Vec::new();
match format {
FrameFormat::Jpeg => {
let rgb = img.to_rgb8();
let mut encoder = JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100));
encoder
.encode_image(&rgb)
.map_err(|e| VoidCrawlError::RecordingError(format!("encode jpeg: {e}")))?;
}
FrameFormat::Png => {
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)
.map_err(|e| VoidCrawlError::RecordingError(format!("encode png: {e}")))?;
}
}
Ok(buf)
}
async fn write_artifacts(
recording: &mut Recording,
dir: &Path,
opts: &RecordingOptions,
) -> Result<()> {
fs::create_dir_all(dir)
.map_err(|e| VoidCrawlError::RecordingError(format!("create {}: {e}", dir.display())))?;
for region in &mut recording.regions {
if opts.write_frames {
let region_dir = dir.join(®ion.label);
fs::create_dir_all(®ion_dir).map_err(|e| {
VoidCrawlError::RecordingError(format!("create {}: {e}", region_dir.display()))
})?;
for frame in ®ion.frames {
let path =
region_dir.join(format!("{:05}.{}", frame.index, opts.format.extension()));
fs::write(&path, &frame.data).map_err(|e| {
VoidCrawlError::RecordingError(format!("write {}: {e}", path.display()))
})?;
}
}
for encoding in &opts.encode {
let path = dir.join(format!("{}.{}", region.label, encoding.extension()));
encode_region(region, *encoding, &path, opts).await?;
region.outputs.push(path);
}
}
Ok(())
}
#[cfg_attr(
not(any(feature = "encode-gif", feature = "encode-ffmpeg")),
expect(
unused_variables,
clippy::unused_async,
reason = "every encoder branch is feature-gated off in this build"
)
)]
async fn encode_region(
region: &RecordedRegion,
encoding: Encoding,
path: &Path,
opts: &RecordingOptions,
) -> Result<()> {
match encoding {
Encoding::Gif => {
#[cfg(feature = "encode-gif")]
{
let frames = region.frames.clone();
let format = opts.format;
let path = path.to_path_buf();
spawn_blocking(move || encoders::gif(&frames, format, &path))
.await
.map_err(|e| VoidCrawlError::RecordingEncodeError(format!("gif task: {e}")))?
}
#[cfg(not(feature = "encode-gif"))]
Err(VoidCrawlError::RecordingEncodeError(
"GIF encoding requires the `encode-gif` cargo feature; the frames are still \
available on the returned Recording"
.into(),
))
}
Encoding::Mp4 | Encoding::WebM => {
#[cfg(feature = "encode-ffmpeg")]
{
encoders::ffmpeg(region, encoding, path, opts).await
}
#[cfg(not(feature = "encode-ffmpeg"))]
Err(VoidCrawlError::RecordingEncodeError(format!(
"{} encoding requires the `encode-ffmpeg` cargo feature and an ffmpeg binary on \
PATH; the frames are still available on the returned Recording",
encoding.extension()
)))
}
}
}
#[cfg(any(feature = "encode-gif", feature = "encode-ffmpeg"))]
mod encoders;
#[cfg(test)]
#[allow(clippy::unwrap_used, reason = "test module")]
mod tests {
use super::*;
use crate::selector::SelectorKind;
fn bbox(x: u32, y: u32, width: u32, height: u32) -> Bbox {
Bbox { x, y, width, height }
}
fn canvas(w: u32, h: u32) -> RgbaImage {
RgbaImage::from_pixel(w, h, Rgba([255, 255, 255, 255]))
}
fn is_black(img: &RgbaImage, x: u32, y: u32) -> bool {
img.get_pixel(x, y).0 == [0, 0, 0, 255]
}
#[test]
fn masks_only_the_named_rectangle() {
let mut img = canvas(100, 100);
mask_image(&mut img, &[bbox(10, 10, 20, 20)], 1.0, 0);
assert!(is_black(&img, 10, 10), "top-left corner of the mask");
assert!(is_black(&img, 29, 29), "bottom-right corner of the mask");
assert!(!is_black(&img, 9, 10), "one pixel left of the mask");
assert!(!is_black(&img, 30, 30), "one pixel past the mask");
assert!(!is_black(&img, 99, 99), "far corner");
}
#[test]
fn pad_grows_the_mask_outward_in_css_pixels() {
let mut img = canvas(100, 100);
mask_image(&mut img, &[bbox(10, 10, 20, 20)], 1.0, 2);
assert!(is_black(&img, 8, 8), "padded out by 2");
assert!(is_black(&img, 31, 31), "padded out by 2 on the far side");
assert!(!is_black(&img, 7, 8));
assert!(!is_black(&img, 32, 32));
}
#[test]
fn scales_css_pixels_into_frame_pixels() {
let mut img = canvas(200, 200);
mask_image(&mut img, &[bbox(10, 10, 20, 20)], 2.0, 0);
assert!(is_black(&img, 20, 20));
assert!(is_black(&img, 59, 59));
assert!(!is_black(&img, 19, 20));
assert!(!is_black(&img, 60, 60));
}
#[test]
fn clamps_a_mask_running_off_the_frame() {
let mut img = canvas(50, 50);
mask_image(&mut img, &[bbox(40, 40, 100, 100)], 1.0, 0);
assert!(is_black(&img, 49, 49), "clamped to the frame edge, not skipped");
assert!(!is_black(&img, 39, 39));
}
#[test]
fn a_mask_entirely_outside_the_frame_is_skipped_not_panicked() {
let mut img = canvas(50, 50);
mask_image(&mut img, &[bbox(80, 80, 10, 10)], 1.0, 0);
assert!(!is_black(&img, 49, 49));
}
#[test]
fn pad_at_the_origin_saturates_instead_of_wrapping() {
let mut img = canvas(50, 50);
mask_image(&mut img, &[bbox(1, 1, 5, 5)], 1.0, 4);
assert!(is_black(&img, 0, 0), "clipped at 0 rather than wrapping to u32::MAX");
assert!(is_black(&img, 9, 9));
}
#[test]
fn several_masks_all_apply() {
let mut img = canvas(100, 100);
mask_image(&mut img, &[bbox(0, 0, 10, 10), bbox(50, 50, 10, 10)], 1.0, 0);
assert!(is_black(&img, 5, 5));
assert!(is_black(&img, 55, 55));
assert!(!is_black(&img, 30, 30));
}
#[test]
fn union_covers_both_positions() {
let joined = union(bbox(10, 40, 20, 20), bbox(10, 10, 20, 20));
assert_eq!(joined, bbox(10, 10, 20, 50));
}
#[test]
fn union_of_disjoint_rectangles_is_the_bounding_box() {
assert_eq!(union(bbox(0, 0, 10, 10), bbox(90, 90, 10, 10)), bbox(0, 0, 100, 100));
}
#[test]
fn union_of_large_user_rectangles_does_not_overflow() {
assert_eq!(
union(bbox(u32::MAX, 0, u32::MAX, 1), bbox(0, 0, 1, 1)),
bbox(0, 0, u32::MAX, 1)
);
}
fn entry(bbox: Bbox, tracked: bool) -> MaskEntry {
MaskEntry {
label: "m".into(),
initial: bbox,
current: bbox,
previous: bbox,
tracked,
unresolved_ticks: 0,
stale_frames: 0,
stale: false,
}
}
#[test]
fn stamping_uses_the_union_of_the_last_two_ticks() {
let mut state = MaskState::new(vec![entry(bbox(10, 10, 20, 20), true)]);
state.record_resolved(0, bbox(10, 40, 20, 20));
assert_eq!(state.stamp(), vec![bbox(10, 10, 20, 50)]);
}
#[test]
fn an_unresolved_tick_keeps_covering_and_is_counted() {
let mut state = MaskState::new(vec![entry(bbox(10, 10, 20, 20), true)]);
state.record_unresolved(0);
let stamped = state.stamp();
assert_eq!(stamped, vec![bbox(10, 10, 20, 20)], "still covered after the element vanished");
let report = &state.reports()[0];
assert_eq!(report.unresolved_ticks, 1);
assert_eq!(report.stale_frames, 1, "the frame is flagged, not silently trusted");
}
#[test]
fn recovering_from_a_stale_tick_stops_counting_stale_frames() {
let mut state = MaskState::new(vec![entry(bbox(10, 10, 20, 20), true)]);
state.record_unresolved(0);
state.stamp();
state.record_resolved(0, bbox(10, 10, 20, 20));
state.stamp();
let report = &state.reports()[0];
assert_eq!(report.unresolved_ticks, 1);
assert_eq!(report.stale_frames, 1);
}
#[test]
fn the_report_keeps_the_rectangle_as_first_resolved() {
let mut state = MaskState::new(vec![entry(bbox(1, 2, 3, 4), true)]);
state.record_resolved(0, bbox(90, 90, 5, 5));
assert_eq!(state.reports()[0].bbox, bbox(1, 2, 3, 4));
}
#[test]
fn a_fixed_mask_is_reported_as_untracked() {
let spec = MaskSpec::bbox(bbox(0, 0, 5, 5));
assert!(!spec.is_tracked());
}
#[test]
fn a_selector_mask_tracks_by_default_and_can_be_pinned() {
let entry = SelectorEntry {
kind: SelectorKind::Css,
value: "#password".into(),
regex: None,
name: None,
nth: None,
x: None,
y: None,
};
assert!(MaskSpec::selector(entry.clone()).is_tracked());
assert!(!MaskSpec::selector(entry).with_track(false).is_tracked());
}
#[test]
fn masking_is_a_no_op_when_a_frame_carries_no_rectangles() {
let mut frames = vec![RawFrame {
offset: Duration::ZERO,
data: b"not a decodable image".to_vec(),
masks: Vec::new(),
device_width: 100.0,
}];
assert!(mask_raw_frames(&mut frames, 2, FrameFormat::Png, 80).is_ok());
}
#[test]
fn an_undecodable_frame_fails_the_recording_rather_than_passing_through() {
let mut frames = vec![RawFrame {
offset: Duration::ZERO,
data: b"not a decodable image".to_vec(),
masks: vec![bbox(0, 0, 10, 10)],
device_width: 100.0,
}];
let err = mask_raw_frames(&mut frames, 0, FrameFormat::Png, 80).unwrap_err();
assert!(matches!(err, VoidCrawlError::RecordingError(_)));
assert_eq!(frames[0].data, b"not a decodable image", "left untouched, not emitted masked");
}
}