From 8f59d3bc66c7a5ccaf2f48e14333bdbdb1205f7a Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Thu, 12 Mar 2026 13:22:16 +0300 Subject: [PATCH] fix(web-test): sync video timeline with wall-clock during static pauses CDP screencast doesn't send frames for static pages, causing video to be shorter than real time (gap-fill capped at 2s, smart pauses are 4-6s). - Add _flushFrames() helper on recorder to write duplicate frames on demand - Call _flushFrames() every 1s during smart TTS pauses in showCaption - Call _flushFrames() in wait() for long explicit pauses during recording - Call _flushFrames() in stopRecording for final gap before closing ffmpeg - Increase gap-fill cap from fps*2 to fps*30 as safety net Co-Authored-By: Claude Opus 4.6 --- .claude/skills/web-test/scripts/browser.mjs | 51 +++++++++++++++++++-- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 2d77b891..403f3309 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -2963,7 +2963,21 @@ export async function wait(seconds) { ms = Math.max(0, ms - credit); recorder.captionCredit = null; } - if (ms > 0) await page.waitForTimeout(ms); + 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(); } @@ -3039,7 +3053,7 @@ export async function startRecording(outputPath, opts = {}) { // Fill the gap with duplicates of the previous frame const gap = now - lastFrameTime; const dupes = Math.round(gap / frameDuration) - 1; - for (let i = 0; i < dupes && i < fps * 2; i++) { + for (let i = 0; i < dupes && i < fps * 30; i++) { ffmpeg.stdin.write(lastFrameBuf); framesWritten++; } @@ -3062,7 +3076,23 @@ export async function startRecording(outputPath, opts = {}) { everyNthFrame: 1 }); - recorder = { cdp, ffmpeg, startTime: Date.now(), outputPath: resolvedPath, ffmpegError: '', captions: [], videoTimeMs: 0 }; + // Expose a frame-writing helper on the recorder object. + // During static periods (e.g. smart TTS pauses), CDP may not send screencast + // frames. Call _flushFrames() to fill the gap with duplicates of the last frame, + // keeping video timeline in sync with wall-clock time. + const _flushFrames = () => { + if (!lastFrameBuf || !lastFrameTime || ffmpeg.stdin.destroyed) return; + const now = Date.now(); + const gap = now - lastFrameTime; + const dupes = Math.round(gap / frameDuration); + for (let i = 0; i < dupes; i++) { + ffmpeg.stdin.write(lastFrameBuf); + if (recorder) recorder.videoTimeMs += frameDuration; + } + if (dupes > 0) lastFrameTime = now; + }; + + recorder = { cdp, ffmpeg, startTime: Date.now(), outputPath: resolvedPath, ffmpegError: '', captions: [], videoTimeMs: 0, _flushFrames }; // Redirect stderr accumulation to the recorder object ffmpeg.stderr.removeAllListeners('data'); ffmpeg.stderr.on('data', d => { recorder.ffmpegError += d.toString(); }); @@ -3077,6 +3107,9 @@ export async function stopRecording() { 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 {} @@ -3172,9 +3205,17 @@ export async function showCaption(text, opts = {}) { el.textContent = text; }, { text, position, fontSize, bg, color }); - // Smart TTS wait: pause for estimated speech duration so video has enough screen time + // 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) { - await page.waitForTimeout(smartWaitMs); + 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() }; } }