mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-09-17 23:35:22 +03:00
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:
co-authored by
Claude Opus 4.6
parent
e6da514b67
commit
43a2691d6a
@@ -282,6 +282,12 @@ Clear filters. Without arguments clears all, with `{ field }` clears specific ba
|
|||||||
#### `screenshot()` → PNG Buffer
|
#### `screenshot()` → PNG Buffer
|
||||||
#### `wait(seconds)` → form state
|
#### `wait(seconds)` → form state
|
||||||
#### `getPage()` → Playwright Page (raw, for advanced scripting)
|
#### `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
|
## Common patterns
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
@@ -7,6 +7,10 @@
|
|||||||
* Handles connection, navigation, waiting, screenshots.
|
* Handles connection, navigation, waiting, screenshots.
|
||||||
*/
|
*/
|
||||||
import { chromium } from 'playwright';
|
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 {
|
import {
|
||||||
readSectionsScript, readTabsScript, readCommandsScript,
|
readSectionsScript, readTabsScript, readCommandsScript,
|
||||||
readFormScript, navigateSectionScript, openCommandScript,
|
readFormScript, navigateSectionScript, openCommandScript,
|
||||||
@@ -20,6 +24,7 @@ let browser = null;
|
|||||||
let page = null;
|
let page = null;
|
||||||
let sessionPrefix = null; // e.g. "http://localhost:8081/bpdemo/ru_RU"
|
let sessionPrefix = null; // e.g. "http://localhost:8081/bpdemo/ru_RU"
|
||||||
let seanceId = null;
|
let seanceId = null;
|
||||||
|
let recorder = null; // { cdp, ffmpeg, startTime, outputPath }
|
||||||
|
|
||||||
const LOAD_TIMEOUT = 60000;
|
const LOAD_TIMEOUT = 60000;
|
||||||
const INIT_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.
|
* Sends POST /e1cib/logout to release the license before closing.
|
||||||
*/
|
*/
|
||||||
export async function disconnect() {
|
export async function disconnect() {
|
||||||
|
// Auto-stop recording if active (prevents orphaned ffmpeg)
|
||||||
|
if (recorder) {
|
||||||
|
try { await stopRecording(); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
if (browser) {
|
if (browser) {
|
||||||
// Graceful logout — release the 1C license
|
// Graceful logout — release the 1C license
|
||||||
if (page && !page.isClosed() && seanceId && sessionPrefix) {
|
if (page && !page.isClosed() && seanceId && sessionPrefix) {
|
||||||
@@ -2354,6 +2364,210 @@ export async function wait(seconds) {
|
|||||||
return await getFormState();
|
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() {
|
function ensureConnected() {
|
||||||
if (!isConnected()) {
|
if (!isConnected()) {
|
||||||
throw new Error('Browser not connected. Call web_connect first.');
|
throw new Error('Browser not connected. Call web_connect first.');
|
||||||
|
|||||||
Reference in New Issue
Block a user