Skip to main content

car_browser/
recorder.rs

1//! Screen recording for a driven browser session, via CDP screencast.
2//!
3//! `capture_screenshot` answers "what does the page look like now". This
4//! answers "what did using the app look like" — the thing a still cannot show:
5//! an answer streaming in, a table populating, a menu opening. A product demo,
6//! an onboarding clip, or a training video wants the second one, and a deck
7//! full of screenshots is the compromise you make when you can't record.
8//!
9//! Frame capture, the CDP ACK discipline, and viewport pinning all live in
10//! [`crate::screencast`] — this module is one consumer of that pump: it
11//! subscribes, writes each frame to disk, and produces an ffmpeg concat
12//! manifest. See `screencast`'s module docs for the ack/change-driven-frame
13//! properties this recorder relies on but no longer implements itself.
14//!
15//! The recorder writes JPEGs plus an ffmpeg concat manifest and stops there:
16//! encoding belongs to the caller, so this crate stays free of an ffmpeg
17//! dependency and the caller picks its own codec/scale settings.
18
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21use std::time::Instant;
22
23use chromiumoxide::Page;
24use tokio::sync::Mutex;
25
26use crate::backend::BrowserError;
27use crate::screencast::ScreencastPump;
28
29/// A finished recording: frame files on disk plus the concat manifest that
30/// carries their real durations.
31#[derive(Debug, Clone)]
32pub struct Recording {
33    /// Directory holding the numbered JPEG frames and the manifest.
34    pub dir: PathBuf,
35    /// ffmpeg concat-demuxer manifest (`-f concat -safe 0 -i <this>`).
36    pub manifest: PathBuf,
37    /// Frames actually captured.
38    pub frame_count: usize,
39    /// Wall-clock span of the recording, in seconds.
40    pub duration_seconds: f64,
41}
42
43/// An in-flight screencast. Dropping it stops the frame writer (and, when
44/// this recording owns one, its pump) but does NOT write a manifest — call
45/// [`RecordingHandle::stop`] for a usable recording.
46pub struct RecordingHandle {
47    dir: PathBuf,
48    /// The pump this recording OWNS, when it created one ([`start`]).
49    /// `None` when the frames come from a pump somebody else owns
50    /// ([`start_with_frames`]) — a shared pump must outlive the recording,
51    /// because stopping it would also cut off its other consumers (a live
52    /// preview stream is the one that matters).
53    pump: Option<ScreencastPump>,
54    frames: Arc<Mutex<Vec<(PathBuf, f64)>>>,
55    started: Instant,
56    task: tokio::task::JoinHandle<()>,
57}
58
59impl RecordingHandle {
60    /// Stop the screencast and write the ffmpeg concat manifest.
61    pub async fn stop(self) -> Result<Recording, BrowserError> {
62        // Stops CDP capture for every consumer of the pump this recording
63        // owns. A recording built on somebody else's pump
64        // ([`start_with_frames`]) owns none and stops nothing — dropping
65        // its receiver is the whole teardown.
66        if let Some(pump) = &self.pump {
67            pump.stop().await;
68        }
69        // The disk-writing task drains the pump's channel, which a still
70        // page may never close on its own — so don't await it, just take
71        // what we have.
72        self.task.abort();
73
74        let frames = self.frames.lock().await.clone();
75        let elapsed = self.started.elapsed().as_secs_f64();
76        if frames.is_empty() {
77            return Err(BrowserError::ScreenshotFailed(
78                "screencast captured no frames — the page never changed, or the \
79                 recording was stopped before Chrome emitted anything"
80                    .to_string(),
81            ));
82        }
83
84        // Per-frame durations from real arrival times. The last frame is held
85        // for the remainder of the recording, so a final still state (the
86        // finished answer on screen) doesn't vanish in a single frame.
87        let mut manifest = String::new();
88        for (i, (path, at)) in frames.iter().enumerate() {
89            let next = frames.get(i + 1).map(|(_, t)| *t).unwrap_or(elapsed);
90            let dur = (next - at).max(0.001);
91            let name = path
92                .file_name()
93                .and_then(|s| s.to_str())
94                .unwrap_or("frame.jpg");
95            manifest.push_str(&format!("file '{name}'\nduration {dur:.4}\n"));
96        }
97        // The concat demuxer ignores the final entry's duration, so the last
98        // file is repeated — the documented idiom for holding the tail frame.
99        if let Some((path, _)) = frames.last() {
100            if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
101                manifest.push_str(&format!("file '{name}'\n"));
102            }
103        }
104
105        let manifest_path = self.dir.join("frames.txt");
106        std::fs::write(&manifest_path, manifest)
107            .map_err(|e| BrowserError::ScreenshotFailed(format!("write concat manifest: {e}")))?;
108
109        Ok(Recording {
110            // Cloned, not moved: this type has a `Drop` impl, so nothing may
111            // be moved out of it. Dropping at the end of this method aborts
112            // an already-aborted task and stops an already-stopped pump —
113            // both no-ops.
114            dir: self.dir.clone(),
115            manifest: manifest_path,
116            frame_count: frames.len(),
117            duration_seconds: elapsed,
118        })
119    }
120}
121
122impl Drop for RecordingHandle {
123    /// Dropping a handle without [`RecordingHandle::stop`] must not leave the
124    /// recording running.
125    ///
126    /// Dropping a tokio `JoinHandle` **detaches** its task, and this one owns
127    /// the moved frame receiver — so it kept draining frames and writing
128    /// `frame-NNNNNN.jpg` into the recording directory indefinitely, and
129    /// because its receiver never dropped, the fan-out consumer above was
130    /// never pruned either: "consumers empty" never tripped and the live
131    /// screencast stayed armed for the life of the process. Unbounded disk
132    /// growth plus permanently-armed capture, from a plain drop.
133    ///
134    /// The owned pump (when there is one) needs nothing here — `ScreencastPump`
135    /// has its own `Drop`. A borrowed one is deliberately left alone: it
136    /// belongs to somebody else, and stopping it would cut off its other
137    /// consumers.
138    fn drop(&mut self) {
139        self.task.abort();
140    }
141}
142
143/// Begin recording `page` into `dir` (created if absent).
144///
145/// `every_nth_frame` throttles Chrome's emission at the source — cheaper than
146/// capturing everything and dropping frames later.
147pub async fn start(
148    page: &Page,
149    dir: &Path,
150    quality: i64,
151    every_nth_frame: i64,
152    max_width: u32,
153    max_height: u32,
154) -> Result<RecordingHandle, BrowserError> {
155    std::fs::create_dir_all(dir)
156        .map_err(|e| BrowserError::ScreenshotFailed(format!("create frame dir: {e}")))?;
157
158    let pump =
159        ScreencastPump::attach(page, quality, every_nth_frame, max_width, max_height).await?;
160    // Disk recording is one consumer of the pump; subscribing here is what
161    // actually starts CDP capture (zero consumers ⇒ zero frames pumped).
162    let (incoming, started) = pump.subscribe().await?;
163
164    let mut handle = start_with_frames(incoming, started, dir).await?;
165    handle.pump = Some(pump);
166    Ok(handle)
167}
168
169/// Record the frames of a pump somebody ELSE owns.
170///
171/// [`start`] is the self-contained form: it attaches its own pump to a page.
172/// This form takes an already-running subscription instead, so disk recording
173/// can be a second consumer alongside a live preview stream on the SAME CDP
174/// screencast. That matters for two reasons a second pump on the same page
175/// cannot satisfy: `Page.stopScreencast` is per-page, not per-pump (one pump
176/// stopping would cut the other's frames), and the caller can drop frames on
177/// their way in — which is how a privacy blackout keeps a window out of the
178/// recorded artifact while the live viewer keeps seeing it.
179///
180/// `started` must be the instant the pump's capture began (what
181/// `ScreencastPump::subscribe` returns) — frame durations in the manifest are
182/// measured against it.
183pub async fn start_with_frames(
184    mut incoming: crate::screencast::FrameReceiver,
185    started: Instant,
186    dir: &Path,
187) -> Result<RecordingHandle, BrowserError> {
188    std::fs::create_dir_all(dir)
189        .map_err(|e| BrowserError::ScreenshotFailed(format!("create frame dir: {e}")))?;
190
191    let frames: Arc<Mutex<Vec<(PathBuf, f64)>>> = Arc::new(Mutex::new(Vec::new()));
192
193    let task = {
194        let frames = Arc::clone(&frames);
195        let dir = dir.to_path_buf();
196        tokio::spawn(async move {
197            let mut n = 0usize;
198            while let Some(frame) = incoming.recv().await {
199                let path = dir.join(format!("frame-{n:06}.jpg"));
200                if std::fs::write(&path, &frame.jpeg).is_ok() {
201                    frames.lock().await.push((path, frame.captured_at));
202                    n += 1;
203                }
204            }
205        })
206    };
207
208    Ok(RecordingHandle {
209        dir: dir.to_path_buf(),
210        pump: None,
211        frames,
212        started,
213        task,
214    })
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    /// `recorder.rs` claimed "dropping it stops the pump" and it did not:
222    /// dropping a tokio `JoinHandle` DETACHES the task, so the writer kept
223    /// the moved frame receiver alive and kept writing frames to disk. That
224    /// live receiver is also why the fan-out consumer above was never pruned,
225    /// so the CDP screencast stayed armed for the process's life.
226    ///
227    /// Stated through the receiver, which is the thing that actually leaks:
228    /// once the writer task is really gone, the channel is closed.
229    #[tokio::test]
230    async fn dropping_a_recording_handle_ends_its_frame_writer() {
231        let dir = tempfile::tempdir().expect("temp dir");
232        let (tx, rx) = tokio::sync::mpsc::channel(crate::screencast::FRAME_CHANNEL_CAP);
233        let handle = start_with_frames(rx, Instant::now(), dir.path())
234            .await
235            .expect("writer starts");
236        assert!(!tx.is_closed(), "the writer is draining frames");
237
238        drop(handle);
239
240        // `abort()` lands at the task's next scheduling point.
241        for _ in 0..200 {
242            if tx.is_closed() {
243                break;
244            }
245            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
246        }
247        assert!(
248            tx.is_closed(),
249            "a dropped handle must not leave a detached task draining frames to disk"
250        );
251    }
252
253    /// The manifest must carry REAL per-frame durations, not a fixed rate —
254    /// a screencast only emits on change, so a constant rate would compress
255    /// every pause and desync narration laid over the result.
256    #[test]
257    fn manifest_shape_holds_the_tail_frame() {
258        // Mirrors the manifest builder in `stop` for a 3-frame recording.
259        let frames = [("frame-000000.jpg", 0.0), ("frame-000001.jpg", 1.5)];
260        let elapsed = 4.0;
261        let mut manifest = String::new();
262        for (i, (name, at)) in frames.iter().enumerate() {
263            let next = frames.get(i + 1).map(|(_, t)| *t).unwrap_or(elapsed);
264            manifest.push_str(&format!("file '{name}'\nduration {:.4}\n", next - at));
265        }
266        manifest.push_str(&format!("file '{}'\n", frames.last().unwrap().0));
267
268        // Second frame is held 2.5s (1.5 -> 4.0), not an assumed frame interval.
269        assert!(manifest.contains("duration 1.5000"));
270        assert!(manifest.contains("duration 2.5000"));
271        // Tail file repeated so the final state is visible, per the concat idiom.
272        assert_eq!(manifest.matches("frame-000001.jpg").count(), 2);
273    }
274}