cdp-toolkit 1.2.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.
- package/CHANGELOG.md +223 -0
- package/CONTRACT.md +4 -2
- package/README.md +67 -20
- package/dist/cli.js +783 -63
- package/dist/index.js +694 -48
- package/dist/mcp.js +982 -66
- package/package.json +5 -3
- package/src/bidi/driver.ts +140 -1
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +148 -1
- package/src/driver.ts +110 -1
- package/src/firefox-tools.ts +1 -1
- package/src/index.ts +22 -4
- package/src/leases-tools.ts +9 -3
- package/src/leases.ts +9 -1
- package/src/manifest.ts +198 -2
- package/src/mcp.ts +1 -1
- package/src/origins.ts +266 -0
- package/src/shared-tools.ts +317 -11
- package/src/tools/screencast.ts +741 -0
|
@@ -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
|
+
*/
|