Determinism
Source README.md · versioncam 0.5.3 from npm
Two runs of a clip on one machine produce the same bytes, draft and full. The package’s own tests assert it for capture and for rendering on every change, on macOS and on Linux. A golden recording pins the capture path against changes nobody meant to make.
A render is the same bytes every time it is made from one recording, on one
ffmpeg build, with one encoder thread count. The thread count otherwise
follows the machine’s cores, so a render that must match another machine’s
fixes it with output.threads or VERSIONCAM_FFMPEG_THREADS. Before 0.3.0
every WebM carried a random id and no two were the same file.
Across machines the claim is narrower. The timeline is identical: it is what the driver decided, and nothing in it depends on the host. The captured pixels are identical only where the rendering environment is. macOS and Linux rasterise text differently, so the same page in the same state is the same picture and a different file. Byte-identity belongs to a fixed environment. In CI that means one runner image, and the Playwright container for the version versioncam pins is the simplest (see In CI).
If two recordings of your clip on one machine differ, something outside the authored clock reached a frame: an unwarmed resource, a real timer, a font that had not finished loading. Find the cause. The next section is how.
The app’s own motion
Section titled “The app’s own motion”The page runs on a fake clock that moves only when the recorder moves it: a frame’s worth at a time while frames are made, and in small ticks while a settle waits. The app’s timers and animation frames run on it, and so does its CSS motion:
- Transitions play at their authored duration and easing, on the page’s
clock, from the frame the recorder first sees each one. A menu fades in, a
drawer slides, a map zooms over its frames, and
transitionendfires on the frame its time runs out. A settle waits until none is running, so the frame after it shows them finished.capture: { transitions: "zero" }makes them take no time instead, as before 0.4.0, in the config or in one clip. - Animations are scrubbed by the clip’s time: a spinner turns at the rate
the video plays, whatever the machine is doing. The scrub counts from the
clip’s start, not the animation’s, so a one-off animation that starts
partway through a clip shows its end. Once a one-off animation’s time is
up it finishes, as in a browser nobody is filming: its
finishedpromise resolves andanimationendfires, so a dialog that unmounts after its exit animation goes, and does not sit invisibly over the page. - Not on the clock: animations a script makes with
element.animate(), Chromium’s smooth scrolling of a mouse wheel, and transitions inside an iframe, which stay zeroed since nothing drives an iframe’s clock. A clip that films one of the first two may differ between runs:stabilitysays so.
When a clip will not reproduce
Section titled “When a clip will not reproduce”The recorder can only be as deterministic as the app it films. An app that is nearly deterministic is the hard case: it reproduces on most runs, so the first failure looks like a recorder bug.
Before assuming it is one, record the clip twice the same way. stability
does exactly that, and compares the runs frame by frame, by what each frame
shows:
npx versioncam stability 02-find-a-shipment # draft; --full for the real thing02-find-a-shipment: run 1 vs run 2 · 268 frames, 264 identical, first difference at frame 131 .versioncam/stability/02-find-a-shipment/frame-131-run1.png .versioncam/stability/02-find-a-shipment/frame-131-run2.pngunstable: something on this page does not run on authored timeThe two files are the pictures behind the first frame that differs. Side by
side, what differs is usually obvious. Each run records beside the clip’s own
recording, under recordings/__stability/, never over it. With no id it
checks every clip, and it exits 0 only when every pair matched. That makes it
the first thing to run in a repository before writing a clip for it, and it
is what the authoring skill does on a first run.
If the runs differ, something on the page did not run on authored time. Look at the app first, but not only there: of the four causes found so far, the recorder was two. What we have found doing this:
- An autosizing text field that settles a frame later depending on how the browser scheduled the layout. The clips that typed diverged. The clips that did not, did not.
- A spinner, the recorder’s fault and since fixed. The screenshot was cancelling the animation the driver had just scrubbed. The symptom was six pixels in one frame of three hundred, which reads as noise rather than as a bug.
- A typed letter’s edge, the recorder’s fault and since fixed. A draft’s screenshot has Chromium paint the page again at CSS scale. It repainted only the rectangle the page reported as changed, which at that scale could stop a pixel short of a new glyph’s antialiased edge. This happened only on Linux, where text has LCD antialiasing: one pixel of one frame, in about half of all runs. Side by side the two pictures look identical; subtracting one from the other finds it.
- A timer firing early, the recorder’s fault and since fixed. The page’s fake clock was installed but left running on real time between frames, so a slow screenshot reached the page as time. One stalled frame on a CI runner moved the clock 840 ms instead of 67, and the example app’s shipment appeared seven frames early. The clock is paused now and moves only when the driver moves it.
If the two recordings match and something else does not, ask what changed
between them: the gate (capture: { gate: false } turns it off), or the
machine.