From a13f16e49df74eb6a5a77a957b4c55d5588a731e Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Mon, 2 Mar 2026 23:10:37 +0300 Subject: [PATCH 01/14] feat(web-test): add TTS narration for video recordings showCaption() collects captions with optional speech parameter, stopRecording() saves .captions.json, addNarration() generates TTS voiceover (Edge TTS or OpenAI) and merges audio with video. Co-Authored-By: Claude Opus 4.6 --- .claude/skills/web-test/SKILL.md | 5 +- .claude/skills/web-test/recording.md | 94 +++++++ .claude/skills/web-test/scripts/browser.mjs | 272 +++++++++++++++++++- 3 files changed, 365 insertions(+), 6 deletions(-) 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..78e4b441 100644 --- a/.claude/skills/web-test/recording.md +++ b/.claude/skills/web-test/recording.md @@ -184,6 +184,97 @@ 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 node-edge-tts` (for Edge TTS provider, free, no API key) + +### 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" + } +} +``` + +### `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) or `'openai'` | +| `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 +284,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..ff4d6d9c 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -8,8 +8,9 @@ */ 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 { 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 } from 'url'; import { readSectionsScript, readTabsScript, readCommandsScript, @@ -24,7 +25,8 @@ 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 highlightMode = false; const LOAD_TIMEOUT = 60000; @@ -2436,6 +2438,7 @@ export function isRecording() { export async function startRecording(outputPath, opts = {}) { ensureConnected(); if (recorder) throw new Error('Already recording. Call stopRecording() first.'); + lastCaptions = []; const fps = opts.fps || 25; const quality = opts.quality || 80; @@ -2503,7 +2506,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: [] }; // Redirect stderr accumulation to the recorder object ffmpeg.stderr.removeAllListeners('data'); ffmpeg.stderr.on('data', d => { recorder.ffmpegError += d.toString(); }); @@ -2544,12 +2547,21 @@ export async function stopRecording() { const duration = (Date.now() - startTime) / 1000; const stats = statSync(outputPath); + + // Preserve captions for addNarration() + lastCaptions = recorder.captions || []; + if (lastCaptions.length) { + const captionsPath = outputPath.replace(/\.[^.]+$/, '.captions.json'); + writeFileSync(captionsPath, JSON.stringify(lastCaptions, 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 +2574,17 @@ 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; + recorder.captions.push({ text, speech, time: Date.now() - recorder.startTime }); + } const position = opts.position || 'bottom'; const fontSize = opts.fontSize || 24; const bg = opts.background || 'rgba(0,0,0,0.7)'; @@ -2600,6 +2620,168 @@ 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 = opts.ffmpegPath || findFfmpeg(); + 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; + if (!captions || !captions.length) { + captions = lastCaptions.length ? [...lastCaptions] : null; + } + if (!captions || !captions.length) { + const captionsJsonPath = videoPath.replace(/\.[^.]+$/, '.captions.json'); + if (fsExistsSync(captionsJsonPath)) { + captions = JSON.parse(readFileSync(captionsJsonPath, 'utf-8')); + } + } + if (!captions || !captions.length) { + throw new Error('No captions available. Record with showCaption() first, or pass opts.captions.'); + } + + // 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 = 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}`); + // Generate 1s silence as placeholder + generateSilence(ttsFile, 1, ffmpegPath); + } + } + return ttsFile; + }); + const results = await Promise.all(promises); + ttsFiles.push(...results); + } + + // Phase 2: Build timeline — interleave silence gaps and TTS segments + const segments = []; // { file, type: 'silence'|'tts' } + + for (let i = 0; i < captions.length; i++) { + const captionTimeMs = captions[i].time; + const ttsFile = ttsFiles[i]; + const ttsDuration = getAudioDuration(ttsFile, ffmpegPath); + + // Calculate gap before this caption + let gapStart; + if (i === 0) { + gapStart = 0; + } else { + const prevCaptionTimeMs = captions[i - 1].time; + const prevTtsDuration = getAudioDuration(ttsFiles[i - 1], ffmpegPath); + gapStart = prevCaptionTimeMs / 1000 + prevTtsDuration; + } + const gapDuration = captionTimeMs / 1000 - gapStart; + + if (gapDuration > 0.05) { + const silenceFile = pathJoin(tempDir, `silence_${i}.mp3`); + generateSilence(silenceFile, gapDuration, ffmpegPath); + segments.push({ file: silenceFile, type: 'silence' }); + } + + // Check if TTS audio is longer than gap to next caption — trim if needed + if (i < captions.length - 1) { + const nextTimeMs = captions[i + 1].time; + const maxDuration = (nextTimeMs - captionTimeMs) / 1000; + if (ttsDuration > maxDuration && maxDuration > 0.1) { + const trimmedFile = pathJoin(tempDir, `tts_${i}_trimmed.mp3`); + execFileSync(ffmpegPath, [ + '-y', '-i', ttsFile, '-t', String(maxDuration), + '-c:a', 'copy', trimmedFile, + ], { stdio: 'pipe', timeout: 10000 }); + segments.push({ file: trimmedFile, type: 'tts' }); + continue; + } + } + + segments.push({ file: ttsFile, type: 'tts' }); + } + + // Phase 3: Concat all segments into a single narration track + const concatListPath = pathJoin(tempDir, 'concat.txt'); + const concatContent = segments.map(s => `file '${s.file.replace(/\\/g, '/')}'`).join('\n'); + writeFileSync(concatListPath, concatContent, 'utf-8'); + + const narrationPath = pathJoin(tempDir, 'narration.mp3'); + execFileSync(ffmpegPath, [ + '-y', '-f', 'concat', '-safe', '0', '-i', concatListPath, + '-c:a', 'libmp3lame', '-b:a', '128k', narrationPath, + ], { stdio: 'pipe', timeout: 60000 }); + + // 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 +3056,86 @@ function resolveFfmpeg(explicit) { ); } +// ── TTS providers ────────────────────────────────────────────────────────── + +/** + * 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 import('node-edge-tts'); + 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); +} + +/** Get TTS provider function by name. */ +function getTtsProvider(name) { + switch (name) { + case 'openai': return openaiTtsProvider; + 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.'); From 6ce36f7d9b5f9e54d853ac81a63d71525bb2517c Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Mon, 2 Mar 2026 23:29:56 +0300 Subject: [PATCH 02/14] fix(web-test): resolve node-edge-tts from tools/node_modules Dynamic import needs file:// URL on Windows and explicit entry point for ESM resolution. Package installed in tools/ alongside ffmpeg. Co-Authored-By: Claude Opus 4.6 --- .claude/skills/web-test/scripts/browser.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index ff4d6d9c..079da284 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -11,7 +11,7 @@ import { spawn, execFileSync } from 'child_process'; 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 } from 'url'; +import { fileURLToPath, pathToFileURL } from 'url'; import { readSectionsScript, readTabsScript, readCommandsScript, readFormScript, navigateSectionScript, openCommandScript, @@ -3065,7 +3065,10 @@ function resolveFfmpeg(explicit) { * @param {object} opts — { voice } */ async function edgeTtsProvider(text, outputPath, opts = {}) { - const { EdgeTTS } = await import('node-edge-tts'); + // Resolve from tools/node_modules/ (next to ffmpeg) + const __fn = fileURLToPath(import.meta.url); + const ttsModulePath = pathResolve(dirname(__fn), '..', '..', '..', '..', 'tools', 'node_modules', 'node-edge-tts', 'dist', 'edge-tts.js'); + const { EdgeTTS } = await import(pathToFileURL(ttsModulePath).href); const voice = opts.voice || 'ru-RU-DmitryNeural'; const tts = new EdgeTTS({ voice }); await Promise.race([ From e9b53505ac01967975616ea9b77af16b02a55cb1 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Mon, 2 Mar 2026 23:46:50 +0300 Subject: [PATCH 03/14] fix(web-test): move TTS to tools/tts/, use atempo instead of trimming - node-edge-tts installed in tools/tts/ (alongside tools/ffmpeg/) - Speed up TTS with ffmpeg atempo when it exceeds gap to next caption, instead of hard-cutting the audio Co-Authored-By: Claude Opus 4.6 --- .claude/skills/web-test/scripts/browser.mjs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 079da284..4de0b189 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -2727,17 +2727,18 @@ export async function addNarration(videoPath, opts = {}) { segments.push({ file: silenceFile, type: 'silence' }); } - // Check if TTS audio is longer than gap to next caption — trim if needed + // Speed up TTS if it's longer than gap to next caption (instead of trimming) if (i < captions.length - 1) { const nextTimeMs = captions[i + 1].time; const maxDuration = (nextTimeMs - captionTimeMs) / 1000; if (ttsDuration > maxDuration && maxDuration > 0.1) { - const trimmedFile = pathJoin(tempDir, `tts_${i}_trimmed.mp3`); + const tempo = ttsDuration / maxDuration; + const spedFile = pathJoin(tempDir, `tts_${i}_sped.mp3`); execFileSync(ffmpegPath, [ - '-y', '-i', ttsFile, '-t', String(maxDuration), - '-c:a', 'copy', trimmedFile, + '-y', '-i', ttsFile, '-af', `atempo=${tempo.toFixed(4)}`, + '-c:a', 'libmp3lame', '-b:a', '128k', spedFile, ], { stdio: 'pipe', timeout: 10000 }); - segments.push({ file: trimmedFile, type: 'tts' }); + segments.push({ file: spedFile, type: 'tts' }); continue; } } @@ -3067,7 +3068,7 @@ function resolveFfmpeg(explicit) { async function edgeTtsProvider(text, outputPath, opts = {}) { // Resolve from tools/node_modules/ (next to ffmpeg) const __fn = fileURLToPath(import.meta.url); - const ttsModulePath = pathResolve(dirname(__fn), '..', '..', '..', '..', 'tools', 'node_modules', 'node-edge-tts', 'dist', 'edge-tts.js'); + const ttsModulePath = pathResolve(dirname(__fn), '..', '..', '..', '..', 'tools', 'tts', 'node_modules', 'node-edge-tts', 'dist', 'edge-tts.js'); const { EdgeTTS } = await import(pathToFileURL(ttsModulePath).href); const voice = opts.voice || 'ru-RU-DmitryNeural'; const tts = new EdgeTTS({ voice }); From 7f6ea32533b31f4d36c29fa1f63ee9d8a5da9e1a Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 3 Mar 2026 00:03:16 +0300 Subject: [PATCH 04/14] refactor(web-test): add resolveEdgeTts() with fallback chain Mirrors resolveFfmpeg() pattern: tries global/project-level import first, then tools/tts/node_modules/, then throws error with install instructions. Caches resolved module for subsequent calls. Co-Authored-By: Claude Opus 4.6 --- .claude/skills/web-test/recording.md | 2 +- .claude/skills/web-test/scripts/browser.mjs | 35 ++++++++++++++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/.claude/skills/web-test/recording.md b/.claude/skills/web-test/recording.md index 78e4b441..07a69b05 100644 --- a/.claude/skills/web-test/recording.md +++ b/.claude/skills/web-test/recording.md @@ -191,7 +191,7 @@ Add voiceover to recorded videos. Captions shown via `showCaption()` are automat ### Prerequisites - **ffmpeg** — same as for video recording (ffprobe must be next to ffmpeg) -- **node-edge-tts** — `npm install node-edge-tts` (for Edge TTS provider, free, no API key) +- **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` diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 4de0b189..53ec9b58 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -3059,6 +3059,36 @@ 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 @@ -3066,10 +3096,7 @@ function resolveFfmpeg(explicit) { * @param {object} opts — { voice } */ async function edgeTtsProvider(text, outputPath, opts = {}) { - // Resolve from tools/node_modules/ (next to ffmpeg) - const __fn = fileURLToPath(import.meta.url); - const ttsModulePath = pathResolve(dirname(__fn), '..', '..', '..', '..', 'tools', 'tts', 'node_modules', 'node-edge-tts', 'dist', 'edge-tts.js'); - const { EdgeTTS } = await import(pathToFileURL(ttsModulePath).href); + const { EdgeTTS } = await resolveEdgeTts(); const voice = opts.voice || 'ru-RU-DmitryNeural'; const tts = new EdgeTTS({ voice }); await Promise.race([ From 5acb586bbfaf1265fa0156693e6d5325a8190d2c Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 3 Mar 2026 00:45:30 +0300 Subject: [PATCH 05/14] fix(web-test): fix audio-video sync drift in narration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screencast frame duplication (Math.round) causes video to be ~5% longer than wall-clock time. Caption timestamps are wall-clock based, so the audio track drifted ahead by ~8s at the midpoint of a 5-minute video. Fix: - stopRecording() saves recordingDuration in captions.json - addNarration() reads actual video duration via ffprobe and scales caption timestamps by videoDuration/recordingDuration ratio - Phase 2 timeline now tracks actual cumulative position instead of computing gaps from previous caption data (prevents MP3 frame quantization drift) - Also fixed findFfmpeg() → resolveFfmpeg() call in addNarration Co-Authored-By: Claude Opus 4.6 --- .claude/skills/web-test/scripts/browser.mjs | 52 +++++++++++++-------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 53ec9b58..5cf48ef7 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -2552,7 +2552,8 @@ export async function stopRecording() { lastCaptions = recorder.captions || []; if (lastCaptions.length) { const captionsPath = outputPath.replace(/\.[^.]+$/, '.captions.json'); - writeFileSync(captionsPath, JSON.stringify(lastCaptions, null, 2), 'utf-8'); + const captionsData = { recordingDuration: duration, captions: lastCaptions }; + writeFileSync(captionsPath, JSON.stringify(captionsData, null, 2), 'utf-8'); } recorder = null; @@ -2645,25 +2646,43 @@ export function getCaptions() { * @returns {{ file: string, duration: number, size: number, captions: number, warnings?: string[] }} */ export async function addNarration(videoPath, opts = {}) { - const ffmpegPath = opts.ffmpegPath || findFfmpeg(); + 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 recordingDuration = null; // wall-clock duration of the recording (seconds) if (!captions || !captions.length) { captions = lastCaptions.length ? [...lastCaptions] : null; } if (!captions || !captions.length) { const captionsJsonPath = videoPath.replace(/\.[^.]+$/, '.captions.json'); if (fsExistsSync(captionsJsonPath)) { - captions = JSON.parse(readFileSync(captionsJsonPath, 'utf-8')); + const raw = JSON.parse(readFileSync(captionsJsonPath, 'utf-8')); + // Support both formats: array (old) and { recordingDuration, captions } (new) + if (Array.isArray(raw)) { + captions = raw; + } else { + captions = raw.captions; + recordingDuration = raw.recordingDuration || null; + } } } if (!captions || !captions.length) { throw new Error('No captions available. Record with showCaption() first, or pass opts.captions.'); } + // Scale caption timestamps to match actual video duration + // (screencast frame duplication can cause video to be longer than wall-clock time) + const videoDuration = getAudioDuration(videoPath, ffmpegPath); + if (recordingDuration && recordingDuration > 0) { + const timeScale = videoDuration / recordingDuration; + if (Math.abs(timeScale - 1) > 0.005) { // only scale if >0.5% difference + captions = captions.map(c => ({ ...c, time: Math.round(c.time * timeScale) })); + } + } + // Output path const ext = extname(videoPath); const base = videoPath.slice(0, -ext.length); @@ -2703,34 +2722,27 @@ export async function addNarration(videoPath, opts = {}) { } // Phase 2: Build timeline — interleave silence gaps and TTS segments + // Track actual accumulated position to prevent drift from MP3 frame quantization const segments = []; // { file, type: 'silence'|'tts' } + let currentPosition = 0; // actual accumulated duration in seconds for (let i = 0; i < captions.length; i++) { - const captionTimeMs = captions[i].time; + const captionTimeSec = captions[i].time / 1000; const ttsFile = ttsFiles[i]; const ttsDuration = getAudioDuration(ttsFile, ffmpegPath); - // Calculate gap before this caption - let gapStart; - if (i === 0) { - gapStart = 0; - } else { - const prevCaptionTimeMs = captions[i - 1].time; - const prevTtsDuration = getAudioDuration(ttsFiles[i - 1], ffmpegPath); - gapStart = prevCaptionTimeMs / 1000 + prevTtsDuration; - } - const gapDuration = captionTimeMs / 1000 - gapStart; - - if (gapDuration > 0.05) { + // Add silence to reach this caption's target timestamp + const silenceDuration = captionTimeSec - currentPosition; + if (silenceDuration > 0.05) { const silenceFile = pathJoin(tempDir, `silence_${i}.mp3`); - generateSilence(silenceFile, gapDuration, ffmpegPath); + generateSilence(silenceFile, silenceDuration, ffmpegPath); segments.push({ file: silenceFile, type: 'silence' }); + currentPosition += getAudioDuration(silenceFile, ffmpegPath); } // Speed up TTS if it's longer than gap to next caption (instead of trimming) if (i < captions.length - 1) { - const nextTimeMs = captions[i + 1].time; - const maxDuration = (nextTimeMs - captionTimeMs) / 1000; + const maxDuration = (captions[i + 1].time - captions[i].time) / 1000; if (ttsDuration > maxDuration && maxDuration > 0.1) { const tempo = ttsDuration / maxDuration; const spedFile = pathJoin(tempDir, `tts_${i}_sped.mp3`); @@ -2739,11 +2751,13 @@ export async function addNarration(videoPath, opts = {}) { '-c:a', 'libmp3lame', '-b:a', '128k', spedFile, ], { stdio: 'pipe', timeout: 10000 }); segments.push({ file: spedFile, type: 'tts' }); + currentPosition += getAudioDuration(spedFile, ffmpegPath); continue; } } segments.push({ file: ttsFile, type: 'tts' }); + currentPosition += ttsDuration; } // Phase 3: Concat all segments into a single narration track From 050d42a4578c9d772605e245ed7c111b1bc723a8 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 3 Mar 2026 10:13:32 +0300 Subject: [PATCH 06/14] fix(web-test): use video-time timestamps for precise TTS sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caption timestamps now use actual video timeline position (frame counter) instead of wall-clock time, eliminating sync drift from non-uniform frame duplication in CDP screencast recordings. Also replace silence-file concatenation with adelay+amix for sample-accurate TTS placement, and fix exec timeout for long scenarios (fetch → http.request with 10min timeout). Co-Authored-By: Claude Opus 4.6 --- .claude/skills/web-test/scripts/browser.mjs | 93 ++++++++++++--------- .claude/skills/web-test/scripts/run.mjs | 17 +++- 2 files changed, 65 insertions(+), 45 deletions(-) diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 5cf48ef7..32f6664f 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -27,6 +27,7 @@ let sessionPrefix = null; // e.g. "http://localhost:8081/bpdemo/ru_RU" let seanceId = null; 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; @@ -2439,6 +2440,7 @@ 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; @@ -2483,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; @@ -2506,7 +2513,7 @@ export async function startRecording(outputPath, opts = {}) { everyNthFrame: 1 }); - recorder = { cdp, ffmpeg, startTime: Date.now(), outputPath: resolvedPath, ffmpegError: '', captions: [] }; + 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(); }); @@ -2550,9 +2557,10 @@ export async function stopRecording() { // Preserve captions for addNarration() lastCaptions = recorder.captions || []; + lastRecordingDuration = duration; if (lastCaptions.length) { const captionsPath = outputPath.replace(/\.[^.]+$/, '.captions.json'); - const captionsData = { recordingDuration: duration, captions: lastCaptions }; + const captionsData = { recordingDuration: duration, videoTimestamps: true, captions: lastCaptions }; writeFileSync(captionsPath, JSON.stringify(captionsData, null, 2), 'utf-8'); } @@ -2584,7 +2592,8 @@ export async function showCaption(text, opts = {}) { // Collect caption for TTS narration if recording if (recorder && text.trim() && opts.speech !== false) { const speech = typeof opts.speech === 'string' ? opts.speech : text; - recorder.captions.push({ text, speech, time: Date.now() - recorder.startTime }); + // 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; @@ -2652,19 +2661,26 @@ export async function addNarration(videoPath, opts = {}) { // Resolve captions: explicit > lastCaptions > .captions.json let captions = opts.captions; - let recordingDuration = null; // wall-clock duration of the recording (seconds) + 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) { - captions = lastCaptions.length ? [...lastCaptions] : null; + 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 both formats: array (old) and { recordingDuration, captions } (new) + // 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; } } @@ -2673,12 +2689,13 @@ export async function addNarration(videoPath, opts = {}) { throw new Error('No captions available. Record with showCaption() first, or pass opts.captions.'); } - // Scale caption timestamps to match actual video duration - // (screencast frame duplication can cause video to be longer than wall-clock time) const videoDuration = getAudioDuration(videoPath, ffmpegPath); - if (recordingDuration && recordingDuration > 0) { + + // 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) { // only scale if >0.5% difference + if (Math.abs(timeScale - 1) > 0.005) { captions = captions.map(c => ({ ...c, time: Math.round(c.time * timeScale) })); } } @@ -2721,55 +2738,49 @@ export async function addNarration(videoPath, opts = {}) { ttsFiles.push(...results); } - // Phase 2: Build timeline — interleave silence gaps and TTS segments - // Track actual accumulated position to prevent drift from MP3 frame quantization - const segments = []; // { file, type: 'silence'|'tts' } - let currentPosition = 0; // actual accumulated duration in seconds + // 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 captionTimeSec = captions[i].time / 1000; + const captionTimeMs = Math.round(captions[i].time); const ttsFile = ttsFiles[i]; const ttsDuration = getAudioDuration(ttsFile, ffmpegPath); - // Add silence to reach this caption's target timestamp - const silenceDuration = captionTimeSec - currentPosition; - if (silenceDuration > 0.05) { - const silenceFile = pathJoin(tempDir, `silence_${i}.mp3`); - generateSilence(silenceFile, silenceDuration, ffmpegPath); - segments.push({ file: silenceFile, type: 'silence' }); - currentPosition += getAudioDuration(silenceFile, ffmpegPath); - } + ffmpegInputs.push('-i', ttsFile); + const filters = []; - // Speed up TTS if it's longer than gap to next caption (instead of trimming) + // 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 = ttsDuration / maxDuration; - const spedFile = pathJoin(tempDir, `tts_${i}_sped.mp3`); - execFileSync(ffmpegPath, [ - '-y', '-i', ttsFile, '-af', `atempo=${tempo.toFixed(4)}`, - '-c:a', 'libmp3lame', '-b:a', '128k', spedFile, - ], { stdio: 'pipe', timeout: 10000 }); - segments.push({ file: spedFile, type: 'tts' }); - currentPosition += getAudioDuration(spedFile, ffmpegPath); - continue; + const tempo = Math.min(ttsDuration / maxDuration, 2.5); + filters.push(`atempo=${tempo.toFixed(4)}`); } } - segments.push({ file: ttsFile, type: 'tts' }); - currentPosition += ttsDuration; + // 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}]`); } - // Phase 3: Concat all segments into a single narration track - const concatListPath = pathJoin(tempDir, 'concat.txt'); - const concatContent = segments.map(s => `file '${s.file.replace(/\\/g, '/')}'`).join('\n'); - writeFileSync(concatListPath, concatContent, 'utf-8'); + const filterComplex = filterParts.join(';') + ';' + + mixLabels.join('') + `amix=inputs=${captions.length}:normalize=0`; const narrationPath = pathJoin(tempDir, 'narration.mp3'); execFileSync(ffmpegPath, [ - '-y', '-f', 'concat', '-safe', '0', '-i', concatListPath, + '-y', ...ffmpegInputs, + '-filter_complex', filterComplex, + '-t', String(Math.ceil(videoDuration)), '-c:a', 'libmp3lame', '-b:a', '128k', narrationPath, - ], { stdio: 'pipe', timeout: 60000 }); + ], { stdio: 'pipe', timeout: 120000 }); // Phase 4: Merge video + narration audio execFileSync(ffmpegPath, [ 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); } From 37d2a5ee15f3bd015314b67848d366597fddcbc0 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 3 Mar 2026 10:58:27 +0300 Subject: [PATCH 07/14] docs(web-test): add video recording guide for users Co-Authored-By: Claude Opus 4.6 --- docs/web-test-guide.md | 1 + docs/web-test-recording-guide.md | 266 +++++++++++++++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 docs/web-test-recording-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..ddc425ed --- /dev/null +++ b/docs/web-test-recording-guide.md @@ -0,0 +1,266 @@ +# Запись видеоинструкций + +Навык `/web-test` умеет записывать видеоинструкции по работе в 1С: автоматические действия в браузере записываются в MP4 с субтитрами, подсветкой элементов и голосовой озвучкой. Результат — готовое обучающее видео. + +``` +сценарий → запись экрана → субтитры → подсветка → озвучка голосом → MP4 +``` + +## Предусловия + +### 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 (бесплатный) + +| Голос | Описание | +|-------|----------| +| `ru-RU-DmitryNeural` | Мужской, русский | +| `ru-RU-SvetlanaNeural` | Женский, русский | + +Полный список: `en-US-AriaNeural`, `en-US-GuyNeural`, `de-DE-ConradNeural` и другие. Edge TTS поддерживает десятки языков. + +### OpenAI-compatible (платный) + +Конфигурация в `.v8-project.json`: + +```json +{ + "tts": { + "provider": "openai", + "apiKey": "sk-...", + "voice": "alloy" + } +} +``` + +Голоса: `alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer`. + +## Полный пример + +Типовая структура озвученного сценария: + +```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` From a8d80078464795576413252e8ebfeb7342680c39 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 3 Mar 2026 11:00:03 +0300 Subject: [PATCH 08/14] docs(web-test): update stopRecording return type in recording.md Co-Authored-By: Claude Opus 4.6 --- .claude/skills/web-test/recording.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.claude/skills/web-test/recording.md b/.claude/skills/web-test/recording.md index 07a69b05..c7232d83 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 From 5bccca24752e6eab7e411ed6886ddc7bbff8928b Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 3 Mar 2026 11:15:36 +0300 Subject: [PATCH 09/14] feat(web-test): add ElevenLabs TTS provider Co-Authored-By: Claude Opus 4.6 --- .claude/skills/web-test/recording.md | 14 +++++++++++- .claude/skills/web-test/scripts/browser.mjs | 24 +++++++++++++++++++++ docs/web-test-recording-guide.md | 18 ++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/.claude/skills/web-test/recording.md b/.claude/skills/web-test/recording.md index c7232d83..91f16eda 100644 --- a/.claude/skills/web-test/recording.md +++ b/.claude/skills/web-test/recording.md @@ -216,6 +216,18 @@ For OpenAI-compatible provider: } ``` +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): @@ -236,7 +248,7 @@ Generate TTS and merge audio with video. Call after `stopRecording()`. |-----------|------|-------------| | `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) or `'openai'` | +| `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) | diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 32f6664f..d6e464c0 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -3154,10 +3154,34 @@ async function openaiTtsProvider(text, outputPath, opts = {}) { 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; } } diff --git a/docs/web-test-recording-guide.md b/docs/web-test-recording-guide.md index ddc425ed..95ddebf2 100644 --- a/docs/web-test-recording-guide.md +++ b/docs/web-test-recording-guide.md @@ -225,6 +225,24 @@ await showCaption('Технические детали', { speech: false }); Голоса: `alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer`. +Поле `apiUrl` позволяет подключить любой OpenAI-совместимый сервис. + +### ElevenLabs (платный) + +Высокое качество синтеза, мультиязычная модель поддерживает русский. + +```json +{ + "tts": { + "provider": "elevenlabs", + "apiKey": "sk_...", + "voice": "JBFqnCBsd6RMkjVDRZzb" + } +} +``` + +`voice` — это ID голоса из библиотеки ElevenLabs (не имя). Список голосов — в личном кабинете ElevenLabs. Требуется платный тариф для использования через API. + ## Полный пример Типовая структура озвученного сценария: From e23a235ffaf137a16458cb74b9ea8332d32ed970 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 3 Mar 2026 12:10:01 +0300 Subject: [PATCH 10/14] fix(web-test): limit ElevenLabs TTS concurrency to 2 and improve error logging Co-Authored-By: Claude Opus 4.6 --- .claude/skills/web-test/scripts/browser.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index d6e464c0..a51fbc86 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -2714,7 +2714,7 @@ export async function addNarration(videoPath, opts = {}) { try { // Phase 1: Generate TTS audio for each caption const ttsFiles = []; - const BATCH_SIZE = 5; + 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) => { @@ -2727,7 +2727,7 @@ export async function addNarration(videoPath, opts = {}) { try { await ttsProvider(cap.speech, ttsFile, ttsOpts); } catch (retryErr) { - warnings.push(`TTS failed for caption ${idx}: ${retryErr.message}`); + warnings.push(`TTS failed for caption ${idx}: ${retryErr.message || retryErr.cause?.message || String(retryErr)}`); // Generate 1s silence as placeholder generateSilence(ttsFile, 1, ffmpegPath); } From 9e022c60f9092b8fd3c140cf06e1017793bf4c2e Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 3 Mar 2026 12:19:23 +0300 Subject: [PATCH 11/14] docs(web-test): add TTS provider recommendations to recording guide Edge TTS recommended for Russian (better stress, intonation, semantic accents), ElevenLabs for English/multilingual content (less robotic timbre). Co-Authored-By: Claude Opus 4.6 --- docs/web-test-recording-guide.md | 66 +++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 23 deletions(-) diff --git a/docs/web-test-recording-guide.md b/docs/web-test-recording-guide.md index 95ddebf2..59f3f555 100644 --- a/docs/web-test-recording-guide.md +++ b/docs/web-test-recording-guide.md @@ -198,20 +198,56 @@ await showCaption('Технические детали', { speech: false }); - **Технических данных** — показать, но не зачитывать - **Информационных плашек** — немой субтитр на несколько секунд -## Доступные голоса +## Доступные голоса и провайдеры -### Edge TTS (бесплатный) +### Какой провайдер выбрать? + +| Контент | Рекомендация | Почему | +|---------|-------------|--------| +| **Русскоязычный** | Edge TTS (DmitryNeural / SvetlanaNeural) | Правильные ударения, естественная интонация, верные смысловые акценты | +| **Англоязычный / мультиязычный** | ElevenLabs | Менее «роботизированный» тембр, хорошая мультиязычная модель | +| **OpenAI-совместимый сервис** | OpenAI | Если уже есть API-ключ или свой TTS-сервер | + +**Для русскоязычных видеоинструкций рекомендуется Edge TTS** — он бесплатный и даёт лучшее качество русской речи. Голоса DmitryNeural и SvetlanaNeural специально обучены для русского языка: правильно расставляют ударения, делают паузы в нужных местах и выделяют ключевые слова. ElevenLabs при озвучке русского текста звучит «через английский» — тембр приятнее, но акцент и просодика хуже. + +### Edge TTS (бесплатный) — рекомендуется для русского | Голос | Описание | |-------|----------| -| `ru-RU-DmitryNeural` | Мужской, русский | -| `ru-RU-SvetlanaNeural` | Женский, русский | +| `ru-RU-DmitryNeural` | Мужской, русский — спокойный, деловой | +| `ru-RU-SvetlanaNeural` | Женский, русский — чёткий, уверенный | Полный список: `en-US-AriaNeural`, `en-US-GuyNeural`, `de-DE-ConradNeural` и другие. Edge TTS поддерживает десятки языков. -### OpenAI-compatible (платный) +Конфигурация не нужна — Edge TTS используется по умолчанию. Для смены голоса: -Конфигурация в `.v8-project.json`: +```json +{ + "tts": { + "voice": "ru-RU-SvetlanaNeural" + } +} +``` + +### ElevenLabs (платный) — для английского и мультиязычного контента + +Модель `eleven_multilingual_v2` поддерживает русский, но лучшие результаты — на английском. Тембр менее синтетический, чем у Edge TTS. + +```json +{ + "tts": { + "provider": "elevenlabs", + "apiKey": "sk_...", + "voice": "onwK4e9ZLuTAKqWW03F9" + } +} +``` + +`voice` — ID голоса из библиотеки ElevenLabs (не имя). Список голосов — в личном кабинете. Требуется платный тариф (starter и выше). + +Особенности: лимит на параллельные запросы (2–3 одновременно), система автоматически ограничивает размер пакета. + +### OpenAI-compatible (платный) ```json { @@ -225,23 +261,7 @@ await showCaption('Технические детали', { speech: false }); Голоса: `alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer`. -Поле `apiUrl` позволяет подключить любой OpenAI-совместимый сервис. - -### ElevenLabs (платный) - -Высокое качество синтеза, мультиязычная модель поддерживает русский. - -```json -{ - "tts": { - "provider": "elevenlabs", - "apiKey": "sk_...", - "voice": "JBFqnCBsd6RMkjVDRZzb" - } -} -``` - -`voice` — это ID голоса из библиотеки ElevenLabs (не имя). Список голосов — в личном кабинете ElevenLabs. Требуется платный тариф для использования через API. +Поле `apiUrl` позволяет подключить любой OpenAI-совместимый сервис (например, локальный TTS-прокси). ## Полный пример From 6505fb1986e329e4efaa10d15773ae5eb16497cd Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 3 Mar 2026 12:55:12 +0300 Subject: [PATCH 12/14] docs(web-test): update TTS provider recommendations with ElevenLabs findings Recommend professional voices with educational/business profile (Olga, Artem, Denis) for ElevenLabs Russian content. Note articulation issues with multilingual model on Russian terminology (stress, vowel quality). Co-Authored-By: Claude Opus 4.6 --- docs/web-test-recording-guide.md | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/docs/web-test-recording-guide.md b/docs/web-test-recording-guide.md index 59f3f555..cf94afc1 100644 --- a/docs/web-test-recording-guide.md +++ b/docs/web-test-recording-guide.md @@ -202,13 +202,15 @@ await showCaption('Технические детали', { speech: false }); ### Какой провайдер выбрать? -| Контент | Рекомендация | Почему | -|---------|-------------|--------| -| **Русскоязычный** | Edge TTS (DmitryNeural / SvetlanaNeural) | Правильные ударения, естественная интонация, верные смысловые акценты | -| **Англоязычный / мультиязычный** | ElevenLabs | Менее «роботизированный» тембр, хорошая мультиязычная модель | -| **OpenAI-совместимый сервис** | OpenAI | Если уже есть API-ключ или свой TTS-сервер | +| Провайдер | Тембр | Произношение русского | Цена | +|-----------|-------|----------------------|------| +| **Edge TTS** | Синтетичнее | Корректные ударения, правильная артикуляция | Бесплатно | +| **ElevenLabs** | Живее, естественнее | Возможны ошибки в ударениях и артикуляции (напр. «докумЭнт», «крЕдит» вместо «кредИт») | Платно (starter+) | +| **OpenAI** | Зависит от голоса | Зависит от сервиса | Платно | -**Для русскоязычных видеоинструкций рекомендуется Edge TTS** — он бесплатный и даёт лучшее качество русской речи. Голоса DmitryNeural и SvetlanaNeural специально обучены для русского языка: правильно расставляют ударения, делают паузы в нужных местах и выделяют ключевые слова. ElevenLabs при озвучке русского текста звучит «через английский» — тембр приятнее, но акцент и просодика хуже. +**Для русскоязычных видеоинструкций рекомендуется Edge TTS** — он бесплатный и даёт надёжное качество русской речи. Голоса DmitryNeural и SvetlanaNeural специально обучены для русского языка: правильно расставляют ударения, корректно артикулируют и делают паузы в нужных местах. + +**ElevenLabs** даёт более живой, «человечный» тембр — голос звучит менее синтетически. Однако мультиязычная модель иногда ошибается в произношении русских слов (особенно профессиональная терминология). Если выбираете ElevenLabs для русского контента — берите **professional-голоса** с образовательным или деловым профилем (например, Olga Orlova, Artem), они дают лучший результат, чем англоязычные premade-голоса через мультиязычную модель. Управлять ударениями через API нельзя — фонемные теги (SSML) поддерживаются только для английских моделей. ### Edge TTS (бесплатный) — рекомендуется для русского @@ -229,21 +231,29 @@ await showCaption('Технические детали', { speech: false }); } ``` -### ElevenLabs (платный) — для английского и мультиязычного контента +### ElevenLabs (платный) — живой тембр -Модель `eleven_multilingual_v2` поддерживает русский, но лучшие результаты — на английском. Тембр менее синтетический, чем у Edge TTS. +Модель `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": "onwK4e9ZLuTAKqWW03F9" + "voice": "d60rsXo2p0OwikDR5bS7" } } ``` -`voice` — ID голоса из библиотеки ElevenLabs (не имя). Список голосов — в личном кабинете. Требуется платный тариф (starter и выше). +`voice` — ID голоса (не имя). Professional-голоса добавляются в аккаунт через Voice Library в личном кабинете. Требуется платный тариф (starter и выше). Особенности: лимит на параллельные запросы (2–3 одновременно), система автоматически ограничивает размер пакета. From 01315839fb6df8c4e66489859fe713c1621ef062 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 3 Mar 2026 13:14:08 +0300 Subject: [PATCH 13/14] docs: add .v8-project.json reference guide Consolidate project config documentation into a single guide covering all fields (v8path, databases, webPath, ffmpegPath, tts, webUrl), with "who fills" column and resolution algorithm. Replace duplicated format sections in db-guide.md and web-guide.md with links to the new guide. Co-Authored-By: Claude Opus 4.6 --- README.md | 3 + docs/db-guide.md | 48 +------- docs/v8-project-guide.md | 191 +++++++++++++++++++++++++++++++ docs/web-guide.md | 21 +--- docs/web-test-recording-guide.md | 2 + 5 files changed, 199 insertions(+), 66 deletions(-) create mode 100644 docs/v8-project-guide.md 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..80dd7984 --- /dev/null +++ b/docs/v8-project-guide.md @@ -0,0 +1,191 @@ +# Конфигурация проекта (.v8-project.json) + +Файл `.v8-project.json` — единый конфиг проекта для всех навыков Claude Code. Хранит пути к платформе 1С, список баз данных и настройки инструментов (Apache, ffmpeg, TTS). + +Размещается в корне проекта (рядом с `.git/`). Создаётся навыком `/db-list add` или вручную. + +## Полная схема + +```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-recording-guide.md b/docs/web-test-recording-guide.md index cf94afc1..80a18eaf 100644 --- a/docs/web-test-recording-guide.md +++ b/docs/web-test-recording-guide.md @@ -8,6 +8,8 @@ ## Предусловия +Все пути и настройки хранятся в `.v8-project.json` — см. [справочник формата](v8-project-guide.md). + ### ffmpeg (обязательно) Выберите один из вариантов: From 09fc3a7f43105f032185fefc35bb6f1544ca4271 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 3 Mar 2026 13:16:45 +0300 Subject: [PATCH 14/14] docs(v8-project): note that file is in .gitignore due to secrets Co-Authored-By: Claude Opus 4.6 --- docs/v8-project-guide.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/v8-project-guide.md b/docs/v8-project-guide.md index 80dd7984..7fd7e70b 100644 --- a/docs/v8-project-guide.md +++ b/docs/v8-project-guide.md @@ -4,6 +4,8 @@ Размещается в корне проекта (рядом с `.git/`). Создаётся навыком `/db-list add` или вручную. +> **Безопасность**: файл содержит секреты (пароли баз данных, API-ключи TTS) и добавлен в `.gitignore` — он не попадает в репозиторий. Каждый разработчик заводит свой `.v8-project.json` локально. + ## Полная схема ```jsonc