The browser never talks to storage directly for studio media. The media bucket is shared with other products and its CORS policy cannot be changed, so a presigned PUT from the browser is refused at preflight. Everything goes through the web server.

Two transports

modules/shared/lib/media-upload.ts decides which one a file takes.

Limits

Every upload in the app answers to one of the two ceilings.

Content types

mediaContentType(file) trusts file.type when it is a known type. Otherwise it maps the extension: png, jpg, jpeg, webp, gif, pdf, mp4, m4v, mov, webm. When neither gives an answer it returns file.type as is.

The inline path

Each feature has its own action that posts to /content/media on the web lane: The body is { data, contentType, filename } plus scope or variant where the feature sends one. The feature wrappers pass the data URL produced by readAsDataUrl as data. The signup wizard strips the data:...;base64, prefix first and only accepts PNG, JPEG and WebP. The response is { url }. readAsDataUrl(blob) in modules/shared/lib/read-as-data-url.ts wraps FileReader.

The chunked path

Steps:
  1. POST /api/content/media-upload/start?slug= with contentType, filename, variant and scope. Returns key and uploadId.
  2. For each part, POST /api/content/media-upload/part?slug=&key=&uploadId=&partNumber= with the raw bytes as application/octet-stream. Returns etag.
  3. POST /api/content/media-upload/complete?slug= with key, uploadId and the list of parts. Returns url.
Behaviour worth knowing:
  • file.slice returns a view over the file on disk. Nothing on this path holds the whole file in memory.
  • A part is retried up to two more times on a network error, a 408 or a 5xx, waiting 300 ms and then 600 ms.
  • Parts are sent one after another, not in parallel.
  • On any failure the function calls abort and rethrows the original error. Parts of an abandoned multipart upload are billed until cleaned up, so the abort is always attempted. A failed abort does not mask the real error.
  • scope decides which studio folder the object is created under on the API side.
  • An empty file or an unknown content type throws before anything is sent.
The server side of the relay is described in Route handlers.

The per feature wrapper

Each feature keeps a small media-upload.ts next to its action. It picks the transport:
The same file exists for exercises, forms and templates. Only the transport and the two ceilings are shared. The picker UI and the inline action belong to the feature. When adding uploads to a new feature, copy this wrapper, add a scope to MediaUploadScope if the objects need their own folder, and reuse UploadBox.

Avatars and logos

User avatars and organization logos use a different path: a signed upload URL from the oRPC API. Each mutation returns a url the component then uploads the cropped image to. CropImageDialog (cropperjs) runs first.

Picking and previewing

UploadBox

modules/shared/components/UploadBox.tsx is the shared drop zone. It classifies the file as image, video or other, and shows a local preview at once through useLocalPreview. The upload itself is the caller’s job.

useLocalPreview

showPreview(file) creates an object URL and revokes the previous one. The hook revokes on unmount. Use it for every picker so the coach sees the image immediately instead of waiting for the upload.

Images

modules/shared/lib/image.ts crops and resizes in the browser before upload. computeCoverCrop(srcWidth, srcHeight, aspectWidth, aspectHeight, maxWidth) returns a centered cover crop. SELFIE_ASPECT is 9:16 at a maximum width of 1080 and JPEG quality 0.9. Decoding uses createImageBitmap with imageOrientation: "from-image" and falls back to an img element.

Video thumbnails

content/video-thumbnail.ts and VideoThumbnailField.tsx grab a frame from a picked video and upload it with variant: "auto-thumbnail".

Stored media URLs

modules/shared/lib/media-url.ts turns what the API stored into something an img can load. The CDN base is NEXT_PUBLIC_MEDIA_CDN_URL, with a default in the file. StoredMediaImage calls resolveStoredMediaUrl for you. Use it instead of a raw img for anything a studio uploaded.

PDFs

The CDN answers without CORS headers, so pdf.js cannot fetch a stored PDF by URL and a download link to another origin only opens a tab. Stored PDFs are therefore read back through same origin relays:
  • /api/forms/templates/[id]/document and /api/forms/responses/[id]/document for coaches.
  • /api/sign/[token]/document and /api/sign/[token]/signed for trainees.
PdfPreviewDialog, PdfInlineFrame and pdf/PdfPages.tsx render them. pdf/pdfjs.ts loads pdfjs-dist, pinned to an exact version in apps/saas/package.json.

Error handling

  • The relay answers with a status and an error code only. uploadMediaInParts throws Error("media upload failed: <status>") or media part <n> failed: <status>.
  • Check the size on the client before starting and show a localized message. The ceilings are exported for that.
  • media-upload.test.ts (shared and per feature) covers transport selection, retry and abort.