import type { DesktopPreviewRecordingArtifact, DesktopPreviewRecordingFrame, ScopedThreadRef,} from "@t3tools/contracts";import { useAtomValue } from "@effect/atom-react";import * as Schema from "effect/Schema";import { Atom } from "effect/unstable/reactivity"; import { previewBridge } from "~/components/preview/previewBridge";import { appAtomRegistry } from "~/rpc/atomRegistry";import { useBrowserSurfaceStore } from "./browserSurfaceStore"; export class BrowserRecordingUnavailableError extends Schema.TaggedErrorClass<BrowserRecordingUnavailableError>()( "BrowserRecordingUnavailableError", { tabId: Schema.String, },) { override get message(): string { return `Browser recording is unavailable for tab ${this.tabId}.`; }} export class BrowserRecordingConflictError extends Schema.TaggedErrorClass<BrowserRecordingConflictError>()( "BrowserRecordingConflictError", { requestedTabId: Schema.String, activeTabId: Schema.String, },) { override get message(): string { return `Cannot record tab ${this.requestedTabId} while tab ${this.activeTabId} is already being recorded.`; }} export class BrowserRecordingCanvasUnavailableError extends Schema.TaggedErrorClass<BrowserRecordingCanvasUnavailableError>()( "BrowserRecordingCanvasUnavailableError", { tabId: Schema.String, width: Schema.Number, height: Schema.Number, },) { override get message(): string { return `Browser recording canvas ${this.width}x${this.height} is unavailable for tab ${this.tabId}.`; }} export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<BrowserRecordingOperationError>()( "BrowserRecordingOperationError", { operation: Schema.Literals([ "initialize-media-recorder", "subscribe-frames", "start-media-recorder", "start-screencast", "stop-screencast", "wait-first-frame", "wait-startup", "stop-media-recorder", "save-artifact", "cleanup", ]), tabId: Schema.String, cause: Schema.Defect(), },) { override get message(): string { return `Browser recording operation ${this.operation} failed for tab ${this.tabId}.`; }} const isBrowserRecordingOperationError = Schema.is(BrowserRecordingOperationError); type BrowserRecordingLifecycle = | { readonly phase: "starting" } | { readonly phase: "recording" } | { readonly phase: "stopping"; readonly stopPromise: Promise<DesktopPreviewRecordingArtifact | null>; }; interface ActiveRecording { /** Desktop-scoped identity used by capture and surface stores. */ readonly tabId: string; /** Server-local identity returned by preview automation tools. */ readonly serverTabId: string; readonly threadRef: ScopedThreadRef | null; readonly canvas: HTMLCanvasElement; readonly context: CanvasRenderingContext2D; readonly chunks: Blob[]; readonly startedAt: string; readonly startupSettled: Promise<void>; readonly firstFrameSize: Promise<"frame" | "cancelled">; readonly settleFirstFrameSize: (outcome: "frame" | "cancelled") => void; recorder: MediaRecorder | null; mimeType: string | null; frameSizeEstablished: boolean; frameSequence: number; lastDrawnFrameSequence: number; lifecycle: BrowserRecordingLifecycle;} export interface ActiveBrowserRecordingTarget { readonly runtimeTabId: string; readonly serverTabId: string;} interface ActiveBrowserRecordingIndex { readonly tabIds: ReadonlySet<string>;} const activeBrowserRecordingTabIdsAtom = Atom.make<ActiveBrowserRecordingIndex>({ tabIds: new Set<string>(),}).pipe(Atom.keepAlive, Atom.withLabel("preview:active-browser-recording-tabs")); export function useActiveBrowserRecordingTabIds(): ReadonlySet<string> { return useAtomValue(activeBrowserRecordingTabIdsAtom).tabIds;} const activeRecordings = new Map<string, ActiveRecording>();let unsubscribeFrames: (() => void) | null = null; export const BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS = 5_000;export const BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS = 5_000; export function readActiveBrowserRecordingTabIds(threadRef?: ScopedThreadRef): ReadonlySet<string> { const tabIds = new Set<string>(); for (const recording of activeRecordings.values()) { if ( threadRef === undefined || (recording.threadRef?.environmentId === threadRef.environmentId && recording.threadRef.threadId === threadRef.threadId) ) { tabIds.add(recording.tabId); } } return tabIds;} export function readActiveBrowserRecordingTargets( threadRef: ScopedThreadRef,): ReadonlyArray<ActiveBrowserRecordingTarget> { return Array.from(activeRecordings.values()).flatMap((recording) => recording.threadRef?.environmentId === threadRef.environmentId && recording.threadRef.threadId === threadRef.threadId ? [{ runtimeTabId: recording.tabId, serverTabId: recording.serverTabId }] : [], );} export function findActiveBrowserRecordingRuntimeTabId( threadRef: ScopedThreadRef, serverTabId: string,): string | null { return ( readActiveBrowserRecordingTargets(threadRef).find( (recording) => recording.serverTabId === serverTabId, )?.runtimeTabId ?? null );} const preferredMimeType = (): string => { const candidates = ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9", "video/webm"]; return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? "video/webm";}; const drawFrame = (frame: DesktopPreviewRecordingFrame): void => { const recording = activeRecordings.get(frame.tabId); if (!recording) return; if ( !Number.isFinite(frame.width) || !Number.isFinite(frame.height) || frame.width <= 0 || frame.height <= 0 ) { return; } const width = Math.max(1, Math.round(frame.width)); const height = Math.max(1, Math.round(frame.height)); if (!recording.frameSizeEstablished) { recording.canvas.width = width; recording.canvas.height = height; recording.frameSizeEstablished = true; recording.settleFirstFrameSize("frame"); } const frameSequence = ++recording.frameSequence; const image = new Image(); image.addEventListener( "load", () => { if ( activeRecordings.get(frame.tabId) !== recording || frameSequence <= recording.lastDrawnFrameSequence ) { return; } recording.lastDrawnFrameSequence = frameSequence; const scale = Math.min(recording.canvas.width / width, recording.canvas.height / height); const targetWidth = width * scale; const targetHeight = height * scale; const targetX = (recording.canvas.width - targetWidth) / 2; const targetY = (recording.canvas.height - targetHeight) / 2; recording.context.fillStyle = "#000000"; recording.context.fillRect(0, 0, recording.canvas.width, recording.canvas.height); recording.context.drawImage(image, targetX, targetY, targetWidth, targetHeight); }, { once: true }, ); image.src = `data:image/jpeg;base64,${frame.data}`;}; const stopMediaRecorder = async (recorder: MediaRecorder | null): Promise<void> => { if (!recorder || recorder.state === "inactive") return; const stopped = new Promise<void>((resolve) => recorder.addEventListener("stop", () => resolve(), { once: true }), ); recorder.stop(); await stopped;}; const clearActiveRecording = (recording: ActiveRecording): void => { if (activeRecordings.get(recording.tabId) !== recording) return; recording.settleFirstFrameSize("cancelled"); activeRecordings.delete(recording.tabId); if (activeRecordings.size === 0) { unsubscribeFrames?.(); unsubscribeFrames = null; } appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { tabIds: new Set(activeRecordings.keys()), });}; const cleanupFailedRecordingStart = async ( bridge: NonNullable<typeof previewBridge>, recording: ActiveRecording,): Promise<unknown | undefined> => { const errors: unknown[] = []; try { await bridge.recording.stopScreencast(recording.tabId); } catch (error) { errors.push(error); } try { await stopMediaRecorder(recording.recorder); } catch (error) { errors.push(error); } finally { clearActiveRecording(recording); } if (errors.length === 0) return undefined; if (errors.length === 1) return errors[0]; return new AggregateError( errors, `Browser recording startup cleanup failed for tab ${recording.tabId}.`, { cause: errors[0] }, );}; const recordingStartupCancelledError = ( recording: ActiveRecording, cause: unknown = new Error(`Browser recording startup was cancelled for tab ${recording.tabId}.`),): BrowserRecordingOperationError => new BrowserRecordingOperationError({ operation: "start-screencast", tabId: recording.tabId, cause, }); const isRecordingStarting = (recording: ActiveRecording): boolean => activeRecordings.get(recording.tabId) === recording && recording.lifecycle.phase === "starting"; const waitForFirstFrameSize = async (recording: ActiveRecording): Promise<boolean> => { if (recording.frameSizeEstablished) return true; let timeout: ReturnType<typeof setTimeout> | null = null; const outcome = await Promise.race([ recording.firstFrameSize, new Promise<"timeout">((resolve) => { timeout = setTimeout(() => resolve("timeout"), BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS); }), ]); if (timeout !== null) clearTimeout(timeout); return outcome === "frame";}; const waitForRecordingStartupToSettle = async (recording: ActiveRecording): Promise<void> => { let timeout: ReturnType<typeof setTimeout> | null = null; try { await Promise.race([ recording.startupSettled, new Promise<void>((_, reject) => { timeout = setTimeout(() => { reject(new Error(`Browser recording startup did not settle for tab ${recording.tabId}.`)); }, BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS); }), ]); } catch (cause) { throw new BrowserRecordingOperationError({ operation: "wait-startup", tabId: recording.tabId, cause, }); } finally { if (timeout !== null) clearTimeout(timeout); }}; const isStartupWaitTimeout = (error: unknown): error is BrowserRecordingOperationError => isBrowserRecordingOperationError(error) && error.operation === "wait-startup"; export async function startBrowserRecording( tabId: string, threadRef: ScopedThreadRef | null = null, serverTabId = tabId,): Promise<string> { const bridge = previewBridge; if (!bridge) throw new BrowserRecordingUnavailableError({ tabId }); const activeRecording = activeRecordings.get(tabId); if (activeRecording) { if (activeRecording.lifecycle.phase === "recording") { return activeRecording.startedAt; } throw new BrowserRecordingConflictError({ requestedTabId: tabId, activeTabId: activeRecording.tabId, }); } const activeLogicalRecording = threadRef === null ? null : findActiveBrowserRecordingRuntimeTabId(threadRef, serverTabId); if (activeLogicalRecording !== null) { throw new BrowserRecordingConflictError({ requestedTabId: tabId, activeTabId: activeLogicalRecording, }); } const surface = useBrowserSurfaceStore.getState().byTabId[tabId]; const recordingSize = surface?.content ?? surface?.rect; const canvas = document.createElement("canvas"); canvas.width = Math.max(1, recordingSize?.width ?? 1280); canvas.height = Math.max(1, recordingSize?.height ?? 800); const context = canvas.getContext("2d", { alpha: false }); if (!context) { throw new BrowserRecordingCanvasUnavailableError({ tabId, width: canvas.width, height: canvas.height, }); } const startedAt = new Date().toISOString(); const chunks: Blob[] = []; let settleStartup: (() => void) | undefined; const startupSettled = new Promise<void>((resolve) => { settleStartup = resolve; }); let settleFirstFrameSize: ((outcome: "frame" | "cancelled") => void) | undefined; const firstFrameSize = new Promise<"frame" | "cancelled">((resolve) => { settleFirstFrameSize = resolve; }); const recording: ActiveRecording = { tabId, serverTabId, threadRef, canvas, context, chunks, startedAt, startupSettled, firstFrameSize, settleFirstFrameSize: (outcome) => settleFirstFrameSize?.(outcome), recorder: null, mimeType: null, frameSizeEstablished: false, frameSequence: 0, lastDrawnFrameSequence: 0, lifecycle: { phase: "starting" }, }; activeRecordings.set(tabId, recording); try { try { unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); } catch (cause) { clearActiveRecording(recording); throw new BrowserRecordingOperationError({ operation: "subscribe-frames", tabId, cause, }); } try { await bridge.recording.startScreencast(tabId); } catch (cause) { if (!isRecordingStarting(recording)) { throw recordingStartupCancelledError(recording, cause); } clearActiveRecording(recording); throw new BrowserRecordingOperationError({ operation: "start-screencast", tabId, cause, }); } const throwIfStartupCancelled = async (): Promise<void> => { // A stop requested during startup should let startup finish so the // caller receives a real artifact. Only replacement/removal cancels it. if (activeRecordings.get(tabId) === recording) return; try { await bridge.recording.stopScreencast(tabId); } catch (cause) { throw recordingStartupCancelledError( recording, new AggregateError( [new Error(`Browser recording startup was cancelled for tab ${tabId}.`), cause], `Browser recording startup cancellation failed for tab ${tabId}.`, { cause }, ), ); } throw recordingStartupCancelledError(recording); }; await throwIfStartupCancelled(); const hasFirstFrame = await waitForFirstFrameSize(recording); await throwIfStartupCancelled(); if (!hasFirstFrame) { const cause = new Error(`No valid recording frame arrived for tab ${tabId}.`); const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); throw new BrowserRecordingOperationError({ operation: "wait-first-frame", tabId, cause: cleanupCause === undefined ? cause : new AggregateError( [cause, cleanupCause], `Browser recording frame wait and cleanup failed for tab ${tabId}.`, { cause }, ), }); } let mimeType: string; let recorder: MediaRecorder; try { mimeType = preferredMimeType(); recorder = new MediaRecorder(canvas.captureStream(12), { mimeType, videoBitsPerSecond: 4_000_000, }); recording.mimeType = mimeType; recording.recorder = recorder; recorder.addEventListener("dataavailable", (event) => { if (event.data.size > 0) chunks.push(event.data); }); } catch (cause) { const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); throw new BrowserRecordingOperationError({ operation: "initialize-media-recorder", tabId, cause: cleanupCause === undefined ? cause : new AggregateError( [cause, cleanupCause], `Browser recording initialization and cleanup failed for tab ${tabId}.`, { cause }, ), }); } try { recorder.start(1_000); } catch (cause) { const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); throw new BrowserRecordingOperationError({ operation: "start-media-recorder", tabId, cause: cleanupCause === undefined ? cause : new AggregateError( [cause, cleanupCause], `Browser media recorder start and cleanup failed for tab ${tabId}.`, { cause }, ), }); } if (recording.lifecycle.phase === "starting") { recording.lifecycle = { phase: "recording" }; } appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { tabIds: new Set(activeRecordings.keys()), }); return startedAt; } finally { settleStartup?.(); }} const finalizeBrowserRecording = async ( bridge: NonNullable<typeof previewBridge>,Lines 1–500 of 627. Load more
import type { DesktopPreviewRecordingArtifact, DesktopPreviewRecordingFrame, ScopedThreadRef, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { Atom } from "effect/unstable/reactivity"; import { previewBridge } from "~/components/preview/previewBridge"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { useBrowserSurfaceStore } from "./browserSurfaceStore"; export class BrowserRecordingUnavailableError extends Schema.TaggedErrorClass<BrowserRecordingUnavailableError>()( "BrowserRecordingUnavailableError", { tabId: Schema.String, }, ) { override get message(): string { return `Browser recording is unavailable for tab ${this.tabId}.`; } } export class BrowserRecordingConflictError extends Schema.TaggedErrorClass<BrowserRecordingConflictError>()( "BrowserRecordingConflictError", { requestedTabId: Schema.String, activeTabId: Schema.String, }, ) { override get message(): string { return `Cannot record tab ${this.requestedTabId} while tab ${this.activeTabId} is already being recorded.`; } } export class BrowserRecordingCanvasUnavailableError extends Schema.TaggedErrorClass<BrowserRecordingCanvasUnavailableError>()( "BrowserRecordingCanvasUnavailableError", { tabId: Schema.String, width: Schema.Number, height: Schema.Number, }, ) { override get message(): string { return `Browser recording canvas ${this.width}x${this.height} is unavailable for tab ${this.tabId}.`; } } export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<BrowserRecordingOperationError>()( "BrowserRecordingOperationError", { operation: Schema.Literals([ "initialize-media-recorder", "subscribe-frames", "start-media-recorder", "start-screencast", "stop-screencast", "wait-first-frame", "wait-startup", "stop-media-recorder", "save-artifact", "cleanup", ]), tabId: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { return `Browser recording operation ${this.operation} failed for tab ${this.tabId}.`; } } const isBrowserRecordingOperationError = Schema.is(BrowserRecordingOperationError); type BrowserRecordingLifecycle = | { readonly phase: "starting" } | { readonly phase: "recording" } | { readonly phase: "stopping"; readonly stopPromise: Promise<DesktopPreviewRecordingArtifact | null>; }; interface ActiveRecording { /** Desktop-scoped identity used by capture and surface stores. */ readonly tabId: string; /** Server-local identity returned by preview automation tools. */ readonly serverTabId: string; readonly threadRef: ScopedThreadRef | null; readonly canvas: HTMLCanvasElement; readonly context: CanvasRenderingContext2D; readonly chunks: Blob[]; readonly startedAt: string; readonly startupSettled: Promise<void>; readonly firstFrameSize: Promise<"frame" | "cancelled">; readonly settleFirstFrameSize: (outcome: "frame" | "cancelled") => void; recorder: MediaRecorder | null; mimeType: string | null; frameSizeEstablished: boolean; frameSequence: number; lastDrawnFrameSequence: number; lifecycle: BrowserRecordingLifecycle; } export interface ActiveBrowserRecordingTarget { readonly runtimeTabId: string; readonly serverTabId: string; } interface ActiveBrowserRecordingIndex { readonly tabIds: ReadonlySet<string>; } const activeBrowserRecordingTabIdsAtom = Atom.make<ActiveBrowserRecordingIndex>({ tabIds: new Set<string>(), }).pipe(Atom.keepAlive, Atom.withLabel("preview:active-browser-recording-tabs")); export function useActiveBrowserRecordingTabIds(): ReadonlySet<string> { return useAtomValue(activeBrowserRecordingTabIdsAtom).tabIds; } const activeRecordings = new Map<string, ActiveRecording>(); let unsubscribeFrames: (() => void) | null = null; export const BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS = 5_000; export const BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS = 5_000; export function readActiveBrowserRecordingTabIds(threadRef?: ScopedThreadRef): ReadonlySet<string> { const tabIds = new Set<string>(); for (const recording of activeRecordings.values()) { if ( threadRef === undefined || (recording.threadRef?.environmentId === threadRef.environmentId && recording.threadRef.threadId === threadRef.threadId) ) { tabIds.add(recording.tabId); } } return tabIds; } export function readActiveBrowserRecordingTargets( threadRef: ScopedThreadRef, ): ReadonlyArray<ActiveBrowserRecordingTarget> { return Array.from(activeRecordings.values()).flatMap((recording) => recording.threadRef?.environmentId === threadRef.environmentId && recording.threadRef.threadId === threadRef.threadId ? [{ runtimeTabId: recording.tabId, serverTabId: recording.serverTabId }] : [], ); } export function findActiveBrowserRecordingRuntimeTabId( threadRef: ScopedThreadRef, serverTabId: string, ): string | null { return ( readActiveBrowserRecordingTargets(threadRef).find( (recording) => recording.serverTabId === serverTabId, )?.runtimeTabId ?? null ); } const preferredMimeType = (): string => { const candidates = ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9", "video/webm"]; return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? "video/webm"; }; const drawFrame = (frame: DesktopPreviewRecordingFrame): void => { const recording = activeRecordings.get(frame.tabId); if (!recording) return; if ( !Number.isFinite(frame.width) || !Number.isFinite(frame.height) || frame.width <= 0 || frame.height <= 0 ) { return; } const width = Math.max(1, Math.round(frame.width)); const height = Math.max(1, Math.round(frame.height)); if (!recording.frameSizeEstablished) { recording.canvas.width = width; recording.canvas.height = height; recording.frameSizeEstablished = true; recording.settleFirstFrameSize("frame"); } const frameSequence = ++recording.frameSequence; const image = new Image(); image.addEventListener( "load", () => { if ( activeRecordings.get(frame.tabId) !== recording || frameSequence <= recording.lastDrawnFrameSequence ) { return; } recording.lastDrawnFrameSequence = frameSequence; const scale = Math.min(recording.canvas.width / width, recording.canvas.height / height); const targetWidth = width * scale; const targetHeight = height * scale; const targetX = (recording.canvas.width - targetWidth) / 2; const targetY = (recording.canvas.height - targetHeight) / 2; recording.context.fillStyle = "#000000"; recording.context.fillRect(0, 0, recording.canvas.width, recording.canvas.height); recording.context.drawImage(image, targetX, targetY, targetWidth, targetHeight); }, { once: true }, ); image.src = `data:image/jpeg;base64,${frame.data}`; }; const stopMediaRecorder = async (recorder: MediaRecorder | null): Promise<void> => { if (!recorder || recorder.state === "inactive") return; const stopped = new Promise<void>((resolve) => recorder.addEventListener("stop", () => resolve(), { once: true }), ); recorder.stop(); await stopped; }; const clearActiveRecording = (recording: ActiveRecording): void => { if (activeRecordings.get(recording.tabId) !== recording) return; recording.settleFirstFrameSize("cancelled"); activeRecordings.delete(recording.tabId); if (activeRecordings.size === 0) { unsubscribeFrames?.(); unsubscribeFrames = null; } appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { tabIds: new Set(activeRecordings.keys()), }); }; const cleanupFailedRecordingStart = async ( bridge: NonNullable<typeof previewBridge>, recording: ActiveRecording, ): Promise<unknown | undefined> => { const errors: unknown[] = []; try { await bridge.recording.stopScreencast(recording.tabId); } catch (error) { errors.push(error); } try { await stopMediaRecorder(recording.recorder); } catch (error) { errors.push(error); } finally { clearActiveRecording(recording); } if (errors.length === 0) return undefined; if (errors.length === 1) return errors[0]; return new AggregateError( errors, `Browser recording startup cleanup failed for tab ${recording.tabId}.`, { cause: errors[0] }, ); }; const recordingStartupCancelledError = ( recording: ActiveRecording, cause: unknown = new Error(`Browser recording startup was cancelled for tab ${recording.tabId}.`), ): BrowserRecordingOperationError => new BrowserRecordingOperationError({ operation: "start-screencast", tabId: recording.tabId, cause, }); const isRecordingStarting = (recording: ActiveRecording): boolean => activeRecordings.get(recording.tabId) === recording && recording.lifecycle.phase === "starting"; const waitForFirstFrameSize = async (recording: ActiveRecording): Promise<boolean> => { if (recording.frameSizeEstablished) return true; let timeout: ReturnType<typeof setTimeout> | null = null; const outcome = await Promise.race([ recording.firstFrameSize, new Promise<"timeout">((resolve) => { timeout = setTimeout(() => resolve("timeout"), BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS); }), ]); if (timeout !== null) clearTimeout(timeout); return outcome === "frame"; }; const waitForRecordingStartupToSettle = async (recording: ActiveRecording): Promise<void> => { let timeout: ReturnType<typeof setTimeout> | null = null; try { await Promise.race([ recording.startupSettled, new Promise<void>((_, reject) => { timeout = setTimeout(() => { reject(new Error(`Browser recording startup did not settle for tab ${recording.tabId}.`)); }, BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS); }), ]); } catch (cause) { throw new BrowserRecordingOperationError({ operation: "wait-startup", tabId: recording.tabId, cause, }); } finally { if (timeout !== null) clearTimeout(timeout); } }; const isStartupWaitTimeout = (error: unknown): error is BrowserRecordingOperationError => isBrowserRecordingOperationError(error) && error.operation === "wait-startup"; export async function startBrowserRecording( tabId: string, threadRef: ScopedThreadRef | null = null, serverTabId = tabId, ): Promise<string> { const bridge = previewBridge; if (!bridge) throw new BrowserRecordingUnavailableError({ tabId }); const activeRecording = activeRecordings.get(tabId); if (activeRecording) { if (activeRecording.lifecycle.phase === "recording") { return activeRecording.startedAt; } throw new BrowserRecordingConflictError({ requestedTabId: tabId, activeTabId: activeRecording.tabId, }); } const activeLogicalRecording = threadRef === null ? null : findActiveBrowserRecordingRuntimeTabId(threadRef, serverTabId); if (activeLogicalRecording !== null) { throw new BrowserRecordingConflictError({ requestedTabId: tabId, activeTabId: activeLogicalRecording, }); } const surface = useBrowserSurfaceStore.getState().byTabId[tabId]; const recordingSize = surface?.content ?? surface?.rect; const canvas = document.createElement("canvas"); canvas.width = Math.max(1, recordingSize?.width ?? 1280); canvas.height = Math.max(1, recordingSize?.height ?? 800); const context = canvas.getContext("2d", { alpha: false }); if (!context) { throw new BrowserRecordingCanvasUnavailableError({ tabId, width: canvas.width, height: canvas.height, }); } const startedAt = new Date().toISOString(); const chunks: Blob[] = []; let settleStartup: (() => void) | undefined; const startupSettled = new Promise<void>((resolve) => { settleStartup = resolve; }); let settleFirstFrameSize: ((outcome: "frame" | "cancelled") => void) | undefined; const firstFrameSize = new Promise<"frame" | "cancelled">((resolve) => { settleFirstFrameSize = resolve; }); const recording: ActiveRecording = { tabId, serverTabId, threadRef, canvas, context, chunks, startedAt, startupSettled, firstFrameSize, settleFirstFrameSize: (outcome) => settleFirstFrameSize?.(outcome), recorder: null, mimeType: null, frameSizeEstablished: false, frameSequence: 0, lastDrawnFrameSequence: 0, lifecycle: { phase: "starting" }, }; activeRecordings.set(tabId, recording); try { try { unsubscribeFrames ??= bridge.recording.onFrame(drawFrame); } catch (cause) { clearActiveRecording(recording); throw new BrowserRecordingOperationError({ operation: "subscribe-frames", tabId, cause, }); } try { await bridge.recording.startScreencast(tabId); } catch (cause) { if (!isRecordingStarting(recording)) { throw recordingStartupCancelledError(recording, cause); } clearActiveRecording(recording); throw new BrowserRecordingOperationError({ operation: "start-screencast", tabId, cause, }); } const throwIfStartupCancelled = async (): Promise<void> => { // A stop requested during startup should let startup finish so the // caller receives a real artifact. Only replacement/removal cancels it. if (activeRecordings.get(tabId) === recording) return; try { await bridge.recording.stopScreencast(tabId); } catch (cause) { throw recordingStartupCancelledError( recording, new AggregateError( [new Error(`Browser recording startup was cancelled for tab ${tabId}.`), cause], `Browser recording startup cancellation failed for tab ${tabId}.`, { cause }, ), ); } throw recordingStartupCancelledError(recording); }; await throwIfStartupCancelled(); const hasFirstFrame = await waitForFirstFrameSize(recording); await throwIfStartupCancelled(); if (!hasFirstFrame) { const cause = new Error(`No valid recording frame arrived for tab ${tabId}.`); const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); throw new BrowserRecordingOperationError({ operation: "wait-first-frame", tabId, cause: cleanupCause === undefined ? cause : new AggregateError( [cause, cleanupCause], `Browser recording frame wait and cleanup failed for tab ${tabId}.`, { cause }, ), }); } let mimeType: string; let recorder: MediaRecorder; try { mimeType = preferredMimeType(); recorder = new MediaRecorder(canvas.captureStream(12), { mimeType, videoBitsPerSecond: 4_000_000, }); recording.mimeType = mimeType; recording.recorder = recorder; recorder.addEventListener("dataavailable", (event) => { if (event.data.size > 0) chunks.push(event.data); }); } catch (cause) { const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); throw new BrowserRecordingOperationError({ operation: "initialize-media-recorder", tabId, cause: cleanupCause === undefined ? cause : new AggregateError( [cause, cleanupCause], `Browser recording initialization and cleanup failed for tab ${tabId}.`, { cause }, ), }); } try { recorder.start(1_000); } catch (cause) { const cleanupCause = await cleanupFailedRecordingStart(bridge, recording); throw new BrowserRecordingOperationError({ operation: "start-media-recorder", tabId, cause: cleanupCause === undefined ? cause : new AggregateError( [cause, cleanupCause], `Browser media recorder start and cleanup failed for tab ${tabId}.`, { cause }, ), }); } if (recording.lifecycle.phase === "starting") { recording.lifecycle = { phase: "recording" }; } appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, { tabIds: new Set(activeRecordings.keys()), }); return startedAt; } finally { settleStartup?.(); } } const finalizeBrowserRecording = async ( bridge: NonNullable<typeof previewBridge>,