mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-06-15 10:24:57 +03:00
feat(web-test): T4.5 — мульти-контекстная запись видео
browser.mjs v1.11: recorder стал глобальным (не per-slot) — один ffmpeg, один mp4 на тест с любым числом переключений контекста. Frame state (lastFrameBuf/lastFrameTime/handler) переехал в поля recorder. Добавлен recorder._attachPage(targetPage) — стопает старый CDP screencast, заводит новый на нужной странице, route'ит фреймы в тот же ffmpeg pipe. setActiveContext: при активной записи делает _flushFrames (замораживает хвост уходящего окна), затем _attachPage(page) после _activateSlot. Видео получается непрерывным с плавным сюжетом — пока активен a, видно a; пока активен b, видно b. _saveActiveSlot/_activateSlot больше не трогают recorder/lastCaptions/ lastRecordingDuration — recorder следует за активной страницей через _attachPage, не через slot mirror. disconnect: убрал leftover из T4.1, который пытался итерировать slot.recorder. Live: 15-multi-context-handover с --record → 17.84s mp4, 446 кадров @ 25fps, извлечённые кадры показывают переключение между окнами a (1920x1042) и b (982x546). Полный регресс 11/12 (04-selectvalue — pre-existing flake). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
// web-test browser v1.10 — Playwright browser management for 1C web client
|
// web-test browser v1.11 — Playwright browser management for 1C web client
|
||||||
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
|
||||||
/**
|
/**
|
||||||
* Playwright browser management for 1C web client.
|
* Playwright browser management for 1C web client.
|
||||||
@@ -169,18 +169,12 @@ export async function connect(url, { extensionPath } = {}) {
|
|||||||
* Sends POST /e1cib/logout to release the license before closing.
|
* Sends POST /e1cib/logout to release the license before closing.
|
||||||
*/
|
*/
|
||||||
export async function disconnect() {
|
export async function disconnect() {
|
||||||
// Multi-context path: stop recordings + logout each slot before closing browser
|
// Multi-context path: stop recording + logout each slot before closing browser
|
||||||
if (contexts.size > 0) {
|
if (contexts.size > 0) {
|
||||||
// Save current active first so iteration is consistent
|
|
||||||
_saveActiveSlot();
|
_saveActiveSlot();
|
||||||
for (const [name, slot] of contexts.entries()) {
|
// Recorder is global — one stop covers all contexts
|
||||||
// Stop recording per slot if any
|
if (recorder) {
|
||||||
if (slot.recorder) {
|
try { await stopRecording(); } catch {}
|
||||||
_activateSlot(name);
|
|
||||||
try { await stopRecording(); } catch {}
|
|
||||||
// re-save in case stopRecording mutated state
|
|
||||||
_saveActiveSlot();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for (const [, slot] of contexts.entries()) {
|
for (const [, slot] of contexts.entries()) {
|
||||||
if (slot.page && !slot.page.isClosed() && slot.seanceId && slot.sessionPrefix) {
|
if (slot.page && !slot.page.isClosed() && slot.seanceId && slot.sessionPrefix) {
|
||||||
@@ -277,10 +271,10 @@ function _saveActiveSlot() {
|
|||||||
slot.page = page;
|
slot.page = page;
|
||||||
slot.sessionPrefix = sessionPrefix;
|
slot.sessionPrefix = sessionPrefix;
|
||||||
slot.seanceId = seanceId;
|
slot.seanceId = seanceId;
|
||||||
slot.recorder = recorder;
|
|
||||||
slot.lastCaptions = lastCaptions;
|
|
||||||
slot.lastRecordingDuration = lastRecordingDuration;
|
|
||||||
slot.highlightMode = highlightMode;
|
slot.highlightMode = highlightMode;
|
||||||
|
// Note: `recorder`, `lastCaptions`, `lastRecordingDuration` are intentionally NOT
|
||||||
|
// mirrored per-slot. A multi-context recording produces one continuous output file —
|
||||||
|
// the recorder follows the active page via recorder._attachPage(), not per-slot state.
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load a slot's state into module-level vars and mark it active. */
|
/** Load a slot's state into module-level vars and mark it active. */
|
||||||
@@ -290,9 +284,6 @@ function _activateSlot(name) {
|
|||||||
page = slot.page;
|
page = slot.page;
|
||||||
sessionPrefix = slot.sessionPrefix;
|
sessionPrefix = slot.sessionPrefix;
|
||||||
seanceId = slot.seanceId;
|
seanceId = slot.seanceId;
|
||||||
recorder = slot.recorder;
|
|
||||||
lastCaptions = slot.lastCaptions || [];
|
|
||||||
lastRecordingDuration = slot.lastRecordingDuration;
|
|
||||||
highlightMode = slot.highlightMode || false;
|
highlightMode = slot.highlightMode || false;
|
||||||
activeContextName = name;
|
activeContextName = name;
|
||||||
}
|
}
|
||||||
@@ -382,8 +373,16 @@ export async function createContext(name, url, { extensionPath } = {}) {
|
|||||||
export async function setActiveContext(name) {
|
export async function setActiveContext(name) {
|
||||||
if (activeContextName === name) return;
|
if (activeContextName === name) return;
|
||||||
if (!contexts.has(name)) throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`);
|
if (!contexts.has(name)) throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`);
|
||||||
|
// If a recording is active, flush the outgoing page's last frame so the gap is filled
|
||||||
|
// up to the moment of the switch (avoids a "jump" in video time).
|
||||||
|
if (recorder && recorder._flushFrames) recorder._flushFrames();
|
||||||
_saveActiveSlot();
|
_saveActiveSlot();
|
||||||
_activateSlot(name);
|
_activateSlot(name);
|
||||||
|
// If the recording is still alive (it lives across slots — we keep the same ffmpeg/output),
|
||||||
|
// re-attach its screencast to the newly active page.
|
||||||
|
if (recorder && recorder._attachPage) {
|
||||||
|
await recorder._attachPage(page);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listContexts() {
|
export function listContexts() {
|
||||||
@@ -5031,10 +5030,7 @@ export async function startRecording(outputPath, opts = {}) {
|
|||||||
const resolvedPath = resolveProjectPath(outputPath);
|
const resolvedPath = resolveProjectPath(outputPath);
|
||||||
mkdirSync(dirname(resolvedPath), { recursive: true });
|
mkdirSync(dirname(resolvedPath), { recursive: true });
|
||||||
|
|
||||||
// Create CDP session for screencast
|
// Spawn ffmpeg process — single output file across context switches
|
||||||
const cdp = await page.context().newCDPSession(page);
|
|
||||||
|
|
||||||
// Spawn ffmpeg process
|
|
||||||
const ffmpeg = spawn(ffmpegPath, [
|
const ffmpeg = spawn(ffmpegPath, [
|
||||||
'-y', // overwrite output
|
'-y', // overwrite output
|
||||||
'-f', 'image2pipe', // input: piped images
|
'-f', 'image2pipe', // input: piped images
|
||||||
@@ -5050,71 +5046,86 @@ export async function startRecording(outputPath, opts = {}) {
|
|||||||
resolvedPath
|
resolvedPath
|
||||||
], { stdio: ['pipe', 'ignore', 'pipe'] });
|
], { stdio: ['pipe', 'ignore', 'pipe'] });
|
||||||
|
|
||||||
let ffmpegError = '';
|
ffmpeg.on('error', err => { if (recorder) recorder.ffmpegError += err.message; });
|
||||||
ffmpeg.stderr.on('data', d => { ffmpegError += d.toString(); });
|
|
||||||
ffmpeg.on('error', err => { ffmpegError += err.message; });
|
|
||||||
|
|
||||||
// Listen for screencast frames and pipe to ffmpeg
|
|
||||||
// CDP sends frames only on screen changes, so we duplicate frames
|
|
||||||
// to fill gaps and maintain real-time playback speed
|
|
||||||
const frameDuration = 1000 / fps;
|
const frameDuration = 1000 / fps;
|
||||||
let lastFrameTime = null;
|
const speechRate = opts.speechRate || 70; // ms per character for smart TTS wait
|
||||||
let lastFrameBuf = null;
|
|
||||||
|
|
||||||
cdp.on('Page.screencastFrame', async ({ data, sessionId }) => {
|
// 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 buf = Buffer.from(data, 'base64');
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
if (!ffmpeg.stdin.destroyed) {
|
if (!ffmpeg.stdin.destroyed) {
|
||||||
let framesWritten = 0;
|
let framesWritten = 0;
|
||||||
if (lastFrameTime && lastFrameBuf) {
|
if (recorder.lastFrameTime && recorder.lastFrameBuf) {
|
||||||
// Fill the gap with duplicates of the previous frame
|
const gap = now - recorder.lastFrameTime;
|
||||||
const gap = now - lastFrameTime;
|
|
||||||
const dupes = Math.round(gap / frameDuration) - 1;
|
const dupes = Math.round(gap / frameDuration) - 1;
|
||||||
for (let i = 0; i < dupes && i < fps * 30; i++) {
|
for (let i = 0; i < dupes && i < fps * 30; i++) {
|
||||||
ffmpeg.stdin.write(lastFrameBuf);
|
ffmpeg.stdin.write(recorder.lastFrameBuf);
|
||||||
framesWritten++;
|
framesWritten++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ffmpeg.stdin.write(buf);
|
ffmpeg.stdin.write(buf);
|
||||||
framesWritten++;
|
framesWritten++;
|
||||||
// Track actual video timeline position (accounts for frame duplication)
|
recorder.videoTimeMs += framesWritten * frameDuration;
|
||||||
if (recorder) recorder.videoTimeMs += framesWritten * frameDuration;
|
|
||||||
}
|
}
|
||||||
|
recorder.lastFrameTime = now;
|
||||||
lastFrameTime = now;
|
recorder.lastFrameBuf = buf;
|
||||||
lastFrameBuf = buf;
|
|
||||||
try { await cdp.send('Page.screencastFrameAck', { sessionId }); } catch {}
|
try { await cdp.send('Page.screencastFrameAck', { sessionId }); } catch {}
|
||||||
});
|
|
||||||
|
|
||||||
// Start the screencast
|
|
||||||
await cdp.send('Page.startScreencast', {
|
|
||||||
format: 'jpeg',
|
|
||||||
quality,
|
|
||||||
everyNthFrame: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const speechRate = opts.speechRate || 70; // ms per character for smart TTS wait
|
// Duplicate the last frame to fill wall-clock gaps (static periods, context switches).
|
||||||
recorder = { cdp, ffmpeg, startTime: Date.now(), outputPath: resolvedPath, ffmpegError: '', captions: [], videoTimeMs: 0, _flushFrames, speechRate };
|
const _flushFrames = () => {
|
||||||
// Redirect stderr accumulation to the recorder object
|
if (!recorder || !recorder.lastFrameBuf || !recorder.lastFrameTime || ffmpeg.stdin.destroyed) return;
|
||||||
ffmpeg.stderr.removeAllListeners('data');
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder = {
|
||||||
|
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(); });
|
ffmpeg.stderr.on('data', d => { recorder.ffmpegError += d.toString(); });
|
||||||
|
|
||||||
|
await _attachPage(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user