⏸️ Virtual time
Chrome's clock starts paused. While the page loads it moves in 1 µs steps until fonts and network are ready; then it advances exactly one frame interval per frame.
CLI · Rust crate · v0.2.0
Turn any HTML/CSS/JS animation into a deterministic, transparent PNG sequence or stream. Every frame is stepped, never sampled from a running clock: same page, same bytes, every time.
cargo install aether-renderer-capture
# It drives chrome-headless-shell (BeginFrame doesn't exist in normal Chrome's --headless=new)
npx @puppeteer/browsers install chrome-headless-shell@stable
export AETHER_CHROME=/path/printed/above/chrome-headless-shellOr download a binary (you still need chrome-headless-shell):
FFmpeg is only needed to turn the frames into video, directly or throughaether-renderer-core. Pages withhtml, body { background: transparent } give real alpha.
# a folder (or a ZIP) of frame_00000.png ... for aether-renderer-core
aether-renderer-capture -i anim.html -o frames --fps 30 -d 4 -s 1080x1080
aether-renderer-capture -i anim.html -o anim.zip --fps 30 -d 4 -s 1080x1080
# stream straight into aether-renderer-core: no files on disk
aether-renderer-capture -i anim.html --stdout --fps 30 -d 4 \
| aether-renderer-core -i - -o anim.webm --fps 30 --frames 120
# ...or into plain ffmpeg
aether-renderer-capture -i anim.html --stdout --fps 30 -d 4 \
| ffmpeg -f image2pipe -framerate 30 -c:v png -i - \
-c:v libvpx-vp9 -pix_fmt yuva420p -b:v 0 -crf 30 anim.webm| Flag | Default | What it does |
|---|---|---|
-i, --input | HTML file or URL | |
-o, --out | frames | Output folder, or a .zip, of frame_00000.png … |
--stdout | off | PNG files back to back on stdout |
--fps | 30 | Frames per second |
-d, --duration | 2.0 | Seconds |
-s, --size | 512x512 | WIDTHxHEIGHT |
--chrome / AETHER_CHROME | Path to chrome-headless-shell | |
--mode | auto | begin-frame (Linux), screenshot (macOS, Windows), or auto |
--no-sandbox | off | Run Chrome without its sandbox (Ubuntu 23.10+, trusted pages only) |
Chrome's clock starts paused. While the page loads it moves in 1 µs steps until fonts and network are ready; then it advances exactly one frame interval per frame.
Injected before any page code: performance.now(), rAF timestamps andDate return the exact frame time, and CSS animations, transitions and the Web Animations API are paused and seeked to it.
begin-frame on Linux (Chrome draws only when asked and returns the PNG),screenshot on macOS and Windows. Both give the same bytes.
Because the clock is exact, you can do the maths in your script and trust it: a loop that's a function of t % T ends exactly where it started. The animation onweb2vid.com is made like that, and rendered with capture.
// A seamless 6 s loop: everything is a function of t modulo the period.
const T = 6000;
function frame(t) {
const phase = (t % T) / T; // 0..1, exactly, on every frame
draw(phase);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);[dependencies]
aether-renderer-capture = "0.2"use aether_renderer_capture::{png_stream, CaptureOptions};
let opts = CaptureOptions::new("anim.html", "/path/to/chrome-headless-shell")
.size(1080, 1080)
.fps(30)
.duration_secs(4.0);
let frames = opts.frame_count() as u32;
let (stream, capture) = png_stream(opts)?; // options are checked here
aether_renderer_core::render_stream(cfg, stream, Some(frames), |p| {
println!("{:.0}%", p.percent);
})?;
capture.wait()?; // the capture's report, or why it failedIf the capture fails, the stream's next read returns the error. Dropping the stream stops the capture and closes Chrome. PngStream::on_frame reports progress as frames arrive. Full API on docs.rs.
<video>, <audio>, and WebGL or canvas driven by their own clocks are not pinned.performance.now() is not pinned.