@openvidstudio/capture 0.0.0-stage → 1.3.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.
@@ -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
+ }
@@ -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
+ }
@@ -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);