authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-06 20:03:05-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 20:03:05-03:00
log3941c2a1de792410db3dc20dfe7fb323ce0733ab
treebe33e0ac8fe8a4d19af6ce4353c57ae81df9749b
parent2d6a3799978659e349faf8ff4297098ffb782ebb
signature Signed by PGP key B5690EEEBB952194

fix(web): improve preview recording frame delivery (#10403)


2 files changed, 57 insertions(+), 16 deletions(-)

apps/web/src/browser/browserRecording.test.ts+38-12
......@@ -146,6 +146,7 @@ describe("browser recording", () => {
146146 vi.stubGlobal("cancelAnimationFrame", vi.fn());
147147 vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder);
148148 getDisplayMedia.mockResolvedValue({
149 getVideoTracks: () => [],
149150 getTracks: () => [{ stop: vi.fn() }],
150151 });
151152 requestDisplayMediaCapture.mockImplementation((tabId: string) => {
......@@ -187,7 +188,7 @@ describe("browser recording", () => {
187188 getDisplayMedia.mockImplementationOnce(async () => {
188189 expect(animationFrameCount).toBe(2);
189190 expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBe(1);
190 return { getTracks: () => [{ stop: vi.fn() }] };
191 return { getVideoTracks: () => [], getTracks: () => [{ stop: vi.fn() }] };
191192 });
192193
193194 await startBrowserRecording("background-tab");
......@@ -214,21 +215,31 @@ describe("browser recording", () => {
214215 await stopBrowserRecording("hidden-window-tab");
215216 });
216217
217 it("records the native tab stream armed by the main process", async () => {
218 it.each([
219 { width: 1280, height: 720, frameRate: 60, bitrate: 2_764_800 },
220 { width: 320, height: 240, frameRate: 30, bitrate: 2_500_000 },
221 { width: 3840, height: 2160, frameRate: 60, bitrate: 24_883_200 },
222 { width: 7680, height: 4320, frameRate: 60, bitrate: 50_000_000 },
223 ])("records the native $width x $height stream at $frameRate fps", async (settings) => {
218224 const stopTrack = vi.fn();
219 const stream = { getTracks: () => [{ stop: stopTrack }] } as unknown as MediaStream;
225 const stream = {
226 getVideoTracks: () => [{ getSettings: () => settings }],
227 getTracks: () => [{ stop: stopTrack }],
228 } as unknown as MediaStream;
220229 getDisplayMedia.mockResolvedValueOnce(stream);
221230
222231 await startBrowserRecording("recording-tab");
223232
224233 expect(getDisplayMedia).toHaveBeenCalledWith({
225234 audio: false,
226 video: { frameRate: { max: 30 } },
235 video: { frameRate: { ideal: 30, max: 30 } },
227236 });
228237 expect(FakeMediaRecorder.instances[0]?.stream).toBe(stream);
238 expect(FakeMediaRecorder.instances[0]?.options?.videoBitsPerSecond).toBe(settings.bitrate);
229239
230240 await stopBrowserRecording("recording-tab");
231241 expect(stopTrack).toHaveBeenCalledOnce();
242 expect(stopTrack.mock.invocationCallOrder[0]).toBeLessThan(save.mock.invocationCallOrder[0]!);
232243 });
233244
234245 it("uses the configured recording frame rate", async () => {
......@@ -238,7 +249,7 @@ describe("browser recording", () => {
238249
239250 expect(getDisplayMedia).toHaveBeenCalledWith({
240251 audio: false,
241 video: { frameRate: { max: 60 } },
252 video: { frameRate: { ideal: 60, max: 60 } },
242253 });
243254 await stopBrowserRecording("recording-tab");
244255 });
......@@ -263,7 +274,7 @@ describe("browser recording", () => {
263274
264275 expect(getDisplayMedia).toHaveBeenCalledWith({
265276 audio: false,
266 video: { frameRate: { max: 60 } },
277 video: { frameRate: { ideal: 60, max: 60 } },
267278 });
268279 await stopBrowserRecording(tabId);
269280
......@@ -275,6 +286,7 @@ describe("browser recording", () => {
275286 it("stops the native stream when MediaRecorder cleanup fails", async () => {
276287 const stopTrack = vi.fn();
277288 getDisplayMedia.mockResolvedValueOnce({
289 getVideoTracks: () => [],
278290 getTracks: () => [{ stop: stopTrack }],
279291 });
280292
......@@ -290,6 +302,7 @@ describe("browser recording", () => {
290302
291303 it("uses the best supported encoder and saves the recorder's actual format", async () => {
292304 FakeMediaRecorder.supportedTypes = new Set([
305 "video/mp4;codecs=avc1",
293306 "video/mp4;codecs=avc1.42e01e",
294307 "video/webm;codecs=vp9",
295308 "video/webm;codecs=av1",
......@@ -300,7 +313,8 @@ describe("browser recording", () => {
300313 await stopBrowserRecording("recording-tab");
301314
302315 expect(FakeMediaRecorder.instances[0]?.options).toEqual({
303 mimeType: "video/webm;codecs=av1",
316 mimeType: "video/mp4;codecs=avc1",
317 videoBitsPerSecond: 3_110_400,
304318 });
305319 expect(save).toHaveBeenCalledWith(
306320 "recording-tab",
......@@ -316,7 +330,7 @@ describe("browser recording", () => {
316330 await startBrowserRecording("recording-tab");
317331 await stopBrowserRecording("recording-tab");
318332
319 expect(FakeMediaRecorder.instances[0]?.options).toBeUndefined();
333 expect(FakeMediaRecorder.instances[0]?.options).toEqual({ videoBitsPerSecond: 3_110_400 });
320334 expect(save).toHaveBeenCalledWith(
321335 "recording-tab",
322336 "video/platform-default",
......@@ -375,7 +389,10 @@ describe("browser recording", () => {
375389 expect(readActiveBrowserRecordingTabIds()).toEqual(new Set());
376390 expect(useBrowserSurfaceStore.getState().activityByTabId["recording-tab"]).toBeUndefined();
377391
378 finishCapture({ getTracks: () => [{ stop: stopTrack }] } as unknown as MediaStream);
392 finishCapture({
393 getVideoTracks: () => [],
394 getTracks: () => [{ stop: stopTrack }],
395 } as unknown as MediaStream);
379396 await vi.advanceTimersByTimeAsync(0);
380397 expect(stopTrack).toHaveBeenCalledOnce();
381398 });
......@@ -411,7 +428,10 @@ describe("browser recording", () => {
411428
412429 it("serializes display media grants for concurrent recording starts", async () => {
413430 let finishFirstCapture!: (stream: MediaStream) => void;
414 const stream = { getTracks: () => [{ stop: vi.fn() }] } as unknown as MediaStream;
431 const stream = {
432 getVideoTracks: () => [],
433 getTracks: () => [{ stop: vi.fn() }],
434 } as unknown as MediaStream;
415435 getDisplayMedia
416436 .mockImplementationOnce(
417437 () =>
......@@ -440,7 +460,10 @@ describe("browser recording", () => {
440460
441461 it("cancels a queued recording when stopped before its media grant", async () => {
442462 let finishFirstCapture!: (stream: MediaStream) => void;
443 const stream = { getTracks: () => [{ stop: vi.fn() }] } as unknown as MediaStream;
463 const stream = {
464 getVideoTracks: () => [],
465 getTracks: () => [{ stop: vi.fn() }],
466 } as unknown as MediaStream;
444467 getDisplayMedia.mockImplementationOnce(
445468 () =>
446469 new Promise<MediaStream>((resolve) => {
......@@ -477,7 +500,10 @@ describe("browser recording", () => {
477500 }),
478501 );
479502 let finishBlockingCapture!: (stream: MediaStream) => void;
480 const stream = { getTracks: () => [{ stop: vi.fn() }] } as unknown as MediaStream;
503 const stream = {
504 getVideoTracks: () => [],
505 getTracks: () => [{ stop: vi.fn() }],
506 } as unknown as MediaStream;
481507 getDisplayMedia.mockImplementationOnce(
482508 () =>
483509 new Promise<MediaStream>((resolve) => {
apps/web/src/browser/browserRecording.ts+19-4
......@@ -238,17 +238,29 @@ export function findActiveBrowserRecordingRuntimeTabId(
238238}
239239
240240const preferredMimeTypes = [
241 "video/webm;codecs=av1",
242 "video/webm;codecs=vp9",
241 "video/mp4;codecs=avc1",
243242 "video/mp4;codecs=avc1.640028",
244243 "video/mp4;codecs=avc1.42e01e",
244 "video/webm;codecs=vp9",
245245 "video/webm;codecs=vp8",
246246 "video/webm",
247247] as const;
248248
249249const createMediaRecorder = (stream: MediaStream): MediaRecorder => {
250250 const mimeType = preferredMimeTypes.find((candidate) => MediaRecorder.isTypeSupported(candidate));
251 return mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
251 const settings = stream.getVideoTracks()[0]?.getSettings();
252 // Browser defaults under-budget native-resolution text and motion. Scale with captured pixels
253 // and frames, while bounding storage and encoder load for very large displays.
254 const videoBitsPerSecond = Math.round(
255 Math.min(
256 50_000_000,
257 Math.max(
258 2_500_000,
259 (settings?.width ?? 1920) * (settings?.height ?? 1080) * (settings?.frameRate ?? 30) * 0.05,
260 ),
261 ),
262 );
263 return new MediaRecorder(stream, { ...(mimeType ? { mimeType } : {}), videoBitsPerSecond });
252264};
253265
254266const captureTabMediaStream = (frameRate: number): Promise<MediaStream> =>
......@@ -256,7 +268,7 @@ const captureTabMediaStream = (frameRate: number): Promise<MediaStream> =>
256268 // stream already arrives at that tab's native size and needs no source or dimension constraints.
257269 navigator.mediaDevices.getDisplayMedia({
258270 audio: false,
259 video: { frameRate: { max: frameRate } },
271 video: { frameRate: { ideal: frameRate, max: frameRate } },
260272 });
261273
262274const stopMediaRecorder = async (recorder: MediaRecorder | null): Promise<void> => {
......@@ -680,6 +692,9 @@ const finalizeBrowserRecording = async (
680692 cause,
681693 });
682694 }
695 // Encoding has flushed; release native capture before materializing and saving the file.
696 stopMediaStream(recording.stream);
697 recording.stream = null;
683698 const mimeType =
684699 recording.recorder.mimeType ||
685700 recording.chunks.find((chunk) => chunk.type.length > 0)?.type;