Skip to main content

aether_renderer_capture/
lib.rs

1//! Capture HTML/CSS/JS animations as a deterministic, transparent PNG sequence.
2//!
3//! Renders a page frame by frame in chrome-headless-shell with virtual time and
4//! an injected frame clock, so every run gives byte-identical frames.
5//!
6//! Two capture modes:
7//! - `begin-frame` (Linux): Chrome only draws when we issue
8//!   `HeadlessExperimental.beginFrame`, which also returns the screenshot.
9//! - `screenshot` (macOS and Windows, where BeginFrame is unsupported or
10//!   unreliable): the frame clock shim runs rAF callbacks itself, then
11//!   `Page.captureScreenshot`.
12//!
13//! # As a library
14//!
15//! [`capture`] calls back with each frame (async). [`png_stream`] runs a capture
16//! on its own thread and returns a [`std::io::Read`] of PNG files back to back,
17//! which is exactly what `aether_renderer_core::render_stream` reads:
18//!
19//! ```no_run
20//! use aether_renderer_capture::{png_stream, CaptureOptions};
21//!
22//! let opts = CaptureOptions::new("anim.html", "/path/to/chrome-headless-shell")
23//!     .size(1080, 1080)
24//!     .fps(30)
25//!     .duration_secs(4.0);
26//! let frames = opts.frame_count();
27//! let (stream, capture) = png_stream(opts)?;
28//! // aether_renderer_core::render_stream(cfg, stream, Some(frames as u32), |p| { ... })?;
29//! # drop(stream);
30//! let report = capture.wait()?;
31//! # Ok::<(), anyhow::Error>(())
32//! ```
33
34use 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
59/// Virtual-time step while the page loads. Tiny, so the clock the page sees at
60/// frame 0 is effectively fixed no matter how many steps loading needs.
61const WARMUP_STEP_MS: f64 = 0.001;
62
63/// Real (wall-clock) time a page gets to load before we give up.
64const LOAD_TIMEOUT: Duration = Duration::from_secs(60);
65
66/// Injected before any page script runs. Chrome coarsens performance.now(),
67/// rAF timestamps and the animation timeline to 100µs *with random jitter*
68/// (Spectre mitigation), so even under virtual time two runs differ by a
69/// sub-pixel. This shim gives the page one exact clock per frame instead:
70///  - performance.now() and rAF timestamps = exact frame time (0 while loading)
71///  - CSS animations / transitions / WAAPI are paused and seeked to the exact
72///    frame time each frame, so the compositor never uses its own clock.
73///  - In screenshot mode (`__MANUAL_RAF__` = true) rAF callbacks are queued and
74///    run exactly once per frame by `step()`, since Chrome's own frame
75///    scheduling isn't under our control there.
76const 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/// How frames are drawn and grabbed.
137#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
138pub enum Mode {
139    /// begin-frame on Linux, screenshot on macOS and Windows
140    Auto,
141    /// HeadlessExperimental.beginFrame (Linux; unsupported on macOS)
142    BeginFrame,
143    /// Shim-driven rAF + Page.captureScreenshot (works everywhere)
144    Screenshot,
145}
146
147impl Mode {
148    /// The mode `Auto` stands for on this platform. BeginFrame is only
149    /// dependable on Linux: unsupported on macOS, and on Windows the first
150    /// frame comes back without a screenshot.
151    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/// What to capture and how.
161#[derive(Clone, Debug)]
162pub struct CaptureOptions {
163    /// HTML file, or an http(s)/file URL.
164    pub input: String,
165    /// Path to chrome-headless-shell.
166    pub chrome: PathBuf,
167    /// Viewport width in CSS pixels.
168    pub width: u32,
169    /// Viewport height in CSS pixels.
170    pub height: u32,
171    /// Frames per second.
172    pub fps: u32,
173    /// Length of the capture in seconds.
174    pub duration_secs: f64,
175    /// How frames are drawn and grabbed.
176    pub mode: Mode,
177    /// Run Chrome without its sandbox (Ubuntu 23.10+; trusted pages only).
178    /// Running as root always disables it, since Chrome refuses otherwise.
179    pub no_sandbox: bool,
180}
181
182impl CaptureOptions {
183    /// 512×512 at 30 fps for 2 seconds, `Mode::Auto`, sandbox on.
184    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    /// How many frames the capture produces: `duration × fps`, rounded.
224    pub fn frame_count(&self) -> u64 {
225        (self.duration_secs * self.fps as f64).round() as u64
226    }
227
228    /// Catch bad options before a browser is started.
229    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/// What a finished capture did.
252#[derive(Clone, Debug)]
253pub struct CaptureReport {
254    pub frames: u64,
255    /// Frames where Chrome drew nothing new, so the previous frame was reused.
256    pub reused: u64,
257    /// The mode that actually ran (`Auto` resolved).
258    pub mode: Mode,
259    pub width: u32,
260    pub height: u32,
261    pub fps: u32,
262}
263
264/// Capture `opts`, calling `on_frame(index, png)` for every frame in order.
265///
266/// If `on_frame` returns an error, the capture stops and returns it. Chrome is
267/// closed in every case.
268pub 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    // --- 1. Launch chrome-headless-shell with deterministic flags -----------
278    let mut builder = BrowserConfig::builder().chrome_executable(&opts.chrome);
279    // Chrome refuses to start sandboxed as root (typical in Docker/CI), so
280    // only drop the sandbox there or when asked.
281    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
323/// Everything after Chrome is up, so `capture` can always close it.
324async 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    // --- 2. Target (with BeginFrame control in begin-frame mode) -------------
340    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    // Transparent default background -> alpha in the PNGs
361    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    // Freeze virtual time before any page script runs. Virtual time drives
374    // setTimeout/setInterval (and network-idle waiting); everything that is
375    // drawn per frame is pinned to an exact frame clock by FRAME_CLOCK_SHIM.
376    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    // Give the page one exact, jitter-free clock (see FRAME_CLOCK_SHIM).
388    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    // --- 3. Load the page ----------------------------------------------------
393    // While virtual time is fully paused, the `load` task never runs. So during
394    // loading we let virtual time move only when no network fetch is pending,
395    // in tiny steps, and without issuing any BeginFrames (so rAF and CSS
396    // animations haven't started yet when frame 0 is drawn).
397    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        // Report why navigation failed (bad URL, net error) when we know.
433        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    // frameTimeTicks is in Chrome's TimeTicks domain (ms of host monotonic
446    // uptime). Values below "now" are ignored by the renderer, which freezes
447    // rAF and CSS animations. So anchor frame 0 at the current monotonic clock
448    // and step exactly one interval per frame from there.
449    let base_ticks = monotonic_ms();
450
451    // --- 4. Capture loop -----------------------------------------------------
452    let mut last_png: Option<Vec<u8>> = None;
453    let mut reused = 0u64;
454
455    for i in 0..total_frames {
456        // 4a. Advance virtual time by exactly one frame interval.
457        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        // 4b. Pin the page clock + all CSS/WAAPI animations to this frame.
468        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        // 4c. Draw exactly one frame and grab it.
476        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            // No damage => Chrome sends no image. Reuse the previous frame so
512            // the sequence never has holes.
513            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
537/// Frames buffered between the capture thread and the reader. Small, so a slow
538/// encoder slows the capture down instead of piling frames up in memory.
539const STREAM_BUFFER_FRAMES: usize = 4;
540
541/// Run a capture on its own thread and read it as a PNG stream (PNG files back
542/// to back), e.g. for `aether_renderer_core::render_stream`.
543///
544/// Options are checked before anything starts. If the capture fails, the next
545/// read returns that error. Dropping the stream stops the capture.
546pub 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        // Hand the reason to the reader (ignored if it has gone away).
580        let _ = tx.send(Err(format!("{e:#}")));
581    }
582    result
583}
584
585/// PNG files back to back, produced by a capture running on another thread.
586/// See [`png_stream`].
587pub 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    /// Call `f(n)` as frame `n` (1-based) of the capture reaches the reader,
597    /// on the thread that reads the stream. Meant for progress bars: the
598    /// capture is usually the slow part, and encoders report frames late.
599    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                // The capture thread is done and every frame has been read.
619                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
629/// The capture thread behind a [`PngStream`].
630pub struct CaptureHandle {
631    thread: std::thread::JoinHandle<Result<CaptureReport>>,
632}
633
634impl CaptureHandle {
635    /// Wait for the capture to finish and return its report or error.
636    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    // `path::absolute` (not `canonicalize`): on Windows canonicalize returns a
652    // verbatim `\\?\C:\...` path, which is not a valid file:// URL. `Url`
653    // does the per-platform escaping (drive letters, backslashes, spaces).
654    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    // SAFETY: geteuid has no preconditions and cannot fail.
663    unsafe { libc::geteuid() == 0 }
664}
665
666#[cfg(not(unix))]
667fn running_as_root() -> bool {
668    false
669}
670
671/// Host monotonic clock in ms — the same clock Chrome's TimeTicks use
672/// (CLOCK_MONOTONIC on Linux, mach_absolute_time on macOS, QPC on Windows).
673#[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    // SAFETY: clock_gettime writes into a valid, owned timespec.
684    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    // SAFETY: both calls write into valid, owned i64s and cannot fail on
695    // Windows XP or later.
696    unsafe {
697        QueryPerformanceCounter(&mut count);
698        QueryPerformanceFrequency(&mut freq);
699    }
700    count as f64 * 1000.0 / freq as f64
701}
702
703/// Let virtual time run for `budget_ms` and block until Chrome reports the
704/// budget as spent (Emulation.virtualTimeBudgetExpired). Virtual time then
705/// pauses again by itself.
706async 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}