versioncam 0.1.1

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 (182) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/LICENSE.md +105 -0
  3. package/README.md +463 -0
  4. package/bin/versioncam.js +29 -0
  5. package/dist/.types-render/render-page/draw.d.ts +28 -0
  6. package/dist/.types-render/render-page/main.d.ts +24 -0
  7. package/dist/.types-render/render-page/theme.d.ts +37 -0
  8. package/dist/app-server.d.ts +59 -0
  9. package/dist/app-server.js +328 -0
  10. package/dist/app-server.js.map +1 -0
  11. package/dist/cli/app.d.ts +13 -0
  12. package/dist/cli/app.js +21 -0
  13. package/dist/cli/app.js.map +1 -0
  14. package/dist/cli/commands/check.d.ts +8 -0
  15. package/dist/cli/commands/check.js +51 -0
  16. package/dist/cli/commands/check.js.map +1 -0
  17. package/dist/cli/commands/doctor.d.ts +8 -0
  18. package/dist/cli/commands/doctor.js +130 -0
  19. package/dist/cli/commands/doctor.js.map +1 -0
  20. package/dist/cli/commands/dsl.d.ts +16 -0
  21. package/dist/cli/commands/dsl.js +22 -0
  22. package/dist/cli/commands/dsl.js.map +1 -0
  23. package/dist/cli/commands/frame.d.ts +8 -0
  24. package/dist/cli/commands/frame.js +72 -0
  25. package/dist/cli/commands/frame.js.map +1 -0
  26. package/dist/cli/commands/init.d.ts +23 -0
  27. package/dist/cli/commands/init.js +109 -0
  28. package/dist/cli/commands/init.js.map +1 -0
  29. package/dist/cli/commands/inspect.d.ts +1 -0
  30. package/dist/cli/commands/inspect.js +32 -0
  31. package/dist/cli/commands/inspect.js.map +1 -0
  32. package/dist/cli/commands/install.d.ts +33 -0
  33. package/dist/cli/commands/install.js +66 -0
  34. package/dist/cli/commands/install.js.map +1 -0
  35. package/dist/cli/commands/login.d.ts +10 -0
  36. package/dist/cli/commands/login.js +49 -0
  37. package/dist/cli/commands/login.js.map +1 -0
  38. package/dist/cli/commands/measure.d.ts +1 -0
  39. package/dist/cli/commands/measure.js +36 -0
  40. package/dist/cli/commands/measure.js.map +1 -0
  41. package/dist/cli/commands/open-app.d.ts +14 -0
  42. package/dist/cli/commands/open-app.js +45 -0
  43. package/dist/cli/commands/open-app.js.map +1 -0
  44. package/dist/cli/commands/preview.d.ts +8 -0
  45. package/dist/cli/commands/preview.js +55 -0
  46. package/dist/cli/commands/preview.js.map +1 -0
  47. package/dist/cli/commands/record.d.ts +10 -0
  48. package/dist/cli/commands/record.js +86 -0
  49. package/dist/cli/commands/record.js.map +1 -0
  50. package/dist/cli/commands/render.d.ts +1 -0
  51. package/dist/cli/commands/render.js +93 -0
  52. package/dist/cli/commands/render.js.map +1 -0
  53. package/dist/cli/commands/review.d.ts +6 -0
  54. package/dist/cli/commands/review.js +89 -0
  55. package/dist/cli/commands/review.js.map +1 -0
  56. package/dist/cli/commands/sheet.d.ts +1 -0
  57. package/dist/cli/commands/sheet.js +48 -0
  58. package/dist/cli/commands/sheet.js.map +1 -0
  59. package/dist/cli/commands/stability.d.ts +14 -0
  60. package/dist/cli/commands/stability.js +110 -0
  61. package/dist/cli/commands/stability.js.map +1 -0
  62. package/dist/cli/main.d.ts +2 -0
  63. package/dist/cli/main.js +69 -0
  64. package/dist/cli/main.js.map +1 -0
  65. package/dist/cli/usage.d.ts +10 -0
  66. package/dist/cli/usage.js +46 -0
  67. package/dist/cli/usage.js.map +1 -0
  68. package/dist/config.d.ts +250 -0
  69. package/dist/config.js +154 -0
  70. package/dist/config.js.map +1 -0
  71. package/dist/core/camera.d.ts +30 -0
  72. package/dist/core/camera.js +94 -0
  73. package/dist/core/camera.js.map +1 -0
  74. package/dist/core/compose.d.ts +38 -0
  75. package/dist/core/compose.js +81 -0
  76. package/dist/core/compose.js.map +1 -0
  77. package/dist/core/cursor.d.ts +38 -0
  78. package/dist/core/cursor.js +103 -0
  79. package/dist/core/cursor.js.map +1 -0
  80. package/dist/core/easing.d.ts +15 -0
  81. package/dist/core/easing.js +33 -0
  82. package/dist/core/easing.js.map +1 -0
  83. package/dist/core/loop.d.ts +30 -0
  84. package/dist/core/loop.js +96 -0
  85. package/dist/core/loop.js.map +1 -0
  86. package/dist/core/motion-defaults.d.ts +61 -0
  87. package/dist/core/motion-defaults.js +62 -0
  88. package/dist/core/motion-defaults.js.map +1 -0
  89. package/dist/core/rng.d.ts +13 -0
  90. package/dist/core/rng.js +27 -0
  91. package/dist/core/rng.js.map +1 -0
  92. package/dist/core/sse.d.ts +15 -0
  93. package/dist/core/sse.js +16 -0
  94. package/dist/core/sse.js.map +1 -0
  95. package/dist/core/timeline.d.ts +146 -0
  96. package/dist/core/timeline.js +81 -0
  97. package/dist/core/timeline.js.map +1 -0
  98. package/dist/core/timing.d.ts +31 -0
  99. package/dist/core/timing.js +29 -0
  100. package/dist/core/timing.js.map +1 -0
  101. package/dist/core/typing.d.ts +12 -0
  102. package/dist/core/typing.js +35 -0
  103. package/dist/core/typing.js.map +1 -0
  104. package/dist/driver/clip.d.ts +71 -0
  105. package/dist/driver/clip.js +120 -0
  106. package/dist/driver/clip.js.map +1 -0
  107. package/dist/driver/compare.d.ts +34 -0
  108. package/dist/driver/compare.js +40 -0
  109. package/dist/driver/compare.js.map +1 -0
  110. package/dist/driver/gate.d.ts +36 -0
  111. package/dist/driver/gate.js +27 -0
  112. package/dist/driver/gate.js.map +1 -0
  113. package/dist/driver/launch.d.ts +43 -0
  114. package/dist/driver/launch.js +47 -0
  115. package/dist/driver/launch.js.map +1 -0
  116. package/dist/driver/page-hooks.d.ts +72 -0
  117. package/dist/driver/page-hooks.js +129 -0
  118. package/dist/driver/page-hooks.js.map +1 -0
  119. package/dist/driver/reports.d.ts +34 -0
  120. package/dist/driver/reports.js +42 -0
  121. package/dist/driver/reports.js.map +1 -0
  122. package/dist/driver/session.d.ts +285 -0
  123. package/dist/driver/session.js +773 -0
  124. package/dist/driver/session.js.map +1 -0
  125. package/dist/driver/settle.d.ts +41 -0
  126. package/dist/driver/settle.js +82 -0
  127. package/dist/driver/settle.js.map +1 -0
  128. package/dist/env.d.ts +11 -0
  129. package/dist/env.js +41 -0
  130. package/dist/env.js.map +1 -0
  131. package/dist/fixtures.d.ts +13 -0
  132. package/dist/fixtures.js +13 -0
  133. package/dist/fixtures.js.map +1 -0
  134. package/dist/index.d.ts +30 -0
  135. package/dist/index.js +16 -0
  136. package/dist/index.js.map +1 -0
  137. package/dist/inspect/inspect.d.ts +70 -0
  138. package/dist/inspect/inspect.js +176 -0
  139. package/dist/inspect/inspect.js.map +1 -0
  140. package/dist/inspect/measure.d.ts +40 -0
  141. package/dist/inspect/measure.js +107 -0
  142. package/dist/inspect/measure.js.map +1 -0
  143. package/dist/loader.d.ts +28 -0
  144. package/dist/loader.js +143 -0
  145. package/dist/loader.js.map +1 -0
  146. package/dist/page/assets/index-DUom5amc.js +1 -0
  147. package/dist/page/index.html +18 -0
  148. package/dist/render/encode.d.ts +40 -0
  149. package/dist/render/encode.js +183 -0
  150. package/dist/render/encode.js.map +1 -0
  151. package/dist/render/ffmpeg.d.ts +13 -0
  152. package/dist/render/ffmpeg.js +72 -0
  153. package/dist/render/ffmpeg.js.map +1 -0
  154. package/dist/render/presentation.d.ts +19 -0
  155. package/dist/render/presentation.js +27 -0
  156. package/dist/render/presentation.js.map +1 -0
  157. package/dist/render/render.d.ts +59 -0
  158. package/dist/render/render.js +144 -0
  159. package/dist/render/render.js.map +1 -0
  160. package/dist/render/sampling.d.ts +47 -0
  161. package/dist/render/sampling.js +129 -0
  162. package/dist/render/sampling.js.map +1 -0
  163. package/dist/render/sequence.d.ts +24 -0
  164. package/dist/render/sequence.js +105 -0
  165. package/dist/render/sequence.js.map +1 -0
  166. package/dist/render/serve.d.ts +35 -0
  167. package/dist/render/serve.js +124 -0
  168. package/dist/render/serve.js.map +1 -0
  169. package/dist/review/review.d.ts +54 -0
  170. package/dist/review/review.js +229 -0
  171. package/dist/review/review.js.map +1 -0
  172. package/dist/scene.d.ts +23 -0
  173. package/dist/scene.js +2 -0
  174. package/dist/scene.js.map +1 -0
  175. package/dsl.md +119 -0
  176. package/package.json +76 -0
  177. package/plugin/.claude-plugin/plugin.json +9 -0
  178. package/plugin/README.md +105 -0
  179. package/plugin/agents/versioncam-reviewer.md +63 -0
  180. package/plugin/skills/versioncam/SKILL.md +235 -0
  181. package/plugin/skills/versioncam/authoring.md +226 -0
  182. package/plugin/skills/versioncam/onboarding.md +199 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,94 @@
1
+ # Changelog
2
+
3
+ What a user of `versioncam` would want to know about each version, newest
4
+ first. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
5
+ and versions follow [semantic versioning](https://semver.org/): while the
6
+ major version is 0, a minor version may change the config or the clip DSL,
7
+ and its entry here says how.
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [0.1.1] — 2026-09-28
12
+
13
+ The first version published by the release workflow rather than by hand.
14
+
15
+ ### Changed
16
+
17
+ - Where there is no `versioncam.config.ts`, `doctor` and every other command
18
+ say how to get one: `npx versioncam init`, then `/versioncam`, whose first
19
+ run writes it from the repository — or by hand, a default export from
20
+ `defineRecorder()`. They used to offer only the second, which is the long
21
+ way round for anyone using the skill.
22
+ - The skill's plugin manifest links to version.cam, and no longer to a
23
+ repository nobody outside can open.
24
+
25
+ ## [0.1.0] — 2026-09-25
26
+
27
+ The first published version. It was built in work packages before it had a
28
+ version number, so this entry is grouped by them, newest first.
29
+
30
+ ### Publishing
31
+
32
+ - Licensed under the Functional Source License 1.1 with Apache 2.0 as the
33
+ future licence (`FSL-1.1-ALv2`): use it on your own apps and in your own
34
+ CI, commercial work included; no competing product or service; each
35
+ version is also available under Apache 2.0 two years after its release.
36
+ - `versioncam install` installs the Chromium that this version's Playwright
37
+ expects — `--with-deps` adds its system libraries on Linux CI — and
38
+ `doctor` names it when the browser is missing.
39
+
40
+ ### Reproducible on Linux
41
+
42
+ - A draft recording reproduces on Linux. Chromium now repaints whole tiles
43
+ (`--disable-partial-raster`): at a draft's CSS scale, repainting only what
44
+ the page reported as changed had left one pixel of a just-typed letter
45
+ stale in about half of all runs on a CI runner. Full recordings are
46
+ byte-identical with and without it.
47
+ - The page's clock moves only when the recorder moves it — 1/fps a frame,
48
+ 16 ms a tick while it waits — instead of running on real time between
49
+ frames, where one slow screenshot had moved it 840 ms and fired an app's
50
+ timer seven frames early. `offCamera` spends no time on the page's clock;
51
+ a sign-in hook runs with the clock ticking; a target that is not yet on
52
+ screen is waited for in ticks, so it arrives on the same tick every run.
53
+
54
+ ### Versioncam — 2026-09-24
55
+
56
+ - One name for everything: the package and CLI `versioncam`, the config
57
+ `versioncam.config.ts`, the output directory `.versioncam/`, the skill
58
+ `/versioncam`.
59
+ - `webServer` in the config, Playwright's fields: every command that drives
60
+ the app starts it when nothing answers, and stops everything it started.
61
+ - `versioncam stability` records a clip twice the same way and says whether
62
+ the app reproduces, with the two pictures behind the first frame that
63
+ differs. `frame --at <label>` renders the frame a beat shows; `inspect`
64
+ says where each name came from; `measure` prints what each match says.
65
+ - One skill and one reviewer file, installed by `versioncam init`; the
66
+ skill's first run in a repository writes the config and proves the app
67
+ records the same way twice before it writes a clip.
68
+
69
+ ### The authoring loop — 2026-09-22
70
+
71
+ - The agent skill: inspect the app, write a clip, record it, have a fresh
72
+ reviewer judge a contact sheet, fix what it finds. No API key; the model
73
+ is whichever agent session runs it.
74
+ - `versioncam review` — the checks that need no eyes; `sheet --at-marks`,
75
+ sampled at the beats; recordings that describe their beats (`marks`) and
76
+ their waits (`settles.json`); `failure.png` and `failure.json` when a clip
77
+ breaks; `s.drag`; `cursorStart`; review bounds on a clip's length that the
78
+ app sets.
79
+
80
+ ### Speed — 2026-09-21
81
+
82
+ - Capture on change: a frame is photographed only when the page can have
83
+ changed, which skips 81–95 % of frames on the example app and leaves the
84
+ recording identical.
85
+ - `--draft`: fifteen frames a second at CSS scale, for looking at while
86
+ writing a clip.
87
+ - `record` prints where the time went.
88
+
89
+ ### The package — 2026-09-21
90
+
91
+ - The recorder, extracted from the prototype it grew up in: a config
92
+ boundary (`defineRecorder`), the clip DSL, a renderer with presentation as
93
+ an optional layer, the CLI, and an example app whose CI records its clips
94
+ on every push.
package/LICENSE.md ADDED
@@ -0,0 +1,105 @@
1
+ # Functional Source License, Version 1.1, ALv2 Future License
2
+
3
+ ## Abbreviation
4
+
5
+ FSL-1.1-ALv2
6
+
7
+ ## Notice
8
+
9
+ Copyright 2026 Peter Bulovec
10
+
11
+ ## Terms and Conditions
12
+
13
+ ### Licensor ("We")
14
+
15
+ The party offering the Software under these Terms and Conditions.
16
+
17
+ ### The Software
18
+
19
+ The "Software" is each version of the software that we make available under
20
+ these Terms and Conditions, as indicated by our inclusion of these Terms and
21
+ Conditions with the Software.
22
+
23
+ ### License Grant
24
+
25
+ Subject to your compliance with this License Grant and the Patents,
26
+ Redistribution and Trademark clauses below, we hereby grant you the right to
27
+ use, copy, modify, create derivative works, publicly perform, publicly display
28
+ and redistribute the Software for any Permitted Purpose identified below.
29
+
30
+ ### Permitted Purpose
31
+
32
+ A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
33
+ means making the Software available to others in a commercial product or
34
+ service that:
35
+
36
+ 1. substitutes for the Software;
37
+
38
+ 2. substitutes for any other product or service we offer using the Software
39
+ that exists as of the date we make the Software available; or
40
+
41
+ 3. offers the same or substantially similar functionality as the Software.
42
+
43
+ Permitted Purposes specifically include using the Software:
44
+
45
+ 1. for your internal use and access;
46
+
47
+ 2. for non-commercial education;
48
+
49
+ 3. for non-commercial research; and
50
+
51
+ 4. in connection with professional services that you provide to a licensee
52
+ using the Software in accordance with these Terms and Conditions.
53
+
54
+ ### Patents
55
+
56
+ To the extent your use for a Permitted Purpose would necessarily infringe our
57
+ patents, the license grant above includes a license under our patents. If you
58
+ make a claim against any party that the Software infringes or contributes to
59
+ the infringement of any patent, then your patent license to the Software ends
60
+ immediately.
61
+
62
+ ### Redistribution
63
+
64
+ The Terms and Conditions apply to all copies, modifications and derivatives of
65
+ the Software.
66
+
67
+ If you redistribute any copies, modifications or derivatives of the Software,
68
+ you must include a copy of or a link to these Terms and Conditions and not
69
+ remove any copyright notices provided in or with the Software.
70
+
71
+ ### Disclaimer
72
+
73
+ THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
74
+ IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
75
+ PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
76
+
77
+ IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
78
+ SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
79
+ EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
80
+
81
+ ### Trademarks
82
+
83
+ Except for displaying the License Details and identifying us as the origin of
84
+ the Software, you have no right under these Terms and Conditions to use our
85
+ trademarks, trade names, service marks or product names.
86
+
87
+ ## Grant of Future License
88
+
89
+ We hereby irrevocably grant you an additional license to use the Software under
90
+ the Apache License, Version 2.0 that is effective on the second anniversary of
91
+ the date we make the Software available. On or after that date, you may use the
92
+ Software under the Apache License, Version 2.0, in which case the following
93
+ will apply:
94
+
95
+ Licensed under the Apache License, Version 2.0 (the "License"); you may not use
96
+ this file except in compliance with the License.
97
+
98
+ You may obtain a copy of the License at
99
+
100
+ http://www.apache.org/licenses/LICENSE-2.0
101
+
102
+ Unless required by applicable law or agreed to in writing, software distributed
103
+ under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
104
+ CONDITIONS OF ANY KIND, either express or implied. See the License for the
105
+ specific language governing permissions and limitations under the License.
package/README.md ADDED
@@ -0,0 +1,463 @@
1
+ # versioncam
2
+
3
+ Record demo clips of a real web app: scripted, deterministic, regenerated on
4
+ deploy.
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.
11
+
12
+ ## Install
13
+
14
+ Three commands, then a sentence:
15
+
16
+ ```bash
17
+ npm i -D versioncam
18
+ npx versioncam install # the Chromium this version records with
19
+ npx versioncam init # installs the authoring skill into .claude/
20
+ ```
21
+
22
+ ```
23
+ /versioncam "Show a user filtering the list down to one shipment"
24
+ ```
25
+
26
+ The first run of the skill sets the repository up: it writes
27
+ `versioncam.config.ts` from what it finds, proves the app starts and records
28
+ the same way twice, and only then writes the clip. See *Authoring*.
29
+
30
+ `npx versioncam doctor` checks the rest — Node 22 or later, Chromium, ffmpeg,
31
+ 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.
35
+
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.
40
+
41
+ ### In CI
42
+
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:
45
+
46
+ ```yaml
47
+ - uses: actions/setup-node@v4
48
+ with:
49
+ node-version: 22
50
+ - run: npm ci
51
+ - run: npx versioncam install --with-deps
52
+ - run: npx versioncam check
53
+ ```
54
+
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.
61
+
62
+ ## Configure
63
+
64
+ `versioncam.config.ts`, beside your clips:
65
+
66
+ ```ts
67
+ import { defineRecorder } from "versioncam";
68
+
69
+ export default defineRecorder({
70
+ baseUrl: process.env.DEMOS_BASE_URL ?? "http://localhost:5173",
71
+ viewport: { width: 1440, height: 900 },
72
+ clips: "demos/clips/**/*.clip.ts",
73
+
74
+ // How to start the app when nothing is serving it.
75
+ webServer: { command: "npm run dev" },
76
+
77
+ // Either a saved browser session…
78
+ auth: { storageState: ".versioncam/auth.json" },
79
+ // …or a hook, if signing in needs steps.
80
+ // auth: { login: async (page) => { … } },
81
+
82
+ // What "the page is done" means beyond a quiet DOM and an idle network.
83
+ settle: {
84
+ ready: async (page) => page.evaluate(() => window.__map?.loaded() ?? true),
85
+ },
86
+ });
87
+ ```
88
+
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.
112
+
113
+ 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.
116
+
117
+ ## Write a clip
118
+
119
+ ```ts
120
+ import { clip } from "versioncam";
121
+
122
+ export default clip("02-find-a-shipment", { title: "Find a shipment" }, async (s) => {
123
+ await s.open("/");
124
+ s.caption("Find a shipment");
125
+ await s.hold(500);
126
+
127
+ await s.camera.zoom(s.byTestId("shipments"), 1.35);
128
+ await s.typeInto(s.byTestId("filter"), "berg");
129
+ await s.settle({ label: "filtered" });
130
+
131
+ await s.highlight(s.byTestId("list-status"), "ring", { for: 1500 });
132
+ await s.hold(1600);
133
+ });
134
+ ```
135
+
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.
140
+
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
144
+ change.
145
+
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.
152
+
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:
155
+
156
+ ```bash
157
+ npx versioncam inspect /shipments # what is on the page and what it is called
158
+ npx versioncam measure / '.marker' # where the matches are, as fractions
159
+ ```
160
+
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.
166
+
167
+ ## Commands
168
+
169
+ ```
170
+ versioncam record [id…] drive the app and capture frames
171
+ --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
176
+ versioncam stability [id…] record each clip twice the same way; stable or not
177
+ --runs <n> how many times (default 2)
178
+ --full at full quality rather than draft
179
+ versioncam sheet <id> an eight-frame contact sheet
180
+ --at-marks sample the beats instead of even spacing
181
+ versioncam frame <id> <n> one frame at output size, to a png
182
+ --t <seconds> pick the frame by time instead
183
+ --at <label> or by the beat it shows
184
+ versioncam preview <id> open the scrubber in a real browser
185
+ --scene <id> preview a scene instead
186
+ --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
+ ```
202
+
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.
206
+
207
+ ## What a recording leaves behind
208
+
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.
228
+
229
+ ## How it works
230
+
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.
236
+
237
+ **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.
242
+
243
+ **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.
246
+
247
+ **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.
250
+
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.
255
+
256
+ ## Speed
257
+
258
+ Writing a clip means running it, looking at it, and changing a line — so what
259
+ matters is how long that loop takes.
260
+
261
+ **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
263
+ 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:
275
+
276
+ ```ts
277
+ export default clip("03-the-map", { title: "The map", capture: { forceCaptureEvery: 1 } }, …);
278
+ ```
279
+
280
+ **`--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.
282
+
283
+ ```bash
284
+ npx versioncam record --draft 02-find-a-shipment # then render and look
285
+ ```
286
+
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.
293
+
294
+ `versioncam record` prints where the time went, which is how you tell whether the
295
+ next second would come from the frames or from somewhere else:
296
+
297
+ ```
298
+ 01-create-a-shipment: 425 frames, 63 states, 1 cuts — plays 7.1s · took 6.3s
299
+ open 0.0s · settles 0.5s · frames 5.7s (79 captured, 346 skipped) · write 0.0s
300
+ ```
301
+
302
+ `plays` is the clip's own length and `took` is what the machine spent making
303
+ it. They move in opposite directions when a clip gets slower to record, which
304
+ is why both are named.
305
+
306
+ ## Authoring
307
+
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.
312
+
313
+ ```bash
314
+ npx versioncam init # .claude/skills/versioncam/ and .claude/agents/versioncam-reviewer.md
315
+ # or, for Claude Code without copying anything:
316
+ claude --plugin-dir node_modules/versioncam/plugin
317
+ ```
318
+
319
+ `init --into <dir>` puts the skill directory somewhere else, for a host that
320
+ reads skills from elsewhere. Then:
321
+
322
+ ```
323
+ /versioncam "Find a shipment by filtering the list, and show how many matched"
324
+ ```
325
+
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.
333
+
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
336
+ 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.
350
+
351
+ **What it costs.** Six to twelve minutes and one or two rounds for a clip on
352
+ 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.
354
+
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.
361
+
362
+ ## Determinism
363
+
364
+ Two runs of a clip on one machine produce the same bytes, draft and full. The
365
+ 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
367
+ changes nobody meant to make.
368
+
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*).
377
+
378
+ 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.
381
+
382
+ ### When a clip will not reproduce
383
+
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
386
+ first failure looks like a recorder bug.
387
+
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:
390
+
391
+ ```bash
392
+ npx versioncam stability 02-find-a-shipment # draft; --full for the real thing
393
+ ```
394
+
395
+ ```
396
+ 02-find-a-shipment: run 1 vs run 2 — 268 frames, 264 identical, first difference at frame 131
397
+ .versioncam/stability/02-find-a-shipment/frame-131-run1.png
398
+ .versioncam/stability/02-find-a-shipment/frame-131-run2.png
399
+ unstable — something on this page does not run on authored time
400
+ ```
401
+
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.
408
+
409
+ If the runs differ, something on the page did not run on authored time. Look
410
+ at the app first, but not only there: of the four causes found so far, the
411
+ recorder was two. What we have found doing this:
412
+
413
+ - 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
415
+ 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.
437
+
438
+ ## While recording
439
+
440
+ Do not edit files. A dev server pushes a full page reload to every connected
441
+ client, including the page being recorded. The driver detects it and says so,
442
+ but the run is lost either way.
443
+
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.
446
+
447
+ ## Help
448
+
449
+ This README is also the documentation at [version.cam](https://version.cam).
450
+ 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
453
+ pictures `versioncam stability` saves.
454
+
455
+ ## Licence
456
+
457
+ 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.