versioncam 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +72 -46
  2. package/README.md +382 -242
  3. package/bin/versioncam.js +1 -1
  4. package/dist/app-server.js +2 -2
  5. package/dist/app-server.js.map +1 -1
  6. package/dist/cli/commands/doctor.js +2 -2
  7. package/dist/cli/commands/doctor.js.map +1 -1
  8. package/dist/cli/commands/init.js +1 -1
  9. package/dist/cli/commands/init.js.map +1 -1
  10. package/dist/cli/commands/record.js +3 -2
  11. package/dist/cli/commands/record.js.map +1 -1
  12. package/dist/cli/commands/render.js +1 -1
  13. package/dist/cli/commands/render.js.map +1 -1
  14. package/dist/cli/commands/stability.js +4 -4
  15. package/dist/cli/commands/stability.js.map +1 -1
  16. package/dist/cli/usage.d.ts +9 -5
  17. package/dist/cli/usage.js +36 -23
  18. package/dist/cli/usage.js.map +1 -1
  19. package/dist/core/timeline.d.ts +22 -0
  20. package/dist/core/timeline.js +10 -0
  21. package/dist/core/timeline.js.map +1 -1
  22. package/dist/driver/clip.d.ts +4 -1
  23. package/dist/driver/clip.js +9 -1
  24. package/dist/driver/clip.js.map +1 -1
  25. package/dist/driver/session.d.ts +13 -0
  26. package/dist/driver/session.js +33 -0
  27. package/dist/driver/session.js.map +1 -1
  28. package/dist/driver/settle.js +2 -2
  29. package/dist/driver/settle.js.map +1 -1
  30. package/dist/inspect/inspect.js +1 -1
  31. package/dist/page/index.html +1 -1
  32. package/dist/render/render.js +1 -1
  33. package/dist/render/render.js.map +1 -1
  34. package/dist/render/sampling.js +1 -1
  35. package/dist/render/sampling.js.map +1 -1
  36. package/dist/review/review.js +3 -3
  37. package/dist/review/review.js.map +1 -1
  38. package/dsl.md +230 -92
  39. package/package.json +2 -2
  40. package/plugin/README.md +53 -47
package/README.md CHANGED
@@ -1,47 +1,48 @@
1
1
  # versioncam
2
2
 
3
- Record demo clips of a real web app: scripted, deterministic, regenerated on
4
- deploy.
3
+ Record demo clips of a real web app: scripted, deterministic, re-recorded on
4
+ every push.
5
5
 
6
- A clip is a script in your repository. It drives the shipped UI through its own
7
- controls, captures it, composites a cursor and a camera over the result, and
8
- encodes it. Run it again after every deploy. When the UI changes in a way the
9
- clip depends on, the clip *fails* — naming the control it could not find and
10
- the second it gave up — instead of quietly producing a video that is wrong.
6
+ A clip is a script in your repository. It drives your app through its own
7
+ controls in a real browser and captures what the page shows. Then it draws a
8
+ cursor and a camera over the frames and encodes a video. Record it again on
9
+ every push. When the app changes under a clip, the clip *fails*: it names the
10
+ control it could not find and the second it gave up. It never turns into a
11
+ video that is quietly wrong.
11
12
 
12
13
  ## Install
13
14
 
14
- Three commands, then a sentence:
15
+ Three commands, then one sentence to your agent:
15
16
 
16
17
  ```bash
17
18
  npm i -D versioncam
18
19
  npx versioncam install # the Chromium this version records with
19
- npx versioncam init # installs the authoring skill into .claude/
20
+ npx versioncam init # the authoring skill, into .claude/
20
21
  ```
21
22
 
22
23
  ```
23
24
  /versioncam "Show a user filtering the list down to one shipment"
24
25
  ```
25
26
 
26
- The first run of the skill sets the repository up: it writes
27
+ The first run of the skill sets the repository up. It writes
27
28
  `versioncam.config.ts` from what it finds, proves the app starts and records
28
29
  the same way twice, and only then writes the clip. See *Authoring*.
29
30
 
30
- `npx versioncam doctor` checks the rest — Node 22 or later, Chromium, ffmpeg,
31
+ `npx versioncam doctor` checks the rest: Node 22 or later, Chromium, ffmpeg,
31
32
  your config, and whether the app starts. ffmpeg and ffprobe come from the
32
- system (`brew install ffmpeg`, `apt install ffmpeg`); the optional
33
- `ffmpeg-static` covers images that have neither, but it does not include
34
- ffprobe. It is tested on macOS and Linux; Windows is untested.
33
+ system (`brew install ffmpeg`, `apt install ffmpeg`). The optional
34
+ `ffmpeg-static` package covers a machine that has neither, but it has no
35
+ ffprobe. versioncam is tested on macOS and Linux. Windows is untested.
35
36
 
36
- `install` rather than `npx playwright install`, because versioncam pins its
37
- Playwright exactly — a new Chromium changes every captured pixel — and your
38
- app may have a Playwright of its own, at another version, whose Chromium is
39
- the one `npx playwright install` fetches.
37
+ Use `versioncam install`, not `npx playwright install`. versioncam pins its
38
+ Playwright exactly, because a new Chromium changes every captured pixel. Your
39
+ app may have a Playwright of its own at another version, and
40
+ `npx playwright install` fetches that one's Chromium.
40
41
 
41
42
  ### In CI
42
43
 
43
- The same install, then `check`, which records every clip and fails the job on
44
- the first one that no longer matches the app:
44
+ The same install, then `check`. It records every clip and fails the job on the
45
+ first one that no longer matches the app.
45
46
 
46
47
  ```yaml
47
48
  - uses: actions/setup-node@v4
@@ -52,16 +53,19 @@ the first one that no longer matches the app:
52
53
  - run: npx versioncam check
53
54
  ```
54
55
 
55
- `check` records and does not render, so it needs no ffmpeg; a job that also
56
- runs `render` adds `sudo apt-get install -y ffmpeg`. Two runs on one runner
57
- image produce the same bytes. To pin everything the pixels depend on — the
58
- browser build, the fonts, the system libraries — run the job in the Playwright
59
- image for the version versioncam pins, `mcr.microsoft.com/playwright:v1.59.1-noble`,
60
- where Chromium and its libraries are already installed.
56
+ `check` records and does not render, so it needs no ffmpeg. A job that also
57
+ runs `render` adds `sudo apt-get install -y ffmpeg`.
58
+
59
+ Two runs on one runner image produce the same bytes. To pin everything the
60
+ pixels depend on (the browser build, the fonts, the system libraries), run the
61
+ job in the Playwright image for the version versioncam pins,
62
+ `mcr.microsoft.com/playwright:v1.59.1-noble`. Chromium and its libraries are
63
+ already installed there.
61
64
 
62
65
  ## Configure
63
66
 
64
- `versioncam.config.ts`, beside your clips:
67
+ `versioncam.config.ts` sits beside your clips. It is the one place your app
68
+ describes itself to the recorder.
65
69
 
66
70
  ```ts
67
71
  import { defineRecorder } from "versioncam";
@@ -86,36 +90,74 @@ export default defineRecorder({
86
90
  });
87
91
  ```
88
92
 
89
- Everything app-specific goes through this object. If the recorder needs to know
90
- something about your app that this cannot express, that is a missing field, not
91
- a reason to fork.
92
-
93
- **The app starts itself.** `webServer` is Playwright's, field for field:
94
- `command`, `url` (default `baseUrl`), `cwd` (default the config's directory),
95
- `timeoutMs` (default two minutes), `reuseExistingServer`, `env`. Every command
96
- that drives the app — `record`, `check`, `stability`, `inspect`, `measure`,
97
- `login`, `doctor` — first asks `url` whether anything is serving. Any answer
98
- counts, a 401 included, and a server that answers is used as it is and left
99
- running afterwards; with `CI` set it is refused instead, because on a runner a
100
- server that was up before the job started is not the build under test
101
- (`reuseExistingServer: true` allows it). Silence means the command starts
102
- `command` through a shell, with the shell's environment plus `env` — not what
103
- the recorder loaded from `.env.recording` for itself — waits for an answer,
104
- and writes what it prints to `.versioncam/webserver.log`. Whatever it started,
105
- it stops when it finishes — normally, on an error, or on Ctrl-C — by
106
- signalling the command's whole process group: SIGTERM, five seconds' grace,
107
- then SIGKILL, and the log says which it took. Something the start command
108
- hands off to outside that group, such as a container runtime, is out of its
109
- reach — the command warns when the port still answers after the stop — and so
110
- is worth starting yourself. So is any stack with a slow cold start: every
111
- command reuses what is already running.
93
+ If the recorder needs to know something about your app that this object
94
+ cannot say, that is a missing field. It is not a reason to fork.
95
+
96
+ | Field | Default | What it sets |
97
+ |---|---|---|
98
+ | `baseUrl` | required | Where the app is served. |
99
+ | `webServer` | none | How to start the app when nothing is serving it. See *Starting the app*. |
100
+ | `clips` | `"demos/**/*.clip.ts"` | Where the clips are: a glob, or a list of them, relative to the config. |
101
+ | `viewport` | `{ width: 1440, height: 900 }` | The page's size. The video has the same shape. |
102
+ | `dpr` | `2` | Device pixel ratio, from 1 to 3. At 2, a zoom shows real pixels. |
103
+ | `fps` | `60` | Frames per second, from 10 to 120. |
104
+ | `output` | `{ width: 1920 }` | The video's width. Its height follows the viewport. |
105
+ | `auth` | none | How the app signs in: a saved session, `{ storageState }`, or a hook, `{ login }`. |
106
+ | `settle` | `quietMs: 150`, `timeoutMs: 15000`, `yieldMs: 20` | When a page counts as done. `ready` adds a condition of your own. |
107
+ | `capture` | `gate: true`, `forceCaptureEvery: 30` | Which frames get a screenshot. See *Speed*. |
108
+ | `review` | `minSeconds: 9`, `maxSeconds: 11` | How long a clip should run. `versioncam review` holds clips to it. |
109
+ | `cursorStart` | `{ x: 0.72, y: 0.28 }` | Where the cursor rests in the first frame, as fractions of the viewport. |
110
+ | `clock` | `time: "2026-09-01T09:00:00Z"`, `timezoneId: "UTC"`, `locale: "en-GB"` | The instant, time zone and locale the app sees. |
111
+ | `theme` | `accent: "#2f6fed"`, `cursorScale: 2.1` | The colour of ripples and rings, the cursor's size, and an optional `presentation`. |
112
+ | `motion` | `MOTION` | Dwells, strokes and camera timing. `MOTION` is exported, with the reason for each number beside it. |
113
+ | `outDir` | `".versioncam"` | Where everything the recorder writes goes. Gitignore it. |
114
+ | `scenes` | none | A module whose default export is `Record<string, Scene>`. |
115
+ | `sequence` | none | Clip ids, in order, for `versioncam render --sequence`. |
116
+
117
+ ### Starting the app
118
+
119
+ `webServer` is Playwright's, field for field:
120
+
121
+ | Field | Default | What it sets |
122
+ |---|---|---|
123
+ | `command` | required | Run through a shell to start the app. |
124
+ | `url` | `baseUrl` | What to ask whether the app is up. |
125
+ | `cwd` | the config's directory | Where `command` runs. |
126
+ | `timeoutMs` | `120000` | How long to wait for `url` to answer. |
127
+ | `reuseExistingServer` | `true`, and `false` when `CI` is set | Use a server that already answers instead of starting one. |
128
+ | `env` | `{}` | Added to the shell's environment, for the server only. |
129
+
130
+ Every command that drives the app (`record`, `check`, `stability`, `inspect`,
131
+ `measure`, `login` and `doctor`) goes through the same steps:
132
+
133
+ 1. It asks `url` whether anything is serving. Any answer counts, a 401
134
+ included.
135
+ 2. If something answers, it uses that server as it is and leaves it running.
136
+ With `CI` set it refuses instead: on a runner, a server that was up before
137
+ the job started is not the build under test. `reuseExistingServer: true`
138
+ allows it.
139
+ 3. If nothing answers, it runs `command` with the shell's environment plus
140
+ `env`. It does not pass on what the recorder loaded from `.env.recording`
141
+ for itself. It waits for `url` to answer and writes the server's output to
142
+ `.versioncam/webserver.log`.
143
+ 4. When it finishes, normally, on an error or on Ctrl-C, it stops what it
144
+ started. It signals the command's whole process group: SIGTERM, then
145
+ SIGKILL after five seconds. The log says which one it took.
146
+
147
+ A process the start command hands off to outside its group, such as a
148
+ container runtime, is out of reach. The command warns when the port still
149
+ answers after the stop. Start a stack like that yourself, and any stack with a
150
+ slow cold start: every command reuses what is already running.
112
151
 
113
152
  For a stack of several services, point `url` at the one that is ready last,
114
- not at the page: a dev server answers seconds before the API behind it, and a
115
- clip that opens then meets a sign-in form with nothing to sign in to.
153
+ not at the page. A dev server answers seconds before the API behind it, and a
154
+ clip that opens too early meets a sign-in form with nothing to sign in to.
116
155
 
117
156
  ## Write a clip
118
157
 
158
+ A clip is a file that default-exports `clip(id, options, body)`. The body
159
+ drives the app through `s`, its session.
160
+
119
161
  ```ts
120
162
  import { clip } from "versioncam";
121
163
 
@@ -133,169 +175,236 @@ export default clip("02-find-a-shipment", { title: "Find a shipment" }, async (s
133
175
  });
134
176
  ```
135
177
 
136
- Every `await` advances the clip's time and captures as it goes. `s.camera.*`,
137
- `s.highlight` and `s.caption` write to tracks at the current instant and cost
138
- no time. `s.offCamera(page => …)` runs raw Playwright with nothing captured —
139
- for setup that should not appear, such as mocking an API.
178
+ Every `await` advances the clip's time and captures frames as it goes.
179
+ `s.camera.*`, `s.highlight` and `s.caption` write to tracks at the current
180
+ instant and cost no time. `s.offCamera(page => …)` runs raw Playwright with
181
+ nothing captured, for setup that should not appear, such as mocking an API.
140
182
 
141
- Target elements by role or test id, never by coordinates. Strokes (`s.drag`
142
- for a straight one, `s.lasso` for a closed loop) are authored in fractions of a
143
- target's box for the same reason: a clip built from locators survives a layout
183
+ Target elements by test id or role, never by coordinates. Strokes (`s.drag`
184
+ for a straight one, `s.lasso` for a closed loop) are written in fractions of a
185
+ target's box for the same reason. A clip built from locators survives a layout
144
186
  change.
145
187
 
146
- `npx versioncam dsl` prints the whole surface, and a test asserts that list and
147
- the session agree. That is worth more than it sounds:
148
- this README promised an `s.drag` for months while there was none, and a clip
149
- that needed one clicked instead — on a d3 brush, where a click sets a selection
150
- of zero width and so *clears* the filter. It recorded cleanly and showed
151
- nothing, for months, and no check caught it.
188
+ `npx versioncam dsl` prints every method with its options and defaults, and a
189
+ test holds that list to the session's code. The same reference is
190
+ *The clip DSL*.
191
+
192
+ | Option | Default | What it sets |
193
+ |---|---|---|
194
+ | `title` | required | The clip's name, kept in its timeline. It is not drawn: write a caption with `s.caption`. |
195
+ | `seed` | `1` | Seeds the jitter in cursor paths, dwells and typing. A new seed is a new take of the same script. |
196
+ | `viewport`, `dpr`, `fps` | the config's | For a clip that needs a different shape. |
197
+ | `capture` | the config's | A clip that films a canvas sets `{ forceCaptureEvery: 1 }` here. |
198
+
199
+ ### Look before you write
152
200
 
153
- **Look before you write.** Clips written from an app's source reach for
154
- controls that are conditional, renamed, or not rendered at all:
201
+ A clip written from an app's source reaches for controls that are
202
+ conditional, renamed, or not rendered at all. Ask the page instead:
155
203
 
156
204
  ```bash
157
205
  npx versioncam inspect /shipments # what is on the page and what it is called
158
206
  npx versioncam measure / '.marker' # where the matches are, as fractions
159
207
  ```
160
208
 
161
- `inspect` says where each name came from — `[aria-label]`, `[label]`,
162
- `[placeholder]`, `[text]` or `[name]` — because that decides the locator:
163
- `byLabel` finds the first two and not a placeholder, which `byPlaceholder`
164
- finds instead. `measure` prints what each match says, so a beat's `expect`
165
- can quote the status line it is waiting for rather than guess at it.
209
+ `inspect` says where each name came from: `[aria-label]`, `[label]`,
210
+ `[placeholder]`, `[text]` or `[name]`. That decides the locator. `byLabel`
211
+ finds the first two but not a placeholder; `byPlaceholder` finds that.
212
+ `measure` prints what each match says, so a beat's `expect` can quote the
213
+ status line it waits for instead of guessing at it.
166
214
 
167
215
  ## Commands
168
216
 
217
+ Every command runs from the directory that holds `versioncam.config.ts`.
218
+ `npx versioncam --help` prints the same list.
219
+
220
+ ### Set up
221
+
222
+ Once per machine, and once per repository.
223
+
224
+ ```
225
+ versioncam install the Chromium this version of versioncam records with
226
+ --with-deps and its system libraries (Linux CI)
227
+ versioncam init install the authoring skill into .claude/
228
+ --into <dir> into <dir>/versioncam/ instead, for another host
229
+ --force overwrite files that are already there
230
+ versioncam doctor check this machine can record, and that the app starts
231
+ versioncam login sign in once and save the browser session
232
+ ```
233
+
234
+ ### Write
235
+
236
+ Look at the page before writing against it.
237
+
238
+ ```
239
+ versioncam inspect <path> what a clip can click, and where each name came from
240
+ --json machine-readable
241
+ versioncam measure <path> <sel> where a selector's matches are, and what they say
242
+ versioncam dsl the clip DSL: every method, its options and defaults
243
+ ```
244
+
245
+ ### Record
246
+
247
+ `check` is the CI command. It does not render: a clip that no longer matches
248
+ the app fails while it records, at the step that no longer exists. A video of
249
+ the wreckage would help nobody.
250
+
169
251
  ```
170
252
  versioncam record [id…] drive the app and capture frames
171
253
  --draft 15fps at CSS scale: cheap, for writing clips
172
- versioncam render [id…] composite and encode what has been recorded
173
- --scene <id> render a designed scene (repeatable)
174
- --sequence stitch the results into one piece
175
- versioncam check [id…] record everything; non-zero if any clip breaks
254
+ versioncam check [id…] record every clip; exit 1 if any breaks
176
255
  versioncam stability [id…] record each clip twice the same way; stable or not
177
256
  --runs <n> how many times (default 2)
178
257
  --full at full quality rather than draft
258
+ ```
259
+
260
+ ### Review
261
+
262
+ Look at what a recording shows, and check what needs no eyes.
263
+
264
+ ```
179
265
  versioncam sheet <id> an eight-frame contact sheet
180
266
  --at-marks sample the beats instead of even spacing
181
267
  versioncam frame <id> <n> one frame at output size, to a png
182
268
  --t <seconds> pick the frame by time instead
183
269
  --at <label> or by the beat it shows
270
+ versioncam review <id> the checks that need no eyes; PASS/FAIL, exit 0/1
271
+ --beats <file> [{ label, expect }] the clip promised
272
+ --min <s> --max <s> duration bounds (default 9 and 11)
184
273
  versioncam preview <id> open the scrubber in a real browser
185
274
  --scene <id> preview a scene instead
186
275
  --frame <n> start at this frame
187
- versioncam review <id> the checks that need no eyes; PASS/FAIL, exit 0/1
188
- --beats <file> [{ label, expect }] the clip promised
189
- --min <s> --max <s> duration bounds (default 9–11)
190
- versioncam inspect <path> what a clip can click, and where each name came from
191
- --json machine-readable
192
- versioncam measure <path> <sel> where the matches of a selector are, and what they say
193
- versioncam dsl the clip DSL, every method, one line each
194
- versioncam login sign in once and save the browser session
195
- versioncam doctor check this machine can record, and that the app starts
196
- versioncam install the Chromium this version of versioncam records with
197
- --with-deps and its system libraries (Linux CI)
198
- versioncam init install the authoring skill into .claude/
199
- --into <dir> into <dir>/versioncam/ instead, for another host
200
- --force overwrite files that are already there
201
276
  ```
202
277
 
203
- `versioncam check` is the CI command. It does not render: a demo that no longer
204
- matches the app fails while being recorded, at the step that no longer exists,
205
- and encoding a video of the wreckage helps nobody.
278
+ ### Output
279
+
280
+ Turn recordings into video.
281
+
282
+ ```
283
+ versioncam render [id…] composite and encode what has been recorded
284
+ --scene <id> render a designed scene (repeatable)
285
+ --sequence stitch the results into one piece
286
+ ```
206
287
 
207
288
  ## What a recording leaves behind
208
289
 
209
- In `.versioncam/recordings/<id>/`: `timeline.json`, the `states/` it indexes, and
210
- `settles.json` — every wait, what it was still blocked on, and how long it
211
- really took. A clip that fails also leaves `failure.png`, the page at the
212
- moment it gave up, and `failure.json` with the message, the frame and the
213
- authored second.
214
-
215
- The timeline's `marks` are the clip's beats: one per labelled `settle()`, at
216
- the first frame that shows what the wait was for. `versioncam sheet --at-marks`
217
- samples those frames instead of eight even ones, which is the difference
218
- between a sheet that shows what the clip is about and one that happens to miss
219
- it.
220
-
221
- `versioncam review <id> --beats beats.json` is the half of reviewing a clip that
222
- needs no eyes, and no browser: is it the right length, did a wait give up, does
223
- every beat in the sheet have a mark, did the screen actually change between one
224
- beat and the next, and did the clip end on a camera move with no frames left to
225
- show it. It prints a line per clause and exits 0 or 1. What needs eyes — is
226
- this legible, is the cursor in the way, does the frame show what the beat
227
- claimed — is what the contact sheet and `versioncam frame` are for.
290
+ `versioncam record` writes each clip to `.versioncam/recordings/<id>/`.
291
+
292
+ | File | What it holds |
293
+ |---|---|
294
+ | `timeline.json` | Everything the renderer needs, and everything a reviewer can check. |
295
+ | `states/` | The page states the timeline indexes, one image each. |
296
+ | `settles.json` | Every wait: what it was still blocked on, and how long it really took. |
297
+ | `failure.png` | Only after a failure: the page at the moment the clip gave up. |
298
+ | `failure.json` | Only after a failure: the message, the frame and the authored second. |
299
+
300
+ The timeline's `frames` say which state each frame shows. Beside them are the
301
+ tracks the renderer draws: `cursor`, `pointer`, `camera`, `highlights` and
302
+ `captions`. Three more fields are there for reading:
303
+
304
+ - **`marks`** are the clip's beats: one per labelled `settle()`, at the first
305
+ frame that shows what the wait was for. `versioncam sheet --at-marks`
306
+ samples those frames instead of eight even ones. That is the difference
307
+ between a sheet that shows what the clip is about and one that happens to
308
+ miss it.
309
+ - **`steps`** are the script's calls, in order, each with the frame it began
310
+ on: `{ "frame": 212, "call": "typeInto" }`. There is one per line of the
311
+ script. `typeInto` is one step, not also the click it makes, and a locator
312
+ is an argument, not a step. A track write spends no time, so it shares a
313
+ frame with the line after it. New in 0.1.3.
314
+ - **`meta`** says where the recording came from: `title`, `appCommit`,
315
+ `baseUrl` and `recordedAt`. `recorder` is the versioncam version that made
316
+ it (new in 0.1.3). `captured` is how many frames got a screenshot, and
317
+ `draft` is set on a draft.
318
+
319
+ ### Checks that need no eyes
320
+
321
+ `versioncam review <id> --beats beats.json` is the half of reviewing a clip
322
+ that needs no eyes and no browser. It prints a line per check and exits 0 or 1:
323
+
324
+ - **duration**: the clip runs as long as `review` in the config says.
325
+ - **settles**: no wait gave up.
326
+ - **beats**: every beat in the beat sheet has a mark.
327
+ - **progress**: the screen changed between one beat and the next.
328
+ - **rendered**: nothing was written after the last frame, where no frame
329
+ would show it. A warning, not a failure.
330
+ - **onscreen**: every highlight is inside what the camera shows. Also a
331
+ warning.
332
+
333
+ What needs eyes is what the contact sheet and `versioncam frame` are for. Is
334
+ it legible? Is the cursor in the way? Does the frame show what the beat
335
+ claimed?
228
336
 
229
337
  ## How it works
230
338
 
231
- **Time is authored, not measured.** The script declares the timeline; the
232
- driver steps the app through it one output frame at a time, advancing the
233
- page's fake clock by exactly 1/fps and capturing. Anything slow — a query, a
234
- tile, a model — happens inside a `settle()` that advances the clock but
235
- captures nothing, so it never reaches the video.
339
+ Five decisions make a recording repeatable.
340
+
341
+ **Time is authored, not measured.** The script declares the timeline. The
342
+ driver steps the app through it one output frame at a time: it advances the
343
+ page's fake clock by exactly 1/fps, then captures. Anything slow, such as a
344
+ query, a map tile or a model, happens inside a `settle()`. A settle advances
345
+ the clock but captures nothing, so the wait never reaches the video.
236
346
 
237
347
  **The cursor is real input with a synthetic glyph.** Headless Chrome draws no
238
- pointer, so the driver moves the *real* mouse along a planned path — hover and
239
- press states genuinely fire in the captured frames — and records that path as
240
- data. The glyph, the press and the ripple are drawn afterwards at output
241
- resolution, which means the look can change without re-recording.
348
+ pointer. So the driver moves the *real* mouse along a planned path, and hover
349
+ and press states really fire in the captured frames. It records that path as
350
+ data. The glyph, the press and the ripple are drawn later at output
351
+ resolution, so the look can change without recording again.
242
352
 
243
353
  **Motion is modelled.** Minimum-jerk profiles, slight Bézier arcs, Fitts-law
244
- durations, a dwell before each click, all seeded — so it never looks
245
- metronomic and never differs between runs.
354
+ durations and a dwell before each click. All of it is seeded, so it never
355
+ looks metronomic and never differs between runs.
246
356
 
247
357
  **Rendering needs nothing running.** The page, the recording, the config and
248
- your scenes are served from an in-memory route table, so `versioncam render` works
249
- from a recording on disk and a browser.
358
+ your scenes are served from an in-memory route table. `versioncam render`
359
+ needs only a recording on disk and a browser.
250
360
 
251
- **Output follows the viewport.** Record at 1440×900 and the video is 16:10;
252
- want 16:9, record at 1600×900. Nothing is letterboxed or cropped. A backdrop,
253
- device frame and padding are available via `theme.presentation`, off by
254
- default.
361
+ **Output follows the viewport.** Record at 1440×900 and the video is 16:10.
362
+ For 16:9, record at 1600×900. Nothing is letterboxed or cropped. A backdrop, a
363
+ device frame and padding are available through `theme.presentation`, and are
364
+ off by default.
255
365
 
256
366
  ## Speed
257
367
 
258
- Writing a clip means running it, looking at it, and changing a line — so what
259
- matters is how long that loop takes.
368
+ Writing a clip is a loop: run it, look at it, change a line. What matters is
369
+ how long that loop takes.
260
370
 
261
371
  **Most frames are not photographed.** A clip is mostly frames in which nothing
262
- moved, and the screenshot is by far the most expensive thing a frame does: 25 ms
372
+ moved. The screenshot is by far the most expensive thing a frame does: 25 ms,
263
373
  against half a millisecond for everything else. So the driver takes one only
264
- when the page can have changed — the DOM mutated, the element under the cursor
265
- changed, an animation is running, input was just sent, or `forceCaptureEvery`
266
- frames have gone by. On the example app that skips 81% to 95% of frames, and
267
- the recording is identical either way. A test records every clip both ways on
268
- every push and compares the bytes, because the failure mode is not a crash but
269
- a video that is quietly wrong.
270
-
271
- The one thing it cannot see is a surface that redraws itself: a canvas, a WebGL
272
- view, a video. None of them touches the DOM. `capture.forceCaptureEvery`
273
- (default 30) is the floor under that; a clip whose subject *is* a canvas should
274
- set it to 1 and pay full price:
374
+ when the page can have changed. That is when the DOM mutated, the element
375
+ under the cursor changed, an animation is running, input was just sent, or
376
+ `forceCaptureEvery` frames have gone by. On the example app that skips 81% to
377
+ 95% of frames, and the recording is identical either way. A test records every
378
+ clip both ways on every push and compares the bytes. The failure it guards
379
+ against is not a crash but a video that is quietly wrong.
380
+
381
+ The one thing the gate cannot see is a surface that redraws itself: a canvas,
382
+ a WebGL view, a video. None of them touches the DOM. `forceCaptureEvery`
383
+ (default 30) is the floor under that. A clip whose subject *is* a canvas
384
+ should set it to 1 and pay full price:
275
385
 
276
386
  ```ts
277
387
  export default clip("03-the-map", { title: "The map", capture: { forceCaptureEvery: 1 } }, …);
278
388
  ```
279
389
 
280
390
  **`--draft` for the loop.** Fifteen frames a second, captured at CSS scale
281
- instead of device scale, into the same place a real recording goes.
391
+ instead of device scale, into the same place a full recording goes.
282
392
 
283
393
  ```bash
284
394
  npx versioncam record --draft 02-find-a-shipment # then render and look
285
395
  ```
286
396
 
287
- It deliberately does not change the page: same viewport, same device pixel
288
- ratio, same layout, same code. A draft that reduced the DPR could pass while
289
- the capture it stands in for fails on a media query. It does ignore a clip's
290
- own `fps`, because the point is to be cheap. Its duration can land up to 1/15 s
291
- from the full capture's, so a draft is for looking at, not for comparing
292
- against.
397
+ A draft does not change the page: same viewport, same device pixel ratio,
398
+ same layout, same code. A draft that lowered the DPR could pass while the
399
+ capture it stands in for fails on a media query. It does ignore a clip's own
400
+ `fps`, because the point is to be cheap. Its length can land up to 1/15 s from
401
+ the full capture's, so a draft is for looking at, not for comparing against.
293
402
 
294
- `versioncam record` prints where the time went, which is how you tell whether the
403
+ `versioncam record` prints where the time went. That tells you whether the
295
404
  next second would come from the frames or from somewhere else:
296
405
 
297
406
  ```
298
- 01-create-a-shipment: 425 frames, 63 states, 1 cuts — plays 7.1s · took 6.3s
407
+ 01-create-a-shipment: 425 frames, 63 states, 1 cut · plays 7.1s · took 6.3s
299
408
  open 0.0s · settles 0.5s · frames 5.7s (79 captured, 346 skipped) · write 0.0s
300
409
  ```
301
410
 
@@ -305,10 +414,11 @@ is why both are named.
305
414
 
306
415
  ## Authoring
307
416
 
308
- A clip is a script, and writing one is a loop: look at the page, write, record,
309
- look at what you got, fix. `versioncam` ships that loop as one agent skill.
310
- There is no API key and no SDK: the model is whichever agent session runs the
311
- skill, and it reads contact sheets with the same tool it reads files with.
417
+ A clip is a script, and writing one is a loop: look at the page, write,
418
+ record, look at what you got, fix. versioncam ships that loop as one agent
419
+ skill. There is no API key and no SDK. The model is whichever agent session
420
+ runs the skill, and it reads contact sheets with the same tool it reads files
421
+ with.
312
422
 
313
423
  ```bash
314
424
  npx versioncam init # .claude/skills/versioncam/ and .claude/agents/versioncam-reviewer.md
@@ -317,123 +427,130 @@ claude --plugin-dir node_modules/versioncam/plugin
317
427
  ```
318
428
 
319
429
  `init --into <dir>` puts the skill directory somewhere else, for a host that
320
- reads skills from elsewhere. Then:
430
+ reads skills from another place. Then ask for a clip:
321
431
 
322
432
  ```
323
433
  /versioncam "Find a shipment by filtering the list, and show how many matched"
324
434
  ```
325
435
 
326
- **The first run** — in a directory with no `versioncam.config.ts` — sets the
327
- repository up before anything else: how the app starts and where it is served,
328
- from its scripts and its framework's config; whether it signs in, which it
329
- asks you about; a config with a `webServer`, so nothing has to be running;
330
- `doctor` and `inspect` to prove the app starts and settles; and `stability`
331
- on a three-second clip, so no clip is written against an app that does not
332
- record the same way twice. It commits nothing.
436
+ **The first run** in a directory with no `versioncam.config.ts` sets the
437
+ repository up before anything else:
438
+
439
+ 1. It works out how the app starts and where it is served, from its scripts
440
+ and its framework's config.
441
+ 2. It asks you whether the app signs in, and how you want that handled.
442
+ 3. It writes a config with a `webServer`, so nothing has to be running.
443
+ 4. It runs `doctor` and `inspect` to prove the app starts and settles.
444
+ 5. It records a three-second clip twice with `stability`, so no clip is
445
+ written against an app that does not record the same way twice.
446
+
447
+ It commits nothing.
333
448
 
334
- **Every run.** The session inspects the start page and writes the clip itself:
335
- it reads `versioncam dsl` and your existing clips, writes a beat sheet, writes
449
+ **Every run** then goes the same way. The session inspects the start page and
450
+ reads `versioncam dsl` and your existing clips. It writes a beat sheet, then
336
451
  the clip, and records it in draft until `versioncam review` passes, three
337
- recordings at most. Then a contact sheet sampled *at the beats*, and a fresh
338
- reviewer every round — `versioncam-reviewer`, which sees the picture, the
339
- beats and a five-item checklist, and nothing of how the clip was written. A
340
- reviewer that has read the author's reasoning agrees with it. Its verdict comes
341
- back as fixes to make, and the round repeats; six rounds at most, then it stops
342
- and says why. On a host that cannot register the reviewer as an agent, the same
343
- file is a subagent's prompt, or — with no subagents at all — a checklist the
344
- session applies to its own sheet, and the verdict says which.
345
-
346
- **What you get**, under `.versioncam/author/<id>/`: the beat sheet, a
347
- `summary.json`, the final sheet, and per round what the author found, its
348
- beats and the reviewer's verdict. The clip itself goes in your clips
349
- directory, which is the part worth reading and committing.
452
+ recordings at most. Then comes a contact sheet sampled *at the beats*, and a
453
+ fresh reviewer.
454
+
455
+ **The reviewer** is `versioncam-reviewer`, new every round. It sees the
456
+ picture, the beats and a five-item checklist, and nothing of how the clip was
457
+ written: a reviewer that has read the author's reasoning agrees with it. Its
458
+ verdict comes back as fixes to make, and the round repeats. After six rounds
459
+ at most it stops and says why. On a host that cannot register the reviewer as
460
+ an agent, the same file is a subagent's prompt. On a host with no subagents at
461
+ all, it is a checklist the session applies to its own sheet, and the verdict
462
+ says which.
463
+
464
+ **What you get** is under `.versioncam/author/<id>/`: the beat sheet, a
465
+ `summary.json`, the final sheet, and for each round what the author found,
466
+ its beats and the reviewer's verdict. The clip itself goes in your clips
467
+ directory. That is the part worth reading and committing.
350
468
 
351
469
  **What it costs.** Six to twelve minutes and one or two rounds for a clip on
352
470
  the example app, most of it model latency rather than the recorder. It is not
353
- cheap and it is not instant; it is, however, unattended.
471
+ cheap and it is not instant. It is, however, unattended.
354
472
 
355
- **What it is not.** The skill cannot sign your app in for you — it asks how
356
- you want that handled, and `versioncam login` is yours to run because it waits
357
- for you. It does not run `git`. And `versioncam review` only checks what needs
358
- no eyes: duration, that every beat left a mark, that each beat changed the
359
- screen, that nothing was written after the last frame. Whether the clip is
360
- *good* is the reviewer's job, and after that, yours.
473
+ **What it is not.** The skill cannot sign your app in for you. It asks how you
474
+ want that handled, and `versioncam login` is yours to run because it waits
475
+ for you. It does not run `git`. And `versioncam review` only checks what
476
+ needs no eyes. Whether the clip is *good* is the reviewer's job, and after
477
+ that, yours.
361
478
 
362
479
  ## Determinism
363
480
 
364
481
  Two runs of a clip on one machine produce the same bytes, draft and full. The
365
482
  package's own tests assert it for capture and for rendering on every change,
366
- on macOS and on Linux, and a golden recording pins the capture path against
483
+ on macOS and on Linux. A golden recording pins the capture path against
367
484
  changes nobody meant to make.
368
485
 
369
- Across machines the claim is narrower, and worth stating precisely: the
370
- **timeline** is identical — it is what the driver decided, and nothing in it
371
- depends on the host. The captured **pixels** are identical only where the
372
- rendering environment is: macOS and Linux rasterise text differently, so the
373
- same page in the same state is the same picture and a different file.
374
- Byte-identity is a property of a fixed environment, which in CI means one
375
- runner image — the Playwright container for the version versioncam pins is
376
- the simplest (see *In CI*).
486
+ Across machines the claim is narrower. The **timeline** is identical: it is
487
+ what the driver decided, and nothing in it depends on the host. The captured
488
+ **pixels** are identical only where the rendering environment is. macOS and
489
+ Linux rasterise text differently, so the same page in the same state is the
490
+ same picture and a different file. Byte-identity belongs to a fixed
491
+ environment. In CI that means one runner image, and the Playwright container
492
+ for the version versioncam pins is the simplest (see *In CI*).
377
493
 
378
494
  If two recordings of your clip on one machine differ, something outside the
379
- authored clock reached a frame — an unwarmed resource, a real timer, a font
380
- that had not finished loading. Find the cause; the next section is how.
495
+ authored clock reached a frame: an unwarmed resource, a real timer, a font
496
+ that had not finished loading. Find the cause. The next section is how.
381
497
 
382
498
  ### When a clip will not reproduce
383
499
 
384
- The recorder can only be as deterministic as the app it is filming, and an app
385
- that is *nearly* deterministic is the hard case: it reproduces most runs, so the
500
+ The recorder can only be as deterministic as the app it films. An app that is
501
+ *nearly* deterministic is the hard case: it reproduces on most runs, so the
386
502
  first failure looks like a recorder bug.
387
503
 
388
- Before assuming it is one, record the clip twice the same way — which is
389
- what `stability` does, frame by frame, by what each frame shows:
504
+ Before assuming it is one, record the clip twice the same way. `stability`
505
+ does exactly that, and compares the runs frame by frame, by what each frame
506
+ shows:
390
507
 
391
508
  ```bash
392
509
  npx versioncam stability 02-find-a-shipment # draft; --full for the real thing
393
510
  ```
394
511
 
395
512
  ```
396
- 02-find-a-shipment: run 1 vs run 2 — 268 frames, 264 identical, first difference at frame 131
513
+ 02-find-a-shipment: run 1 vs run 2 · 268 frames, 264 identical, first difference at frame 131
397
514
  .versioncam/stability/02-find-a-shipment/frame-131-run1.png
398
515
  .versioncam/stability/02-find-a-shipment/frame-131-run2.png
399
- unstable — something on this page does not run on authored time
516
+ unstable: something on this page does not run on authored time
400
517
  ```
401
518
 
402
- The two files are the pictures behind the first frame that differs; what
403
- differs is usually obvious once they are side by side. Each run records beside
404
- the clip's own recording, under `recordings/__stability/`, never over it. With
405
- no id it checks every clip, and it exits 0 only when every pair matched —
406
- which makes it the first thing to run in a repository before writing a clip
407
- for it, and what the authoring skill does on a first run.
519
+ The two files are the pictures behind the first frame that differs. Side by
520
+ side, what differs is usually obvious. Each run records beside the clip's own
521
+ recording, under `recordings/__stability/`, never over it. With no id it
522
+ checks every clip, and it exits 0 only when every pair matched. That makes it
523
+ the first thing to run in a repository before writing a clip for it, and it
524
+ is what the authoring skill does on a first run.
408
525
 
409
526
  If the runs differ, something on the page did not run on authored time. Look
410
527
  at the app first, but not only there: of the four causes found so far, the
411
528
  recorder was two. What we have found doing this:
412
529
 
413
530
  - An **autosizing text field** that settles a frame later depending on how the
414
- browser scheduled the layout. The clips that typed diverged; the clips that
531
+ browser scheduled the layout. The clips that typed diverged. The clips that
415
532
  did not, did not.
416
- - A **spinner**, via the recorder's own fault, since fixed: the screenshot was
417
- cancelling the animation the driver had just scrubbed. Worth knowing because
418
- the symptom was six pixels in one frame of three hundred, which reads as
419
- noise rather than as a bug.
420
- - A **typed letter's edge**, via the recorder's own fault, since fixed: a
421
- draft's screenshot has Chromium paint the page again at CSS scale, and it
422
- repainted only the rectangle the page reported as changed, which at that
423
- scale could stop a pixel short of a new glyph's antialiased edge. Only on
424
- Linux, where text has LCD antialiasing: one pixel of one frame, in about half
425
- of all runs. Side by side the two pictures look identical; subtracting one
426
- from the other finds it.
427
- - A **timer firing early**, via the recorder's own fault, since fixed: the
428
- page's fake clock was installed but left running on real time between
429
- frames, so a slow screenshot reached the page as time. One stalled frame on
430
- a CI runner moved the clock 840 ms instead of 67 and the example app's
431
- shipment appeared seven frames early. The clock is paused now and moves only
432
- when the driver moves it.
433
-
434
- If the two recordings match and something else does not, the next question is
435
- what changed between them — the gate (`capture: { gate: false }` turns it off)
436
- or the machine.
533
+ - A **spinner**, the recorder's fault and since fixed. The screenshot was
534
+ cancelling the animation the driver had just scrubbed. The symptom was six
535
+ pixels in one frame of three hundred, which reads as noise rather than as a
536
+ bug.
537
+ - A **typed letter's edge**, the recorder's fault and since fixed. A draft's
538
+ screenshot has Chromium paint the page again at CSS scale. It repainted only
539
+ the rectangle the page reported as changed, which at that scale could stop
540
+ a pixel short of a new glyph's antialiased edge. This happened only on
541
+ Linux, where text has LCD antialiasing: one pixel of one frame, in about
542
+ half of all runs. Side by side the two pictures look identical; subtracting
543
+ one from the other finds it.
544
+ - A **timer firing early**, the recorder's fault and since fixed. The page's
545
+ fake clock was installed but left running on real time between frames, so a
546
+ slow screenshot reached the page as time. One stalled frame on a CI runner
547
+ moved the clock 840 ms instead of 67, and the example app's shipment
548
+ appeared seven frames early. The clock is paused now and moves only when
549
+ the driver moves it.
550
+
551
+ If the two recordings match and something else does not, ask what changed
552
+ between them: the gate (`capture: { gate: false }` turns it off), or the
553
+ machine.
437
554
 
438
555
  ## While recording
439
556
 
@@ -441,23 +558,46 @@ Do not edit files. A dev server pushes a full page reload to every connected
441
558
  client, including the page being recorded. The driver detects it and says so,
442
559
  but the run is lost either way.
443
560
 
444
- Record one clip at a time against a given app session. The CLI already does;
445
- the thing to avoid is leaving a browser tab open on the same session.
561
+ Record one clip at a time against a given app session. The CLI already does.
562
+ The thing to avoid is a browser tab left open on the same session.
563
+
564
+ ## Glossary
565
+
566
+ The words these pages use in a particular sense.
567
+
568
+ | Word | Meaning |
569
+ |---|---|
570
+ | authored time | The clip's own clock. The page's clock moves only when the driver moves it, one frame at a time. |
571
+ | beat | A moment the clip exists to show. A labelled `settle()` marks one. |
572
+ | beat sheet | The beats a clip promises, as `[{ label, expect }]`. `versioncam review --beats` checks a recording against it. |
573
+ | clip | A script, `*.clip.ts`, that drives the app and says what to show. One clip makes one video. |
574
+ | contact sheet | Eight frames of a recording in one image, for judging it at a glance. `versioncam sheet` makes one. |
575
+ | draft | A cheap recording for writing clips: 15 frames a second, captured at CSS scale. |
576
+ | gate | The check that skips a screenshot when nothing on the page can have changed. |
577
+ | mark | Where a beat first shows, in the timeline's `marks`. |
578
+ | recording | What `record` leaves in `.versioncam/recordings/<id>/`: the timeline and the page states it indexes. |
579
+ | render | Drawing the cursor, camera, highlights and captions over a recording, and encoding the video. |
580
+ | reviewer | The fresh agent that judges a contact sheet against the beats and a five-item checklist. |
581
+ | scene | A designed panel: frames drawn in code rather than captured from the app. |
582
+ | settle | A wait that advances the page's clock but captures nothing, so it never reaches the video. |
583
+ | stable | Two recordings of a clip, made the same way on one machine, are identical. |
584
+ | step | One line of the script, as the timeline's `steps` records it. |
585
+ | timeline | `timeline.json`: which state each frame shows, and every track drawn over it. |
586
+ | track write | A call to `camera`, `highlight` or `caption`. It writes at the current instant and spends no time. |
446
587
 
447
588
  ## Help
448
589
 
449
- This README is also the documentation at [version.cam](https://version.cam).
450
590
  Bugs, questions, and apps it cannot record go to the issue tracker at
451
- [github.com/petbul/versioncam-home](https://github.com/petbul/versioncam-home/issues)
452
- — a clip that will not reproduce is easiest to help with alongside the two
591
+ [github.com/petbul/versioncam-home](https://github.com/petbul/versioncam-home/issues).
592
+ A clip that will not reproduce is easiest to help with alongside the two
453
593
  pictures `versioncam stability` saves.
454
594
 
455
595
  ## Licence
456
596
 
457
597
  Versioncam is under the Functional Source License, version 1.1, with Apache
458
- 2.0 as its future licence (`FSL-1.1-ALv2`). Use it on your own apps and in your
459
- own CI, commercial work included, and change it as you need; what the licence
460
- does not allow is making it available to others in a product or service that
461
- competes with it. Two years after each version is released, that version is
462
- also available under Apache 2.0. This is a summary; `LICENSE.md` is the
463
- licence.
598
+ 2.0 as its future licence (`FSL-1.1-ALv2`). Use it on your own apps and in
599
+ your own CI, commercial work included, and change it as you need. What the
600
+ licence does not allow is making it available to others in a product or
601
+ service that competes with it. Two years after each version is released, that
602
+ version is also available under Apache 2.0. This is a summary; `LICENSE.md`
603
+ is the licence.