| ... | @@ -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 | }); |
| 192 | | 193 | |
| 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 | }); |
| 216 | | 217 | |
| 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); |
| 221 | | 230 | |
| 222 | await startBrowserRecording("recording-tab"); | 231 | await startBrowserRecording("recording-tab"); |
| 223 | | 232 | |
| 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); |
| 229 | | 239 | |
| 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 | }); |
| 233 | | 244 | |
| 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", () => { |
| 238 | | 249 | |
| 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", () => { |
| 263 | | 274 | |
| 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); |
| 269 | | 280 | |
| ... | @@ -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 | }); |
| 280 | | 292 | |
| ... | @@ -290,6 +302,7 @@ describe("browser recording", () => { | ... | @@ -290,6 +302,7 @@ describe("browser recording", () => { |
| 290 | | 302 | |
| 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"); |
| 301 | | 314 | |
| 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"); |
| 318 | | 332 | |
| 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(); |
| 377 | | 391 | |
| 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", () => { |
| 411 | | 428 | |
| 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 | getDisplayMedia | 435 | getDisplayMedia |
| 416 | .mockImplementationOnce( | 436 | .mockImplementationOnce( |
| 417 | () => | 437 | () => |
| ... | @@ -440,7 +460,10 @@ describe("browser recording", () => { | ... | @@ -440,7 +460,10 @@ describe("browser recording", () => { |
| 440 | | 460 | |
| 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) => { |