Skip to main content

Media library

The media library holds files a workspace uploads once and uses in any clip: images, MP4 video, sound and fonts. Content refers to a file as media://{id}, never by URL, and Reelwire hands the renderer a private download of it for each job. There is no lasting public address for a library file.

Nothing is resized, re-encoded or thumbnailed on the way in. A file is stored byte for byte and counted at the size it arrived.

In the dashboard this is the Media screen, with three cards: Media (the library), Rendered clips and Storage split.

MethodPathPermission
GET/v1/mediamedia:read
POST/v1/mediamedia:write
POST/v1/media/importmedia:write
POST/v1/media/upload-linkmedia:write
POST/v1/media/{id}/renamemedia:write
POST/v1/media/{id}/deletemedia:write
GET/v1/media/{id}/usesmedia:read
GET/v1/media/rendersmedia:read
GET/v1/media/renders/{id}/reusemedia:read
POST/v1/media/renders/{id}/deletemedia:write
POST/v1/media/renders/{id}/copymedia:write
GET/v1/assets/{id}media:read
GET/v1/assets/{id}/downloadnone: the signature in the URL
POST/v1/storage/splitsigned-in owner

Uploading is multipart, and only multipart​

POST /v1/media takes exactly one file as multipart/form-data. Not base64 in a JSON body, not a URL to fetch. The field name does not matter: the route takes the first file part it finds, so send only one.

With curl, that is -F and an @path. Give the type explicitly, because curl guesses from the extension and guesses fonts wrong:

curl http://localhost:4000/v1/media \
-H "Authorization: Bearer rw_your_key_here" \
-F "file=@docs-example-logo.png;type=image/png"

Do not also set Content-Type: multipart/form-data by hand. The boundary is part of that header, and a hand-written one without it makes the body unparseable.

{
"file": {
"id": "01M2SQSRPWVG87Y0TD6C77XE61",
"name": "docs-example-logo.png",
"mimeType": "image/png",
"kind": "Image",
"byteSize": 74,
"included": false,
"createdAt": "2026-09-18T07:48:21.347Z",
"uploadedBy": "apikey:01M2SQKKZ3ZPXK8RB7BK5QJ3GH",
"ref": "media://01M2SQSRPWVG87Y0TD6C77XE61",
"url": "http://localhost:4000/v1/assets/01M2SQSRPWVG87Y0TD6C77XE61/download?exp=1789721301&sig=8eeefc2bed6398b1bfcadc69286fce5436c6a28f4c29f5321091351e160c527f"
},
"storage": {
"maxMb": 500,
"maxBytes": 524288000,
"usedBytes": 28182659,
"usedBy": { "media": 10821961, "renders": 17360698 },
"remainingBytes": 496105341,
"includedMb": 500,
"mediaPercent": 30,
"media": { "maxBytes": 157286400, "usedBytes": 10821961, "maxMb": 150, "usedMb": 10.3, "remainingBytes": 146464439 },
"clips": { "maxBytes": 367001600, "usedBytes": 17360698, "maxMb": 350, "usedMb": 16.6, "remainingBytes": 349640902 },
"split": { "minPercent": 10, "maxPercent": 90 }
}
}

201. ref is what you put in content. url is a signed preview, good for an hour, for showing the file to a person. Store the id, never the URL. Content may also name a file by its name, media://docs-example-logo.png, which is what the templates' own examples do.

uploadedBy records which credential did it, apikey:{keyId} or user:{email}, and is null for the starter files a workspace came with. included is true for exactly those files while they are still as they came: they count against no storage (see What a new workspace starts with), and byteSize still says how big they are.

Extra form fields​

Two optional text parts change what an upload means:

FieldWhat it does
replace"true" allows the upload to take over a name already in the library.
replacesA media file id. Replaces that one file, keeps its name with the new file's extension, and points at the new one every brand in which /uses (below) finds the old file, and every post on a post list that named it.

A replaces upload must be the same kind of file: a logo does not become a video. The new file gets a new id rather than taking over the old one's, so nothing that cached the old bytes by id can draw the old picture under the new name.

Without either, a name already in the library is a 409:

{
"error": {
"type": "https://reelwire.io/errors/conflict",
"code": "conflict",
"message": "docs-example-logo.png is already in the media library. Upload it again and choose to replace it.",
"requestId": "01M2SREXKAJRENJMBM4S1CXZ74",
"details": []
}
}

What the library takes​

KindTypesLimit
Imageimage/png, image/jpeg, image/webp, image/gif, image/svg+xml2048 by 2048 pixels
Videovideo/mp450 MB
Audioaudio/mpeg, audio/wav20 MB
Fontfont/woff2, font/woff, font/ttf, font/otf10 MB

50 MB is the ceiling no single upload may exceed, whatever its kind, and the workspace's storage allowance still bounds the total.

The pixel limit is read from the file's own header rather than from what the client claims, and an image whose size cannot be read is refused, because it cannot be shown to be within the limit. SVG is exempt: it is a drawing, drawn sharp at whatever size a clip needs.

Straight off a phone​

What a phone makes fits none of these, so the library makes it fit as it arrives, by every way in (upload, upload link, import):

  • A PNG, JPEG or WebP larger than 2048 pixels a side is scaled down to fit, in its own format, turned the way its camera said, its EXIF (the place it was taken included) dropped. A GIF is not, and has to fit.
  • An iPhone's HEIC or HEIF photo becomes a JPEG, turned the way its camera held it, scaled down where it is larger than 2048 pixels a side, its metadata dropped, its name ending in .jpg. Send it as image/heic or image/heif, or with a .heic or .heif name.
  • A video that is not an H.264 MP4 the library could keep: a MOV, HEVC, more than 1920 pixels a side, or over 50 MB. It becomes an H.264 MP4 at most 1920 pixels a side, under 45 MB, its metadata dropped, its name ending in .mp4. An upload may be up to 300 MB on its way in (an import, 50 MB), and a long video takes a minute or two to convert.

A file that already fits is kept byte for byte, as always.

Fonts and sound files are recognised by their file extension rather than their declared type. Browsers disagree about what to call an .otf or a .wav, so the ending decides.

A file that does not fit in what is left of the media share of the storage is refused with a 429 that says how much room there is. The library never deletes anything to make room: that is for you to do, or to give media a larger share (below).

Two other ways in​

From a public address. POST /v1/media/import fetches a file from the web into the library, which is how an assistant brings in a picture it was pointed at. It takes url, and optionally name, replace: true and replaces (the id of one file this takes the place of, as Replace on its row does), and answers exactly as an upload does. The address has to be public HTTPS with no user name or password in it; every address it resolves to or redirects to, at most three redirects, is checked, and an internal one is refused.

curl http://localhost:4000/v1/media/import \
-H "Authorization: Bearer rw_your_key_here" \
-H "Content-Type: application/json" \
-d '{"url":"https://www.example.com/press/logo.png","name":"press-logo.png"}'

Through a one-time upload link. POST /v1/media/upload-link hands out a link that takes files into this library with no credential, for something that holds a file but no key, such as an assistant's code sandbox. Send { "replace": true } if the files may take over names already in the library, or { "replaces": "<file id>" } for one file that takes the place of that file, as Replace on its row does: the link then takes that one file (maxFiles is 1).

{
"url": "http://localhost:4000/v1/upload-files?token=...",
"expiresAt": "2026-09-18T08:03:21.000Z",
"maxFiles": 10,
"method": "POST",
"field": "file",
"example": "curl -F \"file=@picture.png\" \"http://localhost:4000/v1/upload-files?token=...\""
}

The link lasts fifteen minutes and takes up to ten files, one per request and at most 30 requests a minute, each held to the rules above. A name form field alongside the file renames it on the way in. It stops working at once if the key that asked for it is revoked.

What a new workspace starts with​

Every new workspace finds these files in its library, so the first clip it renders shows what a template and a brand can do rather than grey boxes:

FileWhat it is for
example-image.jpg, example-image.png, example-image.svgThe pictures the reference templates' own examples ask for, by name.
example-image-with-animation.svgThe same landscape with its weather changing, an animated SVG. Workspaces made before it was added were given it too.
example-logo.png, example-badge.pngThe Default brand's logo and badge.
example-background-image-universal.jpgThe Default brand's background: one picture, a 1440 square made to be cropped from the centre, which fills every shape.
example-stinger-video-universal.mp4, example-intro-video-universal.mp4, example-outro-video-universal.mp4The Default brand's stinger, intro and outro: one clip each, a 1440 square made to be cropped from the centre, which plays in every shape. They are switched off until the brand turns them on.

An earlier version of this set gave a background, a stinger, an intro and an outro for each of the five shapes as well, twenty files more. They are gone: they were taken out of every library that still held them as they were given, and out of every brand that wore them, which plays the universal file instead. | example-font.ttf | Bebas Neue, the Default brand's headline typeface. | | example-sound.mp3 | Thirty seconds of music, the Default brand's soundtrack. |

Workspaces made before the square background and the stinger, intro and outro clips were added were given them too. They are the workspace's own copies, free to rename, replace or delete, and they are included: they count against no storage, and the library says Included under Size. The mark holds only while a file is exactly as it came. A file that replaces one counts in full, and so does any upload, even of the very same bytes, so the free set can neither grow nor be swapped for something larger. A template's example finds its pictures by name, so it draws them only while the library still has a file of that name. See the Default brand.

The rest of the library​

GET /v1/media lists every file, newest first, with the storage it takes. It is not paged: the whole library comes back in one response, because a library is hundreds of files and the screens search and sort it themselves.

{
"files": [
{
"id": "01M2S2CPZVEWKJ6TW9EATDYMJB",
"name": "test-font-roboto-mono.ttf",
"mimeType": "font/ttf",
"kind": "Font",
"byteSize": 183700,
"included": false,
"createdAt": "2026-09-18T01:34:13.501Z",
"uploadedBy": "user:enterprise2-1@example.com",
"ref": "media://01M2S2CPZVEWKJ6TW9EATDYMJB",
"url": "http://localhost:4000/v1/assets/01M2S2CPZVEWKJ6TW9EATDYMJB/download?exp=1789721209&sig=a7f7bb9d4bc230b28b59b634056a10071941c066b25f7ed16ebbefd0762594d2"
}
],
"storage": { "maxMb": 500, "usedBytes": 28182585 }
}

GET /v1/media/{id}/uses says what would break if you deleted a file: the brands that wear it, wherever the file sits in the brand (its logo, badge, a background or a clip for one shape such as intro-9x16, a font or the soundtrack, a language's own files included), and the posts on post lists, still to go out, whose content names it. The Default brand takes its typeface and soundtrack from the library in exactly that way.

curl http://localhost:4000/v1/media/01M2SQSRPWVG87Y0TD6C77XE61/uses \
-H "Authorization: Bearer rw_your_key_here"
{
"uses": [
{ "kind": "brand", "id": "01M1VFRWE7VHP3H54ESRJQ0SAJ", "label": "Brand Brand 1: Logo" }
]
}

Ask before you delete. An empty uses is the only safe answer.

POST /v1/media/{id}/rename takes { "name": "..." }, 1 to 200 characters, and keeps the file's ending whatever you type: a .png stays a .png. References are by id, so nothing that uses the file notices. POST /v1/media/{id}/delete removes the file and its bytes, and answers with the storage as it now stands. Every one of those brand slots that held it is emptied in the same step; a post still waiting keeps its content and is drawn without the picture.

Rendered clips​

The clips a render produced are held separately from uploads, in the clips' share of the storage, and are the Rendered clips card on the Media screen. Kept direct exports, the videos made with Create clip in Clip studio or through the API, are listed here too, with source: "export" where a post's clip says source: "post". When the clips' share is full, the oldest clip of either kind is removed to make room for a new one, with the same effect on its posts as deleting it (below); a clip being handed over at that moment is passed by.

A post's clip has a small preview still beside it. The still is not listed here, takes its room in the same share, and goes when its clip goes.

curl http://localhost:4000/v1/media/renders \
-H "Authorization: Bearer rw_your_key_here"
{
"clips": [
{
"id": "01M2SQPAPH4YF66ZKK5Y5XA9Y7",
"kind": "video",
"mimeType": "video/mp4",
"byteSize": 740641,
"ratio": "9x16",
"locale": "en",
"width": 1080,
"height": 1920,
"durationMs": 10000,
"createdAt": "2026-09-18T07:46:28.697Z",
"templateId": "tpl_dd7e6b264fb8",
"templateName": "Big number",
"channelName": "Channel 10 en",
"url": "http://localhost:4000/v1/assets/01M2SQPAPH4YF66ZKK5Y5XA9Y7/download?exp=1789721392&sig=ae02f120693a5043646e04d5bd65a899deea94add53450aea4e9e510053bf1fb",
"needed": false,
"neededBecause": "Awaiting approval",
"source": "post"
}
]
}

GET /v1/media/renders/{id}/reuse answers, under reuse, with what a clip was made from: its template, variant, version, brandId, ratio, locale and data, the content in the template's own shape with locales for further languages. It is the same answer as a post's reuse, for a post's clip or a kept direct export, ready to send to POST /v1/direct-export/video after a change. It reads and changes nothing.

POST /v1/media/renders/{id}/copy, Copy to library on the Rendered clips card, puts a copy of a clip, a post's or a kept direct export, into the media library as a file of its own, to use again like an upload: as a brand's intro or outro, or a video in a template's content. The clip stays where it is and rotates as before; the copy stays until it is deleted. It is stored by the library's own rules, so the size of one file and the storage allowance apply, and a refusal is the one an upload of the same file would get. The body may name the file, { "name": "Summer teaser" }, whose ending is always the clip's own, and a name of your own that the library already holds is a 409. Left out, the name is the template, shape, language and the day it was rendered, made free with -2, -3 where taken, so copying twice makes two files. A clip id that is gone, removed to make room or deleted, is a 404. The answer is 201 with the library file, as an upload's:

{
"file": {
"id": "01M2SR3V8N0Q4K7XG5D2W9PJ6T",
"name": "Big number 9x16 en 2026-10-03.mp4",
"mimeType": "video/mp4",
"kind": "Video",
"byteSize": 740641,
"createdAt": "2026-10-03T09:12:44.120Z",
"uploadedBy": "user:enterprise2-1@example.com",
"ref": "media://01M2SR3V8N0Q4K7XG5D2W9PJ6T",
"url": "http://localhost:4000/v1/assets/01M2SR3V8N0Q4K7XG5D2W9PJ6T/download?exp=..."
},
"storage": { "maxMb": 500, "usedBytes": 28182659 }
}

neededBecause says what a post still using the clip is waiting for. POST /v1/media/renders/{id}/delete deletes the clip anyway, and every post of its job still waiting on it is cancelled with it, which is what cancelled in the answer counts:

{ "deleted": true, "cancelled": 1, "storage": { "maxMb": 500, "usedBytes": 27442018 } }

The one refusal is needed: true: a post is being handed over with this clip at this moment (state PUBLISHING), and deleting it is a 409 until that has finished. A clip whose posts have been handed over, or have ended in any other way, is only kept, and deleting it frees storage; a link to it that was handed over then answers 404.

POST /v1/storage/split moves the boundary between the two, with { "mediaSharePercent": 30 }, which is the Storage split card on the Media screen. It changes what the workspace's paid storage is spent on, so it is the owner's, signed in: a key is refused whatever it holds. It must be an integer, and the split block on any storage response says the range it may take; neither side may go below a tenth or below what it already holds, and a value outside the range is a 403.

Signed downloads​

GET /v1/assets/{id}/download serves the bytes for any asset: a rendered clip, a brand file, a media library file or a kept direct export. It is authorised by the exp and sig in the URL rather than by a bearer token, because these URLs are handed to a browser and pasted into a <video src>, and a video element cannot attach an Authorization header. The signature names exactly one asset and expires, which is what makes that safe. A wrong or expired signature is a 403: ask for a fresh address. It supports Range, so a browser can seek. Add &save=1, and &name= for the file name, to have it saved rather than opened. A font is served to any origin (Access-Control-Allow-Origin: *), so a web page or a mail app elsewhere can load it by its signed address; nothing else is.

GET /v1/assets/{id} returns metadata without the bytes, and takes a bearer token. It answers for the rendered clips of posts only: a media library id here is a 404, because a library file's metadata is on /v1/media, and so is a kept direct export's, which /v1/media/renders lists.

curl http://localhost:4000/v1/assets/01M2SQPAPH4YF66ZKK5Y5XA9Y7 \
-H "Authorization: Bearer rw_your_key_here"
{
"assetId": "01M2SQPAPH4YF66ZKK5Y5XA9Y7",
"kind": "video",
"ratio": "9x16",
"locale": "en",
"mimeType": "video/mp4",
"width": 1080,
"height": 1920,
"durationMs": 10000,
"byteSize": 740641,
"sha256": "19c629bfd5a1ec22a1fb9baed2c043ce23d523d01c5886ffd0d4278be444e5c8",
"url": "http://localhost:4000/v1/assets/01M2SQPAPH4YF66ZKK5Y5XA9Y7/download?exp=1789722005&sig=48542502fdc9cc4b9d0368cefd53b04b7f71f696a3926e0f7e4aadfb9ee5fa23",
"expiresAt": "2026-09-18T09:00:05.000Z"
}

Verify sha256 after a download. A signed URL is not a credential to store: keep the id and ask for a fresh URL.

Mistakes people make​

Sending JSON. Upload is multipart. A JSON body is a 415 that says so. Send the file as a form part, or use /v1/media/import when what you hold is an address.

Setting Content-Type: multipart/form-data yourself. It has to carry the boundary. Let your HTTP client write it.

Sending more than one file. One per request. The limit is enforced, not advisory.

Referring to a file by its signed URL. Content takes media://{id}, and a web address in a picture field is refused. A signed URL would be a URL that expires, and any other address is one the renderer will not fetch.

Deleting a file without checking /uses. A brand loses its logo, and the next clip is drawn without one.

Trusting curl's guess at a font's type. Pass ;type=font/otf explicitly, or rely on the extension, which is what the server does.