Skip to content

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 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 transitionend fires 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 finished promise resolves and animationend fires, 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: stability says so.

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:

Terminal window
npx versioncam stability 02-find-a-shipment # draft; --full for the real thing
02-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.png
unstable: something on this page does not run on authored time

The 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.