From e65fac6ae3af3082b6741a4d6d7e598bd31f534f Mon Sep 17 00:00:00 2001 From: massmarketconsumer-arch Date: Sat, 12 Sep 2026 17:04:30 -0400 Subject: [PATCH] fix(recorder-core): slice streamed chunks into uniform parts for Cloudflare R2 compatibility (#2275) --- .../instant-recording-uploader.test.ts | 62 ++++++++++++++++++- .../recorder-core/src/instant-mp4-uploader.ts | 31 ++++++++-- 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/packages/recorder-core/__tests__/instant-recording-uploader.test.ts b/packages/recorder-core/__tests__/instant-recording-uploader.test.ts index da293fa127..bf7b55acc3 100644 --- a/packages/recorder-core/__tests__/instant-recording-uploader.test.ts +++ b/packages/recorder-core/__tests__/instant-recording-uploader.test.ts @@ -5,7 +5,7 @@ import { import type { VideoId } from "@cap/recorder-core/recorder-types"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const STREAMED_PART_BYTES = 5 * 1024 * 1024 + 128; +const STREAMED_PART_BYTES = 5 * 1024 * 1024; const DRIVE_PART_BYTES = 16 * 1024 * 1024; const OVERFLOW_PART_BYTES = 129 * 1024 * 1024; const FINALIZED_BLOB_BYTES = 129 * 1024 * 1024; @@ -19,6 +19,7 @@ class MockXMLHttpRequest { static outcomes: UploadOutcome[] = []; static abortedCount = 0; static recordedHeaders: Array> = []; + static uploadedBlobs: Blob[] = []; upload = { onprogress: null as ((event: ProgressEvent) => void) | null, @@ -37,6 +38,7 @@ class MockXMLHttpRequest { MockXMLHttpRequest.outcomes = [...outcomes]; MockXMLHttpRequest.abortedCount = 0; MockXMLHttpRequest.recordedHeaders = []; + MockXMLHttpRequest.uploadedBlobs = []; } open() {} @@ -51,6 +53,7 @@ class MockXMLHttpRequest { send(part: Blob) { MockXMLHttpRequest.recordedHeaders.push(new Map(this.headers)); + MockXMLHttpRequest.uploadedBlobs.push(part); const outcome = MockXMLHttpRequest.outcomes.shift(); if (!outcome) { @@ -977,4 +980,61 @@ describe("InstantRecordingUploader", () => { expect(fetchMock.mock.calls.length).toBeGreaterThan(1); await uploader.cancel(); }); + + it("slices streamed buffer into uniform non-trailing parts matching MIN_PART_SIZE_BYTES for Cloudflare R2 compatibility", async () => { + const MIN_PART_SIZE = 5 * 1024 * 1024; + const TRAILING_SIZE = 2 * 1024 * 1024; + const TOTAL_SIZE = MIN_PART_SIZE + TRAILING_SIZE; + + const fetchMock = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const body = init?.body ? JSON.parse(init.body as string) : null; + + if (url === "/api/upload/multipart/presign-part") { + return makeJsonResponse({ + presignedUrl: `https://uploads.example/part-${body.partNumber}`, + }); + } + + if (url === "/api/upload/multipart/complete") { + expect(body.parts).toHaveLength(2); + expect(body.parts[0]).toMatchObject({ partNumber: 1 }); + expect(body.parts[1]).toMatchObject({ partNumber: 2 }); + return makeJsonResponse({ success: true }); + } + + throw new Error(`Unexpected fetch call: ${url}`); + }, + ); + + vi.stubGlobal("fetch", fetchMock); + MockXMLHttpRequest.setOutcomes([ + { type: "success", etag: "part-1" }, + { type: "success", etag: "part-2" }, + ]); + + const uploader = new InstantRecordingUploader({ + videoId, + uploadId: "upload-r2", + mimeType: "video/webm;codecs=vp9,opus", + subpath: "raw-upload.webm", + setUploadStatus: vi.fn(), + sendProgressUpdate: vi.fn().mockResolvedValue(undefined), + }); + + // Push a chunk larger than MIN_PART_SIZE_BYTES + const chunk = makeBlob(TOTAL_SIZE, "video/webm;codecs=vp9,opus"); + uploader.handleChunk(chunk, chunk.size); + + await uploader.finalize({ + durationSeconds: 10, + subpath: "raw-upload.webm", + }); + + // Non-trailing part must have exact MIN_PART_SIZE_BYTES; trailing part has remainder + expect(MockXMLHttpRequest.uploadedBlobs).toHaveLength(2); + expect(MockXMLHttpRequest.uploadedBlobs[0].size).toBe(MIN_PART_SIZE); + expect(MockXMLHttpRequest.uploadedBlobs[1].size).toBe(TRAILING_SIZE); + }); }); diff --git a/packages/recorder-core/src/instant-mp4-uploader.ts b/packages/recorder-core/src/instant-mp4-uploader.ts index e440568dc3..4515ce7590 100644 --- a/packages/recorder-core/src/instant-mp4-uploader.ts +++ b/packages/recorder-core/src/instant-mp4-uploader.ts @@ -478,6 +478,17 @@ export class InstantRecordingUploader { this.bufferedChunks.push(blob); this.bufferedBytes += blob.size; + if ( + this.pendingUploadBytes + this.bufferedBytes > + MAX_PENDING_UPLOAD_BYTES + ) { + const error = this.markFatalError( + new Error("Upload could not keep up with recording"), + ); + this.onOverflow?.(error); + throw error; + } + if (this.bufferedBytes >= MIN_PART_SIZE_BYTES) { this.flushBuffer(); } @@ -489,14 +500,22 @@ export class InstantRecordingUploader { return; } - if (this.bufferedBytes === 0) return; - if (!force && this.bufferedBytes < MIN_PART_SIZE_BYTES) return; + while (this.bufferedBytes > 0) { + if (!force && this.bufferedBytes < MIN_PART_SIZE_BYTES) return; - const chunk = new Blob(this.bufferedChunks, { type: this.mimeType }); - this.bufferedChunks = []; - this.bufferedBytes = 0; + const partSize = + force && this.bufferedBytes <= MIN_PART_SIZE_BYTES + ? this.bufferedBytes + : MIN_PART_SIZE_BYTES; + const { part, remainingChunks, remainingBytes } = + this.takeBufferedPart(partSize); - this.enqueueUpload(chunk); + this.bufferedChunks = remainingChunks; + this.bufferedBytes = remainingBytes; + this.enqueueUpload(part); + + if (partSize < MIN_PART_SIZE_BYTES) return; + } } private flushDriveBuffer(force = false) {