๐ชผ 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.0). 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)
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 |
--mode |
auto |
begin-frame, screenshot, or auto (screenshot on macOS, begin-frame elsewhere) |
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, Windows | Chrome only draws when we send HeadlessExperimental.beginFrame, which returns the PNG |
screenshot |
everywhere (default on macOS) | The frame-clock shim runs rAF callbacks itself, then Page.captureScreenshot |
auto picks screenshot on macOS, because Chrome doesn't support BeginFrame
control there. 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.- 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
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.
- 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)
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.