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
POST /api/content/media-upload/start?slug=withcontentType,filename,variantandscope. ReturnskeyanduploadId.- For each part,
POST /api/content/media-upload/part?slug=&key=&uploadId=&partNumber=with the raw bytes asapplication/octet-stream. Returnsetag. POST /api/content/media-upload/complete?slug=withkey,uploadIdand the list of parts. Returnsurl.
file.slicereturns 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
408or a5xx, waiting 300 ms and then 600 ms. - Parts are sent one after another, not in parallel.
- On any failure the function calls
abortand 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. scopedecides 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 per feature wrapper
Each feature keeps a smallmedia-upload.ts next to its action. It picks the transport:
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 adownload link to another origin only opens a tab. Stored PDFs are therefore read back through same origin relays:
/api/forms/templates/[id]/documentand/api/forms/responses/[id]/documentfor coaches./api/sign/[token]/documentand/api/sign/[token]/signedfor 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
codeonly.uploadMediaInPartsthrowsError("media upload failed: <status>")ormedia 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.