feat: chunked (S3 multipart) uploads for R2/S3 video backend

Self-hosted instances on the R2/S3 backend could only upload a video as a
single PUT, which fails behind a Cloudflare proxy/tunnel (100MB request-body
cap) and is capped at 5GiB with no resilience. Bunny already avoids this via
tus; this brings the R2/S3 path to parity.

Files larger than a threshold (default 90MiB) are now split into parts
(default 32MiB, min 5MiB) and uploaded directly browser->R2 via presigned
UploadPart URLs, then reassembled server-side with CompleteMultipartUpload.
Each request stays under the 100MB cap, lifts the size ceiling well past
5GiB, and adds per-chunk retry. Files at/under the threshold keep the
existing single-PUT path unchanged. Bunny path is untouched.

Thresholds are env-overridable via OPENFRAME_R2_MULTIPART_THRESHOLD_BYTES
and OPENFRAME_R2_MULTIPART_PART_SIZE_BYTES.

Verified end-to-end against real Cloudflare R2 and a local MinIO behind an
nginx 90MB cap (single 141MB PUT 413s on master; 32MB parts pass here).

Closes #22
This commit is contained in:
yusufipk
2026-07-10 19:19:07 +07:00
parent 82932c6b22
commit 880d0ac0fa
9 changed files with 513 additions and 12 deletions
@@ -0,0 +1,2 @@
-- AlterTable: track the S3/R2 multipart upload id so chunked uploads can be completed/aborted.
ALTER TABLE "video_upload_sessions" ADD COLUMN "multipart_upload_id" TEXT;
+1
View File
@@ -724,6 +724,7 @@ model VideoUploadSession {
declaredSizeBytes BigInt @map("declared_size_bytes")
contentType String @map("content_type")
reservationId String? @map("reservation_id")
multipartUploadId String? @map("multipart_upload_id")
expiresAt DateTime @map("expires_at")
status UploadSessionStatus @default(INITIATED)
consumedAt DateTime? @map("consumed_at")