use std::io::Write as _;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use base64::Engine as _;
use chromiumoxide::browser::{Browser, BrowserConfig};
use chromiumoxide::cdp::browser_protocol::dom::Rgba;
use chromiumoxide::cdp::browser_protocol::emulation::{
EventVirtualTimeBudgetExpired, SetDefaultBackgroundColorOverrideParams,
SetDeviceMetricsOverrideParams, SetVirtualTimePolicyParams, VirtualTimePolicy,
};
use chromiumoxide::cdp::browser_protocol::headless_experimental::{
BeginFrameParams, ScreenshotParams, ScreenshotParamsFormat,
};
use chromiumoxide::cdp::browser_protocol::page::{
CaptureScreenshotFormat, CaptureScreenshotParams,
};
use chromiumoxide::cdp::browser_protocol::target::CreateTargetParams;
use chromiumoxide::page::Page;
use clap::{Parser, ValueEnum};
use futures::StreamExt;
const WARMUP_STEP_MS: f64 = 0.001;
const FRAME_CLOCK_SHIM: &str = r#"(() => {
const manualRaf = __MANUAL_RAF__;
let now = 0;
const started = new WeakMap();
performance.now = () => now;
let rafQueue = new Map(), rafNextId = 1;
if (manualRaf) {
window.requestAnimationFrame = (cb) => { const id = rafNextId++; rafQueue.set(id, cb); return id; };
window.cancelAnimationFrame = (id) => { rafQueue.delete(id); };
} else {
const raf = window.requestAnimationFrame.bind(window);
window.requestAnimationFrame = (cb) => raf(() => cb(now));
}
// Date / Date.now() follow the same frame clock from a fixed epoch.
const RealDate = Date, EPOCH = 1700000000000;
function FrameDate(...args) {
const d = args.length ? new RealDate(...args) : new RealDate(EPOCH + Math.floor(now));
return new.target ? d : d.toString();
}
FrameDate.prototype = RealDate.prototype;
FrameDate.now = () => EPOCH + Math.floor(now);
FrameDate.parse = RealDate.parse;
FrameDate.UTC = RealDate.UTC;
window.Date = FrameDate;
Object.defineProperty(window, '__aether', { value: Object.freeze({
step(t) {
now = t;
if (manualRaf) {
const due = rafQueue;
rafQueue = new Map();
for (const cb of due.values()) {
try { cb(t); } catch (e) { console.error(e); }
}
}
for (const a of document.getAnimations()) {
if (!started.has(a)) started.set(a, t);
a.pause();
a.currentTime = t - started.get(a);
}
},
}) });
})();"#;
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
enum Mode {
Auto,
BeginFrame,
Screenshot,
}
#[derive(Parser, Debug)]
#[command(
name = "aether-renderer-capture",
version,
about = "Capture HTML animations as a deterministic, transparent PNG sequence"
)]
struct Args {
#[arg(short, long)]
input: String,
#[arg(short, long, default_value = "frames")]
out: PathBuf,
#[arg(long)]
stdout: bool,
#[arg(long, default_value_t = 30)]
fps: u32,
#[arg(short, long, default_value_t = 2.0)]
duration: f64,
#[arg(short, long, default_value = "512x512")]
size: String,
#[arg(long, env = "AETHER_CHROME")]
chrome: PathBuf,
#[arg(long, value_enum, default_value_t = Mode::Auto)]
mode: Mode,
}
fn parse_size(s: &str) -> Result<(u32, u32)> {
let (w, h) = s
.split_once('x')
.ok_or_else(|| anyhow!("size must be WIDTHxHEIGHT"))?;
Ok((w.parse()?, h.parse()?))
}
fn to_url(input: &str) -> Result<String> {
if input.contains("://") {
return Ok(input.to_string());
}
let p = std::fs::canonicalize(input).with_context(|| format!("cannot find {input}"))?;
Ok(format!("file://{}", p.display()))
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
let (width, height) = parse_size(&args.size)?;
let url = to_url(&args.input)?;
let interval_ms = 1000.0 / args.fps as f64;
let total_frames = (args.duration * args.fps as f64).round() as u64;
let mode = match args.mode {
Mode::Auto if cfg!(target_os = "macos") => Mode::Screenshot,
Mode::Auto => Mode::BeginFrame,
m => m,
};
let begin_frame = mode == Mode::BeginFrame;
let mut builder = BrowserConfig::builder().chrome_executable(&args.chrome);
if running_as_root() {
builder = builder.no_sandbox();
}
if begin_frame {
builder = builder
.arg("deterministic-mode")
.arg("enable-begin-frame-control")
.arg("run-all-compositor-stages-before-draw");
}
let config = builder
.window_size(width, height)
.arg("disable-threaded-animation")
.arg("disable-threaded-scrolling")
.arg("disable-checker-imaging")
.arg("disable-new-content-rendering-timeout")
.arg("disable-gpu")
.arg("hide-scrollbars")
.arg("force-color-profile=srgb")
.arg("font-render-hinting=none")
.build()
.map_err(|e| anyhow!(e))?;
let (mut browser, mut handler) = Browser::launch(config).await.context("launching chrome")?;
let handler_task = tokio::spawn(async move { while handler.next().await.is_some() {} });
let target = CreateTargetParams::builder()
.url("about:blank")
.enable_begin_frame_control(begin_frame)
.width(width as i64)
.height(height as i64)
.build()
.map_err(|e| anyhow!(e))?;
let page = browser.new_page(target).await.context("creating page")?;
page.execute(
SetDeviceMetricsOverrideParams::builder()
.width(width as i64)
.height(height as i64)
.device_scale_factor(1.0)
.mobile(false)
.build()
.map_err(|e| anyhow!(e))?,
)
.await?;
page.execute(
SetDefaultBackgroundColorOverrideParams::builder()
.color(Rgba {
r: 0,
g: 0,
b: 0,
a: Some(0.0),
})
.build(),
)
.await?;
page.execute(
SetVirtualTimePolicyParams::builder()
.policy(VirtualTimePolicy::Pause)
.initial_virtual_time(
chromiumoxide::cdp::browser_protocol::network::TimeSinceEpoch::new(1_700_000_000.0),
)
.build()
.map_err(|e| anyhow!(e))?,
)
.await?;
let shim =
FRAME_CLOCK_SHIM.replace("__MANUAL_RAF__", if begin_frame { "false" } else { "true" });
page.evaluate_on_new_document(shim).await?;
let mut budget_events = page
.event_listener::<EventVirtualTimeBudgetExpired>()
.await?;
let nav = {
let page = page.clone();
let url = url.clone();
tokio::spawn(async move { page.goto(url).await.map(|_| ()) })
};
let mut ready = false;
let mut warmup_steps = 0u32;
for _ in 0..2000 {
if nav.is_finished() {
ready = page
.evaluate(
"document.readyState === 'complete' && document.fonts.status === 'loaded'",
)
.await
.ok()
.and_then(|v| v.into_value::<bool>().ok())
.unwrap_or(false);
if ready {
break;
}
}
warmup_steps += 1;
advance_virtual_time(
&page,
&mut budget_events,
VirtualTimePolicy::PauseIfNetworkFetchesPending,
WARMUP_STEP_MS,
)
.await?;
}
if !ready {
bail!("page did not finish loading (readyState/fonts) within the warm-up budget");
}
nav.await??;
if std::env::var("AETHER_DEBUG").is_ok() {
eprintln!("page ready after {warmup_steps} warm-up steps");
}
let base_ticks = monotonic_ms();
if !args.stdout {
std::fs::create_dir_all(&args.out)?;
}
let pb = if args.stdout {
indicatif::ProgressBar::hidden()
} else {
indicatif::ProgressBar::new(total_frames)
};
let mut stdout = std::io::stdout().lock();
let mut last_png: Option<Vec<u8>> = None;
let mut reused = 0u64;
for i in 0..total_frames {
if i > 0 {
advance_virtual_time(
&page,
&mut budget_events,
VirtualTimePolicy::Advance,
interval_ms,
)
.await?;
}
let frame_ms = i as f64 * interval_ms;
page.evaluate(format!(
"window.__aether && window.__aether.step({frame_ms})"
))
.await
.context("stepping page clock")?;
let shot: Option<String> = if begin_frame {
let ticks = base_ticks + i as f64 * interval_ms;
page.execute(
BeginFrameParams::builder()
.frame_time_ticks(ticks)
.interval(interval_ms)
.screenshot(
ScreenshotParams::builder()
.format(ScreenshotParamsFormat::Png)
.build(),
)
.build(),
)
.await?
.result
.screenshot_data
.map(|b| AsRef::<str>::as_ref(&b).to_string())
} else {
let res = page
.execute(
CaptureScreenshotParams::builder()
.format(CaptureScreenshotFormat::Png)
.from_surface(true)
.build(),
)
.await?;
Some(AsRef::<str>::as_ref(&res.result.data).to_string())
};
let png = match shot {
Some(b64) => {
let bytes = base64::engine::general_purpose::STANDARD.decode(b64)?;
last_png = Some(bytes.clone());
bytes
}
None => {
reused += 1;
last_png
.clone()
.ok_or_else(|| anyhow!("first frame produced no screenshot"))?
}
};
if args.stdout {
stdout.write_all(&png)?;
} else {
std::fs::write(args.out.join(format!("frame_{i:05}.png")), &png)?;
}
pb.inc(1);
}
pb.finish_and_clear();
stdout.flush()?;
eprintln!(
"captured {total_frames} frames @ {} fps ({width}x{height}, {mode:?} mode), {reused} reused (no damage)",
args.fps
);
browser.close().await.ok();
handler_task.abort();
Ok(())
}
#[cfg(unix)]
fn running_as_root() -> bool {
unsafe { libc::geteuid() == 0 }
}
#[cfg(not(unix))]
fn running_as_root() -> bool {
false
}
#[cfg(unix)]
fn monotonic_ms() -> f64 {
#[cfg(target_os = "macos")]
const CLOCK: libc::clockid_t = libc::CLOCK_UPTIME_RAW;
#[cfg(not(target_os = "macos"))]
const CLOCK: libc::clockid_t = libc::CLOCK_MONOTONIC;
let mut ts = libc::timespec {
tv_sec: 0,
tv_nsec: 0,
};
unsafe { libc::clock_gettime(CLOCK, &mut ts) };
ts.tv_sec as f64 * 1000.0 + ts.tv_nsec as f64 / 1_000_000.0
}
#[cfg(windows)]
fn monotonic_ms() -> f64 {
use windows_sys::Win32::System::Performance::{
QueryPerformanceCounter, QueryPerformanceFrequency,
};
let (mut count, mut freq) = (0i64, 0i64);
unsafe {
QueryPerformanceCounter(&mut count);
QueryPerformanceFrequency(&mut freq);
}
count as f64 * 1000.0 / freq as f64
}
async fn advance_virtual_time(
page: &Page,
events: &mut chromiumoxide::listeners::EventStream<EventVirtualTimeBudgetExpired>,
policy: VirtualTimePolicy,
budget_ms: f64,
) -> Result<()> {
page.execute(
SetVirtualTimePolicyParams::builder()
.policy(policy)
.budget(budget_ms)
.build()
.map_err(|e| anyhow!(e))?,
)
.await?;
tokio::time::timeout(Duration::from_secs(30), events.next())
.await
.context("timed out waiting for virtualTimeBudgetExpired")?
.ok_or_else(|| anyhow!("event stream closed"))?;
Ok(())
}