1use std::io::Read;
35use std::path::PathBuf;
36use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
37use std::time::Duration;
38
39use anyhow::{Context, Result, anyhow, bail};
40use base64::Engine as _;
41use chromiumoxide::browser::{Browser, BrowserConfig};
42use chromiumoxide::cdp::browser_protocol::dom::Rgba;
43use chromiumoxide::cdp::browser_protocol::emulation::{
44 EventVirtualTimeBudgetExpired, SetDefaultBackgroundColorOverrideParams,
45 SetDeviceMetricsOverrideParams, SetVirtualTimePolicyParams, VirtualTimePolicy,
46};
47use chromiumoxide::cdp::browser_protocol::headless_experimental::{
48 BeginFrameParams, ScreenshotParams, ScreenshotParamsFormat,
49};
50use chromiumoxide::cdp::browser_protocol::page::{
51 CaptureScreenshotFormat, CaptureScreenshotParams,
52};
53use chromiumoxide::cdp::browser_protocol::target::CreateTargetParams;
54use chromiumoxide::page::Page;
55use futures::StreamExt;
56
57pub mod sink;
58
59const WARMUP_STEP_MS: f64 = 0.001;
62
63const LOAD_TIMEOUT: Duration = Duration::from_secs(60);
65
66const FRAME_CLOCK_SHIM: &str = r#"(() => {
77 const manualRaf = __MANUAL_RAF__;
78 let now = 0;
79 const started = new WeakMap();
80 performance.now = () => now;
81
82 let rafQueue = new Map(), rafNextId = 1;
83 if (manualRaf) {
84 window.requestAnimationFrame = (cb) => { const id = rafNextId++; rafQueue.set(id, cb); return id; };
85 window.cancelAnimationFrame = (id) => { rafQueue.delete(id); };
86 } else {
87 const raf = window.requestAnimationFrame.bind(window);
88 window.requestAnimationFrame = (cb) => raf(() => cb(now));
89 }
90
91 // Date / Date.now() follow the same frame clock from a fixed epoch.
92 const RealDate = Date, EPOCH = 1700000000000;
93 function FrameDate(...args) {
94 const d = args.length ? new RealDate(...args) : new RealDate(EPOCH + Math.floor(now));
95 return new.target ? d : d.toString();
96 }
97 FrameDate.prototype = RealDate.prototype;
98 FrameDate.now = () => EPOCH + Math.floor(now);
99 FrameDate.parse = RealDate.parse;
100 FrameDate.UTC = RealDate.UTC;
101 window.Date = FrameDate;
102 Object.defineProperty(window, '__aether', { value: Object.freeze({
103 step(t) {
104 now = t;
105 if (manualRaf) {
106 // Screenshot mode waits for a new compositor frame, and Chrome draws
107 // none when nothing on the page changed (the request then times out).
108 // Change something invisible: black at 0.1% opacity rounds to nothing
109 // in 8-bit pixels, so the frames stay identical to begin-frame mode.
110 let tick = document.getElementById("__aether_tick");
111 if (!tick) {
112 tick = document.createElement("div");
113 tick.id = "__aether_tick";
114 tick.style.cssText = "position:fixed;right:0;bottom:0;width:1px;height:1px;" +
115 "pointer-events:none;background:#000;z-index:2147483647";
116 document.documentElement.appendChild(tick);
117 }
118 tick.style.opacity = tick.style.opacity === "0.001" ? "0.0011" : "0.001";
119 }
120 if (manualRaf) {
121 const due = rafQueue;
122 rafQueue = new Map();
123 for (const cb of due.values()) {
124 try { cb(t); } catch (e) { console.error(e); }
125 }
126 }
127 for (const a of document.getAnimations()) {
128 if (!started.has(a)) started.set(a, t);
129 a.pause();
130 a.currentTime = t - started.get(a);
131 }
132 },
133 }) });
134})();"#;
135
136#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
138pub enum Mode {
139 Auto,
141 BeginFrame,
143 Screenshot,
145}
146
147impl Mode {
148 pub fn resolve(self) -> Self {
152 match self {
153 Mode::Auto if cfg!(target_os = "linux") => Mode::BeginFrame,
154 Mode::Auto => Mode::Screenshot,
155 m => m,
156 }
157 }
158}
159
160#[derive(Clone, Debug)]
162pub struct CaptureOptions {
163 pub input: String,
165 pub chrome: PathBuf,
167 pub width: u32,
169 pub height: u32,
171 pub fps: u32,
173 pub duration_secs: f64,
175 pub mode: Mode,
177 pub no_sandbox: bool,
180}
181
182impl CaptureOptions {
183 pub fn new(input: impl Into<String>, chrome: impl Into<PathBuf>) -> Self {
185 Self {
186 input: input.into(),
187 chrome: chrome.into(),
188 width: 512,
189 height: 512,
190 fps: 30,
191 duration_secs: 2.0,
192 mode: Mode::Auto,
193 no_sandbox: false,
194 }
195 }
196
197 pub fn size(mut self, width: u32, height: u32) -> Self {
198 self.width = width;
199 self.height = height;
200 self
201 }
202
203 pub fn fps(mut self, fps: u32) -> Self {
204 self.fps = fps;
205 self
206 }
207
208 pub fn duration_secs(mut self, secs: f64) -> Self {
209 self.duration_secs = secs;
210 self
211 }
212
213 pub fn mode(mut self, mode: Mode) -> Self {
214 self.mode = mode;
215 self
216 }
217
218 pub fn no_sandbox(mut self, no_sandbox: bool) -> Self {
219 self.no_sandbox = no_sandbox;
220 self
221 }
222
223 pub fn frame_count(&self) -> u64 {
225 (self.duration_secs * self.fps as f64).round() as u64
226 }
227
228 pub fn validate(&self) -> Result<()> {
230 if self.width == 0 || self.height == 0 {
231 bail!("size must be at least 1x1");
232 }
233 if self.fps == 0 {
234 bail!("fps must be greater than 0");
235 }
236 if !self.duration_secs.is_finite() || self.frame_count() == 0 {
237 bail!("duration must give at least one frame at {} fps", self.fps);
238 }
239 if !self.chrome.is_file() {
240 bail!(
241 "chrome-headless-shell not found at {} (install it with \
242 `npx @puppeteer/browsers install chrome-headless-shell@stable`)",
243 self.chrome.display()
244 );
245 }
246 to_url(&self.input)?;
247 Ok(())
248 }
249}
250
251#[derive(Clone, Debug)]
253pub struct CaptureReport {
254 pub frames: u64,
255 pub reused: u64,
257 pub mode: Mode,
259 pub width: u32,
260 pub height: u32,
261 pub fps: u32,
262}
263
264pub async fn capture<F>(opts: &CaptureOptions, on_frame: F) -> Result<CaptureReport>
269where
270 F: FnMut(u64, Vec<u8>) -> Result<()>,
271{
272 opts.validate()?;
273 let mode = opts.mode.resolve();
274 let begin_frame = mode == Mode::BeginFrame;
275 let (width, height) = (opts.width, opts.height);
276
277 let mut builder = BrowserConfig::builder().chrome_executable(&opts.chrome);
279 if opts.no_sandbox || running_as_root() {
282 builder = builder.no_sandbox();
283 }
284 if begin_frame {
285 builder = builder
286 .arg("deterministic-mode")
287 .arg("enable-begin-frame-control")
288 .arg("run-all-compositor-stages-before-draw");
289 }
290 let config = builder
291 .window_size(width, height)
292 .arg("disable-threaded-animation")
293 .arg("disable-threaded-scrolling")
294 .arg("disable-checker-imaging")
295 .arg("disable-new-content-rendering-timeout")
296 .arg("disable-gpu")
297 .arg("hide-scrollbars")
298 .arg("force-color-profile=srgb")
299 .arg("font-render-hinting=none")
300 .build()
301 .map_err(|e| anyhow!(e))?;
302
303 let (mut browser, mut handler) = match Browser::launch(config).await {
304 Ok(v) => v,
305 Err(e) if e.to_string().contains("No usable sandbox") => bail!(
306 "Chrome's sandbox can't start on this system (Ubuntu 23.10+ restricts the user \
307 namespaces it needs).\n\
308 Either allow them: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0\n\
309 or run without the sandbox (trusted pages only): --no-sandbox / AETHER_NO_SANDBOX=1"
310 ),
311 Err(e) => return Err(anyhow::Error::new(e).context("launching chrome")),
312 };
313 let handler_task = tokio::spawn(async move { while handler.next().await.is_some() {} });
314
315 let result = run(&browser, opts, mode, on_frame).await;
316
317 browser.close().await.ok();
318 browser.wait().await.ok();
319 handler_task.abort();
320 result
321}
322
323async fn run<F>(
325 browser: &Browser,
326 opts: &CaptureOptions,
327 mode: Mode,
328 mut on_frame: F,
329) -> Result<CaptureReport>
330where
331 F: FnMut(u64, Vec<u8>) -> Result<()>,
332{
333 let begin_frame = mode == Mode::BeginFrame;
334 let (width, height) = (opts.width, opts.height);
335 let url = to_url(&opts.input)?;
336 let interval_ms = 1000.0 / opts.fps as f64;
337 let total_frames = opts.frame_count();
338
339 let target = CreateTargetParams::builder()
341 .url("about:blank")
342 .enable_begin_frame_control(begin_frame)
343 .width(width as i64)
344 .height(height as i64)
345 .build()
346 .map_err(|e| anyhow!(e))?;
347 let page = browser.new_page(target).await.context("creating page")?;
348
349 page.execute(
350 SetDeviceMetricsOverrideParams::builder()
351 .width(width as i64)
352 .height(height as i64)
353 .device_scale_factor(1.0)
354 .mobile(false)
355 .build()
356 .map_err(|e| anyhow!(e))?,
357 )
358 .await?;
359
360 page.execute(
362 SetDefaultBackgroundColorOverrideParams::builder()
363 .color(Rgba {
364 r: 0,
365 g: 0,
366 b: 0,
367 a: Some(0.0),
368 })
369 .build(),
370 )
371 .await?;
372
373 page.execute(
377 SetVirtualTimePolicyParams::builder()
378 .policy(VirtualTimePolicy::Pause)
379 .initial_virtual_time(
380 chromiumoxide::cdp::browser_protocol::network::TimeSinceEpoch::new(1_700_000_000.0),
381 )
382 .build()
383 .map_err(|e| anyhow!(e))?,
384 )
385 .await?;
386
387 let shim =
389 FRAME_CLOCK_SHIM.replace("__MANUAL_RAF__", if begin_frame { "false" } else { "true" });
390 page.evaluate_on_new_document(shim).await?;
391
392 let mut budget_events = page
398 .event_listener::<EventVirtualTimeBudgetExpired>()
399 .await?;
400 let nav = {
401 let page = page.clone();
402 let url = url.clone();
403 tokio::spawn(async move { page.goto(url).await.map(|_| ()) })
404 };
405 let mut ready = false;
406 let mut warmup_steps = 0u32;
407 let deadline = std::time::Instant::now() + LOAD_TIMEOUT;
408 while std::time::Instant::now() < deadline {
409 if nav.is_finished() {
410 ready = page
411 .evaluate(
412 "document.readyState === 'complete' && document.fonts.status === 'loaded'",
413 )
414 .await
415 .ok()
416 .and_then(|v| v.into_value::<bool>().ok())
417 .unwrap_or(false);
418 if ready {
419 break;
420 }
421 }
422 warmup_steps += 1;
423 advance_virtual_time(
424 &page,
425 &mut budget_events,
426 VirtualTimePolicy::PauseIfNetworkFetchesPending,
427 WARMUP_STEP_MS,
428 )
429 .await?;
430 }
431 if !ready {
432 if nav.is_finished() {
434 nav.await?.with_context(|| format!("loading {url}"))?;
435 }
436 bail!(
437 "page did not finish loading (readyState/fonts) within {}s: {url}",
438 LOAD_TIMEOUT.as_secs()
439 );
440 }
441 nav.await?.with_context(|| format!("loading {url}"))?;
442 if std::env::var("AETHER_DEBUG").is_ok() {
443 eprintln!("page ready after {warmup_steps} warm-up steps");
444 }
445 let base_ticks = monotonic_ms();
450
451 let mut last_png: Option<Vec<u8>> = None;
453 let mut reused = 0u64;
454
455 for i in 0..total_frames {
456 if i > 0 {
458 advance_virtual_time(
459 &page,
460 &mut budget_events,
461 VirtualTimePolicy::Advance,
462 interval_ms,
463 )
464 .await?;
465 }
466
467 let frame_ms = i as f64 * interval_ms;
469 page.evaluate(format!(
470 "window.__aether && window.__aether.step({frame_ms})"
471 ))
472 .await
473 .context("stepping page clock")?;
474
475 let shot: Option<String> = if begin_frame {
477 let ticks = base_ticks + i as f64 * interval_ms;
478 page.execute(
479 BeginFrameParams::builder()
480 .frame_time_ticks(ticks)
481 .interval(interval_ms)
482 .screenshot(
483 ScreenshotParams::builder()
484 .format(ScreenshotParamsFormat::Png)
485 .build(),
486 )
487 .build(),
488 )
489 .await?
490 .result
491 .screenshot_data
492 .map(|b| AsRef::<str>::as_ref(&b).to_string())
493 } else {
494 let res = page
495 .execute(
496 CaptureScreenshotParams::builder()
497 .format(CaptureScreenshotFormat::Png)
498 .from_surface(true)
499 .build(),
500 )
501 .await?;
502 Some(AsRef::<str>::as_ref(&res.result.data).to_string())
503 };
504
505 let png = match shot {
506 Some(b64) => {
507 let bytes = base64::engine::general_purpose::STANDARD.decode(b64)?;
508 last_png = Some(bytes.clone());
509 bytes
510 }
511 None => {
514 reused += 1;
515 last_png.clone().ok_or_else(|| {
516 anyhow!(
517 "Chrome returned no image for the first frame in begin-frame \
518 mode (known on Windows). Use --mode screenshot."
519 )
520 })?
521 }
522 };
523
524 on_frame(i, png)?;
525 }
526
527 Ok(CaptureReport {
528 frames: total_frames,
529 reused,
530 mode,
531 width,
532 height,
533 fps: opts.fps,
534 })
535}
536
537const STREAM_BUFFER_FRAMES: usize = 4;
540
541pub fn png_stream(opts: CaptureOptions) -> Result<(PngStream, CaptureHandle)> {
547 opts.validate()?;
548 let (tx, rx) = sync_channel::<std::result::Result<Vec<u8>, String>>(STREAM_BUFFER_FRAMES);
549 let thread = std::thread::Builder::new()
550 .name("aether-capture".into())
551 .spawn(move || run_stream(opts, tx))
552 .context("starting the capture thread")?;
553 Ok((
554 PngStream {
555 rx,
556 frame: Vec::new(),
557 pos: 0,
558 received: 0,
559 on_frame: None,
560 },
561 CaptureHandle { thread },
562 ))
563}
564
565fn run_stream(
566 opts: CaptureOptions,
567 tx: SyncSender<std::result::Result<Vec<u8>, String>>,
568) -> Result<CaptureReport> {
569 let runtime = tokio::runtime::Builder::new_multi_thread()
570 .worker_threads(2)
571 .enable_all()
572 .build()
573 .context("starting the async runtime")?;
574 let result = runtime.block_on(capture(&opts, |_, png| {
575 tx.send(Ok(png))
576 .map_err(|_| anyhow!("the PNG stream was closed before the capture finished"))
577 }));
578 if let Err(e) = &result {
579 let _ = tx.send(Err(format!("{e:#}")));
581 }
582 result
583}
584
585pub struct PngStream {
588 rx: Receiver<std::result::Result<Vec<u8>, String>>,
589 frame: Vec<u8>,
590 pos: usize,
591 received: u64,
592 on_frame: Option<Box<dyn FnMut(u64) + Send>>,
593}
594
595impl PngStream {
596 pub fn on_frame(mut self, f: impl FnMut(u64) + Send + 'static) -> Self {
600 self.on_frame = Some(Box::new(f));
601 self
602 }
603}
604
605impl Read for PngStream {
606 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
607 while self.pos == self.frame.len() {
608 match self.rx.recv() {
609 Ok(Ok(frame)) => {
610 self.frame = frame;
611 self.pos = 0;
612 self.received += 1;
613 if let Some(f) = &mut self.on_frame {
614 f(self.received);
615 }
616 }
617 Ok(Err(e)) => return Err(std::io::Error::other(e)),
618 Err(_) => return Ok(0),
620 }
621 }
622 let n = buf.len().min(self.frame.len() - self.pos);
623 buf[..n].copy_from_slice(&self.frame[self.pos..self.pos + n]);
624 self.pos += n;
625 Ok(n)
626 }
627}
628
629pub struct CaptureHandle {
631 thread: std::thread::JoinHandle<Result<CaptureReport>>,
632}
633
634impl CaptureHandle {
635 pub fn wait(self) -> Result<CaptureReport> {
637 self.thread
638 .join()
639 .map_err(|_| anyhow!("the capture thread panicked"))?
640 }
641}
642
643pub(crate) fn to_url(input: &str) -> Result<String> {
644 if input.contains("://") {
645 return Ok(input.to_string());
646 }
647 let path = std::path::Path::new(input);
648 if !path.is_file() {
649 bail!("cannot find {input}");
650 }
651 let abs = std::path::absolute(path)?;
655 let url = url::Url::from_file_path(&abs)
656 .map_err(|_| anyhow!("cannot turn {} into a file:// URL", abs.display()))?;
657 Ok(url.into())
658}
659
660#[cfg(unix)]
661fn running_as_root() -> bool {
662 unsafe { libc::geteuid() == 0 }
664}
665
666#[cfg(not(unix))]
667fn running_as_root() -> bool {
668 false
669}
670
671#[cfg(unix)]
674fn monotonic_ms() -> f64 {
675 #[cfg(target_os = "macos")]
676 const CLOCK: libc::clockid_t = libc::CLOCK_UPTIME_RAW;
677 #[cfg(not(target_os = "macos"))]
678 const CLOCK: libc::clockid_t = libc::CLOCK_MONOTONIC;
679 let mut ts = libc::timespec {
680 tv_sec: 0,
681 tv_nsec: 0,
682 };
683 unsafe { libc::clock_gettime(CLOCK, &mut ts) };
685 ts.tv_sec as f64 * 1000.0 + ts.tv_nsec as f64 / 1_000_000.0
686}
687
688#[cfg(windows)]
689fn monotonic_ms() -> f64 {
690 use windows_sys::Win32::System::Performance::{
691 QueryPerformanceCounter, QueryPerformanceFrequency,
692 };
693 let (mut count, mut freq) = (0i64, 0i64);
694 unsafe {
697 QueryPerformanceCounter(&mut count);
698 QueryPerformanceFrequency(&mut freq);
699 }
700 count as f64 * 1000.0 / freq as f64
701}
702
703async fn advance_virtual_time(
707 page: &Page,
708 events: &mut chromiumoxide::listeners::EventStream<EventVirtualTimeBudgetExpired>,
709 policy: VirtualTimePolicy,
710 budget_ms: f64,
711) -> Result<()> {
712 page.execute(
713 SetVirtualTimePolicyParams::builder()
714 .policy(policy)
715 .budget(budget_ms)
716 .build()
717 .map_err(|e| anyhow!(e))?,
718 )
719 .await?;
720 tokio::time::timeout(Duration::from_secs(30), events.next())
721 .await
722 .context("timed out waiting for virtualTimeBudgetExpired")?
723 .ok_or_else(|| anyhow!("event stream closed"))?;
724 Ok(())
725}