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