diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs
index c62d2f97..1cb965fe 100644
--- a/.claude/skills/web-test/scripts/browser.mjs
+++ b/.claude/skills/web-test/scripts/browser.mjs
@@ -5049,1200 +5049,20 @@ export async function unfilterList({ field } = {}) {
return state;
}
-/** Take a screenshot. Returns PNG buffer. */
-export async function screenshot() {
- ensureConnected();
- return await page.screenshot({ type: 'png' });
-}
-
-/** Wait for a specified number of seconds. */
-export async function wait(seconds) {
- ensureConnected();
- let ms = seconds * 1000;
- // Credit system: if showCaption already waited for TTS, subtract that time
- if (recorder && recorder.captionCredit) {
- const elapsed = Date.now() - recorder.captionCredit.at;
- const credit = Math.max(0, recorder.captionCredit.waitedMs - elapsed);
- ms = Math.max(0, ms - credit);
- recorder.captionCredit = null;
- }
- if (ms > 0) {
- // During recording, split long waits into chunks and flush frames
- // to keep video timeline in sync (CDP may not send frames for static pages)
- if (recorder?._flushFrames && ms > 1000) {
- let remaining = ms;
- while (remaining > 0) {
- const chunk = Math.min(remaining, 1000);
- await page.waitForTimeout(chunk);
- remaining -= chunk;
- recorder._flushFrames();
- }
- } else {
- await page.waitForTimeout(ms);
- }
- }
- return await getFormState();
-}
-
// ============================================================
-// Video recording — CDP screencast + ffmpeg
+// Recording, captions, narration, highlight — extracted to recording/*
// ============================================================
-
-/** Check if video recording is active. */
-export function isRecording() {
- return recorder !== null;
-}
-
-/**
- * Start video recording via CDP screencast + ffmpeg.
- * Frames are captured as JPEG and piped to ffmpeg for MP4 encoding.
- * @param {string} outputPath — output .mp4 file path
- * @param {object} [opts]
- * @param {number} [opts.fps=25] — target framerate
- * @param {number} [opts.quality=80] — JPEG quality (1-100)
- * @param {string} [opts.ffmpegPath] — explicit path to ffmpeg binary
- */
-export async function startRecording(outputPath, opts = {}) {
- ensureConnected();
- if (recorder) {
- if (opts.force) {
- try { await stopRecording(); } catch {}
- } else {
- throw new Error('Already recording. Call stopRecording() first, or use { force: true }.');
- }
- }
- setLastCaptions([]);
- setLastRecordingDuration(null);
-
- const fps = opts.fps || 25;
- const quality = opts.quality || 80;
- const ffmpegPath = resolveFfmpeg(opts.ffmpegPath);
-
- // Ensure output directory exists
- const resolvedPath = resolveProjectPath(outputPath);
- mkdirSync(dirname(resolvedPath), { recursive: true });
-
- // Spawn ffmpeg process — single output file across context switches
- const ffmpeg = spawn(ffmpegPath, [
- '-y', // overwrite output
- '-f', 'image2pipe', // input: piped images
- '-framerate', String(fps), // input framerate
- '-i', '-', // read from stdin
- '-c:v', 'libx264', // H.264 codec
- '-preset', 'fast', // good quality/speed balance
- '-crf', '23', // default quality (good for screen content)
- '-vf', 'scale=in_range=full:out_range=limited', // JPEG full→H.264 limited range
- '-pix_fmt', 'yuv420p', // broad compatibility
- '-color_range', 'tv', // limited range (16-235) — standard for H.264 players
- '-movflags', '+faststart', // web-friendly MP4
- resolvedPath
- ], { stdio: ['pipe', 'ignore', 'pipe'] });
-
- ffmpeg.on('error', err => { if (recorder) recorder.ffmpegError += err.message; });
-
- const frameDuration = 1000 / fps;
- const speechRate = opts.speechRate || 70; // ms per character for smart TTS wait
-
- // Frame handler shared across CDP sessions (lives in recorder, not closure):
- // when the active context switches, we attach a new CDP session and route its
- // frames to the same ffmpeg pipe — preserving a single continuous timeline.
- const frameHandler = async ({ data, sessionId }, cdp) => {
- if (!recorder) return;
- const buf = Buffer.from(data, 'base64');
- const now = Date.now();
- if (!ffmpeg.stdin.destroyed) {
- let framesWritten = 0;
- if (recorder.lastFrameTime && recorder.lastFrameBuf) {
- const gap = now - recorder.lastFrameTime;
- const dupes = Math.round(gap / frameDuration) - 1;
- for (let i = 0; i < dupes && i < fps * 30; i++) {
- ffmpeg.stdin.write(recorder.lastFrameBuf);
- framesWritten++;
- }
- }
- ffmpeg.stdin.write(buf);
- framesWritten++;
- recorder.videoTimeMs += framesWritten * frameDuration;
- }
- recorder.lastFrameTime = now;
- recorder.lastFrameBuf = buf;
- try { await cdp.send('Page.screencastFrameAck', { sessionId }); } catch {}
- };
-
- // Duplicate the last frame to fill wall-clock gaps (static periods, context switches).
- const _flushFrames = () => {
- if (!recorder || !recorder.lastFrameBuf || !recorder.lastFrameTime || ffmpeg.stdin.destroyed) return;
- const now = Date.now();
- const gap = now - recorder.lastFrameTime;
- const dupes = Math.round(gap / frameDuration);
- for (let i = 0; i < dupes; i++) {
- ffmpeg.stdin.write(recorder.lastFrameBuf);
- recorder.videoTimeMs += frameDuration;
- }
- if (dupes > 0) recorder.lastFrameTime = now;
- };
-
- // Attach screencast to a specific page. Stops the old CDP first (if any).
- // Called by startRecording for the initial page, and by setActiveContext when
- // the active context changes mid-recording.
- const _attachPage = async (targetPage) => {
- if (recorder.cdp) {
- _flushFrames(); // freeze the last frame of the outgoing page up to "now"
- try { await recorder.cdp.send('Page.stopScreencast'); } catch {}
- try { await recorder.cdp.detach(); } catch {}
- recorder.cdp = null;
- }
- const cdp = await targetPage.context().newCDPSession(targetPage);
- cdp.on('Page.screencastFrame', (ev) => frameHandler(ev, cdp));
- await cdp.send('Page.startScreencast', { format: 'jpeg', quality, everyNthFrame: 1 });
- recorder.cdp = cdp;
- recorder.activePage = targetPage;
- };
-
- setRecorder({
- cdp: null,
- activePage: null,
- ffmpeg,
- startTime: Date.now(),
- outputPath: resolvedPath,
- ffmpegError: '',
- captions: [],
- videoTimeMs: 0,
- frameDuration,
- lastFrameTime: null,
- lastFrameBuf: null,
- _flushFrames,
- _attachPage,
- speechRate,
- });
- ffmpeg.stderr.on('data', d => { recorder.ffmpegError += d.toString(); });
-
- await _attachPage(page);
-}
-
-/**
- * Stop video recording. Finalizes the MP4 file.
- * @returns {{ file: string, duration: number, size: number }}
- */
-export async function stopRecording() {
- if (!recorder) return { file: null, duration: 0, size: 0 };
-
- const { cdp, ffmpeg, startTime, outputPath } = recorder;
-
- // Final frame flush: write remaining frames to cover the gap since the last screencast frame
- if (recorder._flushFrames) recorder._flushFrames();
-
- // Stop CDP screencast
- try { await cdp.send('Page.stopScreencast'); } catch {}
- try { await cdp.detach(); } catch {}
-
- // Close ffmpeg stdin and wait for encoding to finish
- await new Promise((resolve, reject) => {
- const timeout = setTimeout(() => {
- ffmpeg.kill('SIGKILL');
- reject(new Error('ffmpeg timed out after 30s'));
- }, 30000);
-
- ffmpeg.on('close', (code) => {
- clearTimeout(timeout);
- if (code === 0) resolve();
- else reject(new Error(`ffmpeg exited with code ${code}: ${recorder?.ffmpegError || ''}`));
- });
- ffmpeg.on('error', (err) => {
- clearTimeout(timeout);
- reject(err);
- });
-
- ffmpeg.stdin.end();
- });
-
- const duration = (Date.now() - startTime) / 1000;
- const stats = statSync(outputPath);
-
- // Preserve captions for addNarration()
- setLastCaptions(recorder.captions || []);
- setLastRecordingDuration(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');
- }
-
- setRecorder(null);
-
- return {
- file: outputPath,
- duration: Math.round(duration * 10) / 10,
- size: stats.size,
- captions: lastCaptions.length
- };
-}
-
-/**
- * Show a text caption overlay on the page (visible in recording).
- * Calling again updates the text without creating a new element.
- * @param {string} text — caption text
- * @param {object} [opts]
- * @param {'top'|'bottom'} [opts.position='bottom'] — vertical position
- * @param {number} [opts.fontSize=24] — font size in pixels
- * @param {string} [opts.background='rgba(0,0,0,0.7)'] — background color
- * @param {string} [opts.color='#fff'] — text color
- * @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
- let smartWaitMs = 0;
- if (recorder && (text.trim() || typeof opts.speech === 'string') && 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: text || speech, speech, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
- // Estimate TTS duration and wait so the video has enough screen time for voiceover
- smartWaitMs = Math.max(2000, speech.length * (recorder.speechRate || 70));
- }
- const position = opts.position || 'bottom';
- const fontSize = opts.fontSize || 24;
- const bg = opts.background || 'rgba(0,0,0,0.7)';
- const color = opts.color || '#fff';
-
- await page.evaluate(({ text, position, fontSize, bg, color }) => {
- let el = document.getElementById('__web_test_caption');
- if (!el) {
- el = document.createElement('div');
- el.id = '__web_test_caption';
- el.style.cssText = `
- position: fixed; left: 0; right: 0; z-index: 99999;
- text-align: center; padding: 12px 24px;
- font-family: Arial, sans-serif; pointer-events: none;
- `;
- document.body.appendChild(el);
- }
- el.style[position === 'top' ? 'top' : 'bottom'] = '20px';
- el.style[position === 'top' ? 'bottom' : 'top'] = 'auto';
- el.style.fontSize = fontSize + 'px';
- el.style.background = bg;
- el.style.color = color;
- el.textContent = text;
- }, { text, position, fontSize, bg, color });
-
- // Smart TTS wait: pause for estimated speech duration so video has enough screen time.
- // Split into chunks and flush frames periodically — CDP doesn't send screencast frames
- // for static pages, so we must write duplicate frames to keep video timeline in sync.
- if (smartWaitMs > 0) {
- let remaining = smartWaitMs;
- while (remaining > 0) {
- const chunk = Math.min(remaining, 1000);
- await page.waitForTimeout(chunk);
- remaining -= chunk;
- if (recorder?._flushFrames) recorder._flushFrames();
- }
- recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
- }
-}
-
-/** Remove the caption overlay from the page. */
-export async function hideCaption() {
- ensureConnected();
- await page.evaluate(() => {
- const el = document.getElementById('__web_test_caption');
- if (el) el.remove();
- });
-}
-
-/**
- * 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, voice?: string}>} [opts.captions] — explicit captions (default: from last recording or .captions.json). Each caption may include a `voice` field to override the global voice for that segment
- * @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 = {}) {
- if (!videoPath) return { file: null, duration: 0, size: 0, captions: 0 };
- videoPath = resolveProjectPath(videoPath);
- 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`);
- const capTtsOpts = cap.voice ? { ...ttsOpts, voice: cap.voice } : ttsOpts;
- try {
- await ttsProvider(cap.speech, ttsFile, capTtsOpts);
- } catch (err) {
- // Retry once
- try {
- await ttsProvider(cap.speech, ttsFile, capTtsOpts);
- } 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 slightly if it's longer than gap to next caption (max 1.3x)
- 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;
- if (tempo <= 1.3) {
- filters.push(`atempo=${tempo.toFixed(4)}`);
- } else {
- // Too fast — let audio overlap instead of distorting
- warnings.push(`Caption ${i + 1}/${captions.length}: TTS ${ttsDuration.toFixed(1)}s > gap ${maxDuration.toFixed(1)}s (need ${Math.round(ttsDuration - maxDuration)}s more pause)`);
- }
- }
- }
-
- // Delay to exact caption timestamp (milliseconds)
- if (captionTimeMs > 0) {
- filters.push(`adelay=${captionTimeMs}|${captionTimeMs}`);
- }
-
- const label = `a${i}`;
- mixLabels.push(`[${label}]`);
- // Input indices are shifted by 1 because silence reference is input [0]
- filterParts.push(`[${i + 1}]${filters.length ? filters.join(',') : 'acopy'}[${label}]`);
- }
-
- // Generate a silence reference track as input [0] so amix runs for full video duration
- const silencePath = pathJoin(tempDir, 'silence.mp3');
- generateSilence(silencePath, Math.ceil(videoDuration), ffmpegPath);
-
- const filterComplex = filterParts.join(';') + ';' +
- `[0]${mixLabels.join('')}amix=inputs=${captions.length + 1}:normalize=0:duration=first`;
-
- const narrationPath = pathJoin(tempDir, 'narration.mp3');
- execFileSync(ffmpegPath, [
- '-y', '-i', silencePath, ...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',
- '-t', String(Math.ceil(videoDuration)),
- '-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.
- * @param {string} text Title text (\n → line break)
- * @param {object} [opts]
- * @param {string} [opts.subtitle] Smaller text below the title
- * @param {string} [opts.background] CSS background (default: dark gradient)
- * @param {string} [opts.color] Text color (default: '#fff')
- * @param {number} [opts.fontSize] Title font size in px (default: 36)
- */
-export async function showTitleSlide(text, opts = {}) {
- ensureConnected();
- const {
- subtitle = '',
- background = 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)',
- color = '#fff',
- fontSize = 36,
- speech,
- } = opts;
-
- // Collect caption for TTS narration if recording
- let smartWaitMs = 0;
- if (recorder && speech && speech !== false) {
- const captionText = typeof speech === 'string' ? speech : text.replace(/\n/g, ' ');
- if (captionText) {
- recorder.captions.push({ text: captionText, speech: captionText, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
- smartWaitMs = Math.max(2000, captionText.length * (recorder.speechRate || 70));
- }
- }
-
- await page.evaluate(({ text, subtitle, background, color, fontSize }) => {
- let div = document.getElementById('__web_test_title');
- if (!div) {
- div = document.createElement('div');
- div.id = '__web_test_title';
- document.body.appendChild(div);
- }
- div.style.cssText = [
- 'position:fixed', 'top:0', 'left:0', 'width:100%', 'height:100%',
- `background:${background}`,
- 'display:flex', 'align-items:center', 'justify-content:center',
- 'z-index:999999', 'pointer-events:none',
- ].join(';');
- // Remove other overlays to prevent flash between slides
- const img = document.getElementById('__web_test_image');
- if (img) img.remove();
- const esc = s => s.replace(/&/g, '&').replace(/');
- let html = `
${esc(text)}
`;
- if (subtitle) {
- html += `${esc(subtitle)}
`;
- }
- div.innerHTML = `${html}
`;
- }, { text, subtitle, background, color, fontSize });
-
- // Smart TTS wait (same pattern as showCaption/showImage)
- if (smartWaitMs > 0) {
- let remaining = smartWaitMs;
- while (remaining > 0) {
- const chunk = Math.min(remaining, 1000);
- await page.waitForTimeout(chunk);
- remaining -= chunk;
- if (recorder?._flushFrames) recorder._flushFrames();
- }
- recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
- }
-}
-
-/** Remove the title slide overlay. */
-export async function hideTitleSlide() {
- ensureConnected();
- await page.evaluate(() => {
- const el = document.getElementById('__web_test_title');
- if (el) el.remove();
- });
-}
-
-/**
- * Show a full-screen image overlay (e.g. presentation slide screenshot).
- * Reads the image file, base64-encodes it, and renders as a fixed overlay
- * on the page — captured by CDP screencast automatically.
- *
- * Style presets:
- * - 'blur' (default) — blurred+dimmed copy as background, image centered with shadow
- * - 'dark' — dark background (#2a2a2a) with shadow
- * - 'light' — white background with shadow
- * - 'full' — image covers entire screen, no padding/shadow
- *
- * Custom background overrides the preset (e.g. background: '#003366').
- *
- * @param {string} imagePath — path to the image file (PNG, JPG, etc.)
- * @param {object} [opts]
- * @param {'blur'|'dark'|'light'|'full'} [opts.style='blur'] — display style preset
- * @param {string} [opts.background] — custom background color/gradient (overrides style preset)
- * @param {boolean} [opts.shadow] — show drop shadow (default: true for blur/dark/light, false for full)
- * @param {string|false} [opts.speech] — TTS narration text while image is shown.
- * Pass a string for narration, or false to skip. Omit to skip (no auto-text for images).
- */
-export async function showImage(imagePath, opts = {}) {
- ensureConnected();
- const style = opts.style || 'blur';
- const speech = opts.speech;
-
- // Style presets
- const presets = {
- blur: { bg: '#222', fit: 'contain', shadow: true, blur: true },
- dark: { bg: '#2a2a2a', fit: 'contain', shadow: true, blur: false },
- light: { bg: '#ffffff', fit: 'contain', shadow: true, blur: false },
- full: { bg: '#000', fit: 'contain', shadow: false, blur: false },
- };
- const preset = presets[style] || presets.blur;
-
- const bg = opts.background || preset.bg;
- const fit = preset.fit;
- const shadow = opts.shadow !== undefined ? opts.shadow : preset.shadow;
- const useBlur = opts.background ? false : preset.blur;
-
- // Read image and base64-encode
- const absPath = resolveProjectPath(imagePath);
- if (!fsExistsSync(absPath)) {
- throw new Error(`showImage: file not found: ${absPath}`);
- }
- const buf = readFileSync(absPath);
- const ext = extname(absPath).toLowerCase().replace('.', '');
- const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg'
- : ext === 'png' ? 'image/png'
- : ext === 'gif' ? 'image/gif'
- : ext === 'webp' ? 'image/webp'
- : ext === 'svg' ? 'image/svg+xml'
- : 'image/png';
- const dataUrl = `data:${mime};base64,${buf.toString('base64')}`;
-
- // Collect caption for TTS narration if recording
- let smartWaitMs = 0;
- if (recorder && speech && speech !== false) {
- const captionText = typeof speech === 'string' ? speech : '';
- if (captionText) {
- recorder.captions.push({ text: captionText, speech: captionText, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
- smartWaitMs = Math.max(2000, captionText.length * (recorder.speechRate || 70));
- }
- }
-
- // Padding: full style uses 100%, others use 92% for breathing room
- const isFull = style === 'full';
- const maxSize = isFull ? '100%' : '92%';
-
- await page.evaluate(({ dataUrl, fit, bg, useBlur, shadow, maxSize, isFull }) => {
- let div = document.getElementById('__web_test_image');
- if (!div) {
- div = document.createElement('div');
- div.id = '__web_test_image';
- document.body.appendChild(div);
- }
- // Remove other overlays to prevent flash between slides
- const title = document.getElementById('__web_test_title');
- if (title) title.remove();
-
- div.style.cssText = [
- 'position:fixed', 'top:0', 'left:0', 'width:100%', 'height:100%',
- `background:${bg}`,
- 'display:flex', 'align-items:center', 'justify-content:center',
- 'z-index:999999', 'pointer-events:none', 'overflow:hidden'
- ].join(';');
-
- let html = '';
-
- // Blurred background layer: the same image stretched to cover, blurred and dimmed
- if (useBlur) {
- html += `
`;
- }
-
- // Main image
- const shadowCss = shadow ? 'box-shadow:0 4px 40px rgba(0,0,0,0.5);' : '';
- const sizeCss = isFull
- ? `width:100%;height:100%;object-fit:${fit};`
- : `max-width:${maxSize};max-height:${maxSize};min-width:50%;min-height:50%;object-fit:${fit};`;
- html += `
`;
-
- div.innerHTML = html;
- }, { dataUrl, fit, bg, useBlur, shadow, maxSize, isFull });
-
- // Smart TTS wait (same pattern as showCaption)
- if (smartWaitMs > 0) {
- let remaining = smartWaitMs;
- while (remaining > 0) {
- const chunk = Math.min(remaining, 1000);
- await page.waitForTimeout(chunk);
- remaining -= chunk;
- if (recorder?._flushFrames) recorder._flushFrames();
- }
- recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
- }
-}
-
-/** Remove the image overlay. */
-export async function hideImage() {
- ensureConnected();
- await page.evaluate(() => {
- const el = document.getElementById('__web_test_image');
- if (el) el.remove();
- });
-}
-
-/**
- * Highlight an element on the page (visual accent for video recordings).
- * Uses overlay div for visibility (not clipped by overflow:hidden), with
- * requestAnimationFrame tracking so it follows layout shifts (async banners etc).
- * @param {string} text Element text/label (fuzzy match, same as clickElement/fillFields)
- * @param {object} [opts]
- * @param {string} [opts.color] Outline color (default: '#e74c3c')
- * @param {number} [opts.padding] Extra padding around element (default: 4)
- */
-export async function highlight(text, opts = {}) {
- ensureConnected();
- const { color = '#e74c3c', padding = 4, table } = opts;
-
- // Remove previous highlight first
- await unhighlight();
-
- let elId = null;
-
- // 0. Open submenu/popup — highest priority (submenu overlays the form,
- // so form search would match grid rows behind the popup)
- const popupItems = await page.evaluate(readSubmenuScript());
- if (Array.isArray(popupItems) && popupItems.length > 0) {
- const target = normYo(text.toLowerCase());
- let found = popupItems.find(i => normYo(i.name.toLowerCase()) === target);
- if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).startsWith(target));
- if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).includes(target));
- if (found) {
- // 1C duplicates IDs in clouds — getElementById returns the hidden copy.
- // Use elementFromPoint to find the visible element and get its actual rect.
- await page.evaluate(({ x, y, color, padding }) => {
- const el = document.elementFromPoint(x, y);
- if (!el) return;
- const block = el.closest('.submenuBlock') || el.closest('a.press') || el;
- const r = block.getBoundingClientRect();
- let div = document.getElementById('__web_test_highlight');
- if (!div) {
- div = document.createElement('div');
- div.id = '__web_test_highlight';
- document.body.appendChild(div);
- }
- div.style.cssText = [
- 'position:fixed', 'pointer-events:none', 'z-index:999998',
- `top:${r.y - padding}px`, `left:${r.x - padding}px`,
- `width:${r.width + padding * 2}px`, `height:${r.height + padding * 2}px`,
- `outline:3px solid ${color}`, 'border-radius:4px',
- `box-shadow:0 0 16px ${color}80`,
- ].join(';');
- }, { x: found.x, y: found.y, color, padding });
- return; // overlay placed, done
- }
- }
-
- // 1. Visible commands on the function panel (cmd_XXX_txt elements)
- // Must be checked BEFORE form search: when the section content panel
- // is showing, the form behind it is hidden but detectFormScript still
- // finds it, and form buttons match before commands.
- if (!elId) {
- elId = await page.evaluate(`(() => {
- const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
- const target = ${JSON.stringify(normYo(text.toLowerCase()))};
- const cmds = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(e => e.offsetWidth > 0);
- if (cmds.length === 0) return null;
- let el = cmds.find(e => norm(e.innerText).toLowerCase() === target);
- if (!el) el = cmds.find(e => norm(e.innerText).toLowerCase().startsWith(target));
- if (!el) el = cmds.find(e => norm(e.innerText).toLowerCase().includes(target));
- return el ? el.id : null;
- })()`);
- }
-
- // 1b. Command group headers on the function panel (eAccentColor labels).
- // Match header text, then highlight the header + commands below it
- // until the next spacer/header/end.
- if (!elId) {
- const groupDone = await page.evaluate(({ target, color, padding }) => {
- const container = document.querySelector('#funcPanel_container');
- if (!container) return false;
- const norm = s => (s?.trim().replace(/\u00a0/g, ' ') || '').replace(/ё/gi, 'е').toLowerCase();
- const headers = [...container.querySelectorAll('.eAccentColor')].filter(e => e.offsetWidth > 0);
- if (!headers.length) return false;
-
- let headerEl = headers.find(h => norm(h.textContent) === target);
- if (!headerEl) headerEl = headers.find(h => norm(h.textContent).startsWith(target));
- if (!headerEl) headerEl = headers.find(h => norm(h.textContent).includes(target));
- if (!headerEl) return false;
-
- // Collect header + following cmd siblings until next spacer/header
- const parent = headerEl.parentElement;
- const children = [...parent.children];
- const startIdx = children.indexOf(headerEl);
- const groupEls = [headerEl];
- for (let i = startIdx + 1; i < children.length; i++) {
- const el = children[i];
- if (el.classList.contains('eAccentColor')) break;
- if (!el.id && !el.classList.contains('functionItem') && el.getBoundingClientRect().width < 10) break;
- groupEls.push(el);
- }
-
- // Bounding box
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
- for (const el of groupEls) {
- const r = el.getBoundingClientRect();
- if (r.width === 0 && r.height === 0) continue;
- minX = Math.min(minX, r.left); minY = Math.min(minY, r.top);
- maxX = Math.max(maxX, r.right); maxY = Math.max(maxY, r.bottom);
- }
- if (minX === Infinity) return false;
-
- let div = document.getElementById('__web_test_highlight');
- if (!div) { div = document.createElement('div'); div.id = '__web_test_highlight'; document.body.appendChild(div); }
- div.style.cssText = [
- 'position:fixed', 'pointer-events:none', 'z-index:999998',
- `top:${minY - padding}px`, `left:${minX - padding}px`,
- `width:${maxX - minX + padding * 2}px`, `height:${maxY - minY + padding * 2}px`,
- `outline:3px solid ${color}`, 'border-radius:4px',
- `box-shadow:0 0 16px ${color}80`,
- ].join(';');
- return true;
- }, { target: normYo(text.toLowerCase()), color, padding });
- if (groupDone) return;
- }
-
- // 2. Form groups/panels — checked BEFORE buttons/fields because group names
- // often collide with command bar buttons (e.g. "БизнесПроцессы" is both a
- // panel and a command bar element). Includes _container and _div elements
- // but skips logicGroupContainer (Representation=None, height=0).
- if (!elId) {
- const formNum = await page.evaluate(detectFormScript());
- if (formNum !== null) {
- elId = await page.evaluate(`(() => {
- const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
- const target = ${JSON.stringify(normYo(text.toLowerCase()))};
- const p = 'form' + ${formNum} + '_';
- // Group containers: _container or _div, but skip logicGroupContainer (invisible groups)
- const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')]
- .filter(el => el.offsetWidth > 0 && el.offsetHeight > 0 && !el.classList.contains('logicGroupContainer'));
- const items = groups.map(el => {
- const idName = el.id.replace(p, '').replace(/_(container|div)$/, '');
- const titleEl = document.getElementById(p + idName + '#title_text')
- || document.getElementById(p + idName + '_title_text');
- const label = norm(titleEl?.innerText || '').toLowerCase();
- const name = norm(idName).toLowerCase();
- const big = el.offsetWidth >= 100 && el.offsetHeight >= 50;
- return { id: el.id, name, label, big };
- });
- let found = items.find(i => i.label === target);
- if (!found) found = items.find(i => i.name === target);
- // Fuzzy match: only large groups (min 100x50) to avoid matching command bars
- if (!found) found = items.filter(i => i.big).find(i => i.label.startsWith(target) || i.name.startsWith(target));
- if (!found && target.length >= 4) found = items.filter(i => i.big).find(i => i.label.includes(target) || i.name.includes(target));
- return found ? found.id : null;
- })()`);
- }
- }
-
- // 3. Form-scoped search (buttons, links, fields, grid rows)
- if (!elId) {
- const formNum = await page.evaluate(detectFormScript());
- if (formNum !== null) {
- // 3a. Try button/link/tab/gridRow via findClickTargetScript
- let gridSelector;
- if (table) {
- const resolved = await page.evaluate(resolveGridScript(formNum, table));
- if (!resolved.error) gridSelector = resolved.gridSelector;
- }
- const target = await page.evaluate(findClickTargetScript(formNum, text, table ? { tableName: table, gridSelector } : undefined));
- if (target && !target.error) {
- if (target.id) {
- elId = target.id;
- } else if (target.x && target.y) {
- // Grid row — find the gridLine element and tag it
- elId = await page.evaluate(`(() => {
- const p = ${JSON.stringify(`form${formNum}_`)};
- const grid = document.querySelector('[id^="' + p + '"].grid');
- if (!grid) return null;
- const body = grid.querySelector('.gridBody');
- if (!body) return null;
- const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
- const target = ${JSON.stringify(normYo(text.toLowerCase()))};
- for (const line of body.querySelectorAll('.gridLine')) {
- const cells = [...line.querySelectorAll('.gridBoxText')].filter(b => b.offsetWidth > 0);
- const rowText = cells.map(b => b.innerText?.trim() || '').join(' ').toLowerCase().replace(/ё/gi, 'е');
- if (rowText.includes(target)) {
- if (!line.id) line.id = '__wt_hl_tmp';
- return line.id;
- }
- }
- return null;
- })()`);
- }
- }
-
- // 3b. If not found as button — try as field via resolveFieldsScript
- if (!elId) {
- const dummyFields = { [text]: '' };
- const resolved = await page.evaluate(resolveFieldsScript(formNum, dummyFields));
- if (resolved?.length > 0 && !resolved[0].error && resolved[0].inputId) {
- elId = resolved[0].inputId;
- }
- }
- }
- }
-
- // 4. Fallback: sections (sidebar navigation)
- if (!elId) {
- elId = await page.evaluate(`(() => {
- const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
- const target = ${JSON.stringify(normYo(text.toLowerCase()))};
- const secs = [...document.querySelectorAll('[id^="themesCell_theme_"]')];
- let el = secs.find(e => norm(e.innerText).toLowerCase() === target);
- if (!el) el = secs.find(e => norm(e.innerText).toLowerCase().startsWith(target));
- if (!el) el = secs.find(e => norm(e.innerText).toLowerCase().includes(target));
- return el ? el.id : null;
- })()`);
- }
-
- if (!elId) {
- // Collect available elements to help the caller fix the name
- const available = await page.evaluate(`(() => {
- const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
- const result = {};
- // Commands
- const cmds = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(e => e.offsetWidth > 0).map(e => norm(e.innerText));
- if (cmds.length) result.commands = cmds;
- // Command group headers
- const fp = document.querySelector('#funcPanel_container');
- if (fp) {
- const gh = [...fp.querySelectorAll('.eAccentColor')].filter(e => e.offsetWidth > 0).map(e => norm(e.textContent));
- if (gh.length) result.commandGroups = gh;
- }
- // Sections
- const secs = [...document.querySelectorAll('[id^="themesCell_theme_"]')].map(e => norm(e.innerText)).filter(Boolean);
- if (secs.length) result.sections = secs;
- // Form elements
- ${(() => {
- // Detect form inline to avoid extra evaluate round-trip
- return `
- const forms = {};
- document.querySelectorAll('[id^="form"]').forEach(el => {
- const m = el.id.match(/^form(\\d+)_/);
- if (m) forms[m[1]] = (forms[m[1]] || 0) + 1;
- });
- let formNum = null, maxCount = 0;
- for (const [n, c] of Object.entries(forms)) {
- if (parseInt(n) > 0 && c > maxCount) { maxCount = c; formNum = n; }
- }
- if (formNum !== null) {
- const p = 'form' + formNum + '_';
- // Groups (_container or _div, skip logicGroupContainer, min 100x50)
- const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')]
- .filter(el => el.offsetWidth >= 100 && el.offsetHeight >= 50 && !el.classList.contains('logicGroupContainer'))
- .map(el => {
- const idName = el.id.replace(p, '').replace(/_(container|div)$/, '');
- const titleEl = document.getElementById(p + idName + '#title_text') || document.getElementById(p + idName + '_title_text');
- return norm(titleEl?.innerText || '') || idName;
- }).filter(Boolean);
- if (groups.length) result.groups = groups;
- // Buttons/links
- const btns = [...document.querySelectorAll('[id^="' + p + '"].btnText, [id^="' + p + '"] .btnText, [id^="' + p + '"].hplnk')]
- .filter(el => el.offsetWidth > 0).map(el => norm(el.innerText)).filter(Boolean);
- if (btns.length) result.buttons = [...new Set(btns)];
- }`;
- })()}
- return result;
- })()`);
- const parts = [];
- for (const [cat, items] of Object.entries(available)) {
- parts.push(` ${cat}: ${items.join(', ')}`);
- }
- const hint = parts.length ? `\nAvailable:\n${parts.join('\n')}` : '';
- throw new Error(`highlight: "${text}" not found${hint}`);
- }
-
- // Overlay div + rAF tracking loop (not clipped by overflow:hidden, follows layout shifts)
- await page.evaluate(({ elId, color, padding }) => {
- const target = document.getElementById(elId);
- if (!target) return;
- let div = document.getElementById('__web_test_highlight');
- if (!div) {
- div = document.createElement('div');
- div.id = '__web_test_highlight';
- document.body.appendChild(div);
- }
- function sync() {
- const r = target.getBoundingClientRect();
- div.style.cssText = [
- 'position:fixed', 'pointer-events:none', 'z-index:999998',
- `top:${r.y - padding}px`, `left:${r.x - padding}px`,
- `width:${r.width + padding * 2}px`, `height:${r.height + padding * 2}px`,
- `outline:3px solid ${color}`, 'border-radius:4px',
- `box-shadow:0 0 16px ${color}80`,
- ].join(';');
- }
- sync();
- // Track position changes via rAF
- function tick() {
- if (!document.getElementById('__web_test_highlight')) return; // stopped
- sync();
- requestAnimationFrame(tick);
- }
- requestAnimationFrame(tick);
- }, { elId, color, padding });
-}
-
-/** Remove the highlight overlay. */
-export async function unhighlight() {
- ensureConnected();
- await page.evaluate(() => {
- const el = document.getElementById('__web_test_highlight');
- if (el) el.remove(); // also stops rAF loop (id check)
- // Clean up temp ID from grid rows
- const tmp = document.getElementById('__wt_hl_tmp');
- if (tmp) tmp.removeAttribute('id');
- });
-}
-
-/**
- * Toggle auto-highlight mode. When enabled, clickElement/fillFields/selectValue
- * automatically highlight the target element before acting.
- * @param {boolean} on true to enable, false to disable
- */
-export function setHighlight(on) {
- setHighlightMode(!!on);
-}
-
-/** @returns {boolean} Whether auto-highlight mode is active. */
-export function isHighlightMode() {
- return highlightMode;
-}
-
-// ============================================================
-// Private helpers
-// ============================================================
-
-/** Resolve ffmpeg binary path. */
-function resolveFfmpeg(explicit) {
- // 1. Explicit path
- if (explicit) {
- try { execFileSync(explicit, ['-version'], { stdio: 'ignore', timeout: 5000 }); return explicit; }
- catch { throw new Error(`ffmpeg not found at: ${explicit}`); }
- }
-
- // 2. FFMPEG_PATH env var
- const envPath = process.env.FFMPEG_PATH;
- if (envPath) {
- try { execFileSync(envPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return envPath; }
- catch { /* fall through */ }
- }
-
- // 3. System PATH
- try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore', timeout: 5000 }); return 'ffmpeg'; }
- catch { /* fall through */ }
-
- // 4. tools/ffmpeg/bin/ffmpeg.exe relative to project root
- const localPath = pathResolve(projectRoot, 'tools', 'ffmpeg', 'bin', 'ffmpeg.exe');
- if (fsExistsSync(localPath)) {
- try { execFileSync(localPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return localPath; }
- catch { /* fall through */ }
- }
-
- // 5. Error with instructions
- throw new Error(
- 'ffmpeg not found. Install it:\n' +
- ' - Download from https://www.gyan.dev/ffmpeg/builds/ (essentials build)\n' +
- ' - Add to PATH, or set FFMPEG_PATH env var, or place in tools/ffmpeg/bin/\n' +
- ' - Or pass ffmpegPath option to startRecording()'
- );
-}
-
-// ── 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 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 });
-}
+export {
+ screenshot, wait, isRecording, startRecording, stopRecording,
+} from './recording/capture.mjs';
+export {
+ showCaption, hideCaption, getCaptions,
+ showTitleSlide, hideTitleSlide,
+ showImage, hideImage,
+} from './recording/captions.mjs';
+export {
+ highlight, unhighlight, setHighlight, isHighlightMode,
+} from './recording/highlight.mjs';
+export { addNarration } from './recording/narration.mjs';
/* ensureConnected moved to core/state.mjs */
diff --git a/.claude/skills/web-test/scripts/core/state.mjs b/.claude/skills/web-test/scripts/core/state.mjs
index 62dba485..395a58ad 100644
--- a/.claude/skills/web-test/scripts/core/state.mjs
+++ b/.claude/skills/web-test/scripts/core/state.mjs
@@ -1,113 +1,113 @@
-// web-test core/state v1.16 — module-level state for the web-test engine.
-// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
-//
-// Holds the single browser/page/recorder slot plus the multi-context registry,
-// constants, and small state-only utilities (ensureConnected, getPage,
-// resolveProjectPath, normYo). Mutable values are exported as `let` bindings
-// for live read access from consumer modules; writes go through setters so
-// imported bindings stay read-only at the import site.
-
-import { dirname, resolve as pathResolve } from 'path';
-import { fileURLToPath } from 'url';
-
-// Project root: 4 levels up from .claude/skills/web-test/scripts/core/state.mjs
-const __fn_state = fileURLToPath(import.meta.url);
-export const projectRoot = pathResolve(dirname(__fn_state), '..', '..', '..', '..', '..');
-
-/** Resolve a user-provided path relative to the project root (not cwd). */
-export const resolveProjectPath = (p) => pathResolve(projectRoot, p);
-
-// ──────────────────────────────────────────────────────────────────────────
-// Mutable single-session state. Importers read via the live binding; writes
-// must go through the corresponding setter (ESM imports are read-only).
-// ──────────────────────────────────────────────────────────────────────────
-
-export let browser = null;
-export let page = null;
-export let sessionPrefix = null; // e.g. "http://localhost:8081/bpdemo/ru_RU"
-export let seanceId = null;
-export let recorder = null; // { cdp, ffmpeg, startTime, outputPath, ffmpegError, captions }
-export let lastCaptions = []; // captions from the last completed recording (for addNarration)
-export let lastRecordingDuration = null; // wall-clock duration of the last recording (seconds)
-export let highlightMode = false;
-export let persistentUserDataDir = null; // temp dir for launchPersistentContext, cleaned on disconnect
-
-// Clipboard preservation: save full clipboard contents (all MIME types) right
-// before each writeText+Ctrl+V pair, restore right after. Toggled via
-// setPreserveClipboard() from run.mjs.
-export let preserveClipboard = true;
-export let clipboardWarnLogged = false;
-
-export const setBrowser = (v) => { browser = v; };
-export const setPage = (v) => { page = v; };
-export const setSessionPrefix = (v) => { sessionPrefix = v; };
-export const setSeanceId = (v) => { seanceId = v; };
-export const setRecorder = (v) => { recorder = v; };
-export const setLastCaptions = (v) => { lastCaptions = v; };
-export const setLastRecordingDuration = (v) => { lastRecordingDuration = v; };
-export const setHighlightMode = (v) => { highlightMode = !!v; };
-export const setPersistentUserDataDir = (v) => { persistentUserDataDir = v; };
-export const setPreserveClipboard = (v) => { preserveClipboard = !!v; };
-export const setClipboardWarnLogged = (v) => { clipboardWarnLogged = !!v; };
-
-// ──────────────────────────────────────────────────────────────────────────
-// Multi-context registry: name → { context, page, sessionPrefix, seanceId,
-// recorder, lastCaptions, lastRecordingDuration, highlightMode }.
-// Populated by createContext(); module-level vars above mirror the active
-// slot. connect() does NOT use this Map — it preserves legacy single-session
-// behavior for exec/run/start.
-// ──────────────────────────────────────────────────────────────────────────
-
-export const contexts = new Map();
-export let activeContextName = null;
-// Isolation mode for the current cmdTest session — set by the first
-// createContext call. 'tab': all contexts share one persistent context
-// (one window, multiple tabs, extension loads reliably). 'window': each
-// context gets its own BrowserContext (separate window per context, full
-// cookie isolation, extension may not load).
-export let activeMode = null;
-
-export const setActiveContextName = (v) => { activeContextName = v; };
-export const setActiveMode = (v) => { activeMode = v; };
-
-// ──────────────────────────────────────────────────────────────────────────
-// Constants.
-// ──────────────────────────────────────────────────────────────────────────
-
-export const LOAD_TIMEOUT = 60000;
-export const INIT_TIMEOUT = 60000;
-export const ACTION_WAIT = 2000; // fallback minimum wait
-export const MAX_WAIT = 10000; // max wait for stability
-export const POLL_INTERVAL = 200; // polling interval
-export const STABLE_CYCLES = 3; // consecutive stable cycles needed
-
-// 1C browser extension ID (stable across versions, defined by key in manifest.json)
-export const EXT_ID = 'pbhelknnhilelbnhfpcjlcabhmfangik';
-
-// ──────────────────────────────────────────────────────────────────────────
-// Utilities that only depend on state.
-// ──────────────────────────────────────────────────────────────────────────
-
-/** Normalize ё→е and →space for fuzzy matching. */
-export const normYo = (s) => s.replace(/ё/gi, 'е').replace(/ /g, ' ');
-
-/** Check if browser is connected and page is usable. */
-export function isConnected() {
- if (!browser || !page || page.isClosed()) return false;
- // launchPersistentContext returns BrowserContext (no isConnected), launch returns Browser
- if (typeof browser.isConnected === 'function') return browser.isConnected();
- // For persistent context, check via context's browser()
- return browser.browser()?.isConnected() ?? false;
-}
-
-export function ensureConnected() {
- if (!isConnected()) {
- throw new Error('Browser not connected. Call web_connect first.');
- }
-}
-
-/** Get the raw Playwright page object (for advanced scripting in skill mode). */
-export function getPage() {
- ensureConnected();
- return page;
-}
+// web-test core/state v1.16 — module-level state for the web-test engine.
+// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
+//
+// Holds the single browser/page/recorder slot plus the multi-context registry,
+// constants, and small state-only utilities (ensureConnected, getPage,
+// resolveProjectPath, normYo). Mutable values are exported as `let` bindings
+// for live read access from consumer modules; writes go through setters so
+// imported bindings stay read-only at the import site.
+
+import { dirname, resolve as pathResolve } from 'path';
+import { fileURLToPath } from 'url';
+
+// Project root: 4 levels up from .claude/skills/web-test/scripts/core/state.mjs
+const __fn_state = fileURLToPath(import.meta.url);
+export const projectRoot = pathResolve(dirname(__fn_state), '..', '..', '..', '..', '..');
+
+/** Resolve a user-provided path relative to the project root (not cwd). */
+export const resolveProjectPath = (p) => pathResolve(projectRoot, p);
+
+// ──────────────────────────────────────────────────────────────────────────
+// Mutable single-session state. Importers read via the live binding; writes
+// must go through the corresponding setter (ESM imports are read-only).
+// ──────────────────────────────────────────────────────────────────────────
+
+export let browser = null;
+export let page = null;
+export let sessionPrefix = null; // e.g. "http://localhost:8081/bpdemo/ru_RU"
+export let seanceId = null;
+export let recorder = null; // { cdp, ffmpeg, startTime, outputPath, ffmpegError, captions }
+export let lastCaptions = []; // captions from the last completed recording (for addNarration)
+export let lastRecordingDuration = null; // wall-clock duration of the last recording (seconds)
+export let highlightMode = false;
+export let persistentUserDataDir = null; // temp dir for launchPersistentContext, cleaned on disconnect
+
+// Clipboard preservation: save full clipboard contents (all MIME types) right
+// before each writeText+Ctrl+V pair, restore right after. Toggled via
+// setPreserveClipboard() from run.mjs.
+export let preserveClipboard = true;
+export let clipboardWarnLogged = false;
+
+export const setBrowser = (v) => { browser = v; };
+export const setPage = (v) => { page = v; };
+export const setSessionPrefix = (v) => { sessionPrefix = v; };
+export const setSeanceId = (v) => { seanceId = v; };
+export const setRecorder = (v) => { recorder = v; };
+export const setLastCaptions = (v) => { lastCaptions = v; };
+export const setLastRecordingDuration = (v) => { lastRecordingDuration = v; };
+export const setHighlightMode = (v) => { highlightMode = !!v; };
+export const setPersistentUserDataDir = (v) => { persistentUserDataDir = v; };
+export const setPreserveClipboard = (v) => { preserveClipboard = !!v; };
+export const setClipboardWarnLogged = (v) => { clipboardWarnLogged = !!v; };
+
+// ──────────────────────────────────────────────────────────────────────────
+// Multi-context registry: name → { context, page, sessionPrefix, seanceId,
+// recorder, lastCaptions, lastRecordingDuration, highlightMode }.
+// Populated by createContext(); module-level vars above mirror the active
+// slot. connect() does NOT use this Map — it preserves legacy single-session
+// behavior for exec/run/start.
+// ──────────────────────────────────────────────────────────────────────────
+
+export const contexts = new Map();
+export let activeContextName = null;
+// Isolation mode for the current cmdTest session — set by the first
+// createContext call. 'tab': all contexts share one persistent context
+// (one window, multiple tabs, extension loads reliably). 'window': each
+// context gets its own BrowserContext (separate window per context, full
+// cookie isolation, extension may not load).
+export let activeMode = null;
+
+export const setActiveContextName = (v) => { activeContextName = v; };
+export const setActiveMode = (v) => { activeMode = v; };
+
+// ──────────────────────────────────────────────────────────────────────────
+// Constants.
+// ──────────────────────────────────────────────────────────────────────────
+
+export const LOAD_TIMEOUT = 60000;
+export const INIT_TIMEOUT = 60000;
+export const ACTION_WAIT = 2000; // fallback minimum wait
+export const MAX_WAIT = 10000; // max wait for stability
+export const POLL_INTERVAL = 200; // polling interval
+export const STABLE_CYCLES = 3; // consecutive stable cycles needed
+
+// 1C browser extension ID (stable across versions, defined by key in manifest.json)
+export const EXT_ID = 'pbhelknnhilelbnhfpcjlcabhmfangik';
+
+// ──────────────────────────────────────────────────────────────────────────
+// Utilities that only depend on state.
+// ──────────────────────────────────────────────────────────────────────────
+
+/** Normalize ё→е and →space for fuzzy matching. */
+export const normYo = (s) => s.replace(/ё/gi, 'е').replace(/ /g, ' ');
+
+/** Check if browser is connected and page is usable. */
+export function isConnected() {
+ if (!browser || !page || page.isClosed()) return false;
+ // launchPersistentContext returns BrowserContext (no isConnected), launch returns Browser
+ if (typeof browser.isConnected === 'function') return browser.isConnected();
+ // For persistent context, check via context's browser()
+ return browser.browser()?.isConnected() ?? false;
+}
+
+export function ensureConnected() {
+ if (!isConnected()) {
+ throw new Error('Browser not connected. Call web_connect first.');
+ }
+}
+
+/** Get the raw Playwright page object (for advanced scripting in skill mode). */
+export function getPage() {
+ ensureConnected();
+ return page;
+}
diff --git a/.claude/skills/web-test/scripts/recording/captions.mjs b/.claude/skills/web-test/scripts/recording/captions.mjs
new file mode 100644
index 00000000..c70cd987
--- /dev/null
+++ b/.claude/skills/web-test/scripts/recording/captions.mjs
@@ -0,0 +1,292 @@
+// web-test recording/captions v1.16 — Overlay primitives: captions, title slides, image overlays.
+// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
+
+import { existsSync as fsExistsSync, readFileSync } from 'fs';
+import { extname } from 'path';
+import {
+ page, recorder, lastCaptions, ensureConnected, resolveProjectPath,
+} from '../core/state.mjs';
+
+/**
+ * Show a text caption overlay on the page (visible in recording).
+ * Calling again updates the text without creating a new element.
+ * @param {string} text — caption text
+ * @param {object} [opts]
+ * @param {'top'|'bottom'} [opts.position='bottom'] — vertical position
+ * @param {number} [opts.fontSize=24] — font size in pixels
+ * @param {string} [opts.background='rgba(0,0,0,0.7)'] — background color
+ * @param {string} [opts.color='#fff'] — text color
+ * @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
+ let smartWaitMs = 0;
+ if (recorder && (text.trim() || typeof opts.speech === 'string') && 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: text || speech, speech, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
+ // Estimate TTS duration and wait so the video has enough screen time for voiceover
+ smartWaitMs = Math.max(2000, speech.length * (recorder.speechRate || 70));
+ }
+ const position = opts.position || 'bottom';
+ const fontSize = opts.fontSize || 24;
+ const bg = opts.background || 'rgba(0,0,0,0.7)';
+ const color = opts.color || '#fff';
+
+ await page.evaluate(({ text, position, fontSize, bg, color }) => {
+ let el = document.getElementById('__web_test_caption');
+ if (!el) {
+ el = document.createElement('div');
+ el.id = '__web_test_caption';
+ el.style.cssText = `
+ position: fixed; left: 0; right: 0; z-index: 99999;
+ text-align: center; padding: 12px 24px;
+ font-family: Arial, sans-serif; pointer-events: none;
+ `;
+ document.body.appendChild(el);
+ }
+ el.style[position === 'top' ? 'top' : 'bottom'] = '20px';
+ el.style[position === 'top' ? 'bottom' : 'top'] = 'auto';
+ el.style.fontSize = fontSize + 'px';
+ el.style.background = bg;
+ el.style.color = color;
+ el.textContent = text;
+ }, { text, position, fontSize, bg, color });
+
+ // Smart TTS wait: pause for estimated speech duration so video has enough screen time.
+ // Split into chunks and flush frames periodically — CDP doesn't send screencast frames
+ // for static pages, so we must write duplicate frames to keep video timeline in sync.
+ if (smartWaitMs > 0) {
+ let remaining = smartWaitMs;
+ while (remaining > 0) {
+ const chunk = Math.min(remaining, 1000);
+ await page.waitForTimeout(chunk);
+ remaining -= chunk;
+ if (recorder?._flushFrames) recorder._flushFrames();
+ }
+ recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
+ }
+}
+
+/** Remove the caption overlay from the page. */
+export async function hideCaption() {
+ ensureConnected();
+ await page.evaluate(() => {
+ const el = document.getElementById('__web_test_caption');
+ if (el) el.remove();
+ });
+}
+
+/**
+ * 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];
+}
+
+/**
+ * Show a full-screen title slide overlay (for video recordings).
+ * Repeated calls update the content. Use hideTitleSlide() to remove.
+ * @param {string} text Title text (\n → line break)
+ * @param {object} [opts]
+ * @param {string} [opts.subtitle] Smaller text below the title
+ * @param {string} [opts.background] CSS background (default: dark gradient)
+ * @param {string} [opts.color] Text color (default: '#fff')
+ * @param {number} [opts.fontSize] Title font size in px (default: 36)
+ */
+export async function showTitleSlide(text, opts = {}) {
+ ensureConnected();
+ const {
+ subtitle = '',
+ background = 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)',
+ color = '#fff',
+ fontSize = 36,
+ speech,
+ } = opts;
+
+ // Collect caption for TTS narration if recording
+ let smartWaitMs = 0;
+ if (recorder && speech && speech !== false) {
+ const captionText = typeof speech === 'string' ? speech : text.replace(/\n/g, ' ');
+ if (captionText) {
+ recorder.captions.push({ text: captionText, speech: captionText, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
+ smartWaitMs = Math.max(2000, captionText.length * (recorder.speechRate || 70));
+ }
+ }
+
+ await page.evaluate(({ text, subtitle, background, color, fontSize }) => {
+ let div = document.getElementById('__web_test_title');
+ if (!div) {
+ div = document.createElement('div');
+ div.id = '__web_test_title';
+ document.body.appendChild(div);
+ }
+ div.style.cssText = [
+ 'position:fixed', 'top:0', 'left:0', 'width:100%', 'height:100%',
+ `background:${background}`,
+ 'display:flex', 'align-items:center', 'justify-content:center',
+ 'z-index:999999', 'pointer-events:none',
+ ].join(';');
+ // Remove other overlays to prevent flash between slides
+ const img = document.getElementById('__web_test_image');
+ if (img) img.remove();
+ const esc = s => s.replace(/&/g, '&').replace(/');
+ let html = `${esc(text)}
`;
+ if (subtitle) {
+ html += `${esc(subtitle)}
`;
+ }
+ div.innerHTML = `${html}
`;
+ }, { text, subtitle, background, color, fontSize });
+
+ // Smart TTS wait (same pattern as showCaption/showImage)
+ if (smartWaitMs > 0) {
+ let remaining = smartWaitMs;
+ while (remaining > 0) {
+ const chunk = Math.min(remaining, 1000);
+ await page.waitForTimeout(chunk);
+ remaining -= chunk;
+ if (recorder?._flushFrames) recorder._flushFrames();
+ }
+ recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
+ }
+}
+
+/** Remove the title slide overlay. */
+export async function hideTitleSlide() {
+ ensureConnected();
+ await page.evaluate(() => {
+ const el = document.getElementById('__web_test_title');
+ if (el) el.remove();
+ });
+}
+
+/**
+ * Show a full-screen image overlay (e.g. presentation slide screenshot).
+ * Reads the image file, base64-encodes it, and renders as a fixed overlay
+ * on the page — captured by CDP screencast automatically.
+ *
+ * Style presets:
+ * - 'blur' (default) — blurred+dimmed copy as background, image centered with shadow
+ * - 'dark' — dark background (#2a2a2a) with shadow
+ * - 'light' — white background with shadow
+ * - 'full' — image covers entire screen, no padding/shadow
+ *
+ * Custom background overrides the preset (e.g. background: '#003366').
+ *
+ * @param {string} imagePath — path to the image file (PNG, JPG, etc.)
+ * @param {object} [opts]
+ * @param {'blur'|'dark'|'light'|'full'} [opts.style='blur'] — display style preset
+ * @param {string} [opts.background] — custom background color/gradient (overrides style preset)
+ * @param {boolean} [opts.shadow] — show drop shadow (default: true for blur/dark/light, false for full)
+ * @param {string|false} [opts.speech] — TTS narration text while image is shown.
+ * Pass a string for narration, or false to skip. Omit to skip (no auto-text for images).
+ */
+export async function showImage(imagePath, opts = {}) {
+ ensureConnected();
+ const style = opts.style || 'blur';
+ const speech = opts.speech;
+
+ // Style presets
+ const presets = {
+ blur: { bg: '#222', fit: 'contain', shadow: true, blur: true },
+ dark: { bg: '#2a2a2a', fit: 'contain', shadow: true, blur: false },
+ light: { bg: '#ffffff', fit: 'contain', shadow: true, blur: false },
+ full: { bg: '#000', fit: 'contain', shadow: false, blur: false },
+ };
+ const preset = presets[style] || presets.blur;
+
+ const bg = opts.background || preset.bg;
+ const fit = preset.fit;
+ const shadow = opts.shadow !== undefined ? opts.shadow : preset.shadow;
+ const useBlur = opts.background ? false : preset.blur;
+
+ // Read image and base64-encode
+ const absPath = resolveProjectPath(imagePath);
+ if (!fsExistsSync(absPath)) {
+ throw new Error(`showImage: file not found: ${absPath}`);
+ }
+ const buf = readFileSync(absPath);
+ const ext = extname(absPath).toLowerCase().replace('.', '');
+ const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg'
+ : ext === 'png' ? 'image/png'
+ : ext === 'gif' ? 'image/gif'
+ : ext === 'webp' ? 'image/webp'
+ : ext === 'svg' ? 'image/svg+xml'
+ : 'image/png';
+ const dataUrl = `data:${mime};base64,${buf.toString('base64')}`;
+
+ // Collect caption for TTS narration if recording
+ let smartWaitMs = 0;
+ if (recorder && speech && speech !== false) {
+ const captionText = typeof speech === 'string' ? speech : '';
+ if (captionText) {
+ recorder.captions.push({ text: captionText, speech: captionText, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) });
+ smartWaitMs = Math.max(2000, captionText.length * (recorder.speechRate || 70));
+ }
+ }
+
+ // Padding: full style uses 100%, others use 92% for breathing room
+ const isFull = style === 'full';
+ const maxSize = isFull ? '100%' : '92%';
+
+ await page.evaluate(({ dataUrl, fit, bg, useBlur, shadow, maxSize, isFull }) => {
+ let div = document.getElementById('__web_test_image');
+ if (!div) {
+ div = document.createElement('div');
+ div.id = '__web_test_image';
+ document.body.appendChild(div);
+ }
+ // Remove other overlays to prevent flash between slides
+ const title = document.getElementById('__web_test_title');
+ if (title) title.remove();
+
+ div.style.cssText = [
+ 'position:fixed', 'top:0', 'left:0', 'width:100%', 'height:100%',
+ `background:${bg}`,
+ 'display:flex', 'align-items:center', 'justify-content:center',
+ 'z-index:999999', 'pointer-events:none', 'overflow:hidden'
+ ].join(';');
+
+ let html = '';
+
+ // Blurred background layer: the same image stretched to cover, blurred and dimmed
+ if (useBlur) {
+ html += `
`;
+ }
+
+ // Main image
+ const shadowCss = shadow ? 'box-shadow:0 4px 40px rgba(0,0,0,0.5);' : '';
+ const sizeCss = isFull
+ ? `width:100%;height:100%;object-fit:${fit};`
+ : `max-width:${maxSize};max-height:${maxSize};min-width:50%;min-height:50%;object-fit:${fit};`;
+ html += `
`;
+
+ div.innerHTML = html;
+ }, { dataUrl, fit, bg, useBlur, shadow, maxSize, isFull });
+
+ // Smart TTS wait (same pattern as showCaption)
+ if (smartWaitMs > 0) {
+ let remaining = smartWaitMs;
+ while (remaining > 0) {
+ const chunk = Math.min(remaining, 1000);
+ await page.waitForTimeout(chunk);
+ remaining -= chunk;
+ if (recorder?._flushFrames) recorder._flushFrames();
+ }
+ recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() };
+ }
+}
+
+/** Remove the image overlay. */
+export async function hideImage() {
+ ensureConnected();
+ await page.evaluate(() => {
+ const el = document.getElementById('__web_test_image');
+ if (el) el.remove();
+ });
+}
diff --git a/.claude/skills/web-test/scripts/recording/capture.mjs b/.claude/skills/web-test/scripts/recording/capture.mjs
new file mode 100644
index 00000000..ad94a4ee
--- /dev/null
+++ b/.claude/skills/web-test/scripts/recording/capture.mjs
@@ -0,0 +1,244 @@
+// web-test recording/capture v1.16 — Recording lifecycle (CDP screencast + ffmpeg pipe), screenshot, wait helpers.
+// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
+
+import { spawn } from 'child_process';
+import { mkdirSync, statSync, writeFileSync } from 'fs';
+import { dirname } from 'path';
+import {
+ page, recorder, lastCaptions,
+ setRecorder, setLastCaptions, setLastRecordingDuration,
+ resolveProjectPath, ensureConnected,
+} from '../core/state.mjs';
+import { resolveFfmpeg } from './tts.mjs';
+// getFormState lives in browser.mjs for now (moves to forms/ in a later stage).
+// Imported lazily inside wait() to avoid initialization-time circular deps.
+
+/** Take a screenshot. Returns PNG buffer. */
+export async function screenshot() {
+ ensureConnected();
+ return await page.screenshot({ type: 'png' });
+}
+
+/** Wait for a specified number of seconds. */
+export async function wait(seconds) {
+ ensureConnected();
+ let ms = seconds * 1000;
+ // Credit system: if showCaption already waited for TTS, subtract that time
+ if (recorder && recorder.captionCredit) {
+ const elapsed = Date.now() - recorder.captionCredit.at;
+ const credit = Math.max(0, recorder.captionCredit.waitedMs - elapsed);
+ ms = Math.max(0, ms - credit);
+ recorder.captionCredit = null;
+ }
+ if (ms > 0) {
+ // During recording, split long waits into chunks and flush frames
+ // to keep video timeline in sync (CDP may not send frames for static pages)
+ if (recorder?._flushFrames && ms > 1000) {
+ let remaining = ms;
+ while (remaining > 0) {
+ const chunk = Math.min(remaining, 1000);
+ await page.waitForTimeout(chunk);
+ remaining -= chunk;
+ recorder._flushFrames();
+ }
+ } else {
+ await page.waitForTimeout(ms);
+ }
+ }
+ const { getFormState } = await import('../browser.mjs');
+ return await getFormState();
+}
+
+// ============================================================
+// Video recording — CDP screencast + ffmpeg
+// ============================================================
+
+/** Check if video recording is active. */
+export function isRecording() {
+ return recorder !== null;
+}
+
+/**
+ * Start video recording via CDP screencast + ffmpeg.
+ * Frames are captured as JPEG and piped to ffmpeg for MP4 encoding.
+ * @param {string} outputPath — output .mp4 file path
+ * @param {object} [opts]
+ * @param {number} [opts.fps=25] — target framerate
+ * @param {number} [opts.quality=80] — JPEG quality (1-100)
+ * @param {string} [opts.ffmpegPath] — explicit path to ffmpeg binary
+ */
+export async function startRecording(outputPath, opts = {}) {
+ ensureConnected();
+ if (recorder) {
+ if (opts.force) {
+ try { await stopRecording(); } catch {}
+ } else {
+ throw new Error('Already recording. Call stopRecording() first, or use { force: true }.');
+ }
+ }
+ setLastCaptions([]);
+ setLastRecordingDuration(null);
+
+ const fps = opts.fps || 25;
+ const quality = opts.quality || 80;
+ const ffmpegPath = resolveFfmpeg(opts.ffmpegPath);
+
+ // Ensure output directory exists
+ const resolvedPath = resolveProjectPath(outputPath);
+ mkdirSync(dirname(resolvedPath), { recursive: true });
+
+ // Spawn ffmpeg process — single output file across context switches
+ const ffmpeg = spawn(ffmpegPath, [
+ '-y', // overwrite output
+ '-f', 'image2pipe', // input: piped images
+ '-framerate', String(fps), // input framerate
+ '-i', '-', // read from stdin
+ '-c:v', 'libx264', // H.264 codec
+ '-preset', 'fast', // good quality/speed balance
+ '-crf', '23', // default quality (good for screen content)
+ '-vf', 'scale=in_range=full:out_range=limited', // JPEG full→H.264 limited range
+ '-pix_fmt', 'yuv420p', // broad compatibility
+ '-color_range', 'tv', // limited range (16-235) — standard for H.264 players
+ '-movflags', '+faststart', // web-friendly MP4
+ resolvedPath
+ ], { stdio: ['pipe', 'ignore', 'pipe'] });
+
+ ffmpeg.on('error', err => { if (recorder) recorder.ffmpegError += err.message; });
+
+ const frameDuration = 1000 / fps;
+ const speechRate = opts.speechRate || 70; // ms per character for smart TTS wait
+
+ // Frame handler shared across CDP sessions (lives in recorder, not closure):
+ // when the active context switches, we attach a new CDP session and route its
+ // frames to the same ffmpeg pipe — preserving a single continuous timeline.
+ const frameHandler = async ({ data, sessionId }, cdp) => {
+ if (!recorder) return;
+ const buf = Buffer.from(data, 'base64');
+ const now = Date.now();
+ if (!ffmpeg.stdin.destroyed) {
+ let framesWritten = 0;
+ if (recorder.lastFrameTime && recorder.lastFrameBuf) {
+ const gap = now - recorder.lastFrameTime;
+ const dupes = Math.round(gap / frameDuration) - 1;
+ for (let i = 0; i < dupes && i < fps * 30; i++) {
+ ffmpeg.stdin.write(recorder.lastFrameBuf);
+ framesWritten++;
+ }
+ }
+ ffmpeg.stdin.write(buf);
+ framesWritten++;
+ recorder.videoTimeMs += framesWritten * frameDuration;
+ }
+ recorder.lastFrameTime = now;
+ recorder.lastFrameBuf = buf;
+ try { await cdp.send('Page.screencastFrameAck', { sessionId }); } catch {}
+ };
+
+ // Duplicate the last frame to fill wall-clock gaps (static periods, context switches).
+ const _flushFrames = () => {
+ if (!recorder || !recorder.lastFrameBuf || !recorder.lastFrameTime || ffmpeg.stdin.destroyed) return;
+ const now = Date.now();
+ const gap = now - recorder.lastFrameTime;
+ const dupes = Math.round(gap / frameDuration);
+ for (let i = 0; i < dupes; i++) {
+ ffmpeg.stdin.write(recorder.lastFrameBuf);
+ recorder.videoTimeMs += frameDuration;
+ }
+ if (dupes > 0) recorder.lastFrameTime = now;
+ };
+
+ // Attach screencast to a specific page. Stops the old CDP first (if any).
+ // Called by startRecording for the initial page, and by setActiveContext when
+ // the active context changes mid-recording.
+ const _attachPage = async (targetPage) => {
+ if (recorder.cdp) {
+ _flushFrames(); // freeze the last frame of the outgoing page up to "now"
+ try { await recorder.cdp.send('Page.stopScreencast'); } catch {}
+ try { await recorder.cdp.detach(); } catch {}
+ recorder.cdp = null;
+ }
+ const cdp = await targetPage.context().newCDPSession(targetPage);
+ cdp.on('Page.screencastFrame', (ev) => frameHandler(ev, cdp));
+ await cdp.send('Page.startScreencast', { format: 'jpeg', quality, everyNthFrame: 1 });
+ recorder.cdp = cdp;
+ recorder.activePage = targetPage;
+ };
+
+ setRecorder({
+ cdp: null,
+ activePage: null,
+ ffmpeg,
+ startTime: Date.now(),
+ outputPath: resolvedPath,
+ ffmpegError: '',
+ captions: [],
+ videoTimeMs: 0,
+ frameDuration,
+ lastFrameTime: null,
+ lastFrameBuf: null,
+ _flushFrames,
+ _attachPage,
+ speechRate,
+ });
+ ffmpeg.stderr.on('data', d => { recorder.ffmpegError += d.toString(); });
+
+ await _attachPage(page);
+}
+
+/**
+ * Stop video recording. Finalizes the MP4 file.
+ * @returns {{ file: string, duration: number, size: number }}
+ */
+export async function stopRecording() {
+ if (!recorder) return { file: null, duration: 0, size: 0 };
+
+ const { cdp, ffmpeg, startTime, outputPath } = recorder;
+
+ // Final frame flush: write remaining frames to cover the gap since the last screencast frame
+ if (recorder._flushFrames) recorder._flushFrames();
+
+ // Stop CDP screencast
+ try { await cdp.send('Page.stopScreencast'); } catch {}
+ try { await cdp.detach(); } catch {}
+
+ // Close ffmpeg stdin and wait for encoding to finish
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ ffmpeg.kill('SIGKILL');
+ reject(new Error('ffmpeg timed out after 30s'));
+ }, 30000);
+
+ ffmpeg.on('close', (code) => {
+ clearTimeout(timeout);
+ if (code === 0) resolve();
+ else reject(new Error(`ffmpeg exited with code ${code}: ${recorder?.ffmpegError || ''}`));
+ });
+ ffmpeg.on('error', (err) => {
+ clearTimeout(timeout);
+ reject(err);
+ });
+
+ ffmpeg.stdin.end();
+ });
+
+ const duration = (Date.now() - startTime) / 1000;
+ const stats = statSync(outputPath);
+
+ // Preserve captions for addNarration()
+ setLastCaptions(recorder.captions || []);
+ setLastRecordingDuration(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');
+ }
+
+ setRecorder(null);
+
+ return {
+ file: outputPath,
+ duration: Math.round(duration * 10) / 10,
+ size: stats.size,
+ captions: lastCaptions.length
+ };
+}
diff --git a/.claude/skills/web-test/scripts/recording/highlight.mjs b/.claude/skills/web-test/scripts/recording/highlight.mjs
new file mode 100644
index 00000000..bdf5eee1
--- /dev/null
+++ b/.claude/skills/web-test/scripts/recording/highlight.mjs
@@ -0,0 +1,340 @@
+// web-test recording/highlight v1.16 — Visual highlight overlay (single + auto-mode for clickElement/fillFields/selectValue).
+// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
+
+import {
+ page, highlightMode, ensureConnected, normYo,
+ setHighlightMode,
+} from '../core/state.mjs';
+import {
+ readSubmenuScript, detectFormScript, resolveGridScript,
+ findClickTargetScript, resolveFieldsScript,
+} from '../dom.mjs';
+
+/**
+ * Highlight an element on the page (visual accent for video recordings).
+ * Uses overlay div for visibility (not clipped by overflow:hidden), with
+ * requestAnimationFrame tracking so it follows layout shifts (async banners etc).
+ * @param {string} text Element text/label (fuzzy match, same as clickElement/fillFields)
+ * @param {object} [opts]
+ * @param {string} [opts.color] Outline color (default: '#e74c3c')
+ * @param {number} [opts.padding] Extra padding around element (default: 4)
+ */
+export async function highlight(text, opts = {}) {
+ ensureConnected();
+ const { color = '#e74c3c', padding = 4, table } = opts;
+
+ // Remove previous highlight first
+ await unhighlight();
+
+ let elId = null;
+
+ // 0. Open submenu/popup — highest priority (submenu overlays the form,
+ // so form search would match grid rows behind the popup)
+ const popupItems = await page.evaluate(readSubmenuScript());
+ if (Array.isArray(popupItems) && popupItems.length > 0) {
+ const target = normYo(text.toLowerCase());
+ let found = popupItems.find(i => normYo(i.name.toLowerCase()) === target);
+ if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).startsWith(target));
+ if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).includes(target));
+ if (found) {
+ // 1C duplicates IDs in clouds — getElementById returns the hidden copy.
+ // Use elementFromPoint to find the visible element and get its actual rect.
+ await page.evaluate(({ x, y, color, padding }) => {
+ const el = document.elementFromPoint(x, y);
+ if (!el) return;
+ const block = el.closest('.submenuBlock') || el.closest('a.press') || el;
+ const r = block.getBoundingClientRect();
+ let div = document.getElementById('__web_test_highlight');
+ if (!div) {
+ div = document.createElement('div');
+ div.id = '__web_test_highlight';
+ document.body.appendChild(div);
+ }
+ div.style.cssText = [
+ 'position:fixed', 'pointer-events:none', 'z-index:999998',
+ `top:${r.y - padding}px`, `left:${r.x - padding}px`,
+ `width:${r.width + padding * 2}px`, `height:${r.height + padding * 2}px`,
+ `outline:3px solid ${color}`, 'border-radius:4px',
+ `box-shadow:0 0 16px ${color}80`,
+ ].join(';');
+ }, { x: found.x, y: found.y, color, padding });
+ return; // overlay placed, done
+ }
+ }
+
+ // 1. Visible commands on the function panel (cmd_XXX_txt elements)
+ // Must be checked BEFORE form search: when the section content panel
+ // is showing, the form behind it is hidden but detectFormScript still
+ // finds it, and form buttons match before commands.
+ if (!elId) {
+ elId = await page.evaluate(`(() => {
+ const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
+ const target = ${JSON.stringify(normYo(text.toLowerCase()))};
+ const cmds = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(e => e.offsetWidth > 0);
+ if (cmds.length === 0) return null;
+ let el = cmds.find(e => norm(e.innerText).toLowerCase() === target);
+ if (!el) el = cmds.find(e => norm(e.innerText).toLowerCase().startsWith(target));
+ if (!el) el = cmds.find(e => norm(e.innerText).toLowerCase().includes(target));
+ return el ? el.id : null;
+ })()`);
+ }
+
+ // 1b. Command group headers on the function panel (eAccentColor labels).
+ // Match header text, then highlight the header + commands below it
+ // until the next spacer/header/end.
+ if (!elId) {
+ const groupDone = await page.evaluate(({ target, color, padding }) => {
+ const container = document.querySelector('#funcPanel_container');
+ if (!container) return false;
+ const norm = s => (s?.trim().replace(/\u00a0/g, ' ') || '').replace(/ё/gi, 'е').toLowerCase();
+ const headers = [...container.querySelectorAll('.eAccentColor')].filter(e => e.offsetWidth > 0);
+ if (!headers.length) return false;
+
+ let headerEl = headers.find(h => norm(h.textContent) === target);
+ if (!headerEl) headerEl = headers.find(h => norm(h.textContent).startsWith(target));
+ if (!headerEl) headerEl = headers.find(h => norm(h.textContent).includes(target));
+ if (!headerEl) return false;
+
+ // Collect header + following cmd siblings until next spacer/header
+ const parent = headerEl.parentElement;
+ const children = [...parent.children];
+ const startIdx = children.indexOf(headerEl);
+ const groupEls = [headerEl];
+ for (let i = startIdx + 1; i < children.length; i++) {
+ const el = children[i];
+ if (el.classList.contains('eAccentColor')) break;
+ if (!el.id && !el.classList.contains('functionItem') && el.getBoundingClientRect().width < 10) break;
+ groupEls.push(el);
+ }
+
+ // Bounding box
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ for (const el of groupEls) {
+ const r = el.getBoundingClientRect();
+ if (r.width === 0 && r.height === 0) continue;
+ minX = Math.min(minX, r.left); minY = Math.min(minY, r.top);
+ maxX = Math.max(maxX, r.right); maxY = Math.max(maxY, r.bottom);
+ }
+ if (minX === Infinity) return false;
+
+ let div = document.getElementById('__web_test_highlight');
+ if (!div) { div = document.createElement('div'); div.id = '__web_test_highlight'; document.body.appendChild(div); }
+ div.style.cssText = [
+ 'position:fixed', 'pointer-events:none', 'z-index:999998',
+ `top:${minY - padding}px`, `left:${minX - padding}px`,
+ `width:${maxX - minX + padding * 2}px`, `height:${maxY - minY + padding * 2}px`,
+ `outline:3px solid ${color}`, 'border-radius:4px',
+ `box-shadow:0 0 16px ${color}80`,
+ ].join(';');
+ return true;
+ }, { target: normYo(text.toLowerCase()), color, padding });
+ if (groupDone) return;
+ }
+
+ // 2. Form groups/panels — checked BEFORE buttons/fields because group names
+ // often collide with command bar buttons (e.g. "БизнесПроцессы" is both a
+ // panel and a command bar element). Includes _container and _div elements
+ // but skips logicGroupContainer (Representation=None, height=0).
+ if (!elId) {
+ const formNum = await page.evaluate(detectFormScript());
+ if (formNum !== null) {
+ elId = await page.evaluate(`(() => {
+ const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
+ const target = ${JSON.stringify(normYo(text.toLowerCase()))};
+ const p = 'form' + ${formNum} + '_';
+ // Group containers: _container or _div, but skip logicGroupContainer (invisible groups)
+ const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')]
+ .filter(el => el.offsetWidth > 0 && el.offsetHeight > 0 && !el.classList.contains('logicGroupContainer'));
+ const items = groups.map(el => {
+ const idName = el.id.replace(p, '').replace(/_(container|div)$/, '');
+ const titleEl = document.getElementById(p + idName + '#title_text')
+ || document.getElementById(p + idName + '_title_text');
+ const label = norm(titleEl?.innerText || '').toLowerCase();
+ const name = norm(idName).toLowerCase();
+ const big = el.offsetWidth >= 100 && el.offsetHeight >= 50;
+ return { id: el.id, name, label, big };
+ });
+ let found = items.find(i => i.label === target);
+ if (!found) found = items.find(i => i.name === target);
+ // Fuzzy match: only large groups (min 100x50) to avoid matching command bars
+ if (!found) found = items.filter(i => i.big).find(i => i.label.startsWith(target) || i.name.startsWith(target));
+ if (!found && target.length >= 4) found = items.filter(i => i.big).find(i => i.label.includes(target) || i.name.includes(target));
+ return found ? found.id : null;
+ })()`);
+ }
+ }
+
+ // 3. Form-scoped search (buttons, links, fields, grid rows)
+ if (!elId) {
+ const formNum = await page.evaluate(detectFormScript());
+ if (formNum !== null) {
+ // 3a. Try button/link/tab/gridRow via findClickTargetScript
+ let gridSelector;
+ if (table) {
+ const resolved = await page.evaluate(resolveGridScript(formNum, table));
+ if (!resolved.error) gridSelector = resolved.gridSelector;
+ }
+ const target = await page.evaluate(findClickTargetScript(formNum, text, table ? { tableName: table, gridSelector } : undefined));
+ if (target && !target.error) {
+ if (target.id) {
+ elId = target.id;
+ } else if (target.x && target.y) {
+ // Grid row — find the gridLine element and tag it
+ elId = await page.evaluate(`(() => {
+ const p = ${JSON.stringify(`form${formNum}_`)};
+ const grid = document.querySelector('[id^="' + p + '"].grid');
+ if (!grid) return null;
+ const body = grid.querySelector('.gridBody');
+ if (!body) return null;
+ const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
+ const target = ${JSON.stringify(normYo(text.toLowerCase()))};
+ for (const line of body.querySelectorAll('.gridLine')) {
+ const cells = [...line.querySelectorAll('.gridBoxText')].filter(b => b.offsetWidth > 0);
+ const rowText = cells.map(b => b.innerText?.trim() || '').join(' ').toLowerCase().replace(/ё/gi, 'е');
+ if (rowText.includes(target)) {
+ if (!line.id) line.id = '__wt_hl_tmp';
+ return line.id;
+ }
+ }
+ return null;
+ })()`);
+ }
+ }
+
+ // 3b. If not found as button — try as field via resolveFieldsScript
+ if (!elId) {
+ const dummyFields = { [text]: '' };
+ const resolved = await page.evaluate(resolveFieldsScript(formNum, dummyFields));
+ if (resolved?.length > 0 && !resolved[0].error && resolved[0].inputId) {
+ elId = resolved[0].inputId;
+ }
+ }
+ }
+ }
+
+ // 4. Fallback: sections (sidebar navigation)
+ if (!elId) {
+ elId = await page.evaluate(`(() => {
+ const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
+ const target = ${JSON.stringify(normYo(text.toLowerCase()))};
+ const secs = [...document.querySelectorAll('[id^="themesCell_theme_"]')];
+ let el = secs.find(e => norm(e.innerText).toLowerCase() === target);
+ if (!el) el = secs.find(e => norm(e.innerText).toLowerCase().startsWith(target));
+ if (!el) el = secs.find(e => norm(e.innerText).toLowerCase().includes(target));
+ return el ? el.id : null;
+ })()`);
+ }
+
+ if (!elId) {
+ // Collect available elements to help the caller fix the name
+ const available = await page.evaluate(`(() => {
+ const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е');
+ const result = {};
+ // Commands
+ const cmds = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(e => e.offsetWidth > 0).map(e => norm(e.innerText));
+ if (cmds.length) result.commands = cmds;
+ // Command group headers
+ const fp = document.querySelector('#funcPanel_container');
+ if (fp) {
+ const gh = [...fp.querySelectorAll('.eAccentColor')].filter(e => e.offsetWidth > 0).map(e => norm(e.textContent));
+ if (gh.length) result.commandGroups = gh;
+ }
+ // Sections
+ const secs = [...document.querySelectorAll('[id^="themesCell_theme_"]')].map(e => norm(e.innerText)).filter(Boolean);
+ if (secs.length) result.sections = secs;
+ // Form elements
+ ${(() => {
+ // Detect form inline to avoid extra evaluate round-trip
+ return `
+ const forms = {};
+ document.querySelectorAll('[id^="form"]').forEach(el => {
+ const m = el.id.match(/^form(\\d+)_/);
+ if (m) forms[m[1]] = (forms[m[1]] || 0) + 1;
+ });
+ let formNum = null, maxCount = 0;
+ for (const [n, c] of Object.entries(forms)) {
+ if (parseInt(n) > 0 && c > maxCount) { maxCount = c; formNum = n; }
+ }
+ if (formNum !== null) {
+ const p = 'form' + formNum + '_';
+ // Groups (_container or _div, skip logicGroupContainer, min 100x50)
+ const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')]
+ .filter(el => el.offsetWidth >= 100 && el.offsetHeight >= 50 && !el.classList.contains('logicGroupContainer'))
+ .map(el => {
+ const idName = el.id.replace(p, '').replace(/_(container|div)$/, '');
+ const titleEl = document.getElementById(p + idName + '#title_text') || document.getElementById(p + idName + '_title_text');
+ return norm(titleEl?.innerText || '') || idName;
+ }).filter(Boolean);
+ if (groups.length) result.groups = groups;
+ // Buttons/links
+ const btns = [...document.querySelectorAll('[id^="' + p + '"].btnText, [id^="' + p + '"] .btnText, [id^="' + p + '"].hplnk')]
+ .filter(el => el.offsetWidth > 0).map(el => norm(el.innerText)).filter(Boolean);
+ if (btns.length) result.buttons = [...new Set(btns)];
+ }`;
+ })()}
+ return result;
+ })()`);
+ const parts = [];
+ for (const [cat, items] of Object.entries(available)) {
+ parts.push(` ${cat}: ${items.join(', ')}`);
+ }
+ const hint = parts.length ? `\nAvailable:\n${parts.join('\n')}` : '';
+ throw new Error(`highlight: "${text}" not found${hint}`);
+ }
+
+ // Overlay div + rAF tracking loop (not clipped by overflow:hidden, follows layout shifts)
+ await page.evaluate(({ elId, color, padding }) => {
+ const target = document.getElementById(elId);
+ if (!target) return;
+ let div = document.getElementById('__web_test_highlight');
+ if (!div) {
+ div = document.createElement('div');
+ div.id = '__web_test_highlight';
+ document.body.appendChild(div);
+ }
+ function sync() {
+ const r = target.getBoundingClientRect();
+ div.style.cssText = [
+ 'position:fixed', 'pointer-events:none', 'z-index:999998',
+ `top:${r.y - padding}px`, `left:${r.x - padding}px`,
+ `width:${r.width + padding * 2}px`, `height:${r.height + padding * 2}px`,
+ `outline:3px solid ${color}`, 'border-radius:4px',
+ `box-shadow:0 0 16px ${color}80`,
+ ].join(';');
+ }
+ sync();
+ // Track position changes via rAF
+ function tick() {
+ if (!document.getElementById('__web_test_highlight')) return; // stopped
+ sync();
+ requestAnimationFrame(tick);
+ }
+ requestAnimationFrame(tick);
+ }, { elId, color, padding });
+}
+
+/** Remove the highlight overlay. */
+export async function unhighlight() {
+ ensureConnected();
+ await page.evaluate(() => {
+ const el = document.getElementById('__web_test_highlight');
+ if (el) el.remove(); // also stops rAF loop (id check)
+ // Clean up temp ID from grid rows
+ const tmp = document.getElementById('__wt_hl_tmp');
+ if (tmp) tmp.removeAttribute('id');
+ });
+}
+
+/**
+ * Toggle auto-highlight mode. When enabled, clickElement/fillFields/selectValue
+ * automatically highlight the target element before acting.
+ * @param {boolean} on true to enable, false to disable
+ */
+export function setHighlight(on) {
+ setHighlightMode(!!on);
+}
+
+/** @returns {boolean} Whether auto-highlight mode is active. */
+export function isHighlightMode() {
+ return highlightMode;
+}
diff --git a/.claude/skills/web-test/scripts/recording/narration.mjs b/.claude/skills/web-test/scripts/recording/narration.mjs
new file mode 100644
index 00000000..dff34e0a
--- /dev/null
+++ b/.claude/skills/web-test/scripts/recording/narration.mjs
@@ -0,0 +1,196 @@
+// web-test recording/narration v1.16 — Post-process: generate TTS audio for captions and merge with recorded video.
+// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
+
+import { execFileSync } from 'child_process';
+import { existsSync as fsExistsSync, mkdirSync, readFileSync, rmSync, statSync } from 'fs';
+import { extname, join as pathJoin } from 'path';
+import { tmpdir } from 'os';
+import {
+ lastCaptions, lastRecordingDuration, resolveProjectPath,
+} from '../core/state.mjs';
+import {
+ resolveFfmpeg, getTtsProvider, getAudioDuration, generateSilence,
+} from './tts.mjs';
+
+/**
+ * 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, voice?: string}>} [opts.captions] — explicit captions (default: from last recording or .captions.json). Each caption may include a `voice` field to override the global voice for that segment
+ * @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 = {}) {
+ if (!videoPath) return { file: null, duration: 0, size: 0, captions: 0 };
+ videoPath = resolveProjectPath(videoPath);
+ 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`);
+ const capTtsOpts = cap.voice ? { ...ttsOpts, voice: cap.voice } : ttsOpts;
+ try {
+ await ttsProvider(cap.speech, ttsFile, capTtsOpts);
+ } catch (err) {
+ // Retry once
+ try {
+ await ttsProvider(cap.speech, ttsFile, capTtsOpts);
+ } 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 slightly if it's longer than gap to next caption (max 1.3x)
+ 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;
+ if (tempo <= 1.3) {
+ filters.push(`atempo=${tempo.toFixed(4)}`);
+ } else {
+ // Too fast — let audio overlap instead of distorting
+ warnings.push(`Caption ${i + 1}/${captions.length}: TTS ${ttsDuration.toFixed(1)}s > gap ${maxDuration.toFixed(1)}s (need ${Math.round(ttsDuration - maxDuration)}s more pause)`);
+ }
+ }
+ }
+
+ // Delay to exact caption timestamp (milliseconds)
+ if (captionTimeMs > 0) {
+ filters.push(`adelay=${captionTimeMs}|${captionTimeMs}`);
+ }
+
+ const label = `a${i}`;
+ mixLabels.push(`[${label}]`);
+ // Input indices are shifted by 1 because silence reference is input [0]
+ filterParts.push(`[${i + 1}]${filters.length ? filters.join(',') : 'acopy'}[${label}]`);
+ }
+
+ // Generate a silence reference track as input [0] so amix runs for full video duration
+ const silencePath = pathJoin(tempDir, 'silence.mp3');
+ generateSilence(silencePath, Math.ceil(videoDuration), ffmpegPath);
+
+ const filterComplex = filterParts.join(';') + ';' +
+ `[0]${mixLabels.join('')}amix=inputs=${captions.length + 1}:normalize=0:duration=first`;
+
+ const narrationPath = pathJoin(tempDir, 'narration.mp3');
+ execFileSync(ffmpegPath, [
+ '-y', '-i', silencePath, ...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',
+ '-t', String(Math.ceil(videoDuration)),
+ '-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 {}
+ }
+}
diff --git a/.claude/skills/web-test/scripts/recording/tts.mjs b/.claude/skills/web-test/scripts/recording/tts.mjs
new file mode 100644
index 00000000..0a965fb0
--- /dev/null
+++ b/.claude/skills/web-test/scripts/recording/tts.mjs
@@ -0,0 +1,175 @@
+// web-test recording/tts v1.16 — TTS providers (edge/openai/elevenlabs) and ffmpeg/ffprobe helpers.
+// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
+
+import { execFileSync, spawn } from 'child_process';
+import { existsSync as fsExistsSync, writeFileSync } from 'fs';
+import { resolve as pathResolve } from 'path';
+import { pathToFileURL } from 'url';
+import { projectRoot } from '../core/state.mjs';
+
+/** Resolve ffmpeg binary path. */
+export function resolveFfmpeg(explicit) {
+ // 1. Explicit path
+ if (explicit) {
+ try { execFileSync(explicit, ['-version'], { stdio: 'ignore', timeout: 5000 }); return explicit; }
+ catch { throw new Error(`ffmpeg not found at: ${explicit}`); }
+ }
+
+ // 2. FFMPEG_PATH env var
+ const envPath = process.env.FFMPEG_PATH;
+ if (envPath) {
+ try { execFileSync(envPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return envPath; }
+ catch { /* fall through */ }
+ }
+
+ // 3. System PATH
+ try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore', timeout: 5000 }); return 'ffmpeg'; }
+ catch { /* fall through */ }
+
+ // 4. tools/ffmpeg/bin/ffmpeg.exe relative to project root
+ const localPath = pathResolve(projectRoot, 'tools', 'ffmpeg', 'bin', 'ffmpeg.exe');
+ if (fsExistsSync(localPath)) {
+ try { execFileSync(localPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return localPath; }
+ catch { /* fall through */ }
+ }
+
+ // 5. Error with instructions
+ throw new Error(
+ 'ffmpeg not found. Install it:\n' +
+ ' - Download from https://www.gyan.dev/ffmpeg/builds/ (essentials build)\n' +
+ ' - Add to PATH, or set FFMPEG_PATH env var, or place in tools/ffmpeg/bin/\n' +
+ ' - Or pass ffmpegPath option to startRecording()'
+ );
+}
+
+// ── TTS providers ──────────────────────────────────────────────────────────
+
+/** Resolve node-edge-tts module: global install → tools/tts/ → error with instructions. */
+let _edgeTtsModule = null;
+export 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 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 }
+ */
+export 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 }
+ */
+export 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 }
+ */
+export 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. */
+export 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
+ */
+export 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
+ */
+export 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 });
+}