car-browser 0.47.0

Browser automation and perception pipeline for Common Agent Runtime
//! Screen recording for a driven browser session, via CDP screencast.
//!
//! `capture_screenshot` answers "what does the page look like now". This
//! answers "what did using the app look like" — the thing a still cannot show:
//! an answer streaming in, a table populating, a menu opening. A product demo,
//! an onboarding clip, or a training video wants the second one, and a deck
//! full of screenshots is the compromise you make when you can't record.
//!
//! Chrome pushes `Page.screencastFrame` events (base64 JPEG + a timestamp) once
//! `Page.startScreencast` is issued. Two properties drive the design:
//!
//! - **Every frame MUST be acked.** Chrome keeps at most a small number of
//!   un-acked frames in flight; miss the ack and the stream simply stops,
//!   silently, mid-recording. So the ack is issued before anything else
//!   touches the frame.
//! - **Frames arrive only when the page CHANGES.** A screencast is not a
//!   fixed-rate capture — a still page emits nothing. Wall-clock timestamps
//!   are therefore recorded per frame and turned into per-frame durations at
//!   encode time; assuming a constant rate would compress every pause and
//!   desync the result from any narration laid over it.
//!
//! The recorder writes JPEGs plus an ffmpeg concat manifest and stops there:
//! encoding belongs to the caller, so this crate stays free of an ffmpeg
//! dependency and the caller picks its own codec/scale settings.

use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Instant;

use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
use chromiumoxide::cdp::browser_protocol::emulation::SetDeviceMetricsOverrideParams;
use chromiumoxide::cdp::browser_protocol::page::{
    EventScreencastFrame, ScreencastFrameAckParams, StartScreencastFormat, StartScreencastParams,
    StopScreencastParams,
};
use chromiumoxide::Page;
use futures::StreamExt;
use tokio::sync::Mutex;

use crate::backend::BrowserError;

/// A finished recording: frame files on disk plus the concat manifest that
/// carries their real durations.
#[derive(Debug, Clone)]
pub struct Recording {
    /// Directory holding the numbered JPEG frames and the manifest.
    pub dir: PathBuf,
    /// ffmpeg concat-demuxer manifest (`-f concat -safe 0 -i <this>`).
    pub manifest: PathBuf,
    /// Frames actually captured.
    pub frame_count: usize,
    /// Wall-clock span of the recording, in seconds.
    pub duration_seconds: f64,
}

/// An in-flight screencast. Dropping it stops the pump but does NOT write a
/// manifest — call [`RecordingHandle::stop`] for a usable recording.
pub struct RecordingHandle {
    dir: PathBuf,
    stop: Arc<AtomicBool>,
    frames: Arc<Mutex<Vec<(PathBuf, f64)>>>,
    started: Instant,
    page: Page,
    task: tokio::task::JoinHandle<()>,
}

impl RecordingHandle {
    /// Stop the screencast and write the ffmpeg concat manifest.
    pub async fn stop(self) -> Result<Recording, BrowserError> {
        self.stop.store(true, Ordering::SeqCst);
        let _ = self.page.execute(StopScreencastParams::default()).await;
        // The pump exits on the stop flag at its next frame — but a still page
        // may never send one, so don't await the task; just take what we have.
        self.task.abort();

        let frames = self.frames.lock().await.clone();
        let elapsed = self.started.elapsed().as_secs_f64();
        if frames.is_empty() {
            return Err(BrowserError::ScreenshotFailed(
                "screencast captured no frames — the page never changed, or the \
                 recording was stopped before Chrome emitted anything"
                    .to_string(),
            ));
        }

        // Per-frame durations from real arrival times. The last frame is held
        // for the remainder of the recording, so a final still state (the
        // finished answer on screen) doesn't vanish in a single frame.
        let mut manifest = String::new();
        for (i, (path, at)) in frames.iter().enumerate() {
            let next = frames.get(i + 1).map(|(_, t)| *t).unwrap_or(elapsed);
            let dur = (next - at).max(0.001);
            let name = path
                .file_name()
                .and_then(|s| s.to_str())
                .unwrap_or("frame.jpg");
            manifest.push_str(&format!("file '{name}'\nduration {dur:.4}\n"));
        }
        // The concat demuxer ignores the final entry's duration, so the last
        // file is repeated — the documented idiom for holding the tail frame.
        if let Some((path, _)) = frames.last() {
            if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
                manifest.push_str(&format!("file '{name}'\n"));
            }
        }

        let manifest_path = self.dir.join("frames.txt");
        std::fs::write(&manifest_path, manifest)
            .map_err(|e| BrowserError::ScreenshotFailed(format!("write concat manifest: {e}")))?;

        Ok(Recording {
            dir: self.dir,
            manifest: manifest_path,
            frame_count: frames.len(),
            duration_seconds: elapsed,
        })
    }
}

/// Begin recording `page` into `dir` (created if absent).
///
/// `every_nth_frame` throttles Chrome's emission at the source — cheaper than
/// capturing everything and dropping frames later.
pub async fn start(
    page: &Page,
    dir: &Path,
    quality: i64,
    every_nth_frame: i64,
    max_width: u32,
    max_height: u32,
) -> Result<RecordingHandle, BrowserError> {
    std::fs::create_dir_all(dir)
        .map_err(|e| BrowserError::ScreenshotFailed(format!("create frame dir: {e}")))?;

    let mut events = page
        .event_listener::<EventScreencastFrame>()
        .await
        .map_err(|e| BrowserError::ScreenshotFailed(format!("screencast listener: {e}")))?;

    // Force the PAGE VIEWPORT to the target size before capturing. A headed
    // Chromium's window size is not its viewport size (browser chrome, OS
    // decorations, DPI scaling all eat into it), so launching at 1920x1080
    // still rendered the page into a fraction of the frame with dead margins
    // around it. Overriding device metrics is what makes the capture
    // full-bleed and deterministic across machines — the same thing Playwright
    // does for its `viewport` option.
    page.execute(
        SetDeviceMetricsOverrideParams::builder()
            .width(max_width as i64)
            .height(max_height as i64)
            .device_scale_factor(1.0)
            .mobile(false)
            .build()
            .map_err(|e| BrowserError::ScreenshotFailed(format!("device metrics params: {e}")))?,
    )
    .await
    .map_err(|e| BrowserError::ScreenshotFailed(format!("setDeviceMetricsOverride: {e}")))?;

    // Pin the frame size to the viewport. Omitting max_width/max_height lets
    // Chrome choose, and it letterboxes the page into a differently-shaped
    // frame — a 1920x1080 viewport came back as 1600x1200 with dead margins
    // to the right and below, which is unusable as product footage.
    page.execute(
        StartScreencastParams::builder()
            .format(StartScreencastFormat::Jpeg)
            .quality(quality.clamp(1, 100))
            .every_nth_frame(every_nth_frame.max(1))
            .max_width(max_width as i64)
            .max_height(max_height as i64)
            .build(),
    )
    .await
    .map_err(|e| BrowserError::ScreenshotFailed(format!("startScreencast: {e}")))?;

    let stop = Arc::new(AtomicBool::new(false));
    let frames: Arc<Mutex<Vec<(PathBuf, f64)>>> = Arc::new(Mutex::new(Vec::new()));
    let started = Instant::now();

    let task = {
        let stop = Arc::clone(&stop);
        let frames = Arc::clone(&frames);
        let dir = dir.to_path_buf();
        let page = page.clone();
        tokio::spawn(async move {
            let mut n = 0usize;
            while let Some(frame) = events.next().await {
                if stop.load(Ordering::SeqCst) {
                    break;
                }
                // ACK FIRST — Chrome stalls the stream on an un-acked frame,
                // and it does so silently. Everything below is best-effort;
                // the ack is not.
                let _ = page
                    .execute(ScreencastFrameAckParams::new(frame.session_id))
                    .await;

                // `Binary` wraps the BASE64 TEXT — its `AsRef<[u8]>` hands back
                // the bytes of that text, not the image. Writing those straight
                // to a .jpg succeeds and yields an unopenable file, so decode
                // explicitly. (`page.screenshot()` decodes for you; the raw
                // event does not.)
                let Ok(bytes) = BASE64.decode(AsRef::<str>::as_ref(&frame.data)) else {
                    continue;
                };
                let path = dir.join(format!("frame-{n:06}.jpg"));
                if std::fs::write(&path, &bytes).is_ok() {
                    frames
                        .lock()
                        .await
                        .push((path, started.elapsed().as_secs_f64()));
                    n += 1;
                }
            }
        })
    };

    Ok(RecordingHandle {
        dir: dir.to_path_buf(),
        stop,
        frames,
        started,
        page: page.clone(),
        task,
    })
}

#[cfg(test)]
mod tests {

    /// The manifest must carry REAL per-frame durations, not a fixed rate —
    /// a screencast only emits on change, so a constant rate would compress
    /// every pause and desync narration laid over the result.
    #[test]
    fn manifest_shape_holds_the_tail_frame() {
        // Mirrors the manifest builder in `stop` for a 3-frame recording.
        let frames = [("frame-000000.jpg", 0.0), ("frame-000001.jpg", 1.5)];
        let elapsed = 4.0;
        let mut manifest = String::new();
        for (i, (name, at)) in frames.iter().enumerate() {
            let next = frames.get(i + 1).map(|(_, t)| *t).unwrap_or(elapsed);
            manifest.push_str(&format!("file '{name}'\nduration {:.4}\n", next - at));
        }
        manifest.push_str(&format!("file '{}'\n", frames.last().unwrap().0));

        // Second frame is held 2.5s (1.5 -> 4.0), not an assumed frame interval.
        assert!(manifest.contains("duration 1.5000"));
        assert!(manifest.contains("duration 2.5000"));
        // Tail file repeated so the final state is visible, per the concat idiom.
        assert_eq!(manifest.matches("frame-000001.jpg").count(), 2);
    }
}