Files are uploaded in two steps: first register the file to get an ID, then stream the bytes using the SDK's chunked uploader.
Upload a file
Use the @editframe/api SDK to handle both steps:
import { createFile, uploadFile } from "@editframe/api";
import { createReadStream, statSync } from "node:fs";
const filePath = "clip.mp4";
const byteSize = statSync(filePath).size;
// Step 1: Register the file and get an ID
const file = await createFile(client, {
filename: "clip.mp4",
type: "video",
byte_size: byteSize,
mime_type: "video/mp4",
});
// Step 2: Stream the bytes with chunked upload
for await (const event of uploadFile(
client,
{ id: file.id, byte_size: byteSize, type: file.type },
createReadStream(filePath),
)) {
if (event.type === "progress") {
console.log(`Uploading: ${Math.round(event.progress * 100)}%`);
}
}
console.log(file.id); // stable UUID — use this everywhereUploads are resumable. If the connection drops, re-running uploadFile with the same id resumes from where it left off.
Node.js shorthand
For simple cases, the upload helper does everything in one call:
import { upload } from "@editframe/api/node";
const { file, uploadIterator } = await upload(client, "clip.mp4");
for await (const event of uploadIterator) {
if (event.type === "progress") {
console.log(`Uploading: ${Math.round(event.progress * 100)}%`);
}
}This auto-detects the file type from the extension and handles chunked transfer automatically.
Create a file from a URL
A video that you already host at an HTTPS URL can become a file without an upload. Editframe reads it in place and does not copy it: pipeline steps read the byte ranges they need from your URL.
import { createFile } from "@editframe/api";
const file = await createFile(client, {
type: "video",
source_url: "https://media.example.com/fleet/truck-1182.mp4",
});
console.log(file.status); // "ready"
console.log(file.source_url); // the URL after redirects| Field | Required | Description |
|---|---|---|
type | Yes | video |
source_url | Yes | An HTTPS URL of an MP4 or MOV file that answers Range requests. See the requirements. |
filename | No | The file's name; by default the last segment of the URL's path |
expires_at | No | When the file record expires, as for uploads. See Retention and expiration. |
byte_size, md5 and mime_type are not allowed with source_url. Editframe checks the URL when you create the file, and the response is the file with these values:
| Field | Value |
|---|---|
status | ready |
byte_size, next_byte | The size of the source |
md5 | null |
source_url | The URL after up to 3 redirects. Editframe reads this URL from then on. |
A URL that cannot be read by byte range returns 422, with a code such as source_range_unsupported as error and the reason as message. See URL errors for every code. The source must not change, and must stay readable while you use the file.
Wait for processing
Video files are transcoded after upload. Poll getFileDetail until status is "ready":
import { getFileDetail } from "@editframe/api";
async function waitForFile(client, id) {
while (true) {
const detail = await getFileDetail(client, id);
if (detail.status === "ready") return detail;
if (detail.status === "failed") throw new Error(`Processing failed: ${id}`);
await new Promise((r) => setTimeout(r, 1000));
}
}
const ready = await waitForFile(client, file.id);Image and caption files skip transcoding and are "ready" immediately after upload completes. A file created from a URL is "ready" when it is created.
Retention and expiration
By default, uploaded files are retained until you delete them. The expires_at field on the file record is null, which means the file is not scheduled for automatic removal.
Setting a TTL at upload time
Pass expires_at as an ISO 8601 datetime when registering the file. The maximum allowed value is 30 days in the future. TTL can only be set at creation time — there is no API to change expires_at on an existing file.
import { createFile, uploadFile, getFileDetail } from "@editframe/api";
import { createReadStream, statSync } from "node:fs";
const filePath = "clip.mp4";
const byteSize = statSync(filePath).size;
// Expire in 7 days
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const file = await createFile(client, {
filename: "clip.mp4",
type: "video",
byte_size: byteSize,
mime_type: "video/mp4",
expires_at: expiresAt.toISOString(), // ISO 8601, max 30 days ahead
});
for await (const event of uploadFile(
client,
{ id: file.id, byte_size: byteSize, type: file.type },
createReadStream(filePath),
)) {
if (event.type === "progress") {
console.log(`Uploading: ${Math.round(event.progress * 100)}%`);
}
}
const detail = await getFileDetail(client, file.id);
console.log(detail.expires_at); // "2026-06-22T12:00:00.000Z"expires_at | Meaning |
|---|---|
null (omitted on create) | Permanent — kept until you call deleteFile |
| ISO 8601 datetime | File is removed shortly after this time |
expires_at is validated on every call to createFile. Omitting the field, or passing null, means permanent retention. Any other value must be a future ISO 8601 datetime no more than 30 days out, or POST /api/v1/files returns 400 with one of:
| Status | error | Condition |
|---|---|---|
| 400 | invalid_datetime | expires_at must be a valid ISO 8601 datetime string |
| 400 | must_be_future | expires_at must be in the future |
| 400 | exceeds_max_retention | expires_at must be within 30 days of the current time |
After a file expires:
- Content and playback routes return 410 with
error: "file_expired". GET /api/v1/files/:idstill returns metadata (includingexpires_at) so you can tell why access failed.- A background job reaps expired rows within about five minutes of
expires_at.
Render-ingested assets: When you pass remote https:// sources in inline render HTML, Editframe downloads and stores them with a one-hour TTL automatically. See Renders.
Not the same as URL signing: URL signing issues short-lived JWTs that control access to endpoints. expires_at controls whether the file record and stored bytes still exist.
Expired file error
{
"error": "file_expired",
"expires_at": "2026-05-28T12:00:00.000Z",
"message": "This file expired and is no longer available for download."
}Deleting a file
To remove a file immediately, call deleteFile with the file's id:
import { deleteFile } from "@editframe/api";
const result = await deleteFile(client, file.id);
console.log(result.success); // trueAfter deletion, the file record and stored bytes are removed immediately. Any composition or render referencing the deleted file-id will fail.
File status values
| Status | Description |
|---|---|
created | File registered, awaiting upload |
uploading | Upload in progress |
processing | Upload complete, video being transcoded |
ready | File available for use in compositions |
failed | Processing failed |
Using a file in a composition
Reference an uploaded file using its id with the file-id attribute:
<ef-configuration api-host="https://editframe.com">
<ef-timegroup mode="contain" class="w-[1920px] h-[1080px]">
<ef-video file-id="your-file-id"></ef-video>
</ef-timegroup>
</ef-configuration>The file-id is the UUID returned by createFile. It stays the same throughout upload, processing, and playback.
SDK reference
| Function | Description |
|---|---|
createFile(client: Client, payload: CreateFilePayload) | — |
createFileTrack(client: Client, fileId: string, payload: CreateISOBMFFTrackPayload) | — |
deleteFile(client: Client, id: string) | — |
getFileDetail(client: Client, id: string) | — |
getFileProcessingProgress(client: Client, id: string) | — |
getFileTranscription(client: Client, id: string) | — |
lookupFileByMd5(client: Client, md5: string) | — |
transcribeFile(client: Client, id: string, options?: { trackId?: number }) | Do not use. The endpoint returns 410 Gone. To transcribe a file, start a pipeline run with a transcribe step. |
uploadFile(client: Client, uploadDetails: { id: string; byte_size: number; type: FileType }, fileStream: ReadableStream) | — |
uploadFileIndex(client: Client, fileId: string, fileStream: ReadableStream, fileSize: number) | — |
uploadFileTrack(client: Client, fileId: string, trackId: number, byteSize: number, fileStream: ReadableStream) | — |