feat: Add video recording with Playwright native video (#116)
* feat: add video recording with Playwright native video Adds `record start/stop` commands using Playwright's built-in video recording. No external dependencies required (no FFmpeg). Usage: agent-browser record start ./demo.webm https://example.com agent-browser click @e1 agent-browser record stop Recording creates a fresh browser context with video enabled. For smooth demos, explore the page first to plan actions, then start recording. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: auto-capture URL and transfer state for recording When starting a recording without a URL: - Automatically captures current page URL - Preserves cookies and localStorage from current session This enables a seamless workflow: agent-browser open https://app.example.com agent-browser snapshot -i # explore, plan agent-browser record start ./demo.webm # picks up URL + auth state agent-browser click @e3 agent-browser record stop Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: error on non-webm recording path instead of silent coercion Previously, specifying a non-.webm path like ./demo.mp4 would silently change it to ./demo.webm. Now it throws a clear error telling the user that Playwright native recording only supports WebM format. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: clean up recording temp directory after stopRecording Previously the temp directory was created but never deleted, relying on OS cleanup. Now we explicitly remove it after saving the video, in both success and error paths. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: add record restart command Adds `record restart` command that stops the current recording (if any) and starts a new one. Also improves the error message when trying to start recording while already recording. Changes: - Add restartRecording method to BrowserManager - Add recording_restart action to protocol, types, and actions - Add CLI parsing for `record restart <path> [url]` - Update help text and skill documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: add CLI tests for record restart command Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Chris Tate <chris@ctate.dev>
This commit is contained in:
co-authored by
Claude Opus 4.5
Chris Tate
parent
3675e6bd7a
commit
1f31452fea
@@ -99,6 +99,9 @@ import type {
|
||||
InputMouseCommand,
|
||||
InputKeyboardCommand,
|
||||
InputTouchCommand,
|
||||
RecordingStartCommand,
|
||||
RecordingStopCommand,
|
||||
RecordingRestartCommand,
|
||||
NavigateData,
|
||||
ScreenshotData,
|
||||
EvaluateData,
|
||||
@@ -109,6 +112,9 @@ import type {
|
||||
TabCloseData,
|
||||
ScreencastStartData,
|
||||
ScreencastStopData,
|
||||
RecordingStartData,
|
||||
RecordingStopData,
|
||||
RecordingRestartData,
|
||||
InputEventData,
|
||||
} from './types.js';
|
||||
import { successResponse, errorResponse } from './protocol.js';
|
||||
@@ -427,6 +433,12 @@ export async function executeCommand(command: Command, browser: BrowserManager):
|
||||
return await handleInputKeyboard(command, browser);
|
||||
case 'input_touch':
|
||||
return await handleInputTouch(command, browser);
|
||||
case 'recording_start':
|
||||
return await handleRecordingStart(command, browser);
|
||||
case 'recording_stop':
|
||||
return await handleRecordingStop(command, browser);
|
||||
case 'recording_restart':
|
||||
return await handleRecordingRestart(command, browser);
|
||||
default: {
|
||||
// TypeScript narrows to never here, but we handle it for safety
|
||||
const unknownCommand = command as { id: string; action: string };
|
||||
@@ -1886,3 +1898,37 @@ async function handleInputTouch(
|
||||
});
|
||||
return successResponse(command.id, { injected: true });
|
||||
}
|
||||
|
||||
// Recording handlers (Playwright native video recording)
|
||||
|
||||
async function handleRecordingStart(
|
||||
command: RecordingStartCommand,
|
||||
browser: BrowserManager
|
||||
): Promise<Response<RecordingStartData>> {
|
||||
await browser.startRecording(command.path, command.url);
|
||||
return successResponse(command.id, {
|
||||
started: true,
|
||||
path: command.path,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRecordingStop(
|
||||
command: RecordingStopCommand,
|
||||
browser: BrowserManager
|
||||
): Promise<Response<RecordingStopData>> {
|
||||
const result = await browser.stopRecording();
|
||||
return successResponse(command.id, result);
|
||||
}
|
||||
|
||||
async function handleRecordingRestart(
|
||||
command: RecordingRestartCommand,
|
||||
browser: BrowserManager
|
||||
): Promise<Response<RecordingRestartData>> {
|
||||
const result = await browser.restartRecording(command.path, command.url);
|
||||
return successResponse(command.id, {
|
||||
started: true,
|
||||
path: command.path,
|
||||
previousPath: result.previousPath,
|
||||
stopped: result.stopped,
|
||||
});
|
||||
}
|
||||
|
||||
+234
@@ -12,9 +12,11 @@ import {
|
||||
type Route,
|
||||
type Locator,
|
||||
type CDPSession,
|
||||
type Video,
|
||||
} from 'playwright-core';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import type { LaunchCommand } from './types.js';
|
||||
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
|
||||
|
||||
@@ -89,6 +91,12 @@ export class BrowserManager {
|
||||
private frameCallback: ((frame: ScreencastFrame) => void) | null = null;
|
||||
private screencastFrameHandler: ((params: any) => void) | null = null;
|
||||
|
||||
// Video recording (Playwright native)
|
||||
private recordingContext: BrowserContext | null = null;
|
||||
private recordingPage: Page | null = null;
|
||||
private recordingOutputPath: string = '';
|
||||
private recordingTempDir: string = '';
|
||||
|
||||
/**
|
||||
* Check if browser is launched
|
||||
*/
|
||||
@@ -1108,10 +1116,236 @@ export class BrowserManager {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if video recording is currently active
|
||||
*/
|
||||
isRecording(): boolean {
|
||||
return this.recordingContext !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start recording to a video file using Playwright's native video recording.
|
||||
* Creates a fresh browser context with video recording enabled.
|
||||
* Automatically captures current URL and transfers cookies/storage if no URL provided.
|
||||
*
|
||||
* @param outputPath - Path to the output video file (will be .webm)
|
||||
* @param url - Optional URL to navigate to (defaults to current page URL)
|
||||
*/
|
||||
async startRecording(outputPath: string, url?: string): Promise<void> {
|
||||
if (this.recordingContext) {
|
||||
throw new Error(
|
||||
"Recording already in progress. Run 'record stop' first, or use 'record restart' to stop and start a new recording."
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.browser) {
|
||||
throw new Error('Browser not launched. Call launch first.');
|
||||
}
|
||||
|
||||
// Check if output file already exists
|
||||
if (existsSync(outputPath)) {
|
||||
throw new Error(`Output file already exists: ${outputPath}`);
|
||||
}
|
||||
|
||||
// Validate output path is .webm (Playwright native format)
|
||||
if (!outputPath.endsWith('.webm')) {
|
||||
throw new Error(
|
||||
'Playwright native recording only supports WebM format. Please use a .webm extension.'
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-capture current URL if none provided
|
||||
const currentPage = this.pages.length > 0 ? this.pages[this.activePageIndex] : null;
|
||||
const currentContext = this.contexts.length > 0 ? this.contexts[0] : null;
|
||||
if (!url && currentPage) {
|
||||
const currentUrl = currentPage.url();
|
||||
if (currentUrl && currentUrl !== 'about:blank') {
|
||||
url = currentUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Capture state from current context (cookies + storage)
|
||||
let storageState:
|
||||
| {
|
||||
cookies: Array<{
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number;
|
||||
httpOnly: boolean;
|
||||
secure: boolean;
|
||||
sameSite: 'Strict' | 'Lax' | 'None';
|
||||
}>;
|
||||
origins: Array<{
|
||||
origin: string;
|
||||
localStorage: Array<{ name: string; value: string }>;
|
||||
}>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (currentContext) {
|
||||
try {
|
||||
storageState = await currentContext.storageState();
|
||||
} catch {
|
||||
// Ignore errors - context might be closed or invalid
|
||||
}
|
||||
}
|
||||
|
||||
// Create a temp directory for video recording
|
||||
const session = process.env.AGENT_BROWSER_SESSION || 'default';
|
||||
this.recordingTempDir = path.join(
|
||||
os.tmpdir(),
|
||||
`agent-browser-recording-${session}-${Date.now()}`
|
||||
);
|
||||
mkdirSync(this.recordingTempDir, { recursive: true });
|
||||
|
||||
this.recordingOutputPath = outputPath;
|
||||
|
||||
// Create a new context with video recording enabled and restored state
|
||||
const viewport = { width: 1280, height: 720 };
|
||||
this.recordingContext = await this.browser.newContext({
|
||||
viewport,
|
||||
recordVideo: {
|
||||
dir: this.recordingTempDir,
|
||||
size: viewport,
|
||||
},
|
||||
storageState,
|
||||
});
|
||||
this.recordingContext.setDefaultTimeout(10000);
|
||||
|
||||
// Create a page in the recording context
|
||||
this.recordingPage = await this.recordingContext.newPage();
|
||||
|
||||
// Add the recording context and page to our managed lists
|
||||
this.contexts.push(this.recordingContext);
|
||||
this.pages.push(this.recordingPage);
|
||||
this.activePageIndex = this.pages.length - 1;
|
||||
|
||||
// Set up page tracking
|
||||
this.setupPageTracking(this.recordingPage);
|
||||
|
||||
// Invalidate CDP session since we switched pages
|
||||
await this.invalidateCDPSession();
|
||||
|
||||
// Navigate to URL if provided or captured
|
||||
if (url) {
|
||||
await this.recordingPage.goto(url, { waitUntil: 'load' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop recording and save the video file
|
||||
* @returns Recording result with path
|
||||
*/
|
||||
async stopRecording(): Promise<{ path: string; frames: number; error?: string }> {
|
||||
if (!this.recordingContext || !this.recordingPage) {
|
||||
return { path: '', frames: 0, error: 'No recording in progress' };
|
||||
}
|
||||
|
||||
const outputPath = this.recordingOutputPath;
|
||||
|
||||
try {
|
||||
// Get the video object before closing the page
|
||||
const video = this.recordingPage.video();
|
||||
|
||||
// Remove recording page/context from our managed lists before closing
|
||||
const pageIndex = this.pages.indexOf(this.recordingPage);
|
||||
if (pageIndex !== -1) {
|
||||
this.pages.splice(pageIndex, 1);
|
||||
}
|
||||
const contextIndex = this.contexts.indexOf(this.recordingContext);
|
||||
if (contextIndex !== -1) {
|
||||
this.contexts.splice(contextIndex, 1);
|
||||
}
|
||||
|
||||
// Close the page to finalize the video
|
||||
await this.recordingPage.close();
|
||||
|
||||
// Save the video to the desired output path
|
||||
if (video) {
|
||||
await video.saveAs(outputPath);
|
||||
}
|
||||
|
||||
// Clean up temp directory
|
||||
if (this.recordingTempDir) {
|
||||
rmSync(this.recordingTempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Close the recording context
|
||||
await this.recordingContext.close();
|
||||
|
||||
// Reset recording state
|
||||
this.recordingContext = null;
|
||||
this.recordingPage = null;
|
||||
this.recordingOutputPath = '';
|
||||
this.recordingTempDir = '';
|
||||
|
||||
// Adjust active page index
|
||||
if (this.pages.length > 0) {
|
||||
this.activePageIndex = Math.min(this.activePageIndex, this.pages.length - 1);
|
||||
} else {
|
||||
this.activePageIndex = 0;
|
||||
}
|
||||
|
||||
// Invalidate CDP session since we may have switched pages
|
||||
await this.invalidateCDPSession();
|
||||
|
||||
return { path: outputPath, frames: 0 }; // Playwright doesn't expose frame count
|
||||
} catch (error) {
|
||||
// Clean up temp directory on error
|
||||
if (this.recordingTempDir) {
|
||||
rmSync(this.recordingTempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Reset state on error
|
||||
this.recordingContext = null;
|
||||
this.recordingPage = null;
|
||||
this.recordingOutputPath = '';
|
||||
this.recordingTempDir = '';
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { path: outputPath, frames: 0, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart recording - stops current recording (if any) and starts a new one.
|
||||
* Convenience method that combines stopRecording and startRecording.
|
||||
*
|
||||
* @param outputPath - Path to the output video file (must be .webm)
|
||||
* @param url - Optional URL to navigate to (defaults to current page URL)
|
||||
* @returns Result from stopping the previous recording (if any)
|
||||
*/
|
||||
async restartRecording(
|
||||
outputPath: string,
|
||||
url?: string
|
||||
): Promise<{ previousPath?: string; stopped: boolean }> {
|
||||
let previousPath: string | undefined;
|
||||
let stopped = false;
|
||||
|
||||
// Stop current recording if active
|
||||
if (this.recordingContext) {
|
||||
const result = await this.stopRecording();
|
||||
previousPath = result.path;
|
||||
stopped = true;
|
||||
}
|
||||
|
||||
// Start new recording
|
||||
await this.startRecording(outputPath, url);
|
||||
|
||||
return { previousPath, stopped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the browser and clean up
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
// Stop recording if active (saves video)
|
||||
if (this.recordingContext) {
|
||||
await this.stopRecording();
|
||||
}
|
||||
|
||||
// Stop screencast if active
|
||||
if (this.screencastActive) {
|
||||
await this.stopScreencast();
|
||||
|
||||
@@ -315,6 +315,23 @@ const videoStopSchema = baseCommandSchema.extend({
|
||||
action: z.literal('video_stop'),
|
||||
});
|
||||
|
||||
// Recording schemas (Playwright native video recording)
|
||||
const recordingStartSchema = baseCommandSchema.extend({
|
||||
action: z.literal('recording_start'),
|
||||
path: z.string().min(1),
|
||||
url: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const recordingStopSchema = baseCommandSchema.extend({
|
||||
action: z.literal('recording_stop'),
|
||||
});
|
||||
|
||||
const recordingRestartSchema = baseCommandSchema.extend({
|
||||
action: z.literal('recording_restart'),
|
||||
path: z.string().min(1),
|
||||
url: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const traceStartSchema = baseCommandSchema.extend({
|
||||
action: z.literal('trace_start'),
|
||||
screenshots: z.boolean().optional(),
|
||||
@@ -804,6 +821,9 @@ const commandSchema = z.discriminatedUnion('action', [
|
||||
boundingBoxSchema,
|
||||
videoStartSchema,
|
||||
videoStopSchema,
|
||||
recordingStartSchema,
|
||||
recordingStopSchema,
|
||||
recordingRestartSchema,
|
||||
traceStartSchema,
|
||||
traceStopSchema,
|
||||
harStartSchema,
|
||||
|
||||
+39
-1
@@ -508,7 +508,7 @@ export interface InputTouchCommand extends BaseCommand {
|
||||
modifiers?: number;
|
||||
}
|
||||
|
||||
// Video recording
|
||||
// Video recording (Playwright native - requires launch-time setup)
|
||||
export interface VideoStartCommand extends BaseCommand {
|
||||
action: 'video_start';
|
||||
path: string;
|
||||
@@ -518,6 +518,23 @@ export interface VideoStopCommand extends BaseCommand {
|
||||
action: 'video_stop';
|
||||
}
|
||||
|
||||
// Screen recording (Playwright native - creates fresh recording context)
|
||||
export interface RecordingStartCommand extends BaseCommand {
|
||||
action: 'recording_start';
|
||||
path: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface RecordingStopCommand extends BaseCommand {
|
||||
action: 'recording_stop';
|
||||
}
|
||||
|
||||
export interface RecordingRestartCommand extends BaseCommand {
|
||||
action: 'recording_restart';
|
||||
path: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
// Tracing
|
||||
export interface TraceStartCommand extends BaseCommand {
|
||||
action: 'trace_start';
|
||||
@@ -841,6 +858,9 @@ export type Command =
|
||||
| BoundingBoxCommand
|
||||
| VideoStartCommand
|
||||
| VideoStopCommand
|
||||
| RecordingStartCommand
|
||||
| RecordingStopCommand
|
||||
| RecordingRestartCommand
|
||||
| TraceStartCommand
|
||||
| TraceStopCommand
|
||||
| HarStartCommand
|
||||
@@ -974,6 +994,24 @@ export interface ScreencastStopData {
|
||||
stopped: boolean;
|
||||
}
|
||||
|
||||
export interface RecordingStartData {
|
||||
started: boolean;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface RecordingStopData {
|
||||
path: string;
|
||||
frames: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RecordingRestartData {
|
||||
started: boolean;
|
||||
path: string;
|
||||
previousPath?: string;
|
||||
stopped: boolean;
|
||||
}
|
||||
|
||||
export interface InputEventData {
|
||||
injected: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user