๐ชผ aether-renderer-capture
The HTML finger of Aether Renderer: turn any HTML/CSS/JS animation into a deterministic, transparent PNG sequence โ ready for
aether-renderer-core.
Status: proof of concept (v0.1.1). Same input โ bit-identical frames, every run, on any machine speed.
Install
# It drives chrome-headless-shell (BeginFrame doesn't exist in normal Chrome's --headless=new)
On Ubuntu 23.10+ Chrome's sandbox is blocked by AppArmor. Either allow it
(sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0) or pass
--no-sandbox (trusted pages only).
FFmpeg is only needed to turn the frames into video (directly, or through
aether-renderer-core).
Usage
# 1. PNG folder -> works with aether-renderer-core today, no changes needed
# 2. Stream straight into ffmpeg (no files on disk)
|
| Flag | Default | |
|---|---|---|
-i, --input |
โ | HTML file or URL |
-o, --out |
frames |
Output folder (frame_00000.png โฆ) |
--stdout |
off | Concatenated PNG stream on stdout |
--fps |
30 | |
-d, --duration |
2.0 | Seconds |
-s, --size |
512x512 | WIDTHxHEIGHT |
--chrome / AETHER_CHROME |
โ | Path to chrome-headless-shell |
--no-sandbox / AETHER_NO_SANDBOX |
off | Run Chrome without its sandbox (Ubuntu 23.10+, trusted pages only) |
--mode |
auto |
begin-frame, screenshot, or auto (begin-frame on Linux, screenshot on macOS and Windows) |
Transparent pages (html, body { background: transparent }) give real alpha.
How it works
Every frame is stepped, never sampled from a running clock. There are two ways to draw and grab a frame, and both give bit-identical output:
| Mode | Platforms | How a frame is drawn |
|---|---|---|
begin-frame |
Linux | Chrome only draws when we send HeadlessExperimental.beginFrame, which returns the PNG |
screenshot |
everywhere (default on macOS and Windows) | The frame-clock shim runs rAF callbacks itself, then Page.captureScreenshot |
auto picks screenshot on macOS (Chrome doesn't support BeginFrame control
there) and on Windows (BeginFrame returns no image for the first frame). The
steps below describe begin-frame; screenshot mode is the same minus the
BeginFrame flags.
- chrome-headless-shell is launched with
--deterministic-mode,--enable-begin-frame-control,--run-all-compositor-stages-before-draw. (BeginFrame only exists in the old headless โ i.e. chrome-headless-shell, not--headless=new.) - The target is created with
enableBeginFrameControl: true, so Chrome never draws on its own. Emulation.setVirtualTimePolicystarts paused. During page load it advances in 1 ยตs steps withpauseIfNetworkFetchesPending, untilreadyState === 'complete'and fonts are loaded.- Per frame:
- virtual time advances exactly one interval (
advance+ wait forvirtualTimeBudgetExpired) โ timers fire deterministically window.__aether.step(t)pins the page clock to the exact frame timeHeadlessExperimental.beginFramedraws once and returns the PNG- no damage โ no image from Chrome โ previous frame is reused
- virtual time advances exactly one interval (
The frame-clock shim (why it's needed)
Even under virtual time, Chrome coarsens performance.now(), rAF timestamps
and the animation timeline to 100 ยตs with random jitter (Spectre
mitigation). That alone made ~10k pixels differ between runs. A tiny script
injected before any page code replaces them with one exact clock:
performance.now(), rAF timestamps โ exact frame timeDate/Date.now()โ fixed epoch (2023-11-14T22:13:20Z) + frame time- CSS animations, transitions, WAAPI โ paused and seeked to the frame time
Gotchas learned the hard way
- BeginFrame control is not supported on macOS at all
("BeginFrameControl is not supported on MacOS yet"), hence
screenshotmode. frameTimeTicksmust be in Chrome's TimeTicks domain (host monotonic clock). Small values like33.3are silently ignored and rAF/CSS freeze.- On Windows,
canonicalize()returns\\?\C:\..., which isn't a validfile://URL. Build file URLs withUrl::from_file_path. - Ubuntu 23.10+ blocks the user namespaces Chrome's sandbox needs.
- With virtual time fully paused, the
loadevent never fires. - chromiumoxide adds the
--itself:.arg("enable-begin-frame-control").
Verified
- 10/10 runs bit-identical (60 frames, sha256 of all PNGs), also under 4ร CPU load
- 30 fps frame 15 == 60 fps frame 30 (same instant, bit-identical)
screenshotmode produces the exact same frames asbegin-framemode- macOS (Apple Silicon, Chrome 156): repeated runs bit-identical in
screenshotmode - CI captures twice on Linux, macOS and Windows and requires identical frames
- Alpha survives into VP9 WebM (
alpha_mode=1) - 512ร512: ~1 s for 60 frames; 1920ร1080: ~3.5 s (
begin-frame) / ~6.5 s (screenshot) for 60 frames (software GL, Linux)
Determinism holds per machine and Chrome version. Across operating systems or Chrome versions, fonts and anti-aliasing are rasterized slightly differently, so frames look the same but are not byte-identical. Pin the chrome-headless-shell version if you need identical output on several machines.
Known limits
<video>,<audio>, WebGL/Canvas driven by their own clocks, and Web Workers'performance.now()are not pinned.- Pages measuring real durations with
performance.now()will see 0 inside a frame. - Timers run on virtual time, which lands ~10 ms ahead of the frame clock after the first step (harmless for drawing, but noted).
Roadmap
Follows the capture plan that lives next to the other Aether repos.
- Page mode: deterministic, transparent viewport capture (this PoC)
- Streaming input in
aether-renderer-core(--input -, raw RGBA) so capture encodes straight to WebM/MOV/GIF with no temp files - Canvas mode: auto-pick the largest
<canvas>, grab exact pixels (preserveDrawingBufferpatch for WebGL) - Start/stop handshake:
window.aether?.ready()/window.aether?.done(), plus--wait-for,--click,--start - Workers / OffscreenCanvas clocks
- Find or download chrome-headless-shell automatically
-
--scalefor retina output - URL/HTML input in
aether-renderer-gui
Made by Mads Paaskesen, Hardcore Websmith. MIT licensed.