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}