@mario.andreschak/mcp-browser 3.44.0 → 3.45.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/dist/capture.d.ts +34 -10
- package/dist/capture.js +202 -102
- package/dist/capture.js.map +1 -1
- package/dist/gateway.d.ts +1 -1
- package/dist/gateway.js +23 -10
- package/dist/gateway.js.map +1 -1
- package/dist/index.js +15 -1
- package/dist/index.js.map +1 -1
- package/dist/recording.d.ts +10 -3
- package/dist/recording.js +513 -67
- package/dist/recording.js.map +1 -1
- package/dist/resources.js +74 -11
- package/dist/resources.js.map +1 -1
- package/dist/runtime.d.ts +31 -7
- package/dist/runtime.js +262 -88
- package/dist/runtime.js.map +1 -1
- package/dist/tools.d.ts +1 -1
- package/dist/tools.js +283 -157
- package/dist/tools.js.map +1 -1
- package/dist/viewHtml.js +8 -2
- package/dist/viewHtml.js.map +1 -1
- package/package.json +4 -3
- package/scripts/install-browser.mjs +166 -0
package/dist/recording.js
CHANGED
|
@@ -24,15 +24,17 @@ import { promises as fs } from 'node:fs';
|
|
|
24
24
|
import path from 'node:path';
|
|
25
25
|
import { randomUUID } from 'node:crypto';
|
|
26
26
|
import { spawn } from 'node:child_process';
|
|
27
|
-
import { getDataDir, isInside } from './capture.js';
|
|
27
|
+
import { getDataDir, isInside, navigateCaptureSource, normalizeResolution, resolutionFallbacks, resolveCaptureSource, } from './capture.js';
|
|
28
28
|
import { audioTapSource } from './audioTap.js';
|
|
29
|
-
import { BrowserMcpError, acquireBrowser, defaultViewport, ensureScratchDir, closeSession, integerEnv, installRequestPolicy, recordingRoot, registerSession, } from './runtime.js';
|
|
29
|
+
import { BrowserMcpError, acquireBrowser, defaultViewport, effectiveBrowserOwnerScope, ensureScratchDir, closeSession, integerEnv, installRequestPolicy, recordingRoot, registerSession, releaseSessionReservation, reserveSession, } from './runtime.js';
|
|
30
30
|
const MAX_CONCURRENT_RECORDINGS = 2;
|
|
31
31
|
const DEFAULT_RECORD_MAX_MS = 120_000;
|
|
32
32
|
/** Hard memory cap for buffered PCM per recording (~roughly 17 minutes of stereo 16-bit 48kHz audio). */
|
|
33
33
|
const MAX_AUDIO_BYTES = 200_000_000;
|
|
34
34
|
const FFMPEG_TIMEOUT_MS = 60_000;
|
|
35
35
|
const AUDIO_BINDING_PREFIX = '__flujoRecordAudio_';
|
|
36
|
+
const COMPLETED_RECORDING_TTL_MS = 30 * 60_000;
|
|
37
|
+
const MAX_COMPLETED_RECORDINGS = 16;
|
|
36
38
|
function createDeferred() {
|
|
37
39
|
let resolve;
|
|
38
40
|
const promise = new Promise((res) => {
|
|
@@ -41,6 +43,10 @@ function createDeferred() {
|
|
|
41
43
|
return { promise, resolve };
|
|
42
44
|
}
|
|
43
45
|
const recordings = new Map();
|
|
46
|
+
const recordingReservations = new Set();
|
|
47
|
+
const finalizingRecordings = new Map();
|
|
48
|
+
const completedRecordings = new Map();
|
|
49
|
+
const latestCompletedIds = new Map();
|
|
44
50
|
function wavHeader(dataLength, sampleRate, channels, bitsPerSample) {
|
|
45
51
|
const blockAlign = (channels * bitsPerSample) / 8;
|
|
46
52
|
const byteRate = sampleRate * blockAlign;
|
|
@@ -84,10 +90,19 @@ async function attachAudioTap(state) {
|
|
|
84
90
|
}
|
|
85
91
|
});
|
|
86
92
|
const source = audioTapSource(binding);
|
|
93
|
+
// BrowserContext.addInitScript is the durable main-world hook. The old CDP-
|
|
94
|
+
// only install could remain in an isolated/default execution context and
|
|
95
|
+
// disappear when about:blank navigated to the actual page.
|
|
96
|
+
await state.context.addInitScript({ content: source });
|
|
87
97
|
await cdp.send('Runtime.enable');
|
|
88
98
|
await cdp.send('Runtime.addBinding', { name: binding });
|
|
89
|
-
await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source });
|
|
90
|
-
await cdp.send('Runtime.evaluate', { expression: source })
|
|
99
|
+
await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source }).catch(() => undefined);
|
|
100
|
+
const evaluated = await cdp.send('Runtime.evaluate', { expression: source });
|
|
101
|
+
if ('exceptionDetails' in evaluated && evaluated.exceptionDetails) {
|
|
102
|
+
throw new Error('Chromium rejected the recording audio initialization script.');
|
|
103
|
+
}
|
|
104
|
+
// about:blank can use a different execution world from the destination. The
|
|
105
|
+
// durable context init script above is verified after the real navigation.
|
|
91
106
|
}
|
|
92
107
|
async function detachAudioTap(state) {
|
|
93
108
|
if (!state.cdp)
|
|
@@ -170,28 +185,189 @@ async function confineOutputPath(outputPath) {
|
|
|
170
185
|
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
171
186
|
return resolved;
|
|
172
187
|
}
|
|
173
|
-
function resolveRecordingState(args, allowMissing) {
|
|
188
|
+
function resolveRecordingState(args, allowMissing, ownerScopeInput) {
|
|
189
|
+
const ownerScope = effectiveBrowserOwnerScope(ownerScopeInput);
|
|
174
190
|
const id = typeof args.recordingId === 'string' && args.recordingId
|
|
175
191
|
? args.recordingId
|
|
176
192
|
: (typeof args.sessionId === 'string' && args.sessionId ? args.sessionId : undefined);
|
|
177
193
|
if (id) {
|
|
178
194
|
const state = recordings.get(id);
|
|
179
|
-
if (!state) {
|
|
195
|
+
if (!state || state.ownerScope !== ownerScope) {
|
|
180
196
|
if (allowMissing)
|
|
181
197
|
return undefined;
|
|
182
198
|
throw new BrowserMcpError('NOT_FOUND', 'No matching recording is running.');
|
|
183
199
|
}
|
|
184
200
|
return state;
|
|
185
201
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
202
|
+
const owned = [...recordings.values()].filter((state) => state.ownerScope === ownerScope);
|
|
203
|
+
if (owned.length === 1)
|
|
204
|
+
return owned[0];
|
|
205
|
+
if (owned.length === 0) {
|
|
189
206
|
if (allowMissing)
|
|
190
207
|
return undefined;
|
|
191
208
|
throw new BrowserMcpError('NOT_FOUND', 'No recording is running.');
|
|
192
209
|
}
|
|
193
210
|
throw new BrowserMcpError('INVALID_ARGUMENT', 'Multiple recordings are running; specify recordingId.');
|
|
194
211
|
}
|
|
212
|
+
function rememberCompleted(result) {
|
|
213
|
+
const id = typeof result.recordingId === 'string' ? result.recordingId : undefined;
|
|
214
|
+
if (!id)
|
|
215
|
+
return;
|
|
216
|
+
const now = Date.now();
|
|
217
|
+
for (const [recordingId, entry] of completedRecordings) {
|
|
218
|
+
if (now - entry.completedAt > COMPLETED_RECORDING_TTL_MS)
|
|
219
|
+
completedRecordings.delete(recordingId);
|
|
220
|
+
}
|
|
221
|
+
while (completedRecordings.size >= MAX_COMPLETED_RECORDINGS) {
|
|
222
|
+
const oldest = completedRecordings.keys().next().value;
|
|
223
|
+
if (!oldest)
|
|
224
|
+
break;
|
|
225
|
+
completedRecordings.delete(oldest);
|
|
226
|
+
}
|
|
227
|
+
const state = finalizingRecordings.get(id);
|
|
228
|
+
const ownerScope = state?.ownerScope ?? effectiveBrowserOwnerScope();
|
|
229
|
+
completedRecordings.set(id, { result, completedAt: now, ownerScope });
|
|
230
|
+
latestCompletedIds.set(ownerScope, id);
|
|
231
|
+
}
|
|
232
|
+
function completedRecording(args, ownerScopeInput) {
|
|
233
|
+
const ownerScope = effectiveBrowserOwnerScope(ownerScopeInput);
|
|
234
|
+
const id = typeof args.recordingId === 'string' && args.recordingId
|
|
235
|
+
? args.recordingId
|
|
236
|
+
: (typeof args.sessionId === 'string' && args.sessionId ? args.sessionId : latestCompletedIds.get(ownerScope));
|
|
237
|
+
const entry = id ? completedRecordings.get(id) : undefined;
|
|
238
|
+
return entry?.ownerScope === ownerScope ? entry.result : undefined;
|
|
239
|
+
}
|
|
240
|
+
function finalizingRecording(args, ownerScopeInput) {
|
|
241
|
+
const ownerScope = effectiveBrowserOwnerScope(ownerScopeInput);
|
|
242
|
+
const id = typeof args.recordingId === 'string' && args.recordingId
|
|
243
|
+
? args.recordingId
|
|
244
|
+
: (typeof args.sessionId === 'string' && args.sessionId ? args.sessionId : undefined);
|
|
245
|
+
if (id) {
|
|
246
|
+
const state = finalizingRecordings.get(id);
|
|
247
|
+
return state?.ownerScope === ownerScope ? state : undefined;
|
|
248
|
+
}
|
|
249
|
+
const owned = [...finalizingRecordings.values()].filter((state) => state.ownerScope === ownerScope);
|
|
250
|
+
if (owned.length === 1 && ![...recordings.values()].some((state) => state.ownerScope === ownerScope))
|
|
251
|
+
return owned[0];
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
function mimeForPath(filePath) {
|
|
255
|
+
if (!filePath)
|
|
256
|
+
return undefined;
|
|
257
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
258
|
+
if (extension === '.mp4')
|
|
259
|
+
return 'video/mp4';
|
|
260
|
+
if (extension === '.mov')
|
|
261
|
+
return 'video/quicktime';
|
|
262
|
+
if (extension === '.webm')
|
|
263
|
+
return 'video/webm';
|
|
264
|
+
if (extension === '.wav')
|
|
265
|
+
return 'audio/wav';
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
async function readWebmDimensions(filePath) {
|
|
269
|
+
if (!filePath)
|
|
270
|
+
return null;
|
|
271
|
+
let handle;
|
|
272
|
+
try {
|
|
273
|
+
handle = await fs.open(filePath, 'r');
|
|
274
|
+
const stat = await handle.stat();
|
|
275
|
+
const bytes = Math.min(stat.size, 2 * 1024 * 1024);
|
|
276
|
+
const buffer = Buffer.alloc(bytes);
|
|
277
|
+
await handle.read(buffer, 0, bytes, 0);
|
|
278
|
+
let width;
|
|
279
|
+
let height;
|
|
280
|
+
for (let index = 0; index < buffer.length - 2 && (!width || !height); index += 1) {
|
|
281
|
+
const id = buffer[index];
|
|
282
|
+
if (id !== 0xb0 && id !== 0xba)
|
|
283
|
+
continue;
|
|
284
|
+
const first = buffer[index + 1];
|
|
285
|
+
let length = 1;
|
|
286
|
+
let mask = 0x80;
|
|
287
|
+
while (length <= 4 && (first & mask) === 0) {
|
|
288
|
+
length += 1;
|
|
289
|
+
mask >>= 1;
|
|
290
|
+
}
|
|
291
|
+
if (length > 4 || index + 1 + length >= buffer.length)
|
|
292
|
+
continue;
|
|
293
|
+
let size = first & (mask - 1);
|
|
294
|
+
for (let cursor = 1; cursor < length; cursor += 1)
|
|
295
|
+
size = (size << 8) | buffer[index + 1 + cursor];
|
|
296
|
+
if (size < 1 || size > 4 || index + 1 + length + size > buffer.length)
|
|
297
|
+
continue;
|
|
298
|
+
let value = 0;
|
|
299
|
+
for (let cursor = 0; cursor < size; cursor += 1) {
|
|
300
|
+
value = (value << 8) | buffer[index + 1 + length + cursor];
|
|
301
|
+
}
|
|
302
|
+
if (value < 1 || value > 16_384)
|
|
303
|
+
continue;
|
|
304
|
+
if (id === 0xb0)
|
|
305
|
+
width = value;
|
|
306
|
+
else
|
|
307
|
+
height = value;
|
|
308
|
+
}
|
|
309
|
+
return width && height ? { width, height } : null;
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
await handle?.close().catch(() => undefined);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
function geometryPayload(state, encoded = null) {
|
|
319
|
+
const viewportMismatch = state.actualViewport.width !== state.effectiveResolution.width
|
|
320
|
+
|| state.actualViewport.height !== state.effectiveResolution.height
|
|
321
|
+
|| state.deviceScaleFactor !== 1;
|
|
322
|
+
const encodedMismatch = encoded !== null
|
|
323
|
+
&& (encoded.width !== state.effectiveResolution.width || encoded.height !== state.effectiveResolution.height);
|
|
324
|
+
return {
|
|
325
|
+
requestedViewport: state.requestedResolution,
|
|
326
|
+
actualViewport: state.actualViewport,
|
|
327
|
+
deviceScaleFactor: state.deviceScaleFactor,
|
|
328
|
+
configuredVideoResolution: state.effectiveResolution,
|
|
329
|
+
actualEncodedVideoResolution: encoded,
|
|
330
|
+
contentBounds: {
|
|
331
|
+
x: 0,
|
|
332
|
+
y: 0,
|
|
333
|
+
width: state.effectiveResolution.width,
|
|
334
|
+
height: state.effectiveResolution.height,
|
|
335
|
+
},
|
|
336
|
+
letterboxInsets: { top: 0, right: 0, bottom: 0, left: 0 },
|
|
337
|
+
geometryMismatch: viewportMismatch || encodedMismatch,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
function runFfmpegTranscode(ffmpeg, source, dest) {
|
|
341
|
+
const extension = path.extname(dest).toLowerCase();
|
|
342
|
+
const codecArgs = extension === '.mp4' || extension === '.mov'
|
|
343
|
+
? ['-c:v', 'libx264', '-preset', 'veryfast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-movflags', '+faststart']
|
|
344
|
+
: ['-c:v', 'copy', '-c:a', 'libopus'];
|
|
345
|
+
return new Promise((resolve, reject) => {
|
|
346
|
+
let child;
|
|
347
|
+
try {
|
|
348
|
+
child = spawn(ffmpeg, ['-y', '-i', source, ...codecArgs, dest], { stdio: 'ignore' });
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
reject(error);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const timer = setTimeout(() => {
|
|
355
|
+
child.kill('SIGKILL');
|
|
356
|
+
reject(new Error('ffmpeg timed out.'));
|
|
357
|
+
}, FFMPEG_TIMEOUT_MS);
|
|
358
|
+
child.on('error', (error) => {
|
|
359
|
+
clearTimeout(timer);
|
|
360
|
+
reject(error);
|
|
361
|
+
});
|
|
362
|
+
child.on('close', (code) => {
|
|
363
|
+
clearTimeout(timer);
|
|
364
|
+
if (code === 0)
|
|
365
|
+
resolve();
|
|
366
|
+
else
|
|
367
|
+
reject(new Error(`ffmpeg exited with code ${code}.`));
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
}
|
|
195
371
|
async function finalizeRecording(state, outputPathOverride) {
|
|
196
372
|
if (state.stopping)
|
|
197
373
|
return state.done.promise;
|
|
@@ -199,32 +375,56 @@ async function finalizeRecording(state, outputPathOverride) {
|
|
|
199
375
|
if (state.durationTimer)
|
|
200
376
|
clearTimeout(state.durationTimer);
|
|
201
377
|
recordings.delete(state.id);
|
|
378
|
+
finalizingRecordings.set(state.id, state);
|
|
379
|
+
const warnings = [...state.warnings];
|
|
202
380
|
await detachAudioTap(state).catch(() => undefined);
|
|
203
381
|
const durationMs = Date.now() - state.startedAt;
|
|
204
382
|
let videoPath;
|
|
205
383
|
const video = state.page.video();
|
|
206
|
-
await closeSession(state.id).catch(() =>
|
|
384
|
+
await closeSession(state.id, state.ownerScope).catch((error) => {
|
|
385
|
+
warnings.push(`Closing the recording context reported: ${error instanceof Error ? error.message : 'unknown error'}.`);
|
|
386
|
+
});
|
|
207
387
|
if (video) {
|
|
388
|
+
const dest = path.join(recordingRoot(), `${state.id}.webm`);
|
|
208
389
|
try {
|
|
209
|
-
const dest = path.join(recordingRoot(), `${state.id}.webm`);
|
|
210
390
|
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
211
391
|
await video.saveAs(dest);
|
|
212
392
|
videoPath = dest;
|
|
213
393
|
}
|
|
214
|
-
catch {
|
|
215
|
-
|
|
394
|
+
catch (error) {
|
|
395
|
+
warnings.push(`The normal video save failed; trying Chromium's raw recording file (${error instanceof Error ? error.message : 'unknown error'}).`);
|
|
396
|
+
try {
|
|
397
|
+
const rawPath = await video.path();
|
|
398
|
+
await fs.copyFile(rawPath, dest);
|
|
399
|
+
videoPath = dest;
|
|
400
|
+
warnings.push('Recovered the video from Chromium\'s raw recording file.');
|
|
401
|
+
}
|
|
402
|
+
catch (fallbackError) {
|
|
403
|
+
warnings.push(`Raw video recovery also failed: ${fallbackError instanceof Error ? fallbackError.message : 'unknown error'}.`);
|
|
404
|
+
}
|
|
216
405
|
}
|
|
217
406
|
}
|
|
407
|
+
else {
|
|
408
|
+
warnings.push('Chromium did not expose a video artifact for this recording context.');
|
|
409
|
+
}
|
|
218
410
|
await fs.rm(state.videoDir, { recursive: true, force: true }).catch(() => undefined);
|
|
219
411
|
let audioPath;
|
|
220
412
|
if (state.audio && state.audioBytes > 0) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
413
|
+
try {
|
|
414
|
+
const rate = state.audioRate ?? 48_000;
|
|
415
|
+
const pcm = Buffer.concat(state.audioChunks);
|
|
416
|
+
const header = wavHeader(pcm.length, rate, 2, 16);
|
|
417
|
+
const dest = path.join(recordingRoot(), `${state.id}.wav`);
|
|
418
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
419
|
+
await fs.writeFile(dest, Buffer.concat([header, pcm]));
|
|
420
|
+
audioPath = dest;
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
warnings.push(`Audio was captured but could not be saved: ${error instanceof Error ? error.message : 'unknown error'}.`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
else if (state.audio) {
|
|
427
|
+
warnings.push('No audible page signal was captured; returning the video without an audio track.');
|
|
228
428
|
}
|
|
229
429
|
let mergedPath;
|
|
230
430
|
let muxed = false;
|
|
@@ -238,12 +438,14 @@ async function finalizeRecording(state, outputPathOverride) {
|
|
|
238
438
|
mergedPath = dest;
|
|
239
439
|
muxed = true;
|
|
240
440
|
}
|
|
241
|
-
catch {
|
|
441
|
+
catch (error) {
|
|
242
442
|
reason = 'mux-failed';
|
|
443
|
+
warnings.push(`ffmpeg could not mux audio and video; both original files remain available (${error instanceof Error ? error.message : 'unknown error'}).`);
|
|
243
444
|
}
|
|
244
445
|
}
|
|
245
446
|
else {
|
|
246
447
|
reason = 'ffmpeg-not-available';
|
|
448
|
+
warnings.push('ffmpeg was not available, so video and audio are returned as separate artifacts.');
|
|
247
449
|
}
|
|
248
450
|
}
|
|
249
451
|
else if (videoPath && !audioPath) {
|
|
@@ -255,91 +457,233 @@ async function finalizeRecording(state, outputPathOverride) {
|
|
|
255
457
|
let outputPath = mergedPath ?? videoPath;
|
|
256
458
|
if (outputPathOverride && outputPath) {
|
|
257
459
|
try {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
460
|
+
let requested = outputPathOverride;
|
|
461
|
+
if (!path.extname(requested)) {
|
|
462
|
+
requested += '.webm';
|
|
463
|
+
warnings.push(`outputPath had no extension; wrote ${requested} instead.`);
|
|
464
|
+
}
|
|
465
|
+
const dest = await confineOutputPath(requested);
|
|
466
|
+
const destinationExtension = path.extname(dest).toLowerCase();
|
|
467
|
+
const sourceExtension = path.extname(outputPath).toLowerCase();
|
|
468
|
+
if (destinationExtension === sourceExtension) {
|
|
469
|
+
await fs.copyFile(outputPath, dest);
|
|
470
|
+
outputPath = dest;
|
|
471
|
+
}
|
|
472
|
+
else if (['.mp4', '.mov', '.webm'].includes(destinationExtension)) {
|
|
473
|
+
const ffmpeg = await resolveFfmpeg();
|
|
474
|
+
if (ffmpeg) {
|
|
475
|
+
await runFfmpegTranscode(ffmpeg, outputPath, dest);
|
|
476
|
+
outputPath = dest;
|
|
477
|
+
warnings.push(`ffmpeg converted the recording to ${destinationExtension}.`);
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
warnings.push(`Could not create ${destinationExtension} because ffmpeg is unavailable; keeping ${sourceExtension || '.webm'}.`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
warnings.push(`Unsupported output extension ${destinationExtension}; keeping the WebM artifact instead.`);
|
|
485
|
+
}
|
|
261
486
|
}
|
|
262
|
-
catch {
|
|
263
|
-
|
|
487
|
+
catch (error) {
|
|
488
|
+
warnings.push(`The requested outputPath could not be used; the safe recording-root artifact was kept (${error instanceof Error ? error.message : 'unknown error'}).`);
|
|
264
489
|
}
|
|
265
490
|
}
|
|
266
491
|
const stat = outputPath ? await fs.stat(outputPath).catch(() => undefined) : undefined;
|
|
492
|
+
const actualEncodedVideoResolution = await readWebmDimensions(videoPath);
|
|
493
|
+
if (!actualEncodedVideoResolution) {
|
|
494
|
+
warnings.push('The finalized WebM dimensions could not be verified in-process; actualEncodedVideoResolution is null.');
|
|
495
|
+
}
|
|
496
|
+
else if (actualEncodedVideoResolution.width !== state.effectiveResolution.width
|
|
497
|
+
|| actualEncodedVideoResolution.height !== state.effectiveResolution.height) {
|
|
498
|
+
warnings.push(`Encoded geometry mismatch: configured ${state.effectiveResolution.width}x${state.effectiveResolution.height}, observed ${actualEncodedVideoResolution.width}x${actualEncodedVideoResolution.height}. Exact evidence is invalid.`);
|
|
499
|
+
}
|
|
500
|
+
const success = Boolean(outputPath && stat?.isFile() && stat.size > 0);
|
|
501
|
+
const artifacts = [
|
|
502
|
+
outputPath ? { kind: 'video', path: outputPath, mimeType: mimeForPath(outputPath) ?? 'video/webm', bytes: stat?.size ?? 0 } : undefined,
|
|
503
|
+
audioPath ? { kind: 'audio', path: audioPath, mimeType: 'audio/wav' } : undefined,
|
|
504
|
+
].filter(Boolean);
|
|
267
505
|
const result = {
|
|
268
|
-
success
|
|
506
|
+
success,
|
|
269
507
|
recordingId: state.id,
|
|
270
508
|
sessionId: state.id,
|
|
271
|
-
status: 'stopped',
|
|
509
|
+
status: success ? 'stopped' : 'failed',
|
|
272
510
|
durationMs,
|
|
511
|
+
requestedResolution: state.requestedResolution,
|
|
512
|
+
effectiveResolution: state.effectiveResolution,
|
|
513
|
+
...geometryPayload(state, actualEncodedVideoResolution),
|
|
273
514
|
videoPath,
|
|
274
515
|
audioPath,
|
|
275
516
|
mergedPath,
|
|
276
517
|
outputPath,
|
|
277
518
|
muxed,
|
|
278
519
|
bytes: stat?.size ?? 0,
|
|
520
|
+
artifacts,
|
|
521
|
+
warnings,
|
|
522
|
+
...(state.setupAttempts.length ? { attempts: state.setupAttempts } : {}),
|
|
279
523
|
...(reason ? { reason } : {}),
|
|
524
|
+
...(!success ? {
|
|
525
|
+
error: {
|
|
526
|
+
code: 'RECORDING_FAILED',
|
|
527
|
+
message: 'Chromium did not produce a usable video after the normal save and raw-file recovery attempts.',
|
|
528
|
+
recovery: { suggestedResolution: '1280x720', audio: false },
|
|
529
|
+
},
|
|
530
|
+
} : {}),
|
|
531
|
+
nextAction: success
|
|
532
|
+
? 'Use outputPath directly; the video is also returned as MCP media when it is small enough.'
|
|
533
|
+
: 'Retry browser_record_start with resolution="720p" and audio=false; warnings contains every attempted recovery.',
|
|
280
534
|
};
|
|
281
535
|
state.done.resolve(result);
|
|
536
|
+
rememberCompleted(result);
|
|
537
|
+
finalizingRecordings.delete(state.id);
|
|
282
538
|
return result;
|
|
283
539
|
}
|
|
284
|
-
export async function startRecording(args, signal) {
|
|
285
|
-
|
|
540
|
+
export async function startRecording(args, signal, ownerScopeInput) {
|
|
541
|
+
const ownerScope = effectiveBrowserOwnerScope(ownerScopeInput);
|
|
542
|
+
if (recordings.size + recordingReservations.size >= MAX_CONCURRENT_RECORDINGS) {
|
|
286
543
|
throw new BrowserMcpError('SESSION_LIMIT', `The recording limit (${MAX_CONCURRENT_RECORDINGS}) has been reached.`);
|
|
287
544
|
}
|
|
288
545
|
const defaults = defaultViewport();
|
|
289
|
-
const
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
546
|
+
const maxWidth = integerEnv('FLUJO_BROWSER_RECORD_MAX_WIDTH', 1920, 640, 3840);
|
|
547
|
+
const maxHeight = integerEnv('FLUJO_BROWSER_RECORD_MAX_HEIGHT', 1080, 360, 2160);
|
|
548
|
+
const resolution = normalizeResolution(args.resolution, args.width, args.height, {
|
|
549
|
+
defaultValue: defaults,
|
|
550
|
+
minWidth: 320,
|
|
551
|
+
minHeight: 240,
|
|
552
|
+
maxWidth,
|
|
553
|
+
maxHeight,
|
|
554
|
+
even: true,
|
|
555
|
+
});
|
|
294
556
|
const maxMs = integerEnv('FLUJO_BROWSER_RECORD_MAX_MS', DEFAULT_RECORD_MAX_MS, 1_000, 30 * 60_000);
|
|
295
557
|
let durationMs;
|
|
296
558
|
if (args.durationMs !== undefined) {
|
|
297
|
-
|
|
298
|
-
|
|
559
|
+
const parsed = typeof args.durationMs === 'number' ? args.durationMs : Number(args.durationMs);
|
|
560
|
+
if (!Number.isFinite(parsed)) {
|
|
561
|
+
resolution.warnings.push('durationMs was not numeric, so auto-stop was disabled.');
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
durationMs = Math.min(maxMs, Math.max(250, Math.trunc(parsed)));
|
|
565
|
+
if (durationMs !== Math.trunc(parsed)) {
|
|
566
|
+
resolution.warnings.push(`durationMs was adjusted to ${durationMs}ms.`);
|
|
567
|
+
}
|
|
299
568
|
}
|
|
300
|
-
durationMs = Math.min(maxMs, Math.trunc(args.durationMs));
|
|
301
569
|
}
|
|
302
|
-
const audioRequested = args.audio
|
|
570
|
+
const audioRequested = !(args.audio === false || (typeof args.audio === 'string' && /^(0|false|no|off)$/i.test(args.audio.trim())));
|
|
303
571
|
const outputPath = typeof args.outputPath === 'string' && args.outputPath.length > 0 ? args.outputPath : undefined;
|
|
304
|
-
if (signal.aborted)
|
|
305
|
-
throw new BrowserMcpError('CANCELLED', 'The browser request was cancelled.');
|
|
306
|
-
const browser = await acquireBrowser();
|
|
307
572
|
if (signal.aborted)
|
|
308
573
|
throw new BrowserMcpError('CANCELLED', 'The browser request was cancelled.');
|
|
309
574
|
const id = randomUUID();
|
|
310
|
-
|
|
311
|
-
const context = await browser.newContext({
|
|
312
|
-
viewport: { width, height },
|
|
313
|
-
acceptDownloads: false,
|
|
314
|
-
recordVideo: { dir: videoDir, size: { width, height } },
|
|
315
|
-
});
|
|
316
|
-
let page;
|
|
575
|
+
recordingReservations.add(id);
|
|
317
576
|
try {
|
|
318
|
-
|
|
319
|
-
await page.goto('about:blank', { waitUntil: 'load' }).catch(() => undefined);
|
|
577
|
+
reserveSession(id, ownerScope, 'recording');
|
|
320
578
|
}
|
|
321
579
|
catch (error) {
|
|
322
|
-
|
|
580
|
+
recordingReservations.delete(id);
|
|
581
|
+
throw error;
|
|
582
|
+
}
|
|
583
|
+
let browser;
|
|
584
|
+
try {
|
|
585
|
+
browser = await acquireBrowser();
|
|
586
|
+
}
|
|
587
|
+
catch (error) {
|
|
588
|
+
recordingReservations.delete(id);
|
|
589
|
+
releaseSessionReservation(id, ownerScope);
|
|
323
590
|
throw error;
|
|
324
591
|
}
|
|
325
592
|
if (signal.aborted) {
|
|
593
|
+
recordingReservations.delete(id);
|
|
594
|
+
releaseSessionReservation(id, ownerScope);
|
|
595
|
+
throw new BrowserMcpError('CANCELLED', 'The browser request was cancelled.');
|
|
596
|
+
}
|
|
597
|
+
const setupAttempts = [];
|
|
598
|
+
let context;
|
|
599
|
+
let page;
|
|
600
|
+
let videoDir = '';
|
|
601
|
+
let effectiveResolution;
|
|
602
|
+
let actualViewport;
|
|
603
|
+
const candidates = resolutionFallbacks(resolution.effective)
|
|
604
|
+
.filter(({ width, height }) => width <= maxWidth && height <= maxHeight);
|
|
605
|
+
for (const candidate of candidates) {
|
|
606
|
+
try {
|
|
607
|
+
videoDir = await ensureScratchDir(path.join('recordings', `${id}-${candidate.width}x${candidate.height}`));
|
|
608
|
+
context = await browser.newContext({
|
|
609
|
+
viewport: candidate,
|
|
610
|
+
deviceScaleFactor: 1,
|
|
611
|
+
acceptDownloads: false,
|
|
612
|
+
recordVideo: { dir: videoDir, size: candidate },
|
|
613
|
+
});
|
|
614
|
+
page = await context.newPage();
|
|
615
|
+
const observed = await page.evaluate('({ width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio })');
|
|
616
|
+
if (observed.width !== candidate.width || observed.height !== candidate.height || observed.dpr !== 1) {
|
|
617
|
+
throw new BrowserMcpError('BROWSER_UNAVAILABLE', `Recording geometry mismatch during startup: requested ${candidate.width}x${candidate.height} DPR 1, observed ${observed.width}x${observed.height} DPR ${observed.dpr}.`);
|
|
618
|
+
}
|
|
619
|
+
effectiveResolution = candidate;
|
|
620
|
+
actualViewport = { width: observed.width, height: observed.height };
|
|
621
|
+
break;
|
|
622
|
+
}
|
|
623
|
+
catch (error) {
|
|
624
|
+
setupAttempts.push(`${candidate.width}x${candidate.height}: ${error instanceof Error ? error.message : 'context creation failed'}`);
|
|
625
|
+
await context?.close().catch(() => undefined);
|
|
626
|
+
await fs.rm(videoDir, { recursive: true, force: true }).catch(() => undefined);
|
|
627
|
+
context = undefined;
|
|
628
|
+
page = undefined;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (!context || !page || !effectiveResolution || !actualViewport) {
|
|
632
|
+
recordingReservations.delete(id);
|
|
633
|
+
releaseSessionReservation(id, ownerScope);
|
|
634
|
+
throw new BrowserMcpError('BROWSER_UNAVAILABLE', `Could not start video recording after safe resolution fallbacks. ${setupAttempts.join(' | ')}`);
|
|
635
|
+
}
|
|
636
|
+
if (setupAttempts.length > 0) {
|
|
637
|
+
resolution.warnings.push(`Recording recovered at ${effectiveResolution.width}x${effectiveResolution.height} after ${setupAttempts.length} failed resolution attempt(s).`);
|
|
638
|
+
}
|
|
639
|
+
if (signal.aborted) {
|
|
640
|
+
recordingReservations.delete(id);
|
|
641
|
+
releaseSessionReservation(id, ownerScope);
|
|
326
642
|
await context.close().catch(() => undefined);
|
|
327
643
|
throw new BrowserMcpError('CANCELLED', 'The browser request was cancelled.');
|
|
328
644
|
}
|
|
645
|
+
const now = Date.now();
|
|
329
646
|
const session = {
|
|
330
647
|
id,
|
|
331
648
|
mode: 'sandbox',
|
|
649
|
+
ownerScope,
|
|
650
|
+
purpose: 'recording',
|
|
651
|
+
lifecycleState: 'active',
|
|
652
|
+
viewportPolicy: 'fixed',
|
|
653
|
+
createdAt: now,
|
|
654
|
+
gatewayToken: randomUUID(),
|
|
655
|
+
recordingId: id,
|
|
656
|
+
configuredViewport: effectiveResolution,
|
|
657
|
+
configuredVideoResolution: effectiveResolution,
|
|
658
|
+
deviceScaleFactor: 1,
|
|
332
659
|
context,
|
|
333
660
|
page,
|
|
334
|
-
touchedAt:
|
|
661
|
+
touchedAt: now,
|
|
335
662
|
documentRequests: 0,
|
|
336
663
|
navigationBlocked: false,
|
|
337
664
|
blockedRequestCount: 0,
|
|
338
665
|
};
|
|
339
|
-
|
|
340
|
-
|
|
666
|
+
try {
|
|
667
|
+
registerSession(session, ownerScope);
|
|
668
|
+
}
|
|
669
|
+
catch (error) {
|
|
670
|
+
recordingReservations.delete(id);
|
|
671
|
+
releaseSessionReservation(id, ownerScope);
|
|
672
|
+
await context.close().catch(() => undefined);
|
|
673
|
+
throw error;
|
|
674
|
+
}
|
|
675
|
+
try {
|
|
676
|
+
await installRequestPolicy(context);
|
|
677
|
+
}
|
|
678
|
+
catch (error) {
|
|
679
|
+
recordingReservations.delete(id);
|
|
680
|
+
await closeSession(id, ownerScope).catch(() => undefined);
|
|
681
|
+
await fs.rm(videoDir, { recursive: true, force: true }).catch(() => undefined);
|
|
682
|
+
throw error;
|
|
683
|
+
}
|
|
341
684
|
const state = {
|
|
342
685
|
id,
|
|
686
|
+
ownerScope,
|
|
343
687
|
context,
|
|
344
688
|
page,
|
|
345
689
|
videoDir,
|
|
@@ -349,31 +693,116 @@ export async function startRecording(args, signal) {
|
|
|
349
693
|
audioBytes: 0,
|
|
350
694
|
stopping: false,
|
|
351
695
|
done: createDeferred(),
|
|
696
|
+
requestedResolution: resolution.requested,
|
|
697
|
+
effectiveResolution,
|
|
698
|
+
actualViewport,
|
|
699
|
+
deviceScaleFactor: 1,
|
|
700
|
+
warnings: [...resolution.warnings],
|
|
701
|
+
setupAttempts,
|
|
352
702
|
};
|
|
703
|
+
session.onExpire = () => { void finalizeRecording(state); };
|
|
704
|
+
recordingReservations.delete(id);
|
|
353
705
|
recordings.set(id, state);
|
|
354
706
|
if (audioRequested) {
|
|
355
|
-
|
|
707
|
+
try {
|
|
708
|
+
await attachAudioTap(state);
|
|
709
|
+
}
|
|
710
|
+
catch (error) {
|
|
711
|
+
state.audio = false;
|
|
712
|
+
state.warnings.push(`Audio capture could not initialize; video recording continues without audio (${error instanceof Error ? error.message : 'unknown error'}).`);
|
|
713
|
+
}
|
|
356
714
|
}
|
|
715
|
+
let sourceLoaded = false;
|
|
716
|
+
const hasSource = [args.source, args.url, args.html, args.filePath]
|
|
717
|
+
.some((value) => typeof value === 'string' && value.trim().length > 0);
|
|
718
|
+
if (hasSource) {
|
|
719
|
+
try {
|
|
720
|
+
const source = await resolveCaptureSource({
|
|
721
|
+
source: args.source,
|
|
722
|
+
url: typeof args.url === 'string' ? args.url : undefined,
|
|
723
|
+
html: typeof args.html === 'string' ? args.html : undefined,
|
|
724
|
+
filePath: typeof args.filePath === 'string' ? args.filePath : undefined,
|
|
725
|
+
});
|
|
726
|
+
state.warnings.push(...source.warnings);
|
|
727
|
+
const rawTimeout = typeof args.timeoutMs === 'number' ? args.timeoutMs : Number(args.timeoutMs);
|
|
728
|
+
const navigationTimeout = Number.isFinite(rawTimeout) ? Math.min(60_000, Math.max(1_000, Math.trunc(rawTimeout))) : 30_000;
|
|
729
|
+
await navigateCaptureSource(page, source, navigationTimeout);
|
|
730
|
+
sourceLoaded = true;
|
|
731
|
+
}
|
|
732
|
+
catch (error) {
|
|
733
|
+
state.warnings.push(`The initial source could not be loaded; the recording session remains open for browser_navigate (${error instanceof Error ? error.message : 'unknown error'}).`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
let autoStopAt;
|
|
357
737
|
if (durationMs !== undefined) {
|
|
738
|
+
autoStopAt = Date.now() + durationMs;
|
|
358
739
|
state.durationTimer = setTimeout(() => {
|
|
359
740
|
void finalizeRecording(state, outputPath);
|
|
360
741
|
}, durationMs);
|
|
361
742
|
state.durationTimer.unref?.();
|
|
362
|
-
return state.done.promise;
|
|
363
743
|
}
|
|
364
|
-
return {
|
|
744
|
+
return {
|
|
745
|
+
success: true,
|
|
746
|
+
recordingId: id,
|
|
747
|
+
sessionId: id,
|
|
748
|
+
status: 'recording',
|
|
749
|
+
startedAt: state.startedAt,
|
|
750
|
+
requestedResolution: state.requestedResolution,
|
|
751
|
+
effectiveResolution: state.effectiveResolution,
|
|
752
|
+
...geometryPayload(state),
|
|
753
|
+
audio: state.audio,
|
|
754
|
+
sourceLoaded,
|
|
755
|
+
...(autoStopAt ? { autoStopAt, durationMs } : {}),
|
|
756
|
+
warnings: state.warnings,
|
|
757
|
+
...(setupAttempts.length ? { attempts: setupAttempts } : {}),
|
|
758
|
+
nextAction: autoStopAt
|
|
759
|
+
? 'Drive this session now; after autoStopAt, call browser_record_stop or browser_record_status with recordingId to retrieve the artifact.'
|
|
760
|
+
: 'Drive this session with browser_navigate/click/type, then call browser_record_stop with recordingId.',
|
|
761
|
+
};
|
|
365
762
|
}
|
|
366
|
-
export async function stopRecording(args) {
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
763
|
+
export async function stopRecording(args, ownerScopeInput) {
|
|
764
|
+
const existing = completedRecording(args, ownerScopeInput);
|
|
765
|
+
const state = resolveRecordingState(args, true, ownerScopeInput);
|
|
766
|
+
if (!state) {
|
|
767
|
+
const finalizing = finalizingRecording(args, ownerScopeInput);
|
|
768
|
+
if (finalizing)
|
|
769
|
+
return finalizing.done.promise;
|
|
770
|
+
if (existing)
|
|
771
|
+
return existing;
|
|
772
|
+
throw new BrowserMcpError('NOT_FOUND', 'No matching recording is running or recently completed. Start one with browser_record_start.');
|
|
773
|
+
}
|
|
370
774
|
const outputPath = typeof args.outputPath === 'string' && args.outputPath.length > 0 ? args.outputPath : undefined;
|
|
371
775
|
return finalizeRecording(state, outputPath);
|
|
372
776
|
}
|
|
373
|
-
export function recordingStatus(args) {
|
|
374
|
-
const state = resolveRecordingState(args, true);
|
|
375
|
-
if (!state)
|
|
376
|
-
|
|
777
|
+
export function recordingStatus(args, ownerScopeInput) {
|
|
778
|
+
const state = resolveRecordingState(args, true, ownerScopeInput);
|
|
779
|
+
if (!state) {
|
|
780
|
+
const finalizing = finalizingRecording(args, ownerScopeInput);
|
|
781
|
+
if (finalizing) {
|
|
782
|
+
return {
|
|
783
|
+
success: true,
|
|
784
|
+
recordingId: finalizing.id,
|
|
785
|
+
sessionId: finalizing.id,
|
|
786
|
+
running: false,
|
|
787
|
+
status: 'finalizing',
|
|
788
|
+
elapsedMs: Date.now() - finalizing.startedAt,
|
|
789
|
+
audioBytes: finalizing.audioBytes,
|
|
790
|
+
effectiveResolution: finalizing.effectiveResolution,
|
|
791
|
+
...geometryPayload(finalizing),
|
|
792
|
+
warnings: finalizing.warnings,
|
|
793
|
+
nextAction: 'Call browser_record_stop with this recordingId; it will wait for finalization and return the artifact.',
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
const completed = completedRecording(args, ownerScopeInput);
|
|
797
|
+
if (completed)
|
|
798
|
+
return { ...completed, running: false };
|
|
799
|
+
return {
|
|
800
|
+
success: true,
|
|
801
|
+
recordingId: null,
|
|
802
|
+
running: false,
|
|
803
|
+
nextAction: 'Call browser_record_start; source and durationMs can make this a one-call setup.',
|
|
804
|
+
};
|
|
805
|
+
}
|
|
377
806
|
return {
|
|
378
807
|
success: true,
|
|
379
808
|
recordingId: state.id,
|
|
@@ -381,11 +810,28 @@ export function recordingStatus(args) {
|
|
|
381
810
|
running: !state.stopping,
|
|
382
811
|
elapsedMs: Date.now() - state.startedAt,
|
|
383
812
|
audioBytes: state.audioBytes,
|
|
813
|
+
effectiveResolution: state.effectiveResolution,
|
|
814
|
+
...geometryPayload(state),
|
|
815
|
+
warnings: state.warnings,
|
|
816
|
+
nextAction: 'Continue driving the session, or call browser_record_stop to finalize and retrieve the video.',
|
|
384
817
|
};
|
|
385
818
|
}
|
|
819
|
+
export async function releaseRecordingsForOwner(ownerScopeInput) {
|
|
820
|
+
const ownerScope = effectiveBrowserOwnerScope(ownerScopeInput);
|
|
821
|
+
const states = [...recordings.values()].filter((state) => state.ownerScope === ownerScope);
|
|
822
|
+
await Promise.all(states.map((state) => finalizeRecording(state).catch(() => undefined)));
|
|
823
|
+
return states.length;
|
|
824
|
+
}
|
|
386
825
|
/** Finalise (never silently drop) every in-flight recording during process shutdown. */
|
|
387
826
|
export async function shutdownAllRecordings() {
|
|
388
827
|
const states = [...recordings.values()];
|
|
389
|
-
await Promise.all(
|
|
828
|
+
await Promise.all([
|
|
829
|
+
...states.map((state) => finalizeRecording(state).catch(() => undefined)),
|
|
830
|
+
...[...finalizingRecordings.values()].map((state) => state.done.promise.catch(() => undefined)),
|
|
831
|
+
]);
|
|
832
|
+
recordingReservations.clear();
|
|
833
|
+
finalizingRecordings.clear();
|
|
834
|
+
completedRecordings.clear();
|
|
835
|
+
latestCompletedIds.clear();
|
|
390
836
|
}
|
|
391
837
|
//# sourceMappingURL=recording.js.map
|