autokap 1.3.10 → 1.3.12
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/dist/browser.d.ts +0 -8
- package/dist/browser.js +32 -59
- package/dist/clip-capture-loop.js +6 -5
- package/dist/web-playwright-local.js +10 -68
- package/package.json +1 -1
- package/dist/ffmpeg-x11-recorder.d.ts +0 -42
- package/dist/ffmpeg-x11-recorder.js +0 -173
- package/dist/xvfb-process.d.ts +0 -30
- package/dist/xvfb-process.js +0 -103
package/dist/browser.d.ts
CHANGED
|
@@ -96,7 +96,6 @@ export declare class Browser {
|
|
|
96
96
|
private browser;
|
|
97
97
|
private context;
|
|
98
98
|
private page;
|
|
99
|
-
private xvfb;
|
|
100
99
|
private elementMap;
|
|
101
100
|
private akNodeIndex;
|
|
102
101
|
private poolContext;
|
|
@@ -335,13 +334,6 @@ export declare class Browser {
|
|
|
335
334
|
resizeViewport(width: number, height: number): Promise<void>;
|
|
336
335
|
get currentPage(): Page;
|
|
337
336
|
get browserContext(): BrowserContext;
|
|
338
|
-
/**
|
|
339
|
-
* DISPLAY string of the Xvfb virtual screen, when this browser was launched
|
|
340
|
-
* with one (cloud clip capture path). `null` for headless / Mac / Windows
|
|
341
|
-
* launches that don't use Xvfb. Consumed by `FfmpegX11Recorder` so it knows
|
|
342
|
-
* which display to grab.
|
|
343
|
-
*/
|
|
344
|
-
get xvfbDisplay(): string | null;
|
|
345
337
|
/**
|
|
346
338
|
* Observation pass for mock data generation.
|
|
347
339
|
* Navigates to the given URL, waits for network idle, and records all JSON API responses.
|
package/dist/browser.js
CHANGED
|
@@ -98,7 +98,6 @@ function resolveEffectivePadding(config, bbox) {
|
|
|
98
98
|
}
|
|
99
99
|
import { CAPTURE_HIDE_STYLE_ID, dismissCookiesAndWidgets, ensureCaptureHideStyles, getCaptureHideCSS, } from './cookie-dismiss.js';
|
|
100
100
|
import { CHROMIUM_ARGS, browserPool } from './browser-pool.js';
|
|
101
|
-
import { XvfbProcess } from './xvfb-process.js';
|
|
102
101
|
import { isDebugEnabled, logger } from './logger.js';
|
|
103
102
|
async function withHelperTimeout(label, timeoutMs, work) {
|
|
104
103
|
if (!timeoutMs || timeoutMs <= 0) {
|
|
@@ -773,7 +772,6 @@ export class Browser {
|
|
|
773
772
|
browser = null;
|
|
774
773
|
context = null;
|
|
775
774
|
page = null;
|
|
776
|
-
xvfb = null;
|
|
777
775
|
elementMap = new Map();
|
|
778
776
|
akNodeIndex = new Map();
|
|
779
777
|
poolContext = false;
|
|
@@ -811,64 +809,54 @@ export class Browser {
|
|
|
811
809
|
static async forClipCapture(options, cursorScript) {
|
|
812
810
|
const instance = new Browser(options);
|
|
813
811
|
const deviceScaleFactor = normalizeDeviceScaleFactor(options.deviceScaleFactor);
|
|
814
|
-
// Enable GPU compositor on
|
|
815
|
-
//
|
|
816
|
-
// `--disable-gpu` from CHROMIUM_ARGS
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
|
|
812
|
+
// Enable GPU compositor on every platform that has a real GPU available:
|
|
813
|
+
// macOS (Metal), Windows (D3D11), and Cloud Run (NVIDIA L4 via Vulkan).
|
|
814
|
+
// Local Linux without a GPU keeps `--disable-gpu` from CHROMIUM_ARGS as
|
|
815
|
+
// a safe software fallback. Cloud Run mounts the NVIDIA driver under
|
|
816
|
+
// /usr/local/nvidia/lib64 (see cloud-runner/Dockerfile) so the userspace
|
|
817
|
+
// Vulkan loader can bind to the L4. SwiftShader was tried on Fly (v1.3.8)
|
|
818
|
+
// and crashed because Fly machines had no real GPU; with a hardware L4
|
|
819
|
+
// and the proper userspace stack the path is supported.
|
|
820
|
+
const isCloudRunner = process.env.AUTOKAP_CLOUD_RUNNER === '1';
|
|
821
|
+
const isLinuxWithGpu = process.platform === 'linux' && isCloudRunner;
|
|
822
|
+
const baseArgs = (process.platform === 'linux' && !isLinuxWithGpu)
|
|
821
823
|
? CHROMIUM_ARGS
|
|
822
824
|
: CHROMIUM_ARGS.filter(arg => arg !== '--disable-gpu' && arg !== '--disable-gpu-sandbox');
|
|
823
825
|
// Pin ANGLE to the platform's native graphics API. Chrome's default
|
|
824
826
|
// backend is OpenGL on macOS, which is far slower than Metal for the
|
|
825
827
|
// compositor (measured 4 FPS vs 32 FPS at 2880×1800 on a heavy React UI).
|
|
826
|
-
// Same story on Windows where D3D11 is the native fast path.
|
|
828
|
+
// Same story on Windows where D3D11 is the native fast path. Cloud Run
|
|
829
|
+
// L4 routes through Vulkan — the most stable backend Chromium supports
|
|
830
|
+
// on Linux + NVIDIA, with hardware-accelerated rasterization and video.
|
|
827
831
|
const angleArg = process.platform === 'darwin' ? '--use-angle=metal'
|
|
828
832
|
: process.platform === 'win32' ? '--use-angle=d3d11'
|
|
829
|
-
:
|
|
830
|
-
|
|
831
|
-
//
|
|
832
|
-
//
|
|
833
|
-
//
|
|
834
|
-
//
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
// Chromium reads DISPLAY when launched non-headless — this directs
|
|
846
|
-
// rendering to the Xvfb framebuffer that ffmpeg will later capture.
|
|
847
|
-
process.env.DISPLAY = instance.xvfb.display;
|
|
848
|
-
logger.info(`[capture] Cloud clip capture: Chromium → Xvfb ${instance.xvfb.display} → ffmpeg x11grab path enabled`);
|
|
849
|
-
}
|
|
850
|
-
// Kiosk + zero-position anchor for Xvfb: Chromium normally renders its
|
|
851
|
-
// chrome (toolbar, address bar, tabs) above the page in headed mode.
|
|
852
|
-
// x11grab captures the whole screen, so the chrome would sit at the top
|
|
853
|
-
// of every clip. `--kiosk` removes all UI; `--window-position=0,0` and
|
|
854
|
-
// `--window-size` ensure the page fills the Xvfb screen exactly.
|
|
855
|
-
const xvfbWindowArgs = useXvfb ? [
|
|
856
|
-
'--kiosk',
|
|
857
|
-
'--window-position=0,0',
|
|
858
|
-
] : [];
|
|
833
|
+
: isLinuxWithGpu ? '--use-angle=vulkan'
|
|
834
|
+
: null;
|
|
835
|
+
// Cloud Run NVIDIA L4: explicit Vulkan + GPU rasterization opt-ins.
|
|
836
|
+
// `--ignore-gpu-blocklist` bypasses Chromium's hardcoded driver blocklist
|
|
837
|
+
// (it doesn't know about Cloud Run's mounted NVIDIA driver). Skia GPU
|
|
838
|
+
// rasterization + zero-copy texture upload are the throughput wins for
|
|
839
|
+
// backdrop-filter, blur, and full-viewport repaints (the modal-open
|
|
840
|
+
// scenario that defeats software rasterization at ~1 fps).
|
|
841
|
+
const cloudGpuArgs = isLinuxWithGpu
|
|
842
|
+
? [
|
|
843
|
+
'--enable-features=Vulkan',
|
|
844
|
+
'--ignore-gpu-blocklist',
|
|
845
|
+
'--enable-gpu-rasterization',
|
|
846
|
+
'--enable-zero-copy',
|
|
847
|
+
]
|
|
848
|
+
: [];
|
|
859
849
|
const clipArgs = [
|
|
860
850
|
...baseArgs,
|
|
861
851
|
`--force-device-scale-factor=${deviceScaleFactor}`,
|
|
862
852
|
`--window-size=${Math.round(options.viewport.width)},${Math.round(options.viewport.height)}`,
|
|
863
853
|
...(angleArg ? [angleArg] : []),
|
|
864
|
-
...
|
|
854
|
+
...cloudGpuArgs,
|
|
865
855
|
];
|
|
866
856
|
// Dedicated browser process for clip capture. Not pooled because clip
|
|
867
857
|
// capture installs context-level init scripts (cursor overlay).
|
|
868
858
|
instance.browser = await chromium.launch({
|
|
869
|
-
|
|
870
|
-
// pixels to the display (headless mode skips that work entirely).
|
|
871
|
-
headless: useXvfb ? false : !options.headed,
|
|
859
|
+
headless: !options.headed,
|
|
872
860
|
args: clipArgs,
|
|
873
861
|
});
|
|
874
862
|
const contextOptions = {
|
|
@@ -1074,12 +1062,6 @@ export class Browser {
|
|
|
1074
1062
|
this.context = null;
|
|
1075
1063
|
this.page = null;
|
|
1076
1064
|
}
|
|
1077
|
-
// Tear down Xvfb only after the browser process is gone — Chromium needs
|
|
1078
|
-
// a live display until it exits or it'll spam X errors on shutdown.
|
|
1079
|
-
if (this.xvfb) {
|
|
1080
|
-
await this.xvfb.stop();
|
|
1081
|
-
this.xvfb = null;
|
|
1082
|
-
}
|
|
1083
1065
|
}
|
|
1084
1066
|
async navigateTo(url) {
|
|
1085
1067
|
const page = this.ensurePage();
|
|
@@ -5089,15 +5071,6 @@ export class Browser {
|
|
|
5089
5071
|
get browserContext() {
|
|
5090
5072
|
return this.ensureContext();
|
|
5091
5073
|
}
|
|
5092
|
-
/**
|
|
5093
|
-
* DISPLAY string of the Xvfb virtual screen, when this browser was launched
|
|
5094
|
-
* with one (cloud clip capture path). `null` for headless / Mac / Windows
|
|
5095
|
-
* launches that don't use Xvfb. Consumed by `FfmpegX11Recorder` so it knows
|
|
5096
|
-
* which display to grab.
|
|
5097
|
-
*/
|
|
5098
|
-
get xvfbDisplay() {
|
|
5099
|
-
return this.xvfb?.display ?? null;
|
|
5100
|
-
}
|
|
5101
5074
|
/**
|
|
5102
5075
|
* Observation pass for mock data generation.
|
|
5103
5076
|
* Navigates to the given URL, waits for network idle, and records all JSON API responses.
|
|
@@ -35,12 +35,13 @@ export class ClipCaptureLoop {
|
|
|
35
35
|
this.framesDir = opts.framesDir;
|
|
36
36
|
this.jpegQuality = opts.jpegQuality ?? 80;
|
|
37
37
|
// Linux default is 8 fps to stay safe on 2 vCPU CI runners. Cloud runners
|
|
38
|
-
// (AUTOKAP_CLOUD_RUNNER=1, set by the
|
|
39
|
-
// flag)
|
|
40
|
-
//
|
|
38
|
+
// (AUTOKAP_CLOUD_RUNNER=1, set by the Cloud Run image and the `--cloud`
|
|
39
|
+
// CLI flag) target 30 fps now that NVIDIA L4 GPU compositing is on (AUT-79
|
|
40
|
+
// migration). macOS/Windows also target 30 since their native compositors
|
|
41
|
+
// (Metal/D3D11) sustain it. Callers can still override via opts.targetFps.
|
|
41
42
|
const isCloudRunner = process.env.AUTOKAP_CLOUD_RUNNER === '1';
|
|
42
|
-
const linuxDefault = isCloudRunner ?
|
|
43
|
-
const platformDefault = process.platform === 'linux' ? linuxDefault :
|
|
43
|
+
const linuxDefault = isCloudRunner ? 30 : 8;
|
|
44
|
+
const platformDefault = process.platform === 'linux' ? linuxDefault : 30;
|
|
44
45
|
const targetFps = Math.max(1, Math.min(30, opts.targetFps ?? platformDefault));
|
|
45
46
|
this.targetFps = targetFps;
|
|
46
47
|
this.targetFrameIntervalMs = 1000 / targetFps;
|
|
@@ -12,7 +12,6 @@ import { resolveTarget } from './semantic-resolver.js';
|
|
|
12
12
|
import { logger } from './logger.js';
|
|
13
13
|
import { ClipCaptureLoop } from './clip-capture-loop.js';
|
|
14
14
|
import { assembleMp4FromFrames, getMediaDurationMs } from './clip-postprocess.js';
|
|
15
|
-
import { FfmpegX11Recorder } from './ffmpeg-x11-recorder.js';
|
|
16
15
|
export class WebPlaywrightLocal {
|
|
17
16
|
browser;
|
|
18
17
|
recordingDir;
|
|
@@ -353,52 +352,21 @@ export class WebPlaywrightLocal {
|
|
|
353
352
|
const cloudClipFps = isCloudRunner ? 30 : defaultFps;
|
|
354
353
|
const targetFps = options.captureFps
|
|
355
354
|
?? (options.mediaMode === 'video' ? 30 : cloudClipFps);
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
const useX11Capture = options.mediaMode === 'clip' && xvfbDisplay !== null;
|
|
366
|
-
let loop = null;
|
|
367
|
-
let x11Recorder = null;
|
|
368
|
-
const mp4Path = path.join(baseDir, `${options.mediaMode}.mp4`);
|
|
369
|
-
if (useX11Capture && xvfbDisplay) {
|
|
370
|
-
// Use the actual rendered surface size (CSS px × DPR) so x11grab and
|
|
371
|
-
// Chromium agree on dimensions. On cloud DPR is capped at 1 by
|
|
372
|
-
// cli-runner.ts, so this matches the viewport.
|
|
373
|
-
const surfaceW = Math.round(page.viewportSize()?.width ?? options.captureResolution?.width ?? 1440);
|
|
374
|
-
const surfaceH = Math.round(page.viewportSize()?.height ?? options.captureResolution?.height ?? 900);
|
|
375
|
-
x11Recorder = new FfmpegX11Recorder({
|
|
376
|
-
display: xvfbDisplay,
|
|
377
|
-
width: surfaceW,
|
|
378
|
-
height: surfaceH,
|
|
379
|
-
fps: targetFps,
|
|
380
|
-
outputPath: mp4Path,
|
|
381
|
-
});
|
|
382
|
-
await x11Recorder.start();
|
|
383
|
-
}
|
|
384
|
-
else {
|
|
385
|
-
loop = new ClipCaptureLoop({
|
|
386
|
-
page,
|
|
387
|
-
framesDir,
|
|
388
|
-
targetFps,
|
|
389
|
-
// Cloud runners have CPU headroom — drop the Linux 50 ms idle cushion
|
|
390
|
-
// (sized for tight CI runners) to let the loop stay close to its target.
|
|
391
|
-
minRestMs: process.platform === 'linux' && !isCloudRunner ? 50 : 16,
|
|
392
|
-
});
|
|
393
|
-
await loop.start();
|
|
394
|
-
}
|
|
355
|
+
const loop = new ClipCaptureLoop({
|
|
356
|
+
page,
|
|
357
|
+
framesDir,
|
|
358
|
+
targetFps,
|
|
359
|
+
// Cloud runners have CPU headroom — drop the Linux 50 ms idle cushion
|
|
360
|
+
// (sized for tight CI runners) to let the loop stay close to its target.
|
|
361
|
+
minRestMs: process.platform === 'linux' && !isCloudRunner ? 50 : 16,
|
|
362
|
+
});
|
|
363
|
+
await loop.start();
|
|
395
364
|
this.recording = {
|
|
396
365
|
mediaMode: options.mediaMode,
|
|
397
366
|
startedAt: Date.now(),
|
|
398
367
|
framesDir,
|
|
399
|
-
mp4Path,
|
|
368
|
+
mp4Path: path.join(baseDir, `${options.mediaMode}.mp4`),
|
|
400
369
|
loop,
|
|
401
|
-
x11Recorder,
|
|
402
370
|
finalized: false,
|
|
403
371
|
};
|
|
404
372
|
this.clipCursor = {
|
|
@@ -493,32 +461,6 @@ export class WebPlaywrightLocal {
|
|
|
493
461
|
this.recordingNavWatcher.detach();
|
|
494
462
|
this.recordingNavWatcher = null;
|
|
495
463
|
}
|
|
496
|
-
// Cloud Linux clip capture path: ffmpeg already wrote the final MP4
|
|
497
|
-
// directly from x11grab, no frame assembly needed. Stop ffmpeg cleanly
|
|
498
|
-
// (`q` on stdin → moov atom is finalized) and surface the output.
|
|
499
|
-
if (this.recording.x11Recorder) {
|
|
500
|
-
const x11Result = await this.recording.x11Recorder.stop();
|
|
501
|
-
// Tear down the browser context AFTER ffmpeg stops — closing it sooner
|
|
502
|
-
// would freeze Chromium's last frame mid-paint and the tail of the clip
|
|
503
|
-
// would show a partial render.
|
|
504
|
-
await this.browser.closeContext();
|
|
505
|
-
this.recording.finalized = true;
|
|
506
|
-
this.recording.sourcePath = x11Result.outputPath;
|
|
507
|
-
this.recording.sourceMimeType = 'video/mp4';
|
|
508
|
-
this.recording.trimStartMs = x11Result.trimStartMs;
|
|
509
|
-
this.recording.encodedDurationMs = await getMediaDurationMs(x11Result.outputPath);
|
|
510
|
-
this.clipCursor = null;
|
|
511
|
-
const buffer = await fs.readFile(x11Result.outputPath);
|
|
512
|
-
return {
|
|
513
|
-
buffer,
|
|
514
|
-
durationMs: this.recording.encodedDurationMs,
|
|
515
|
-
mimeType: 'video/mp4',
|
|
516
|
-
trimStartMs: x11Result.trimStartMs,
|
|
517
|
-
};
|
|
518
|
-
}
|
|
519
|
-
if (!this.recording.loop) {
|
|
520
|
-
throw new Error('recording loop was not initialized');
|
|
521
|
-
}
|
|
522
464
|
const result = await this.recording.loop.stop();
|
|
523
465
|
logger.info(`[capture] Clip frame capture: ${result.frameCount} frame(s), ` +
|
|
524
466
|
`${result.measuredFps.toFixed(1)} fps over ${(result.actualDurationMs / 1000).toFixed(2)}s ` +
|
package/package.json
CHANGED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ffmpeg x11grab recorder.
|
|
3
|
-
*
|
|
4
|
-
* Captures the Xvfb virtual display directly with `ffmpeg -f x11grab` at a
|
|
5
|
-
* fixed framerate, encoded straight to MP4 (libx264). Decouples the clip
|
|
6
|
-
* recording rate from Chromium's compositor speed — software-rasterized
|
|
7
|
-
* Linux compositors cap CDP `Page.captureScreenshot` at ~6 fps on heavy
|
|
8
|
-
* React UIs, which produces choppy clips. With x11grab we get a steady 30
|
|
9
|
-
* fps; if Chromium is slow to render, ffmpeg simply records the same frame
|
|
10
|
-
* twice (matches what a user would see on screen).
|
|
11
|
-
*
|
|
12
|
-
* Lifecycle: one recorder instance per BEGIN_CLIP/END_CLIP. Xvfb itself
|
|
13
|
-
* runs for the whole browser process lifetime.
|
|
14
|
-
*/
|
|
15
|
-
export interface FfmpegX11RecorderOptions {
|
|
16
|
-
/** DISPLAY string (e.g. `:99`). */
|
|
17
|
-
display: string;
|
|
18
|
-
/** Capture region width in pixels. Should match Xvfb screen width. */
|
|
19
|
-
width: number;
|
|
20
|
-
/** Capture region height in pixels. Should match Xvfb screen height. */
|
|
21
|
-
height: number;
|
|
22
|
-
/** Target framerate. */
|
|
23
|
-
fps: number;
|
|
24
|
-
/** Absolute path to the output .mp4 file. */
|
|
25
|
-
outputPath: string;
|
|
26
|
-
}
|
|
27
|
-
export interface FfmpegX11RecorderResult {
|
|
28
|
-
outputPath: string;
|
|
29
|
-
trimStartMs: number;
|
|
30
|
-
durationMs: number;
|
|
31
|
-
}
|
|
32
|
-
export declare class FfmpegX11Recorder {
|
|
33
|
-
private readonly opts;
|
|
34
|
-
private process;
|
|
35
|
-
private startedAt;
|
|
36
|
-
private firstFrameAt;
|
|
37
|
-
private lastReportedFrameLine;
|
|
38
|
-
private stderrTail;
|
|
39
|
-
constructor(opts: FfmpegX11RecorderOptions);
|
|
40
|
-
start(): Promise<void>;
|
|
41
|
-
stop(): Promise<FfmpegX11RecorderResult>;
|
|
42
|
-
}
|
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ffmpeg x11grab recorder.
|
|
3
|
-
*
|
|
4
|
-
* Captures the Xvfb virtual display directly with `ffmpeg -f x11grab` at a
|
|
5
|
-
* fixed framerate, encoded straight to MP4 (libx264). Decouples the clip
|
|
6
|
-
* recording rate from Chromium's compositor speed — software-rasterized
|
|
7
|
-
* Linux compositors cap CDP `Page.captureScreenshot` at ~6 fps on heavy
|
|
8
|
-
* React UIs, which produces choppy clips. With x11grab we get a steady 30
|
|
9
|
-
* fps; if Chromium is slow to render, ffmpeg simply records the same frame
|
|
10
|
-
* twice (matches what a user would see on screen).
|
|
11
|
-
*
|
|
12
|
-
* Lifecycle: one recorder instance per BEGIN_CLIP/END_CLIP. Xvfb itself
|
|
13
|
-
* runs for the whole browser process lifetime.
|
|
14
|
-
*/
|
|
15
|
-
import { spawn } from 'node:child_process';
|
|
16
|
-
import fs from 'node:fs/promises';
|
|
17
|
-
import { logger } from './logger.js';
|
|
18
|
-
const FFMPEG_FIRST_FRAME_TIMEOUT_MS = 5_000;
|
|
19
|
-
const FFMPEG_FIRST_FRAME_POLL_MS = 50;
|
|
20
|
-
const FFMPEG_GRACEFUL_STOP_MS = 3_000;
|
|
21
|
-
const FFMPEG_FORCE_STOP_MS = 2_000;
|
|
22
|
-
export class FfmpegX11Recorder {
|
|
23
|
-
opts;
|
|
24
|
-
process = null;
|
|
25
|
-
startedAt = 0;
|
|
26
|
-
firstFrameAt = 0;
|
|
27
|
-
lastReportedFrameLine = null;
|
|
28
|
-
stderrTail = [];
|
|
29
|
-
constructor(opts) {
|
|
30
|
-
this.opts = opts;
|
|
31
|
-
}
|
|
32
|
-
async start() {
|
|
33
|
-
if (this.process)
|
|
34
|
-
throw new Error('ffmpeg x11grab already running');
|
|
35
|
-
const { display, width, height, fps, outputPath } = this.opts;
|
|
36
|
-
// -draw_mouse 0: hide the X cursor — the cursor overlay script paints a
|
|
37
|
-
// fake cursor in the DOM that's already captured via the page.
|
|
38
|
-
// -preset ultrafast + -crf 20: encode in real time on 8 vCPU; CRF 20 is
|
|
39
|
-
// high quality (clip artifacts are visible at 28+).
|
|
40
|
-
// -pix_fmt yuv420p + +faststart: maximum playback compatibility (Safari,
|
|
41
|
-
// QuickTime, browser <video>).
|
|
42
|
-
const args = [
|
|
43
|
-
'-y',
|
|
44
|
-
'-loglevel', 'warning',
|
|
45
|
-
'-stats',
|
|
46
|
-
'-f', 'x11grab',
|
|
47
|
-
'-draw_mouse', '0',
|
|
48
|
-
'-framerate', String(fps),
|
|
49
|
-
'-video_size', `${width}x${height}`,
|
|
50
|
-
'-i', `${display}.0+0,0`,
|
|
51
|
-
'-c:v', 'libx264',
|
|
52
|
-
'-preset', 'ultrafast',
|
|
53
|
-
'-crf', '20',
|
|
54
|
-
'-pix_fmt', 'yuv420p',
|
|
55
|
-
'-movflags', '+faststart',
|
|
56
|
-
outputPath,
|
|
57
|
-
];
|
|
58
|
-
// Cloud-runner ships two ffmpeg binaries side-by-side: the static one at
|
|
59
|
-
// /usr/local/bin (first in PATH, has `-fps_mode` for VFR encoding but
|
|
60
|
-
// NO x11grab) and the system one at /usr/bin (Jammy 4.4.2, has x11grab).
|
|
61
|
-
// AUTOKAP_FFMPEG_X11_BIN is set in cloud-runner/Dockerfile to point at
|
|
62
|
-
// the system binary. Falls back to PATH ffmpeg for local testing.
|
|
63
|
-
const ffmpegBin = process.env.AUTOKAP_FFMPEG_X11_BIN || 'ffmpeg';
|
|
64
|
-
logger.info(`[ffmpeg-x11] starting capture on ${display} → ${outputPath} (${width}×${height} @ ${fps}fps, bin=${ffmpegBin})`);
|
|
65
|
-
this.startedAt = performance.now();
|
|
66
|
-
// stdin is `pipe` so we can send 'q' for graceful shutdown (writes the
|
|
67
|
-
// moov atom; SIGTERM produces an unplayable file).
|
|
68
|
-
this.process = spawn(ffmpegBin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
69
|
-
let exited = false;
|
|
70
|
-
let exitError = null;
|
|
71
|
-
this.process.stderr?.on('data', (chunk) => {
|
|
72
|
-
const text = String(chunk);
|
|
73
|
-
this.stderrTail.push(text);
|
|
74
|
-
// Cap retained stderr at ~10 KB to avoid unbounded memory growth on
|
|
75
|
-
// long recordings.
|
|
76
|
-
while (this.stderrTail.join('').length > 10_000) {
|
|
77
|
-
this.stderrTail.shift();
|
|
78
|
-
}
|
|
79
|
-
// ffmpeg's progress lines look like: `frame= 42 fps=30 q=23 size= ...`
|
|
80
|
-
// First non-zero `frame=` value signals capture is actually streaming.
|
|
81
|
-
if (this.firstFrameAt === 0 && /frame=\s*[1-9]/.test(text)) {
|
|
82
|
-
this.firstFrameAt = performance.now();
|
|
83
|
-
}
|
|
84
|
-
// Track the latest progress line for the final summary log.
|
|
85
|
-
const match = text.match(/frame=\s*\d+\s+fps=[\d.]+\s+[^\n]+/);
|
|
86
|
-
if (match)
|
|
87
|
-
this.lastReportedFrameLine = match[0].trim();
|
|
88
|
-
});
|
|
89
|
-
this.process.on('exit', (code, signal) => {
|
|
90
|
-
exited = true;
|
|
91
|
-
const wasGracefulStop = signal === 'SIGTERM' || signal === 'SIGINT' || code === 0;
|
|
92
|
-
if (!wasGracefulStop && code !== null) {
|
|
93
|
-
exitError = new Error(`ffmpeg exited unexpectedly: code=${code} signal=${signal}\n` +
|
|
94
|
-
`Last stderr:\n${this.stderrTail.join('').slice(-2_000)}`);
|
|
95
|
-
}
|
|
96
|
-
});
|
|
97
|
-
this.process.on('error', (err) => {
|
|
98
|
-
exitError = new Error(`ffmpeg spawn error: ${err.message}`);
|
|
99
|
-
});
|
|
100
|
-
// Wait for the first frame to confirm x11grab connected to Xvfb and
|
|
101
|
-
// encoding has begun. If ffmpeg dies before this, propagate the error.
|
|
102
|
-
const waitStartedAt = Date.now();
|
|
103
|
-
while (Date.now() - waitStartedAt < FFMPEG_FIRST_FRAME_TIMEOUT_MS) {
|
|
104
|
-
if (exited) {
|
|
105
|
-
throw exitError ?? new Error(`ffmpeg exited before first frame:\n${this.stderrTail.join('').slice(-2_000)}`);
|
|
106
|
-
}
|
|
107
|
-
if (this.firstFrameAt > 0) {
|
|
108
|
-
logger.info(`[ffmpeg-x11] capturing — first frame after ${Math.round(this.firstFrameAt - this.startedAt)}ms`);
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
await new Promise(r => setTimeout(r, FFMPEG_FIRST_FRAME_POLL_MS));
|
|
112
|
-
}
|
|
113
|
-
throw new Error(`ffmpeg did not produce first frame within ${FFMPEG_FIRST_FRAME_TIMEOUT_MS}ms\n` +
|
|
114
|
-
`Last stderr:\n${this.stderrTail.join('').slice(-2_000)}`);
|
|
115
|
-
}
|
|
116
|
-
async stop() {
|
|
117
|
-
if (!this.process)
|
|
118
|
-
throw new Error('ffmpeg x11grab not running');
|
|
119
|
-
const proc = this.process;
|
|
120
|
-
this.process = null;
|
|
121
|
-
// 'q' → ffmpeg writes the moov atom and exits cleanly. SIGTERM/SIGKILL
|
|
122
|
-
// would corrupt the MP4 (no moov, unplayable in browsers).
|
|
123
|
-
try {
|
|
124
|
-
proc.stdin?.write('q');
|
|
125
|
-
proc.stdin?.end();
|
|
126
|
-
}
|
|
127
|
-
catch { /* stdin may already be closed */ }
|
|
128
|
-
await new Promise(resolve => {
|
|
129
|
-
const sigtermTimer = setTimeout(() => {
|
|
130
|
-
logger.warn(`[ffmpeg-x11] did not exit within ${FFMPEG_GRACEFUL_STOP_MS}ms — sending SIGTERM`);
|
|
131
|
-
try {
|
|
132
|
-
proc.kill('SIGTERM');
|
|
133
|
-
}
|
|
134
|
-
catch { /* already dead */ }
|
|
135
|
-
const sigkillTimer = setTimeout(() => {
|
|
136
|
-
try {
|
|
137
|
-
proc.kill('SIGKILL');
|
|
138
|
-
}
|
|
139
|
-
catch { /* already dead */ }
|
|
140
|
-
resolve();
|
|
141
|
-
}, FFMPEG_FORCE_STOP_MS);
|
|
142
|
-
proc.on('exit', () => { clearTimeout(sigkillTimer); resolve(); });
|
|
143
|
-
}, FFMPEG_GRACEFUL_STOP_MS);
|
|
144
|
-
proc.on('exit', () => { clearTimeout(sigtermTimer); resolve(); });
|
|
145
|
-
});
|
|
146
|
-
const stoppedAt = performance.now();
|
|
147
|
-
const trimStartMs = this.firstFrameAt > 0
|
|
148
|
-
? Math.max(0, this.firstFrameAt - this.startedAt)
|
|
149
|
-
: 0;
|
|
150
|
-
const durationMs = stoppedAt - this.startedAt;
|
|
151
|
-
let fileSize = 0;
|
|
152
|
-
try {
|
|
153
|
-
const stat = await fs.stat(this.opts.outputPath);
|
|
154
|
-
fileSize = stat.size;
|
|
155
|
-
if (fileSize === 0) {
|
|
156
|
-
throw new Error(`ffmpeg produced 0-byte file at ${this.opts.outputPath}`);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
catch (err) {
|
|
160
|
-
throw new Error(`ffmpeg output unreadable: ${err.message}\n` +
|
|
161
|
-
`Last stderr:\n${this.stderrTail.join('').slice(-2_000)}`);
|
|
162
|
-
}
|
|
163
|
-
logger.info(`[ffmpeg-x11] finalized: ${(fileSize / 1024).toFixed(1)} KB, ` +
|
|
164
|
-
`${(durationMs / 1000).toFixed(2)}s wall, trim ${Math.round(trimStartMs)}ms` +
|
|
165
|
-
(this.lastReportedFrameLine ? ` (${this.lastReportedFrameLine})` : ''));
|
|
166
|
-
return {
|
|
167
|
-
outputPath: this.opts.outputPath,
|
|
168
|
-
trimStartMs,
|
|
169
|
-
durationMs,
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
//# sourceMappingURL=ffmpeg-x11-recorder.js.map
|
package/dist/xvfb-process.d.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Xvfb (X virtual framebuffer) process wrapper.
|
|
3
|
-
*
|
|
4
|
-
* Spins up a virtual X display that headed Chromium can render to. Used by
|
|
5
|
-
* cloud clip capture so the recording surface is reachable by ffmpeg via
|
|
6
|
-
* `x11grab` — bypassing the slow `Page.captureScreenshot` CDP path that
|
|
7
|
-
* software-rasterized Linux compositors cap at ~6 fps on heavy React UIs.
|
|
8
|
-
*
|
|
9
|
-
* Lifecycle: Xvfb runs for the entire browser process lifetime. ffmpeg
|
|
10
|
-
* recording starts/stops per BEGIN_CLIP/END_CLIP and grabs from the same
|
|
11
|
-
* display.
|
|
12
|
-
*/
|
|
13
|
-
export interface XvfbProcessOptions {
|
|
14
|
-
/** Display number (without leading colon). E.g. 99 → DISPLAY=:99. */
|
|
15
|
-
displayNumber: number;
|
|
16
|
-
/** Screen width in pixels. Should match the Chromium window size. */
|
|
17
|
-
width: number;
|
|
18
|
-
/** Screen height in pixels. Should match the Chromium window size. */
|
|
19
|
-
height: number;
|
|
20
|
-
}
|
|
21
|
-
export declare class XvfbProcess {
|
|
22
|
-
private readonly opts;
|
|
23
|
-
private process;
|
|
24
|
-
private exited;
|
|
25
|
-
constructor(opts: XvfbProcessOptions);
|
|
26
|
-
/** DISPLAY string suitable for `process.env.DISPLAY` (e.g. `:99`). */
|
|
27
|
-
get display(): string;
|
|
28
|
-
start(): Promise<void>;
|
|
29
|
-
stop(): Promise<void>;
|
|
30
|
-
}
|
package/dist/xvfb-process.js
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Xvfb (X virtual framebuffer) process wrapper.
|
|
3
|
-
*
|
|
4
|
-
* Spins up a virtual X display that headed Chromium can render to. Used by
|
|
5
|
-
* cloud clip capture so the recording surface is reachable by ffmpeg via
|
|
6
|
-
* `x11grab` — bypassing the slow `Page.captureScreenshot` CDP path that
|
|
7
|
-
* software-rasterized Linux compositors cap at ~6 fps on heavy React UIs.
|
|
8
|
-
*
|
|
9
|
-
* Lifecycle: Xvfb runs for the entire browser process lifetime. ffmpeg
|
|
10
|
-
* recording starts/stops per BEGIN_CLIP/END_CLIP and grabs from the same
|
|
11
|
-
* display.
|
|
12
|
-
*/
|
|
13
|
-
import { spawn } from 'node:child_process';
|
|
14
|
-
import fs from 'node:fs/promises';
|
|
15
|
-
import { logger } from './logger.js';
|
|
16
|
-
const XVFB_READY_TIMEOUT_MS = 5_000;
|
|
17
|
-
const XVFB_READY_POLL_MS = 50;
|
|
18
|
-
const XVFB_STOP_GRACE_MS = 2_000;
|
|
19
|
-
export class XvfbProcess {
|
|
20
|
-
opts;
|
|
21
|
-
process = null;
|
|
22
|
-
exited = false;
|
|
23
|
-
constructor(opts) {
|
|
24
|
-
this.opts = opts;
|
|
25
|
-
}
|
|
26
|
-
/** DISPLAY string suitable for `process.env.DISPLAY` (e.g. `:99`). */
|
|
27
|
-
get display() {
|
|
28
|
-
return `:${this.opts.displayNumber}`;
|
|
29
|
-
}
|
|
30
|
-
async start() {
|
|
31
|
-
if (this.process)
|
|
32
|
-
throw new Error('xvfb already started');
|
|
33
|
-
// -ac: no access control (any local client can connect)
|
|
34
|
-
// -screen 0 WxHxDEPTH: screen 0 sized to W×H at 24-bit color
|
|
35
|
-
// -nolisten tcp: only listen on the Unix socket (no network exposure)
|
|
36
|
-
// -dpi 96: pin DPI so CSS pixel sizing matches a typical monitor
|
|
37
|
-
const args = [
|
|
38
|
-
this.display,
|
|
39
|
-
'-ac',
|
|
40
|
-
'-screen', '0', `${this.opts.width}x${this.opts.height}x24`,
|
|
41
|
-
'-nolisten', 'tcp',
|
|
42
|
-
'-dpi', '96',
|
|
43
|
-
];
|
|
44
|
-
this.process = spawn('Xvfb', args, {
|
|
45
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
46
|
-
detached: false,
|
|
47
|
-
});
|
|
48
|
-
this.process.stderr?.on('data', (chunk) => {
|
|
49
|
-
const text = String(chunk).trim();
|
|
50
|
-
if (text)
|
|
51
|
-
logger.warn(`[xvfb] ${text}`);
|
|
52
|
-
});
|
|
53
|
-
this.process.on('exit', (code, signal) => {
|
|
54
|
-
this.exited = true;
|
|
55
|
-
if (code !== 0 && code !== null) {
|
|
56
|
-
logger.error(`[xvfb] exited unexpectedly: code=${code} signal=${signal}`);
|
|
57
|
-
}
|
|
58
|
-
});
|
|
59
|
-
this.process.on('error', (err) => {
|
|
60
|
-
logger.error(`[xvfb] spawn error: ${err.message}`);
|
|
61
|
-
});
|
|
62
|
-
// Xvfb signals readiness by creating its Unix socket. Polling that socket
|
|
63
|
-
// is more reliable than `setTimeout(500)` because cold container starts
|
|
64
|
-
// are unpredictable.
|
|
65
|
-
const socketPath = `/tmp/.X11-unix/X${this.opts.displayNumber}`;
|
|
66
|
-
const startedAt = Date.now();
|
|
67
|
-
while (Date.now() - startedAt < XVFB_READY_TIMEOUT_MS) {
|
|
68
|
-
if (this.exited) {
|
|
69
|
-
throw new Error('Xvfb exited before becoming ready — check stderr above');
|
|
70
|
-
}
|
|
71
|
-
try {
|
|
72
|
-
await fs.access(socketPath);
|
|
73
|
-
logger.info(`[xvfb] ready on display ${this.display} (${this.opts.width}×${this.opts.height}) ` +
|
|
74
|
-
`after ${Date.now() - startedAt}ms`);
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
catch {
|
|
78
|
-
// socket not yet created — keep polling
|
|
79
|
-
}
|
|
80
|
-
await new Promise(r => setTimeout(r, XVFB_READY_POLL_MS));
|
|
81
|
-
}
|
|
82
|
-
throw new Error(`Xvfb did not become ready within ${XVFB_READY_TIMEOUT_MS}ms`);
|
|
83
|
-
}
|
|
84
|
-
async stop() {
|
|
85
|
-
if (!this.process)
|
|
86
|
-
return;
|
|
87
|
-
const proc = this.process;
|
|
88
|
-
this.process = null;
|
|
89
|
-
proc.kill('SIGTERM');
|
|
90
|
-
await new Promise(resolve => {
|
|
91
|
-
const timer = setTimeout(() => {
|
|
92
|
-
try {
|
|
93
|
-
proc.kill('SIGKILL');
|
|
94
|
-
}
|
|
95
|
-
catch { /* already dead */ }
|
|
96
|
-
resolve();
|
|
97
|
-
}, XVFB_STOP_GRACE_MS);
|
|
98
|
-
proc.on('exit', () => { clearTimeout(timer); resolve(); });
|
|
99
|
-
});
|
|
100
|
-
logger.info(`[xvfb] stopped (display ${this.display})`);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
//# sourceMappingURL=xvfb-process.js.map
|