feat(web-test): add video recording via CDP screencast + ffmpeg

New functions: startRecording, stopRecording, isRecording, showCaption, hideCaption.
Recording guide in recording.md with setup, API, examples, troubleshooting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-02-28 20:49:47 +03:00
parent e6da514b67
commit 43a2691d6a
3 changed files with 343 additions and 0 deletions
+6
View File
@@ -282,6 +282,12 @@ Clear filters. Without arguments clears all, with `{ field }` clears specific ba
#### `screenshot()` → PNG Buffer
#### `wait(seconds)` → form state
#### `getPage()` → Playwright Page (raw, for advanced scripting)
#### `startRecording(path, opts?)` / `stopRecording()` → MP4 video recording
#### `showCaption(text, opts?)` / `hideCaption()` → text overlay on page
#### `isRecording()` → boolean
See [recording.md](recording.md) for setup (ffmpeg), API details, and examples.
If `.v8-project.json` has `ffmpegPath`, pass it to `startRecording({ ffmpegPath })`.
## Common patterns
+123
View File
@@ -0,0 +1,123 @@
# Video Recording
Record browser automation sessions as MP4 video files. Uses CDP `Page.startScreencast` to capture JPEG frames and pipes them to ffmpeg for encoding.
## Prerequisites
**ffmpeg** must be installed. The binary is resolved in this order:
1. `opts.ffmpegPath` parameter in `startRecording()`
2. `FFMPEG_PATH` environment variable
3. `ffmpeg` in system PATH
4. `tools/ffmpeg/bin/ffmpeg.exe` relative to project root
### Install ffmpeg on Windows
Download from https://ffmpeg.org/download.html (Windows builds by gyan.dev or BtbN).
Extract and either:
- Add `bin/` to system PATH
- Set environment variable: `$env:FFMPEG_PATH = "C:\tools\ffmpeg\bin\ffmpeg.exe"`
- Place in project: `tools/ffmpeg/bin/ffmpeg.exe`
### Shared path via .v8-project.json
To avoid installing per-project, add `ffmpegPath` to `.v8-project.json`:
```json
{
"v8path": "...",
"ffmpegPath": "C:\\tools\\ffmpeg\\bin\\ffmpeg.exe",
"databases": [...]
}
```
When calling `startRecording()`, read this field and pass it:
```js
await startRecording('output.mp4', { ffmpegPath: 'C:\\tools\\ffmpeg\\bin\\ffmpeg.exe' });
```
## API
### `startRecording(outputPath, opts?)`
Start recording the browser viewport to an MP4 file.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `outputPath` | string | required | Output .mp4 file path |
| `opts.fps` | number | 25 | Target framerate |
| `opts.quality` | number | 80 | JPEG quality (1-100) |
| `opts.ffmpegPath` | string | auto | Explicit path to ffmpeg binary |
- Output directory is created automatically if it doesn't exist
- Throws if already recording or browser not connected
- Recording auto-stops when `disconnect()` is called
### `stopRecording()` → `{ file, duration, size }`
Stop recording and finalize the MP4 file.
| Return field | Type | Description |
|-------------|------|-------------|
| `file` | string | Absolute path to the MP4 file |
| `duration` | number | Recording duration in seconds |
| `size` | number | File size in bytes |
### `isRecording()` → boolean
Check if recording is active.
### `showCaption(text, opts?)`
Display a text overlay on the page (visible in recording). Calling again updates the text.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `text` | string | required | Caption text |
| `opts.position` | `'top'` \| `'bottom'` | `'bottom'` | Vertical position |
| `opts.fontSize` | number | 24 | Font size in px |
| `opts.background` | string | `'rgba(0,0,0,0.7)'` | Background color |
| `opts.color` | string | `'#fff'` | Text color |
The overlay uses `pointer-events: none` — does not interfere with clicking.
### `hideCaption()`
Remove the caption overlay.
## Example: Record a workflow with captions
```js
await startRecording('recordings/create-order.mp4');
await showCaption('Step 1: Navigate to Sales');
await navigateSection('Продажи');
await wait(1);
await showCaption('Step 2: Open Customer Orders');
await openCommand('Заказы клиентов');
await wait(1);
await showCaption('Step 3: Create new order');
await clickElement('Создать');
await wait(2);
await showCaption('Step 4: Fill header fields');
await fillFields({ 'Организация': 'Конфетпром', 'Контрагент': 'Альфа' });
await wait(2);
await hideCaption();
const result = await stopRecording();
console.log(`Recorded ${result.duration}s, ${(result.size / 1024 / 1024).toFixed(1)} MB`);
```
## Troubleshooting
| Problem | Solution |
|---------|----------|
| "ffmpeg not found" | Install ffmpeg and ensure it's discoverable (see Prerequisites) |
| Recording file is 0 bytes | Check that output path is writable. ffmpeg may have crashed |
| Video is choppy | Add `wait()` between steps. Reduce `quality` for faster capture |
| "Already recording" | Call `stopRecording()` before starting a new recording |
| Recording stops on disconnect | Expected — auto-stop prevents orphaned ffmpeg processes |
+214
View File
@@ -7,6 +7,10 @@
* Handles connection, navigation, waiting, screenshots.
*/
import { chromium } from 'playwright';
import { spawn, execFileSync } from 'child_process';
import { statSync, mkdirSync, existsSync as fsExistsSync } from 'fs';
import { dirname, resolve as pathResolve } from 'path';
import { fileURLToPath } from 'url';
import {
readSectionsScript, readTabsScript, readCommandsScript,
readFormScript, navigateSectionScript, openCommandScript,
@@ -20,6 +24,7 @@ let browser = null;
let page = null;
let sessionPrefix = null; // e.g. "http://localhost:8081/bpdemo/ru_RU"
let seanceId = null;
let recorder = null; // { cdp, ffmpeg, startTime, outputPath }
const LOAD_TIMEOUT = 60000;
const INIT_TIMEOUT = 60000;
@@ -79,6 +84,11 @@ export async function connect(url) {
* Sends POST /e1cib/logout to release the license before closing.
*/
export async function disconnect() {
// Auto-stop recording if active (prevents orphaned ffmpeg)
if (recorder) {
try { await stopRecording(); } catch {}
}
if (browser) {
// Graceful logout — release the 1C license
if (page && !page.isClosed() && seanceId && sessionPrefix) {
@@ -2354,6 +2364,210 @@ export async function wait(seconds) {
return await getFormState();
}
// ============================================================
// Video recording — CDP screencast + ffmpeg
// ============================================================
/** Check if video recording is active. */
export function isRecording() {
return recorder !== null;
}
/**
* Start video recording via CDP screencast + ffmpeg.
* Frames are captured as JPEG and piped to ffmpeg for MP4 encoding.
* @param {string} outputPath — output .mp4 file path
* @param {object} [opts]
* @param {number} [opts.fps=25] — target framerate
* @param {number} [opts.quality=80] — JPEG quality (1-100)
* @param {string} [opts.ffmpegPath] — explicit path to ffmpeg binary
*/
export async function startRecording(outputPath, opts = {}) {
ensureConnected();
if (recorder) throw new Error('Already recording. Call stopRecording() first.');
const fps = opts.fps || 25;
const quality = opts.quality || 80;
const ffmpegPath = resolveFfmpeg(opts.ffmpegPath);
// Ensure output directory exists
const resolvedPath = pathResolve(outputPath);
mkdirSync(dirname(resolvedPath), { recursive: true });
// Create CDP session for screencast
const cdp = await page.context().newCDPSession(page);
// Spawn ffmpeg process
const ffmpeg = spawn(ffmpegPath, [
'-y', // overwrite output
'-f', 'image2pipe', // input: piped images
'-framerate', String(fps), // input framerate
'-i', '-', // read from stdin
'-c:v', 'libx264', // H.264 codec
'-preset', 'ultrafast', // fast encoding
'-pix_fmt', 'yuv420p', // broad compatibility
'-movflags', '+faststart', // web-friendly MP4
resolvedPath
], { stdio: ['pipe', 'ignore', 'pipe'] });
let ffmpegError = '';
ffmpeg.stderr.on('data', d => { ffmpegError += d.toString(); });
ffmpeg.on('error', err => { ffmpegError += err.message; });
// Listen for screencast frames and pipe to ffmpeg
cdp.on('Page.screencastFrame', async ({ data, sessionId }) => {
const buf = Buffer.from(data, 'base64');
if (!ffmpeg.stdin.destroyed) {
ffmpeg.stdin.write(buf);
}
try { await cdp.send('Page.screencastFrameAck', { sessionId }); } catch {}
});
// Start the screencast
await cdp.send('Page.startScreencast', {
format: 'jpeg',
quality,
everyNthFrame: 1
});
recorder = { cdp, ffmpeg, startTime: Date.now(), outputPath: resolvedPath, ffmpegError: '' };
// Redirect stderr accumulation to the recorder object
ffmpeg.stderr.removeAllListeners('data');
ffmpeg.stderr.on('data', d => { recorder.ffmpegError += d.toString(); });
}
/**
* Stop video recording. Finalizes the MP4 file.
* @returns {{ file: string, duration: number, size: number }}
*/
export async function stopRecording() {
if (!recorder) throw new Error('Not recording. Call startRecording() first.');
const { cdp, ffmpeg, startTime, outputPath } = recorder;
// Stop CDP screencast
try { await cdp.send('Page.stopScreencast'); } catch {}
try { await cdp.detach(); } catch {}
// Close ffmpeg stdin and wait for encoding to finish
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
ffmpeg.kill('SIGKILL');
reject(new Error('ffmpeg timed out after 30s'));
}, 30000);
ffmpeg.on('close', (code) => {
clearTimeout(timeout);
if (code === 0) resolve();
else reject(new Error(`ffmpeg exited with code ${code}: ${recorder?.ffmpegError || ''}`));
});
ffmpeg.on('error', (err) => {
clearTimeout(timeout);
reject(err);
});
ffmpeg.stdin.end();
});
const duration = (Date.now() - startTime) / 1000;
const stats = statSync(outputPath);
recorder = null;
return {
file: outputPath,
duration: Math.round(duration * 10) / 10,
size: stats.size
};
}
/**
* Show a text caption overlay on the page (visible in recording).
* Calling again updates the text without creating a new element.
* @param {string} text — caption text
* @param {object} [opts]
* @param {'top'|'bottom'} [opts.position='bottom'] — vertical position
* @param {number} [opts.fontSize=24] — font size in pixels
* @param {string} [opts.background='rgba(0,0,0,0.7)'] — background color
* @param {string} [opts.color='#fff'] — text color
*/
export async function showCaption(text, opts = {}) {
ensureConnected();
const position = opts.position || 'bottom';
const fontSize = opts.fontSize || 24;
const bg = opts.background || 'rgba(0,0,0,0.7)';
const color = opts.color || '#fff';
await page.evaluate(({ text, position, fontSize, bg, color }) => {
let el = document.getElementById('__web_test_caption');
if (!el) {
el = document.createElement('div');
el.id = '__web_test_caption';
el.style.cssText = `
position: fixed; left: 0; right: 0; z-index: 99999;
text-align: center; padding: 12px 24px;
font-family: Arial, sans-serif; pointer-events: none;
`;
document.body.appendChild(el);
}
el.style[position === 'top' ? 'top' : 'bottom'] = '20px';
el.style[position === 'top' ? 'bottom' : 'top'] = 'auto';
el.style.fontSize = fontSize + 'px';
el.style.background = bg;
el.style.color = color;
el.textContent = text;
}, { text, position, fontSize, bg, color });
}
/** Remove the caption overlay from the page. */
export async function hideCaption() {
ensureConnected();
await page.evaluate(() => {
const el = document.getElementById('__web_test_caption');
if (el) el.remove();
});
}
// ============================================================
// Private helpers
// ============================================================
/** Resolve ffmpeg binary path. */
function resolveFfmpeg(explicit) {
// 1. Explicit path
if (explicit) {
try { execFileSync(explicit, ['-version'], { stdio: 'ignore', timeout: 5000 }); return explicit; }
catch { throw new Error(`ffmpeg not found at: ${explicit}`); }
}
// 2. FFMPEG_PATH env var
const envPath = process.env.FFMPEG_PATH;
if (envPath) {
try { execFileSync(envPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return envPath; }
catch { /* fall through */ }
}
// 3. System PATH
try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore', timeout: 5000 }); return 'ffmpeg'; }
catch { /* fall through */ }
// 4. tools/ffmpeg/bin/ffmpeg.exe relative to project root
const __filename = fileURLToPath(import.meta.url);
const projectRoot = pathResolve(dirname(__filename), '..', '..', '..', '..');
const localPath = pathResolve(projectRoot, 'tools', 'ffmpeg', 'bin', 'ffmpeg.exe');
if (fsExistsSync(localPath)) {
try { execFileSync(localPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return localPath; }
catch { /* fall through */ }
}
// 5. Error with instructions
throw new Error(
'ffmpeg not found. Install it:\n' +
' - Download from https://ffmpeg.org/download.html\n' +
' - Add to PATH, or set FFMPEG_PATH env var, or place in tools/ffmpeg/bin/\n' +
' - Or pass ffmpegPath option to startRecording()'
);
}
function ensureConnected() {
if (!isConnected()) {
throw new Error('Browser not connected. Call web_connect first.');