diff --git a/.claude/skills/web-test/SKILL.md b/.claude/skills/web-test/SKILL.md index c3f225a1..9be01253 100644 --- a/.claude/skills/web-test/SKILL.md +++ b/.claude/skills/web-test/SKILL.md @@ -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 diff --git a/.claude/skills/web-test/recording.md b/.claude/skills/web-test/recording.md index afd05780..91f16eda 100644 --- a/.claude/skills/web-test/recording.md +++ b/.claude/skills/web-test/recording.md @@ -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 | diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 39e481de..a51fbc86 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -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.'); diff --git a/.claude/skills/web-test/scripts/run.mjs b/.claude/skills/web-test/scripts/run.mjs index d31811da..496874e9 100644 --- a/.claude/skills/web-test/scripts/run.mjs +++ b/.claude/skills/web-test/scripts/run.mjs @@ -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); } diff --git a/README.md b/README.md index 46dcedba..577d87e1 100644 --- a/README.md +++ b/README.md @@ -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 # Спецификация управляемых форм diff --git a/docs/db-guide.md b/docs/db-guide.md index 459cdf44..c238a545 100644 --- a/docs/db-guide.md +++ b/docs/db-guide.md @@ -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). ### Разрешение базы diff --git a/docs/v8-project-guide.md b/docs/v8-project-guide.md new file mode 100644 index 00000000..7fd7e70b --- /dev/null +++ b/docs/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) — запись видео, субтитры, озвучка diff --git a/docs/web-guide.md b/docs/web-guide.md index 49d2e83d..e28ab3d1 100644 --- a/docs/web-guide.md +++ b/docs/web-guide.md @@ -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). ## Сценарии использования diff --git a/docs/web-test-guide.md b/docs/web-test-guide.md index 744c50ef..78afef25 100644 --- a/docs/web-test-guide.md +++ b/docs/web-test-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` diff --git a/docs/web-test-recording-guide.md b/docs/web-test-recording-guide.md new file mode 100644 index 00000000..80a18eaf --- /dev/null +++ b/docs/web-test-recording-guide.md @@ -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`