authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-08-31 01:28:24-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-31 01:28:24-04:00
log3958111057c10c10350dd9c20ec2a2df00f504be
tree2345787328c2898485bd82d18352c77f8e7350f3
parent3f62e6fa65c2a2a91a367be4ee95da1b50007bde
signature Signed by PGP key B5690EEEBB952194

fix(preview): improve browser recording quality (#8839)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

15 files changed, 861 insertions(+), 793 deletions(-)

apps/desktop/src/ipc/methods/preview.ts+10-5
......@@ -11,6 +11,7 @@ import {
1111 DesktopPreviewConfigInputSchema,
1212 DesktopPreviewNavigateInputSchema,
1313 DesktopPreviewRecordingArtifactSchema,
14 DesktopPreviewRecordingSourceSchema,
1415 DesktopPreviewRecordingSaveInputSchema,
1516 DesktopPreviewRegisterWebviewInputSchema,
1617 DesktopPreviewScreenshotArtifactSchema,
......@@ -173,11 +174,15 @@ export const cancelPickElement = tabMethod(
173174 "desktop.ipc.preview.cancelPickElement",
174175 (manager, tabId) => manager.cancelPickElement(tabId),
175176);
176export const startRecording = tabMethod(
177 IpcChannels.PREVIEW_RECORDING_START_CHANNEL,
178 "desktop.ipc.preview.startRecording",
179 (manager, tabId) => manager.startRecording(tabId),
180);
177export const startRecording = DesktopIpc.makeIpcMethod({
178 channel: IpcChannels.PREVIEW_RECORDING_START_CHANNEL,
179 payload: DesktopPreviewTabInputSchema,
180 result: DesktopPreviewRecordingSourceSchema,
181 handler: Effect.fn("desktop.ipc.preview.startRecording")(function* ({ tabId }) {
182 const manager = yield* PreviewManager.PreviewManager;
183 return yield* manager.startRecording(tabId);
184 }),
185});
181186export const stopRecording = tabMethod(
182187 IpcChannels.PREVIEW_RECORDING_STOP_CHANNEL,
183188 "desktop.ipc.preview.stopRecording",
apps/desktop/src/preview/Manager.test.ts+180-319
......@@ -36,6 +36,14 @@ describe("fitPictureInPictureContentSize", () => {
3636 });
3737});
3838
39describe("recordingFileExtension", () => {
40 it("derives the artifact extension from the recorder's actual mime type", () => {
41 expect(PreviewManager.recordingFileExtension("video/mp4;codecs=avc1.640028")).toBe("mp4");
42 expect(PreviewManager.recordingFileExtension("video/webm;codecs=vp9")).toBe("webm");
43 expect(PreviewManager.recordingFileExtension("video/x-matroska")).toBe("matroska");
44 });
45});
46
3947describe("isPreviewRefreshShortcut", () => {
4048 const input = (overrides: Partial<Electron.Input> = {}) =>
4149 ({
......@@ -207,6 +215,9 @@ const makeTestPreviewWebContents = (
207215) =>
208216 ({
209217 id,
218 hostWebContents: { id: 7 },
219 getMediaSourceId: vi.fn(() => `tab:${id}`),
220 executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })),
210221 isDestroyed: () => false,
211222 getType: () => "webview",
212223 getURL: () => "https://example.com",
......@@ -1666,7 +1677,9 @@ describe("PreviewManager", () => {
16661677 const recreated = yield* Fiber.join(recreateFiber);
16671678 const registrationExit = yield* Fiber.await(registrationFiber);
16681679
1669 for (const exit of [registrationExit, recordingExit]) {
1680 for (const exit of [registrationExit, recordingExit] as ReadonlyArray<
1681 Exit.Exit<unknown, PreviewManager.PreviewManagerError>
1682 >) {
16701683 expect(Exit.isFailure(exit)).toBe(true);
16711684 if (Exit.isSuccess(exit)) continue;
16721685 expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({
......@@ -2062,7 +2075,7 @@ describe("PreviewManager", () => {
20622075 ),
20632076 );
20642077
2065 effectIt.effect("captures hidden preview recordings independently for concurrent tabs", () =>
2078 effectIt.effect("returns native media sources for concurrent preview recordings", () =>
20662079 withManager((manager) =>
20672080 Effect.gen(function* () {
20682081 const firstJpeg = Buffer.from("first-recording-frame");
......@@ -2084,6 +2097,11 @@ describe("PreviewManager", () => {
20842097 ) =>
20852098 ({
20862099 id,
2100 hostWebContents: { id: 7 },
2101 getMediaSourceId: vi.fn(() => `tab:${id}`),
2102 executeJavaScript: vi.fn(async () =>
2103 id === 41 ? { width: 800, height: 600 } : { width: 390, height: 844 },
2104 ),
20872105 isDestroyed: () => false,
20882106 getType: () => "webview",
20892107 getURL: () => `https://example.com/${id}`,
......@@ -2115,41 +2133,21 @@ describe("PreviewManager", () => {
21152133 fromId.mockImplementation((id) =>
21162134 id === undefined ? null : (webContentsById.get(id) ?? null),
21172135 );
2118 const frames: DesktopPreviewRecordingFrame[] = [];
2119
2120 yield* manager.subscribeRecordingFrames((frame) =>
2121 Effect.sync(() => {
2122 frames.push(frame);
2123 }),
2124 );
21252136 yield* manager.createTab("tab_1");
21262137 yield* manager.createTab("tab_2");
21272138 yield* manager.registerWebview("tab_1", 41);
21282139 yield* manager.registerWebview("tab_2", 42);
2129 yield* Effect.all([manager.startRecording("tab_1"), manager.startRecording("tab_2")], {
2130 concurrency: 2,
2131 discard: true,
2132 });
2140 const sources = yield* Effect.all(
2141 [manager.startRecording("tab_1"), manager.startRecording("tab_2")],
2142 { concurrency: 2 },
2143 );
21332144
2145 expect(sources).toEqual([
2146 { sourceId: "tab:41", width: 800, height: 600 },
2147 { sourceId: "tab:42", width: 390, height: 844 },
2148 ]);
21342149 expect(firstCapturePage).toHaveBeenCalledOnce();
21352150 expect(secondCapturePage).toHaveBeenCalledOnce();
2136 expect(frames).toHaveLength(2);
2137 expect(frames).toEqual(
2138 expect.arrayContaining([
2139 expect.objectContaining({
2140 tabId: "tab_1",
2141 data: firstJpeg.toString("base64"),
2142 width: 800,
2143 height: 600,
2144 }),
2145 expect.objectContaining({
2146 tabId: "tab_2",
2147 data: secondJpeg.toString("base64"),
2148 width: 390,
2149 height: 844,
2150 }),
2151 ]),
2152 );
21532151 expect(firstSendCommand).not.toHaveBeenCalledWith(
21542152 "Page.startScreencast",
21552153 expect.anything(),
......@@ -2167,203 +2165,178 @@ describe("PreviewManager", () => {
21672165 ),
21682166 );
21692167
2170 effectIt.effect("drops a captured frame when the tab webview changes during capture", () =>
2168 effectIt.effect("continues native recording when the source warmup fails", () =>
21712169 withManager((manager) =>
21722170 Effect.gen(function* () {
2173 const staleImage: TestCapturedPreviewImage = {
2174 toJPEG: vi.fn(() => Buffer.from("stale-recording-frame")),
2175 getSize: vi.fn(() => ({ width: 1280, height: 720 })),
2176 };
2177 let markCaptureStarted!: () => void;
2178 const captureStarted = new Promise<void>((resolve) => {
2179 markCaptureStarted = resolve;
2180 });
2181 let resolveCapture: ((image: TestCapturedPreviewImage) => void) | undefined;
2182 const staleCapturePage = vi.fn(() => {
2183 markCaptureStarted();
2184 return new Promise<TestCapturedPreviewImage>((resolve) => {
2185 resolveCapture = resolve;
2186 });
2171 const capturePage = vi.fn(async () => {
2172 throw new Error("source is not ready");
21872173 });
2188 const replacementCapturePage = vi.fn(async () => ({
2189 toJPEG: () => Buffer.from("replacement-recording-frame"),
2190 getSize: () => ({ width: 1280, height: 720 }),
2191 }));
2192 const initialWebContents = makeTestPreviewWebContents(staleCapturePage, 42);
2193 const replacementWebContents = makeTestPreviewWebContents(replacementCapturePage, 43);
2194 fromId.mockImplementation((webContentsId?: number) => {
2195 if (webContentsId === 42) return initialWebContents;
2196 if (webContentsId === 43) return replacementWebContents;
2197 return null;
2174 const getMediaSourceId = vi.fn(() => "tab:42");
2175 const webContents = Object.assign(makeTestPreviewWebContents(capturePage), {
2176 executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })),
2177 getMediaSourceId,
21982178 });
2199 const frames: DesktopPreviewRecordingFrame[] = [];
2179 fromId.mockReturnValue(webContents);
22002180
2201 yield* manager.subscribeRecordingFrames((frame) =>
2202 Effect.sync(() => {
2203 frames.push(frame);
2204 }),
2205 );
2206 yield* manager.createTab("tab_capture_replaced");
2207 yield* manager.registerWebview("tab_capture_replaced", 42);
2208 const recordingFiber = yield* manager
2209 .startRecording("tab_capture_replaced")
2210 .pipe(Effect.forkChild({ startImmediately: true }));
2211 yield* Effect.promise(() => captureStarted);
2212
2213 yield* manager.registerWebview("tab_capture_replaced", 43);
2214 resolveCapture?.(staleImage);
2215 yield* Fiber.join(recordingFiber);
2181 yield* manager.createTab("tab_recording_warmup_failure");
2182 yield* manager.registerWebview("tab_recording_warmup_failure", 42);
22162183
2217 expect(staleImage.getSize).not.toHaveBeenCalled();
2218 expect(staleImage.toJPEG).not.toHaveBeenCalled();
2219 expect(frames).toHaveLength(0);
2220 expect(replacementCapturePage).not.toHaveBeenCalled();
2184 expect(yield* manager.startRecording("tab_recording_warmup_failure")).toEqual({
2185 sourceId: "tab:42",
2186 width: 1280,
2187 height: 720,
2188 });
2189 expect(capturePage).toHaveBeenCalledTimes(2);
2190 expect(getMediaSourceId).toHaveBeenCalledOnce();
22212191
2222 yield* manager.stopRecording("tab_capture_replaced");
2192 yield* manager.stopRecording("tab_recording_warmup_failure");
22232193 }),
22242194 ),
22252195 );
22262196
2227 effectIt.effect("keeps an in-flight frame when a capture consumer is added", () =>
2197 effectIt.effect("reports invalid native recording dimensions as a structured error", () =>
22282198 withManager((manager) =>
22292199 Effect.gen(function* () {
2230 const image: TestCapturedPreviewImage = {
2231 toJPEG: vi.fn(() => Buffer.from("shared-in-flight-frame")),
2232 getSize: vi.fn(() => ({ width: 1280, height: 720 })),
2233 };
2234 let markCaptureStarted!: () => void;
2235 const captureStarted = new Promise<void>((resolve) => {
2236 markCaptureStarted = resolve;
2237 });
2238 let resolveCapture: ((captured: TestCapturedPreviewImage) => void) | undefined;
2239 const capturePage = vi.fn(() => {
2240 markCaptureStarted();
2241 return new Promise<TestCapturedPreviewImage>((resolve) => {
2242 resolveCapture = resolve;
2243 });
2244 });
2245 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
2246 const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow();
2247 browserWindowConstructor.mockImplementation(function () {
2248 return pictureInPictureWindow;
2200 const capturePage = vi.fn(async () => ({
2201 toJPEG: () => Buffer.from("unused-recording-frame"),
2202 getSize: () => ({ width: 1280, height: 720 }),
2203 }));
2204 const getMediaSourceId = vi.fn(() => "tab:42");
2205 const webContents = Object.assign(makeTestPreviewWebContents(capturePage), {
2206 executeJavaScript: vi.fn(async () => ({ width: 0, height: 720 })),
2207 getMediaSourceId,
22492208 });
2250 const recordingFrames: DesktopPreviewRecordingFrame[] = [];
2251 yield* manager.subscribeRecordingFrames((frame) =>
2252 Effect.sync(() => {
2253 recordingFrames.push(frame);
2254 }),
2255 );
2256
2257 yield* manager.createTab("tab_capture_consumer_added");
2258 yield* manager.registerWebview("tab_capture_consumer_added", 42);
2259 const recordingFiber = yield* manager
2260 .startRecording("tab_capture_consumer_added")
2261 .pipe(Effect.forkChild({ startImmediately: true }));
2262 yield* Effect.promise(() => captureStarted);
2209 fromId.mockReturnValue(webContents);
22632210
2264 yield* manager.openPictureInPicture("tab_capture_consumer_added");
2265 resolveCapture?.(image);
2266 yield* Fiber.join(recordingFiber);
2211 yield* manager.createTab("tab_invalid_recording_size");
2212 yield* manager.registerWebview("tab_invalid_recording_size", 42);
2213 const exit = yield* Effect.exit(manager.startRecording("tab_invalid_recording_size"));
22672214
2268 expect(recordingFrames).toHaveLength(1);
2269 expect(send).toHaveBeenCalledWith(
2270 "desktop:preview-pip-frame",
2271 expect.objectContaining({
2272 tabId: "tab_capture_consumer_added",
2273 data: Buffer.from("shared-in-flight-frame").toString("base64"),
2274 }),
2275 );
2276
2277 yield* manager.stopRecording("tab_capture_consumer_added");
2278 yield* manager.closePictureInPicture("tab_capture_consumer_added");
2215 expect(Exit.isFailure(exit)).toBe(true);
2216 if (Exit.isSuccess(exit)) return;
2217 expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({
2218 _tag: "PreviewRecordingSourceSizeUnavailableError",
2219 tabId: "tab_invalid_recording_size",
2220 webContentsId: 42,
2221 });
2222 expect(getMediaSourceId).not.toHaveBeenCalled();
22792223 }),
22802224 ),
22812225 );
22822226
2283 effectIt.effect("emits debugger screencast frames only while recording is active", () =>
2227 effectIt.effect("keeps a newer recording lease when an earlier start fails", () =>
22842228 withManager((manager) =>
22852229 Effect.gen(function* () {
2286 let debuggerMessage:
2287 | ((event: unknown, method: string, params: Record<string, unknown>) => void)
2288 | undefined;
2230 const setBackgroundThrottling = vi.fn();
22892231 const capturePage = vi.fn(async () => ({
2290 toJPEG: () => Buffer.from("scheduled-recording-frame"),
2232 toJPEG: () => Buffer.from("unused-recording-frame"),
22912233 getSize: () => ({ width: 1280, height: 720 }),
22922234 }));
2293 const sendCommand = vi.fn(async (method: string) =>
2294 method === "Runtime.evaluate" ? { result: { value: null } } : undefined,
2295 );
2296 fromId.mockReturnValue({
2297 id: 42,
2235 let markMeasurementStarted!: () => void;
2236 const measurementStarted = new Promise<void>((resolve) => {
2237 markMeasurementStarted = resolve;
2238 });
2239 let rejectFirstMeasurement!: (error: Error) => void;
2240 const executeJavaScript = vi
2241 .fn<(expression: string, userGesture?: boolean) => Promise<unknown>>()
2242 .mockImplementationOnce(() => {
2243 markMeasurementStarted();
2244 return new Promise((_, reject) => {
2245 rejectFirstMeasurement = reject;
2246 });
2247 })
2248 .mockResolvedValue({ width: 1280, height: 720 });
2249 const webContents = Object.assign(makeTestPreviewWebContents(capturePage), {
2250 executeJavaScript,
2251 });
2252 fromId.mockReturnValue(webContents);
2253
2254 yield* manager.createTab("tab_recording_start_race");
2255 yield* manager.registerWebview("tab_recording_start_race", 42);
2256 yield* manager.setMainWindow({
22982257 isDestroyed: () => false,
2299 getType: () => "webview",
2300 getURL: () => "https://example.com",
2301 getTitle: () => "Example",
2302 isLoading: () => false,
2303 isDevToolsOpened: () => false,
2304 getZoomFactor: () => 1,
2305 setZoomFactor: vi.fn(),
2306 setAudioMuted: vi.fn(),
2307 isCurrentlyAudible: () => false,
2308 on: vi.fn(),
2309 off: vi.fn(),
2310 ipc: { on: vi.fn(), off: vi.fn() },
2311 send: webviewSend,
2312 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
2313 setWindowOpenHandler: vi.fn(),
2314 debugger: {
2315 isAttached: () => false,
2316 attach: vi.fn(),
2317 sendCommand,
2318 on: vi.fn(
2319 (
2320 event: string,
2321 listener: (event: unknown, method: string, params: Record<string, unknown>) => void,
2322 ) => {
2323 if (event === "message") debuggerMessage = listener;
2324 },
2325 ),
2326 off: vi.fn(),
2327 },
2328 capturePage,
2258 once: vi.fn(),
2259 webContents: { setBackgroundThrottling },
23292260 } as never);
2330 const recordingFrames: DesktopPreviewRecordingFrame[] = [];
23312261
2332 yield* manager.subscribeRecordingFrames((frame) =>
2333 Effect.sync(() => {
2334 recordingFrames.push(frame);
2335 }),
2336 );
2337 yield* manager.createTab("tab_screencast_guard");
2338 yield* manager.registerWebview("tab_screencast_guard", 42);
2339 yield* manager.automationEvaluate("tab_screencast_guard", { expression: "null" });
2262 const firstStart = yield* manager
2263 .startRecording("tab_recording_start_race")
2264 .pipe(Effect.forkChild({ startImmediately: true }));
2265 yield* Effect.promise(() => measurementStarted);
2266 const secondStart = yield* manager
2267 .startRecording("tab_recording_start_race")
2268 .pipe(Effect.forkChild({ startImmediately: true }));
2269 yield* Effect.yieldNow;
2270 expect(executeJavaScript).toHaveBeenCalledOnce();
23402271
2341 debuggerMessage?.({}, "Page.screencastFrame", {
2342 sessionId: 1,
2343 data: "inactive-frame",
2344 metadata: { deviceWidth: 1280, deviceHeight: 720 },
2272 rejectFirstMeasurement(new Error("first measurement failed"));
2273 const firstExit = yield* Fiber.await(firstStart);
2274 expect(Exit.isFailure(firstExit)).toBe(true);
2275 expect(yield* Fiber.join(secondStart)).toEqual({
2276 sourceId: "tab:42",
2277 width: 1280,
2278 height: 720,
23452279 });
2346 yield* Effect.yieldNow;
2347 expect(recordingFrames).toHaveLength(0);
23482280
2349 yield* manager.startRecording("tab_screencast_guard");
2350 recordingFrames.length = 0;
2351 debuggerMessage?.({}, "Page.screencastFrame", {
2352 sessionId: 2,
2353 data: "active-frame",
2354 metadata: { deviceWidth: 1280, deviceHeight: 720 },
2281 yield* manager.stopRecording("tab_recording_start_race");
2282 expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true], [false], [true]]);
2283 }),
2284 ),
2285 );
2286
2287 effectIt.effect("serializes recording source acquisition with webview replacement", () =>
2288 withManager((manager) =>
2289 Effect.gen(function* () {
2290 const capturePage = vi.fn(async () => ({
2291 toJPEG: () => Buffer.from("unused-recording-frame"),
2292 getSize: () => ({ width: 1280, height: 720 }),
2293 }));
2294 let markMeasurementStarted!: () => void;
2295 const measurementStarted = new Promise<void>((resolve) => {
2296 markMeasurementStarted = resolve;
23552297 });
2298 let finishMeasurement!: (size: { readonly width: number; readonly height: number }) => void;
2299 const executeJavaScript = vi.fn(
2300 () =>
2301 new Promise<{ readonly width: number; readonly height: number }>((resolve) => {
2302 markMeasurementStarted();
2303 finishMeasurement = resolve;
2304 }),
2305 );
2306 const initialWebContents = Object.assign(makeTestPreviewWebContents(capturePage, 42), {
2307 executeJavaScript,
2308 });
2309 const replacementOn = vi.fn();
2310 const replacementWebContents = Object.assign(makeTestPreviewWebContents(capturePage, 43), {
2311 on: replacementOn,
2312 });
2313 fromId.mockImplementation((id) => {
2314 if (id === 42) return initialWebContents;
2315 if (id === 43) return replacementWebContents;
2316 return null;
2317 });
2318
2319 yield* manager.createTab("tab_recording_replacement_race");
2320 yield* manager.registerWebview("tab_recording_replacement_race", 42);
2321 const start = yield* manager
2322 .startRecording("tab_recording_replacement_race")
2323 .pipe(Effect.forkChild({ startImmediately: true }));
2324 yield* Effect.promise(() => measurementStarted);
2325 const replacement = yield* manager
2326 .registerWebview("tab_recording_replacement_race", 43)
2327 .pipe(Effect.forkChild({ startImmediately: true }));
23562328 yield* Effect.yieldNow;
2329 expect(replacementOn).not.toHaveBeenCalled();
23572330
2358 expect(recordingFrames).toEqual([
2359 expect.objectContaining({
2360 tabId: "tab_screencast_guard",
2361 data: "active-frame",
2362 width: 1280,
2363 height: 720,
2364 }),
2365 ]);
2366 yield* manager.stopRecording("tab_screencast_guard");
2331 finishMeasurement({ width: 1280, height: 720 });
2332 expect(yield* Fiber.join(start)).toEqual({
2333 sourceId: "tab:42",
2334 width: 1280,
2335 height: 720,
2336 });
2337 yield* Fiber.join(replacement);
2338 expect(replacementOn).toHaveBeenCalled();
2339 yield* manager.stopRecording("tab_recording_replacement_race");
23672340 }),
23682341 ),
23692342 );
......@@ -2381,6 +2354,8 @@ describe("PreviewManager", () => {
23812354 fromId.mockReturnValue({
23822355 id: 42,
23832356 hostWebContents: mainWindowWebContents,
2357 getMediaSourceId: vi.fn(() => "tab:42"),
2358 executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })),
23842359 isDestroyed: () => false,
23852360 getType: () => "webview",
23862361 getURL: () => "https://example.com",
......@@ -2494,21 +2469,21 @@ describe("PreviewManager", () => {
24942469 const pictureInPictureFramesBeforeRecording = pictureInPictureSend.mock.calls.length;
24952470
24962471 yield* manager.startRecording("tab_pip");
2497 expect(capturePage).toHaveBeenCalledOnce();
2472 expect(capturePage).toHaveBeenCalledTimes(2);
24982473 expect(recordingFrames).toHaveLength(0);
24992474
25002475 yield* TestClock.adjust(100);
2501 expect(capturePage).toHaveBeenCalledTimes(2);
2476 expect(capturePage).toHaveBeenCalledTimes(3);
25022477 expect(pictureInPictureSend).toHaveBeenCalledTimes(pictureInPictureFramesBeforeRecording);
2503 expect(recordingFrames).toHaveLength(1);
2478 expect(recordingFrames).toHaveLength(0);
25042479
25052480 yield* manager.stopRecording("tab_pip");
25062481 expect(setBackgroundThrottling.mock.calls).toEqual([[false]]);
25072482 const framesBeforePictureInPictureOnlyTick = pictureInPictureSend.mock.calls.length;
25082483 yield* TestClock.adjust(100);
2509 expect(capturePage).toHaveBeenCalledTimes(3);
2484 expect(capturePage).toHaveBeenCalledTimes(4);
25102485 expect(pictureInPictureSend.mock.calls.length).toBe(framesBeforePictureInPictureOnlyTick);
2511 expect(recordingFrames).toHaveLength(1);
2486 expect(recordingFrames).toHaveLength(0);
25122487
25132488 setBackgroundThrottling.mockImplementationOnce(() => {
25142489 throw new Error("picture-in-picture throttling restore failed");
......@@ -2524,7 +2499,7 @@ describe("PreviewManager", () => {
25242499 ),
25252500 );
25262501
2527 effectIt.effect("starts picture-in-picture without an extra recording capture", () =>
2502 effectIt.effect("keeps picture-in-picture capture separate from recording warmup", () =>
25282503 withManager((manager) =>
25292504 Effect.gen(function* () {
25302505 const jpeg = Buffer.from("shared-preview-frame");
......@@ -2548,15 +2523,16 @@ describe("PreviewManager", () => {
25482523 yield* manager.registerWebview("tab_recording_then_pip", 42);
25492524 yield* manager.startRecording("tab_recording_then_pip");
25502525
2551 expect(recordingFrames).toHaveLength(1);
2552 yield* manager.openPictureInPicture("tab_recording_then_pip");
2526 expect(recordingFrames).toHaveLength(0);
25532527 expect(capturePage).toHaveBeenCalledOnce();
2554 expect(send).not.toHaveBeenCalled();
2528 yield* manager.openPictureInPicture("tab_recording_then_pip");
2529 expect(capturePage).toHaveBeenCalledTimes(2);
2530 expect(send).toHaveBeenCalledOnce();
25552531
25562532 yield* TestClock.adjust(100);
25572533
2558 expect(capturePage).toHaveBeenCalledTimes(2);
2559 expect(recordingFrames).toHaveLength(2);
2534 expect(capturePage).toHaveBeenCalledTimes(3);
2535 expect(recordingFrames).toHaveLength(0);
25602536 expect(send).toHaveBeenCalledOnce();
25612537 yield* manager.closePictureInPicture("tab_recording_then_pip");
25622538 yield* manager.stopRecording("tab_recording_then_pip");
......@@ -2658,121 +2634,6 @@ describe("PreviewManager", () => {
26582634 ),
26592635 );
26602636
2661 effectIt.effect("keeps recording cadence when pixels remain unchanged", () =>
2662 withManager((manager) =>
2663 Effect.gen(function* () {
2664 const unchanged = Buffer.from("unchanged-preview-frame");
2665 const capturePage = vi.fn(async () => ({
2666 toJPEG: () => unchanged,
2667 getSize: () => ({ width: 1280, height: 720 }),
2668 }));
2669 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
2670 const frames: DesktopPreviewRecordingFrame[] = [];
2671
2672 yield* manager.subscribeRecordingFrames((frame) =>
2673 Effect.sync(() => {
2674 frames.push(frame);
2675 }),
2676 );
2677 yield* manager.createTab("tab_unchanged_frame");
2678 yield* manager.registerWebview("tab_unchanged_frame", 42);
2679 yield* manager.startRecording("tab_unchanged_frame");
2680
2681 expect(frames.map((frame) => frame.data)).toEqual([unchanged.toString("base64")]);
2682
2683 yield* TestClock.adjust(100);
2684
2685 expect(capturePage).toHaveBeenCalledTimes(2);
2686 expect(frames.map((frame) => frame.data)).toEqual([
2687 unchanged.toString("base64"),
2688 unchanged.toString("base64"),
2689 ]);
2690
2691 yield* TestClock.adjust(100);
2692
2693 expect(capturePage).toHaveBeenCalledTimes(3);
2694 expect(frames.map((frame) => frame.data)).toEqual([
2695 unchanged.toString("base64"),
2696 unchanged.toString("base64"),
2697 unchanged.toString("base64"),
2698 ]);
2699 yield* manager.stopRecording("tab_unchanged_frame");
2700 }),
2701 ),
2702 );
2703
2704 effectIt.effect("retries recording delivery when pixels remain unchanged", () =>
2705 withManager((manager) =>
2706 Effect.gen(function* () {
2707 const jpeg = Buffer.from("retry-recording-frame");
2708 const capturePage = vi.fn(async () => ({
2709 toJPEG: () => jpeg,
2710 getSize: () => ({ width: 1280, height: 720 }),
2711 }));
2712 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
2713 let deliveries = 0;
2714
2715 yield* manager.subscribeRecordingFrames(() =>
2716 Effect.sync(() => {
2717 deliveries += 1;
2718 if (deliveries === 1) throw new Error("recording delivery failed");
2719 }),
2720 );
2721 yield* manager.createTab("tab_recording_delivery_retry");
2722 yield* manager.registerWebview("tab_recording_delivery_retry", 42);
2723 yield* manager.startRecording("tab_recording_delivery_retry");
2724 expect(deliveries).toBe(1);
2725
2726 yield* TestClock.adjust(100);
2727
2728 expect(deliveries).toBe(2);
2729 yield* manager.stopRecording("tab_recording_delivery_retry");
2730 }),
2731 ),
2732 );
2733
2734 effectIt.effect("retries a cold hidden-tab capture without dropping recording", () =>
2735 withManager((manager) =>
2736 Effect.gen(function* () {
2737 const jpeg = Buffer.from("recovered-preview-frame");
2738 const capturePage = vi.fn(async () => ({
2739 toJPEG: () => jpeg,
2740 getSize: () => ({ width: 1280, height: 720 }),
2741 }));
2742 capturePage.mockRejectedValueOnce(new Error("UnknownVizError"));
2743 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
2744 const frames: DesktopPreviewRecordingFrame[] = [];
2745
2746 yield* manager.subscribeRecordingFrames((frame) =>
2747 Effect.sync(() => {
2748 frames.push(frame);
2749 }),
2750 );
2751 yield* manager.createTab("tab_cold_capture");
2752 yield* manager.registerWebview("tab_cold_capture", 42);
2753
2754 yield* manager.startRecording("tab_cold_capture");
2755
2756 expect(capturePage).toHaveBeenCalledOnce();
2757 expect(frames).toHaveLength(0);
2758
2759 yield* TestClock.adjust(100);
2760
2761 expect(capturePage).toHaveBeenCalledTimes(2);
2762 expect(frames).toEqual([
2763 expect.objectContaining({
2764 tabId: "tab_cold_capture",
2765 data: jpeg.toString("base64"),
2766 width: 1280,
2767 height: 720,
2768 }),
2769 ]);
2770
2771 yield* manager.stopRecording("tab_cold_capture");
2772 }),
2773 ),
2774 );
2775
27762637 effectIt.effect("drops empty frames before picture-in-picture delivery", () =>
27772638 withManager((manager) =>
27782639 Effect.gen(function* () {
apps/desktop/src/preview/Manager.ts+150-62
......@@ -16,6 +16,7 @@ import type {
1616 PreviewAnnotationSubmissionResult,
1717 DesktopPreviewRecordingArtifact,
1818 DesktopPreviewRecordingFrame,
19 DesktopPreviewRecordingSource,
1920 DesktopPreviewScreenshotArtifact,
2021 DesktopPreviewTabDefaults,
2122 PreviewAutomationClickInput,
......@@ -108,8 +109,10 @@ const MAX_EVALUATION_BYTES = 64_000;
108109const MAX_VISIBLE_TEXT_LENGTH = 20_000;
109110const MAX_INTERACTIVE_ELEMENTS = 200;
110111const MAX_SCREENSHOT_WIDTH = 1280;
111const RECORDING_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12);
112const RECORDING_JPEG_QUALITY = 80;
112const RECORDING_SOURCE_SIZE_EXPRESSION =
113 "({ width: Math.round(globalThis.innerWidth), height: Math.round(globalThis.innerHeight) })";
114const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12);
115const PICTURE_IN_PICTURE_JPEG_QUALITY = 80;
113116const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480;
114117const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320;
115118const PICTURE_IN_PICTURE_MIN_WIDTH = 240;
......@@ -188,6 +191,12 @@ export const fitPictureInPictureContentSize = (
188191 return [Math.round(width), Math.round(height)];
189192};
190193
194export const recordingFileExtension = (mimeType: string): string => {
195 const subtype = mimeType.split(";", 1)[0]?.trim().toLowerCase().split("/")[1] ?? "";
196 const extension = subtype.replace(/^x-/, "").replace(/[^a-z0-9]/g, "");
197 return extension || "video";
198};
199
191200const artifactSiteSlug = (rawUrl: string): string => {
192201 try {
193202 const url = new URL(rawUrl);
......@@ -379,7 +388,7 @@ interface ManagedListeners {
379388type FrameCaptureConsumer = "picture-in-picture" | "recording";
380389
381390interface FrameCaptureSession {
382 readonly scope: Scope.Closeable;
391 readonly scope: Scope.Closeable | null;
383392 readonly consumers: ReadonlySet<FrameCaptureConsumer>;
384393 readonly lastPictureInPictureFrame: Buffer | null;
385394}
......@@ -670,10 +679,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
670679 consumers.delete(consumer);
671680 if (consumers.size > 0) {
672681 return [
673 undefined,
682 consumer === "picture-in-picture" ? current.scope : undefined,
674683 replaceMap(sessions, (copy) => {
675684 copy.set(tabId, {
676685 ...current,
686 scope: consumer === "picture-in-picture" ? null : current.scope,
677687 consumers,
678688 lastPictureInPictureFrame:
679689 consumer === "picture-in-picture" ? null : current.lastPictureInPictureFrame,
......@@ -2575,7 +2585,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
25752585 tabId: string,
25762586 ) {
25772587 const captureSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId);
2578 if (!captureSession) return;
2588 if (!captureSession?.consumers.has("picture-in-picture") || captureSession.scope === null)
2589 return;
25792590 const wc = yield* requireWebContents(tabId);
25802591 const image = yield* attemptPromise(
25812592 {
......@@ -2621,15 +2632,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
26212632 tabId,
26222633 webContentsId: wc.id,
26232634 },
2624 () => image.toJPEG(RECORDING_JPEG_QUALITY),
2635 () => image.toJPEG(PICTURE_IN_PICTURE_JPEG_QUALITY),
26252636 );
26262637 const frameSession = (yield* SynchronizedRef.get(frameCaptureSessionsRef)).get(tabId);
26272638 if (frameSession?.scope !== captureSession.scope) return;
2628 const recording = frameSession.consumers.has("recording");
26292639 const pictureInPicture =
26302640 frameSession.consumers.has("picture-in-picture") &&
26312641 frameSession.lastPictureInPictureFrame?.equals(encoded) !== true;
2632 if (!recording && !pictureInPicture) return;
2642 if (!pictureInPicture) return;
26332643 const receivedAt = yield* currentIso;
26342644 const frame: DesktopPreviewRecordingFrame = {
26352645 tabId,
......@@ -2639,16 +2649,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
26392649 receivedAt,
26402650 };
26412651 const deliveries: Array<Effect.Effect<void>> = [];
2642 if (recording) {
2643 const listeners = yield* Ref.get(recordingFrameListenersRef);
2644 deliveries.push(
2645 Effect.forEach(
2646 listeners,
2647 (listener) => deliverEvent("recording-frame", frame.tabId, () => listener(frame)),
2648 { discard: true },
2649 ),
2650 );
2651 }
26522652 if (pictureInPicture) {
26532653 const pictureInPictureWindow = (yield* SynchronizedRef.get(pictureInPictureSessionsRef)).get(
26542654 tabId,
......@@ -2726,12 +2726,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
27262726 tabId: string,
27272727 consumer: FrameCaptureConsumer,
27282728 ) {
2729 // Validate the tab synchronously, but treat capturePage failures as
2730 // transient. Chromium can return UnknownVizError while a hidden guest is
2731 // warming its first compositor frame; the scheduled loop should keep the
2732 // consumer alive and recover instead of tearing recording/PiP back down.
2729 // Recording keeps only the activity lease. Picture-in-picture owns the
2730 // capturePage loop and tolerates transient compositor warmup failures.
27332731 yield* requireWebContents(tabId);
2734 const captureNextFrame = Effect.sleep(RECORDING_FRAME_INTERVAL_MS).pipe(
2732 const captureNextFrame = Effect.sleep(PICTURE_IN_PICTURE_FRAME_INTERVAL_MS).pipe(
27352733 Effect.andThen(capturePreviewFrame(tabId)),
27362734 Effect.catch((error) =>
27372735 Effect.logWarning("Background preview frame capture failed.", {
......@@ -2740,48 +2738,60 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
27402738 }),
27412739 ),
27422740 );
2743 const created = yield* SynchronizedRef.modifyEffect(frameCaptureSessionsRef, (sessions) => {
2744 return Effect.gen(function* () {
2745 if (!frameCaptureWindowOpen) {
2746 return yield* new PreviewMainWindowClosedError({ tabId });
2747 }
2748 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
2749 if (!tab || (yield* Ref.get(closingTabIdsRef)).has(tabId)) {
2750 return yield* new PreviewTabNotFoundError({ tabId });
2751 }
2752 const current = sessions.get(tabId);
2753 if (current) {
2754 if (current.consumers.has(consumer)) {
2755 return [false, sessions] as const;
2741 const captureInitialFrame = yield* SynchronizedRef.modifyEffect(
2742 frameCaptureSessionsRef,
2743 (sessions) => {
2744 return Effect.gen(function* () {
2745 if (!frameCaptureWindowOpen) {
2746 return yield* new PreviewMainWindowClosedError({ tabId });
2747 }
2748 const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId);
2749 if (!tab || (yield* Ref.get(closingTabIdsRef)).has(tabId)) {
2750 return yield* new PreviewTabNotFoundError({ tabId });
2751 }
2752 const current = sessions.get(tabId);
2753 if (current) {
2754 if (current.consumers.has(consumer)) {
2755 return [false, sessions] as const;
2756 }
2757 let scope = current.scope;
2758 if (consumer === "picture-in-picture" && scope === null) {
2759 scope = yield* Scope.fork(parentScope, "sequential");
2760 yield* Effect.forkIn(Effect.forever(captureNextFrame), scope);
2761 }
2762 return [
2763 consumer === "picture-in-picture",
2764 replaceMap(sessions, (copy) => {
2765 copy.set(tabId, {
2766 ...current,
2767 scope,
2768 consumers: new Set([...current.consumers, consumer]),
2769 });
2770 }),
2771 ] as const;
2772 }
2773 if (sessions.size === 0) {
2774 yield* setFrameCaptureBackgroundThrottling(false);
2775 }
2776 const scope =
2777 consumer === "picture-in-picture" ? yield* Scope.fork(parentScope, "sequential") : null;
2778 if (scope !== null) {
2779 yield* Effect.forkIn(Effect.forever(captureNextFrame), scope);
27562780 }
27572781 return [
2758 false,
2782 consumer === "picture-in-picture",
27592783 replaceMap(sessions, (copy) => {
27602784 copy.set(tabId, {
2761 ...current,
2762 consumers: new Set([...current.consumers, consumer]),
2785 scope,
2786 consumers: new Set([consumer]),
2787 lastPictureInPictureFrame: null,
27632788 });
27642789 }),
27652790 ] as const;
2766 }
2767 if (sessions.size === 0) {
2768 yield* setFrameCaptureBackgroundThrottling(false);
2769 }
2770 const scope = yield* Scope.fork(parentScope, "sequential");
2771 yield* Effect.forkIn(Effect.forever(captureNextFrame), scope);
2772 return [
2773 true,
2774 replaceMap(sessions, (copy) => {
2775 copy.set(tabId, {
2776 scope,
2777 consumers: new Set([consumer]),
2778 lastPictureInPictureFrame: null,
2779 });
2780 }),
2781 ] as const;
2782 });
2783 }).pipe(Effect.uninterruptible);
2784 if (!created) return;
2791 });
2792 },
2793 ).pipe(Effect.uninterruptible);
2794 if (!captureInitialFrame) return;
27852795 yield* capturePreviewFrame(tabId).pipe(
27862796 Effect.catch((error) =>
27872797 Effect.logWarning("Initial background preview frame was not ready; capture will retry.", {
......@@ -3090,11 +3100,77 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
30903100 });
30913101
30923102 const startRecording = Effect.fn("PreviewManager.startRecording")(function* (tabId: string) {
3093 yield* startFrameCapture(tabId, "recording");
3103 if ((yield* Ref.get(closingTabIdsRef)).has(tabId)) {
3104 return yield* new PreviewTabNotFoundError({ tabId });
3105 }
3106 return yield* withTabLifecycleLock(
3107 tabId,
3108 Effect.gen(function* () {
3109 yield* startFrameCapture(tabId, "recording");
3110 const wc = yield* requireWebContents(tabId);
3111 const requestWebContents = wc.hostWebContents;
3112 if (requestWebContents === null) {
3113 return yield* new PreviewMainWindowClosedError({ tabId });
3114 }
3115 const measuredSize = yield* attemptPromise(
3116 {
3117 operation: "recording.measureSource",
3118 tabId,
3119 webContentsId: wc.id,
3120 },
3121 () => wc.executeJavaScript(RECORDING_SOURCE_SIZE_EXPRESSION, true),
3122 );
3123 if (
3124 typeof measuredSize !== "object" ||
3125 measuredSize === null ||
3126 !("width" in measuredSize) ||
3127 !("height" in measuredSize) ||
3128 typeof measuredSize.width !== "number" ||
3129 typeof measuredSize.height !== "number" ||
3130 !Number.isInteger(measuredSize.width) ||
3131 !Number.isInteger(measuredSize.height) ||
3132 measuredSize.width <= 0 ||
3133 measuredSize.height <= 0
3134 ) {
3135 return yield* new PreviewRecordingSourceSizeUnavailableError({
3136 tabId,
3137 webContentsId: wc.id,
3138 });
3139 }
3140 yield* attemptPromise(
3141 {
3142 operation: "recording.warmSource",
3143 tabId,
3144 webContentsId: wc.id,
3145 },
3146 () => wc.capturePage().then(() => undefined),
3147 ).pipe(Effect.retry({ times: 1 }), Effect.ignore);
3148 const currentWebContents = yield* requireWebContents(tabId);
3149 if (currentWebContents !== wc || wc.isDestroyed()) {
3150 return yield* new PreviewWebContentsNotFoundError({
3151 tabId,
3152 webContentsId: wc.id,
3153 });
3154 }
3155 const sourceId = yield* attempt(
3156 {
3157 operation: "recording.getMediaSourceId",
3158 tabId,
3159 webContentsId: wc.id,
3160 },
3161 () => wc.getMediaSourceId(requestWebContents),
3162 );
3163 return {
3164 sourceId,
3165 width: measuredSize.width,
3166 height: measuredSize.height,
3167 } satisfies DesktopPreviewRecordingSource;
3168 }).pipe(Effect.onError(() => stopFrameCapture(tabId, "recording").pipe(Effect.ignore))),
3169 );
30943170 });
30953171
30963172 const stopRecording = Effect.fn("PreviewManager.stopRecording")(function* (tabId: string) {
3097 yield* stopFrameCapture(tabId, "recording");
3173 yield* withTabLifecycleLock(tabId, stopFrameCapture(tabId, "recording"));
30983174 });
30993175
31003176 const saveRecording = Effect.fn("PreviewManager.saveRecording")(function* (
......@@ -3104,7 +3180,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
31043180 ) {
31053181 const [createdAt, millis] = yield* Effect.all([currentIso, currentMillis]);
31063182 const id = `browser-recording-${millis.toString(36)}`;
3107 const extension = mimeType.includes("mp4") ? "mp4" : "webm";
3183 const extension = recordingFileExtension(mimeType);
31083184 const artifactPath = path.join(resolvedArtifactDirectory, `${id}.${extension}`);
31093185 yield* fileSystem.makeDirectory(resolvedArtifactDirectory, { recursive: true }).pipe(
31103186 Effect.mapError(
......@@ -3904,6 +3980,15 @@ export class PreviewMainWindowClosedError extends Schema.TaggedErrorClass<Previe
39043980 }
39053981}
39063982
3983export class PreviewRecordingSourceSizeUnavailableError extends Schema.TaggedErrorClass<PreviewRecordingSourceSizeUnavailableError>()(
3984 "PreviewRecordingSourceSizeUnavailableError",
3985 { tabId: Schema.String, webContentsId: Schema.Number },
3986) {
3987 override get message(): string {
3988 return `Preview media source dimensions are unavailable for tab ${this.tabId}`;
3989 }
3990}
3991
39073992export class PreviewOperationError extends Schema.TaggedErrorClass<PreviewOperationError>()(
39083993 "PreviewOperationError",
39093994 {
......@@ -4111,6 +4196,7 @@ export const PreviewManagerError = Schema.Union([
41114196 PreviewWebContentsNotFoundError,
41124197 PreviewWebviewNotInitializedError,
41134198 PreviewMainWindowClosedError,
4199 PreviewRecordingSourceSizeUnavailableError,
41144200 PreviewOperationError,
41154201 PreviewArtifactPathOutsideDirectoryError,
41164202 PreviewArtifactImageLoadError,
......@@ -4188,7 +4274,9 @@ export class PreviewManager extends Context.Service<
41884274 readonly copyArtifactToClipboard: (path: string) => Effect.Effect<void, PreviewManagerError>;
41894275 readonly openPictureInPicture: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
41904276 readonly closePictureInPicture: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
4191 readonly startRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
4277 readonly startRecording: (
4278 tabId: string,
4279 ) => Effect.Effect<DesktopPreviewRecordingSource, PreviewManagerError>;
41924280 readonly stopRecording: (tabId: string) => Effect.Effect<void, PreviewManagerError>;
41934281 readonly saveRecording: (
41944282 tabId: string,
apps/desktop/src/settings/DesktopClientSettings.test.ts+1
......@@ -17,6 +17,7 @@ const clientSettings: ClientSettings = {
1717 browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" },
1818 browserDefaultZoomFactor: 1.25,
1919 browserDefaultAppearance: "dark",
20 browserRecordingFrameRate: 60,
2021 browserAutoShowFloatingPreview: false,
2122 confirmQuit: true,
2223 confirmThreadArchive: true,
apps/web/src/browser/browserRecording.test.ts+228-260
......@@ -1,77 +1,37 @@
11import { EnvironmentId, ThreadId } from "@t3tools/contracts";
22import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
33
4const {
5 events,
6 frameSubscription,
7 onFrame,
8 registrySet,
9 save,
10 startScreencast,
11 stopScreencast,
12 surfaceState,
13} = vi.hoisted(() => {
14 const events: string[] = [];
15 type Frame = {
16 readonly tabId: string;
17 readonly data: string;
18 readonly width: number;
19 readonly height: number;
20 readonly receivedAt: string;
21 };
22 const frameSubscription: { listener: ((frame: Frame) => void) | null } = {
23 listener: null,
24 };
25 const surfaceState = {
26 byTabId: {} as Record<string, unknown>,
27 };
28 return {
29 events,
30 frameSubscription,
31 onFrame: vi.fn((listener: (frame: Frame) => void) => {
32 frameSubscription.listener = listener;
33 return () => {
34 if (frameSubscription.listener === listener) frameSubscription.listener = null;
35 };
36 }),
37 registrySet: vi.fn((_atom: unknown, value: { readonly tabIds: ReadonlySet<string> }) => {
38 events.push(
39 value.tabIds.size === 0 ? "clear" : `publish:${Array.from(value.tabIds).join(",")}`,
40 );
41 }),
42 save: vi.fn(async (tabId: string) => ({
43 id: "recording-test",
44 tabId,
45 path: "/tmp/recording-test.webm",
46 mimeType: "video/webm" as const,
47 sizeBytes: 0,
48 createdAt: "2026-06-26T00:00:00.000Z",
49 })),
50 startScreencast: vi.fn(async (tabId: string) => {
51 events.push("start-screencast");
52 const surface = surfaceState.byTabId[tabId] as
53 | {
54 readonly content?: { readonly width: number; readonly height: number };
55 readonly rect?: { readonly width: number; readonly height: number };
56 }
57 | undefined;
58 const size = surface?.content ?? surface?.rect;
59 frameSubscription.listener?.({
4const { clientSettings, events, getUserMedia, registrySet, save, startScreencast, stopScreencast } =
5 vi.hoisted(() => {
6 const events: string[] = [];
7 return {
8 clientSettings: { browserRecordingFrameRate: 30 as 30 | 60 },
9 events,
10 getUserMedia: vi.fn(),
11 registrySet: vi.fn((_atom: unknown, value: { readonly tabIds: ReadonlySet<string> }) => {
12 events.push(
13 value.tabIds.size === 0 ? "clear" : `publish:${Array.from(value.tabIds).join(",")}`,
14 );
15 }),
16 save: vi.fn(async (tabId: string) => ({
17 id: "recording-test",
6018 tabId,
61 data: "initial-frame",
62 width: size?.width ?? 1280,
63 height: size?.height ?? 800,
64 receivedAt: "2026-06-26T00:00:00.000Z",
65 });
66 }),
67 stopScreencast: vi.fn(async () => undefined),
68 surfaceState,
69 };
70});
19 path: "/tmp/recording-test.webm",
20 mimeType: "video/webm" as const,
21 sizeBytes: 0,
22 createdAt: "2026-06-26T00:00:00.000Z",
23 })),
24 startScreencast: vi.fn(async (tabId: string) => {
25 events.push("start-screencast");
26 return { sourceId: `source:${tabId}`, width: 1000, height: 620 };
27 }),
28 stopScreencast: vi.fn(async () => undefined),
29 };
30 });
7131
7232vi.mock("~/components/preview/previewBridge", () => ({
7333 previewBridge: {
74 recording: { onFrame, save, startScreencast, stopScreencast },
34 recording: { onFrame: vi.fn(), save, startScreencast, stopScreencast },
7535 },
7636}));
7737
......@@ -79,32 +39,49 @@ vi.mock("~/rpc/atomRegistry", () => ({
7939 appAtomRegistry: { set: registrySet },
8040}));
8141
82vi.mock("./browserSurfaceStore", () => ({
83 useBrowserSurfaceStore: {
84 getState: () => surfaceState,
85 },
42vi.mock("~/hooks/useSettings", () => ({
43 ensureClientSettingsHydrated: vi.fn(async () => undefined),
44 getClientSettings: () => clientSettings,
8645}));
8746
8847import {
89 BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS,
48 BROWSER_RECORDING_PAINT_SETTLE_TIMEOUT_MS,
9049 BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS,
50 BrowserRecordingCaptureTimeoutError,
9151 BrowserRecordingConflictError,
52 BrowserRecordingFormatUnavailableError,
9253 findActiveBrowserRecordingRuntimeTabId,
9354 readActiveBrowserRecordingTabIds,
9455 readActiveBrowserRecordingTargets,
9556 startBrowserRecording,
9657 stopBrowserRecording,
9758} from "./browserRecording";
59import { useBrowserSurfaceStore } from "./browserSurfaceStore";
9860import { previewRuntimeTabId } from "./previewRuntimeTabId";
9961
10062class FakeMediaRecorder {
101 static isTypeSupported(): boolean {
102 return true;
63 static readonly instances: FakeMediaRecorder[] = [];
64 static supportedTypes = new Set(["video/webm;codecs=vp9"]);
65 static outputMimeType: string | undefined;
66 static stopError: unknown;
67 static isTypeSupported(type: string): boolean {
68 return this.supportedTypes.has(type);
10369 }
10470
10571 state: RecordingState = "inactive";
72 readonly mimeType: string;
73 readonly stream: MediaStream;
74 readonly options: MediaRecorderOptions | undefined;
10675 private readonly listeners = new Map<string, Set<EventListenerOrEventListenerObject>>();
10776
77 constructor(stream: MediaStream, options?: MediaRecorderOptions) {
78 this.stream = stream;
79 this.options = options;
80 this.mimeType =
81 FakeMediaRecorder.outputMimeType ?? options?.mimeType ?? "video/browser-default";
82 FakeMediaRecorder.instances.push(this);
83 }
84
10885 addEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
10986 const listeners = this.listeners.get(type) ?? new Set();
11087 listeners.add(listener);
......@@ -116,6 +93,7 @@ class FakeMediaRecorder {
11693 }
11794
11895 stop(): void {
96 if (FakeMediaRecorder.stopError !== undefined) throw FakeMediaRecorder.stopError;
11997 this.state = "inactive";
12098 for (const listener of this.listeners.get("stop") ?? []) {
12199 if (typeof listener === "function") listener(new Event("stop"));
......@@ -124,52 +102,31 @@ class FakeMediaRecorder {
124102 }
125103}
126104
127const emitRecordingFrame = () => {
128 frameSubscription.listener?.({
129 tabId: "recording-tab",
130 data: "startup-frame",
131 width: 800,
132 height: 600,
133 receivedAt: "2026-06-26T00:00:00.000Z",
134 });
135};
136
137105describe("browser recording", () => {
106 let animationFrameCount = 0;
107
138108 beforeEach(() => {
139109 events.length = 0;
140 frameSubscription.listener = null;
141 surfaceState.byTabId = {
142 "recording-tab": {
143 visible: true,
144 rect: { x: 0, y: 0, width: 800, height: 600 },
145 content: { x: 0, y: 0, width: 800, height: 600, scale: 1, scrollLeft: 0, scrollTop: 0 },
146 },
147 };
148110 vi.clearAllMocks();
111 FakeMediaRecorder.instances.length = 0;
112 FakeMediaRecorder.supportedTypes = new Set(["video/webm;codecs=vp9"]);
113 FakeMediaRecorder.outputMimeType = undefined;
114 FakeMediaRecorder.stopError = undefined;
115 clientSettings.browserRecordingFrameRate = 30;
116 animationFrameCount = 0;
149117 vi.stubGlobal("window", globalThis);
118 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
119 animationFrameCount += 1;
120 callback(animationFrameCount);
121 return animationFrameCount;
122 });
123 vi.stubGlobal("cancelAnimationFrame", vi.fn());
150124 vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder);
151 class ImmediateImage {
152 private loadListener: EventListenerOrEventListenerObject | undefined;
153
154 addEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
155 if (type === "load") this.loadListener = listener;
156 }
157
158 set src(_value: string) {
159 const event = new Event("load");
160 if (typeof this.loadListener === "function") this.loadListener(event);
161 else this.loadListener?.handleEvent(event);
162 }
163 }
164 vi.stubGlobal("Image", ImmediateImage as unknown as typeof Image);
165 vi.stubGlobal("document", {
166 createElement: () => ({
167 width: 0,
168 height: 0,
169 captureStream: () => ({}),
170 getContext: () => ({ drawImage: vi.fn(), fillRect: vi.fn(), fillStyle: "" }),
171 }),
125 getUserMedia.mockResolvedValue({
126 getTracks: () => [{ stop: vi.fn() }],
172127 });
128 vi.stubGlobal("navigator", { mediaDevices: { getUserMedia } });
129 useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} });
173130 });
174131
175132 afterEach(() => {
......@@ -185,146 +142,189 @@ describe("browser recording", () => {
185142 expect(startupEvents).toEqual(["publish:recording-tab", "start-screencast"]);
186143 });
187144
188 it("records a hidden tab without requiring it to become visible", async () => {
189 surfaceState.byTabId = {
190 "recording-tab": {
191 visible: false,
192 rect: { x: 0, y: 0, width: 800, height: 600 },
193 content: { x: 0, y: 0, width: 800, height: 600, scale: 1, scrollLeft: 0, scrollTop: 0 },
194 },
195 };
145 it("paints and holds a hidden browser surface for the recording lifetime", async () => {
146 startScreencast.mockImplementationOnce(async (tabId: string) => {
147 expect(animationFrameCount).toBe(2);
148 expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBe(1);
149 return { sourceId: `source:${tabId}`, width: 1000, height: 620 };
150 });
151 getUserMedia.mockImplementationOnce(async () => {
152 expect(animationFrameCount).toBe(2);
153 expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBe(1);
154 return { getTracks: () => [{ stop: vi.fn() }] };
155 });
196156
197 await startBrowserRecording("recording-tab");
198 const startupEvents = [...events];
157 await startBrowserRecording("background-tab");
158 expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBe(1);
199159
200 expect(startScreencast).toHaveBeenCalledWith("recording-tab");
201 await stopBrowserRecording("recording-tab");
202 expect(startupEvents).toEqual(["publish:recording-tab", "start-screencast"]);
160 await stopBrowserRecording("background-tab");
161 expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBeUndefined();
203162 });
204163
205 it("fails startup instead of locking a fallback size when no frame arrives", async () => {
164 it("bounds compositor warmup when animation frames are paused", async () => {
206165 vi.useFakeTimers();
207 startScreencast.mockImplementationOnce(async () => {
208 events.push("start-screencast");
209 });
166 const cancelAnimationFrame = vi.fn();
167 vi.stubGlobal(
168 "requestAnimationFrame",
169 vi.fn(() => 42),
170 );
171 vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame);
210172
211 const startPromise = startBrowserRecording("recording-tab");
212 const rejection = expect(startPromise).rejects.toMatchObject({
213 operation: "wait-first-frame",
214 tabId: "recording-tab",
173 const startPromise = startBrowserRecording("hidden-window-tab");
174 await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_PAINT_SETTLE_TIMEOUT_MS);
175
176 await startPromise;
177 expect(cancelAnimationFrame).toHaveBeenCalledWith(42);
178 await stopBrowserRecording("hidden-window-tab");
179 });
180
181 it("records the native tab stream at the preview's current dimensions", async () => {
182 const stopTrack = vi.fn();
183 const stream = { getTracks: () => [{ stop: stopTrack }] } as unknown as MediaStream;
184 getUserMedia.mockResolvedValueOnce(stream);
185
186 await startBrowserRecording("recording-tab");
187
188 expect(getUserMedia).toHaveBeenCalledWith({
189 audio: false,
190 video: {
191 mandatory: {
192 chromeMediaSource: "tab",
193 chromeMediaSourceId: "source:recording-tab",
194 minWidth: 1000,
195 maxWidth: 1000,
196 minHeight: 620,
197 maxHeight: 620,
198 maxFrameRate: 30,
199 },
200 },
215201 });
216 await Promise.resolve();
217 await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS);
202 expect(FakeMediaRecorder.instances[0]?.stream).toBe(stream);
218203
219 await rejection;
220 expect(stopScreencast).toHaveBeenCalledWith("recording-tab");
221 expect(events.at(-1)).toBe("clear");
204 await stopBrowserRecording("recording-tab");
205 expect(stopTrack).toHaveBeenCalledOnce();
222206 });
223207
224 it("fixes hidden recording dimensions before MediaRecorder starts", async () => {
225 const drawImage = vi.fn();
226 const fillRect = vi.fn();
227 let capturedStreamSize: { readonly width: number; readonly height: number } | undefined;
228 const canvas = {
229 width: 0,
230 height: 0,
231 captureStream: () => {
232 capturedStreamSize = { width: canvas.width, height: canvas.height };
233 return {};
208 it("uses the configured recording frame rate", async () => {
209 clientSettings.browserRecordingFrameRate = 60;
210
211 await startBrowserRecording("recording-tab");
212
213 expect(getUserMedia).toHaveBeenCalledWith({
214 audio: false,
215 video: {
216 mandatory: expect.objectContaining({ maxFrameRate: 60 }),
234217 },
235 getContext: () => ({ drawImage, fillRect, fillStyle: "" }),
236 };
237 vi.stubGlobal("document", {
238 createElement: () => canvas,
239218 });
240 surfaceState.byTabId = {};
241 startScreencast.mockImplementationOnce(async (tabId: string) => {
242 events.push("start-screencast");
243 frameSubscription.listener?.({
244 tabId,
245 data: "captured-frame",
246 width: 390,
247 height: 844,
248 receivedAt: "2026-06-26T00:00:00.000Z",
249 });
219 await stopBrowserRecording("recording-tab");
220 });
221
222 it("stops the native stream when MediaRecorder cleanup fails", async () => {
223 const stopTrack = vi.fn();
224 getUserMedia.mockResolvedValueOnce({
225 getTracks: () => [{ stop: stopTrack }],
250226 });
251227
252228 await startBrowserRecording("recording-tab");
229 FakeMediaRecorder.stopError = new Error("stop failed");
253230
254 expect(canvas).toMatchObject({ width: 390, height: 844 });
255 expect(capturedStreamSize).toEqual({ width: 390, height: 844 });
256 expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 390, 844);
257
258 frameSubscription.listener?.({
231 await expect(stopBrowserRecording("recording-tab")).rejects.toMatchObject({
232 operation: "cleanup",
259233 tabId: "recording-tab",
260 data: "different-sized-frame",
261 width: 1280,
262 height: 720,
263 receivedAt: "2026-06-26T00:00:01.000Z",
264234 });
235 expect(stopTrack).toHaveBeenCalledOnce();
236 });
265237
266 expect(canvas).toMatchObject({ width: 390, height: 844 });
267 expect(fillRect).toHaveBeenLastCalledWith(0, 0, 390, 844);
238 it("uses the best supported encoder and saves the recorder's actual format", async () => {
239 FakeMediaRecorder.supportedTypes = new Set([
240 "video/mp4;codecs=avc1.42e01e",
241 "video/webm;codecs=vp9",
242 "video/webm;codecs=av1",
243 ]);
244 FakeMediaRecorder.outputMimeType = "video/webm;codecs=av01";
268245
246 await startBrowserRecording("recording-tab");
269247 await stopBrowserRecording("recording-tab");
270 });
271248
272 it("draws the newest decoded frames without starving behind decode latency", async () => {
273 const drawImage = vi.fn();
274 class DeferredImage {
275 static readonly instances: DeferredImage[] = [];
276 private loadListener: EventListenerOrEventListenerObject | undefined;
249 expect(FakeMediaRecorder.instances[0]?.options).toEqual({
250 mimeType: "video/webm;codecs=av1",
251 });
252 expect(save).toHaveBeenCalledWith(
253 "recording-tab",
254 "video/webm;codecs=av01",
255 expect.any(Uint8Array),
256 );
257 });
277258
278 constructor() {
279 DeferredImage.instances.push(this);
280 }
259 it("lets the browser select the format when no preferred encoding is supported", async () => {
260 FakeMediaRecorder.supportedTypes = new Set();
261 FakeMediaRecorder.outputMimeType = "video/platform-default";
281262
282 addEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
283 if (type === "load") this.loadListener = listener;
284 }
263 await startBrowserRecording("recording-tab");
264 await stopBrowserRecording("recording-tab");
285265
286 set src(_value: string) {}
266 expect(FakeMediaRecorder.instances[0]?.options).toBeUndefined();
267 expect(save).toHaveBeenCalledWith(
268 "recording-tab",
269 "video/platform-default",
270 expect.any(Uint8Array),
271 );
272 });
287273
288 finishLoading(): void {
289 const event = new Event("load");
290 if (typeof this.loadListener === "function") this.loadListener(event);
291 else this.loadListener?.handleEvent(event);
292 }
293 }
294 vi.stubGlobal("Image", DeferredImage as unknown as typeof Image);
295 vi.stubGlobal("document", {
296 createElement: () => ({
297 width: 0,
298 height: 0,
299 captureStream: () => ({}),
300 getContext: () => ({ drawImage, fillRect: vi.fn(), fillStyle: "" }),
301 }),
302 });
274 it("reports when MediaRecorder provides no output format", async () => {
275 FakeMediaRecorder.supportedTypes = new Set();
276 FakeMediaRecorder.outputMimeType = "";
303277
304278 await startBrowserRecording("recording-tab");
305 frameSubscription.listener?.({
279
280 await expect(stopBrowserRecording("recording-tab")).rejects.toBeInstanceOf(
281 BrowserRecordingFormatUnavailableError,
282 );
283 expect(save).not.toHaveBeenCalled();
284 expect(readActiveBrowserRecordingTabIds()).toEqual(new Set());
285 });
286
287 it("releases the native capture lease when stream acquisition fails", async () => {
288 getUserMedia.mockRejectedValueOnce(new Error("capture failed"));
289
290 await expect(startBrowserRecording("recording-tab")).rejects.toMatchObject({
291 operation: "capture-media-stream",
306292 tabId: "recording-tab",
307 data: "second-frame",
308 width: 800,
309 height: 600,
310 receivedAt: "2026-06-26T00:00:01.000Z",
311293 });
312 frameSubscription.listener?.({
294
295 expect(stopScreencast).toHaveBeenCalledWith("recording-tab");
296 expect(events.at(-1)).toBe("clear");
297 });
298
299 it("times out stalled stream acquisition and stops a late stream", async () => {
300 vi.useFakeTimers();
301 let finishCapture!: (stream: MediaStream) => void;
302 const stopTrack = vi.fn();
303 getUserMedia.mockImplementationOnce(
304 () =>
305 new Promise<MediaStream>((resolve) => {
306 finishCapture = resolve;
307 }),
308 );
309
310 const startPromise = startBrowserRecording("recording-tab");
311 await vi.waitFor(() => expect(getUserMedia).toHaveBeenCalledOnce());
312 const rejection = expect(startPromise).rejects.toMatchObject({
313 _tag: "BrowserRecordingCaptureTimeoutError",
313314 tabId: "recording-tab",
314 data: "third-frame",
315 width: 800,
316 height: 600,
317 receivedAt: "2026-06-26T00:00:02.000Z",
315 timeoutMs: BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS,
318316 });
317 await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS);
319318
320 DeferredImage.instances[1]?.finishLoading();
321 expect(drawImage).toHaveBeenCalledOnce();
322 DeferredImage.instances[2]?.finishLoading();
323 expect(drawImage).toHaveBeenCalledTimes(2);
324 DeferredImage.instances[0]?.finishLoading();
325 expect(drawImage).toHaveBeenCalledTimes(2);
319 await rejection;
320 await expect(startPromise).rejects.toBeInstanceOf(BrowserRecordingCaptureTimeoutError);
321 expect(stopScreencast).toHaveBeenCalledWith("recording-tab");
322 expect(readActiveBrowserRecordingTabIds()).toEqual(new Set());
323 expect(useBrowserSurfaceStore.getState().activityByTabId["recording-tab"]).toBeUndefined();
326324
327 await stopBrowserRecording("recording-tab");
325 finishCapture({ getTracks: () => [{ stop: stopTrack }] } as unknown as MediaStream);
326 await vi.advanceTimersByTimeAsync(0);
327 expect(stopTrack).toHaveBeenCalledOnce();
328328 });
329329
330330 it("records separate tabs concurrently", async () => {
......@@ -336,22 +336,12 @@ describe("browser recording", () => {
336336 environmentId: EnvironmentId.make("environment-recording"),
337337 threadId: ThreadId.make("thread-recording-second"),
338338 };
339 surfaceState.byTabId = {
340 ...surfaceState.byTabId,
341 "recording-tab-2": {
342 visible: false,
343 rect: { x: 0, y: 0, width: 390, height: 844 },
344 content: { x: 0, y: 0, width: 390, height: 844, scale: 1, scrollLeft: 0, scrollTop: 0 },
345 },
346 };
347
348339 await Promise.all([
349340 startBrowserRecording("recording-tab", firstThreadRef),
350341 startBrowserRecording("recording-tab-2", secondThreadRef),
351342 ]);
352343
353344 expect(startScreencast).toHaveBeenCalledTimes(2);
354 expect(onFrame).toHaveBeenCalledOnce();
355345 expect(events).toContain("publish:recording-tab,recording-tab-2");
356346 expect(readActiveBrowserRecordingTabIds()).toEqual(
357347 new Set(["recording-tab", "recording-tab-2"]),
......@@ -372,22 +362,6 @@ describe("browser recording", () => {
372362 threadId: ThreadId.make("thread-recording-scoped"),
373363 };
374364 const runtimeTabId = previewRuntimeTabId(threadRef, "epoch-a", "tab_1");
375 surfaceState.byTabId = {
376 [runtimeTabId]: {
377 visible: false,
378 rect: { x: 0, y: 0, width: 1280, height: 800 },
379 content: {
380 x: 0,
381 y: 0,
382 width: 1280,
383 height: 800,
384 scale: 1,
385 scrollLeft: 0,
386 scrollTop: 0,
387 },
388 },
389 };
390
391365 await startBrowserRecording(runtimeTabId, threadRef, "tab_1");
392366
393367 expect(startScreencast).toHaveBeenCalledWith(runtimeTabId);
......@@ -408,18 +382,12 @@ describe("browser recording", () => {
408382
409383 it("does not report success for a second start while the first is still starting", async () => {
410384 let finishStartingScreencast: (() => void) | undefined;
411 startScreencast.mockImplementationOnce(async (tabId: string) => {
385 startScreencast.mockImplementationOnce(async () => {
412386 events.push("start-screencast");
413 frameSubscription.listener?.({
414 tabId,
415 data: "initial-frame",
416 width: 800,
417 height: 600,
418 receivedAt: "2026-06-26T00:00:00.000Z",
419 });
420387 await new Promise<void>((resolve) => {
421388 finishStartingScreencast = resolve;
422389 });
390 return { sourceId: "source:recording-tab", width: 1000, height: 620 };
423391 });
424392
425393 const firstStart = startBrowserRecording("recording-tab");
......@@ -484,7 +452,7 @@ describe("browser recording", () => {
484452 await new Promise<void>((resolve) => {
485453 finishStartingScreencast = resolve;
486454 });
487 emitRecordingFrame();
455 return { sourceId: "source:recording-tab", width: 1000, height: 620 };
488456 });
489457
490458 const startPromise = startBrowserRecording("recording-tab");
......@@ -508,7 +476,7 @@ describe("browser recording", () => {
508476 await new Promise<void>((resolve) => {
509477 finishStartingScreencast = resolve;
510478 });
511 emitRecordingFrame();
479 return { sourceId: "source:recording-tab", width: 1000, height: 620 };
512480 });
513481
514482 const firstStart = startBrowserRecording("recording-tab");
......@@ -535,7 +503,7 @@ describe("browser recording", () => {
535503 await new Promise<void>((resolve) => {
536504 finishStartingScreencast = resolve;
537505 });
538 emitRecordingFrame();
506 return { sourceId: "source:recording-tab", width: 1000, height: 620 };
539507 });
540508 stopScreencast.mockRejectedValueOnce(new Error("initial stop failed"));
541509
......@@ -569,15 +537,14 @@ describe("browser recording", () => {
569537 await new Promise<void>((resolve) => {
570538 finishStartingScreencast = resolve;
571539 });
572 emitRecordingFrame();
540 return { sourceId: "source:recording-tab", width: 1000, height: 620 };
573541 });
574542
575543 const startPromise = startBrowserRecording("recording-tab");
576 expect(startScreencast).toHaveBeenCalledOnce();
544 await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce());
577545
578546 const stopPromise = stopBrowserRecording("recording-tab");
579 await Promise.resolve();
580 await Promise.resolve();
547 await vi.advanceTimersByTimeAsync(0);
581548 expect(stopScreencast).not.toHaveBeenCalled();
582549
583550 const rejection = expect(stopPromise).rejects.toMatchObject({
......@@ -593,6 +560,7 @@ describe("browser recording", () => {
593560 );
594561
595562 finishStartingScreencast?.();
563 await vi.advanceTimersByTimeAsync(32);
596564 await startPromise;
597565 const cleanupResult = await stopBrowserRecording("recording-tab");
598566 expect(cleanupResult).toBeNull();
apps/web/src/browser/browserRecording.ts+171-143
......@@ -1,6 +1,6 @@
11import type {
22 DesktopPreviewRecordingArtifact,
3 DesktopPreviewRecordingFrame,
3 DesktopPreviewRecordingSource,
44 ScopedThreadRef,
55} from "@t3tools/contracts";
66import { useAtomValue } from "@effect/atom-react";
......@@ -8,8 +8,10 @@ import * as Schema from "effect/Schema";
88import { Atom } from "effect/unstable/reactivity";
99
1010import { previewBridge } from "~/components/preview/previewBridge";
11import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings";
1112import { appAtomRegistry } from "~/rpc/atomRegistry";
12import { useBrowserSurfaceStore } from "./browserSurfaceStore";
13
14import { acquireBrowserSurfaceActivity } from "./browserSurfaceStore";
1315
1416export class BrowserRecordingUnavailableError extends Schema.TaggedErrorClass<BrowserRecordingUnavailableError>()(
1517 "BrowserRecordingUnavailableError",
......@@ -34,16 +36,24 @@ export class BrowserRecordingConflictError extends Schema.TaggedErrorClass<Brows
3436 }
3537}
3638
37export class BrowserRecordingCanvasUnavailableError extends Schema.TaggedErrorClass<BrowserRecordingCanvasUnavailableError>()(
38 "BrowserRecordingCanvasUnavailableError",
39export class BrowserRecordingFormatUnavailableError extends Schema.TaggedErrorClass<BrowserRecordingFormatUnavailableError>()(
40 "BrowserRecordingFormatUnavailableError",
41 { tabId: Schema.String },
42) {
43 override get message(): string {
44 return `MediaRecorder did not report an output format for tab ${this.tabId}.`;
45 }
46}
47
48export class BrowserRecordingCaptureTimeoutError extends Schema.TaggedErrorClass<BrowserRecordingCaptureTimeoutError>()(
49 "BrowserRecordingCaptureTimeoutError",
3950 {
4051 tabId: Schema.String,
41 width: Schema.Number,
42 height: Schema.Number,
52 timeoutMs: Schema.Number,
4353 },
4454) {
4555 override get message(): string {
46 return `Browser recording canvas ${this.width}x${this.height} is unavailable for tab ${this.tabId}.`;
56 return `Browser recording media capture for tab ${this.tabId} did not settle within ${this.timeoutMs}ms.`;
4757 }
4858}
4959
......@@ -52,11 +62,10 @@ export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<Brow
5262 {
5363 operation: Schema.Literals([
5464 "initialize-media-recorder",
55 "subscribe-frames",
65 "capture-media-stream",
5666 "start-media-recorder",
5767 "start-screencast",
5868 "stop-screencast",
59 "wait-first-frame",
6069 "wait-startup",
6170 "stop-media-recorder",
6271 "save-artifact",
......@@ -72,6 +81,7 @@ export class BrowserRecordingOperationError extends Schema.TaggedErrorClass<Brow
7281}
7382
7483const isBrowserRecordingOperationError = Schema.is(BrowserRecordingOperationError);
84const isBrowserRecordingCaptureTimeoutError = Schema.is(BrowserRecordingCaptureTimeoutError);
7585
7686type BrowserRecordingLifecycle =
7787 | { readonly phase: "starting" }
......@@ -82,23 +92,17 @@ type BrowserRecordingLifecycle =
8292 };
8393
8494interface ActiveRecording {
85 /** Desktop-scoped identity used by capture and surface stores. */
95 /** Desktop-scoped identity used by the native capture lease. */
8696 readonly tabId: string;
8797 /** Server-local identity returned by preview automation tools. */
8898 readonly serverTabId: string;
8999 readonly threadRef: ScopedThreadRef | null;
90 readonly canvas: HTMLCanvasElement;
91 readonly context: CanvasRenderingContext2D;
92100 readonly chunks: Blob[];
93101 readonly startedAt: string;
94102 readonly startupSettled: Promise<void>;
95 readonly firstFrameSize: Promise<"frame" | "cancelled">;
96 readonly settleFirstFrameSize: (outcome: "frame" | "cancelled") => void;
103 releaseSurfaceActivity: (() => void) | null;
104 stream: MediaStream | null;
97105 recorder: MediaRecorder | null;
98 mimeType: string | null;
99 frameSizeEstablished: boolean;
100 frameSequence: number;
101 lastDrawnFrameSequence: number;
102106 lifecycle: BrowserRecordingLifecycle;
103107}
104108
......@@ -120,7 +124,6 @@ export function useActiveBrowserRecordingTabIds(): ReadonlySet<string> {
120124}
121125
122126const activeRecordings = new Map<string, ActiveRecording>();
123let unsubscribeFrames: (() => void) | null = null;
124127
125128const publishActiveRecordingTabIds = (): void => {
126129 appAtomRegistry.set(activeBrowserRecordingTabIdsAtom, {
......@@ -129,7 +132,7 @@ const publishActiveRecordingTabIds = (): void => {
129132};
130133
131134export const BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS = 5_000;
132export const BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS = 5_000;
135export const BROWSER_RECORDING_PAINT_SETTLE_TIMEOUT_MS = 250;
133136
134137export function readActiveBrowserRecordingTabIds(threadRef?: ScopedThreadRef): ReadonlySet<string> {
135138 const tabIds = new Set<string>();
......@@ -167,55 +170,38 @@ export function findActiveBrowserRecordingRuntimeTabId(
167170 );
168171}
169172
170const preferredMimeType = (): string => {
171 const candidates = ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9", "video/webm"];
172 return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? "video/webm";
173const preferredMimeTypes = [
174 "video/webm;codecs=av1",
175 "video/webm;codecs=vp9",
176 "video/mp4;codecs=avc1.640028",
177 "video/mp4;codecs=avc1.42e01e",
178 "video/webm;codecs=vp8",
179 "video/webm",
180] as const;
181
182const createMediaRecorder = (stream: MediaStream): MediaRecorder => {
183 const mimeType = preferredMimeTypes.find((candidate) => MediaRecorder.isTypeSupported(candidate));
184 return mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
173185};
174186
175const drawFrame = (frame: DesktopPreviewRecordingFrame): void => {
176 const recording = activeRecordings.get(frame.tabId);
177 if (!recording) return;
178 if (
179 !Number.isFinite(frame.width) ||
180 !Number.isFinite(frame.height) ||
181 frame.width <= 0 ||
182 frame.height <= 0
183 ) {
184 return;
185 }
186 const width = Math.max(1, Math.round(frame.width));
187 const height = Math.max(1, Math.round(frame.height));
188 if (!recording.frameSizeEstablished) {
189 recording.canvas.width = width;
190 recording.canvas.height = height;
191 recording.frameSizeEstablished = true;
192 recording.settleFirstFrameSize("frame");
193 }
194 const frameSequence = ++recording.frameSequence;
195 const image = new Image();
196 image.addEventListener(
197 "load",
198 () => {
199 if (
200 activeRecordings.get(frame.tabId) !== recording ||
201 frameSequence <= recording.lastDrawnFrameSequence
202 ) {
203 return;
204 }
205 recording.lastDrawnFrameSequence = frameSequence;
206 const scale = Math.min(recording.canvas.width / width, recording.canvas.height / height);
207 const targetWidth = width * scale;
208 const targetHeight = height * scale;
209 const targetX = (recording.canvas.width - targetWidth) / 2;
210 const targetY = (recording.canvas.height - targetHeight) / 2;
211 recording.context.fillStyle = "#000000";
212 recording.context.fillRect(0, 0, recording.canvas.width, recording.canvas.height);
213 recording.context.drawImage(image, targetX, targetY, targetWidth, targetHeight);
214 },
215 { once: true },
216 );
217 image.src = `data:image/jpeg;base64,${frame.data}`;
218};
187const captureTabMediaStream = (
188 source: DesktopPreviewRecordingSource,
189 frameRate: number,
190): Promise<MediaStream> =>
191 navigator.mediaDevices.getUserMedia({
192 audio: false,
193 video: {
194 mandatory: {
195 chromeMediaSource: "tab",
196 chromeMediaSourceId: source.sourceId,
197 minWidth: source.width,
198 maxWidth: source.width,
199 minHeight: source.height,
200 maxHeight: source.height,
201 maxFrameRate: frameRate,
202 },
203 } as unknown as MediaTrackConstraints,
204 });
219205
220206const stopMediaRecorder = async (recorder: MediaRecorder | null): Promise<void> => {
221207 if (!recorder || recorder.state === "inactive") return;
......@@ -226,17 +212,76 @@ const stopMediaRecorder = async (recorder: MediaRecorder | null): Promise<void>
226212 await stopped;
227213};
228214
215const stopMediaStream = (stream: MediaStream | null): void => {
216 for (const track of stream?.getTracks() ?? []) track.stop();
217};
218
219const captureTabMediaStreamWithTimeout = async (
220 tabId: string,
221 source: DesktopPreviewRecordingSource,
222 frameRate: number,
223): Promise<MediaStream> => {
224 let acceptStream = true;
225 let timeoutId: number | null = null;
226 const streamPromise = captureTabMediaStream(source, frameRate).then((stream) => {
227 if (!acceptStream) stopMediaStream(stream);
228 return stream;
229 });
230 try {
231 return await Promise.race([
232 streamPromise,
233 new Promise<never>((_, reject) => {
234 timeoutId = window.setTimeout(
235 () =>
236 reject(
237 new BrowserRecordingCaptureTimeoutError({
238 tabId,
239 timeoutMs: BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS,
240 }),
241 ),
242 BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS,
243 );
244 }),
245 ]);
246 } finally {
247 acceptStream = false;
248 if (timeoutId !== null) window.clearTimeout(timeoutId);
249 }
250};
251
229252const clearActiveRecording = (recording: ActiveRecording): void => {
253 recording.releaseSurfaceActivity?.();
254 recording.releaseSurfaceActivity = null;
230255 if (activeRecordings.get(recording.tabId) !== recording) return;
231 recording.settleFirstFrameSize("cancelled");
232256 activeRecordings.delete(recording.tabId);
233 if (activeRecordings.size === 0) {
234 unsubscribeFrames?.();
235 unsubscribeFrames = null;
236 }
237257 publishActiveRecordingTabIds();
238258};
239259
260const waitForBrowserRecordingPaint = async (): Promise<void> => {
261 let firstFrameId: number | null = null;
262 let secondFrameId: number | null = null;
263 let timeoutId: number | null = null;
264 const painted = new Promise<void>((resolve) => {
265 firstFrameId = window.requestAnimationFrame(() => {
266 firstFrameId = null;
267 secondFrameId = window.requestAnimationFrame(() => {
268 secondFrameId = null;
269 resolve();
270 });
271 });
272 });
273 const timedOut = new Promise<void>((resolve) => {
274 timeoutId = window.setTimeout(resolve, BROWSER_RECORDING_PAINT_SETTLE_TIMEOUT_MS);
275 });
276 try {
277 await Promise.race([painted, timedOut]);
278 } finally {
279 if (timeoutId !== null) window.clearTimeout(timeoutId);
280 if (firstFrameId !== null) window.cancelAnimationFrame(firstFrameId);
281 if (secondFrameId !== null) window.cancelAnimationFrame(secondFrameId);
282 }
283};
284
240285const cleanupFailedRecordingStart = async (
241286 bridge: NonNullable<typeof previewBridge>,
242287 recording: ActiveRecording,
......@@ -251,6 +296,11 @@ const cleanupFailedRecordingStart = async (
251296 await stopMediaRecorder(recording.recorder);
252297 } catch (error) {
253298 errors.push(error);
299 }
300 try {
301 stopMediaStream(recording.stream);
302 } catch (error) {
303 errors.push(error);
254304 } finally {
255305 clearActiveRecording(recording);
256306 }
......@@ -276,19 +326,6 @@ const recordingStartupCancelledError = (
276326const isRecordingStarting = (recording: ActiveRecording): boolean =>
277327 activeRecordings.get(recording.tabId) === recording && recording.lifecycle.phase === "starting";
278328
279const waitForFirstFrameSize = async (recording: ActiveRecording): Promise<boolean> => {
280 if (recording.frameSizeEstablished) return true;
281 let timeout: ReturnType<typeof setTimeout> | null = null;
282 const outcome = await Promise.race([
283 recording.firstFrameSize,
284 new Promise<"timeout">((resolve) => {
285 timeout = setTimeout(() => resolve("timeout"), BROWSER_RECORDING_FIRST_FRAME_SIZE_TIMEOUT_MS);
286 }),
287 ]);
288 if (timeout !== null) clearTimeout(timeout);
289 return outcome === "frame";
290};
291
292329const waitForRecordingStartupToSettle = async (recording: ActiveRecording): Promise<void> => {
293330 let timeout: ReturnType<typeof setTimeout> | null = null;
294331 try {
......@@ -339,62 +376,35 @@ export async function startBrowserRecording(
339376 activeTabId: activeLogicalRecording,
340377 });
341378 }
342 const surface = useBrowserSurfaceStore.getState().byTabId[tabId];
343 const recordingSize = surface?.content ?? surface?.rect;
344 const canvas = document.createElement("canvas");
345 canvas.width = Math.max(1, recordingSize?.width ?? 1280);
346 canvas.height = Math.max(1, recordingSize?.height ?? 800);
347 const context = canvas.getContext("2d", { alpha: false });
348 if (!context) {
349 throw new BrowserRecordingCanvasUnavailableError({
350 tabId,
351 width: canvas.width,
352 height: canvas.height,
353 });
354 }
355379 const startedAt = new Date().toISOString();
356380 const chunks: Blob[] = [];
357381 let settleStartup: (() => void) | undefined;
358382 const startupSettled = new Promise<void>((resolve) => {
359383 settleStartup = resolve;
360384 });
361 let settleFirstFrameSize: ((outcome: "frame" | "cancelled") => void) | undefined;
362 const firstFrameSize = new Promise<"frame" | "cancelled">((resolve) => {
363 settleFirstFrameSize = resolve;
364 });
385 const releaseSurfaceActivity = acquireBrowserSurfaceActivity(tabId);
365386 const recording: ActiveRecording = {
366387 tabId,
367388 serverTabId,
368389 threadRef,
369 canvas,
370 context,
371390 chunks,
372391 startedAt,
373392 startupSettled,
374 firstFrameSize,
375 settleFirstFrameSize: (outcome) => settleFirstFrameSize?.(outcome),
393 releaseSurfaceActivity,
394 stream: null,
376395 recorder: null,
377 mimeType: null,
378 frameSizeEstablished: false,
379 frameSequence: 0,
380 lastDrawnFrameSequence: 0,
381396 lifecycle: { phase: "starting" },
382397 };
383398 activeRecordings.set(tabId, recording);
384399 publishActiveRecordingTabIds();
385400 try {
401 const frameRatePromise = ensureClientSettingsHydrated().then(
402 () => getClientSettings().browserRecordingFrameRate,
403 );
404 const [frameRate] = await Promise.all([frameRatePromise, waitForBrowserRecordingPaint()]);
405 let source: DesktopPreviewRecordingSource;
386406 try {
387 unsubscribeFrames ??= bridge.recording.onFrame(drawFrame);
388 } catch (cause) {
389 clearActiveRecording(recording);
390 throw new BrowserRecordingOperationError({
391 operation: "subscribe-frames",
392 tabId,
393 cause,
394 });
395 }
396 try {
397 await bridge.recording.startScreencast(tabId);
407 source = await bridge.recording.startScreencast(tabId);
398408 } catch (cause) {
399409 if (!isRecordingStarting(recording)) {
400410 throw recordingStartupCancelledError(recording, cause);
......@@ -425,34 +435,29 @@ export async function startBrowserRecording(
425435 throw recordingStartupCancelledError(recording);
426436 };
427437 await throwIfStartupCancelled();
428 const hasFirstFrame = await waitForFirstFrameSize(recording);
429 await throwIfStartupCancelled();
430 if (!hasFirstFrame) {
431 const cause = new Error(`No valid recording frame arrived for tab ${tabId}.`);
438 try {
439 recording.stream = await captureTabMediaStreamWithTimeout(tabId, source, frameRate);
440 } catch (cause) {
432441 const cleanupCause = await cleanupFailedRecordingStart(bridge, recording);
442 if (isBrowserRecordingCaptureTimeoutError(cause) && cleanupCause === undefined) throw cause;
433443 throw new BrowserRecordingOperationError({
434 operation: "wait-first-frame",
444 operation: "capture-media-stream",
435445 tabId,
436446 cause:
437447 cleanupCause === undefined
438448 ? cause
439449 : new AggregateError(
440450 [cause, cleanupCause],
441 `Browser recording frame wait and cleanup failed for tab ${tabId}.`,
451 `Browser media capture and cleanup failed for tab ${tabId}.`,
442452 { cause },
443453 ),
444454 });
445455 }
456 await throwIfStartupCancelled();
446457
447 let mimeType: string;
448458 let recorder: MediaRecorder;
449459 try {
450 mimeType = preferredMimeType();
451 recorder = new MediaRecorder(canvas.captureStream(12), {
452 mimeType,
453 videoBitsPerSecond: 4_000_000,
454 });
455 recording.mimeType = mimeType;
460 recorder = createMediaRecorder(recording.stream);
456461 recording.recorder = recorder;
457462 recorder.addEventListener("dataavailable", (event) => {
458463 if (event.data.size > 0) chunks.push(event.data);
......@@ -520,7 +525,7 @@ const finalizeBrowserRecording = async (
520525 cause,
521526 });
522527 }
523 if (!recording.recorder || !recording.mimeType) {
528 if (!recording.recorder) {
524529 result = { _tag: "Success", artifact: null };
525530 } else {
526531 try {
......@@ -532,11 +537,17 @@ const finalizeBrowserRecording = async (
532537 cause,
533538 });
534539 }
540 const mimeType =
541 recording.recorder.mimeType ||
542 recording.chunks.find((chunk) => chunk.type.length > 0)?.type;
543 if (!mimeType) {
544 throw new BrowserRecordingFormatUnavailableError({ tabId });
545 }
535546 try {
536 const blob = new Blob(recording.chunks, { type: recording.mimeType });
547 const blob = new Blob(recording.chunks, { type: mimeType });
537548 const artifact = await bridge.recording.save(
538549 tabId,
539 recording.mimeType,
550 mimeType,
540551 new Uint8Array(await blob.arrayBuffer()),
541552 );
542553 result = { _tag: "Success", artifact };
......@@ -560,18 +571,34 @@ const finalizeBrowserRecording = async (
560571 throw result.error;
561572 }
562573
563 let cleanupError: BrowserRecordingOperationError | undefined;
574 const cleanupErrors: unknown[] = [];
564575 try {
565576 await stopMediaRecorder(recording.recorder);
566577 } catch (cause) {
567 cleanupError = new BrowserRecordingOperationError({
568 operation: "stop-media-recorder",
569 tabId,
570 cause,
571 });
578 cleanupErrors.push(cause);
579 }
580 try {
581 stopMediaStream(recording.stream);
582 } catch (cause) {
583 cleanupErrors.push(cause);
572584 } finally {
573585 clearActiveRecording(recording);
574586 }
587 const cleanupError =
588 cleanupErrors.length === 0
589 ? undefined
590 : new BrowserRecordingOperationError({
591 operation: "cleanup",
592 tabId,
593 cause:
594 cleanupErrors.length === 1
595 ? cleanupErrors[0]
596 : new AggregateError(
597 cleanupErrors,
598 `Browser recording media cleanup failed for tab ${tabId}.`,
599 { cause: cleanupErrors[0] },
600 ),
601 });
575602
576603 if (result._tag === "Failure") {
577604 if (cleanupError) {
......@@ -598,6 +625,7 @@ const discardBrowserRecording = async (
598625 try {
599626 await bridge.recording.stopScreencast(recording.tabId).catch(() => undefined);
600627 await stopMediaRecorder(recording.recorder).catch(() => undefined);
628 stopMediaStream(recording.stream);
601629 return null;
602630 } finally {
603631 clearActiveRecording(recording);
apps/web/src/components/settings/IntegrationsSettings.tsx+48
......@@ -7,7 +7,9 @@
77 * @module IntegrationsSettings
88 */
99import {
10 BROWSER_RECORDING_FRAME_RATES,
1011 DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW,
12 DEFAULT_BROWSER_RECORDING_FRAME_RATE,
1113 DEFAULT_BROWSER_VIEWPORT,
1214 DEFAULT_PREVIEW_APPEARANCE,
1315 DEFAULT_UNIFIED_SETTINGS,
......@@ -355,6 +357,51 @@ function BrowserAppearanceSetting({ disabled }: { readonly disabled: boolean })
355357 );
356358}
357359
360function BrowserRecordingFrameRateSetting({ disabled }: { readonly disabled: boolean }) {
361 const frameRate = useClientSettings((settings) => settings.browserRecordingFrameRate);
362 const updateSettings = useUpdatePrimarySettings();
363
364 return (
365 <SettingsRow
366 {...searchableSetting("browser-recording-frame-rate")}
367 description="Maximum frame rate for browser recordings. 30 fps is the default and uses less CPU and storage; 60 fps captures smoother motion."
368 resetAction={
369 !disabled && frameRate !== DEFAULT_BROWSER_RECORDING_FRAME_RATE ? (
370 <SettingResetButton
371 label="browser recording frame rate"
372 onClick={() =>
373 updateSettings({ browserRecordingFrameRate: DEFAULT_BROWSER_RECORDING_FRAME_RATE })
374 }
375 />
376 ) : null
377 }
378 control={
379 <Select
380 disabled={disabled}
381 value={String(frameRate)}
382 onValueChange={(value) => {
383 const next = BROWSER_RECORDING_FRAME_RATES.find((rate) => String(rate) === value);
384 if (next !== undefined) {
385 updateSettings({ browserRecordingFrameRate: next });
386 }
387 }}
388 >
389 <SelectTrigger className="w-full sm:w-40" aria-label="Browser recording frame rate">
390 <SelectValue>{frameRate} fps</SelectValue>
391 </SelectTrigger>
392 <SelectPopup align="end" alignItemWithTrigger={false}>
393 {BROWSER_RECORDING_FRAME_RATES.map((rate) => (
394 <SelectItem hideIndicator key={rate} value={String(rate)}>
395 {rate} fps
396 </SelectItem>
397 ))}
398 </SelectPopup>
399 </Select>
400 }
401 />
402 );
403}
404
358405function AgentBrowserAccessSetting() {
359406 const settings = usePrimarySettings();
360407 const updateSettings = useUpdatePrimarySettings();
......@@ -461,6 +508,7 @@ export function IntegrationsSettingsPanel() {
461508 <BrowserViewportSetting disabled={previewDefaultsDisabled} />
462509 <BrowserZoomSetting disabled={previewDefaultsDisabled} />
463510 <BrowserAppearanceSetting disabled={previewDefaultsDisabled} />
511 <BrowserRecordingFrameRateSetting disabled={previewDefaultsDisabled} />
464512 <BrowserAutoShowFloatingPreviewSetting disabled={previewDefaultsDisabled} />
465513 </>
466514 );
apps/web/src/components/settings/SettingsPanels.logic.test.ts+8-1
......@@ -268,9 +268,16 @@ describe("getChangedBrowserSettingLabels", () => {
268268 browserDefaultViewport: { _tag: "freeform", width: 900, height: 600 },
269269 browserDefaultZoomFactor: 1.5,
270270 browserDefaultAppearance: "dark",
271 browserRecordingFrameRate: 60,
271272 browserAutoShowFloatingPreview: !DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview,
272273 }),
273 ).toEqual(["Browser viewport", "Browser zoom", "Browser appearance", "Floating preview"]);
274 ).toEqual([
275 "Browser viewport",
276 "Browser zoom",
277 "Browser appearance",
278 "Recording frame rate",
279 "Floating preview",
280 ]);
274281 });
275282});
276283
apps/web/src/components/settings/SettingsPanels.logic.ts+4
......@@ -114,6 +114,7 @@ export type BrowserDefaultSettings = Pick<
114114 | "browserDefaultViewport"
115115 | "browserDefaultZoomFactor"
116116 | "browserDefaultAppearance"
117 | "browserRecordingFrameRate"
117118 | "browserAutoShowFloatingPreview"
118119>;
119120
......@@ -151,6 +152,9 @@ export function getChangedBrowserSettingLabels(settings: BrowserDefaultSettings)
151152 ...(settings.browserDefaultAppearance !== DEFAULT_UNIFIED_SETTINGS.browserDefaultAppearance
152153 ? ["Browser appearance"]
153154 : []),
155 ...(settings.browserRecordingFrameRate !== DEFAULT_UNIFIED_SETTINGS.browserRecordingFrameRate
156 ? ["Recording frame rate"]
157 : []),
154158 ...(settings.browserAutoShowFloatingPreview !==
155159 DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview
156160 ? ["Floating preview"]
apps/web/src/components/settings/SettingsPanels.tsx+2
......@@ -553,6 +553,7 @@ export function useSettingsRestore(onRestored?: () => void) {
553553 settings.browserDefaultViewport,
554554 settings.browserDefaultZoomFactor,
555555 settings.browserDefaultAppearance,
556 settings.browserRecordingFrameRate,
556557 settings.browserAutoShowFloatingPreview,
557558 settings.appearanceContrast,
558559 settings.enableAgentBrowserAccess,
......@@ -688,6 +689,7 @@ export function useSettingsRestore(onRestored?: () => void) {
688689 browserDefaultViewport: DEFAULT_UNIFIED_SETTINGS.browserDefaultViewport,
689690 browserDefaultZoomFactor: DEFAULT_UNIFIED_SETTINGS.browserDefaultZoomFactor,
690691 browserDefaultAppearance: DEFAULT_UNIFIED_SETTINGS.browserDefaultAppearance,
692 browserRecordingFrameRate: DEFAULT_UNIFIED_SETTINGS.browserRecordingFrameRate,
691693 browserAutoShowFloatingPreview: DEFAULT_UNIFIED_SETTINGS.browserAutoShowFloatingPreview,
692694 // Re-granted like any other default. The confirmation dialog lists it by
693695 // name, so a user restoring defaults is told the agent regains access
apps/web/src/components/settings/settingsSearch.test.ts+8
......@@ -98,4 +98,12 @@ describe("searchSettings", () => {
9898 targetId: "appearance",
9999 });
100100 });
101
102 it("routes browser recording quality to integrations", () => {
103 expect(searchSettings("recording frame rate")[0]).toMatchObject({
104 id: "browser-recording-frame-rate",
105 to: "/settings/integrations",
106 targetId: "browser",
107 });
108 });
101109});
apps/web/src/components/settings/settingsSearch.ts+6
......@@ -237,6 +237,12 @@ export const SETTINGS_SEARCH_ITEMS = [
237237 to: "/settings/integrations",
238238 targetId: "browser",
239239 },
240 {
241 id: "browser-recording-frame-rate",
242 title: "Browser recording frame rate",
243 to: "/settings/integrations",
244 targetId: "browser",
245 },
240246 {
241247 id: "browser-auto-show-floating-preview",
242248 title: "Auto-show floating preview",
packages/contracts/src/ipc.ts+14-1
......@@ -734,6 +734,19 @@ export const DesktopPreviewRecordingFrameSchema: Schema.Codec<DesktopPreviewReco
734734 receivedAt: Schema.String,
735735 });
736736
737export interface DesktopPreviewRecordingSource {
738 sourceId: string;
739 width: number;
740 height: number;
741}
742
743export const DesktopPreviewRecordingSourceSchema: Schema.Codec<DesktopPreviewRecordingSource> =
744 Schema.Struct({
745 sourceId: Schema.String,
746 width: Schema.Int.check(Schema.isGreaterThan(0)),
747 height: Schema.Int.check(Schema.isGreaterThan(0)),
748 });
749
737750export interface DesktopPreviewRecordingArtifact {
738751 id: string;
739752 tabId: string;
......@@ -1215,7 +1228,7 @@ export interface DesktopPreviewBridge {
12151228 close: (tabId: string) => Promise<void>;
12161229 };
12171230 recording: {
1218 startScreencast: (tabId: string) => Promise<void>;
1231 startScreencast: (tabId: string) => Promise<DesktopPreviewRecordingSource>;
12191232 stopScreencast: (tabId: string) => Promise<void>;
12201233 save: (
12211234 tabId: string,
packages/contracts/src/settings.test.ts+20
......@@ -66,6 +66,26 @@ describe("ClientSettings word wrap", () => {
6666 });
6767});
6868
69describe("ClientSettings browser recording frame rate", () => {
70 it("defaults to 30 fps", () => {
71 expect(decodeClientSettings({}).browserRecordingFrameRate).toBe(30);
72 });
73
74 it.each([30, 60])("accepts a supported frame rate: %s", (frameRate) => {
75 expect(
76 decodeClientSettings({ browserRecordingFrameRate: frameRate }).browserRecordingFrameRate,
77 ).toBe(frameRate);
78 expect(
79 decodeClientSettingsPatch({ browserRecordingFrameRate: frameRate }).browserRecordingFrameRate,
80 ).toBe(frameRate);
81 });
82
83 it.each([24, 59, 120])("rejects an unsupported frame rate: %s", (frameRate) => {
84 expect(() => decodeClientSettings({ browserRecordingFrameRate: frameRate })).toThrow();
85 expect(() => decodeClientSettingsPatch({ browserRecordingFrameRate: frameRate })).toThrow();
86 });
87});
88
6989describe("ClientSettings glass opacity", () => {
7090 it("defaults to a readable translucent surface", () => {
7191 expect(decodeClientSettings({}).glassOpacity).toBe(80);
packages/contracts/src/settings.ts+11-2
......@@ -147,11 +147,16 @@ export type DefaultThemePreference = typeof DefaultThemePreference.Type;
147147/**
148148 * Defaults for the in-app preview browser, applied whenever a tab is opened
149149 * without an explicit viewport/zoom/appearance — by the user opening a browser
150 * tab, or by an agent calling `preview_open` with no size. Client-local
151 * because the Chromium guest they configure is desktop-local.
150 * tab, or by an agent calling `preview_open` with no size. Recording quality is
151 * client-local for the same reason: the Chromium guest being captured belongs
152 * to the desktop app.
152153 */
153154export const DEFAULT_BROWSER_VIEWPORT: PreviewViewportSetting = FILL_PREVIEW_VIEWPORT;
154155export const DEFAULT_BROWSER_AUTO_SHOW_FLOATING_PREVIEW = true;
156export const BROWSER_RECORDING_FRAME_RATES = [30, 60] as const;
157export const BrowserRecordingFrameRate = Schema.Literals(BROWSER_RECORDING_FRAME_RATES);
158export type BrowserRecordingFrameRate = typeof BrowserRecordingFrameRate.Type;
159export const DEFAULT_BROWSER_RECORDING_FRAME_RATE: BrowserRecordingFrameRate = 30;
155160
156161export const ClientSettingsSchema = Schema.Struct({
157162 appearanceContrast: AppearanceContrast.pipe(
......@@ -166,6 +171,9 @@ export const ClientSettingsSchema = Schema.Struct({
166171 browserDefaultAppearance: PreviewAppearancePreference.pipe(
167172 Schema.withDecodingDefault(Effect.succeed(DEFAULT_PREVIEW_APPEARANCE)),
168173 ),
174 browserRecordingFrameRate: BrowserRecordingFrameRate.pipe(
175 Schema.withDecodingDefault(Effect.succeed(DEFAULT_BROWSER_RECORDING_FRAME_RATE)),
176 ),
169177 /**
170178 * Whether an agent opening a preview pops the floating mini player into
171179 * view. Only applies when the agent didn't ask either way — an explicit
......@@ -926,6 +934,7 @@ export const ClientSettingsPatch = Schema.Struct({
926934 browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting),
927935 browserDefaultZoomFactor: Schema.optionalKey(PreviewZoomFactor),
928936 browserDefaultAppearance: Schema.optionalKey(PreviewAppearancePreference),
937 browserRecordingFrameRate: Schema.optionalKey(BrowserRecordingFrameRate),
929938 browserAutoShowFloatingPreview: Schema.optionalKey(Schema.Boolean),
930939 confirmQuit: Schema.optionalKey(Schema.Boolean),
931940 confirmThreadArchive: Schema.optionalKey(Schema.Boolean),