@openvidstudio/capture 0.0.0-stage → 1.4.0
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/LICENSE +202 -0
- package/README.md +93 -3
- package/dist/browser.d.ts +161 -0
- package/dist/browser.js +299 -0
- package/dist/captureSidecar.d.ts +43 -0
- package/dist/captureSidecar.js +65 -0
- package/dist/cursor.d.ts +53 -0
- package/dist/cursor.js +54 -0
- package/dist/determinism.d.ts +34 -0
- package/dist/determinism.js +33 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +36 -0
- package/dist/layers.d.ts +44 -0
- package/dist/layers.js +115 -0
- package/dist/native.d.ts +126 -0
- package/dist/native.js +240 -0
- package/dist/process.d.ts +20 -0
- package/dist/process.js +46 -0
- package/dist/settle.d.ts +69 -0
- package/dist/settle.js +124 -0
- package/dist/terminal.d.ts +117 -0
- package/dist/terminal.js +232 -0
- package/package.json +44 -3
package/dist/browser.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.chromium = exports.ZoomCompensationError = exports.ZOOM_EPSILON = exports.interactionSchema = exports.viewportSchema = exports.DEFAULT_DEVICE_SCALE = exports.DEFAULT_VIEWPORT = void 0;
|
|
4
|
+
exports.replayInteractions = replayInteractions;
|
|
5
|
+
exports.detectAndCompensateZoom = detectAndCompensateZoom;
|
|
6
|
+
exports.launchChromium = launchChromium;
|
|
7
|
+
exports.resolvedPlaywrightVersion = resolvedPlaywrightVersion;
|
|
8
|
+
const playwright_1 = require("playwright");
|
|
9
|
+
Object.defineProperty(exports, "chromium", { enumerable: true, get: function () { return playwright_1.chromium; } });
|
|
10
|
+
const settle_1 = require("./settle");
|
|
11
|
+
const zod_1 = require("zod");
|
|
12
|
+
/** CAPTURE.md's worked example. */
|
|
13
|
+
exports.DEFAULT_VIEWPORT = { width: 1600, height: 1000 };
|
|
14
|
+
/**
|
|
15
|
+
* Capture at twice the CSS resolution by default.
|
|
16
|
+
*
|
|
17
|
+
* This is the single biggest quality lever in the pipeline and it used to be missing.
|
|
18
|
+
* A capture taken at 1x is exactly as many pixels as the viewport, and the video then
|
|
19
|
+
* puts it on a 1920x1080 stage and pushes a camera into it. Every one of those steps
|
|
20
|
+
* is an upscale, so a 1440x900 capture shown at a 1.33 camera scale is being asked for
|
|
21
|
+
* roughly 1.8x the pixels it has, and the result is the soft, slightly smeared text
|
|
22
|
+
* that makes a demo look cheap.
|
|
23
|
+
*
|
|
24
|
+
* At 2x there are pixels to spare for both the stage and the push-in, so the frame stays
|
|
25
|
+
* sharp all the way through. The cost is memory and file size during capture, which is
|
|
26
|
+
* the right trade for an asset that gets rendered once and watched many times.
|
|
27
|
+
*/
|
|
28
|
+
exports.DEFAULT_DEVICE_SCALE = 2;
|
|
29
|
+
exports.viewportSchema = zod_1.z.object({
|
|
30
|
+
width: zod_1.z.number().int().positive(),
|
|
31
|
+
height: zod_1.z.number().int().positive(),
|
|
32
|
+
});
|
|
33
|
+
exports.interactionSchema = zod_1.z.discriminatedUnion("type", [
|
|
34
|
+
zod_1.z.object({ type: zod_1.z.literal("click"), selector: zod_1.z.string().min(1) }),
|
|
35
|
+
zod_1.z.object({ type: zod_1.z.literal("fill"), selector: zod_1.z.string().min(1), value: zod_1.z.string() }),
|
|
36
|
+
zod_1.z.object({ type: zod_1.z.literal("select"), selector: zod_1.z.string().min(1), value: zod_1.z.string() }),
|
|
37
|
+
zod_1.z.object({ type: zod_1.z.literal("hover"), selector: zod_1.z.string().min(1) }),
|
|
38
|
+
zod_1.z.object({
|
|
39
|
+
type: zod_1.z.literal("scroll"),
|
|
40
|
+
selector: zod_1.z.string().optional(),
|
|
41
|
+
x: zod_1.z.number().optional(),
|
|
42
|
+
y: zod_1.z.number().optional(),
|
|
43
|
+
// Undefined/0 keeps the old instant scrollTo -- an unrequested behaviour change would
|
|
44
|
+
// silently retime every beat that already has a scroll interaction. Positive is an
|
|
45
|
+
// eased scroll (see easedScrollInPage below) driven by requestAnimationFrame, since
|
|
46
|
+
// CSS's `behavior: "smooth"` gives no control over duration or curve and browsers are
|
|
47
|
+
// free to shorten or skip it, neither of which is acceptable for video timing.
|
|
48
|
+
durationMs: zod_1.z.number().positive().optional(),
|
|
49
|
+
}),
|
|
50
|
+
zod_1.z.object({ type: zod_1.z.literal("wait"), ms: zod_1.z.number().optional(), selector: zod_1.z.string().optional() }),
|
|
51
|
+
// Not a timed guess. "settle" drives whatever the previous step started (a modal
|
|
52
|
+
// transition, a route change, a lazily loaded image) to completion before the next step
|
|
53
|
+
// runs, which is what stops a capture landing halfway through a fade.
|
|
54
|
+
zod_1.z.object({ type: zod_1.z.literal("settle"), ms: zod_1.z.number().optional() }),
|
|
55
|
+
]);
|
|
56
|
+
/**
|
|
57
|
+
* Runs in the page against a scrollable element. `behavior: "smooth"` was rejected on
|
|
58
|
+
* purpose: it has no duration/easing parameters, a page can override its own
|
|
59
|
+
* scroll-behavior CSS out from under it, and browsers are free to shorten or skip a smooth
|
|
60
|
+
* scroll under reduced-motion or heavy load -- none of which is acceptable when the
|
|
61
|
+
* duration has to match the video's timeline exactly. Driven by requestAnimationFrame
|
|
62
|
+
* instead, resolving its own promise so replayInteractions can await the real end of the
|
|
63
|
+
* animation rather than a fixed sleep.
|
|
64
|
+
*/
|
|
65
|
+
/* c8 ignore start -- executes in the browser, covered by the real-browser E2E */
|
|
66
|
+
function easedScrollElementInPage(el, args) {
|
|
67
|
+
const target = el;
|
|
68
|
+
const startX = target.scrollLeft;
|
|
69
|
+
const startY = target.scrollTop;
|
|
70
|
+
const endX = args.x ?? startX;
|
|
71
|
+
const endY = args.y ?? startY;
|
|
72
|
+
const duration = args.durationMs;
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
const start = performance.now();
|
|
75
|
+
const step = (now) => {
|
|
76
|
+
const t = Math.min(1, (now - start) / duration);
|
|
77
|
+
// Cubic ease-in-out: slow away from the start, fast through the middle, slow into
|
|
78
|
+
// the landing point -- a linear scroll reads as mechanical on camera in a way an
|
|
79
|
+
// eased one does not.
|
|
80
|
+
const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
|
81
|
+
target.scrollTo(startX + (endX - startX) * eased, startY + (endY - startY) * eased);
|
|
82
|
+
if (t < 1)
|
|
83
|
+
requestAnimationFrame(step);
|
|
84
|
+
else
|
|
85
|
+
resolve();
|
|
86
|
+
};
|
|
87
|
+
requestAnimationFrame(step);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/* c8 ignore stop */
|
|
91
|
+
/** Same easing as easedScrollElementInPage, against the window instead of an element. */
|
|
92
|
+
/* c8 ignore start -- executes in the browser, covered by the real-browser E2E */
|
|
93
|
+
function easedScrollWindowInPage(args) {
|
|
94
|
+
const startX = window.scrollX;
|
|
95
|
+
const startY = window.scrollY;
|
|
96
|
+
const endX = args.x ?? startX;
|
|
97
|
+
const endY = args.y ?? startY;
|
|
98
|
+
const duration = args.durationMs;
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
const start = performance.now();
|
|
101
|
+
const step = (now) => {
|
|
102
|
+
const t = Math.min(1, (now - start) / duration);
|
|
103
|
+
const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
|
104
|
+
window.scrollTo(startX + (endX - startX) * eased, startY + (endY - startY) * eased);
|
|
105
|
+
if (t < 1)
|
|
106
|
+
requestAnimationFrame(step);
|
|
107
|
+
else
|
|
108
|
+
resolve();
|
|
109
|
+
};
|
|
110
|
+
requestAnimationFrame(step);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/* c8 ignore stop */
|
|
114
|
+
/**
|
|
115
|
+
* Measures a click/hover/fill/select target's real on-screen center right before the
|
|
116
|
+
* action runs -- same rect-measurement style as captureScreenshot.ts's cropSelector code.
|
|
117
|
+
* Scrolls the target into view first, matching the auto-scroll every one of these
|
|
118
|
+
* Playwright actions already does internally, so the coordinate lands in the same
|
|
119
|
+
* effective viewport space the final screenshot/recording is captured in rather than
|
|
120
|
+
* wherever the element happened to sit before the action's own scroll would have moved it.
|
|
121
|
+
*
|
|
122
|
+
* Best-effort and silent on failure: a selector that doesn't resolve here is about to make
|
|
123
|
+
* the action itself throw a real, clearer error a moment later, and this is not the call
|
|
124
|
+
* that should report it first.
|
|
125
|
+
*/
|
|
126
|
+
/* c8 ignore start -- executes in the browser, covered by the real-browser E2E */
|
|
127
|
+
async function recordCursorPoint(page, points, index, type, selector, replayStartedAt) {
|
|
128
|
+
try {
|
|
129
|
+
const center = await page.$eval(selector, (el) => {
|
|
130
|
+
el.scrollIntoView({ block: "center", inline: "center" });
|
|
131
|
+
const r = el.getBoundingClientRect();
|
|
132
|
+
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
|
133
|
+
});
|
|
134
|
+
points.push({ index, type, selector, x: center.x, y: center.y, atMs: Date.now() - replayStartedAt });
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// Selector didn't resolve or wasn't measurable -- not this function's job to report.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/* c8 ignore stop */
|
|
141
|
+
/**
|
|
142
|
+
* Replays a beat's interactions in array order. Shared verbatim by both capture
|
|
143
|
+
* tools so their replay semantics never drift apart -- called after navigation
|
|
144
|
+
* and (for both tools) after the zoom-desync compensation below, in the same
|
|
145
|
+
* effective CSS-pixel viewport CAPTURE.md measured.
|
|
146
|
+
*
|
|
147
|
+
* Returns every click/hover/fill/select target's real measured center, in replay order,
|
|
148
|
+
* for capture_screenshot/capture_screen_recording to persist as a cursor sidecar (see
|
|
149
|
+
* cursor.ts) -- this is what lets scaffold_scene's cursor overlay land on real coordinates
|
|
150
|
+
* instead of a hand-authored guess.
|
|
151
|
+
*/
|
|
152
|
+
async function replayInteractions(page, interactions) {
|
|
153
|
+
const points = [];
|
|
154
|
+
const replayStartedAt = Date.now();
|
|
155
|
+
let index = 0;
|
|
156
|
+
for (const interaction of interactions ?? []) {
|
|
157
|
+
switch (interaction.type) {
|
|
158
|
+
case "click":
|
|
159
|
+
await recordCursorPoint(page, points, index, "click", interaction.selector, replayStartedAt);
|
|
160
|
+
await page.click(interaction.selector);
|
|
161
|
+
break;
|
|
162
|
+
case "fill":
|
|
163
|
+
await recordCursorPoint(page, points, index, "fill", interaction.selector, replayStartedAt);
|
|
164
|
+
await page.fill(interaction.selector, interaction.value);
|
|
165
|
+
break;
|
|
166
|
+
case "select":
|
|
167
|
+
await recordCursorPoint(page, points, index, "select", interaction.selector, replayStartedAt);
|
|
168
|
+
await page.selectOption(interaction.selector, interaction.value);
|
|
169
|
+
break;
|
|
170
|
+
case "hover":
|
|
171
|
+
await recordCursorPoint(page, points, index, "hover", interaction.selector, replayStartedAt);
|
|
172
|
+
await page.hover(interaction.selector);
|
|
173
|
+
break;
|
|
174
|
+
case "scroll":
|
|
175
|
+
if (interaction.durationMs && interaction.durationMs > 0) {
|
|
176
|
+
const args = { x: interaction.x, y: interaction.y, durationMs: interaction.durationMs };
|
|
177
|
+
if (interaction.selector) {
|
|
178
|
+
await page.$eval(interaction.selector, easedScrollElementInPage, args);
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
await page.evaluate(easedScrollWindowInPage, args);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else if (interaction.selector) {
|
|
185
|
+
await page.$eval(interaction.selector, (el, coords) => el.scrollTo(coords.x ?? 0, coords.y ?? 0), { x: interaction.x, y: interaction.y });
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
await page.evaluate((coords) => window.scrollTo(coords.x ?? 0, coords.y ?? 0), { x: interaction.x, y: interaction.y });
|
|
189
|
+
}
|
|
190
|
+
break;
|
|
191
|
+
case "wait":
|
|
192
|
+
if (interaction.selector) {
|
|
193
|
+
await page.waitForSelector(interaction.selector, { timeout: interaction.ms });
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
await page.waitForTimeout(interaction.ms ?? 500);
|
|
197
|
+
}
|
|
198
|
+
break;
|
|
199
|
+
case "settle":
|
|
200
|
+
await (0, settle_1.settlePage)(page, { timeoutMs: interaction.ms });
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
index++;
|
|
204
|
+
}
|
|
205
|
+
return points;
|
|
206
|
+
}
|
|
207
|
+
exports.ZOOM_EPSILON = 0.01;
|
|
208
|
+
/**
|
|
209
|
+
* Thrown when a single compensation attempt doesn't converge -- CAPTURE.md
|
|
210
|
+
* doesn't explicitly cover this case (it assumes the desync ratio is
|
|
211
|
+
* consistent), so this is surfaced as a real error rather than silently
|
|
212
|
+
* proceeding with a crop rect measured against the wrong effective viewport.
|
|
213
|
+
*/
|
|
214
|
+
class ZoomCompensationError extends Error {
|
|
215
|
+
constructor(target, zoom, effective) {
|
|
216
|
+
super(`Zoom-desync compensation did not converge: requested ${target.width}x${target.height}, measured zoom ` +
|
|
217
|
+
`${zoom.toFixed(4)} (per CAPTURE.md, this ratio is not hardcoded, it can differ per machine/profile), ` +
|
|
218
|
+
`but after one compensation attempt the effective viewport was ${effective.width}x${effective.height}, ` +
|
|
219
|
+
`not ${target.width}x${target.height} within ${(exports.ZOOM_EPSILON * 100).toFixed(0)}%. Refusing to proceed ` +
|
|
220
|
+
`with a screenshot/crop measured against the wrong effective viewport -- see packages/docs/CAPTURE.md.`);
|
|
221
|
+
this.target = target;
|
|
222
|
+
this.zoom = zoom;
|
|
223
|
+
this.effective = effective;
|
|
224
|
+
this.name = "ZoomCompensationError";
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
exports.ZoomCompensationError = ZoomCompensationError;
|
|
228
|
+
async function measureEffectiveViewport(page) {
|
|
229
|
+
const [width, height] = await page.evaluate(() => [window.innerWidth, window.innerHeight]);
|
|
230
|
+
return { width, height };
|
|
231
|
+
}
|
|
232
|
+
function relativeDiff(actual, target) {
|
|
233
|
+
return Math.abs(actual - target) / target;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* CAPTURE.md's zoom-desync detection + compensation (protocol steps 1-2).
|
|
237
|
+
* Assumes the caller already called `page.setViewportSize(target)` and
|
|
238
|
+
* `page.goto(...)` -- this only measures and, if needed, re-requests a
|
|
239
|
+
* compensated viewport and re-verifies once.
|
|
240
|
+
*
|
|
241
|
+
* Never hardcodes a zoom constant (CAPTURE.md is explicit the ratio "can
|
|
242
|
+
* differ per machine/profile") -- it is measured live on every call. If the
|
|
243
|
+
* one compensation attempt doesn't bring the effective viewport back to
|
|
244
|
+
* `target` within `ZOOM_EPSILON`, throws `ZoomCompensationError` rather than
|
|
245
|
+
* silently proceeding.
|
|
246
|
+
*/
|
|
247
|
+
async function detectAndCompensateZoom(page, target) {
|
|
248
|
+
const firstMeasurement = await measureEffectiveViewport(page);
|
|
249
|
+
const zoom = target.width / firstMeasurement.width;
|
|
250
|
+
if (Math.abs(zoom - 1) <= exports.ZOOM_EPSILON) {
|
|
251
|
+
return { viewport: target, zoom, effective: firstMeasurement };
|
|
252
|
+
}
|
|
253
|
+
const compensated = {
|
|
254
|
+
width: Math.round(target.width * zoom),
|
|
255
|
+
height: Math.round(target.height * zoom),
|
|
256
|
+
};
|
|
257
|
+
await page.setViewportSize(compensated);
|
|
258
|
+
const secondMeasurement = await measureEffectiveViewport(page);
|
|
259
|
+
const converged = relativeDiff(secondMeasurement.width, target.width) <= exports.ZOOM_EPSILON &&
|
|
260
|
+
relativeDiff(secondMeasurement.height, target.height) <= exports.ZOOM_EPSILON;
|
|
261
|
+
if (!converged) {
|
|
262
|
+
throw new ZoomCompensationError(target, zoom, secondMeasurement);
|
|
263
|
+
}
|
|
264
|
+
return { viewport: compensated, zoom, effective: secondMeasurement };
|
|
265
|
+
}
|
|
266
|
+
const BROWSER_NOT_INSTALLED_RE = /Executable doesn't exist at/;
|
|
267
|
+
/**
|
|
268
|
+
* Playwright's bundled browser binary is not installed by a plain `npm
|
|
269
|
+
* install`/`pnpm install` (it needs a separate `npx playwright install
|
|
270
|
+
* chromium`, deliberately not wired as a postinstall script -- see this
|
|
271
|
+
* package's README/tool descriptions). Rather than let that surface as a
|
|
272
|
+
* cryptic native-binary launch error, catch it here and return a clear,
|
|
273
|
+
* actionable message.
|
|
274
|
+
*/
|
|
275
|
+
async function launchChromium() {
|
|
276
|
+
try {
|
|
277
|
+
return await playwright_1.chromium.launch({ headless: true });
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
281
|
+
if (BROWSER_NOT_INSTALLED_RE.test(message)) {
|
|
282
|
+
throw new Error(`Playwright's Chromium browser is not installed. Run "npx playwright install chromium" (in the ` +
|
|
283
|
+
`project, or wherever @openvidstudio/mcp-server is installed) and retry. Original error: ${message}`);
|
|
284
|
+
}
|
|
285
|
+
throw err;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* The version of the `chromium` BrowserType re-exported above, read from that same resolved
|
|
290
|
+
* package rather than a fresh "playwright" lookup, so a report naming this version can never
|
|
291
|
+
* name a different install than the one executablePath() below actually resolved against.
|
|
292
|
+
*/
|
|
293
|
+
function resolvedPlaywrightVersion() {
|
|
294
|
+
// A plain require for one field off a package.json, not a code import -- this package's
|
|
295
|
+
// commonjs target has no resolveJsonModule story that would make `import ... from
|
|
296
|
+
// "playwright/package.json"` resolve the same way `import { chromium } from "playwright"`
|
|
297
|
+
// above already does.
|
|
298
|
+
return require("playwright/package.json").version;
|
|
299
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Interaction, Viewport } from "./browser";
|
|
2
|
+
/**
|
|
3
|
+
* What docs_drift and release_diff need to re-capture a reference frame the SAME way it
|
|
4
|
+
* was captured the first time, instead of guessing a CSS viewport back out of the reference
|
|
5
|
+
* image's own pixel dimensions.
|
|
6
|
+
*
|
|
7
|
+
* That guess is the bug this sidecar exists to kill: a screenshot taken at a 1440x900 CSS
|
|
8
|
+
* viewport with deviceScaleFactor 2 is a 2880x1800 PNG on disk, and treating 2880x1800 as
|
|
9
|
+
* the viewport to re-navigate to is a completely different, much wider layout -- not the
|
|
10
|
+
* page that was actually captured, just a bigger one. Every re-capture built on that guess
|
|
11
|
+
* reports drift on a page that never changed.
|
|
12
|
+
*
|
|
13
|
+
* `viewport` here is the *requested* CSS viewport (the same value passed to
|
|
14
|
+
* `detectAndCompensateZoom` as `target`), not the compensated size that call returned.
|
|
15
|
+
* Re-feeding the request through that same live zoom-detection path on whatever machine
|
|
16
|
+
* does the re-capture is what CAPTURE.md's "never hardcode the ratio, it differs per
|
|
17
|
+
* machine/profile" rule requires -- baking in one machine's already-compensated size would
|
|
18
|
+
* just move the guessing problem, not remove it. `zoom` is carried along anyway, purely as
|
|
19
|
+
* a record of what this capture measured; nothing re-derives a viewport from it.
|
|
20
|
+
*/
|
|
21
|
+
export interface CaptureSidecar {
|
|
22
|
+
url: string;
|
|
23
|
+
viewport: Viewport;
|
|
24
|
+
deviceScaleFactor: number;
|
|
25
|
+
/** The zoom-desync ratio measured at capture time. Informational: a re-capture measures its own. */
|
|
26
|
+
zoom: number;
|
|
27
|
+
/** Replayed before the capture. Absent when there were none. */
|
|
28
|
+
interactions?: Interaction[];
|
|
29
|
+
/** ISO-8601 timestamp of the capture. */
|
|
30
|
+
capturedAt: string;
|
|
31
|
+
}
|
|
32
|
+
/** Sidecar path for a capture artifact, mirroring settle's/cursor's `<outPath>.*.json` convention. */
|
|
33
|
+
export declare function captureSidecarPath(outPathAbs: string): string;
|
|
34
|
+
/**
|
|
35
|
+
* Persists a CaptureSidecar next to the artifact it describes. Written for every capture,
|
|
36
|
+
* cropSelector-based ones included: docs_drift already requires an explicit binding for a
|
|
37
|
+
* cropSelector beat (its reference is a crop of a page, not a view of one), and this sidecar
|
|
38
|
+
* does not change that, it just gives every OTHER beat a real binding to read instead of a
|
|
39
|
+
* guess.
|
|
40
|
+
*/
|
|
41
|
+
export declare function writeCaptureSidecar(outPathAbs: string, sidecar: CaptureSidecar): void;
|
|
42
|
+
/** Reads the sidecar for a capture artifact, or undefined when there isn't one (a legacy asset). */
|
|
43
|
+
export declare function readCaptureSidecar(outPathAbs: string): CaptureSidecar | undefined;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.captureSidecarPath = captureSidecarPath;
|
|
37
|
+
exports.writeCaptureSidecar = writeCaptureSidecar;
|
|
38
|
+
exports.readCaptureSidecar = readCaptureSidecar;
|
|
39
|
+
const fs = __importStar(require("node:fs"));
|
|
40
|
+
/** Sidecar path for a capture artifact, mirroring settle's/cursor's `<outPath>.*.json` convention. */
|
|
41
|
+
function captureSidecarPath(outPathAbs) {
|
|
42
|
+
return `${outPathAbs}.capture.json`;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Persists a CaptureSidecar next to the artifact it describes. Written for every capture,
|
|
46
|
+
* cropSelector-based ones included: docs_drift already requires an explicit binding for a
|
|
47
|
+
* cropSelector beat (its reference is a crop of a page, not a view of one), and this sidecar
|
|
48
|
+
* does not change that, it just gives every OTHER beat a real binding to read instead of a
|
|
49
|
+
* guess.
|
|
50
|
+
*/
|
|
51
|
+
function writeCaptureSidecar(outPathAbs, sidecar) {
|
|
52
|
+
fs.writeFileSync(captureSidecarPath(outPathAbs), JSON.stringify(sidecar, null, 2) + "\n", "utf8");
|
|
53
|
+
}
|
|
54
|
+
/** Reads the sidecar for a capture artifact, or undefined when there isn't one (a legacy asset). */
|
|
55
|
+
function readCaptureSidecar(outPathAbs) {
|
|
56
|
+
const sidecarPath = captureSidecarPath(outPathAbs);
|
|
57
|
+
if (!fs.existsSync(sidecarPath))
|
|
58
|
+
return undefined;
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(fs.readFileSync(sidecarPath, "utf8"));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
package/dist/cursor.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Viewport } from "./browser";
|
|
2
|
+
/**
|
|
3
|
+
* Real cursor coordinates recorded during replayInteractions, so scaffold_scene's cursor
|
|
4
|
+
* overlay can show the actor landing where the product was actually clicked instead of a
|
|
5
|
+
* hand-drawn guess.
|
|
6
|
+
*
|
|
7
|
+
* The old way: a scene author who wanted a cursor built one from primitives, picking x/y
|
|
8
|
+
* keyframes by eye against a static screenshot. Nothing tied those numbers to where the
|
|
9
|
+
* real click during capture happened, so a redesign that moved a button left the cursor
|
|
10
|
+
* pointing at empty space with no error anywhere -- the same silent-wrongness failure mode
|
|
11
|
+
* validate_scenes exists to catch for framing, just uncaught for this.
|
|
12
|
+
*/
|
|
13
|
+
export type CursorPointType = "click" | "hover" | "fill" | "select";
|
|
14
|
+
export interface CursorPoint {
|
|
15
|
+
/** Index into the interactions array this point came from, in replay order. */
|
|
16
|
+
index: number;
|
|
17
|
+
type: CursorPointType;
|
|
18
|
+
selector: string;
|
|
19
|
+
/**
|
|
20
|
+
* The target's bounding-rect center, in the CSS-pixel viewport space the capture ran in
|
|
21
|
+
* (the same effective, zoom-compensated space captureWidth/captureHeight describe) --
|
|
22
|
+
* NOT multiplied by deviceScaleFactor. Measured after scrolling the target into view,
|
|
23
|
+
* matching what a real click/hover action does before it acts.
|
|
24
|
+
*/
|
|
25
|
+
x: number;
|
|
26
|
+
y: number;
|
|
27
|
+
/**
|
|
28
|
+
* Milliseconds since replayInteractions started measuring this beat's interactions.
|
|
29
|
+
* capture_screen_recording rewrites this to be relative to the RECORDING's own start
|
|
30
|
+
* (adding back the navigation+settle time that ran before replay began) before writing
|
|
31
|
+
* the sidecar, so a consumer can turn it into a frame via fps directly. capture_screenshot
|
|
32
|
+
* leaves it replay-relative: a still has no video timeline for it to be relative to.
|
|
33
|
+
*/
|
|
34
|
+
atMs: number;
|
|
35
|
+
}
|
|
36
|
+
export interface CursorSidecar {
|
|
37
|
+
/**
|
|
38
|
+
* The CSS-pixel viewport the points were measured in. Carried here explicitly rather
|
|
39
|
+
* than assumed, so a consumer can convert a point into its own stage/frame geometry
|
|
40
|
+
* without having to reprobe or guess the capture's own viewport.
|
|
41
|
+
*/
|
|
42
|
+
viewport: Viewport;
|
|
43
|
+
points: CursorPoint[];
|
|
44
|
+
}
|
|
45
|
+
export declare function cursorSidecarPath(outPathAbs: string): string;
|
|
46
|
+
/**
|
|
47
|
+
* Persists recorded cursor points next to the artifact they describe, mirroring settle's
|
|
48
|
+
* `<outPath>.settle.json` sidecar convention. Writes nothing when there are no points, so a
|
|
49
|
+
* beat with no click/hover/fill/select interactions (most `dom-demo`-adjacent capture
|
|
50
|
+
* beats, or one that only scrolls/waits) produces no sidecar rather than an empty,
|
|
51
|
+
* meaningless file that scaffold_scene would otherwise have to special-case around.
|
|
52
|
+
*/
|
|
53
|
+
export declare function writeCursorSidecar(outPathAbs: string, viewport: Viewport, points: CursorPoint[]): void;
|
package/dist/cursor.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.cursorSidecarPath = cursorSidecarPath;
|
|
37
|
+
exports.writeCursorSidecar = writeCursorSidecar;
|
|
38
|
+
const fs = __importStar(require("node:fs"));
|
|
39
|
+
function cursorSidecarPath(outPathAbs) {
|
|
40
|
+
return `${outPathAbs}.cursor.json`;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Persists recorded cursor points next to the artifact they describe, mirroring settle's
|
|
44
|
+
* `<outPath>.settle.json` sidecar convention. Writes nothing when there are no points, so a
|
|
45
|
+
* beat with no click/hover/fill/select interactions (most `dom-demo`-adjacent capture
|
|
46
|
+
* beats, or one that only scrolls/waits) produces no sidecar rather than an empty,
|
|
47
|
+
* meaningless file that scaffold_scene would otherwise have to special-case around.
|
|
48
|
+
*/
|
|
49
|
+
function writeCursorSidecar(outPathAbs, viewport, points) {
|
|
50
|
+
if (points.length === 0)
|
|
51
|
+
return;
|
|
52
|
+
const sidecar = { viewport, points };
|
|
53
|
+
fs.writeFileSync(cursorSidecarPath(outPathAbs), JSON.stringify(sidecar, null, 2) + "\n", "utf8");
|
|
54
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Page } from "playwright";
|
|
2
|
+
/**
|
|
3
|
+
* Opt-in determinism for capture_screenshot/capture_screen_recording, so two captures of
|
|
4
|
+
* an unchanged page are byte-identical instead of differing on whatever the page derived
|
|
5
|
+
* from the real clock or Math.random() (a timestamp-based id, a typewriter effect keyed off
|
|
6
|
+
* Date.now(), a randomly ordered list).
|
|
7
|
+
*
|
|
8
|
+
* Deliberately uses `clock.setFixedTime`, not `clock.install`/`pauseAt`: those replace
|
|
9
|
+
* setTimeout/setInterval/requestAnimationFrame with fake, manually-driven timers, which
|
|
10
|
+
* would also freeze settlePage's own real-time deadline race (it runs *inside* the page via
|
|
11
|
+
* page.evaluate) and every CSS/JS animation settlePage is waiting on -- exactly the two
|
|
12
|
+
* things a capture still needs to actually finish. `setFixedTime` freezes only what
|
|
13
|
+
* `Date.now()`/`new Date()` report, and "keeps all the timers running" (Playwright's own
|
|
14
|
+
* wording), so nothing about settle or animation timing changes.
|
|
15
|
+
*
|
|
16
|
+
* Forcing a CSS animation to a specific phase, or a JS-driven one to a deterministic state,
|
|
17
|
+
* is a separate, harder problem (reducedMotion emulation gets partway there but does not
|
|
18
|
+
* solve it) and is intentionally out of scope here.
|
|
19
|
+
*/
|
|
20
|
+
export interface DeterminismOptions {
|
|
21
|
+
/** Fixed value Date.now()/new Date() report for the whole capture. */
|
|
22
|
+
epochMs?: number;
|
|
23
|
+
/** Seed for the Math.random() override. */
|
|
24
|
+
seed?: number;
|
|
25
|
+
}
|
|
26
|
+
/** An arbitrary fixed instant -- any constant works, what matters is that it never changes. */
|
|
27
|
+
export declare const DEFAULT_DETERMINISTIC_EPOCH_MS: number;
|
|
28
|
+
export declare const DEFAULT_DETERMINISTIC_SEED = 49734321;
|
|
29
|
+
/**
|
|
30
|
+
* Installs both overrides. Call after the page exists but before navigation -- same timing
|
|
31
|
+
* rule as colorScheme/reducedMotion: applied after the first paint, this would race a page
|
|
32
|
+
* that reads Math.random()/Date.now() once at load and caches the result.
|
|
33
|
+
*/
|
|
34
|
+
export declare function installDeterminism(page: Page, opts?: DeterminismOptions): Promise<void>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_DETERMINISTIC_SEED = exports.DEFAULT_DETERMINISTIC_EPOCH_MS = void 0;
|
|
4
|
+
exports.installDeterminism = installDeterminism;
|
|
5
|
+
/** An arbitrary fixed instant -- any constant works, what matters is that it never changes. */
|
|
6
|
+
exports.DEFAULT_DETERMINISTIC_EPOCH_MS = Date.UTC(2024, 0, 1);
|
|
7
|
+
exports.DEFAULT_DETERMINISTIC_SEED = 0x2f6e2b1;
|
|
8
|
+
/**
|
|
9
|
+
* Runs in the page, injected via addInitScript before any page script. A small fixed-seed
|
|
10
|
+
* PRNG (mulberry32) so the override needs no dependency and stays reproducible across Node
|
|
11
|
+
* versions/platforms, unlike reseeding whatever RNG the JS engine's own Math.random() uses
|
|
12
|
+
* internally (which isn't seedable at all).
|
|
13
|
+
*/
|
|
14
|
+
/* c8 ignore start -- executes in the browser, covered by the real-browser E2E */
|
|
15
|
+
function seedMathRandomInPage(seed) {
|
|
16
|
+
let state = seed >>> 0;
|
|
17
|
+
Math.random = () => {
|
|
18
|
+
state = (state + 0x6d2b79f5) | 0;
|
|
19
|
+
let t = Math.imul(state ^ (state >>> 15), 1 | state);
|
|
20
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
21
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/* c8 ignore stop */
|
|
25
|
+
/**
|
|
26
|
+
* Installs both overrides. Call after the page exists but before navigation -- same timing
|
|
27
|
+
* rule as colorScheme/reducedMotion: applied after the first paint, this would race a page
|
|
28
|
+
* that reads Math.random()/Date.now() once at load and caches the result.
|
|
29
|
+
*/
|
|
30
|
+
async function installDeterminism(page, opts = {}) {
|
|
31
|
+
await page.addInitScript(seedMathRandomInPage, opts.seed ?? exports.DEFAULT_DETERMINISTIC_SEED);
|
|
32
|
+
await page.clock.setFixedTime(opts.epochMs ?? exports.DEFAULT_DETERMINISTIC_EPOCH_MS);
|
|
33
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @openvidstudio/capture
|
|
3
|
+
*
|
|
4
|
+
* The half of openvidstudio that has nothing to do with video: getting a true frame out
|
|
5
|
+
* of a running product. A real browser with the per-origin zoom desync measured and
|
|
6
|
+
* compensated rather than assumed, interaction replay, and ffmpeg/adb/simctl backends for
|
|
7
|
+
* the desktop, mobile, and terminal surfaces a browser cannot reach.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately free of Remotion, React, and @openvidstudio/core: a team that wants
|
|
10
|
+
* trustworthy capture and does not want to make videos should be able to take this alone.
|
|
11
|
+
*/
|
|
12
|
+
export * from "./process";
|
|
13
|
+
export * from "./browser";
|
|
14
|
+
export * from "./determinism";
|
|
15
|
+
export * from "./settle";
|
|
16
|
+
export * from "./cursor";
|
|
17
|
+
export * from "./captureSidecar";
|
|
18
|
+
export * from "./native";
|
|
19
|
+
export * from "./terminal";
|
|
20
|
+
export * from "./layers";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
/**
|
|
18
|
+
* @openvidstudio/capture
|
|
19
|
+
*
|
|
20
|
+
* The half of openvidstudio that has nothing to do with video: getting a true frame out
|
|
21
|
+
* of a running product. A real browser with the per-origin zoom desync measured and
|
|
22
|
+
* compensated rather than assumed, interaction replay, and ffmpeg/adb/simctl backends for
|
|
23
|
+
* the desktop, mobile, and terminal surfaces a browser cannot reach.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately free of Remotion, React, and @openvidstudio/core: a team that wants
|
|
26
|
+
* trustworthy capture and does not want to make videos should be able to take this alone.
|
|
27
|
+
*/
|
|
28
|
+
__exportStar(require("./process"), exports);
|
|
29
|
+
__exportStar(require("./browser"), exports);
|
|
30
|
+
__exportStar(require("./determinism"), exports);
|
|
31
|
+
__exportStar(require("./settle"), exports);
|
|
32
|
+
__exportStar(require("./cursor"), exports);
|
|
33
|
+
__exportStar(require("./captureSidecar"), exports);
|
|
34
|
+
__exportStar(require("./native"), exports);
|
|
35
|
+
__exportStar(require("./terminal"), exports);
|
|
36
|
+
__exportStar(require("./layers"), exports);
|