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", () => {...@@ -146,6 +146,7 @@ describe("browser recording", () => {
146 vi.stubGlobal("cancelAnimationFrame", vi.fn());146 vi.stubGlobal("cancelAnimationFrame", vi.fn());
147 vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder);147 vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder);
148 getDisplayMedia.mockResolvedValue({148 getDisplayMedia.mockResolvedValue({
149 getVideoTracks: () => [],
149 getTracks: () => [{ stop: vi.fn() }],150 getTracks: () => [{ stop: vi.fn() }],
150 });151 });
151 requestDisplayMediaCapture.mockImplementation((tabId: string) => {152 requestDisplayMediaCapture.mockImplementation((tabId: string) => {
...@@ -187,7 +188,7 @@ describe("browser recording", () => {...@@ -187,7 +188,7 @@ describe("browser recording", () => {
187 getDisplayMedia.mockImplementationOnce(async () => {188 getDisplayMedia.mockImplementationOnce(async () => {
188 expect(animationFrameCount).toBe(2);189 expect(animationFrameCount).toBe(2);
189 expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBe(1);190 expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBe(1);
190 return { getTracks: () => [{ stop: vi.fn() }] };191 return { getVideoTracks: () => [], getTracks: () => [{ stop: vi.fn() }] };
191 });192 });
192193
193 await startBrowserRecording("background-tab");194 await startBrowserRecording("background-tab");
...@@ -214,21 +215,31 @@ describe("browser recording", () => {...@@ -214,21 +215,31 @@ describe("browser recording", () => {
214 await stopBrowserRecording("hidden-window-tab");215 await stopBrowserRecording("hidden-window-tab");
215 });216 });
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) => {
218 const stopTrack = vi.fn();224 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;
220 getDisplayMedia.mockResolvedValueOnce(stream);229 getDisplayMedia.mockResolvedValueOnce(stream);
221230
222 await startBrowserRecording("recording-tab");231 await startBrowserRecording("recording-tab");
223232
224 expect(getDisplayMedia).toHaveBeenCalledWith({233 expect(getDisplayMedia).toHaveBeenCalledWith({
225 audio: false,234 audio: false,
226 video: { frameRate: { max: 30 } },235 video: { frameRate: { ideal: 30, max: 30 } },
227 });236 });
228 expect(FakeMediaRecorder.instances[0]?.stream).toBe(stream);237 expect(FakeMediaRecorder.instances[0]?.stream).toBe(stream);
238 expect(FakeMediaRecorder.instances[0]?.options?.videoBitsPerSecond).toBe(settings.bitrate);
229239
230 await stopBrowserRecording("recording-tab");240 await stopBrowserRecording("recording-tab");
231 expect(stopTrack).toHaveBeenCalledOnce();241 expect(stopTrack).toHaveBeenCalledOnce();
242 expect(stopTrack.mock.invocationCallOrder[0]).toBeLessThan(save.mock.invocationCallOrder[0]!);
232 });243 });
233244
234 it("uses the configured recording frame rate", async () => {245 it("uses the configured recording frame rate", async () => {
...@@ -238,7 +249,7 @@ describe("browser recording", () => {...@@ -238,7 +249,7 @@ describe("browser recording", () => {
238249
239 expect(getDisplayMedia).toHaveBeenCalledWith({250 expect(getDisplayMedia).toHaveBeenCalledWith({
240 audio: false,251 audio: false,
241 video: { frameRate: { max: 60 } },252 video: { frameRate: { ideal: 60, max: 60 } },
242 });253 });
243 await stopBrowserRecording("recording-tab");254 await stopBrowserRecording("recording-tab");
244 });255 });
...@@ -263,7 +274,7 @@ describe("browser recording", () => {...@@ -263,7 +274,7 @@ describe("browser recording", () => {
263274
264 expect(getDisplayMedia).toHaveBeenCalledWith({275 expect(getDisplayMedia).toHaveBeenCalledWith({
265 audio: false,276 audio: false,
266 video: { frameRate: { max: 60 } },277 video: { frameRate: { ideal: 60, max: 60 } },
267 });278 });
268 await stopBrowserRecording(tabId);279 await stopBrowserRecording(tabId);
269280
...@@ -275,6 +286,7 @@ describe("browser recording", () => {...@@ -275,6 +286,7 @@ describe("browser recording", () => {
275 it("stops the native stream when MediaRecorder cleanup fails", async () => {286 it("stops the native stream when MediaRecorder cleanup fails", async () => {
276 const stopTrack = vi.fn();287 const stopTrack = vi.fn();
277 getDisplayMedia.mockResolvedValueOnce({288 getDisplayMedia.mockResolvedValueOnce({
289 getVideoTracks: () => [],
278 getTracks: () => [{ stop: stopTrack }],290 getTracks: () => [{ stop: stopTrack }],
279 });291 });
280292
...@@ -290,6 +302,7 @@ describe("browser recording", () => {...@@ -290,6 +302,7 @@ describe("browser recording", () => {
290302
291 it("uses the best supported encoder and saves the recorder's actual format", async () => {303 it("uses the best supported encoder and saves the recorder's actual format", async () => {
292 FakeMediaRecorder.supportedTypes = new Set([304 FakeMediaRecorder.supportedTypes = new Set([
305 "video/mp4;codecs=avc1",
293 "video/mp4;codecs=avc1.42e01e",306 "video/mp4;codecs=avc1.42e01e",
294 "video/webm;codecs=vp9",307 "video/webm;codecs=vp9",
295 "video/webm;codecs=av1",308 "video/webm;codecs=av1",
...@@ -300,7 +313,8 @@ describe("browser recording", () => {...@@ -300,7 +313,8 @@ describe("browser recording", () => {
300 await stopBrowserRecording("recording-tab");313 await stopBrowserRecording("recording-tab");
301314
302 expect(FakeMediaRecorder.instances[0]?.options).toEqual({315 expect(FakeMediaRecorder.instances[0]?.options).toEqual({
303 mimeType: "video/webm;codecs=av1",316 mimeType: "video/mp4;codecs=avc1",
317 videoBitsPerSecond: 3_110_400,
304 });318 });
305 expect(save).toHaveBeenCalledWith(319 expect(save).toHaveBeenCalledWith(
306 "recording-tab",320 "recording-tab",
...@@ -316,7 +330,7 @@ describe("browser recording", () => {...@@ -316,7 +330,7 @@ describe("browser recording", () => {
316 await startBrowserRecording("recording-tab");330 await startBrowserRecording("recording-tab");
317 await stopBrowserRecording("recording-tab");331 await stopBrowserRecording("recording-tab");
318332
319 expect(FakeMediaRecorder.instances[0]?.options).toBeUndefined();333 expect(FakeMediaRecorder.instances[0]?.options).toEqual({ videoBitsPerSecond: 3_110_400 });
320 expect(save).toHaveBeenCalledWith(334 expect(save).toHaveBeenCalledWith(
321 "recording-tab",335 "recording-tab",
322 "video/platform-default",336 "video/platform-default",
...@@ -375,7 +389,10 @@ describe("browser recording", () => {...@@ -375,7 +389,10 @@ describe("browser recording", () => {
375 expect(readActiveBrowserRecordingTabIds()).toEqual(new Set());389 expect(readActiveBrowserRecordingTabIds()).toEqual(new Set());
376 expect(useBrowserSurfaceStore.getState().activityByTabId["recording-tab"]).toBeUndefined();390 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);
379 await vi.advanceTimersByTimeAsync(0);396 await vi.advanceTimersByTimeAsync(0);
380 expect(stopTrack).toHaveBeenCalledOnce();397 expect(stopTrack).toHaveBeenCalledOnce();
381 });398 });
...@@ -411,7 +428,10 @@ describe("browser recording", () => {...@@ -411,7 +428,10 @@ describe("browser recording", () => {
411428
412 it("serializes display media grants for concurrent recording starts", async () => {429 it("serializes display media grants for concurrent recording starts", async () => {
413 let finishFirstCapture!: (stream: MediaStream) => void;430 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;
415 getDisplayMedia435 getDisplayMedia
416 .mockImplementationOnce(436 .mockImplementationOnce(
417 () =>437 () =>
...@@ -440,7 +460,10 @@ describe("browser recording", () => {...@@ -440,7 +460,10 @@ describe("browser recording", () => {
440460
441 it("cancels a queued recording when stopped before its media grant", async () => {461 it("cancels a queued recording when stopped before its media grant", async () => {
442 let finishFirstCapture!: (stream: MediaStream) => void;462 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;
444 getDisplayMedia.mockImplementationOnce(467 getDisplayMedia.mockImplementationOnce(
445 () =>468 () =>
446 new Promise<MediaStream>((resolve) => {469 new Promise<MediaStream>((resolve) => {
...@@ -477,7 +500,10 @@ describe("browser recording", () => {...@@ -477,7 +500,10 @@ describe("browser recording", () => {
477 }),500 }),
478 );501 );
479 let finishBlockingCapture!: (stream: MediaStream) => void;502 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;
481 getDisplayMedia.mockImplementationOnce(507 getDisplayMedia.mockImplementationOnce(
482 () =>508 () =>
483 new Promise<MediaStream>((resolve) => {509 new Promise<MediaStream>((resolve) => {
apps/web/src/browser/browserRecording.ts+19-4
...@@ -238,17 +238,29 @@ export function findActiveBrowserRecordingRuntimeTabId(...@@ -238,17 +238,29 @@ export function findActiveBrowserRecordingRuntimeTabId(
238}238}
239239
240const preferredMimeTypes = [240const preferredMimeTypes = [
241 "video/webm;codecs=av1",241 "video/mp4;codecs=avc1",
242 "video/webm;codecs=vp9",
243 "video/mp4;codecs=avc1.640028",242 "video/mp4;codecs=avc1.640028",
244 "video/mp4;codecs=avc1.42e01e",243 "video/mp4;codecs=avc1.42e01e",
244 "video/webm;codecs=vp9",
245 "video/webm;codecs=vp8",245 "video/webm;codecs=vp8",
246 "video/webm",246 "video/webm",
247] as const;247] as const;
248248
249const createMediaRecorder = (stream: MediaStream): MediaRecorder => {249const createMediaRecorder = (stream: MediaStream): MediaRecorder => {
250 const mimeType = preferredMimeTypes.find((candidate) => MediaRecorder.isTypeSupported(candidate));250 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 });
252};264};
253265
254const captureTabMediaStream = (frameRate: number): Promise<MediaStream> =>266const captureTabMediaStream = (frameRate: number): Promise<MediaStream> =>
...@@ -256,7 +268,7 @@ const captureTabMediaStream = (frameRate: number): Promise<MediaStream> =>...@@ -256,7 +268,7 @@ const captureTabMediaStream = (frameRate: number): Promise<MediaStream> =>
256 // stream already arrives at that tab's native size and needs no source or dimension constraints.268 // stream already arrives at that tab's native size and needs no source or dimension constraints.
257 navigator.mediaDevices.getDisplayMedia({269 navigator.mediaDevices.getDisplayMedia({
258 audio: false,270 audio: false,
259 video: { frameRate: { max: frameRate } },271 video: { frameRate: { ideal: frameRate, max: frameRate } },
260 });272 });
261273
262const stopMediaRecorder = async (recorder: MediaRecorder | null): Promise<void> => {274const stopMediaRecorder = async (recorder: MediaRecorder | null): Promise<void> => {
...@@ -680,6 +692,9 @@ const finalizeBrowserRecording = async (...@@ -680,6 +692,9 @@ const finalizeBrowserRecording = async (
680 cause,692 cause,
681 });693 });
682 }694 }
695 // Encoding has flushed; release native capture before materializing and saving the file.
696 stopMediaStream(recording.stream);
697 recording.stream = null;
683 const mimeType =698 const mimeType =
684 recording.recorder.mimeType ||699 recording.recorder.mimeType ||
685 recording.chunks.find((chunk) => chunk.type.length > 0)?.type;700 recording.chunks.find((chunk) => chunk.type.length > 0)?.type;