Media upload flow
Every media asset — logos, token images, series asset bundles, generative code — goes through the same three-step flow:
- Create an upload session. The backend creates a pending
MediaAssetand returns a signed URL. - PUT the bytes to that URL.
- Poll until the asset’s
statusisReady.
Files (images, videos, audio, metadata JSON) go Pending → Ready directly after upload. Directories (zips) go Pending → Processing → Ready as the backend extracts them.
MediaKind
Section titled “MediaKind”Every upload declares a kind:
| Kind | Use for |
|---|---|
File | Single images, videos, audio, metadata JSON |
Directory | Zip archives for series asset bundles or generative code |
The kind is declared, not inferred. A zip uploaded with kind: File stays a blob; you need Directory to trigger extraction.
import { MediaKind } from "@highlightxyz/sdk";Size limits
Section titled “Size limits”| Kind | Max size |
|---|---|
| File (image) | 50 MB |
| File (audio) | 250 MB |
| File (video) | 500 MB |
| File (metadata) | 1 MB |
| Directory | 1 GB |
Exceeding returns MediaTooLargeError at session creation time (before you waste bandwidth uploading).
The full upload
Section titled “The full upload”import { readFileSync } from "node:fs";import { MediaKind } from "@highlightxyz/sdk";
async function uploadFile( client: ReturnType<typeof createHighlightClient>, accessToken: string, path: string, mimeType: string, kind: MediaKind,): Promise<string> { const buffer = readFileSync(path);
// 1. Create session const session = (await client.media.createUploadSession({ kind, fileName: path.split("/").pop()!, mimeType, fileSize: buffer.byteLength, })).data!;
// 2. PUT bytes const putRes = await fetch(session.upload.url, { method: session.upload.method, headers: { ...session.upload.headers, authorization: `Bearer ${accessToken}`, }, body: buffer, }); if (!putRes.ok) { throw new Error(`Upload PUT failed: ${putRes.status} ${await putRes.text()}`); }
// 3. Poll until Ready return waitForReady(client, session.media.id);}
async function waitForReady( client: ReturnType<typeof createHighlightClient>, mediaId: string, timeoutMs = 120_000,): Promise<string> { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const media = (await client.media.get({ mediaId })).data!; if (media.status === "Ready") return mediaId; if (media.status === "Failed") throw new Error(`Media ${mediaId} failed`); await new Promise((r) => setTimeout(r, 2000)); } throw new Error(`Media ${mediaId} did not become Ready within ${timeoutMs}ms`);}Uploading a directory (zip)
Section titled “Uploading a directory (zip)”Series asset bundles and generative code are zip archives:
const codeMediaId = await uploadFile( client, tokens.accessToken, "./generative-code.zip", "application/zip", MediaKind.Directory,);After upload, the backend extracts the archive. status transitions Pending → Processing → Ready. The waitForReady helper handles both cases — just give it enough timeout for extraction (~2 minutes is typical).
Once Ready, enumerate extracted files:
const children = (await client.media.listChildren({ mediaId: codeMediaId, limit: 100,})).data!;
for (const child of children.entries) { console.log(child.key, child.size, child.mimeType);}
// Fetch a specific child's full Entity (with a resolved URL)const entry = (await client.media.getChild({ mediaId: codeMediaId, path: "index.html",})).data!;console.log(entry.url);Why polling?
Section titled “Why polling?”The PUT handler runs synchronously for File kinds — most images are Ready on the next poll. Directory extraction runs in the background (zip decompression, per-file mime detection, storage writes for every child). Poll at 2-second intervals; don’t re-PUT on a slow response.
Retrying a failed Directory
Section titled “Retrying a failed Directory”A Directory that ends up Failed (corrupt zip, extraction crash, etc.) can be retried:
await client.media.process({ mediaId });process is idempotent — Ready assets come back unchanged; Processing assets keep going; Failed Directories retry.
Archive to Arweave
Section titled “Archive to Arweave”Media lives on R2 by default. To freeze it permanently on Arweave:
await client.media.publish({ mediaId });Files publish the blob; Directories publish a manifest over all children. Idempotent. After publish succeeds, the media has both R2 and Arweave storage locations.
For a collection’s metadata specifically, prefer client.collection.finalizeBaseUri(highlightId) — it archives the token-metadata directory and flips the on-chain baseURI in one call.
Deleting media
Section titled “Deleting media”await client.media.delete({ mediaId });Media in-use by a deployed contract or a generative collection’s code can’t be deleted — the call returns MediaInUseError (409). Arweave locations are immutable and stay on-chain even after deletion; only R2 objects are freed.
Related
Section titled “Related”- Media & storage concepts — higher-level overview
- Mint an NFT end-to-end — media in context
- Errors — typed failures
- REST API: media — auto-generated schema