car-browser 0.52.1

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.
//!
//! Frame capture, the CDP ACK discipline, and viewport pinning all live in
//! [`crate::screencast`] — this module is one consumer of that pump: it
//! subscribes, writes each frame to disk, and produces an ffmpeg concat
//! manifest. See `screencast`'s module docs for the ack/change-driven-frame
//! properties this recorder relies on but no longer implements itself.
//!
//! 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::Arc;
use std::time::Instant;

use chromiumoxide::Page;
use tokio::sync::Mutex;

use crate::backend::BrowserError;
use crate::screencast::ScreencastPump;

/// 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 frame writer (and, when
/// this recording owns one, its pump) but does NOT write a manifest — call
/// [`RecordingHandle::stop`] for a usable recording.
pub struct RecordingHandle {
    dir: PathBuf,
    /// The pump this recording OWNS, when it created one ([`start`]).
    /// `None` when the frames come from a pump somebody else owns
    /// ([`start_with_frames`]) — a shared pump must outlive the recording,
    /// because stopping it would also cut off its other consumers (a live
    /// preview stream is the one that matters).
    pump: Option<ScreencastPump>,
    frames: Arc<Mutex<Vec<(PathBuf, f64)>>>,
    started: Instant,
    task: tokio::task::JoinHandle<()>,
}

impl RecordingHandle {
    /// Stop the screencast and write the ffmpeg concat manifest.
    pub async fn stop(self) -> Result<Recording, BrowserError> {
        // Stops CDP capture for every consumer of the pump this recording
        // owns. A recording built on somebody else's pump
        // ([`start_with_frames`]) owns none and stops nothing — dropping
        // its receiver is the whole teardown.
        if let Some(pump) = &self.pump {
            pump.stop().await;
        }
        // The disk-writing task drains the pump's channel, which a still
        // page may never close on its own — so don't await it, 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 {
            // Cloned, not moved: this type has a `Drop` impl, so nothing may
            // be moved out of it. Dropping at the end of this method aborts
            // an already-aborted task and stops an already-stopped pump —
            // both no-ops.
            dir: self.dir.clone(),
            manifest: manifest_path,
            frame_count: frames.len(),
            duration_seconds: elapsed,
        })
    }
}

impl Drop for RecordingHandle {
    /// Dropping a handle without [`RecordingHandle::stop`] must not leave the
    /// recording running.
    ///
    /// Dropping a tokio `JoinHandle` **detaches** its task, and this one owns
    /// the moved frame receiver — so it kept draining frames and writing
    /// `frame-NNNNNN.jpg` into the recording directory indefinitely, and
    /// because its receiver never dropped, the fan-out consumer above was
    /// never pruned either: "consumers empty" never tripped and the live
    /// screencast stayed armed for the life of the process. Unbounded disk
    /// growth plus permanently-armed capture, from a plain drop.
    ///
    /// The owned pump (when there is one) needs nothing here — `ScreencastPump`
    /// has its own `Drop`. A borrowed one is deliberately left alone: it
    /// belongs to somebody else, and stopping it would cut off its other
    /// consumers.
    fn drop(&mut self) {
        self.task.abort();
    }
}

/// 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 pump =
        ScreencastPump::attach(page, quality, every_nth_frame, max_width, max_height).await?;
    // Disk recording is one consumer of the pump; subscribing here is what
    // actually starts CDP capture (zero consumers ⇒ zero frames pumped).
    let (incoming, started) = pump.subscribe().await?;

    let mut handle = start_with_frames(incoming, started, dir).await?;
    handle.pump = Some(pump);
    Ok(handle)
}

/// Record the frames of a pump somebody ELSE owns.
///
/// [`start`] is the self-contained form: it attaches its own pump to a page.
/// This form takes an already-running subscription instead, so disk recording
/// can be a second consumer alongside a live preview stream on the SAME CDP
/// screencast. That matters for two reasons a second pump on the same page
/// cannot satisfy: `Page.stopScreencast` is per-page, not per-pump (one pump
/// stopping would cut the other's frames), and the caller can drop frames on
/// their way in — which is how a privacy blackout keeps a window out of the
/// recorded artifact while the live viewer keeps seeing it.
///
/// `started` must be the instant the pump's capture began (what
/// `ScreencastPump::subscribe` returns) — frame durations in the manifest are
/// measured against it.
pub async fn start_with_frames(
    mut incoming: crate::screencast::FrameReceiver,
    started: Instant,
    dir: &Path,
) -> Result<RecordingHandle, BrowserError> {
    std::fs::create_dir_all(dir)
        .map_err(|e| BrowserError::ScreenshotFailed(format!("create frame dir: {e}")))?;

    let frames: Arc<Mutex<Vec<(PathBuf, f64)>>> = Arc::new(Mutex::new(Vec::new()));

    let task = {
        let frames = Arc::clone(&frames);
        let dir = dir.to_path_buf();
        tokio::spawn(async move {
            let mut n = 0usize;
            while let Some(frame) = incoming.recv().await {
                let path = dir.join(format!("frame-{n:06}.jpg"));
                if std::fs::write(&path, &frame.jpeg).is_ok() {
                    frames.lock().await.push((path, frame.captured_at));
                    n += 1;
                }
            }
        })
    };

    Ok(RecordingHandle {
        dir: dir.to_path_buf(),
        pump: None,
        frames,
        started,
        task,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    /// `recorder.rs` claimed "dropping it stops the pump" and it did not:
    /// dropping a tokio `JoinHandle` DETACHES the task, so the writer kept
    /// the moved frame receiver alive and kept writing frames to disk. That
    /// live receiver is also why the fan-out consumer above was never pruned,
    /// so the CDP screencast stayed armed for the process's life.
    ///
    /// Stated through the receiver, which is the thing that actually leaks:
    /// once the writer task is really gone, the channel is closed.
    #[tokio::test]
    async fn dropping_a_recording_handle_ends_its_frame_writer() {
        let dir = tempfile::tempdir().expect("temp dir");
        let (tx, rx) = tokio::sync::mpsc::channel(crate::screencast::FRAME_CHANNEL_CAP);
        let handle = start_with_frames(rx, Instant::now(), dir.path())
            .await
            .expect("writer starts");
        assert!(!tx.is_closed(), "the writer is draining frames");

        drop(handle);

        // `abort()` lands at the task's next scheduling point.
        for _ in 0..200 {
            if tx.is_closed() {
                break;
            }
            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
        }
        assert!(
            tx.is_closed(),
            "a dropped handle must not leave a detached task draining frames to disk"
        );
    }

    /// 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);
    }
}