cbrowser 18.70.0 → 18.71.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.
Files changed (67) hide show
  1. package/dist/browser.d.ts +32 -0
  2. package/dist/browser.d.ts.map +1 -1
  3. package/dist/browser.js +46 -2
  4. package/dist/browser.js.map +1 -1
  5. package/dist/cli.js +1465 -24
  6. package/dist/cli.js.map +1 -1
  7. package/dist/cognitive/index.d.ts +10 -0
  8. package/dist/cognitive/index.d.ts.map +1 -1
  9. package/dist/cognitive/index.js +8 -0
  10. package/dist/cognitive/index.js.map +1 -1
  11. package/dist/daemon.d.ts.map +1 -1
  12. package/dist/daemon.js +151 -3
  13. package/dist/daemon.js.map +1 -1
  14. package/dist/mcp-server.d.ts.map +1 -1
  15. package/dist/mcp-server.js +67 -0
  16. package/dist/mcp-server.js.map +1 -1
  17. package/dist/mcp-tools/base/capture-tools.d.ts +189 -0
  18. package/dist/mcp-tools/base/capture-tools.d.ts.map +1 -0
  19. package/dist/mcp-tools/base/capture-tools.js +667 -0
  20. package/dist/mcp-tools/base/capture-tools.js.map +1 -0
  21. package/dist/mcp-tools/base/gif-tools.d.ts +19 -0
  22. package/dist/mcp-tools/base/gif-tools.d.ts.map +1 -1
  23. package/dist/mcp-tools/base/gif-tools.js +45 -27
  24. package/dist/mcp-tools/base/gif-tools.js.map +1 -1
  25. package/dist/mcp-tools/base/index.d.ts +2 -1
  26. package/dist/mcp-tools/base/index.d.ts.map +1 -1
  27. package/dist/mcp-tools/base/index.js +5 -1
  28. package/dist/mcp-tools/base/index.js.map +1 -1
  29. package/dist/mcp-tools/tool-categories.d.ts.map +1 -1
  30. package/dist/mcp-tools/tool-categories.js +11 -0
  31. package/dist/mcp-tools/tool-categories.js.map +1 -1
  32. package/dist/recording/auto-capture.d.ts +72 -0
  33. package/dist/recording/auto-capture.d.ts.map +1 -0
  34. package/dist/recording/auto-capture.js +155 -0
  35. package/dist/recording/auto-capture.js.map +1 -0
  36. package/dist/recording/contact-sheet.d.ts +65 -0
  37. package/dist/recording/contact-sheet.d.ts.map +1 -0
  38. package/dist/recording/contact-sheet.js +204 -0
  39. package/dist/recording/contact-sheet.js.map +1 -0
  40. package/dist/recording/encoders.d.ts +101 -0
  41. package/dist/recording/encoders.d.ts.map +1 -0
  42. package/dist/recording/encoders.js +304 -0
  43. package/dist/recording/encoders.js.map +1 -0
  44. package/dist/recording/engine.d.ts +263 -0
  45. package/dist/recording/engine.d.ts.map +1 -0
  46. package/dist/recording/engine.js +890 -0
  47. package/dist/recording/engine.js.map +1 -0
  48. package/dist/recording/ssim.d.ts +35 -0
  49. package/dist/recording/ssim.d.ts.map +1 -0
  50. package/dist/recording/ssim.js +113 -0
  51. package/dist/recording/ssim.js.map +1 -0
  52. package/dist/recording/timing.d.ts +124 -0
  53. package/dist/recording/timing.d.ts.map +1 -0
  54. package/dist/recording/timing.js +251 -0
  55. package/dist/recording/timing.js.map +1 -0
  56. package/dist/recording/types.d.ts +213 -0
  57. package/dist/recording/types.d.ts.map +1 -0
  58. package/dist/recording/types.js +171 -0
  59. package/dist/recording/types.js.map +1 -0
  60. package/dist/testing/nl-test-suite.d.ts +10 -0
  61. package/dist/testing/nl-test-suite.d.ts.map +1 -1
  62. package/dist/testing/nl-test-suite.js +13 -1
  63. package/dist/testing/nl-test-suite.js.map +1 -1
  64. package/dist/types.d.ts +22 -0
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/types.js.map +1 -1
  67. package/package.json +1 -1
@@ -0,0 +1,890 @@
1
+ /**
2
+ * CBrowser - Cognitive Browser Automation
3
+ * Copyright 2026 Alexandria Eden alexandria.shai.eden@gmail.com
4
+ * Learn more at https://cbrowser.ai - MIT License
5
+ */
6
+ /**
7
+ * Frame-capture engine for screen recordings.
8
+ *
9
+ * Chromium capture goes through the CDP screencast (`Page.startScreencast`),
10
+ * which is **event-driven, not clocked**: the browser emits a frame when the
11
+ * page paints, so a static page emits nothing at all. Three consequences run
12
+ * through this whole file:
13
+ *
14
+ * - The clock is wall time. Every frame is stamped at receipt, and playback
15
+ * delays are derived from those stamps. Nothing is ever synthesised to fill
16
+ * a quiet stretch; quiet stretches are recorded as `frame_gaps`.
17
+ * - A recording always brackets its window with a directly-captured frame at
18
+ * the start and (when the stream has gone quiet) at the end, so a completely
19
+ * static page still produces a valid recording rather than an empty one.
20
+ * - Frames stream to disk as they arrive. Nothing accumulates the full frame
21
+ * set in memory, so recording length is bounded by disk, not RAM.
22
+ *
23
+ * Screencast frames arrive full-viewport - CDP takes no clip parameter - so
24
+ * the frames on disk are always the whole viewport. Cropping is a post-hoc
25
+ * concern, which is what keeps a recording re-croppable after the fact.
26
+ *
27
+ * Firefox and WebKit have no screencast; they fall back to a screenshot loop.
28
+ *
29
+ * @since v18.70.0
30
+ */
31
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
32
+ import { join, relative } from "node:path";
33
+ import { buildContactSheet } from "./contact-sheet.js";
34
+ import { encodeGif, encodeMp4, encodeWebm, encodeWebp, MissingEncoderError } from "./encoders.js";
35
+ import { detectChangePoints, frameSignature, ssim } from "./ssim.js";
36
+ import { clampRect, expandForPlayback, findFrameGaps } from "./timing.js";
37
+ import { MANIFEST_VERSION, parseManifest, } from "./types.js";
38
+ /** Default ceiling on how long any trigger may wait. */
39
+ export const DEFAULT_TRIGGER_TIMEOUT_MS = 30_000;
40
+ /** Human-readable trigger label for the manifest. */
41
+ function describeStartTrigger(trigger, delayMs, waitedMs) {
42
+ const base = trigger.kind === "lifecycle" ? trigger.event : `element:${trigger.selector}`;
43
+ const withDelay = delayMs > 0 ? `${base}+${delayMs}ms` : base;
44
+ return `${withDelay} (waited ${Math.round(waitedMs)}ms)`;
45
+ }
46
+ /** Human-readable stop-reason label for the manifest. */
47
+ function describeStopTrigger(trigger) {
48
+ switch (trigger.kind) {
49
+ case "element-appears": return `element-appears:${trigger.selector}`;
50
+ case "element-disappears": return `element-disappears:${trigger.selector}`;
51
+ case "idle": return `idle:${trigger.idleMs}ms`;
52
+ }
53
+ }
54
+ const DEFAULT_FPS = 10;
55
+ const DEFAULT_QUALITY = 80;
56
+ const DEFAULT_MAX_FRAMES = 3000;
57
+ /**
58
+ * SSIM below this against the previous frame marks a change point. Screencast
59
+ * JPEG noise on an unchanged region sits well above 0.99, so 0.9 separates real
60
+ * motion from compression jitter without firing on every frame of a video.
61
+ */
62
+ const CHANGE_THRESHOLD = 0.9;
63
+ /**
64
+ * SSIM below this marks a frame as a change point.
65
+ *
66
+ * Deliberately far stricter than CHANGE_THRESHOLD, which stays the bar for
67
+ * `anomaly` (a big visual jump). change_points answers the question the
68
+ * manifest exists to answer - "when did the page change" - and at 0.9 the
69
+ * answer was NOTHING for a form fill: text typed into an input on a 1280x800
70
+ * page scores ~0.998, and a whole daemon-recorded flow of two fills produced
71
+ * zero change points. Measured on this box: a genuinely still page scores
72
+ * exactly 1.00000, so 0.999 catches real changes without firing on JPEG noise.
73
+ */
74
+ const CHANGE_POINT_THRESHOLD = 0.999;
75
+ /**
76
+ * SSIM above this means "nothing moved" for the idle stop trigger.
77
+ *
78
+ * Same comparator as CHANGE_THRESHOLD, deliberately different sensitivity:
79
+ * change_points answers "did something SIGNIFICANT happen", idle answers "did
80
+ * anything happen at all". Measured on this box, consecutive frames of a
81
+ * continuously animating page score 0.81-0.97 while a genuinely still page
82
+ * scores exactly 1.00000, so 0.999 separates them with room to spare. Reusing
83
+ * 0.9 here reports a visibly animating page as idle.
84
+ */
85
+ const IDLE_THRESHOLD = 0.999;
86
+ /**
87
+ * Slack allowed when matching a paint event to its slot on the frame grid.
88
+ * Paint events land a few milliseconds either side of the nominal boundary;
89
+ * without this, every near-miss is deferred to the next slot and the effective
90
+ * rate drifts below the target.
91
+ */
92
+ const FRAME_DUE_TOLERANCE_MS = 8;
93
+ /**
94
+ * How far past the last frame the recording may end before `stop()` captures a
95
+ * closing frame, as a fraction of the frame interval.
96
+ */
97
+ const CLOSING_FRAME_THRESHOLD = 0.8;
98
+ /**
99
+ * One recording run. Not reusable: `start()` may be called once, and `stop()`
100
+ * is idempotent, returning the same result on every subsequent call.
101
+ */
102
+ export class VideoCaptureSession {
103
+ page;
104
+ engine;
105
+ baseDir;
106
+ opts;
107
+ startTrigger;
108
+ startDelayMs = 0;
109
+ stopTrigger;
110
+ target = { kind: "viewport" };
111
+ slug = "";
112
+ outDir = "";
113
+ framesDir = "";
114
+ state = "idle";
115
+ captureMethod = "cdp-screencast";
116
+ cdp = null;
117
+ loopTimer = null;
118
+ durationTimer = null;
119
+ stopping = null;
120
+ startWallMs = 0;
121
+ startedAtIso = "";
122
+ stopWallMs = 0;
123
+ frames = [];
124
+ nextDueMs = 0;
125
+ writeQueue = Promise.resolve();
126
+ consoleEvents = [];
127
+ networkEvents = [];
128
+ consoleListener = null;
129
+ requestListener = null;
130
+ viewport = { width: 0, height: 0, deviceScaleFactor: 1 };
131
+ regionClamped = false;
132
+ outputDims = { width: 0, height: 0 };
133
+ /**
134
+ * Output dimensions as whole pixels.
135
+ *
136
+ * NOT rounded to even: the GIF/WebP encoders now emit exactly these numbers,
137
+ * so a 393px-wide viewport produces a 393px artifact. Only the video path
138
+ * rounds up to even internally (yuv420p needs it), which is the one place a
139
+ * single pixel of difference is unavoidable.
140
+ */
141
+ static outputDim(value) {
142
+ return Math.max(1, Math.round(value));
143
+ }
144
+ /** Crop applied to frames captured right now; re-derived per frame for element targets. */
145
+ currentCrop = null;
146
+ /** Whether `currentCrop` still reflects a live element measurement. */
147
+ currentTracking = null;
148
+ elementLocator = null;
149
+ trackTimer = null;
150
+ startTriggerLabel = null;
151
+ stopTriggerLabel = null;
152
+ triggerTimedOut = false;
153
+ stopTriggerTimer = null;
154
+ stopTimeoutTimer = null;
155
+ /** Rolling SSIM state for the idle stop trigger. */
156
+ idleSignature = null;
157
+ lastChangeMs = 0;
158
+ idleScanQueue = Promise.resolve();
159
+ result = null;
160
+ finishedResolve;
161
+ /**
162
+ * Resolves when the capture has stopped from ANY source - explicit stop(),
163
+ * the duration timer, or a stop trigger. Callers that started an open-ended
164
+ * capture await this instead of assuming they are the ones who will end it.
165
+ */
166
+ finished = new Promise((resolve) => {
167
+ this.finishedResolve = resolve;
168
+ });
169
+ /**
170
+ * @param page Page to record. Must already be navigated - the engine never
171
+ * navigates, so the caller controls what is on screen at t=0.
172
+ * @param engine Browser family, which decides the capture method.
173
+ * @param baseDir Directory recordings are created under when no `outDir` is given.
174
+ */
175
+ constructor(page, engine, baseDir) {
176
+ this.page = page;
177
+ this.engine = engine;
178
+ this.baseDir = baseDir;
179
+ }
180
+ // =======================================================================
181
+ // Lifecycle
182
+ // =======================================================================
183
+ async start(options = {}) {
184
+ if (this.state !== "idle") {
185
+ throw new Error(`Recording already ${this.state}; construct a new VideoCaptureSession per run`);
186
+ }
187
+ const fps = options.fps ?? DEFAULT_FPS;
188
+ if (!Number.isFinite(fps) || fps <= 0) {
189
+ throw new Error(`Invalid fps ${options.fps} - must be a positive number`);
190
+ }
191
+ this.opts = {
192
+ fps,
193
+ durationMs: options.durationMs,
194
+ format: options.format ?? ["gif"],
195
+ quality: options.quality ?? DEFAULT_QUALITY,
196
+ maxFrames: options.maxFrames ?? DEFAULT_MAX_FRAMES,
197
+ contactSheet: options.contactSheet ?? false,
198
+ triggerTimeoutMs: options.triggerTimeoutMs ?? DEFAULT_TRIGGER_TIMEOUT_MS,
199
+ };
200
+ this.startTrigger = options.startTrigger;
201
+ this.startDelayMs = options.startDelayMs ?? 0;
202
+ this.stopTrigger = options.stopTrigger;
203
+ this.target = options.target ?? { kind: "viewport" };
204
+ this.slug = options.slug ?? `recording-${new Date().toISOString().replace(/[:.]/g, "-")}`;
205
+ this.outDir = options.outDir ?? join(this.baseDir, this.slug);
206
+ this.framesDir = join(this.outDir, "frames");
207
+ mkdirSync(this.framesDir, { recursive: true });
208
+ const vp = this.page.viewportSize() ?? { width: 1280, height: 800 };
209
+ const scale = await this.page
210
+ .evaluate(() => window.devicePixelRatio)
211
+ .catch(() => 1);
212
+ this.viewport = { width: vp.width, height: vp.height, deviceScaleFactor: scale || 1 };
213
+ await this.setUpTarget(options);
214
+ // The start trigger is awaited BEFORE the clock starts, so t=0 is the
215
+ // moment the user asked to start from, not the moment the process launched.
216
+ if (this.startTrigger || this.startDelayMs > 0) {
217
+ const fired = await this.awaitStartTrigger(options);
218
+ if (!fired) {
219
+ // The condition never happened, so there is nothing the caller asked
220
+ // for to record. Write an honest empty manifest rather than capturing
221
+ // an arbitrary window and presenting it as the requested one.
222
+ this.state = "recording";
223
+ this.startWallMs = Date.now();
224
+ this.startedAtIso = new Date(this.startWallMs).toISOString();
225
+ this.triggerTimedOut = true;
226
+ this.stopTriggerLabel = "start-trigger-timeout";
227
+ await this.stop();
228
+ return this.status();
229
+ }
230
+ }
231
+ this.attachPageListeners();
232
+ this.startWallMs = Date.now();
233
+ this.startedAtIso = new Date(this.startWallMs).toISOString();
234
+ this.state = "recording";
235
+ if (this.engine === "chromium") {
236
+ await this.startScreencast();
237
+ }
238
+ else {
239
+ this.captureMethod = "screenshot-loop";
240
+ this.startScreenshotLoop();
241
+ }
242
+ // Bracket the window: a page that never paints again still has a frame at
243
+ // t=0, and `stop()` adds the closing frame.
244
+ await this.captureDirectFrame();
245
+ if (this.stopTrigger)
246
+ this.startStopTriggerWatch(options);
247
+ if (this.opts.durationMs !== undefined) {
248
+ this.durationTimer = setTimeout(() => {
249
+ if (!this.stopTriggerLabel)
250
+ this.stopTriggerLabel = "duration";
251
+ void this.stop().catch(() => { });
252
+ }, this.opts.durationMs);
253
+ // Do not hold the event loop open on behalf of the auto-stop timer.
254
+ this.durationTimer.unref?.();
255
+ }
256
+ return this.status();
257
+ }
258
+ /**
259
+ * Stop capture, score the frames, write the manifest, run the encoders.
260
+ *
261
+ * Idempotent: concurrent and repeated calls all resolve to the same result,
262
+ * so an auto-stop racing an explicit stop cannot double-encode.
263
+ */
264
+ async stop() {
265
+ if (this.stopping)
266
+ return this.stopping;
267
+ // An explicit stop wins over any pending trigger: whichever call arrives
268
+ // first labels the recording, and the rest collapse onto its result.
269
+ if (this.state === "recording" && !this.stopTriggerLabel)
270
+ this.stopTriggerLabel = "manual";
271
+ if (this.state === "idle") {
272
+ throw new Error("Recording was never started");
273
+ }
274
+ this.stopping = this.doStop();
275
+ return this.stopping;
276
+ }
277
+ status() {
278
+ return {
279
+ state: this.state,
280
+ slug: this.slug,
281
+ outDir: this.outDir,
282
+ frames: this.frames.length,
283
+ elapsedMs: this.state === "stopped" ? this.stopWallMs - this.startWallMs
284
+ : this.state === "recording" ? Date.now() - this.startWallMs
285
+ : 0,
286
+ captureMethod: this.captureMethod,
287
+ ...(this.result ? { manifestPath: this.result.manifestPath } : {}),
288
+ };
289
+ }
290
+ /**
291
+ * Resolve the capture target into a crop rect and a locked output size.
292
+ *
293
+ * Output dimensions are fixed here, for the whole capture, because animated
294
+ * GIF/WebP and every video container require a constant frame size. An
295
+ * element that grows or shrinks is re-fitted into these locked dimensions
296
+ * rather than changing them mid-stream.
297
+ */
298
+ async setUpTarget(options) {
299
+ switch (this.target.kind) {
300
+ case "viewport": {
301
+ this.currentCrop = null;
302
+ this.outputDims = {
303
+ width: VideoCaptureSession.outputDim(this.viewport.width),
304
+ height: VideoCaptureSession.outputDim(this.viewport.height),
305
+ };
306
+ return;
307
+ }
308
+ case "region": {
309
+ // Overflow is trimmed, not rejected: "capture roughly there" is a
310
+ // reasonable ask, and the manifest reports the trim via region_clamped.
311
+ const { rect, clamped } = clampRect(this.target.rect, this.viewport);
312
+ this.target = { kind: "region", rect };
313
+ this.regionClamped = clamped;
314
+ this.currentCrop = rect;
315
+ this.outputDims = {
316
+ width: VideoCaptureSession.outputDim(rect.width),
317
+ height: VideoCaptureSession.outputDim(rect.height),
318
+ };
319
+ return;
320
+ }
321
+ case "element": {
322
+ const { selector, padding } = this.target;
323
+ if (!options.resolveElement) {
324
+ throw new Error(`Cannot capture element "${selector}": no element resolver was supplied to the capture session`);
325
+ }
326
+ this.elementLocator = await options.resolveElement(selector);
327
+ if (!this.elementLocator) {
328
+ throw new Error(`Cannot capture element "${selector}": no element matched that selector`);
329
+ }
330
+ const rect = await this.measureElement();
331
+ if (!rect) {
332
+ throw new Error(`Cannot capture element "${selector}": it resolved but has no visible box (display:none, zero size, or fully off-screen)`);
333
+ }
334
+ this.currentCrop = rect;
335
+ this.currentTracking = "ok";
336
+ this.outputDims = {
337
+ width: VideoCaptureSession.outputDim(rect.width),
338
+ height: VideoCaptureSession.outputDim(rect.height),
339
+ };
340
+ this.startElementTracking();
341
+ return;
342
+ }
343
+ }
344
+ }
345
+ /**
346
+ * Measure the tracked element, padded and clamped into the viewport.
347
+ * Returns null when the element has no usable box right now.
348
+ */
349
+ async measureElement() {
350
+ if (!this.elementLocator || this.target.kind !== "element")
351
+ return null;
352
+ const padding = this.target.padding;
353
+ try {
354
+ const box = await this.elementLocator.boundingBox({ timeout: 1000 });
355
+ if (!box || box.width <= 0 || box.height <= 0)
356
+ return null;
357
+ const padded = {
358
+ x: box.x - padding,
359
+ y: box.y - padding,
360
+ width: box.width + padding * 2,
361
+ height: box.height + padding * 2,
362
+ };
363
+ // clampRect throws when the padded box has left the viewport entirely,
364
+ // which is a tracking outcome rather than a capture failure.
365
+ return clampRect(padded, this.viewport).rect;
366
+ }
367
+ catch {
368
+ return null;
369
+ }
370
+ }
371
+ /**
372
+ * Re-measure the tracked element on the frame cadence so `crop` follows it as
373
+ * it moves. Polling rather than measuring inside the screencast handler: that
374
+ * handler must stay synchronous to ack frames promptly, and an await there
375
+ * would stall the stream.
376
+ */
377
+ startElementTracking() {
378
+ const intervalMs = 1000 / this.opts.fps;
379
+ let inFlight = false;
380
+ this.trackTimer = setInterval(() => {
381
+ if (this.state !== "recording" || inFlight)
382
+ return;
383
+ inFlight = true;
384
+ void this.measureElement()
385
+ .then((rect) => {
386
+ if (rect) {
387
+ this.currentCrop = rect;
388
+ this.currentTracking = "ok";
389
+ }
390
+ else {
391
+ // Element detached, hidden or scrolled away: keep capturing with
392
+ // the last known rect and mark the frames stale, so the recording
393
+ // shows what happened instead of ending.
394
+ this.currentTracking = "stale";
395
+ }
396
+ })
397
+ .catch(() => { this.currentTracking = "stale"; })
398
+ .finally(() => { inFlight = false; });
399
+ }, intervalMs);
400
+ this.trackTimer.unref?.();
401
+ }
402
+ // =======================================================================
403
+ // Triggers
404
+ // =======================================================================
405
+ /**
406
+ * Wait for the start trigger, then the start delay.
407
+ *
408
+ * Returns false when the trigger timed out. Timing out is a reportable
409
+ * outcome, not an exception: the caller still gets a manifest saying which
410
+ * trigger was asked for and that it never fired.
411
+ */
412
+ async awaitStartTrigger(options) {
413
+ const began = Date.now();
414
+ const trigger = this.startTrigger;
415
+ if (trigger) {
416
+ const fired = await this.waitForCondition(() => this.startConditionHolds(trigger, options), this.opts.triggerTimeoutMs);
417
+ if (!fired) {
418
+ this.startTriggerLabel = `${trigger.kind === "lifecycle" ? trigger.event : `element:${trigger.selector}`} (timed out after ${this.opts.triggerTimeoutMs}ms)`;
419
+ return false;
420
+ }
421
+ }
422
+ if (this.startDelayMs > 0) {
423
+ await new Promise((r) => setTimeout(r, this.startDelayMs));
424
+ }
425
+ this.startTriggerLabel = trigger
426
+ ? describeStartTrigger(trigger, this.startDelayMs, Date.now() - began)
427
+ : `delay:${this.startDelayMs}ms`;
428
+ return true;
429
+ }
430
+ /** Whether the start condition currently holds. */
431
+ async startConditionHolds(trigger, options) {
432
+ if (trigger.kind === "lifecycle") {
433
+ try {
434
+ // Already-fired lifecycle events resolve immediately; the short timeout
435
+ // keeps this a poll rather than a second, competing wait.
436
+ await this.page.waitForLoadState(trigger.event, { timeout: 500 });
437
+ return true;
438
+ }
439
+ catch {
440
+ return false;
441
+ }
442
+ }
443
+ const locator = options.resolveElement
444
+ ? await options.resolveElement(trigger.selector).catch(() => null)
445
+ : this.page.locator(trigger.selector).first();
446
+ if (!locator)
447
+ return false;
448
+ // Present AND visible: an element in the DOM but not painted is not
449
+ // something a viewer would call "there yet".
450
+ return locator.isVisible({ timeout: 500 }).catch(() => false);
451
+ }
452
+ /** Poll a condition until it holds or the budget runs out. */
453
+ async waitForCondition(holds, timeoutMs) {
454
+ const deadline = Date.now() + timeoutMs;
455
+ const intervalMs = Math.min(100, Math.max(20, 1000 / this.opts.fps));
456
+ while (Date.now() < deadline) {
457
+ if (await holds().catch(() => false))
458
+ return true;
459
+ await new Promise((r) => setTimeout(r, intervalMs));
460
+ }
461
+ return false;
462
+ }
463
+ /**
464
+ * Watch for the stop condition, and enforce the trigger timeout.
465
+ *
466
+ * A stop trigger that never fires must not hang the capture: the timeout
467
+ * stops it anyway and flags `trigger_timeout`, keeping every frame captured
468
+ * up to that point.
469
+ */
470
+ startStopTriggerWatch(options) {
471
+ const trigger = this.stopTrigger;
472
+ if (!trigger)
473
+ return;
474
+ if (trigger.kind === "idle") {
475
+ this.lastChangeMs = Date.now();
476
+ this.watchIdle(trigger.idleMs);
477
+ }
478
+ else {
479
+ const intervalMs = 1000 / this.opts.fps;
480
+ let inFlight = false;
481
+ this.stopTriggerTimer = setInterval(() => {
482
+ if (this.state !== "recording" || inFlight)
483
+ return;
484
+ inFlight = true;
485
+ void this.stopConditionHolds(trigger, options)
486
+ .then((fired) => {
487
+ if (fired && this.state === "recording") {
488
+ this.stopTriggerLabel = describeStopTrigger(trigger);
489
+ void this.stop().catch(() => { });
490
+ }
491
+ })
492
+ .catch(() => { })
493
+ .finally(() => { inFlight = false; });
494
+ }, intervalMs);
495
+ this.stopTriggerTimer.unref?.();
496
+ }
497
+ this.stopTimeoutTimer = setTimeout(() => {
498
+ if (this.state !== "recording")
499
+ return;
500
+ this.triggerTimedOut = true;
501
+ this.stopTriggerLabel = `${describeStopTrigger(trigger)} (timed out after ${this.opts.triggerTimeoutMs}ms)`;
502
+ void this.stop().catch(() => { });
503
+ }, this.opts.triggerTimeoutMs);
504
+ this.stopTimeoutTimer.unref?.();
505
+ }
506
+ /** Whether an element-based stop condition currently holds. */
507
+ async stopConditionHolds(trigger, options) {
508
+ if (trigger.kind === "idle")
509
+ return false;
510
+ const selector = trigger.selector;
511
+ const locator = options.resolveElement
512
+ ? await options.resolveElement(selector).catch(() => null)
513
+ : this.page.locator(selector).first();
514
+ if (!locator)
515
+ return trigger.kind === "element-disappears";
516
+ const visible = await locator.isVisible({ timeout: 500 }).catch(() => false);
517
+ return trigger.kind === "element-appears" ? visible : !visible;
518
+ }
519
+ /**
520
+ * Stop once no frame has changed for `idleMs`.
521
+ *
522
+ * Reuses the same SSIM comparator and threshold that produce `change_points`,
523
+ * so "changed" means exactly the same thing live as it does in the manifest.
524
+ */
525
+ watchIdle(idleMs) {
526
+ let lastScanned = -1;
527
+ this.stopTriggerTimer = setInterval(() => {
528
+ if (this.state !== "recording")
529
+ return;
530
+ const latest = this.frames.length - 1;
531
+ if (latest > lastScanned) {
532
+ const frame = this.frames[latest];
533
+ lastScanned = latest;
534
+ // Signatures are queued so scanning never overlaps itself and never
535
+ // blocks the screencast handler.
536
+ this.idleScanQueue = this.idleScanQueue
537
+ .then(() => this.writeQueue)
538
+ .then(() => frameSignature(frame.path))
539
+ .then((signature) => {
540
+ if (this.idleSignature && ssim(this.idleSignature, signature) < IDLE_THRESHOLD) {
541
+ this.lastChangeMs = Date.now();
542
+ }
543
+ this.idleSignature = signature;
544
+ })
545
+ .catch(() => { });
546
+ }
547
+ if (Date.now() - this.lastChangeMs >= idleMs) {
548
+ this.stopTriggerLabel = `idle:${idleMs}ms`;
549
+ void this.stop().catch(() => { });
550
+ }
551
+ }, Math.min(100, 1000 / this.opts.fps));
552
+ this.stopTriggerTimer.unref?.();
553
+ }
554
+ // =======================================================================
555
+ // Capture
556
+ // =======================================================================
557
+ async startScreencast() {
558
+ this.cdp = await this.page.context().newCDPSession(this.page);
559
+ this.cdp.on("Page.screencastFrame", (event) => {
560
+ const tMs = Date.now() - this.startWallMs;
561
+ // The ack must go out for every frame regardless of whether the frame is
562
+ // kept - Chromium stops emitting until the outstanding frame is acked.
563
+ this.cdp?.send("Page.screencastFrameAck", { sessionId: event.sessionId }).catch(() => {
564
+ /* session detached mid-flight; nothing left to ack */
565
+ });
566
+ if (this.state !== "recording")
567
+ return;
568
+ if (!this.shouldKeep(tMs))
569
+ return;
570
+ this.enqueueFrame(tMs, Buffer.from(event.data, "base64"));
571
+ });
572
+ await this.cdp.send("Page.startScreencast", {
573
+ format: "jpeg",
574
+ quality: this.opts.quality,
575
+ everyNthFrame: 1,
576
+ });
577
+ }
578
+ /** Firefox/WebKit fallback: poll `page.screenshot` at the target rate. */
579
+ startScreenshotLoop() {
580
+ const intervalMs = 1000 / this.opts.fps;
581
+ let inFlight = false;
582
+ this.loopTimer = setInterval(() => {
583
+ if (this.state !== "recording" || inFlight)
584
+ return;
585
+ inFlight = true;
586
+ void this.captureDirectFrame()
587
+ .catch(() => { })
588
+ .finally(() => { inFlight = false; });
589
+ }, intervalMs);
590
+ this.loopTimer.unref?.();
591
+ }
592
+ /**
593
+ * Capture one frame directly through Playwright rather than the screencast.
594
+ * Used to bracket the recording and to drive the non-Chromium fallback.
595
+ */
596
+ async captureDirectFrame() {
597
+ try {
598
+ const buffer = await this.page.screenshot({ type: "jpeg", quality: this.opts.quality });
599
+ // Stamped on completion, not on entry: a screencast frame can land while
600
+ // the screenshot is in flight, and an entry-time stamp would order this
601
+ // frame behind a frame that was captured after it.
602
+ this.enqueueFrame(Date.now() - this.startWallMs, buffer);
603
+ }
604
+ catch {
605
+ // Page closed or navigating: no frame is better than a fabricated one.
606
+ }
607
+ }
608
+ /**
609
+ * Rate-limit screencast frames to the requested fps.
610
+ *
611
+ * Frames are matched against a fixed grid of due times rather than against
612
+ * "time since the last kept frame". A since-last rule compounds every paint
613
+ * event's jitter into the next interval and drifts the achieved rate away
614
+ * from the target; the grid holds the average at exactly `fps`.
615
+ */
616
+ shouldKeep(tMs) {
617
+ if (this.frames.length >= this.opts.maxFrames)
618
+ return false;
619
+ if (tMs + FRAME_DUE_TOLERANCE_MS < this.nextDueMs)
620
+ return false;
621
+ const interval = 1000 / this.opts.fps;
622
+ this.nextDueMs += interval;
623
+ // A stall longer than one interval must not bank credit for a burst of
624
+ // frames once painting resumes; re-base the grid on the current time.
625
+ if (this.nextDueMs < tMs)
626
+ this.nextDueMs = tMs + interval;
627
+ return true;
628
+ }
629
+ /**
630
+ * Record a frame and stream its bytes to disk.
631
+ *
632
+ * The index and timestamp are assigned synchronously so frame ordering
633
+ * follows arrival order; only the write is deferred, serialised through a
634
+ * queue so a slow disk cannot reorder frames or unbound memory.
635
+ */
636
+ enqueueFrame(rawTMs, buffer) {
637
+ // Absolute cap, checked here rather than only in shouldKeep, because the
638
+ // bracket frames captured directly at start and stop bypass shouldKeep -
639
+ // and a --max-frames 5 that delivers 6 is a documented number the tool
640
+ // does not honour.
641
+ if (this.frames.length >= this.opts.maxFrames)
642
+ return;
643
+ const index = this.frames.length;
644
+ // The manifest contract requires non-decreasing timestamps. Timestamps are
645
+ // assigned at capture completion so they already arrive in order; this
646
+ // floor is the backstop that keeps a clock adjustment or an unforeseen
647
+ // interleaving from producing an unparseable manifest.
648
+ const previous = this.frames[index - 1];
649
+ const tMs = previous ? Math.max(rawTMs, previous.tMs) : Math.max(0, rawTMs);
650
+ const name = `${String(index).padStart(4, "0")}.jpg`;
651
+ const path = join(this.framesDir, name);
652
+ this.frames.push({
653
+ index,
654
+ tMs,
655
+ path,
656
+ // Snapshotted, not referenced: currentCrop is re-measured on the tracking
657
+ // timer, and a shared reference would rewrite every earlier frame's crop.
658
+ crop: this.currentCrop
659
+ ? { ...this.currentCrop }
660
+ : { x: 0, y: 0, width: this.viewport.width, height: this.viewport.height },
661
+ tracking: this.currentTracking,
662
+ });
663
+ this.writeQueue = this.writeQueue.then(() => {
664
+ writeFileSync(path, buffer);
665
+ }).catch(() => { });
666
+ }
667
+ attachPageListeners() {
668
+ this.consoleListener = (msg) => {
669
+ if (this.state !== "recording")
670
+ return;
671
+ this.consoleEvents.push({
672
+ t_ms: Math.max(0, Date.now() - this.startWallMs),
673
+ type: msg.type(),
674
+ text: msg.text(),
675
+ });
676
+ };
677
+ this.requestListener = (req) => {
678
+ if (this.state !== "recording")
679
+ return;
680
+ this.networkEvents.push({
681
+ t_ms: Math.max(0, Date.now() - this.startWallMs),
682
+ url: req.url(),
683
+ method: req.method(),
684
+ });
685
+ };
686
+ this.page.on("console", this.consoleListener);
687
+ this.page.on("request", this.requestListener);
688
+ }
689
+ detachPageListeners() {
690
+ if (this.consoleListener)
691
+ this.page.off("console", this.consoleListener);
692
+ if (this.requestListener)
693
+ this.page.off("request", this.requestListener);
694
+ this.consoleListener = null;
695
+ this.requestListener = null;
696
+ }
697
+ // =======================================================================
698
+ // Stop / manifest / encode
699
+ // =======================================================================
700
+ async doStop() {
701
+ if (this.durationTimer) {
702
+ clearTimeout(this.durationTimer);
703
+ this.durationTimer = null;
704
+ }
705
+ if (this.loopTimer) {
706
+ clearInterval(this.loopTimer);
707
+ this.loopTimer = null;
708
+ }
709
+ if (this.trackTimer) {
710
+ clearInterval(this.trackTimer);
711
+ this.trackTimer = null;
712
+ }
713
+ if (this.stopTriggerTimer) {
714
+ clearInterval(this.stopTriggerTimer);
715
+ this.stopTriggerTimer = null;
716
+ }
717
+ if (this.stopTimeoutTimer) {
718
+ clearTimeout(this.stopTimeoutTimer);
719
+ this.stopTimeoutTimer = null;
720
+ }
721
+ // Close the window with a real frame when the stream has gone quiet, so the
722
+ // recording's last timestamp reflects the requested duration instead of
723
+ // whenever the page happened to stop painting.
724
+ const nowMs = Date.now() - this.startWallMs;
725
+ const lastMs = this.frames.length > 0 ? this.frames[this.frames.length - 1].tMs : -Infinity;
726
+ if (nowMs - lastMs > (1000 / this.opts.fps) * CLOSING_FRAME_THRESHOLD) {
727
+ await this.captureDirectFrame();
728
+ }
729
+ this.state = "stopped";
730
+ this.stopWallMs = Date.now();
731
+ this.detachPageListeners();
732
+ if (this.cdp) {
733
+ await this.cdp.send("Page.stopScreencast").catch(() => { });
734
+ await this.cdp.detach().catch(() => { });
735
+ this.cdp = null;
736
+ }
737
+ await this.writeQueue;
738
+ const durationMs = Math.max(this.stopWallMs - this.startWallMs, this.frames.length > 0 ? this.frames[this.frames.length - 1].tMs : 0);
739
+ const manifest = await this.buildManifest(durationMs);
740
+ const manifestPath = join(this.outDir, "manifest.json");
741
+ const encodeErrors = await this.runEncoders(manifest, durationMs);
742
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
743
+ this.result = { manifestPath, manifest, encodeErrors };
744
+ this.finishedResolve(this.result);
745
+ return this.result;
746
+ }
747
+ async buildManifest(durationMs) {
748
+ const scores = await this.scoreFrames();
749
+ const changePoints = detectChangePoints(scores, CHANGE_POINT_THRESHOLD);
750
+ // `anomaly` keeps the stricter "something jumped" meaning, so the two
751
+ // fields answer different questions instead of duplicating one.
752
+ const anomalies = new Set(detectChangePoints(scores, CHANGE_THRESHOLD));
753
+ const tMs = this.frames.map((f) => f.tMs);
754
+ const frames = this.frames.map((f, i) => ({
755
+ index: f.index,
756
+ t_ms: f.tMs,
757
+ path: relative(this.outDir, f.path),
758
+ crop: f.crop,
759
+ ssim_prev: scores[i] ?? null,
760
+ anomaly: anomalies.has(i),
761
+ tracking: f.tracking,
762
+ console: this.eventsForFrame(this.consoleEvents, i),
763
+ network: this.eventsForFrame(this.networkEvents, i),
764
+ }));
765
+ const candidate = {
766
+ version: MANIFEST_VERSION,
767
+ slug: this.slug,
768
+ engine: this.engine,
769
+ capture_method: this.captureMethod,
770
+ target_fps: this.opts.fps,
771
+ actual_fps: durationMs > 0 ? frames.length / (durationMs / 1000) : 0,
772
+ viewport: this.viewport,
773
+ target: this.target,
774
+ start_trigger: this.startTriggerLabel,
775
+ stop_trigger: this.stopTriggerLabel,
776
+ trigger_timeout: this.triggerTimedOut,
777
+ started_at: this.startedAtIso,
778
+ duration_ms: durationMs,
779
+ output_dims: this.outputDims,
780
+ frames,
781
+ change_points: changePoints,
782
+ frame_gaps: tMs.length > 0 ? findFrameGaps(tMs, this.opts.fps) : [],
783
+ region_clamped: this.regionClamped,
784
+ artifacts: {},
785
+ };
786
+ return parseManifest(candidate);
787
+ }
788
+ /** SSIM of each frame against its predecessor; index 0 is always null. */
789
+ async scoreFrames() {
790
+ const scores = [];
791
+ let previous = null;
792
+ for (const frame of this.frames) {
793
+ let signature = null;
794
+ try {
795
+ signature = await frameSignature(frame.path);
796
+ }
797
+ catch {
798
+ // An unreadable frame scores null rather than poisoning its neighbours.
799
+ }
800
+ scores.push(previous && signature ? ssim(previous, signature) : null);
801
+ if (signature)
802
+ previous = signature;
803
+ }
804
+ return scores;
805
+ }
806
+ /**
807
+ * Attach each side-channel event to the frame whose display window contains
808
+ * it: the last frame at or before the event, or frame 0 for anything that
809
+ * fired before the first frame landed.
810
+ */
811
+ eventsForFrame(events, index) {
812
+ if (this.frames.length === 0)
813
+ return [];
814
+ const start = this.frames[index].tMs;
815
+ const next = this.frames[index + 1];
816
+ const end = next ? next.tMs : Infinity;
817
+ return events.filter((e) => index === 0 ? e.t_ms < end : e.t_ms >= start && e.t_ms < end);
818
+ }
819
+ /**
820
+ * Run the requested encoders. An encoder failure is recorded and reported but
821
+ * never thrown - the frames and manifest are the durable artifact, and losing
822
+ * a recording because a codec is missing would be the worst possible trade.
823
+ */
824
+ async runEncoders(manifest, durationMs) {
825
+ const errors = {};
826
+ if (this.frames.length === 0)
827
+ return errors;
828
+ // The contact sheet samples the REAL frames, so it keeps the unexpanded
829
+ // list - expanded repeats would fill the sheet with duplicate tail frames.
830
+ const paths = this.frames.map((f) => f.path);
831
+ // A long quiet tail produces one enormous final delay, and GIF/WebP cap a
832
+ // single delay at 65535ms. Capping alone would silently truncate playback
833
+ // (a 5-minute recording rendered as 68 seconds), so the held frame is
834
+ // repeated instead: identical consecutive frames merge back into one page
835
+ // whose delays sum to the real hold. Frames and crops expand TOGETHER,
836
+ // because encoders index crops per-frame and expanding one alone would
837
+ // misalign every crop after the first repeat.
838
+ const expanded = expandForPlayback(this.frames.map((f) => ({ path: f.path, crop: f.crop })), this.frames.map((f) => f.tMs), Math.max(durationMs, 1));
839
+ const encodePaths = expanded.items.map((i) => i.path);
840
+ const delays = expanded.delays;
841
+ // Frames on disk are full-viewport; the crop is applied here, at encode
842
+ // time, which is what keeps a capture re-croppable after the fact.
843
+ const cropOpts = this.target.kind === "viewport"
844
+ ? { width: this.outputDims.width, height: this.outputDims.height }
845
+ : {
846
+ width: this.outputDims.width,
847
+ height: this.outputDims.height,
848
+ crops: expanded.items.map((i) => i.crop),
849
+ };
850
+ if (this.opts.contactSheet) {
851
+ const sheetOpts = typeof this.opts.contactSheet === "object" ? this.opts.contactSheet : {};
852
+ const sheetPath = join(this.outDir, `${this.slug}-contact-sheet.jpg`);
853
+ try {
854
+ const sheet = await buildContactSheet({ framePaths: paths, outPath: sheetPath, ...sheetOpts });
855
+ manifest.artifacts["contact-sheet"] = relative(this.outDir, sheet.path);
856
+ }
857
+ catch (err) {
858
+ errors["contact-sheet"] = `contact sheet failed: ${err.message}`;
859
+ }
860
+ }
861
+ for (const format of this.opts.format) {
862
+ const outPath = join(this.outDir, `${this.slug}.${format}`);
863
+ try {
864
+ switch (format) {
865
+ case "gif":
866
+ await encodeGif(encodePaths, delays, outPath, cropOpts);
867
+ break;
868
+ case "webp":
869
+ await encodeWebp(encodePaths, delays, outPath, cropOpts);
870
+ break;
871
+ case "webm":
872
+ await encodeWebm(encodePaths, delays, outPath, { ...cropOpts, fps: this.opts.fps });
873
+ break;
874
+ case "mp4":
875
+ await encodeMp4(encodePaths, delays, outPath, { ...cropOpts, fps: this.opts.fps });
876
+ break;
877
+ }
878
+ if (existsSync(outPath))
879
+ manifest.artifacts[format] = relative(this.outDir, outPath);
880
+ }
881
+ catch (err) {
882
+ errors[format] = err instanceof MissingEncoderError
883
+ ? err.message
884
+ : `${format} encode failed: ${err.message}`;
885
+ }
886
+ }
887
+ return errors;
888
+ }
889
+ }
890
+ //# sourceMappingURL=engine.js.map