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.
- package/CHANGELOG.md +94 -0
- package/LICENSE.md +105 -0
- package/README.md +463 -0
- package/bin/versioncam.js +29 -0
- package/dist/.types-render/render-page/draw.d.ts +28 -0
- package/dist/.types-render/render-page/main.d.ts +24 -0
- package/dist/.types-render/render-page/theme.d.ts +37 -0
- package/dist/app-server.d.ts +59 -0
- package/dist/app-server.js +328 -0
- package/dist/app-server.js.map +1 -0
- package/dist/cli/app.d.ts +13 -0
- package/dist/cli/app.js +21 -0
- package/dist/cli/app.js.map +1 -0
- package/dist/cli/commands/check.d.ts +8 -0
- package/dist/cli/commands/check.js +51 -0
- package/dist/cli/commands/check.js.map +1 -0
- package/dist/cli/commands/doctor.d.ts +8 -0
- package/dist/cli/commands/doctor.js +130 -0
- package/dist/cli/commands/doctor.js.map +1 -0
- package/dist/cli/commands/dsl.d.ts +16 -0
- package/dist/cli/commands/dsl.js +22 -0
- package/dist/cli/commands/dsl.js.map +1 -0
- package/dist/cli/commands/frame.d.ts +8 -0
- package/dist/cli/commands/frame.js +72 -0
- package/dist/cli/commands/frame.js.map +1 -0
- package/dist/cli/commands/init.d.ts +23 -0
- package/dist/cli/commands/init.js +109 -0
- package/dist/cli/commands/init.js.map +1 -0
- package/dist/cli/commands/inspect.d.ts +1 -0
- package/dist/cli/commands/inspect.js +32 -0
- package/dist/cli/commands/inspect.js.map +1 -0
- package/dist/cli/commands/install.d.ts +33 -0
- package/dist/cli/commands/install.js +66 -0
- package/dist/cli/commands/install.js.map +1 -0
- package/dist/cli/commands/login.d.ts +10 -0
- package/dist/cli/commands/login.js +49 -0
- package/dist/cli/commands/login.js.map +1 -0
- package/dist/cli/commands/measure.d.ts +1 -0
- package/dist/cli/commands/measure.js +36 -0
- package/dist/cli/commands/measure.js.map +1 -0
- package/dist/cli/commands/open-app.d.ts +14 -0
- package/dist/cli/commands/open-app.js +45 -0
- package/dist/cli/commands/open-app.js.map +1 -0
- package/dist/cli/commands/preview.d.ts +8 -0
- package/dist/cli/commands/preview.js +55 -0
- package/dist/cli/commands/preview.js.map +1 -0
- package/dist/cli/commands/record.d.ts +10 -0
- package/dist/cli/commands/record.js +86 -0
- package/dist/cli/commands/record.js.map +1 -0
- package/dist/cli/commands/render.d.ts +1 -0
- package/dist/cli/commands/render.js +93 -0
- package/dist/cli/commands/render.js.map +1 -0
- package/dist/cli/commands/review.d.ts +6 -0
- package/dist/cli/commands/review.js +89 -0
- package/dist/cli/commands/review.js.map +1 -0
- package/dist/cli/commands/sheet.d.ts +1 -0
- package/dist/cli/commands/sheet.js +48 -0
- package/dist/cli/commands/sheet.js.map +1 -0
- package/dist/cli/commands/stability.d.ts +14 -0
- package/dist/cli/commands/stability.js +110 -0
- package/dist/cli/commands/stability.js.map +1 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +69 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/usage.d.ts +10 -0
- package/dist/cli/usage.js +46 -0
- package/dist/cli/usage.js.map +1 -0
- package/dist/config.d.ts +250 -0
- package/dist/config.js +154 -0
- package/dist/config.js.map +1 -0
- package/dist/core/camera.d.ts +30 -0
- package/dist/core/camera.js +94 -0
- package/dist/core/camera.js.map +1 -0
- package/dist/core/compose.d.ts +38 -0
- package/dist/core/compose.js +81 -0
- package/dist/core/compose.js.map +1 -0
- package/dist/core/cursor.d.ts +38 -0
- package/dist/core/cursor.js +103 -0
- package/dist/core/cursor.js.map +1 -0
- package/dist/core/easing.d.ts +15 -0
- package/dist/core/easing.js +33 -0
- package/dist/core/easing.js.map +1 -0
- package/dist/core/loop.d.ts +30 -0
- package/dist/core/loop.js +96 -0
- package/dist/core/loop.js.map +1 -0
- package/dist/core/motion-defaults.d.ts +61 -0
- package/dist/core/motion-defaults.js +62 -0
- package/dist/core/motion-defaults.js.map +1 -0
- package/dist/core/rng.d.ts +13 -0
- package/dist/core/rng.js +27 -0
- package/dist/core/rng.js.map +1 -0
- package/dist/core/sse.d.ts +15 -0
- package/dist/core/sse.js +16 -0
- package/dist/core/sse.js.map +1 -0
- package/dist/core/timeline.d.ts +146 -0
- package/dist/core/timeline.js +81 -0
- package/dist/core/timeline.js.map +1 -0
- package/dist/core/timing.d.ts +31 -0
- package/dist/core/timing.js +29 -0
- package/dist/core/timing.js.map +1 -0
- package/dist/core/typing.d.ts +12 -0
- package/dist/core/typing.js +35 -0
- package/dist/core/typing.js.map +1 -0
- package/dist/driver/clip.d.ts +71 -0
- package/dist/driver/clip.js +120 -0
- package/dist/driver/clip.js.map +1 -0
- package/dist/driver/compare.d.ts +34 -0
- package/dist/driver/compare.js +40 -0
- package/dist/driver/compare.js.map +1 -0
- package/dist/driver/gate.d.ts +36 -0
- package/dist/driver/gate.js +27 -0
- package/dist/driver/gate.js.map +1 -0
- package/dist/driver/launch.d.ts +43 -0
- package/dist/driver/launch.js +47 -0
- package/dist/driver/launch.js.map +1 -0
- package/dist/driver/page-hooks.d.ts +72 -0
- package/dist/driver/page-hooks.js +129 -0
- package/dist/driver/page-hooks.js.map +1 -0
- package/dist/driver/reports.d.ts +34 -0
- package/dist/driver/reports.js +42 -0
- package/dist/driver/reports.js.map +1 -0
- package/dist/driver/session.d.ts +285 -0
- package/dist/driver/session.js +773 -0
- package/dist/driver/session.js.map +1 -0
- package/dist/driver/settle.d.ts +41 -0
- package/dist/driver/settle.js +82 -0
- package/dist/driver/settle.js.map +1 -0
- package/dist/env.d.ts +11 -0
- package/dist/env.js +41 -0
- package/dist/env.js.map +1 -0
- package/dist/fixtures.d.ts +13 -0
- package/dist/fixtures.js +13 -0
- package/dist/fixtures.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/inspect/inspect.d.ts +70 -0
- package/dist/inspect/inspect.js +176 -0
- package/dist/inspect/inspect.js.map +1 -0
- package/dist/inspect/measure.d.ts +40 -0
- package/dist/inspect/measure.js +107 -0
- package/dist/inspect/measure.js.map +1 -0
- package/dist/loader.d.ts +28 -0
- package/dist/loader.js +143 -0
- package/dist/loader.js.map +1 -0
- package/dist/page/assets/index-DUom5amc.js +1 -0
- package/dist/page/index.html +18 -0
- package/dist/render/encode.d.ts +40 -0
- package/dist/render/encode.js +183 -0
- package/dist/render/encode.js.map +1 -0
- package/dist/render/ffmpeg.d.ts +13 -0
- package/dist/render/ffmpeg.js +72 -0
- package/dist/render/ffmpeg.js.map +1 -0
- package/dist/render/presentation.d.ts +19 -0
- package/dist/render/presentation.js +27 -0
- package/dist/render/presentation.js.map +1 -0
- package/dist/render/render.d.ts +59 -0
- package/dist/render/render.js +144 -0
- package/dist/render/render.js.map +1 -0
- package/dist/render/sampling.d.ts +47 -0
- package/dist/render/sampling.js +129 -0
- package/dist/render/sampling.js.map +1 -0
- package/dist/render/sequence.d.ts +24 -0
- package/dist/render/sequence.js +105 -0
- package/dist/render/sequence.js.map +1 -0
- package/dist/render/serve.d.ts +35 -0
- package/dist/render/serve.js +124 -0
- package/dist/render/serve.js.map +1 -0
- package/dist/review/review.d.ts +54 -0
- package/dist/review/review.js +229 -0
- package/dist/review/review.js.map +1 -0
- package/dist/scene.d.ts +23 -0
- package/dist/scene.js +2 -0
- package/dist/scene.js.map +1 -0
- package/dsl.md +119 -0
- package/package.json +76 -0
- package/plugin/.claude-plugin/plugin.json +9 -0
- package/plugin/README.md +105 -0
- package/plugin/agents/versioncam-reviewer.md +63 -0
- package/plugin/skills/versioncam/SKILL.md +235 -0
- package/plugin/skills/versioncam/authoring.md +226 -0
- package/plugin/skills/versioncam/onboarding.md +199 -0
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
+
import { cursorEnd, evalCursor, planMove, targetPoint, } from "../core/cursor.js";
|
|
6
|
+
import { zoomRect } from "../core/camera.js";
|
|
7
|
+
import { loop as planLoop, resampleByArcLength } from "../core/loop.js";
|
|
8
|
+
import { seeded } from "../core/rng.js";
|
|
9
|
+
import { typingSchedule } from "../core/typing.js";
|
|
10
|
+
import { emptyTimeline, frameAt, timeAt, validateTimeline, } from "../core/timeline.js";
|
|
11
|
+
import { emptyTiming } from "../core/timing.js";
|
|
12
|
+
import { TICK_MS, settlePage } from "./settle.js";
|
|
13
|
+
import { FRAME_PROBE } from "./page-hooks.js";
|
|
14
|
+
/**
|
|
15
|
+
* `Array.isArray` does not narrow a readonly tuple union, and a Locator is an
|
|
16
|
+
* object like any other, so both targets need real guards.
|
|
17
|
+
*/
|
|
18
|
+
function isPoint(target) {
|
|
19
|
+
return Array.isArray(target);
|
|
20
|
+
}
|
|
21
|
+
function isRect(target) {
|
|
22
|
+
return (typeof target === "object" &&
|
|
23
|
+
target !== null &&
|
|
24
|
+
"x" in target &&
|
|
25
|
+
"w" in target);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Drives the app through an authored timeline and captures one image per
|
|
29
|
+
* output frame.
|
|
30
|
+
*
|
|
31
|
+
* The central idea: the clip's time is declared by the script, not measured
|
|
32
|
+
* from the wall clock. Each frame we apply that frame's input, advance the
|
|
33
|
+
* page's fake clock by exactly 1/fps, and capture. Real latency — a DuckDB
|
|
34
|
+
* query, a tile fetch, WebGL under software rendering — is absorbed by
|
|
35
|
+
* `settle()`, which advances the clock without capturing. Two runs of the
|
|
36
|
+
* same script therefore produce the same frames on any machine.
|
|
37
|
+
*/
|
|
38
|
+
export class RecordingSession {
|
|
39
|
+
page;
|
|
40
|
+
options;
|
|
41
|
+
timeline;
|
|
42
|
+
rng;
|
|
43
|
+
motion;
|
|
44
|
+
statesDir;
|
|
45
|
+
stateIndexByHash = new Map();
|
|
46
|
+
queue = [];
|
|
47
|
+
settleReports = [];
|
|
48
|
+
/** Authored time, in seconds. */
|
|
49
|
+
t = 0;
|
|
50
|
+
/** Index of the last captured frame; -1 before frame 0 exists. */
|
|
51
|
+
frame = -1;
|
|
52
|
+
cursor;
|
|
53
|
+
inFlight = 0;
|
|
54
|
+
lastSettleFrame = -1;
|
|
55
|
+
timing = emptyTiming();
|
|
56
|
+
/**
|
|
57
|
+
* Where the real mouse actually is. The planned cursor holds still for most
|
|
58
|
+
* of a clip, and telling Chromium to move a pointer to where it already is
|
|
59
|
+
* costs 8 ms and achieves nothing.
|
|
60
|
+
*/
|
|
61
|
+
lastMouse = null;
|
|
62
|
+
/** What the page looked like to the probe when it was last photographed. */
|
|
63
|
+
lastCaptured = null;
|
|
64
|
+
lastCapturedFrame = -1;
|
|
65
|
+
/**
|
|
66
|
+
* Set whenever the driver sends the page real input, and cleared by the next
|
|
67
|
+
* frame's capture decision.
|
|
68
|
+
*
|
|
69
|
+
* Input can change what the page looks like without changing anything the
|
|
70
|
+
* probe watches: `:active` while a button is held is pure CSS, driven by a
|
|
71
|
+
* pointer event, touching neither the DOM nor the element under the cursor.
|
|
72
|
+
* The gate would skip those frames and the clip would show a button that is
|
|
73
|
+
* never pressed.
|
|
74
|
+
*/
|
|
75
|
+
inputPending = false;
|
|
76
|
+
constructor(page, options) {
|
|
77
|
+
this.page = page;
|
|
78
|
+
this.options = options;
|
|
79
|
+
this.rng = seeded(options.seed);
|
|
80
|
+
this.motion = options.config.motion;
|
|
81
|
+
this.statesDir = join(options.outputDir, "states");
|
|
82
|
+
// Where frame 0 finds the pointer. Parking it on a control means the clip
|
|
83
|
+
// opens with that control's tooltip already up, or its focus ring showing —
|
|
84
|
+
// which a reviewer will fail, and has, on two different apps. There is no
|
|
85
|
+
// default that suits every layout, so `cursorStart` is a config field and
|
|
86
|
+
// this is only its fallback.
|
|
87
|
+
const start = options.config.cursorStart;
|
|
88
|
+
this.cursor = [
|
|
89
|
+
options.viewport.width * start.x,
|
|
90
|
+
options.viewport.height * start.y,
|
|
91
|
+
];
|
|
92
|
+
this.timeline = emptyTimeline({
|
|
93
|
+
clipId: options.clipId,
|
|
94
|
+
fps: options.fps,
|
|
95
|
+
// A draft's state images are CSS-sized, and `dpr` is how the renderer
|
|
96
|
+
// maps viewport coordinates onto pixels inside them.
|
|
97
|
+
viewport: { ...options.viewport, dpr: options.draft ? 1 : options.dpr },
|
|
98
|
+
title: options.title,
|
|
99
|
+
appCommit: options.appCommit,
|
|
100
|
+
baseUrl: options.config.baseUrl,
|
|
101
|
+
// Fixed, so two recordings of the same script differ in no byte at all.
|
|
102
|
+
recordedAt: "1970-01-01T00:00:00.000Z",
|
|
103
|
+
});
|
|
104
|
+
page.on("request", () => {
|
|
105
|
+
this.inFlight += 1;
|
|
106
|
+
});
|
|
107
|
+
page.on("requestfinished", () => {
|
|
108
|
+
this.inFlight = Math.max(0, this.inFlight - 1);
|
|
109
|
+
});
|
|
110
|
+
page.on("requestfailed", () => {
|
|
111
|
+
this.inFlight = Math.max(0, this.inFlight - 1);
|
|
112
|
+
});
|
|
113
|
+
rmSync(options.outputDir, { recursive: true, force: true });
|
|
114
|
+
mkdirSync(this.statesDir, { recursive: true });
|
|
115
|
+
}
|
|
116
|
+
// ── locators ──────────────────────────────────────────────────────────────
|
|
117
|
+
byTestId(testId) {
|
|
118
|
+
return this.page.getByTestId(testId);
|
|
119
|
+
}
|
|
120
|
+
byRole(role, options) {
|
|
121
|
+
return this.page.getByRole(role, options);
|
|
122
|
+
}
|
|
123
|
+
byLabel(text) {
|
|
124
|
+
return this.page.getByLabel(text);
|
|
125
|
+
}
|
|
126
|
+
byPlaceholder(text) {
|
|
127
|
+
return this.page.getByPlaceholder(text);
|
|
128
|
+
}
|
|
129
|
+
byText(text) {
|
|
130
|
+
return this.page.getByText(text);
|
|
131
|
+
}
|
|
132
|
+
css(selector) {
|
|
133
|
+
return this.page.locator(selector);
|
|
134
|
+
}
|
|
135
|
+
// ── time ──────────────────────────────────────────────────────────────────
|
|
136
|
+
/** Hold still for `ms`, capturing frames. */
|
|
137
|
+
async hold(ms) {
|
|
138
|
+
const until = this.t + ms / 1000;
|
|
139
|
+
this.pushHold(until);
|
|
140
|
+
await this.advanceTo(until);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Run raw Playwright without capturing or advancing authored time. For
|
|
144
|
+
* setup that should not appear in the clip: seeding cohorts, warming tiles,
|
|
145
|
+
* priming a chat conversation.
|
|
146
|
+
*
|
|
147
|
+
* The page's clock does not move either, however long `fn` takes: time the
|
|
148
|
+
* app needs to finish what `fn` started is what the next `settle()` is for.
|
|
149
|
+
*/
|
|
150
|
+
async offCamera(fn) {
|
|
151
|
+
return fn(this.page);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Wait for the page to stop working. Advances the page's clock but captures
|
|
155
|
+
* nothing, so the wait never reaches the timeline.
|
|
156
|
+
*/
|
|
157
|
+
async settle(options = {}) {
|
|
158
|
+
const defaults = this.options.config.settle;
|
|
159
|
+
const startedAt = performance.now();
|
|
160
|
+
const report = await settlePage(this.page, () => this.inFlight, {
|
|
161
|
+
quietMs: defaults.quietMs,
|
|
162
|
+
timeoutMs: defaults.timeoutMs,
|
|
163
|
+
yieldMs: defaults.yieldMs,
|
|
164
|
+
...options,
|
|
165
|
+
hook: options.hook ?? this.defaultHook(),
|
|
166
|
+
});
|
|
167
|
+
this.timing.settleMs += performance.now() - startedAt;
|
|
168
|
+
this.settleReports.push(report);
|
|
169
|
+
if (report.elapsedMs > 1000) {
|
|
170
|
+
console.log(`[recorder] ${report.label} took ${report.elapsedMs}ms (last blocker: ${report.lastBlocker})`);
|
|
171
|
+
}
|
|
172
|
+
// A labelled settle is a beat, and the beat is worth finding again: by
|
|
173
|
+
// frame, for the sheet and for review. The frame after this one, because
|
|
174
|
+
// a settle captures nothing and the first picture of what it waited for is
|
|
175
|
+
// the next frame — which for `open()`, settling before any frame exists,
|
|
176
|
+
// is frame 0.
|
|
177
|
+
if (options.label) {
|
|
178
|
+
this.timeline.marks.push({ frame: this.frame + 1, label: options.label });
|
|
179
|
+
}
|
|
180
|
+
// The next captured frame may differ wholesale from the last one; tell the
|
|
181
|
+
// renderer it may crossfade here instead of cutting hard.
|
|
182
|
+
if (this.frame >= 0 && this.frame !== this.lastSettleFrame) {
|
|
183
|
+
this.timeline.cuts.push(this.frame);
|
|
184
|
+
this.lastSettleFrame = this.frame;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
defaultHook() {
|
|
188
|
+
const ready = this.options.config.settle.ready;
|
|
189
|
+
if (!ready)
|
|
190
|
+
return undefined;
|
|
191
|
+
return () => ready(this.page);
|
|
192
|
+
}
|
|
193
|
+
// ── navigation ────────────────────────────────────────────────────────────
|
|
194
|
+
/**
|
|
195
|
+
* Open a path, sign in if the app needs it, wait for the page to be
|
|
196
|
+
* genuinely finished, and capture frame 0.
|
|
197
|
+
*
|
|
198
|
+
* Signing in is the app's own business: how it knows it is signed out, which
|
|
199
|
+
* fields, which button, what counts as done. The config supplies either a
|
|
200
|
+
* saved browser session — in which case there is nothing to do here — or a
|
|
201
|
+
* hook, which is called unconditionally after the navigation and decides for
|
|
202
|
+
* itself whether anything is needed.
|
|
203
|
+
*/
|
|
204
|
+
async open(path, options = {}) {
|
|
205
|
+
const url = path.startsWith("http")
|
|
206
|
+
? path
|
|
207
|
+
: `${this.options.config.baseUrl}${path}`;
|
|
208
|
+
const startedAt = performance.now();
|
|
209
|
+
await this.page.goto(url, { waitUntil: "domcontentloaded" });
|
|
210
|
+
// Signing in is the one stretch where the page is in someone else's hands
|
|
211
|
+
// and the recorder cannot know what the app is waiting for — a redirect
|
|
212
|
+
// on a timer, a debounced field — so the page's clock runs alongside real
|
|
213
|
+
// time for it, as it would in any browser. Nothing is filmed until the
|
|
214
|
+
// settle below has had its say.
|
|
215
|
+
const auth = this.options.config.auth;
|
|
216
|
+
const login = auth?.login;
|
|
217
|
+
if (login)
|
|
218
|
+
await this.whileTicking(() => login(this.page));
|
|
219
|
+
this.timing.openMs += performance.now() - startedAt;
|
|
220
|
+
await this.settle({
|
|
221
|
+
label: `open ${path}`,
|
|
222
|
+
timeoutMs: options.settleTimeoutMs ?? 90_000,
|
|
223
|
+
});
|
|
224
|
+
const framesAt = performance.now();
|
|
225
|
+
await this.moveMouse(this.cursor);
|
|
226
|
+
await this.captureFrame(0, await this.page.evaluate(FRAME_PROBE(0, this.cursor[0], this.cursor[1])));
|
|
227
|
+
this.timing.framesMs += performance.now() - framesAt;
|
|
228
|
+
this.frame = 0;
|
|
229
|
+
}
|
|
230
|
+
// ── pointer ───────────────────────────────────────────────────────────────
|
|
231
|
+
/** Glide to a target without pressing anything. */
|
|
232
|
+
async moveTo(target, options = {}) {
|
|
233
|
+
const point = await this.resolvePoint(target);
|
|
234
|
+
const size = await this.resolveSize(target);
|
|
235
|
+
const segment = planMove(this.cursor, point, this.t, this.rng, {
|
|
236
|
+
targetSize: size,
|
|
237
|
+
duration: options.duration,
|
|
238
|
+
});
|
|
239
|
+
this.timeline.cursor.push(segment);
|
|
240
|
+
await this.advanceTo(segment.t1);
|
|
241
|
+
this.cursor = point;
|
|
242
|
+
}
|
|
243
|
+
/** Move to a target and pause on it, the way a person does before clicking. */
|
|
244
|
+
async hover(target, options = {}) {
|
|
245
|
+
await this.moveTo(target, { duration: options.duration });
|
|
246
|
+
await this.hold(options.dwell ?? this.motion.hoverDwellMs);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* A full click: approach, a short dwell on the target, press, release.
|
|
250
|
+
*
|
|
251
|
+
* Implemented on `page.mouse` rather than `locator.click()`, which would
|
|
252
|
+
* teleport the pointer to the element — the approach *is* the thing being
|
|
253
|
+
* recorded.
|
|
254
|
+
*/
|
|
255
|
+
async click(target, options = {}) {
|
|
256
|
+
if (target)
|
|
257
|
+
await this.moveTo(target);
|
|
258
|
+
const dwell = options.dwell ??
|
|
259
|
+
this.motion.clickDwellMinMs +
|
|
260
|
+
this.rng() *
|
|
261
|
+
(this.motion.clickDwellMaxMs - this.motion.clickDwellMinMs);
|
|
262
|
+
this.pushHold(this.t + dwell / 1000);
|
|
263
|
+
await this.advanceTo(this.t + dwell / 1000);
|
|
264
|
+
await this.pressMouse("down");
|
|
265
|
+
const pressMs = this.motion.pressMinMs +
|
|
266
|
+
this.rng() * (this.motion.pressMaxMs - this.motion.pressMinMs);
|
|
267
|
+
this.pushHold(this.t + pressMs / 1000);
|
|
268
|
+
await this.advanceTo(this.t + pressMs / 1000);
|
|
269
|
+
await this.pressMouse("up");
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Draw a lasso: press, follow a smoothed closed loop at constant speed,
|
|
273
|
+
* release. `planner` comes from `loop([...])` and is expressed in fractions
|
|
274
|
+
* of the target's box, so the stroke survives a layout change.
|
|
275
|
+
*/
|
|
276
|
+
/**
|
|
277
|
+
* Drag across a target, in fractions of its own box.
|
|
278
|
+
*
|
|
279
|
+
* A brush, a slider, a range handle: controls that respond to a stroke and
|
|
280
|
+
* ignore a click. The README promised this method for months before it
|
|
281
|
+
* existed, and the gap was not academic — a date-histogram clip on the
|
|
282
|
+
* first app this recorded clicked a bar to filter by month, which sets a
|
|
283
|
+
* zero-width d3 brush, which *clears* the filter rather than applying one.
|
|
284
|
+
* That clip never showed the thing its caption claimed, and nothing said
|
|
285
|
+
* so: the click lands, the page changes enough for a pixel diff, and the
|
|
286
|
+
* recording looks fine.
|
|
287
|
+
*
|
|
288
|
+
* Fractions rather than pixels for the same reason `lasso` uses them: a
|
|
289
|
+
* stroke authored against a real box survives the next padding change.
|
|
290
|
+
* `{ x1: 0.18, y1: 0.5, x2: 0.36, y2: 0.5 }` drags left-to-right across the
|
|
291
|
+
* middle. Beware the edges — a chart's axis labels live inside its own box,
|
|
292
|
+
* and a drag starting at x 0.08 selects that text instead of the control.
|
|
293
|
+
*/
|
|
294
|
+
async drag(target, from, to, options = {}) {
|
|
295
|
+
const box = isRect(target) ? target : await this.boxOf(target);
|
|
296
|
+
const at = (f) => [
|
|
297
|
+
box.x + box.w * f.x,
|
|
298
|
+
box.y + box.h * f.y,
|
|
299
|
+
];
|
|
300
|
+
const start = at(from);
|
|
301
|
+
const end = at(to);
|
|
302
|
+
const durationMs = options.duration ?? this.motion.lassoDurationMs;
|
|
303
|
+
const frames = Math.max(2, Math.round((durationMs / 1000) * this.options.fps));
|
|
304
|
+
await this.moveTo(start);
|
|
305
|
+
await this.hold(this.motion.hoverDwellMs);
|
|
306
|
+
for (const modifier of options.modifiers ?? []) {
|
|
307
|
+
await this.page.keyboard.down(modifier);
|
|
308
|
+
}
|
|
309
|
+
await this.pressMouse("down");
|
|
310
|
+
// One authored point per frame, as `lasso` does: the stroke advances at
|
|
311
|
+
// exactly the rate the video plays, so the app is never asked to keep up
|
|
312
|
+
// with a drag that outruns its own rendering.
|
|
313
|
+
const frameSeconds = 1 / this.options.fps;
|
|
314
|
+
for (let step = 1; step <= frames; step += 1) {
|
|
315
|
+
const u = step / frames;
|
|
316
|
+
const point = [
|
|
317
|
+
start[0] + (end[0] - start[0]) * u,
|
|
318
|
+
start[1] + (end[1] - start[1]) * u,
|
|
319
|
+
];
|
|
320
|
+
const previous = this.cursor;
|
|
321
|
+
this.timeline.cursor.push({
|
|
322
|
+
kind: "move",
|
|
323
|
+
t0: this.t,
|
|
324
|
+
t1: this.t + frameSeconds,
|
|
325
|
+
from: previous,
|
|
326
|
+
to: point,
|
|
327
|
+
ctrl: [(previous[0] + point[0]) / 2, (previous[1] + point[1]) / 2],
|
|
328
|
+
ease: "minjerk",
|
|
329
|
+
});
|
|
330
|
+
this.cursor = point;
|
|
331
|
+
await this.advanceTo(this.t + frameSeconds);
|
|
332
|
+
}
|
|
333
|
+
await this.pressMouse("up");
|
|
334
|
+
for (const modifier of options.modifiers ?? []) {
|
|
335
|
+
await this.page.keyboard.up(modifier);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
async lasso(target, planner, options = {}) {
|
|
339
|
+
const box = await this.boxOf(target);
|
|
340
|
+
const path = planner(box, this.rng);
|
|
341
|
+
const durationMs = options.duration ?? this.motion.lassoDurationMs;
|
|
342
|
+
const frames = Math.max(2, Math.round((durationMs / 1000) * this.options.fps));
|
|
343
|
+
const points = resampleByArcLength(path, frames);
|
|
344
|
+
await this.moveTo(points[0]);
|
|
345
|
+
await this.hold(this.motion.hoverDwellMs);
|
|
346
|
+
for (const modifier of options.modifiers ?? []) {
|
|
347
|
+
await this.page.keyboard.down(modifier);
|
|
348
|
+
}
|
|
349
|
+
await this.pressMouse("down");
|
|
350
|
+
// One authored point per frame: the stroke advances exactly as fast as the
|
|
351
|
+
// video does, so it can never outrun what the map has drawn.
|
|
352
|
+
const frameSeconds = 1 / this.options.fps;
|
|
353
|
+
for (const point of points.slice(1)) {
|
|
354
|
+
const from = this.cursor;
|
|
355
|
+
this.timeline.cursor.push({
|
|
356
|
+
kind: "move",
|
|
357
|
+
t0: this.t,
|
|
358
|
+
t1: this.t + frameSeconds,
|
|
359
|
+
from,
|
|
360
|
+
to: point,
|
|
361
|
+
ctrl: [(from[0] + point[0]) / 2, (from[1] + point[1]) / 2],
|
|
362
|
+
ease: "minjerk",
|
|
363
|
+
});
|
|
364
|
+
this.cursor = point;
|
|
365
|
+
await this.advanceTo(this.t + frameSeconds);
|
|
366
|
+
}
|
|
367
|
+
await this.pressMouse("up");
|
|
368
|
+
for (const modifier of options.modifiers ?? []) {
|
|
369
|
+
await this.page.keyboard.up(modifier);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// ── keyboard ──────────────────────────────────────────────────────────────
|
|
373
|
+
/**
|
|
374
|
+
* Type at a human cadence into `target`, clicking it first so the keystrokes
|
|
375
|
+
* have somewhere to land. Without the click the text goes to whatever had
|
|
376
|
+
* focus — usually nothing — and the clip shows an empty field being
|
|
377
|
+
* "typed" into.
|
|
378
|
+
*/
|
|
379
|
+
async typeInto(target, text) {
|
|
380
|
+
await this.click(target);
|
|
381
|
+
await this.type(text);
|
|
382
|
+
}
|
|
383
|
+
/** Type at a human cadence. The cursor stays where it is. */
|
|
384
|
+
async type(text) {
|
|
385
|
+
const schedule = typingSchedule(text, this.rng);
|
|
386
|
+
const start = this.t;
|
|
387
|
+
for (const [index, char] of [...text].entries()) {
|
|
388
|
+
this.queue.push({
|
|
389
|
+
t: start + schedule[index] / 1000,
|
|
390
|
+
run: async () => {
|
|
391
|
+
await this.page.keyboard.type(char);
|
|
392
|
+
this.inputPending = true;
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
const until = start + schedule[schedule.length - 1] / 1000;
|
|
397
|
+
this.pushHold(until);
|
|
398
|
+
await this.advanceTo(until);
|
|
399
|
+
}
|
|
400
|
+
async press(key) {
|
|
401
|
+
await this.page.keyboard.press(key);
|
|
402
|
+
this.inputPending = true;
|
|
403
|
+
}
|
|
404
|
+
// ── camera, highlight, caption ────────────────────────────────────────────
|
|
405
|
+
camera = {
|
|
406
|
+
zoom: async (target, scale, options = {}) => {
|
|
407
|
+
const box = isRect(target) ? target : await this.boxOf(target);
|
|
408
|
+
const rect = zoomRect(box, this.options.viewport, {
|
|
409
|
+
maxScale: scale ?? this.motion.defaultZoom,
|
|
410
|
+
pad: this.motion.zoomPad,
|
|
411
|
+
});
|
|
412
|
+
this.pushCameraKey(rect, options.duration ?? this.motion.zoomDurationMs);
|
|
413
|
+
},
|
|
414
|
+
to: (rect, options = {}) => {
|
|
415
|
+
this.pushCameraKey(rect, options.duration ?? this.motion.zoomDurationMs);
|
|
416
|
+
},
|
|
417
|
+
reset: (options = {}) => {
|
|
418
|
+
this.pushCameraKey({
|
|
419
|
+
x: 0,
|
|
420
|
+
y: 0,
|
|
421
|
+
w: this.options.viewport.width,
|
|
422
|
+
h: this.options.viewport.height,
|
|
423
|
+
}, options.duration ?? this.motion.zoomDurationMs);
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
pushCameraKey(rect, durationMs) {
|
|
427
|
+
const keys = this.timeline.camera;
|
|
428
|
+
const last = keys[keys.length - 1];
|
|
429
|
+
// Hold the current framing until "now", then glide to the new one.
|
|
430
|
+
if (last.t < this.t) {
|
|
431
|
+
keys.push({ t: this.t, rect: last.rect, ease: "hold" });
|
|
432
|
+
}
|
|
433
|
+
keys.push({ t: this.t + durationMs / 1000, rect, ease: "minjerk" });
|
|
434
|
+
}
|
|
435
|
+
async highlight(target, style, options = {}) {
|
|
436
|
+
const rect = isRect(target) ? target : await this.boxOf(target);
|
|
437
|
+
this.timeline.highlights.push({
|
|
438
|
+
t0: this.t,
|
|
439
|
+
t1: this.t + (options.for ?? this.motion.highlightMs) / 1000,
|
|
440
|
+
rect,
|
|
441
|
+
style,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Put a line of text on the clip, from now until the next caption or the end.
|
|
446
|
+
*
|
|
447
|
+
* `from` defaults to the moment it is written, not to zero. Defaulting to
|
|
448
|
+
* zero meant a second caption covered the whole clip on top of the first, so
|
|
449
|
+
* every frame carried both lines drawn over each other — which reads on a
|
|
450
|
+
* contact sheet as ghosted text rather than as a caption bug, and cost a
|
|
451
|
+
* pilot most of a round to diagnose.
|
|
452
|
+
*/
|
|
453
|
+
caption(text, options = {}) {
|
|
454
|
+
const t0 = options.from ?? this.t;
|
|
455
|
+
const previous = this.timeline.captions[this.timeline.captions.length - 1];
|
|
456
|
+
// A caption stays up until something replaces it, so writing the next one
|
|
457
|
+
// is what ends the last one. `max` keeps the range well formed when a
|
|
458
|
+
// caption was given an explicit `from` later than this one's.
|
|
459
|
+
if (previous && previous.t1 > t0) {
|
|
460
|
+
previous.t1 = Math.max(previous.t0, t0);
|
|
461
|
+
}
|
|
462
|
+
this.timeline.captions.push({
|
|
463
|
+
t0,
|
|
464
|
+
t1: options.to ?? Number.POSITIVE_INFINITY,
|
|
465
|
+
text,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
// ── internals ─────────────────────────────────────────────────────────────
|
|
469
|
+
pushHold(until) {
|
|
470
|
+
if (until <= this.t)
|
|
471
|
+
return;
|
|
472
|
+
this.timeline.cursor.push({
|
|
473
|
+
kind: "hold",
|
|
474
|
+
t0: this.t,
|
|
475
|
+
t1: until,
|
|
476
|
+
at: this.cursor,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Bounded on purpose. `boundingBox()` waits indefinitely by default, so a
|
|
481
|
+
* clip that reaches for a control the app did not render — a button that
|
|
482
|
+
* only appears once something is selected, say — hangs forever with no clue
|
|
483
|
+
* as to which step it was on. Fifteen seconds and a named failure instead.
|
|
484
|
+
*/
|
|
485
|
+
async boxOf(locator, timeoutMs = 15_000) {
|
|
486
|
+
if (!(await this.untilVisible(locator, timeoutMs))) {
|
|
487
|
+
throw new Error(await this.whyLocatorFailed(locator, timeoutMs));
|
|
488
|
+
}
|
|
489
|
+
const box = await locator.boundingBox();
|
|
490
|
+
if (!box) {
|
|
491
|
+
throw new Error(`Matched ${locator.toString()} but it has no box (zero-sized or detached).`);
|
|
492
|
+
}
|
|
493
|
+
return { x: box.x, y: box.y, w: box.width, h: box.height };
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Wait for a target to be on screen, advancing the page's clock a tick at a
|
|
497
|
+
* time while it is not.
|
|
498
|
+
*
|
|
499
|
+
* The clock is paused, so a target the app shows from its own timer — a menu
|
|
500
|
+
* that opens after a delay, results behind a debounce — would never arrive
|
|
501
|
+
* if this only waited. Ticking as `settle()` does brings it in the same
|
|
502
|
+
* number of ticks every run, and costs nothing in the usual case: a target
|
|
503
|
+
* already on screen is one round trip and no page time at all.
|
|
504
|
+
*/
|
|
505
|
+
async untilVisible(locator, timeoutMs) {
|
|
506
|
+
const deadline = performance.now() + timeoutMs;
|
|
507
|
+
for (;;) {
|
|
508
|
+
try {
|
|
509
|
+
if (await locator.isVisible())
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
catch {
|
|
513
|
+
// Almost always a locator that matched more than one element, which
|
|
514
|
+
// no amount of waiting fixes; the caller says which failure it was.
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
if (performance.now() > deadline)
|
|
518
|
+
return false;
|
|
519
|
+
await this.page.clock.runFor(TICK_MS);
|
|
520
|
+
await sleep(TICK_MS);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Run `fn` with the page's clock ticking alongside real time, the way it
|
|
525
|
+
* runs in a browser nobody is driving.
|
|
526
|
+
*
|
|
527
|
+
* Only for code the recorder hands the page to and cannot see into, and
|
|
528
|
+
* never while a frame is being made: the ticks land wherever the machine's
|
|
529
|
+
* speed puts them, which is exactly what the frames must not depend on.
|
|
530
|
+
*/
|
|
531
|
+
async whileTicking(fn) {
|
|
532
|
+
let done = false;
|
|
533
|
+
const ticking = (async () => {
|
|
534
|
+
while (!done) {
|
|
535
|
+
await sleep(TICK_MS);
|
|
536
|
+
if (done)
|
|
537
|
+
break;
|
|
538
|
+
// One of the app's timers throwing is the app's business, as it
|
|
539
|
+
// would be in any browser, and not a reason to fail the sign-in it
|
|
540
|
+
// happened during.
|
|
541
|
+
await this.page.clock.runFor(TICK_MS).catch(() => undefined);
|
|
542
|
+
}
|
|
543
|
+
})();
|
|
544
|
+
try {
|
|
545
|
+
return await fn();
|
|
546
|
+
}
|
|
547
|
+
finally {
|
|
548
|
+
done = true;
|
|
549
|
+
await ticking;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Which kind of locator failure this was.
|
|
554
|
+
*
|
|
555
|
+
* Playwright reports both of them as a rejected wait, and they call for
|
|
556
|
+
* opposite repairs. Nothing matched means the clip reached for something the
|
|
557
|
+
* app never rendered — look at the page again. Several matched is a
|
|
558
|
+
* strict-mode violation: the locator found the text, on more than one
|
|
559
|
+
* element, and the beat is fine as long as the locator names one of them.
|
|
560
|
+
* Saying "nothing matched" for the second sent a pilot hunting a control
|
|
561
|
+
* that was on screen the whole time.
|
|
562
|
+
*/
|
|
563
|
+
async whyLocatorFailed(locator, timeoutMs) {
|
|
564
|
+
const when = `at t=${this.t.toFixed(2)}s (frame ${this.frame})`;
|
|
565
|
+
// The count is a second round trip to a page that has just failed one, so
|
|
566
|
+
// a locator that cannot even be counted falls back to the simpler report.
|
|
567
|
+
const count = await locator.count().catch(() => 0);
|
|
568
|
+
if (count > 1) {
|
|
569
|
+
return (`Matched ${count} elements with ${locator.toString()} ${when}. ` +
|
|
570
|
+
"A locator has to resolve to one: use `byRole` with a name, or a test id.");
|
|
571
|
+
}
|
|
572
|
+
return (`Nothing matched ${locator.toString()} within ${timeoutMs}ms ${when}. ` +
|
|
573
|
+
"The clip expected it to be on screen by this point.");
|
|
574
|
+
}
|
|
575
|
+
async resolvePoint(target) {
|
|
576
|
+
if (isPoint(target))
|
|
577
|
+
return target;
|
|
578
|
+
const box = await this.boxOf(target);
|
|
579
|
+
return targetPoint(box, this.rng);
|
|
580
|
+
}
|
|
581
|
+
async resolveSize(target) {
|
|
582
|
+
if (isPoint(target))
|
|
583
|
+
return 24;
|
|
584
|
+
const box = await this.boxOf(target);
|
|
585
|
+
return Math.min(box.w, box.h);
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* The frame loop. Everything that advances authored time comes through here.
|
|
589
|
+
*/
|
|
590
|
+
async advanceTo(t1) {
|
|
591
|
+
const endFrame = frameAt(t1, this.options.fps);
|
|
592
|
+
const frameMs = 1000 / this.options.fps;
|
|
593
|
+
const startedAt = performance.now();
|
|
594
|
+
for (let f = this.frame + 1; f <= endFrame; f += 1) {
|
|
595
|
+
const tf = timeAt(f, this.options.fps);
|
|
596
|
+
const position = evalCursor(this.timeline.cursor, tf);
|
|
597
|
+
await this.moveMouse(position);
|
|
598
|
+
while (this.queue.length > 0 && this.queue[0].t <= tf) {
|
|
599
|
+
const action = this.queue.shift();
|
|
600
|
+
if (action)
|
|
601
|
+
await action.run();
|
|
602
|
+
}
|
|
603
|
+
await this.page.clock.runFor(frameMs);
|
|
604
|
+
// One round trip, after the input and the tick, because that is the
|
|
605
|
+
// state this frame will be photographed in. It scrubs the page's own CSS
|
|
606
|
+
// animations to this frame's authored time — so a spinner spins at the
|
|
607
|
+
// rate the clip plays rather than the rate the machine ran — and reads
|
|
608
|
+
// back what decides whether this frame needs a screenshot.
|
|
609
|
+
const probe = await this.page.evaluate(FRAME_PROBE(tf, position[0], position[1]));
|
|
610
|
+
if (this.shouldCapture(f, probe)) {
|
|
611
|
+
await this.captureFrame(f, probe);
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
this.timeline.frames[f] = this.timeline.frames[f - 1];
|
|
615
|
+
this.timing.skipped += 1;
|
|
616
|
+
}
|
|
617
|
+
this.frame = f;
|
|
618
|
+
}
|
|
619
|
+
this.timing.framesMs += performance.now() - startedAt;
|
|
620
|
+
this.t = t1;
|
|
621
|
+
this.cursor = cursorEnd(this.timeline.cursor, this.cursor);
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Does this frame need its own screenshot?
|
|
625
|
+
*
|
|
626
|
+
* Every clause errs towards capturing, because the two mistakes are not
|
|
627
|
+
* equal: capturing a frame that did not change wastes 25 ms, and skipping
|
|
628
|
+
* one that did puts a wrong picture in the video.
|
|
629
|
+
*/
|
|
630
|
+
shouldCapture(frame, probe) {
|
|
631
|
+
if (!this.options.capture.gate)
|
|
632
|
+
return true;
|
|
633
|
+
const last = this.lastCaptured;
|
|
634
|
+
if (!last)
|
|
635
|
+
return true;
|
|
636
|
+
// Input landed since the last frame. Its effect may never reach the DOM —
|
|
637
|
+
// a press that only changes `:active` styling never does — so do not ask
|
|
638
|
+
// the probe, just capture.
|
|
639
|
+
if (this.inputPending) {
|
|
640
|
+
this.inputPending = false;
|
|
641
|
+
return true;
|
|
642
|
+
}
|
|
643
|
+
if (probe.mutations !== last.mutations)
|
|
644
|
+
return true;
|
|
645
|
+
// A scrubbed animation repaints every frame while it runs, touching
|
|
646
|
+
// nothing the other clauses watch.
|
|
647
|
+
if (probe.animations > 0 || last.animations > 0)
|
|
648
|
+
return true;
|
|
649
|
+
// The pointer glyph is drawn in post, so the cursor moving is not a
|
|
650
|
+
// change — the cursor moving onto something else is.
|
|
651
|
+
if (probe.hovered !== last.hovered)
|
|
652
|
+
return true;
|
|
653
|
+
return (frame - this.lastCapturedFrame >= this.options.capture.forceCaptureEvery);
|
|
654
|
+
}
|
|
655
|
+
/** Press or release, recording it and flagging the frame as changed. */
|
|
656
|
+
async pressMouse(kind) {
|
|
657
|
+
if (kind === "down")
|
|
658
|
+
await this.page.mouse.down();
|
|
659
|
+
else
|
|
660
|
+
await this.page.mouse.up();
|
|
661
|
+
this.timeline.pointer.push({
|
|
662
|
+
t: this.t,
|
|
663
|
+
kind,
|
|
664
|
+
at: this.cursor,
|
|
665
|
+
button: "left",
|
|
666
|
+
});
|
|
667
|
+
this.inputPending = true;
|
|
668
|
+
}
|
|
669
|
+
/** Move the real pointer, unless it is already there. */
|
|
670
|
+
async moveMouse(to) {
|
|
671
|
+
if (this.lastMouse &&
|
|
672
|
+
this.lastMouse[0] === to[0] &&
|
|
673
|
+
this.lastMouse[1] === to[1]) {
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
await this.page.mouse.move(to[0], to[1]);
|
|
677
|
+
this.lastMouse = to;
|
|
678
|
+
}
|
|
679
|
+
async captureFrame(frame, probe = null) {
|
|
680
|
+
this.timing.captured += 1;
|
|
681
|
+
const buffer = await this.page.screenshot({
|
|
682
|
+
type: "png",
|
|
683
|
+
scale: this.options.draft ? "css" : "device",
|
|
684
|
+
// Not "hide", although the caret must indeed be hidden.
|
|
685
|
+
//
|
|
686
|
+
// Playwright hides it by injecting a style element before the shot and
|
|
687
|
+
// removing it after — two DOM mutations per screenshot. The capture gate
|
|
688
|
+
// decides whether to take a screenshot by asking whether the DOM changed,
|
|
689
|
+
// so with "hide" every capture guarantees the next frame captures too,
|
|
690
|
+
// and the gate can never skip anything. It reads its own footprint.
|
|
691
|
+
//
|
|
692
|
+
// `FREEZE_CSS` already sets `caret-color: transparent` on everything, as
|
|
693
|
+
// part of putting the page's rendering under the recorder's control, so
|
|
694
|
+
// there is nothing left for this option to do.
|
|
695
|
+
caret: "initial",
|
|
696
|
+
// "allow", emphatically, although this is a recorder of still frames.
|
|
697
|
+
//
|
|
698
|
+
// Playwright's "disabled" is built for a screenshot taken at an
|
|
699
|
+
// arbitrary moment: it cancels an infinite animation to its initial
|
|
700
|
+
// state, shoots, and plays it again. That is the right answer when you
|
|
701
|
+
// have no control over animation time — and the wrong one here, because
|
|
702
|
+
// the recorder does have control. `FREEZE_CSS` has already paused every
|
|
703
|
+
// animation and the driver has already scrubbed it to this frame's
|
|
704
|
+
// authored time, so "disabled" throws that away and photographs a
|
|
705
|
+
// spinner at rotation zero on every frame.
|
|
706
|
+
//
|
|
707
|
+
// It cost more than a dead spinner. Cancelling and replaying around each
|
|
708
|
+
// shot raced the scrub, so the same frame of the same clip came out a
|
|
709
|
+
// few pixels different about one run in three — which is what sent us
|
|
710
|
+
// looking here.
|
|
711
|
+
animations: "allow",
|
|
712
|
+
});
|
|
713
|
+
const hash = createHash("sha1").update(buffer).digest("hex");
|
|
714
|
+
let index = this.stateIndexByHash.get(hash);
|
|
715
|
+
if (index === undefined) {
|
|
716
|
+
index = this.timeline.states.length;
|
|
717
|
+
const name = `${String(index).padStart(4, "0")}.png`;
|
|
718
|
+
writeFileSync(join(this.statesDir, name), buffer);
|
|
719
|
+
this.timeline.states.push(name);
|
|
720
|
+
this.stateIndexByHash.set(hash, index);
|
|
721
|
+
}
|
|
722
|
+
this.timeline.frames[frame] = index;
|
|
723
|
+
this.lastCaptured = probe;
|
|
724
|
+
this.lastCapturedFrame = frame;
|
|
725
|
+
}
|
|
726
|
+
/** What this recording cost in real time. Diagnostics, never content. */
|
|
727
|
+
get cost() {
|
|
728
|
+
return { ...this.timing };
|
|
729
|
+
}
|
|
730
|
+
/** Every settle that happened, in order: what each waited for, and for how long. */
|
|
731
|
+
get settles() {
|
|
732
|
+
return [...this.settleReports];
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* How far the clip had got. Meaningless as content — it is where a failure
|
|
736
|
+
* happened, which is the first thing a repair needs and the one thing the
|
|
737
|
+
* script cannot be read to find out.
|
|
738
|
+
*/
|
|
739
|
+
get at() {
|
|
740
|
+
return { frame: this.frame, t: this.t };
|
|
741
|
+
}
|
|
742
|
+
/** Write the timeline out. Called by `clip()` once the script has run. */
|
|
743
|
+
finish() {
|
|
744
|
+
const startedAt = performance.now();
|
|
745
|
+
this.timeline.durationFrames = this.timeline.frames.length;
|
|
746
|
+
this.timeline.meta.captured = this.timing.captured;
|
|
747
|
+
if (this.options.draft)
|
|
748
|
+
this.timeline.meta.draft = true;
|
|
749
|
+
const finite = this.timeline.durationFrames / this.options.fps;
|
|
750
|
+
for (const caption of this.timeline.captions) {
|
|
751
|
+
if (!Number.isFinite(caption.t1))
|
|
752
|
+
caption.t1 = finite;
|
|
753
|
+
}
|
|
754
|
+
// A settle at the very end of a clip has no frame after it. Its mark
|
|
755
|
+
// points at the last frame there is, which is the closest true picture of
|
|
756
|
+
// what it produced — and leaves `review` to notice that the beat ended
|
|
757
|
+
// where the one before it did.
|
|
758
|
+
const lastFrame = Math.max(0, this.timeline.durationFrames - 1);
|
|
759
|
+
for (const mark of this.timeline.marks) {
|
|
760
|
+
if (mark.frame > lastFrame)
|
|
761
|
+
mark.frame = lastFrame;
|
|
762
|
+
}
|
|
763
|
+
const problems = validateTimeline(this.timeline);
|
|
764
|
+
if (problems.length > 0) {
|
|
765
|
+
throw new Error(`Recorded an invalid timeline for ${this.options.clipId}:\n ${problems.join("\n ")}`);
|
|
766
|
+
}
|
|
767
|
+
writeFileSync(join(this.options.outputDir, "timeline.json"), `${JSON.stringify(this.timeline, null, 2)}\n`);
|
|
768
|
+
this.timing.writeMs += performance.now() - startedAt;
|
|
769
|
+
return this.timeline;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
export { planLoop as loop };
|
|
773
|
+
//# sourceMappingURL=session.js.map
|