627 lines · 20.2 KB · TypeScript
RawDownload
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.