/**
* GIF Auto-Capture Hook System
*
* Provides automatic frame capture for GIF recording when browser actions succeed.
* Tools like chrome_computer and chrome_navigate can trigger frame captures
* after successful operations, creating smooth recordings of user interactions.
*
* Architecture:
* - Centralized capture manager with per-tab recording state
* - Hooks can be registered/unregistered per tab
* - Configurable capture delay for UI stabilization
* - Enhanced rendering overlays (click indicators, drag paths, labels)
*/
import { cdpSessionManager } from '@/utils/cdp-session-manager';
import { OFFSCREEN_MESSAGE_TYPES, MessageTarget } from '@/common/message-types';
import { offscreenManager } from '@/utils/offscreen-manager';
import { createImageBitmapFromUrl } from '@/utils/image-utils';
import {
pruneActionEventsInPlace,
renderGifEnhancedOverlays,
resolveCapturePlanForAction,
resolveGifEnhancedRenderingConfig,
type ActionEvent,
type ActionMetadata,
type ActionType,
type GifEnhancedRenderingConfig,
type ResolvedGifEnhancedRenderingConfig,
} from './gif-enhanced-renderer';
// Re-export types for consumers
export type {
ActionMetadata,
ActionType,
GifEnhancedRenderingConfig,
} from './gif-enhanced-renderer';
// ============================================================================
// Constants
// ============================================================================
const CDP_SESSION_KEY = 'gif-auto-capture';
const DEFAULT_CAPTURE_DELAY_MS = 150;
const DEFAULT_WIDTH = 800;
const DEFAULT_HEIGHT = 600;
const DEFAULT_FRAME_DELAY_CS = 20; // 20 centiseconds = 200ms per frame
const DEFAULT_MAX_COLORS = 256;
// ============================================================================
// Types
// ============================================================================
export interface AutoCaptureConfig {
width: number;
height: number;
maxColors: number;
frameDelayCs: number;
captureDelayMs: number;
maxFrames: number;
enhancedRendering?: GifEnhancedRenderingConfig;
}
interface TabCaptureState {
tabId: number;
config: AutoCaptureConfig;
rendering: ResolvedGifEnhancedRenderingConfig;
frameCount: number;
startTime: number;
canvas: OffscreenCanvas;
ctx: OffscreenCanvasRenderingContext2D;
pendingCapture: Promise<void> | null;
actions: ActionMetadata[];
actionEvents: ActionEvent[];
lastViewportWidth: number;
lastViewportHeight: number;
}
// ============================================================================
// State Management
// ============================================================================
const tabStates = new Map<number, TabCaptureState>();
// ============================================================================
// Utilities
// ============================================================================
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function normalizeActionMetadata(action: ActionMetadata, atMs: number): ActionMetadata {
const normalized: ActionMetadata = {
...action,
timestampMs: atMs,
coordinateSpace: action.coordinateSpace ?? 'viewport',
};
// For drag, treat `coordinates` as end position (legacy) and also populate `endCoordinates`
if (normalized.type === 'drag') {
const end = normalized.endCoordinates ?? normalized.coordinates;
if (end) {
normalized.endCoordinates = end;
normalized.coordinates = end;
}
}
return normalized;
}
// ============================================================================
// Offscreen Communication
// ============================================================================
async function sendToOffscreen<T extends { success: boolean; error?: string }>(
type: string,
payload: Record<string, unknown> = {},
): Promise<T> {
await offscreenManager.ensureOffscreenDocument();
const response = (await chrome.runtime.sendMessage({
target: MessageTarget.Offscreen,
type,
...payload,
})) as T | undefined;
if (!response) {
throw new Error('No response from offscreen document');
}
if (!response.success) {
throw new Error(response.error || 'Unknown offscreen error');
}
return response;
}
// ============================================================================
// Frame Capture
// ============================================================================
async function captureFrameData(tabId: number, state: TabCaptureState): Promise<Uint8ClampedArray> {
const width = state.config.width;
const height = state.config.height;
const ctx = state.ctx;
// Get viewport metrics
const metrics: { layoutViewport?: { clientWidth: number; clientHeight: number } } =
await cdpSessionManager.sendCommand(tabId, 'Page.getLayoutMetrics', {});
const viewportWidth = metrics.layoutViewport?.clientWidth || width;
const viewportHeight = metrics.layoutViewport?.clientHeight || height;
// Store viewport dimensions for coordinate projection
state.lastViewportWidth = viewportWidth;
state.lastViewportHeight = viewportHeight;
// Capture screenshot
const screenshot: { data: string } = await cdpSessionManager.sendCommand(
tabId,
'Page.captureScreenshot',
{
format: 'png',
clip: {
x: 0,
y: 0,
width: viewportWidth,
height: viewportHeight,
scale: 1,
},
},
);
const imageBitmap = await createImageBitmapFromUrl(`data:image/png;base64,${screenshot.data}`);
// Scale to target dimensions
ctx.clearRect(0, 0, width, height);
ctx.drawImage(imageBitmap, 0, 0, width, height);
imageBitmap.close();
// Apply enhanced rendering overlays
if (state.rendering.enabled) {
const nowMs = Date.now();
renderGifEnhancedOverlays({
ctx,
outputWidth: width,
outputHeight: height,
viewportWidth,
viewportHeight,
nowMs,
events: state.actionEvents,
config: state.rendering,
});
pruneActionEventsInPlace(state.actionEvents, nowMs, state.rendering);
}
return ctx.getImageData(0, 0, width, height).data;
}
// ============================================================================
// Public API
// ============================================================================
/**
* Start auto-capture for a tab. This initializes the GIF encoder
* and prepares for automatic frame capture on tool actions.
*/
export async function startAutoCapture(
tabId: number,
config?: Partial<AutoCaptureConfig>,
): Promise<{ success: boolean; error?: string }> {
if (tabStates.has(tabId)) {
return { success: false, error: 'Auto-capture already active for this tab' };
}
const finalConfig: AutoCaptureConfig = {
width: config?.width ?? DEFAULT_WIDTH,
height: config?.height ?? DEFAULT_HEIGHT,
maxColors: config?.maxColors ?? DEFAULT_MAX_COLORS,
frameDelayCs: config?.frameDelayCs ?? DEFAULT_FRAME_DELAY_CS,
captureDelayMs: config?.captureDelayMs ?? DEFAULT_CAPTURE_DELAY_MS,
maxFrames: config?.maxFrames ?? 100,
enhancedRendering: config?.enhancedRendering,
};
try {
// Attach CDP session
await cdpSessionManager.attach(tabId, CDP_SESSION_KEY);
// Reset offscreen encoder
await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_RESET, {});
// Create canvas
if (typeof OffscreenCanvas === 'undefined') {
throw new Error('OffscreenCanvas not available');
}
const canvas = new OffscreenCanvas(finalConfig.width, finalConfig.height);
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Failed to get canvas context');
}
const state: TabCaptureState = {
tabId,
config: finalConfig,
rendering: resolveGifEnhancedRenderingConfig(finalConfig.enhancedRendering),
frameCount: 0,
startTime: Date.now(),
canvas,
ctx,
pendingCapture: null,
actions: [],
actionEvents: [],
lastViewportWidth: finalConfig.width,
lastViewportHeight: finalConfig.height,
};
tabStates.set(tabId, state);
return { success: true };
} catch (error) {
// Cleanup on failure
try {
await cdpSessionManager.detach(tabId, CDP_SESSION_KEY);
} catch {
// Ignore
}
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Stop auto-capture and finalize the GIF.
* Returns the GIF data for saving/downloading.
*/
export async function stopAutoCapture(tabId: number): Promise<{
success: boolean;
gifData?: Uint8Array;
frameCount?: number;
durationMs?: number;
actions?: ActionMetadata[];
error?: string;
}> {
const state = tabStates.get(tabId);
if (!state) {
return { success: false, error: 'No auto-capture active for this tab' };
}
try {
// Wait for any pending capture
if (state.pendingCapture) {
await state.pendingCapture;
}
const frameCount = state.frameCount;
const durationMs = Date.now() - state.startTime;
const actions = [...state.actions];
if (frameCount === 0) {
return {
success: false,
error: 'No frames captured',
frameCount: 0,
durationMs,
actions,
};
}
// Finalize GIF
const response = await sendToOffscreen<{
success: boolean;
gifData?: number[];
byteLength?: number;
error?: string;
}>(OFFSCREEN_MESSAGE_TYPES.GIF_FINISH, {});
if (!response.gifData || response.gifData.length === 0) {
return {
success: false,
error: 'Failed to encode GIF',
frameCount,
durationMs,
actions,
};
}
return {
success: true,
gifData: new Uint8Array(response.gifData),
frameCount,
durationMs,
actions,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
} finally {
// Cleanup
tabStates.delete(tabId);
try {
await cdpSessionManager.detach(tabId, CDP_SESSION_KEY);
} catch {
// Ignore
}
}
}
/**
* Check if auto-capture is active for a tab.
*/
export function isAutoCaptureActive(tabId: number): boolean {
return tabStates.has(tabId);
}
/**
* Get current auto-capture status for a tab.
*/
export function getAutoCaptureStatus(tabId: number): {
active: boolean;
frameCount?: number;
durationMs?: number;
actionsCount?: number;
enhancedRenderingEnabled?: boolean;
} {
const state = tabStates.get(tabId);
if (!state) {
return { active: false };
}
return {
active: true,
frameCount: state.frameCount,
durationMs: Date.now() - state.startTime,
actionsCount: state.actions.length,
enhancedRenderingEnabled: state.rendering.enabled,
};
}
/**
* Trigger a frame capture after a successful action.
* This is the main hook that tools should call.
*
* @param tabId - The tab to capture
* @param action - Optional action metadata for overlay rendering
* @param immediate - If true, capture immediately without delay
*/
export async function captureFrameOnAction(
tabId: number,
action?: ActionMetadata,
immediate = false,
): Promise<{ success: boolean; frameNumber?: number; error?: string }> {
const state = tabStates.get(tabId);
if (!state) {
// No auto-capture active - silently succeed (tools shouldn't fail because recording isn't active)
return { success: true };
}
// Check frame limit
if (state.frameCount >= state.config.maxFrames) {
return { success: false, error: 'Max frame limit reached' };
}
// Wait for any pending capture to complete
if (state.pendingCapture) {
try {
await state.pendingCapture;
} catch {
// Ignore errors from previous capture
}
}
// Verify state still exists (might have been stopped while awaiting)
const currentState = tabStates.get(tabId);
if (!currentState) {
return { success: true };
}
// Calculate delay for UI stabilization
const delayMs = immediate ? 0 : currentState.config.captureDelayMs;
// Normalize and record action metadata
let normalizedAction: ActionMetadata | undefined;
if (action) {
const atMs = Date.now() + delayMs;
normalizedAction = normalizeActionMetadata(action, atMs);
currentState.actions.push(normalizedAction);
currentState.actionEvents.push({ action: normalizedAction, atMs });
}
// Determine capture plan (may involve multiple frames for click animations)
const plan = resolveCapturePlanForAction(
currentState.rendering,
normalizedAction,
currentState.config.frameDelayCs,
);
const capturePromise = (async () => {
if (delayMs > 0) await sleep(delayMs);
for (let i = 0; i < plan.frames; i++) {
const activeState = tabStates.get(tabId);
if (!activeState) return;
if (activeState.frameCount >= activeState.config.maxFrames) return;
try {
const frameData = await captureFrameData(tabId, activeState);
// Use animation delay for intermediate frames, regular delay for final frame
const delayCs = i < plan.frames - 1 ? plan.delayCs : activeState.config.frameDelayCs;
await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME, {
imageData: Array.from(frameData),
width: activeState.config.width,
height: activeState.config.height,
delay: delayCs,
maxColors: activeState.config.maxColors,
});
activeState.frameCount += 1;
} catch (error) {
console.error('[GIF Auto-Capture] Frame capture failed:', error);
return;
}
// Wait between animation frames
if (i < plan.frames - 1 && plan.intervalMs > 0) {
await sleep(plan.intervalMs);
}
}
})();
state.pendingCapture = capturePromise;
try {
await capturePromise;
return { success: true, frameNumber: state.frameCount };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
} finally {
// Clean up reference to avoid holding completed Promise
const currentState = tabStates.get(tabId);
if (currentState?.pendingCapture === capturePromise) {
currentState.pendingCapture = null;
}
}
}
/**
* Capture an initial frame immediately (useful for recording start state).
*/
export async function captureInitialFrame(
tabId: number,
): Promise<{ success: boolean; error?: string }> {
return captureFrameOnAction(tabId, undefined, true);
}
/**
* Clear all auto-capture state (useful for cleanup).
*/
export async function clearAllAutoCapture(): Promise<void> {
const tabIds = Array.from(tabStates.keys());
for (const tabId of tabIds) {
try {
await stopAutoCapture(tabId);
} catch {
// Ignore errors during cleanup
tabStates.delete(tabId);
}
}
}