Asset storage
Store uploads and AI-generated media in your own storage.
The editor never writes to cloud storage itself. When a design needs a file stored, the editor gives the file to your onUploadAsset function, and keeps the reference that your function returns.
Uploads
A file that the user adds from the computer exists only in the browser. When the user saves the design, the editor passes each such file to onUploadAsset, and then the preview picture of the design:
import {
VideoEditor,
type ResolveAssetUrl,
type Template,
type UploadAssetHandler,
} from '@layermetry/media-editor/react';
// Store the bytes in your storage, and return the reference the design will keep.
const uploadAsset: UploadAssetHandler = async (file, ctx) => {
const body = new FormData();
body.append('file', file, file.name);
body.append('kind', ctx.type);
body.append('templateId', ctx.templateId);
const response = await fetch('/api/assets', { method: 'POST', body });
if (!response.ok) throw new Error(`Upload failed with status ${response.status}`);
const { key } = await response.json();
console.log(`Stored ${ctx.type} (${ctx.source ?? 'template'}) as ${key}`);
return { url: key };
};
// Turn a stored key into an address the browser can load, for example a signed URL.
const resolveAssetUrl: ResolveAssetUrl = async (key) => `/api/assets/${key}`;
async function saveTemplate({ template }: { template: Template }) {
await fetch('/api/templates', { method: 'POST', body: JSON.stringify(template) });
}
export default function App() {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<VideoEditor
licenseKey="YOUR_LICENSE_KEY"
onClose={() => console.log('The editor was closed')}
onSaveTemplate={saveTemplate}
onUploadAsset={uploadAsset}
resolveAssetUrl={resolveAssetUrl}
assetMetadata={{ workspaceId: 'ws-42' }}
/>
</div>
);
}Your function receives the File and a context object. It returns { url }. The editor stores url in the design exactly as you return it: a full address, a path, or a key of your own.
| Context field | Description |
|---|---|
type | image, video, audio, or thumbnail (the preview picture of the design). New kinds can appear in later versions. Store an unknown kind like any other file. |
mimeType | The real type of the file. Use it as Content-Type. |
itemId | The id of the item in the design. |
templateId | The id of the template record being saved, so you can group its files. |
sceneMode | video in the video editor. |
suggestedPath | A suggested name, such as templates/1754500000000_item-42.mp4. You can ignore it. |
source | Why the file is stored: template for a save, generated for AI output, staged for a clip that your media server must download, and recorded for a voiceover recorded with the microphone. New values can appear in later versions. Store an unknown value like any other file. |
capability, producedBy | For AI output: the capability and the model that made it. |
take | For a recording, when you set recording.uploadAllTakes: { index, used }. index counts the takes of one recorder session from 1. used is true for the take the user added to the timeline. |
punchIn | For a recording that replaces part of a clip: { itemId, startSeconds, endSeconds }. itemId is the clip in the design. The times are the range, in seconds on the timeline. |
assetMetadata adds your own text fields to every context, for example a workspace or a customer id. They reach every upload: a template save, a generated file, a clip for a media server job, and a file that the AI assistant placed. A field name that the editor already uses keeps the editor's value.
If your function rejects, the whole save fails, and the user can try again. The editor never saves a design with missing media.
A file that already has an http or https address, such as a stock photo, is stored by its address and not uploaded again.
When the design opens again, a reference that is a full http or https address loads as it is. Any other reference goes through resolveAssetUrl, which returns an address the browser can load, such as a signed URL. Without resolveAssetUrl, the editor adds the reference to assetBaseUrl.
Storage keys
When onUploadAsset returns a storage key instead of a web address, the item keeps your key in the design. A later save keeps the key and sends nothing to onUploadAsset again. This is true for a picture, a clip, or a sound, including one added from the generated results, by Edit with AI, or by the AI assistant. When the design opens again, the editor loads the file from the key through resolveAssetUrl, or assetBaseUrl. Undo and redo load such a picture the same way.
The export also turns a background's key into an address itself, by the same rule: resolveAssetUrl first, then assetBaseUrl.
Signed addresses
The editor loads the address that resolveAssetUrl returns exactly as it is. A signed S3 or CloudFront address is never changed, so its signature stays valid. The editor adds a t= parameter only to an S3 or CloudFront address that has no query at all, so that the browser asks for a fresh copy.
Every file that your storage returns must allow your page to read it, with the Access-Control-Allow-Origin header. The editor draws pictures on a canvas and reads sound and video for the export.
Default: no onUploadAsset. See Temporary assets for what happens then.
Generated media
When the user keeps a picture, a clip, or a sound that an AI model made, the editor fetches it and passes it to onUploadAsset at once, before it appears on the canvas. The context has source: 'generated'. The design then keeps your reference, not the model's address.
Addresses from AI models are temporary. Some stop working after about an hour, so the editor stores the file as soon as the user keeps it.
capability says which AI feature made the file, for example image, imageToVideo, music, or soundEffect. Music and sound effects arrive with type: 'audio'. The words that the user typed are not in the context.
Default: without onUploadAsset, the design keeps the model's address, which can stop working.
Recorded voiceovers
When the user presses Use this in the voiceover recorder, the editor passes the recording to onUploadAsset before it appears on the timeline. The design then keeps your reference. The context has these values:
| Context field | Value |
|---|---|
source | recorded |
type | audio |
mimeType | audio/webm in Chrome, Edge, and Firefox, and audio/mp4 in Safari. The editor does not convert the file. |
itemId, templateId | recording- followed by a number, because no template is being saved |
suggestedPath | A name in the recordings/ folder |
take | Only with recording.uploadAllTakes. See below. |
punchIn | Only for a take that replaces part of a clip: the clip and the range it replaces. |
By default, only the take that the user adds is sent. To receive every take, set recording.uploadAllTakes to true. The added take is sent first, with take.used: true. The other takes follow, with take.used: false. Takes that the user did not add are also sent when the recorder closes.
import { VideoEditor, type UploadAssetHandler } from '@layermetry/media-editor/react';
async function storeFile(file: File, folder: string): Promise<string> {
const body = new FormData();
body.append('file', file, file.name);
body.append('folder', folder);
const response = await fetch('/api/assets', { method: 'POST', body });
if (!response.ok) throw new Error(`Upload failed with status ${response.status}`);
const { key } = await response.json();
return key;
}
const uploadAsset: UploadAssetHandler = async (file, ctx) => {
if (ctx.source === 'recorded' && ctx.take && !ctx.take.used) {
// A take the user did not add: keep it as a draft.
return { url: await storeFile(file, `draft-takes/${ctx.take.index}`) };
}
return { url: await storeFile(file, ctx.type) };
};
export default function App() {
return (
<div style={{ position: 'fixed', inset: 0 }}>
<VideoEditor
licenseKey="YOUR_LICENSE_KEY"
onClose={() => console.log('The editor was closed')}
recording={{ uploadAllTakes: true }}
onUploadAsset={uploadAsset}
/>
</div>
);
}When your function rejects the added take, the take is not lost. The editor adds it to the timeline from the browser, and writes one line to the console that starts with "[MediaEditor] onUploadAsset refused a recording". When your function rejects a take that was not added, the editor writes one line to the console and carries on. It never shows the user an error for it.
Default: without onUploadAsset, the take stays in the browser, like a sound file from the user's computer. See Temporary assets.
Staged clips
Stabilize and Remove background send a clip to your media server, and the server downloads it. A clip that is already at an http or https address is sent as it is. A clip that exists only in the browser, such as a file from the user's computer, is first passed to onUploadAsset:
| Context field | Value |
|---|---|
source | staged |
type | video |
templateId | processing- followed by the item id, because no template is being saved |
suggestedPath | A name in the processing/ folder |
Return an https address that your media server can reach, or a key that resolveAssetUrl turns into one. The editor sends that address to the server and does not keep it in the design.
Default: without onUploadAsset, the editor uploads the clip with its own uploader, which needs assetBaseUrl.
Temporary assets
Without onUploadAsset, files from the user's computer stay in the browser until the design is saved. Then:
- Pictures are stored inside the design record as data. The save works, but the record is large.
- A video or sound file stops the save. The user sees "This design contains video that this app can't store yet, so it wasn't saved." The console names
onUploadAsset.
To store every file inside the record on purpose, set inlineTemplateMedia to true. Records can then be many megabytes.
A file the user has added but not saved exists only in that browser tab. Closing the tab loses it.
Default: inlineTemplateMedia is false.