cdp-toolkit 1.4.0 → 1.6.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.
@@ -0,0 +1,741 @@
1
+ /**
2
+ * screencast.ts: tab-to-video capture. `start_screen_recording` opens a
3
+ * persistent page connection and streams CDP `Page.screencastFrame` events to a
4
+ * spool directory; `stop_screen_recording` assembles those frames into an H.265
5
+ * MP4 with ffmpeg.
6
+ *
7
+ * WHY THIS IS A START/STOP PAIR AND NOT A ONE-SHOT
8
+ * ===============================================
9
+ * A screencast is a live event subscription on ONE CDP connection, exactly like
10
+ * recorder.ts's console/network capture: the frames arrive as events on the
11
+ * socket that called `Page.startScreencast`, so the recording cannot be handed
12
+ * to another process. The module therefore mirrors recorder.ts's model (a
13
+ * persistent `openPage` connection held open until stop) and performance.ts's
14
+ * registry (module-level `Map` keyed by targetId, so start and stop must run in
15
+ * the SAME process). Unlike tracing, which is browser-global, screencasts are
16
+ * per-target: several recordings on DIFFERENT targets run concurrently, and the
17
+ * registry key is what keeps their spools and ledgers apart.
18
+ *
19
+ * WHY PER-FRAME DURATIONS (the thing a naive implementation gets wrong)
20
+ * --------------------------------------------------------------------
21
+ * Chrome emits a screencast frame ON REPAINT, not on a clock. A page that sits
22
+ * still emits nothing; an animating page emits at whatever rate it paints. The
23
+ * stream is therefore inherently VARIABLE-frame-rate, and feeding those frames
24
+ * to ffmpeg at a fixed rate produces a video that is either sped up (a 30s
25
+ * recording of a mostly-still page collapsing to 2s) or frozen. So every frame
26
+ * is timestamped into an in-memory LEDGER, and on stop the ledger is rendered
27
+ * as an ffconcat manifest carrying an explicit `duration` per frame:
28
+ *
29
+ * duration(frame N) = ts(N+1) - ts(N)
30
+ * duration(last) = stopWallclock - ts(last)
31
+ *
32
+ * so a still page yields one long-held frame and the total video duration comes
33
+ * out at the wallclock length of the recording. `metadata.timestamp` (seconds
34
+ * since epoch, from the renderer) is preferred over arrival wallclock, which
35
+ * only backfills when Chrome omits it.
36
+ *
37
+ * ACKING IS NOT OPTIONAL. Chrome stops sending frames until the previous one is
38
+ * acknowledged with `Page.screencastFrameAck(sessionId)`. The ack is therefore
39
+ * fired from the event handler BEFORE the frame is written to disk: an ack that
40
+ * waits on a disk write turns the frame rate into the disk's write latency.
41
+ *
42
+ * ENCODER LADDER. Probed ONCE per process from `ffmpeg -hide_banner -encoders`:
43
+ * hevc_videotoolbox -> h264_videotoolbox -> libx265 -> libx264. Both HEVC
44
+ * encoders get `-tag:v hvc1`, without which QuickTime refuses to play the file
45
+ * it just wrote. Dimensions are forced even because videotoolbox rejects odd
46
+ * ones. ffmpeg is probed at START time, not stop, so a missing ffmpeg fails
47
+ * before a recording is captured and then thrown away.
48
+ *
49
+ * ZERO NEW DEPENDENCIES: ffmpeg is spawned via node:child_process, the same
50
+ * shell-out shape lighthouse.ts uses. No Bun.* APIs anywhere (dist/ is bundled
51
+ * to node ESM).
52
+ */
53
+ import { spawn } from "node:child_process";
54
+ import { mkdir, rm, stat, writeFile } from "node:fs/promises";
55
+ import { join } from "node:path";
56
+ import { CdpError, openPage } from "../client.ts";
57
+ import type { CdpConnection } from "../client.ts";
58
+ import type { Target, TargetSelector } from "../types.ts";
59
+
60
+ const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
61
+
62
+ /** ffmpeg binary, overridable for a non-PATH install. */
63
+ const FFMPEG_BIN = process.env.CDP_FFMPEG ?? "ffmpeg";
64
+
65
+ /* --------------------------------- ledger --------------------------------- */
66
+
67
+ /** One captured frame: where it landed, when it was painted, and how big it is. */
68
+ export interface FrameLedgerEntry {
69
+ /** File name relative to the spool directory (the ffconcat manifest lives there too). */
70
+ file: string;
71
+ /** Paint time in ms since epoch: metadata.timestamp*1000 when Chrome supplies it, else arrival wallclock. */
72
+ timestamp: number;
73
+ width: number;
74
+ height: number;
75
+ }
76
+
77
+ /**
78
+ * Per-frame display durations in MILLISECONDS, from the ledger. Frame N is held
79
+ * until frame N+1 paints; the final frame is held until the caller stopped.
80
+ * Clamped to >=1ms: a repeated or non-monotonic timestamp would otherwise emit a
81
+ * zero/negative `duration` that the concat demuxer silently drops.
82
+ */
83
+ export function frameDurationsMs(ledger: FrameLedgerEntry[], stopWallclockMs: number): number[] {
84
+ return ledger.map((entry, i) => {
85
+ const next = i + 1 < ledger.length ? ledger[i + 1]!.timestamp : stopWallclockMs;
86
+ return Math.max(1, Math.round(next - entry.timestamp));
87
+ });
88
+ }
89
+
90
+ /**
91
+ * The concat demuxer's timestamp resolution, in ms.
92
+ *
93
+ * MEASURED, not assumed. The demuxer takes its stream time_base from the first
94
+ * file's sub-demuxer, and image files come in through image2 at its default 25
95
+ * fps, so EVERY frame's presentation timestamp is snapped to a 1/25s grid
96
+ * regardless of what the manifest's `duration` lines say. Feeding it a 93 fps
97
+ * screencast unchanged therefore hands several frames the same timestamp, and
98
+ * ffmpeg silently drops all but one of each collision: a 373-frame capture came
99
+ * out as 102 encoded frames with no warning. There is no way around it from the
100
+ * outside either: `-r` before `-i` overrides the durations entirely (a 1.0s clip
101
+ * became 0.101s), `-framerate` is not an option the concat demuxer accepts, and
102
+ * `settb`/`-video_track_timescale` change only the OUTPUT timebase.
103
+ *
104
+ * So the snapping is done here, deliberately and visibly, instead of being left
105
+ * to ffmpeg to do silently. Encoded video tops out at 25 fps.
106
+ */
107
+ export const CONCAT_GRID_MS = 40;
108
+
109
+ /** One frame as it will actually appear in the video: a file and a grid-aligned hold. */
110
+ export interface GriddedFrame {
111
+ file: string;
112
+ durationMs: number;
113
+ }
114
+
115
+ /**
116
+ * Snap the ledger's raw per-frame durations onto the concat demuxer's grid,
117
+ * keeping the FIRST frame that falls in each slot and dropping the frames that
118
+ * would otherwise collide on the same timestamp.
119
+ *
120
+ * The two properties this preserves are the ones the whole design exists for:
121
+ * a long still stays ONE entry held for its full length (a 3s hold encodes as a
122
+ * single 3s frame, not 75 resampled ones), and the durations still sum to the
123
+ * wallclock span, rounded to the grid.
124
+ */
125
+ export function coalesceToConcatGrid(
126
+ ledger: FrameLedgerEntry[],
127
+ stopWallclockMs: number,
128
+ gridMs: number = CONCAT_GRID_MS,
129
+ ): GriddedFrame[] {
130
+ if (!ledger.length) return [];
131
+ const raw = frameDurationsMs(ledger, stopWallclockMs);
132
+ // Cumulative offsets from the first frame, taken from the CLAMPED durations so
133
+ // a non-monotonic clock cannot walk the timeline backwards.
134
+ let elapsed = 0;
135
+ const kept: Array<{ file: string; slot: number }> = [];
136
+ let lastSlot = -1;
137
+ for (let i = 0; i < ledger.length; i++) {
138
+ const slot = Math.round(elapsed / gridMs);
139
+ if (slot > lastSlot) {
140
+ kept.push({ file: ledger[i]!.file, slot });
141
+ lastSlot = slot;
142
+ }
143
+ elapsed += raw[i]!;
144
+ }
145
+ const endSlot = Math.max(lastSlot + 1, Math.round(elapsed / gridMs));
146
+ return kept.map((frame, i) => ({
147
+ file: frame.file,
148
+ durationMs: ((i + 1 < kept.length ? kept[i + 1]!.slot : endSlot) - frame.slot) * gridMs,
149
+ }));
150
+ }
151
+
152
+ /**
153
+ * Render grid-aligned frames as an ffconcat v1 manifest. Paths are the bare
154
+ * spool-relative file names (the concat demuxer resolves them against the
155
+ * manifest's own directory), which sidesteps ffconcat path quoting entirely.
156
+ *
157
+ * THE TRAILING REPEAT is load-bearing: the concat demuxer applies a `duration`
158
+ * only when another `file` line follows it, so without repeating the final entry
159
+ * the last frame's hold time is discarded and a recording that ends on a long
160
+ * still cuts short. The repeated entry itself contributes one grid step, which
161
+ * is why the encoded file runs exactly one 40ms frame longer than the durations
162
+ * here sum to.
163
+ */
164
+ export function buildConcatManifest(frames: GriddedFrame[]): string {
165
+ if (!frames.length) throw new CdpError("cannot build a concat manifest from an empty frame ledger");
166
+ const lines = ["ffconcat version 1.0"];
167
+ for (const frame of frames) {
168
+ lines.push(`file ${frame.file}`);
169
+ lines.push(`duration ${(frame.durationMs / 1000).toFixed(6)}`);
170
+ }
171
+ lines.push(`file ${frames[frames.length - 1]!.file}`);
172
+ return `${lines.join("\n")}\n`;
173
+ }
174
+
175
+ /** videotoolbox rejects odd dimensions; this is the JS twin of `scale=trunc(iw/2)*2:trunc(ih/2)*2`. */
176
+ export function evenDimensions(width: number, height: number): { width: number; height: number } {
177
+ return { width: Math.max(0, Math.trunc(width / 2) * 2), height: Math.max(0, Math.trunc(height / 2) * 2) };
178
+ }
179
+
180
+ /* -------------------------------- image size ------------------------------- */
181
+
182
+ /** PNG: an 8-byte signature, then an IHDR chunk whose payload opens with two big-endian uint32s. */
183
+ function pngSize(buf: Buffer): { width: number; height: number } | undefined {
184
+ if (buf.length < 24) return undefined;
185
+ if (buf.readUInt32BE(0) !== 0x89504e47 || buf.readUInt32BE(4) !== 0x0d0a1a0a) return undefined;
186
+ if (buf.toString("latin1", 12, 16) !== "IHDR") return undefined;
187
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
188
+ }
189
+
190
+ /** SOF markers carrying frame dimensions. SOF4 (0xc4 DHT), SOF8 (0xc8), SOF12 (0xcc DAC) are NOT frame headers. */
191
+ const JPEG_SOF = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]);
192
+
193
+ /** JPEG: walk the marker segments to the first start-of-frame, which carries height then width. */
194
+ function jpegSize(buf: Buffer): { width: number; height: number } | undefined {
195
+ if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return undefined;
196
+ let i = 2;
197
+ while (i + 3 < buf.length) {
198
+ if (buf[i] !== 0xff) {
199
+ i += 1; // resync past fill bytes / padding
200
+ continue;
201
+ }
202
+ const marker = buf[i + 1]!;
203
+ if (marker === 0xff) {
204
+ i += 1;
205
+ continue;
206
+ }
207
+ // Standalone markers: no length field follows.
208
+ if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
209
+ i += 2;
210
+ continue;
211
+ }
212
+ if (i + 3 >= buf.length) return undefined;
213
+ const segLen = buf.readUInt16BE(i + 2);
214
+ if (JPEG_SOF.has(marker)) {
215
+ if (i + 9 > buf.length) return undefined;
216
+ return { width: buf.readUInt16BE(i + 7), height: buf.readUInt16BE(i + 5) };
217
+ }
218
+ if (segLen < 2) return undefined;
219
+ i += 2 + segLen;
220
+ }
221
+ return undefined;
222
+ }
223
+
224
+ /** Frame dimensions read from the encoded bytes themselves (never guessed from metadata). */
225
+ export function imageSize(buf: Buffer): { width: number; height: number } | undefined {
226
+ return pngSize(buf) ?? jpegSize(buf);
227
+ }
228
+
229
+ /* ------------------------------ encoder ladder ----------------------------- */
230
+
231
+ export interface EncoderChoice {
232
+ /** The ffmpeg encoder name passed to -c:v. */
233
+ encoder: string;
234
+ /** The codec the encoder produces, as ffprobe reports it. */
235
+ codec: "hevc" | "h264";
236
+ /** -tag:v value, set on HEVC so QuickTime will play the result. */
237
+ tag?: "hvc1";
238
+ }
239
+
240
+ /** Preference order: hardware HEVC, hardware H.264, software HEVC, software H.264. */
241
+ export const ENCODER_LADDER: readonly EncoderChoice[] = [
242
+ { encoder: "hevc_videotoolbox", codec: "hevc", tag: "hvc1" },
243
+ { encoder: "h264_videotoolbox", codec: "h264" },
244
+ { encoder: "libx265", codec: "hevc", tag: "hvc1" },
245
+ { encoder: "libx264", codec: "h264" },
246
+ ];
247
+
248
+ /**
249
+ * The encoder NAMES listed by `ffmpeg -encoders`, read out of the name column
250
+ * only. Each row is `<6 capability flags> <name> <description>`, e.g.
251
+ * ` V....D libx264rgb libx264 H.264 / AVC ... RGB (codec h264)`.
252
+ *
253
+ * Searching the raw text instead is the trap this exists to avoid: ffmpeg
254
+ * repeats an encoder's family name inside OTHER encoders' descriptions, so a
255
+ * substring (or even word-boundary) match on "libx264" hits the libx264rgb row,
256
+ * and the toolkit would then encode with an RGB-only encoder that cannot write
257
+ * the yuv420p MP4 it just promised.
258
+ */
259
+ export function ffmpegEncoderNames(encodersOutput: string): Set<string> {
260
+ const names = new Set<string>();
261
+ for (const line of encodersOutput.split("\n")) {
262
+ const m = /^\s*[VAS][A-Z.]{5}\s+(\S+)/.exec(line);
263
+ if (m) names.add(m[1]!);
264
+ }
265
+ return names;
266
+ }
267
+
268
+ /** Pick the best available encoder from the raw text of `ffmpeg -encoders`. */
269
+ export function selectEncoder(encodersOutput: string): EncoderChoice {
270
+ const available = ffmpegEncoderNames(encodersOutput);
271
+ for (const candidate of ENCODER_LADDER) {
272
+ if (available.has(candidate.encoder)) return candidate;
273
+ }
274
+ throw new CdpError(
275
+ `ffmpeg reports none of the supported video encoders (${ENCODER_LADDER.map((c) => c.encoder).join(", ")}). ` +
276
+ "Install a full ffmpeg build: 'brew install ffmpeg' (macOS) or 'apt install ffmpeg' (Debian/Ubuntu).",
277
+ );
278
+ }
279
+
280
+ /**
281
+ * The exact ffmpeg argv. `-vsync vfr` (not `-fps_mode`, which needs ffmpeg
282
+ * >=5.1 and would break Ubuntu 22.04's 4.4) passes the concat demuxer's
283
+ * per-frame timestamps through instead of resampling them to a constant rate.
284
+ * `-t` is deliberately NOT used to pin the duration: it trims at the LAST
285
+ * frame's PTS, which throws away exactly the long final hold the ledger exists
286
+ * to preserve (measured: a 4.25s ledger came out at 3.56s).
287
+ */
288
+ export function buildFfmpegArgs(manifestPath: string, outPath: string, choice: EncoderChoice): string[] {
289
+ return [
290
+ "-hide_banner",
291
+ "-loglevel", "error",
292
+ "-y",
293
+ "-f", "concat",
294
+ "-safe", "0",
295
+ "-i", manifestPath,
296
+ "-vsync", "vfr",
297
+ "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
298
+ "-c:v", choice.encoder,
299
+ ...(choice.tag ? ["-tag:v", choice.tag] : []),
300
+ "-pix_fmt", "yuv420p",
301
+ "-movflags", "+faststart",
302
+ "-an",
303
+ outPath,
304
+ ];
305
+ }
306
+
307
+ /* ------------------------------- ffmpeg shell ------------------------------ */
308
+
309
+ interface RunResult {
310
+ code: number | null;
311
+ stdout: string;
312
+ stderr: string;
313
+ }
314
+
315
+ /** Spawn a binary and collect its output. Rejects only when the process cannot be spawned at all. */
316
+ function run(bin: string, args: string[]): Promise<RunResult> {
317
+ return new Promise((resolve, reject) => {
318
+ const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
319
+ let stdout = "";
320
+ let stderr = "";
321
+ child.stdout?.on("data", (chunk: Buffer) => {
322
+ stdout += chunk.toString("utf8");
323
+ });
324
+ child.stderr?.on("data", (chunk: Buffer) => {
325
+ stderr += chunk.toString("utf8");
326
+ });
327
+ child.on("error", reject);
328
+ child.on("close", (code) => resolve({ code, stdout, stderr }));
329
+ });
330
+ }
331
+
332
+ /** The single encoder probe per process (see the header note on probing at start, not stop). */
333
+ let encoderProbe: Promise<EncoderChoice> | undefined;
334
+
335
+ const FFMPEG_MISSING =
336
+ `ffmpeg is required to encode a screen recording but '${FFMPEG_BIN}' could not be executed. ` +
337
+ "Install it with 'brew install ffmpeg' (macOS) or 'apt install ffmpeg' (Debian/Ubuntu), " +
338
+ "or point CDP_FFMPEG at the binary.";
339
+
340
+ /**
341
+ * Probe ffmpeg once and cache the chosen encoder. Called from START so a missing
342
+ * ffmpeg is reported before any frames are captured, rather than after a
343
+ * recording has been made and is about to be discarded.
344
+ */
345
+ async function probeEncoder(): Promise<EncoderChoice> {
346
+ if (!encoderProbe) {
347
+ encoderProbe = (async () => {
348
+ let res: RunResult;
349
+ try {
350
+ res = await run(FFMPEG_BIN, ["-hide_banner", "-encoders"]);
351
+ } catch (err) {
352
+ throw new CdpError(`${FFMPEG_MISSING} (${(err as Error).message})`);
353
+ }
354
+ if (res.code !== 0) throw new CdpError(`${FFMPEG_MISSING} ('-encoders' exited ${res.code}: ${res.stderr.trim().slice(0, 300)})`);
355
+ return selectEncoder(`${res.stdout}\n${res.stderr}`);
356
+ })();
357
+ // A failed probe must not poison every later call: forget it so a caller who
358
+ // installs ffmpeg and retries is not told it is still missing.
359
+ encoderProbe.catch(() => {
360
+ encoderProbe = undefined;
361
+ });
362
+ }
363
+ return encoderProbe;
364
+ }
365
+
366
+ /* -------------------------------- registry --------------------------------- */
367
+
368
+ /** A live screencast held in-process between start and stop, keyed by targetId. */
369
+ interface LiveRecording {
370
+ conn: CdpConnection;
371
+ target: Target;
372
+ spoolDir: string;
373
+ ledger: FrameLedgerEntry[];
374
+ startedAt: number;
375
+ format: "jpeg" | "png";
376
+ encoder: EncoderChoice;
377
+ /** Serialized frame writes, so two frames never interleave a partial file. */
378
+ writes: Promise<void>;
379
+ /** Frames whose bytes could not be written or parsed (reported, never hidden). */
380
+ dropped: number;
381
+ /** Ledger file names whose write failed; dropped before the manifest so ffmpeg never chases a missing frame. */
382
+ unwritten: Set<string>;
383
+ /** Detach the Page.screencastFrame subscription. */
384
+ off: () => void;
385
+ }
386
+
387
+ const liveRecordings = new Map<string, LiveRecording>();
388
+
389
+ /** Live recording target ids, for tests and diagnostics. */
390
+ export function activeScreenRecordings(): string[] {
391
+ return [...liveRecordings.keys()];
392
+ }
393
+
394
+ function shortId(id: string): string {
395
+ return id.slice(0, 8);
396
+ }
397
+
398
+ /**
399
+ * Refuse a second recording on a target that is already recording. Starting
400
+ * twice would orphan the first screencast's connection and its spool, and the
401
+ * caller would then stop a recording that is not the one they think it is.
402
+ * Split out from the tool so the guard is testable without a browser.
403
+ */
404
+ export function assertNotRecording(targetId: string, activeIds: readonly string[]): void {
405
+ if (activeIds.includes(targetId)) {
406
+ throw new CdpError(
407
+ `a screen recording is already in progress for target ${shortId(targetId)}; call stop_screen_recording first`,
408
+ );
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Decide which live recording a stop call means, from the active ids alone.
414
+ * Returns the id when it can be settled without touching the browser, or
415
+ * `undefined` when `selector` is a non-id form (index:/url:/title:) that has to
416
+ * be resolved against the browser first. Throws when nothing is recording, and
417
+ * when several are recording but the caller did not say which: silently
418
+ * stopping "the first one" would end another agent's recording.
419
+ */
420
+ export function chooseRecordingId(activeIds: readonly string[], selector?: TargetSelector): string | undefined {
421
+ if (!activeIds.length) {
422
+ throw new CdpError(
423
+ "no screen recording is in progress in this process. start_screen_recording parks a live CDP connection in memory, so a recording cannot be stopped from a different process than the one that started it (the CLI is one process per call: use the MCP server for the pair).",
424
+ );
425
+ }
426
+ if (selector === undefined || selector === "active") {
427
+ if (activeIds.length === 1) return activeIds[0]!;
428
+ throw new CdpError(
429
+ `${activeIds.length} screen recordings are in progress (targets: ${activeIds.map(shortId).join(", ")}); pass 'target' to say which one to stop`,
430
+ );
431
+ }
432
+ return activeIds.find((id) => id === selector);
433
+ }
434
+
435
+ function stamp(): string {
436
+ return new Date(Date.now()).toISOString().replace(/[:.]/g, "-");
437
+ }
438
+
439
+ function target3(t: Target): { id: string; url: string; title: string } {
440
+ return { id: t.id, url: t.url, title: t.title };
441
+ }
442
+
443
+ /* ---------------------------- start_screen_recording ---------------------------- */
444
+
445
+ export interface StartScreenRecordingArgs {
446
+ target?: TargetSelector;
447
+ /** Frame image format. jpeg (default) is smaller per repaint; png is lossless. */
448
+ format?: "jpeg" | "png";
449
+ /** JPEG quality 0-100. Ignored for png. */
450
+ quality?: number;
451
+ maxWidth?: number;
452
+ maxHeight?: number;
453
+ /** Capture every Nth repaint (1 = every frame). */
454
+ everyNthFrame?: number;
455
+ /** Activate the tab before recording. A fully backgrounded tab may never repaint. */
456
+ bringToFront?: boolean;
457
+ }
458
+
459
+ export interface StartScreenRecordingResult {
460
+ target: { id: string; url: string; title: string };
461
+ format: "jpeg" | "png";
462
+ spoolDir: string;
463
+ encoder: string;
464
+ codec: "hevc" | "h264";
465
+ startedAt: number;
466
+ note: string;
467
+ }
468
+
469
+ /**
470
+ * Begin recording the target tab. Opens a persistent page connection, parks it
471
+ * in the module registry, and streams `Page.screencastFrame` to a spool dir.
472
+ * Pairs with `stopScreenRecording` WITHIN THE SAME PROCESS.
473
+ */
474
+ export async function startScreenRecording(args: StartScreenRecordingArgs = {}): Promise<StartScreenRecordingResult> {
475
+ const format = args.format ?? "jpeg";
476
+ if (format !== "jpeg" && format !== "png") throw new CdpError("start_screen_recording: format must be 'jpeg' or 'png'");
477
+ if (args.quality !== undefined && (!Number.isFinite(args.quality) || args.quality < 0 || args.quality > 100)) {
478
+ throw new CdpError("start_screen_recording: quality must be a number between 0 and 100 (jpeg only)");
479
+ }
480
+
481
+ // Probe ffmpeg BEFORE dialing the browser: failing here costs the caller
482
+ // nothing, whereas failing at stop costs them the whole recording.
483
+ const encoder = await probeEncoder();
484
+
485
+ const { conn, target } = await openPage(args.target);
486
+
487
+ try {
488
+ assertNotRecording(target.id, [...liveRecordings.keys()]);
489
+ } catch (err) {
490
+ conn.close();
491
+ throw err;
492
+ }
493
+
494
+ const spoolDir = join(ARTIFACT_DIR, `screencast-${shortId(target.id)}-${stamp()}`);
495
+ try {
496
+ await mkdir(spoolDir, { recursive: true });
497
+ } catch (err) {
498
+ // An unwritable artifact dir must not leak the page connection we just opened.
499
+ conn.close();
500
+ throw new CdpError(`cannot create the screencast spool directory '${spoolDir}': ${(err as Error).message}`);
501
+ }
502
+
503
+ const ext = format === "png" ? "png" : "jpg";
504
+ const rec: LiveRecording = {
505
+ conn,
506
+ target,
507
+ spoolDir,
508
+ ledger: [],
509
+ startedAt: Date.now(),
510
+ format,
511
+ encoder,
512
+ writes: Promise.resolve(),
513
+ dropped: 0,
514
+ unwritten: new Set<string>(),
515
+ off: () => {},
516
+ };
517
+
518
+ let seq = 0;
519
+ rec.off = conn.on("Page.screencastFrame", (params) => {
520
+ const sessionId = (params as { sessionId?: number }).sessionId;
521
+ // ACK FIRST, ALWAYS. Chrome will not send frame N+1 until frame N is acked,
522
+ // so anything awaited before this ack becomes the frame rate.
523
+ if (typeof sessionId === "number") {
524
+ void conn.send("Page.screencastFrameAck", { sessionId }).catch(() => {
525
+ /* the socket is closing or the ack raced stop(); frames simply end */
526
+ });
527
+ }
528
+ const data = (params as { data?: string }).data;
529
+ if (typeof data !== "string" || !data.length) {
530
+ rec.dropped += 1;
531
+ return;
532
+ }
533
+ const meta = (params as { metadata?: { timestamp?: number } }).metadata;
534
+ // metadata.timestamp is Network.TimeSinceEpoch: SECONDS (float). Arrival
535
+ // wallclock only backfills when Chrome omits it.
536
+ const timestamp = typeof meta?.timestamp === "number" ? Math.round(meta.timestamp * 1000) : Date.now();
537
+ const buf = Buffer.from(data, "base64");
538
+ const size = imageSize(buf);
539
+ if (!size || size.width <= 0 || size.height <= 0) {
540
+ rec.dropped += 1;
541
+ return;
542
+ }
543
+ seq += 1;
544
+ const file = `frame-${String(seq).padStart(6, "0")}.${ext}`;
545
+ // Ledger order is the arrival order, fixed synchronously here; the write is
546
+ // queued behind whatever is already in flight.
547
+ rec.ledger.push({ file, timestamp, width: size.width, height: size.height });
548
+ rec.writes = rec.writes.then(() => writeFile(join(spoolDir, file), buf)).catch(() => {
549
+ // A ledger row whose file never landed would send ffmpeg looking for a
550
+ // missing frame and fail the whole encode. Record it so stop can drop the
551
+ // row; the neighbouring frame's duration then covers the gap, because
552
+ // durations come from timestamps rather than from row positions.
553
+ rec.dropped += 1;
554
+ rec.unwritten.add(file);
555
+ });
556
+ });
557
+
558
+ try {
559
+ await conn.send("Page.enable");
560
+ if (args.bringToFront) await conn.send("Page.bringToFront").catch(() => {});
561
+ const params: Record<string, unknown> = { format };
562
+ if (format === "jpeg" && args.quality !== undefined) params.quality = args.quality;
563
+ if (args.maxWidth !== undefined) params.maxWidth = args.maxWidth;
564
+ if (args.maxHeight !== undefined) params.maxHeight = args.maxHeight;
565
+ if (args.everyNthFrame !== undefined) params.everyNthFrame = args.everyNthFrame;
566
+ await conn.send("Page.startScreencast", params);
567
+ } catch (err) {
568
+ rec.off();
569
+ conn.close();
570
+ await rm(spoolDir, { recursive: true, force: true }).catch(() => {});
571
+ throw err;
572
+ }
573
+
574
+ liveRecordings.set(target.id, rec);
575
+
576
+ return {
577
+ target: target3(target),
578
+ format,
579
+ spoolDir,
580
+ encoder: encoder.encoder,
581
+ codec: encoder.codec,
582
+ startedAt: rec.startedAt,
583
+ note: "Recording. Call stop_screen_recording in THIS process to encode the MP4; frames only arrive on repaint, so a still page yields one long-held frame.",
584
+ };
585
+ }
586
+
587
+ /* ---------------------------- stop_screen_recording ----------------------------- */
588
+
589
+ export interface StopScreenRecordingArgs {
590
+ target?: TargetSelector;
591
+ /** Output file path. Default: <artifact dir>/screen-recording-<targetIdShort>-<stamp>.mp4. */
592
+ savePath?: string;
593
+ }
594
+
595
+ export interface StopScreenRecordingResult {
596
+ path: string;
597
+ bytes: number;
598
+ /** Sum of the durations actually written to the concat manifest; the file itself runs one 40ms grid step longer (see buildConcatManifest). */
599
+ durationMs: number;
600
+ /** Frames CAPTURED off the wire. */
601
+ frameCount: number;
602
+ /** Frames that survived the concat demuxer's 40ms grid and are in the video (see CONCAT_GRID_MS). */
603
+ encodedFrames: number;
604
+ codec: "hevc" | "h264";
605
+ encoder: string;
606
+ width: number;
607
+ height: number;
608
+ droppedFrames: number;
609
+ target: { id: string; url: string; title: string };
610
+ }
611
+
612
+ /**
613
+ * Look the chosen recording up, resolving a non-id selector (index:/url:/title:)
614
+ * against the browser exactly the way every other tool resolves a target,
615
+ * including its lease check.
616
+ */
617
+ async function pickRecording(selector?: TargetSelector): Promise<LiveRecording> {
618
+ const chosen = chooseRecordingId([...liveRecordings.keys()], selector);
619
+ if (chosen) return liveRecordings.get(chosen)!;
620
+ const { conn, target } = await openPage(selector);
621
+ conn.close();
622
+ const hit = liveRecordings.get(target.id);
623
+ if (!hit) {
624
+ throw new CdpError(
625
+ `no screen recording is in progress for target ${shortId(target.id)} (recording: ${[...liveRecordings.keys()].map(shortId).join(", ") || "none"})`,
626
+ );
627
+ }
628
+ return hit;
629
+ }
630
+
631
+ /**
632
+ * Stop the recording, encode the spooled frames into an MP4 with per-frame
633
+ * durations, and return the file's real measured size alongside the ledger's
634
+ * own numbers. On an encode failure the spool directory is KEPT and named in the
635
+ * error, so the frames can be re-encoded by hand rather than lost.
636
+ */
637
+ export async function stopScreenRecording(args: StopScreenRecordingArgs = {}): Promise<StopScreenRecordingResult> {
638
+ const rec = await pickRecording(args.target);
639
+
640
+ // Tear the stream down first so no frame lands after the stop wallclock that
641
+ // the last frame's duration is measured against.
642
+ rec.off();
643
+ try {
644
+ await rec.conn.send("Page.stopScreencast");
645
+ } catch {
646
+ /* the page may have navigated or closed; the frames already spooled are still good */
647
+ }
648
+ const stoppedAt = Date.now();
649
+ await rec.writes.catch(() => {});
650
+ rec.conn.close();
651
+ liveRecordings.delete(rec.target.id);
652
+
653
+ const ledger = rec.unwritten.size ? rec.ledger.filter((entry) => !rec.unwritten.has(entry.file)) : rec.ledger;
654
+ if (!ledger.length) {
655
+ await rm(rec.spoolDir, { recursive: true, force: true }).catch(() => {});
656
+ throw new CdpError(
657
+ `screen recording captured 0 frames for target ${shortId(rec.target.id)}. Chrome emits a screencast frame only on repaint: a fully backgrounded or occluded tab may never paint. Retry with bringToFront:true, or drive the page while recording.`,
658
+ );
659
+ }
660
+
661
+ await mkdir(ARTIFACT_DIR, { recursive: true });
662
+ const outPath = args.savePath ?? join(ARTIFACT_DIR, `screen-recording-${shortId(rec.target.id)}-${stamp()}.mp4`);
663
+
664
+ const frames = coalesceToConcatGrid(ledger, stoppedAt);
665
+ const manifestPath = join(rec.spoolDir, "frames.ffconcat");
666
+ await writeFile(manifestPath, buildConcatManifest(frames), "utf8");
667
+
668
+ // Both numbers are computed, never estimated: durationMs is the sum of the
669
+ // durations actually written to the manifest, so it matches the file.
670
+ const durationMs = frames.reduce((total, f) => total + f.durationMs, 0);
671
+ const { width, height } = evenDimensions(ledger[0]!.width, ledger[0]!.height);
672
+
673
+ let res: RunResult;
674
+ try {
675
+ res = await run(FFMPEG_BIN, buildFfmpegArgs(manifestPath, outPath, rec.encoder));
676
+ } catch (err) {
677
+ throw new CdpError(`${FFMPEG_MISSING} Spooled frames kept at ${rec.spoolDir}. (${(err as Error).message})`);
678
+ }
679
+ if (res.code !== 0) {
680
+ throw new CdpError(
681
+ `ffmpeg (${rec.encoder.encoder}) exited ${res.code} encoding ${frames.length} frames. Spooled frames kept at ${rec.spoolDir}, re-run by hand with: ` +
682
+ `${FFMPEG_BIN} ${buildFfmpegArgs(manifestPath, outPath, rec.encoder).join(" ")}\nffmpeg said: ${res.stderr.trim().slice(0, 800)}`,
683
+ );
684
+ }
685
+
686
+ const { size } = await stat(outPath);
687
+ await rm(rec.spoolDir, { recursive: true, force: true }).catch(() => {});
688
+
689
+ return {
690
+ path: outPath,
691
+ bytes: size,
692
+ durationMs,
693
+ frameCount: ledger.length,
694
+ encodedFrames: frames.length,
695
+ codec: rec.encoder.codec,
696
+ encoder: rec.encoder.encoder,
697
+ width,
698
+ height,
699
+ droppedFrames: rec.dropped,
700
+ target: target3(rec.target),
701
+ };
702
+ }
703
+
704
+ /*
705
+ * CDP methods / domains used:
706
+ * Page.enable
707
+ * Page.bringToFront (optional, only with bringToFront:true)
708
+ * Page.startScreencast (format, quality, maxWidth, maxHeight, everyNthFrame)
709
+ * Page.screencastFrame (event; data + metadata.timestamp + sessionId)
710
+ * Page.screencastFrameAck (acked from the event handler; unacked frames stall the stream)
711
+ * Page.stopScreencast
712
+ *
713
+ * External process: ffmpeg (spawned via node:child_process, like lighthouse.ts).
714
+ * This is the second non-pure-CDP tool group in the toolkit.
715
+ *
716
+ * Parity gaps / known limits:
717
+ * - chrome-devtools-mcp has no screen-recording tool at all; this is a toolkit addition.
718
+ * - start/stop must run in ONE process, for the same reason performance_start_trace does:
719
+ * the frames are events on the connection that started the screencast and cannot be
720
+ * re-attached from a fresh process. There is deliberately no one-shot convenience twin,
721
+ * because "record for N seconds" is exactly what an agent cannot know in advance.
722
+ * - WebDriver BiDi has no screencast primitive (no streamed-frame command exists in the
723
+ * spec, and Firefox 153 implements none), so both tools declare the capability
724
+ * "capture.screencast", which the BiDi driver does not offer: they are absent from
725
+ * tools/list under --browser firefox rather than present and throwing.
726
+ * - The encoded file runs exactly one 40ms grid step longer than the reported
727
+ * durationMs: the concat demuxer honors a `duration` only when another `file` line
728
+ * follows, so the final frame is repeated and that repeat costs one step.
729
+ * Trimming it back with -t is worse (it cuts at the last frame's PTS and discards the
730
+ * final hold entirely: a 4.25s ledger measured 3.56s).
731
+ * - Encoded video tops out at 25 fps, because the concat demuxer represents image PTS
732
+ * on a 1/25s grid (see CONCAT_GRID_MS for the measurements and the three workarounds
733
+ * that do not work). Frames captured less than 40ms apart are coalesced HERE, first
734
+ * one per slot, rather than being dropped silently by ffmpeg; frameCount reports what
735
+ * was captured and encodedFrames what reached the video. Pass everyNthFrame on a
736
+ * high-framerate page to stop spooling frames that will be coalesced away.
737
+ * - Frames are captured at the size Chrome sends. If the viewport is resized mid-recording
738
+ * the spool holds mixed dimensions and ffmpeg's concat demuxer will reject the change;
739
+ * resize before starting, or pass maxWidth/maxHeight to pin the stream.
740
+ * - No audio: CDP screencast is video-only, so the MP4 is muxed with -an.
741
+ */