feat(soak): Screencaster — CDP Page.startScreencast wrapper
Attach/detach CDP sessions per Playwright Page, start/stop JPEG screencasts with configurable quality and frame rate, forward each frame to a callback. Used by the dashboard for click-to-watch live video (wired in Task 23). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
89
tests/soak/core/screencaster.ts
Normal file
89
tests/soak/core/screencaster.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Screencaster — CDP Page.startScreencast wrapper for live video.
|
||||||
|
*
|
||||||
|
* Attach a CDP session to a Playwright Page, start emitting JPEG
|
||||||
|
* frames at the configured frame rate, forward each frame to a
|
||||||
|
* callback, detach on stop. Used by the dashboard's click-to-watch
|
||||||
|
* feature: when a user clicks a player tile, the runner calls
|
||||||
|
* `start(key, page, frame => ws.send(...))`; when they close the
|
||||||
|
* modal it calls `stop(key)`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Page, CDPSession } from 'playwright-core';
|
||||||
|
import type { Logger } from './types';
|
||||||
|
|
||||||
|
export interface ScreencastOptions {
|
||||||
|
format?: 'jpeg' | 'png';
|
||||||
|
quality?: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
everyNthFrame?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FrameCallback = (jpegBase64: string) => void;
|
||||||
|
|
||||||
|
export class Screencaster {
|
||||||
|
private sessions = new Map<string, CDPSession>();
|
||||||
|
|
||||||
|
constructor(private logger: Logger) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach a CDP session to the given page and start forwarding frames.
|
||||||
|
* If a screencast is already running for this sessionKey, no-op.
|
||||||
|
*/
|
||||||
|
async start(
|
||||||
|
sessionKey: string,
|
||||||
|
page: Page,
|
||||||
|
onFrame: FrameCallback,
|
||||||
|
opts: ScreencastOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
|
if (this.sessions.has(sessionKey)) {
|
||||||
|
this.logger.warn('screencast_already_running', { sessionKey });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const client = await page.context().newCDPSession(page);
|
||||||
|
this.sessions.set(sessionKey, client);
|
||||||
|
|
||||||
|
client.on('Page.screencastFrame', async (evt: { data: string; sessionId: number }) => {
|
||||||
|
try {
|
||||||
|
onFrame(evt.data);
|
||||||
|
await client.send('Page.screencastFrameAck', { sessionId: evt.sessionId });
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn('screencast_frame_error', {
|
||||||
|
sessionKey,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.send('Page.startScreencast', {
|
||||||
|
format: opts.format ?? 'jpeg',
|
||||||
|
quality: opts.quality ?? 60,
|
||||||
|
maxWidth: opts.maxWidth ?? 640,
|
||||||
|
maxHeight: opts.maxHeight ?? 360,
|
||||||
|
everyNthFrame: opts.everyNthFrame ?? 2,
|
||||||
|
});
|
||||||
|
this.logger.info('screencast_started', { sessionKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(sessionKey: string): Promise<void> {
|
||||||
|
const client = this.sessions.get(sessionKey);
|
||||||
|
if (!client) return;
|
||||||
|
try {
|
||||||
|
await client.send('Page.stopScreencast');
|
||||||
|
await client.detach();
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn('screencast_stop_error', {
|
||||||
|
sessionKey,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.sessions.delete(sessionKey);
|
||||||
|
this.logger.info('screencast_stopped', { sessionKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopAll(): Promise<void> {
|
||||||
|
const keys = Array.from(this.sessions.keys());
|
||||||
|
await Promise.all(keys.map((k) => this.stop(k)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user