mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-06-12 00:44:57 +03:00
Merge branch 'dev-web': TTS narration, ElevenLabs provider, video recording guide, v8-project reference
This commit is contained in:
@@ -288,9 +288,12 @@ Clear filters. Without arguments clears all, with `{ field }` clears specific ba
|
||||
#### `isRecording()` → boolean
|
||||
#### `setHighlight(on)` / `isHighlightMode()` → auto-highlight mode for video
|
||||
#### `highlight(text)` / `unhighlight()` → manual element highlighting
|
||||
#### `addNarration(videoPath, opts?)` → narrated MP4 with TTS voiceover
|
||||
#### `getCaptions()` → caption timestamps from last recording
|
||||
|
||||
See [recording.md](recording.md) for setup (ffmpeg), highlight mode, API details, and examples.
|
||||
See [recording.md](recording.md) for setup (ffmpeg), highlight mode, TTS narration, API details, and examples.
|
||||
If `.v8-project.json` has `ffmpegPath`, pass it to `startRecording({ ffmpegPath })`.
|
||||
If `.v8-project.json` has `tts` config, pass it to `addNarration()` (provider, voice, apiKey).
|
||||
|
||||
## Common patterns
|
||||
|
||||
|
||||
@@ -62,15 +62,16 @@ Start recording the browser viewport to an MP4 file.
|
||||
- Throws if already recording or browser not connected
|
||||
- Recording auto-stops when `disconnect()` is called
|
||||
|
||||
### `stopRecording()` → `{ file, duration, size }`
|
||||
### `stopRecording()` → `{ file, duration, size, captions }`
|
||||
|
||||
Stop recording and finalize the MP4 file.
|
||||
Stop recording and finalize the MP4 file. Saves `.captions.json` next to the video if captions were collected.
|
||||
|
||||
| Return field | Type | Description |
|
||||
|-------------|------|-------------|
|
||||
| `file` | string | Absolute path to the MP4 file |
|
||||
| `duration` | number | Recording duration in seconds |
|
||||
| `size` | number | File size in bytes |
|
||||
| `captions` | number | Number of captions collected during recording |
|
||||
|
||||
### `isRecording()` → boolean
|
||||
|
||||
@@ -184,6 +185,109 @@ console.log(`Recorded ${result.duration}s, ${(result.size / 1024 / 1024).toFixed
|
||||
|
||||
**Highlight timing**: `setHighlight(true)` enables auto-mode — each action function highlights the target for 500ms, then removes the highlight before performing the action. No manual `highlight()`/`unhighlight()` calls needed. Enable after title slide, disable before `stopRecording()`.
|
||||
|
||||
## TTS Narration
|
||||
|
||||
Add voiceover to recorded videos. Captions shown via `showCaption()` are automatically collected during recording and can be synthesized into speech.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **ffmpeg** — same as for video recording (ffprobe must be next to ffmpeg)
|
||||
- **node-edge-tts** — `npm install --prefix tools/tts node-edge-tts` (for Edge TTS provider, free, no API key). Also works if installed globally or at project level — the resolver tries multiple locations automatically
|
||||
|
||||
### Configuration in `.v8-project.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"tts": {
|
||||
"provider": "edge",
|
||||
"voice": "ru-RU-DmitryNeural"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For OpenAI-compatible provider:
|
||||
```json
|
||||
{
|
||||
"tts": {
|
||||
"provider": "openai",
|
||||
"apiKey": "sk-...",
|
||||
"voice": "alloy"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For ElevenLabs:
|
||||
```json
|
||||
{
|
||||
"tts": {
|
||||
"provider": "elevenlabs",
|
||||
"apiKey": "sk_...",
|
||||
"voice": "JBFqnCBsd6RMkjVDRZzb"
|
||||
}
|
||||
}
|
||||
```
|
||||
Note: `voice` is the ElevenLabs voice ID (not a name). Default model: `eleven_multilingual_v2` (supports Russian and other languages).
|
||||
|
||||
### `showCaption()` speech parameter
|
||||
|
||||
The `speech` option controls what text is narrated (vs displayed):
|
||||
|
||||
```js
|
||||
await showCaption('Дт 60.02 — Кт 51'); // narrates the displayed text
|
||||
await showCaption('Дт 60.02 — Кт 51', { speech: 'Проводка: дебет шестьдесят ноль два, кредит пятьдесят один' }); // custom narration
|
||||
await showCaption('Техническая информация', { speech: false }); // no narration for this caption
|
||||
```
|
||||
|
||||
### `addNarration(videoPath, opts?)`
|
||||
|
||||
Generate TTS and merge audio with video. Call after `stopRecording()`.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `videoPath` | `string` | Path to the recorded MP4 file |
|
||||
| `opts.captions` | `Array` | Explicit captions (default: from last recording or `.captions.json`) |
|
||||
| `opts.provider` | `string` | `'edge'` (default), `'openai'`, or `'elevenlabs'` |
|
||||
| `opts.voice` | `string` | Voice name (provider-specific) |
|
||||
| `opts.apiKey` | `string` | API key (for openai) |
|
||||
| `opts.apiUrl` | `string` | Endpoint (for openai) |
|
||||
| `opts.model` | `string` | Model (for openai, default: `tts-1`) |
|
||||
| `opts.ffmpegPath` | `string` | Path to ffmpeg binary |
|
||||
| `opts.outputPath` | `string` | Output file (default: `video-narrated.mp4`) |
|
||||
|
||||
**Returns:** `{ file, duration, size, captions, warnings? }`
|
||||
|
||||
### `getCaptions()`
|
||||
|
||||
Returns captions from the current or last recording: `Array<{ text, speech, time }>`.
|
||||
|
||||
### Example: Record and narrate
|
||||
|
||||
```js
|
||||
await startRecording('recordings/demo.mp4');
|
||||
await showCaption('Переходим в раздел Банк и касса');
|
||||
await wait(1.5);
|
||||
await navigateSection('Банк и касса');
|
||||
await showCaption('Открываем банковские выписки');
|
||||
await wait(1.5);
|
||||
await openCommand('Банковские выписки');
|
||||
await hideCaption();
|
||||
const video = await stopRecording();
|
||||
|
||||
// Add narration (reads tts config from .v8-project.json)
|
||||
const narrated = await addNarration(video.file, { voice: 'ru-RU-DmitryNeural' });
|
||||
console.log(`Narrated: ${narrated.file}, ${narrated.duration}s`);
|
||||
```
|
||||
|
||||
### Re-narration
|
||||
|
||||
After recording, a `.captions.json` file is saved next to the video. You can re-narrate with a different voice without re-recording:
|
||||
|
||||
```js
|
||||
const result = await addNarration('recordings/demo.mp4', { voice: 'ru-RU-SvetlanaNeural' });
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
@@ -193,3 +297,6 @@ console.log(`Recorded ${result.duration}s, ${(result.size / 1024 / 1024).toFixed
|
||||
| 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 |
|
||||
| "No captions available" | Use `showCaption()` during recording, or pass `opts.captions` |
|
||||
| TTS timeout | Check internet connection. Edge TTS requires network access |
|
||||
| Audio cuts off between captions | TTS is auto-trimmed to fit the timeline. Add longer `wait()` pauses |
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
*/
|
||||
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 { statSync, mkdirSync, existsSync as fsExistsSync, writeFileSync, readFileSync, rmSync } from 'fs';
|
||||
import { dirname, resolve as pathResolve, join as pathJoin, basename, extname } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { fileURLToPath, pathToFileURL } from 'url';
|
||||
import {
|
||||
readSectionsScript, readTabsScript, readCommandsScript,
|
||||
readFormScript, navigateSectionScript, openCommandScript,
|
||||
@@ -24,7 +25,9 @@ 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 }
|
||||
let recorder = null; // { cdp, ffmpeg, startTime, outputPath, ffmpegError, captions }
|
||||
let lastCaptions = []; // captions from the last completed recording (for addNarration)
|
||||
let lastRecordingDuration = null; // wall-clock duration of the last recording (seconds)
|
||||
let highlightMode = false;
|
||||
|
||||
const LOAD_TIMEOUT = 60000;
|
||||
@@ -2436,6 +2439,8 @@ export function isRecording() {
|
||||
export async function startRecording(outputPath, opts = {}) {
|
||||
ensureConnected();
|
||||
if (recorder) throw new Error('Already recording. Call stopRecording() first.');
|
||||
lastCaptions = [];
|
||||
lastRecordingDuration = null;
|
||||
|
||||
const fps = opts.fps || 25;
|
||||
const quality = opts.quality || 80;
|
||||
@@ -2480,15 +2485,20 @@ export async function startRecording(outputPath, opts = {}) {
|
||||
const now = Date.now();
|
||||
|
||||
if (!ffmpeg.stdin.destroyed) {
|
||||
let framesWritten = 0;
|
||||
if (lastFrameTime && lastFrameBuf) {
|
||||
// Fill the gap with duplicates of the previous frame
|
||||
const gap = now - lastFrameTime;
|
||||
const dupes = Math.round(gap / frameDuration) - 1;
|
||||
for (let i = 0; i < dupes && i < fps * 2; i++) {
|
||||
ffmpeg.stdin.write(lastFrameBuf);
|
||||
framesWritten++;
|
||||
}
|
||||
}
|
||||
ffmpeg.stdin.write(buf);
|
||||
framesWritten++;
|
||||
// Track actual video timeline position (accounts for frame duplication)
|
||||
if (recorder) recorder.videoTimeMs += framesWritten * frameDuration;
|
||||
}
|
||||
|
||||
lastFrameTime = now;
|
||||
@@ -2503,7 +2513,7 @@ export async function startRecording(outputPath, opts = {}) {
|
||||
everyNthFrame: 1
|
||||
});
|
||||
|
||||
recorder = { cdp, ffmpeg, startTime: Date.now(), outputPath: resolvedPath, ffmpegError: '' };
|
||||
recorder = { cdp, ffmpeg, startTime: Date.now(), outputPath: resolvedPath, ffmpegError: '', captions: [], videoTimeMs: 0 };
|
||||
// Redirect stderr accumulation to the recorder object
|
||||
ffmpeg.stderr.removeAllListeners('data');
|
||||
ffmpeg.stderr.on('data', d => { recorder.ffmpegError += d.toString(); });
|
||||
@@ -2544,12 +2554,23 @@ export async function stopRecording() {
|
||||
|
||||
const duration = (Date.now() - startTime) / 1000;
|
||||
const stats = statSync(outputPath);
|
||||
|
||||
// Preserve captions for addNarration()
|
||||
lastCaptions = recorder.captions || [];
|
||||
lastRecordingDuration = duration;
|
||||
if (lastCaptions.length) {
|
||||
const captionsPath = outputPath.replace(/\.[^.]+$/, '.captions.json');
|
||||
const captionsData = { recordingDuration: duration, videoTimestamps: true, captions: lastCaptions };
|
||||
writeFileSync(captionsPath, JSON.stringify(captionsData, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
recorder = null;
|
||||
|
||||
return {
|
||||
file: outputPath,
|
||||
duration: Math.round(duration * 10) / 10,
|
||||
size: stats.size
|
||||
size: stats.size,
|
||||
captions: lastCaptions.length
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2562,9 +2583,18 @@ export async function stopRecording() {
|
||||
* @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
|
||||
* @param {string|false} [opts.speech] — TTS narration text. Omit to use displayed text,
|
||||
* pass a string for custom narration, or false to skip narration for this caption.
|
||||
*/
|
||||
export async function showCaption(text, opts = {}) {
|
||||
ensureConnected();
|
||||
|
||||
// Collect caption for TTS narration if recording
|
||||
if (recorder && text.trim() && opts.speech !== false) {
|
||||
const speech = typeof opts.speech === 'string' ? opts.speech : text;
|
||||
// Use video timeline position (accounts for frame duplication) instead of wall-clock
|
||||
recorder.captions.push({ text, speech, time: Math.round(recorder.videoTimeMs) });
|
||||
}
|
||||
const position = opts.position || 'bottom';
|
||||
const fontSize = opts.fontSize || 24;
|
||||
const bg = opts.background || 'rgba(0,0,0,0.7)';
|
||||
@@ -2600,6 +2630,184 @@ export async function hideCaption() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get captions collected during the current or last recording.
|
||||
* @returns {Array<{text: string, speech: string, time: number}>}
|
||||
*/
|
||||
export function getCaptions() {
|
||||
if (recorder) return [...recorder.captions];
|
||||
return [...lastCaptions];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add TTS narration to a recorded video.
|
||||
* Generates speech from captions and merges audio with the video.
|
||||
* @param {string} videoPath — path to the recorded MP4 file
|
||||
* @param {object} [opts]
|
||||
* @param {Array<{text: string, speech: string, time: number}>} [opts.captions] — explicit captions (default: from last recording or .captions.json)
|
||||
* @param {string} [opts.provider='edge'] — TTS provider: 'edge' or 'openai'
|
||||
* @param {string} [opts.voice] — voice name (provider-specific)
|
||||
* @param {string} [opts.apiKey] — API key (for openai provider)
|
||||
* @param {string} [opts.apiUrl] — API endpoint (for openai provider)
|
||||
* @param {string} [opts.model] — model name (for openai provider, default: 'tts-1')
|
||||
* @param {string} [opts.ffmpegPath] — path to ffmpeg binary
|
||||
* @param {string} [opts.outputPath] — output file path (default: video-narrated.mp4)
|
||||
* @returns {{ file: string, duration: number, size: number, captions: number, warnings?: string[] }}
|
||||
*/
|
||||
export async function addNarration(videoPath, opts = {}) {
|
||||
const ffmpegPath = resolveFfmpeg(opts.ffmpegPath);
|
||||
const ttsProvider = getTtsProvider(opts.provider || 'edge');
|
||||
const ttsOpts = { voice: opts.voice, apiKey: opts.apiKey, apiUrl: opts.apiUrl, model: opts.model };
|
||||
|
||||
// Resolve captions: explicit > lastCaptions > .captions.json
|
||||
let captions = opts.captions;
|
||||
let videoTimestamps = true; // new recordings use video-time timestamps (no scaling needed)
|
||||
let recordingDuration = null; // wall-clock duration (for legacy scaling fallback)
|
||||
if (!captions || !captions.length) {
|
||||
if (lastCaptions.length) {
|
||||
captions = [...lastCaptions];
|
||||
recordingDuration = lastRecordingDuration;
|
||||
// Runtime captions always use video timestamps (set in showCaption)
|
||||
}
|
||||
}
|
||||
if (!captions || !captions.length) {
|
||||
const captionsJsonPath = videoPath.replace(/\.[^.]+$/, '.captions.json');
|
||||
if (fsExistsSync(captionsJsonPath)) {
|
||||
const raw = JSON.parse(readFileSync(captionsJsonPath, 'utf-8'));
|
||||
// Support formats: array (old), { recordingDuration, captions } (v2), { videoTimestamps, captions } (v3)
|
||||
if (Array.isArray(raw)) {
|
||||
captions = raw;
|
||||
videoTimestamps = false;
|
||||
} else {
|
||||
captions = raw.captions;
|
||||
videoTimestamps = !!raw.videoTimestamps;
|
||||
recordingDuration = raw.recordingDuration || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!captions || !captions.length) {
|
||||
throw new Error('No captions available. Record with showCaption() first, or pass opts.captions.');
|
||||
}
|
||||
|
||||
const videoDuration = getAudioDuration(videoPath, ffmpegPath);
|
||||
|
||||
// Legacy fallback: scale wall-clock timestamps to video duration
|
||||
// (only for old captions without videoTimestamps flag)
|
||||
if (!videoTimestamps && recordingDuration && recordingDuration > 0) {
|
||||
const timeScale = videoDuration / recordingDuration;
|
||||
if (Math.abs(timeScale - 1) > 0.005) {
|
||||
captions = captions.map(c => ({ ...c, time: Math.round(c.time * timeScale) }));
|
||||
}
|
||||
}
|
||||
|
||||
// Output path
|
||||
const ext = extname(videoPath);
|
||||
const base = videoPath.slice(0, -ext.length);
|
||||
const outputPath = opts.outputPath || `${base}-narrated${ext}`;
|
||||
|
||||
// Temp directory
|
||||
const tempDir = pathJoin(tmpdir(), `web-test-tts-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
const warnings = [];
|
||||
|
||||
try {
|
||||
// Phase 1: Generate TTS audio for each caption
|
||||
const ttsFiles = [];
|
||||
const BATCH_SIZE = (opts.provider === 'elevenlabs') ? 2 : 5;
|
||||
for (let batchStart = 0; batchStart < captions.length; batchStart += BATCH_SIZE) {
|
||||
const batch = captions.slice(batchStart, batchStart + BATCH_SIZE);
|
||||
const promises = batch.map(async (cap, batchIdx) => {
|
||||
const idx = batchStart + batchIdx;
|
||||
const ttsFile = pathJoin(tempDir, `tts_${idx}.mp3`);
|
||||
try {
|
||||
await ttsProvider(cap.speech, ttsFile, ttsOpts);
|
||||
} catch (err) {
|
||||
// Retry once
|
||||
try {
|
||||
await ttsProvider(cap.speech, ttsFile, ttsOpts);
|
||||
} catch (retryErr) {
|
||||
warnings.push(`TTS failed for caption ${idx}: ${retryErr.message || retryErr.cause?.message || String(retryErr)}`);
|
||||
// Generate 1s silence as placeholder
|
||||
generateSilence(ttsFile, 1, ffmpegPath);
|
||||
}
|
||||
}
|
||||
return ttsFile;
|
||||
});
|
||||
const results = await Promise.all(promises);
|
||||
ttsFiles.push(...results);
|
||||
}
|
||||
|
||||
// Phase 2+3: Place each TTS at its exact timestamp using adelay + amix
|
||||
// This avoids MP3 frame quantization drift from silence-file concatenation
|
||||
const ffmpegInputs = [];
|
||||
const filterParts = [];
|
||||
const mixLabels = [];
|
||||
|
||||
for (let i = 0; i < captions.length; i++) {
|
||||
const captionTimeMs = Math.round(captions[i].time);
|
||||
const ttsFile = ttsFiles[i];
|
||||
const ttsDuration = getAudioDuration(ttsFile, ffmpegPath);
|
||||
|
||||
ffmpegInputs.push('-i', ttsFile);
|
||||
const filters = [];
|
||||
|
||||
// Speed up TTS if it's longer than gap to next caption
|
||||
if (i < captions.length - 1) {
|
||||
const maxDuration = (captions[i + 1].time - captions[i].time) / 1000;
|
||||
if (ttsDuration > maxDuration && maxDuration > 0.1) {
|
||||
const tempo = Math.min(ttsDuration / maxDuration, 2.5);
|
||||
filters.push(`atempo=${tempo.toFixed(4)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Delay to exact caption timestamp (milliseconds)
|
||||
if (captionTimeMs > 0) {
|
||||
filters.push(`adelay=${captionTimeMs}|${captionTimeMs}`);
|
||||
}
|
||||
|
||||
const label = `a${i}`;
|
||||
mixLabels.push(`[${label}]`);
|
||||
filterParts.push(`[${i}]${filters.length ? filters.join(',') : 'acopy'}[${label}]`);
|
||||
}
|
||||
|
||||
const filterComplex = filterParts.join(';') + ';' +
|
||||
mixLabels.join('') + `amix=inputs=${captions.length}:normalize=0`;
|
||||
|
||||
const narrationPath = pathJoin(tempDir, 'narration.mp3');
|
||||
execFileSync(ffmpegPath, [
|
||||
'-y', ...ffmpegInputs,
|
||||
'-filter_complex', filterComplex,
|
||||
'-t', String(Math.ceil(videoDuration)),
|
||||
'-c:a', 'libmp3lame', '-b:a', '128k', narrationPath,
|
||||
], { stdio: 'pipe', timeout: 120000 });
|
||||
|
||||
// Phase 4: Merge video + narration audio
|
||||
execFileSync(ffmpegPath, [
|
||||
'-y', '-i', videoPath, '-i', narrationPath,
|
||||
'-c:v', 'copy', '-c:a', 'aac', '-b:a', '128k',
|
||||
'-map', '0:v:0', '-map', '1:a:0',
|
||||
'-shortest', '-movflags', '+faststart', outputPath,
|
||||
], { stdio: 'pipe', timeout: 120000 });
|
||||
|
||||
const stats = statSync(outputPath);
|
||||
const duration = getAudioDuration(outputPath, ffmpegPath);
|
||||
|
||||
const result = {
|
||||
file: outputPath,
|
||||
duration: Math.round(duration * 10) / 10,
|
||||
size: stats.size,
|
||||
captions: captions.length,
|
||||
};
|
||||
if (warnings.length) result.warnings = warnings;
|
||||
return result;
|
||||
|
||||
} finally {
|
||||
// Cleanup temp directory
|
||||
try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a full-screen title slide overlay (for video recordings).
|
||||
* Repeated calls update the content. Use hideTitleSlide() to remove.
|
||||
@@ -2874,6 +3082,140 @@ function resolveFfmpeg(explicit) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── TTS providers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Resolve node-edge-tts module: global install → tools/tts/ → error with instructions. */
|
||||
let _edgeTtsModule = null;
|
||||
async function resolveEdgeTts() {
|
||||
if (_edgeTtsModule) return _edgeTtsModule;
|
||||
|
||||
// 1. Global/project-level install (standard Node resolution)
|
||||
try {
|
||||
_edgeTtsModule = await import('node-edge-tts');
|
||||
return _edgeTtsModule;
|
||||
} catch { /* fall through */ }
|
||||
|
||||
// 2. tools/tts/ relative to project root
|
||||
const __fn = fileURLToPath(import.meta.url);
|
||||
const projectRoot = pathResolve(dirname(__fn), '..', '..', '..', '..');
|
||||
const localPath = pathResolve(projectRoot, 'tools', 'tts', 'node_modules', 'node-edge-tts', 'dist', 'edge-tts.js');
|
||||
if (fsExistsSync(localPath)) {
|
||||
try {
|
||||
_edgeTtsModule = await import(pathToFileURL(localPath).href);
|
||||
return _edgeTtsModule;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
||||
// 3. Error with instructions
|
||||
throw new Error(
|
||||
'node-edge-tts not found. Install it:\n' +
|
||||
' - npm install --prefix tools/tts node-edge-tts\n' +
|
||||
' - or: npm install node-edge-tts (global/project-level)'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge TTS provider (free, no API key). Uses node-edge-tts package.
|
||||
* @param {string} text — text to synthesize
|
||||
* @param {string} outputPath — path for the output mp3 file
|
||||
* @param {object} opts — { voice }
|
||||
*/
|
||||
async function edgeTtsProvider(text, outputPath, opts = {}) {
|
||||
const { EdgeTTS } = await resolveEdgeTts();
|
||||
const voice = opts.voice || 'ru-RU-DmitryNeural';
|
||||
const tts = new EdgeTTS({ voice });
|
||||
await Promise.race([
|
||||
tts.ttsPromise(text, outputPath),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Edge TTS timeout (30s)')), 30000)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI-compatible TTS provider. Requires apiKey.
|
||||
* @param {string} text — text to synthesize
|
||||
* @param {string} outputPath — path for the output mp3 file
|
||||
* @param {object} opts — { apiKey, apiUrl, voice, model }
|
||||
*/
|
||||
async function openaiTtsProvider(text, outputPath, opts = {}) {
|
||||
const apiUrl = opts.apiUrl || 'https://api.openai.com/v1/audio/speech';
|
||||
if (!opts.apiKey) throw new Error('OpenAI TTS requires apiKey');
|
||||
const resp = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${opts.apiKey}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: opts.model || 'tts-1',
|
||||
input: text,
|
||||
voice: opts.voice || 'alloy',
|
||||
response_format: 'mp3',
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`OpenAI TTS error ${resp.status}: ${await resp.text()}`);
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
writeFileSync(outputPath, buf);
|
||||
}
|
||||
|
||||
/**
|
||||
* ElevenLabs TTS provider. Requires apiKey.
|
||||
* @param {string} text — text to synthesize
|
||||
* @param {string} outputPath — path for the output mp3 file
|
||||
* @param {object} opts — { apiKey, apiUrl, voice, model }
|
||||
*/
|
||||
async function elevenlabsTtsProvider(text, outputPath, opts = {}) {
|
||||
const voiceId = opts.voice || 'JBFqnCBsd6RMkjVDRZzb'; // George
|
||||
const apiUrl = opts.apiUrl || `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`;
|
||||
if (!opts.apiKey) throw new Error('ElevenLabs TTS requires apiKey');
|
||||
const resp = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'xi-api-key': opts.apiKey, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
model_id: opts.model || 'eleven_multilingual_v2',
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`ElevenLabs TTS error ${resp.status}: ${await resp.text()}`);
|
||||
const buf = Buffer.from(await resp.arrayBuffer());
|
||||
writeFileSync(outputPath, buf);
|
||||
}
|
||||
|
||||
/** Get TTS provider function by name. */
|
||||
function getTtsProvider(name) {
|
||||
switch (name) {
|
||||
case 'openai': return openaiTtsProvider;
|
||||
case 'elevenlabs': return elevenlabsTtsProvider;
|
||||
case 'edge': default: return edgeTtsProvider;
|
||||
}
|
||||
}
|
||||
|
||||
// ── TTS audio helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get audio duration in seconds using ffprobe.
|
||||
* @param {string} filePath — path to audio file
|
||||
* @param {string} ffmpegPath — path to ffmpeg binary (ffprobe is found next to it)
|
||||
* @returns {number} duration in seconds
|
||||
*/
|
||||
function getAudioDuration(filePath, ffmpegPath) {
|
||||
const ffprobePath = ffmpegPath.replace(/ffmpeg(\.exe)?$/i, 'ffprobe$1');
|
||||
const out = execFileSync(ffprobePath, [
|
||||
'-v', 'error', '-show_entries', 'format=duration',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1', filePath,
|
||||
], { encoding: 'utf8', timeout: 10000 }).trim();
|
||||
return parseFloat(out) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a silence mp3 file of given duration.
|
||||
* @param {string} outputPath — path for the output mp3 file
|
||||
* @param {number} seconds — duration in seconds
|
||||
* @param {string} ffmpegPath — path to ffmpeg binary
|
||||
*/
|
||||
function generateSilence(outputPath, seconds, ffmpegPath) {
|
||||
execFileSync(ffmpegPath, [
|
||||
'-y', '-f', 'lavfi', '-i', `anullsrc=r=24000:cl=mono`,
|
||||
'-t', String(seconds), '-c:a', 'libmp3lame', '-b:a', '32k', outputPath,
|
||||
], { stdio: 'pipe', timeout: 10000 });
|
||||
}
|
||||
|
||||
function ensureConnected() {
|
||||
if (!isConnected()) {
|
||||
throw new Error('Browser not connected. Call web_connect first.');
|
||||
|
||||
@@ -204,11 +204,20 @@ async function cmdExec(fileOrDash) {
|
||||
: readFileSync(resolve(fileOrDash), 'utf-8');
|
||||
|
||||
const sess = loadSession();
|
||||
const resp = await fetch(`http://127.0.0.1:${sess.port}/exec`, {
|
||||
method: 'POST',
|
||||
body: code
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const req = http.request({
|
||||
hostname: '127.0.0.1', port: sess.port, path: '/exec',
|
||||
method: 'POST', timeout: 10 * 60 * 1000,
|
||||
}, res => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => { try { resolve(JSON.parse(data)); } catch { reject(new Error(data)); } });
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => { req.destroy(new Error('Exec timeout (10 min)')); });
|
||||
req.write(code);
|
||||
req.end();
|
||||
});
|
||||
const result = await resp.json();
|
||||
out(result);
|
||||
if (!result.ok) process.exit(1);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
| Базы данных (DB) | 9 навыков `/db-*` | Создание баз, загрузка/выгрузка конфигураций, обновление БД, загрузка из Git | [Подробнее](docs/db-guide.md) |
|
||||
| Веб-публикация (Web) | 4 навыка `/web-*` | Публикация баз через Apache, статус, остановка, удаление публикаций | [Подробнее](docs/web-guide.md) |
|
||||
| Тестирование (Web) | `/web-test` | Взаимодействие с веб-клиентом 1С — навигация, формы, таблицы, отчёты, тестирование | [Подробнее](docs/web-test-guide.md) |
|
||||
| Запись видео (Web) | `/web-test` | Запись видеоинструкций с субтитрами, подсветкой и TTS-озвучкой | [Подробнее](docs/web-test-recording-guide.md) |
|
||||
| Утилиты | `/img-grid` | Наложение сетки на изображение для определения пропорций колонок | — |
|
||||
|
||||
## Требования
|
||||
@@ -192,9 +193,11 @@ docs/
|
||||
├── cf-guide.md # Гайд: корневые файлы конфигурации
|
||||
├── cfe-guide.md # Гайд: расширения конфигурации (CFE)
|
||||
├── subsystem-guide.md # Гайд: подсистемы и командный интерфейс
|
||||
├── v8-project-guide.md # Гайд: конфигурация проекта (.v8-project.json)
|
||||
├── db-guide.md # Гайд: базы данных 1С
|
||||
├── web-guide.md # Гайд: веб-публикация через Apache
|
||||
├── web-test-guide.md # Гайд: тестирование через веб-клиент
|
||||
├── web-test-recording-guide.md # Гайд: запись видеоинструкций
|
||||
├── 1c-epf-spec.md # Спецификация XML-формата (EPF)
|
||||
├── 1c-erf-spec.md # Спецификация XML-формата (ERF)
|
||||
├── 1c-form-spec.md # Спецификация управляемых форм
|
||||
|
||||
+1
-47
@@ -37,53 +37,7 @@
|
||||
|
||||
## Формат `.v8-project.json`
|
||||
|
||||
Файл размещается в корне проекта (рядом с `.git/`).
|
||||
|
||||
```json
|
||||
{
|
||||
"v8path": "C:\\Program Files\\1cv8\\8.3.25.1257\\bin",
|
||||
"databases": [
|
||||
{
|
||||
"id": "dev",
|
||||
"name": "Разработка",
|
||||
"type": "file",
|
||||
"path": "C:\\Bases\\MyApp_Dev",
|
||||
"user": "Admin",
|
||||
"password": "",
|
||||
"aliases": ["dev", "разработка"],
|
||||
"branches": ["dev", "develop", "feature/*"],
|
||||
"configSrc": "C:\\WS\\myapp\\cfsrc"
|
||||
},
|
||||
{
|
||||
"id": "test",
|
||||
"name": "Тестовая",
|
||||
"type": "server",
|
||||
"server": "srv01",
|
||||
"ref": "MyApp_Test",
|
||||
"user": "Admin",
|
||||
"password": "123",
|
||||
"aliases": ["test", "тест"]
|
||||
}
|
||||
],
|
||||
"default": "dev"
|
||||
}
|
||||
```
|
||||
|
||||
### Поля базы данных
|
||||
|
||||
| Поле | Тип | Обязательное | Описание |
|
||||
|------|-----|:------------:|----------|
|
||||
| `id` | string | да | Уникальный идентификатор |
|
||||
| `name` | string | да | Имя |
|
||||
| `type` | `"file"` / `"server"` | да | Тип подключения |
|
||||
| `path` | string | для file | Каталог файловой базы |
|
||||
| `server` | string | для server | Адрес сервера |
|
||||
| `ref` | string | для server | Имя базы на сервере |
|
||||
| `user` | string | нет | Пользователь 1С |
|
||||
| `password` | string | нет | Пароль |
|
||||
| `aliases` | string[] | нет | Альтернативные имена |
|
||||
| `branches` | string[] | нет | Git-ветки или glob-паттерны (`release/*`, `feature/*`) |
|
||||
| `configSrc` | string | нет | Каталог XML-выгрузки |
|
||||
Полное описание формата — в [справочнике .v8-project.json](v8-project-guide.md).
|
||||
|
||||
### Разрешение базы
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# Конфигурация проекта (.v8-project.json)
|
||||
|
||||
Файл `.v8-project.json` — единый конфиг проекта для всех навыков Claude Code. Хранит пути к платформе 1С, список баз данных и настройки инструментов (Apache, ffmpeg, TTS).
|
||||
|
||||
Размещается в корне проекта (рядом с `.git/`). Создаётся навыком `/db-list add` или вручную.
|
||||
|
||||
> **Безопасность**: файл содержит секреты (пароли баз данных, API-ключи TTS) и добавлен в `.gitignore` — он не попадает в репозиторий. Каждый разработчик заводит свой `.v8-project.json` локально.
|
||||
|
||||
## Полная схема
|
||||
|
||||
```jsonc
|
||||
{
|
||||
// === Платформа ===
|
||||
"v8path": "C:\\Program Files\\1cv8\\8.3.25.1257\\bin",
|
||||
|
||||
// === Базы данных ===
|
||||
"databases": [
|
||||
{
|
||||
"id": "dev", // уникальный идентификатор
|
||||
"name": "Разработка", // отображаемое имя
|
||||
"type": "file", // "file" или "server"
|
||||
"path": "C:\\Bases\\MyApp_Dev", // каталог (для file)
|
||||
"user": "Admin", // пользователь 1С
|
||||
"password": "", // пароль
|
||||
"aliases": ["dev", "разработка"], // альтернативные имена
|
||||
"branches": ["dev", "feature/*"], // привязка к Git-веткам
|
||||
"configSrc": "C:\\WS\\myapp\\cfsrc", // каталог XML-выгрузки конфигурации
|
||||
"webUrl": "http://localhost:8081/dev" // URL веб-клиента (для /web-test)
|
||||
},
|
||||
{
|
||||
"id": "test",
|
||||
"name": "Тестовая",
|
||||
"type": "server", // серверная база
|
||||
"server": "srv01", // адрес сервера 1С
|
||||
"ref": "MyApp_Test", // имя базы на сервере
|
||||
"user": "Admin",
|
||||
"password": "123",
|
||||
"aliases": ["test", "тест"]
|
||||
}
|
||||
],
|
||||
"default": "dev",
|
||||
|
||||
// === Инструменты ===
|
||||
"webPath": "C:\\tools\\apache24", // каталог Apache
|
||||
"ffmpegPath": "C:\\tools\\ffmpeg\\bin\\ffmpeg.exe", // путь к ffmpeg
|
||||
"tts": { // настройки озвучки
|
||||
"provider": "edge",
|
||||
"voice": "ru-RU-DmitryNeural"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Корневые поля
|
||||
|
||||
| Поле | Тип | Обяз. | По умолчанию | Описание | Кто заполняет |
|
||||
|------|-----|:-----:|-------------|----------|---------------|
|
||||
| `v8path` | string | да | — | Путь к каталогу `bin` платформы 1С | `/db-list add` или руками |
|
||||
| `databases` | array | да | — | Список баз данных | `/db-list add` |
|
||||
| `default` | string | нет | — | `id` базы по умолчанию | `/db-list` |
|
||||
| `webPath` | string | нет | `tools/apache24` | Каталог Apache HTTP Server | Руками |
|
||||
| `ffmpegPath` | string | нет | `tools/ffmpeg/bin/ffmpeg.exe` | Путь к ffmpeg | Руками |
|
||||
| `tts` | object | нет | Edge TTS, DmitryNeural | Настройки озвучки видео | Руками |
|
||||
|
||||
## Базы данных (`databases[]`)
|
||||
|
||||
| Поле | Тип | Обяз. | Описание | Кто заполняет |
|
||||
|------|-----|:-----:|----------|---------------|
|
||||
| `id` | string | да | Уникальный идентификатор | `/db-list add` |
|
||||
| `name` | string | да | Отображаемое имя | `/db-list add` |
|
||||
| `type` | `"file"` / `"server"` | да | Тип подключения | `/db-list add` |
|
||||
| `path` | string | для file | Каталог файловой базы | `/db-list add` |
|
||||
| `server` | string | для server | Адрес сервера 1С | `/db-list add` |
|
||||
| `ref` | string | для server | Имя базы на сервере | `/db-list add` |
|
||||
| `user` | string | нет | Пользователь 1С | `/db-list add` или руками |
|
||||
| `password` | string | нет | Пароль | `/db-list add` или руками |
|
||||
| `aliases` | string[] | нет | Альтернативные имена для обращения к базе | `/db-list add` или руками |
|
||||
| `branches` | string[] | нет | Git-ветки или glob-паттерны (`release/*`, `feature/*`) | Руками |
|
||||
| `configSrc` | string | нет | Каталог XML-выгрузки конфигурации | Руками |
|
||||
| `webUrl` | string | нет | URL веб-клиента для `/web-test` | Руками |
|
||||
|
||||
### Разрешение базы
|
||||
|
||||
Все навыки `/db-*`, `/epf-build`, `/epf-dump`, `/erf-build`, `/erf-dump`, `/web-publish` используют единый алгоритм:
|
||||
|
||||
1. Если пользователь указал **параметры подключения** (путь, сервер) — используются напрямую
|
||||
2. Если указал **базу по имени** — поиск: `id` → `aliases` (с учётом морфологии) → `name` (нечёткое)
|
||||
3. Если **не указал** — сопоставление текущей ветки Git с `branches` (точно или по glob-паттерну)
|
||||
4. Fallback на `default`
|
||||
5. Если не найдено — Claude спросит пользователя
|
||||
6. Если база не зарегистрирована — Claude предложит `/db-list add`
|
||||
|
||||
## Настройки инструментов
|
||||
|
||||
### `webPath` — Apache HTTP Server
|
||||
|
||||
Путь к каталогу Apache. Используется навыками `/web-publish`, `/web-info`, `/web-stop`, `/web-unpublish`.
|
||||
|
||||
Если не задан — ищется в `tools/apache24` от корня проекта. При первом вызове `/web-publish` Apache скачивается автоматически.
|
||||
|
||||
Подробнее — в [гайде по веб-публикации](web-guide.md).
|
||||
|
||||
### `ffmpegPath` — ffmpeg
|
||||
|
||||
Путь к исполняемому файлу ffmpeg. Используется навыком `/web-test` для записи видео.
|
||||
|
||||
Если не задан — ищется по порядку:
|
||||
1. `tools/ffmpeg/bin/ffmpeg.exe` (от корня проекта)
|
||||
2. `ffmpeg` в системном PATH
|
||||
|
||||
Подробнее — в [гайде по записи видео](web-test-recording-guide.md).
|
||||
|
||||
### `tts` — озвучка видеоинструкций
|
||||
|
||||
| Поле | Тип | По умолчанию | Описание |
|
||||
|------|-----|-------------|----------|
|
||||
| `provider` | string | `"edge"` | Провайдер: `"edge"`, `"elevenlabs"`, `"openai"` |
|
||||
| `voice` | string | `"ru-RU-DmitryNeural"` | Голос (имя или ID в зависимости от провайдера) |
|
||||
| `apiKey` | string | — | API-ключ (для elevenlabs, openai) |
|
||||
| `apiUrl` | string | — | URL сервиса (для openai-совместимых) |
|
||||
| `model` | string | — | Модель (для openai) |
|
||||
|
||||
Подробнее о выборе провайдера и голосов — в [гайде по записи видео](web-test-recording-guide.md#доступные-голоса-и-провайдеры).
|
||||
|
||||
### `webUrl` — URL веб-клиента (per-database)
|
||||
|
||||
URL для открытия базы в браузере через `/web-test`. Задаётся в записи конкретной базы.
|
||||
|
||||
Если не задан — `/web-test` берёт URL из активной веб-публикации (`/web-publish`).
|
||||
|
||||
Полезно, если веб-клиент доступен по нестандартному адресу (другой порт, внешний сервер, reverse proxy).
|
||||
|
||||
## Минимальный пример
|
||||
|
||||
```json
|
||||
{
|
||||
"v8path": "C:\\Program Files\\1cv8\\8.3.25.1257\\bin",
|
||||
"databases": [
|
||||
{
|
||||
"id": "dev",
|
||||
"name": "Разработка",
|
||||
"type": "file",
|
||||
"path": "C:\\Bases\\MyApp"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Полный пример
|
||||
|
||||
```json
|
||||
{
|
||||
"v8path": "C:\\Program Files\\1cv8\\8.3.25.1257\\bin",
|
||||
"databases": [
|
||||
{
|
||||
"id": "dev",
|
||||
"name": "Разработка",
|
||||
"type": "file",
|
||||
"path": "C:\\Bases\\MyApp_Dev",
|
||||
"user": "Admin",
|
||||
"password": "",
|
||||
"aliases": ["dev", "разработка"],
|
||||
"branches": ["dev", "develop", "feature/*"],
|
||||
"configSrc": "C:\\WS\\myapp\\cfsrc",
|
||||
"webUrl": "http://localhost:8081/dev"
|
||||
},
|
||||
{
|
||||
"id": "prod",
|
||||
"name": "Рабочая",
|
||||
"type": "server",
|
||||
"server": "srv01",
|
||||
"ref": "MyApp_Prod",
|
||||
"user": "Admin",
|
||||
"password": "secret",
|
||||
"aliases": ["prod", "рабочая", "боевая"],
|
||||
"branches": ["main", "release/*"]
|
||||
}
|
||||
],
|
||||
"default": "dev",
|
||||
"webPath": "C:\\tools\\apache24",
|
||||
"ffmpegPath": "C:\\tools\\ffmpeg\\bin\\ffmpeg.exe",
|
||||
"tts": {
|
||||
"provider": "edge",
|
||||
"voice": "ru-RU-DmitryNeural"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Связанные навыки
|
||||
|
||||
- [Базы данных](db-guide.md) — `/db-list`, `/db-create`, `/db-load-xml`, `/db-dump-xml` и другие
|
||||
- [Веб-публикация](web-guide.md) — `/web-publish`, `/web-info`, `/web-stop`
|
||||
- [Тестирование в браузере](web-test-guide.md) — `/web-test`
|
||||
- [Запись видеоинструкций](web-test-recording-guide.md) — запись видео, субтитры, озвучка
|
||||
+2
-19
@@ -33,28 +33,11 @@
|
||||
8. **Проверка** — обновить страницу в браузере
|
||||
9. **Завершение** — `/web-stop` остановить Apache
|
||||
|
||||
## Конфигурация `webPath` в `.v8-project.json`
|
||||
## Конфигурация в `.v8-project.json`
|
||||
|
||||
Поле `webPath` указывает путь к каталогу Apache. Если не задано, используется `tools/apache24` от корня проекта.
|
||||
|
||||
```json
|
||||
{
|
||||
"v8path": "C:\\Program Files\\1cv8\\8.3.24.1691\\bin",
|
||||
"webPath": "C:\\tools\\apache24",
|
||||
"databases": [
|
||||
{
|
||||
"id": "dev",
|
||||
"name": "Разработка",
|
||||
"type": "file",
|
||||
"path": "C:\\Bases\\MyApp_Dev",
|
||||
"user": "Admin"
|
||||
}
|
||||
],
|
||||
"default": "dev"
|
||||
}
|
||||
```
|
||||
|
||||
> `webPath` — опционально. Если не задан, все навыки `/web-*` ищут Apache в `tools/apache24`.
|
||||
Полное описание формата — в [справочнике .v8-project.json](v8-project-guide.md).
|
||||
|
||||
## Сценарии использования
|
||||
|
||||
|
||||
@@ -296,6 +296,7 @@ await closeForm({ save: false });
|
||||
|
||||
## Связанные навыки
|
||||
|
||||
- [Запись видеоинструкций](web-test-recording-guide.md) — запись видео, субтитры, подсветка, TTS-озвучка
|
||||
- [Веб-публикация](web-guide.md) — `/web-publish`, `/web-info`, `/web-stop`, `/web-unpublish`
|
||||
- [Базы данных](db-guide.md) — `/db-load-xml`, `/db-update`, `/db-run`
|
||||
- [Расширения](cfe-guide.md) — `/cfe-init`, `/cfe-borrow`, `/cfe-patch-method`
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
# Запись видеоинструкций
|
||||
|
||||
Навык `/web-test` умеет записывать видеоинструкции по работе в 1С: автоматические действия в браузере записываются в MP4 с субтитрами, подсветкой элементов и голосовой озвучкой. Результат — готовое обучающее видео.
|
||||
|
||||
```
|
||||
сценарий → запись экрана → субтитры → подсветка → озвучка голосом → MP4
|
||||
```
|
||||
|
||||
## Предусловия
|
||||
|
||||
Все пути и настройки хранятся в `.v8-project.json` — см. [справочник формата](v8-project-guide.md).
|
||||
|
||||
### ffmpeg (обязательно)
|
||||
|
||||
Выберите один из вариантов:
|
||||
|
||||
1. **В проект** (рекомендуется) — скачать essentials build с https://www.gyan.dev/ffmpeg/builds/, распаковать в `tools/ffmpeg/`. Код найдёт `tools/ffmpeg/bin/ffmpeg.exe` автоматически
|
||||
|
||||
2. **Глобально** — скачать, распаковать в любой каталог, добавить `bin/` в системный PATH
|
||||
|
||||
3. **Через конфиг** — указать путь в `.v8-project.json`:
|
||||
```json
|
||||
{ "ffmpegPath": "C:\\tools\\ffmpeg\\bin\\ffmpeg.exe" }
|
||||
```
|
||||
|
||||
### node-edge-tts (для озвучки)
|
||||
|
||||
```bash
|
||||
npm install --prefix tools/tts node-edge-tts
|
||||
```
|
||||
|
||||
Бесплатный, без API-ключа. Если не установлен — запись видео работает, только озвучка недоступна.
|
||||
|
||||
### Конфигурация голоса в `.v8-project.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"ffmpegPath": "tools/ffmpeg/bin/ffmpeg.exe",
|
||||
"tts": {
|
||||
"provider": "edge",
|
||||
"voice": "ru-RU-DmitryNeural"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
Минимальный сценарий — запись 3 шагов с озвучкой:
|
||||
|
||||
```js
|
||||
// Начинаем запись
|
||||
await startRecording('recordings/demo.mp4');
|
||||
|
||||
// Субтитры + действия
|
||||
await showCaption('Переходим в раздел «Продажи»');
|
||||
await wait(1.5);
|
||||
await navigateSection('Продажи');
|
||||
|
||||
await showCaption('Открываем заказы клиентов');
|
||||
await wait(1.5);
|
||||
await openCommand('Заказы клиентов');
|
||||
|
||||
await showCaption('Создаём новый заказ');
|
||||
await wait(1.5);
|
||||
await clickElement('Создать');
|
||||
await wait(2);
|
||||
|
||||
// Завершаем запись
|
||||
await hideCaption();
|
||||
const video = await stopRecording();
|
||||
console.log(`Записано: ${video.duration.toFixed(1)}s`);
|
||||
|
||||
// Озвучка
|
||||
const narrated = await addNarration(video.file, {
|
||||
ffmpegPath: 'tools/ffmpeg/bin/ffmpeg.exe',
|
||||
voice: 'ru-RU-DmitryNeural',
|
||||
});
|
||||
console.log(`Озвучено: ${narrated.file}`);
|
||||
```
|
||||
|
||||
Результат: `recordings/demo-narrated.mp4` — видео с голосовым сопровождением.
|
||||
|
||||
## Сценарии использования
|
||||
|
||||
### Запись без озвучки
|
||||
|
||||
Простейший вариант — субтитры на экране, без голоса:
|
||||
|
||||
```
|
||||
> Запиши видеоинструкцию: открой раздел Продажи, создай заказ клиента,
|
||||
> заполни организацию и контрагента. Без озвучки
|
||||
```
|
||||
|
||||
Claude запишет видео с субтитрами и подсветкой элементов.
|
||||
|
||||
### Запись с озвучкой
|
||||
|
||||
Полный pipeline — голос озвучивает каждый шаг:
|
||||
|
||||
```
|
||||
> Запиши озвученную видеоинструкцию по созданию заказа клиента.
|
||||
> Голос — Светлана
|
||||
```
|
||||
|
||||
Claude запишет видео, затем наложит голосовую дорожку. Субтитры показываются на экране, параллельно звучит голос.
|
||||
|
||||
### Переозвучка другим голосом
|
||||
|
||||
Видео уже записано — хотите другой голос? Не нужно перезаписывать:
|
||||
|
||||
```
|
||||
> Переозвучь recordings/demo.mp4 голосом Светланы
|
||||
```
|
||||
|
||||
Claude вызовет `addNarration` с другим голосом. Тексты берутся из файла `.captions.json`, который сохраняется рядом с видео при записи.
|
||||
|
||||
### Редактирование субтитров
|
||||
|
||||
После записи рядом с видео появляется файл `video.captions.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"videoTimestamps": true,
|
||||
"captions": [
|
||||
{ "text": "Переходим в раздел «Продажи»", "speech": "Переходим в раздел Продажи", "time": 3160 },
|
||||
{ "text": "Открываем заказы клиентов", "speech": "Открываем заказы клиентов", "time": 7040 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Можно отредактировать `speech` (текст озвучки) и переозвучить:
|
||||
|
||||
```
|
||||
> Отредактируй субтитры в recordings/demo.captions.json — замени "Продажи" на
|
||||
> "раздел Продажи", потом переозвучь
|
||||
```
|
||||
|
||||
## Приёмы
|
||||
|
||||
### Титульный слайд
|
||||
|
||||
Полноэкранная заставка в начале видео:
|
||||
|
||||
```js
|
||||
await startRecording('recordings/demo.mp4');
|
||||
await showTitleSlide('Создание заказа клиента', {
|
||||
subtitle: '1С:Бухгалтерия в примерах'
|
||||
});
|
||||
await wait(4);
|
||||
await hideTitleSlide();
|
||||
// ... далее контент
|
||||
```
|
||||
|
||||
### Подсветка элементов
|
||||
|
||||
Полупрозрачная рамка на элементе, который сейчас используется. Два режима:
|
||||
|
||||
- **Авторежим** — `setHighlight(true)` перед началом действий. Каждая функция (`navigateSection`, `clickElement`, `fillFields` и т.д.) автоматически подсвечивает элемент перед действием
|
||||
- **Ручная** — `highlight('Провести')` для произвольной подсветки конкретного элемента
|
||||
|
||||
```js
|
||||
setHighlight(true); // включить авто
|
||||
// ... все действия подсвечиваются автоматически
|
||||
setHighlight(false); // выключить перед stopRecording
|
||||
```
|
||||
|
||||
### Паузы и ритм
|
||||
|
||||
Ритм «субтитр → пауза → действие» даёт зрителю время прочитать, что произойдёт:
|
||||
|
||||
```js
|
||||
await showCaption('Проводим документ'); // зритель читает
|
||||
await wait(1.5); // пауза 1.5 сек
|
||||
await clickElement('Провести'); // действие
|
||||
```
|
||||
|
||||
Пауза после действия нужна только когда загружается следующая форма:
|
||||
|
||||
```js
|
||||
await clickElement('Создать');
|
||||
await wait(2); // форма загружается
|
||||
```
|
||||
|
||||
### Разделение текста и озвучки
|
||||
|
||||
Параметр `speech` в `showCaption` позволяет показывать одно, а озвучивать другое:
|
||||
|
||||
```js
|
||||
// Субтитр технический, озвучка человечная
|
||||
await showCaption('Дт 60.02 — Кт 51', {
|
||||
speech: 'Дебет шестьдесят ноль два — кредит пятьдесят один'
|
||||
});
|
||||
|
||||
// Показать субтитр, но НЕ озвучивать
|
||||
await showCaption('Технические детали', { speech: false });
|
||||
```
|
||||
|
||||
Это полезно для:
|
||||
- **Бухгалтерских проводок** — на экране формула, голосом — словами
|
||||
- **Технических данных** — показать, но не зачитывать
|
||||
- **Информационных плашек** — немой субтитр на несколько секунд
|
||||
|
||||
## Доступные голоса и провайдеры
|
||||
|
||||
### Какой провайдер выбрать?
|
||||
|
||||
| Провайдер | Тембр | Произношение русского | Цена |
|
||||
|-----------|-------|----------------------|------|
|
||||
| **Edge TTS** | Синтетичнее | Корректные ударения, правильная артикуляция | Бесплатно |
|
||||
| **ElevenLabs** | Живее, естественнее | Возможны ошибки в ударениях и артикуляции (напр. «докумЭнт», «крЕдит» вместо «кредИт») | Платно (starter+) |
|
||||
| **OpenAI** | Зависит от голоса | Зависит от сервиса | Платно |
|
||||
|
||||
**Для русскоязычных видеоинструкций рекомендуется Edge TTS** — он бесплатный и даёт надёжное качество русской речи. Голоса DmitryNeural и SvetlanaNeural специально обучены для русского языка: правильно расставляют ударения, корректно артикулируют и делают паузы в нужных местах.
|
||||
|
||||
**ElevenLabs** даёт более живой, «человечный» тембр — голос звучит менее синтетически. Однако мультиязычная модель иногда ошибается в произношении русских слов (особенно профессиональная терминология). Если выбираете ElevenLabs для русского контента — берите **professional-голоса** с образовательным или деловым профилем (например, Olga Orlova, Artem), они дают лучший результат, чем англоязычные premade-голоса через мультиязычную модель. Управлять ударениями через API нельзя — фонемные теги (SSML) поддерживаются только для английских моделей.
|
||||
|
||||
### Edge TTS (бесплатный) — рекомендуется для русского
|
||||
|
||||
| Голос | Описание |
|
||||
|-------|----------|
|
||||
| `ru-RU-DmitryNeural` | Мужской, русский — спокойный, деловой |
|
||||
| `ru-RU-SvetlanaNeural` | Женский, русский — чёткий, уверенный |
|
||||
|
||||
Полный список: `en-US-AriaNeural`, `en-US-GuyNeural`, `de-DE-ConradNeural` и другие. Edge TTS поддерживает десятки языков.
|
||||
|
||||
Конфигурация не нужна — Edge TTS используется по умолчанию. Для смены голоса:
|
||||
|
||||
```json
|
||||
{
|
||||
"tts": {
|
||||
"voice": "ru-RU-SvetlanaNeural"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ElevenLabs (платный) — живой тембр
|
||||
|
||||
Модель `eleven_multilingual_v2` поддерживает русский. Тембр заметно живее, чем у Edge TTS, но возможны артикуляционные ошибки на русской терминологии.
|
||||
|
||||
Для русского контента выбирайте **professional-голоса** с образовательным/деловым профилем из Voice Library:
|
||||
|
||||
| Голос | ID | Профиль |
|
||||
|-------|----|---------|
|
||||
| Olga Orlova | `d60rsXo2p0OwikDR5bS7` | Clear and Engaging |
|
||||
| Artem | `WTn2eCRCpoFAC50VD351` | Friendly & Professional |
|
||||
| Denis | `0BcDz9UPwL3MpsnTeUlO` | Pleasant, Engaging and Friendly |
|
||||
|
||||
```json
|
||||
{
|
||||
"tts": {
|
||||
"provider": "elevenlabs",
|
||||
"apiKey": "sk_...",
|
||||
"voice": "d60rsXo2p0OwikDR5bS7"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`voice` — ID голоса (не имя). Professional-голоса добавляются в аккаунт через Voice Library в личном кабинете. Требуется платный тариф (starter и выше).
|
||||
|
||||
Особенности: лимит на параллельные запросы (2–3 одновременно), система автоматически ограничивает размер пакета.
|
||||
|
||||
### OpenAI-compatible (платный)
|
||||
|
||||
```json
|
||||
{
|
||||
"tts": {
|
||||
"provider": "openai",
|
||||
"apiKey": "sk-...",
|
||||
"voice": "alloy"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Голоса: `alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer`.
|
||||
|
||||
Поле `apiUrl` позволяет подключить любой OpenAI-совместимый сервис (например, локальный TTS-прокси).
|
||||
|
||||
## Полный пример
|
||||
|
||||
Типовая структура озвученного сценария:
|
||||
|
||||
```js
|
||||
await startRecording('output.mp4');
|
||||
await showTitleSlide('Заголовок', { subtitle: 'Подзаголовок' });
|
||||
await wait(4);
|
||||
await hideTitleSlide();
|
||||
setHighlight(true);
|
||||
|
||||
// ... шаги с showCaption + действия ...
|
||||
|
||||
await hideCaption();
|
||||
setHighlight(false);
|
||||
const video = await stopRecording();
|
||||
|
||||
const narrated = await addNarration(video.file, {
|
||||
ffmpegPath: 'tools/ffmpeg/bin/ffmpeg.exe',
|
||||
voice: 'ru-RU-SvetlanaNeural',
|
||||
});
|
||||
```
|
||||
|
||||
## Типичные проблемы
|
||||
|
||||
| Проблема | Решение |
|
||||
|----------|---------|
|
||||
| `ffmpeg not found` | Установите ffmpeg (см. Предусловия) |
|
||||
| Файл записи 0 байт | Проверьте права на запись в выходной каталог |
|
||||
| Видео дёргается | Добавьте `wait()` между шагами |
|
||||
| `Already recording` | Вызовите `stopRecording()` перед новой записью |
|
||||
| `No captions available` | Используйте `showCaption()` во время записи |
|
||||
| TTS timeout | Проверьте интернет-соединение (Edge TTS требует сеть) |
|
||||
| Озвучка обрезается | Увеличьте паузы `wait()` между субтитрами |
|
||||
|
||||
## Связанные навыки
|
||||
|
||||
- [Тестирование через веб-клиент](web-test-guide.md) — навигация, формы, таблицы, отчёты
|
||||
- [Веб-публикация](web-guide.md) — `/web-publish`, `/web-info`, `/web-stop`
|
||||
Reference in New Issue
Block a user