404.A pipeline is a named, versioned graph of steps. A run processes one video file through one version of a pipeline. Each step starts when the steps it needs have finished, so independent steps run in parallel.
Editframe runs the built-in steps: probe, transcription, frame sampling, scene detection, redaction and publishing. An endpoint step sends the media and earlier results to your own service. Filter and review steps decide whether the rest of a branch runs.
Every step writes its output to a namespace on the file. Later steps, filters and your own code read outputs by namespace. See Pipeline runs to monitor, cancel, retry and review runs.
Quick start
The pipeline SDK is the @editframe/api/pipelines entry point of @editframe/api. It is experimental and can change without a major version.
import {
Client,
createPipeline,
createPipelineRun,
getPipelineRun,
isPipelineRunTerminal,
} from "@editframe/api/pipelines";
const client = new Client(process.env.EDITFRAME_API_KEY);
const pipeline = await createPipeline(client, {
name: "speech-check",
definition: {
steps: {
probe: { type: "probe" },
transcript: { type: "transcribe", needs: ["probe"] },
has_speech: {
type: "filter",
needs: ["transcript"],
when: { path: "transcript.metadata.word_count", op: "gt", value: 0 },
},
publish: { type: "publish", needs: ["has_speech"] },
},
},
});
const { run } = await createPipelineRun(
client,
{ pipeline_id: pipeline.id, file_id: "your-video-file-id" },
{ idempotencyKey: "speech-check:your-video-file-id" },
);
let current = run;
while (!isPipelineRunTerminal(current)) {
await new Promise((resolve) => setTimeout(resolve, 5000));
current = await getPipelineRun(client, run.id);
}
console.log(current.status); // "succeeded", "failed", "filtered" or "cancelled"The same flow with the CLI, which reads the API key from EDITFRAME_TOKEN:
export EDITFRAME_TOKEN="$EDITFRAME_API_KEY"
npx editframe pipelines create speech-check --definition speech-check.json
npx editframe pipelines runs create PIPELINE_ID FILE_ID --watch--watch polls the run until it finishes and exits with status 1 when the run fails or is cancelled. See CLI: pipelines for every command.
Definitions
A definition is a JSON object with one field, steps. Each key of steps is a step key: 1–63 characters of lowercase letters, digits and underscores, starting with a letter. A definition has 1–64 steps.
| Field | Required | Description |
|---|---|---|
type | Yes | One of the step types |
needs | No | Keys of the steps that must finish first, at most 16. Default []. The graph cannot have cycles. |
namespace | No | Where the step's output is stored on the file. Same format as a step key, unique in the definition. Default: the step key. |
granularity | No | { "mode": "file" } (default) or { "mode": "segment", "window_ms", "overlap_ms" }. See Segment steps. |
with | Depends | Options of the step type |
when | Filters only | The predicate a filter step evaluates |
Unknown fields cause a validation error. Editframe stores the normalized definition: it records every default, sorts needs, kinds and formats, and computes a digest (sha256: followed by the SHA-256 of the canonical JSON). Equivalent definitions have the same digest.
Step types
| Type | Granularity | with | Output |
|---|---|---|---|
probe | file | Not allowed | Metadata: duration_ms, width, height, has_video, has_audio, source, tracks |
transcribe | file or segment | Not allowed | speech.segment and speech.word annotations with text. Metadata: track_id, segment_count, word_count, segments_included, words_included, truncated |
sample_frames | file or segment | Optional: every_ms | JPEG frames of the first video track. Metadata: every_ms, track_id, frame_count, and the frame list |
detect_scenes | file or segment | Optional: threshold | scene.cut annotations, with the scene score as confidence. Metadata: threshold, track_id, cut_count |
endpoint | file or segment | Required: endpoint_id, optional max_concurrency | What your service returns. See Endpoint steps. |
filter | file | Not allowed | No output. The step succeeds or is skipped. |
review | file | Optional: instructions, expires_after_ms, on_expiry | The decision and any corrected annotations. See Review steps. |
redact_render | file | Required: kinds, optional captions, title | Metadata: render_id, status, output_url, output_file_id, file_id, kinds, annotation_count, box_count, muted_span_count, and caption_count and title_applied when captions or a title are set |
publish | file | Optional: formats | Metadata: file_id, source_url, formats, and hls_url or dash_url for each format |
| Option | Step | Range | Default |
|---|---|---|---|
every_ms | sample_frames | 100–600,000 | 1000 |
threshold | detect_scenes | 0.01–1 | 0.3 |
endpoint_id | endpoint | An endpoint ID of your organization that is not archived | — |
max_concurrency | endpoint | 1–1024 requests of this step at a time | 8 |
instructions | review | 1–2000 characters | None |
expires_after_ms | review | 60,000 (one minute) to 2,592,000,000 (30 days) | 604,800,000 (seven days) |
on_expiry | review | fail, approve or reject | fail |
kinds | redact_render | 1–32 distinct annotation kinds | — |
captions | redact_render | true or false | false |
title | redact_render | { "text": 1–120 characters, "duration_ms": 1000–15,000 } | None; duration_ms defaults to 4000 |
formats | publish | One or both of hls and dash | ["hls"] |
Notes on the built-in steps:
probeis final: it runs once the whole file is available.transcribereads the first audio track. When an output would exceed the output limits, it keeps segments in time order and includes words only if all of them fit.truncatedreports that annotations were left out.sample_framestakes a frame at each multiple ofevery_ms. Endpoint steps that need it receive the frame URLs.redact_renderblurs every annotation of the selectedkindsthat has keyframes, and mutes the audio of every one without keyframes. It reads the annotations of every step it needs, directly or indirectly, with review corrections applied. It creates an ordinary render in your organization, which uses render minutes like any other render. Editframe then adds the rendered MP4 to your organization as a new file, named after the input with a-redacted.mp4suffix, and the step succeeds when that file is ready.output_file_idis its ID. Like an upload, it sendsfile.processingand thenfile.readyorfile.failedwebhooks, and it expires when the input file does. When nothing matches, the step succeeds without rendering, and the output'sstatusisskipped. A failed render or redacted file fails the step without an automatic retry; retrying the run renders again.- With
captions: true,redact_renderburns in captions from the transcript of the onetranscribestep it needs, directly or indirectly; needing none fails validation withnot_upstreamatwith.captions, and more than one withnot_allowed. Each transcript segment is one caption, and a muted word shows as▇▇▇. Withtitle, it showstitle.text, trimmed of white space, at the top of the video untilduration_ms, fading out over the last half second. A step with captions or a title always renders, even when nothing matches. publishreturns playback URLs for the run's input file. After aredact_renderstep it needs, directly or indirectly, it plays the redacted file instead, or the input file when the redaction wasskipped.file_idis the file it plays. Apublishstep can need only oneredact_renderstep, directly or indirectly; more fail validation withnot_allowedat itsneeds. Requests to the URLs need an API key, a session or a signed URL token.
Segment steps
A step with granularity { "mode": "segment", "window_ms": 60000, "overlap_ms": 2000 } processes the file in windows instead of all at once. window_ms is 1000–3,600,000. overlap_ms is 0 to half of window_ms, default 0. Only transcribe, sample_frames, detect_scenes and endpoint steps can use segment mode.
Windows start on fragment boundaries, so a window can be longer than window_ms and an overlap can be longer than overlap_ms. The last window ends at the end of the file. Each window has its own output, and its annotations must be inside the window.
Segment steps can start before an upload finishes. With a streamed upload, a segment step processes each window as soon as the media for it has arrived. This is true only when every step it needs is also a segment step: a whole-file step it needs must finish first, and whole-file steps wait for the complete file. A URL source is complete when it is created, so all of its windows are ready at once.
Branching with filters
A filter step evaluates its when predicate against the outputs of earlier steps. When the predicate is true, the filter succeeds. When it is false, the filter is skipped, and so is every step that needs it, directly or indirectly.
{
"type": "filter",
"needs": ["detector"],
"when": {
"any": [
{ "path": "detector.annotations", "op": "non_empty" },
{ "path": "detector.metadata.flagged", "op": "eq", "value": true }
]
}
}A predicate is a leaf or a combination of predicates:
{ "all": [...] }is true when every child is true.{ "any": [...] }is true when one child is true. Each takes 1–16 children.{ "not": predicate }inverts its child.- A leaf is
{ "path", "op", "value" }. Predicates nest at most 4 levels deep.
A path is <namespace>.metadata.<field>... or <namespace>.annotations..., with at most 16 segments. A numeric segment selects an array item, for example detector.annotations.0.kind. A filter can read only the namespaces of steps it needs, directly or indirectly.
op | value | True when the value at path |
|---|---|---|
exists | None | is not null |
non_empty | None | is a string, array or object that is not empty |
eq, ne | A string, number, boolean or null | equals, or does not equal, value |
gt, gte, lt, lte | A number | is a number greater than, at least, less than, or at most value |
in | 1–64 scalars | is a scalar in value |
contains | A scalar | is an array that contains value, or a string that contains the string value |
Every leaf is false when nothing is at path, including ne. Strings in value have at most 1024 characters.
A filter reads a merged view of each segment step: the annotations of every window, and each metadata field from the last window that sets it.
To branch, give two filters complementary predicates. The branch that is not taken is skipped, and the run ends succeeded when the taken branch reaches its final step. A step that needs steps from both branches is always skipped. See Run statuses.
Review steps
A review step pauses its branch until a person approves or rejects the results of the steps before it.
{
"type": "review",
"needs": ["has_faces"],
"with": {
"instructions": "Check every face box. Remove false detections.",
"expires_after_ms": 86400000,
"on_expiry": "reject"
}
}When the review step starts, Editframe opens a review task, and the step waits in waiting_review. The decision changes the run:
- Approve: the step succeeds. The approval can include corrected annotations, which replace the original annotations of their kinds for every later step.
- Reject: the step is skipped, so the steps after it are skipped. The run ends
filteredunless another branch reaches a final step. - Expiry: when nobody decides within
expires_after_ms,on_expiryapplies.approvesucceeds the step with the decisionexpired.rejectskips the step.failfails the step and the run, and a run retry opens a new review task.
Decide reviews through the API, the SDK, the CLI or the dashboard. See Reviews.
Endpoint steps
An endpoint step sends a signed HTTP request to your service and uses the response as the step's output. First register the endpoint:
import { createPipelineEndpoint } from "@editframe/api/pipelines";
const endpoint = await createPipelineEndpoint(client, {
url: "https://models.example.com/editframe/faces",
headers: { Authorization: `Bearer ${process.env.MODEL_TOKEN}` },
timeout_ms: 30000,
callback_timeout_ms: 3600000,
max_concurrency: 16,
});
// Store this secret now. Editframe returns it only once.
console.log(endpoint.signing_secret); // "whsec_..."| Field | Range | Default | Description |
|---|---|---|---|
url | HTTPS, at most 2048 characters | — | Must resolve to a public address |
headers | At most 16 | {} | Headers sent with every request. Editframe returns only their names. |
timeout_ms | 1000–600,000 | 30,000 | How long Editframe waits for a response |
callback_timeout_ms | 1000–2,592,000,000 | 3,600,000 | How long Editframe waits for a callback after a 202 response |
max_concurrency | 1–1024 | 8 | Requests in flight to this endpoint, across all runs and steps |
You cannot set headers that Editframe sets itself: host, connection, keep-alive, upgrade, te, trailer, transfer-encoding, expect, content-length, content-type, content-encoding, user-agent, or any header that starts with proxy- or webhook-.
You can change the timeout_ms, callback_timeout_ms and max_concurrency of an endpoint. Send only the limits to change. Requests sent after the change use the new limits; requests already sent keep their timeouts:
import { updatePipelineEndpoint } from "@editframe/api/pipelines";
await updatePipelineEndpoint(client, endpoint.id, { max_concurrency: 4, timeout_ms: 45000 });The ranges are the same as for a new endpoint. A body with no limit, an unknown field or a value out of range fails with 422 invalid_request. An archived endpoint keeps its limits: the change fails with 409 endpoint_archived. Editframe records each change, with the API key or user that made it, in the endpoint's change history in the dashboard. A change to values that the endpoint already has records nothing.
You cannot change the URL, the headers or the signing secret. To change them, create an endpoint, create a pipeline version that uses it, and then archive the old endpoint. A new version cannot name an archived endpoint, and a run whose version names an archived endpoint fails with endpoint_not_found.
Then reference the endpoint from a step:
{
"type": "endpoint",
"needs": ["frames", "transcript"],
"granularity": { "mode": "segment", "window_ms": 60000 },
"with": { "endpoint_id": "6f1c2a9e-3b4d-4e5f-8a7b-1c2d3e4f5a6b", "max_concurrency": 16 }
}Request
Editframe sends POST with a JSON body and the User-Agent Editframe-Pipelines/1:
{
"pipeline_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
"pipeline_version": 12,
"pipeline_run_id": "3d0f6c1a-5b2e-4c7d-9f8a-1b2c3d4e5f60",
"step_run_id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
"step_key": "detector",
"namespace": "detector",
"attempt": 1,
"idempotency_key": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f:1:14",
"external_id": "truck-1182/2026-10-01",
"deadline_at": "2026-10-02T04:15:30.000Z",
"file": {
"id": "7b8c9d0e-1f2a-4b3c-8d4e-5f6a7b8c9d0e",
"filename": "truck-1182.mp4",
"status": "receiving",
"duration_ms": null,
"tracks": [
{ "track_id": 1, "type": "video", "codec": "h264", "duration_ms": null },
{ "track_id": 2, "type": "audio", "codec": "aac", "duration_ms": null }
]
},
"window": { "index": 14, "start_ms": 840000, "end_ms": 900000, "overlap_ms": 0 },
"media": {
"expires_at": "2026-10-02T04:15:30.000Z",
"source_url": "https://editframe.com/api/v1/files/7b8c9d0e-…/tracks/1?media_token=…",
"index_url": "https://editframe.com/api/v1/files/7b8c9d0e-…/index?media_token=…",
"audio_url": "https://editframe.com/api/v1/files/7b8c9d0e-…/tracks/2?media_token=…",
"frames": [
{ "t_ms": 840000, "url": "https://editframe.com/api/v1/files/7b8c9d0e-…/pipeline-frames/…/840000?media_token=…" }
]
},
"metadata": {
"transcript": { "metadata": { "word_count": 112 }, "annotations": [] },
"frames": { "metadata": { "every_ms": 1000, "frame_count": 60 }, "annotations": [] }
},
"callback": {
"url": "https://editframe.com/api/v1/pipeline-callbacks/…",
"expires_at": "2026-10-02T04:15:30.000Z"
}
}windowisnullfor a file step.file.statusisreceivingwhile a streamed upload is still arriving, andreadyafter that. Durations arenulluntil the upload is sealed. A file that was uploaded whole also has the probedwidthandheightof video tracks, andsample_rateandchannelsof audio tracks.deadline_atandcallback.expires_atarecallback_timeout_msafter the request.metadataholds the outputs of every step this step needs, directly or indirectly, by namespace, with review corrections applied. For a segment step, annotations are limited to the window.mediaURLs need no API key. They work untilmedia.expires_at, at most four hours after the request.frameslists the frames of everysample_framesstep the step needs, at most 1000 per request.idempotency_keyis the same for every delivery of one attempt. Use it to deduplicate.
Requests for URL sources
When the run's file was created from a URL, Editframe serves none of its media, and media has your URL instead of Editframe's media URLs. The rest of the request, including file and window, is the same:
{
"media": {
"url": "https://media.example.com/fleet/truck-1182.mp4",
"tracks": [
{ "track_id": 1, "type": "video", "label": "camera", "codec": "avc1.640028" },
{ "track_id": 2, "type": "audio", "label": null, "codec": "mp4a.40.2" }
],
"expires_at": "2026-10-02T04:15:30.000Z",
"frames": [
{ "t_ms": 840000, "url": "https://editframe.com/api/v1/files/7b8c9d0e-…/pipeline-frames/…/840000?media_token=…" }
]
}
}urlis the URL that Editframe recorded when it created the file, after redirects. Read it yourself, by byte range for thewindowof a segment step. Editframe adds no token to it.trackslists every track with its label and codec, without URLs.source_url,audio_urlandindex_urlare absent.expires_atis present only withframes, whose URLs work until then.
In the SDK, request.media.url is set only for a URL source.
Signatures
Requests are signed with Standard Webhooks headers: webhook-id, webhook-timestamp and webhook-signature (v1, followed by a base64 HMAC-SHA256). Verify them with the endpoint's whsec_ secret and the raw request body:
import express from "express";
import { postPipelineCallback, verifyPipelineEndpointRequest } from "@editframe/api/pipelines";
const app = express();
app.post("/editframe/faces", express.raw({ type: "application/json" }), async (req, res) => {
let request;
try {
request = await verifyPipelineEndpointRequest({
secret: process.env.EDITFRAME_ENDPOINT_SECRET,
headers: req.headers,
body: req.body,
});
} catch {
return res.status(401).end();
}
// Accept the work now and post the output to the callback URL when it is done.
res.status(202).end();
const annotations = await detectFaces(request.media); // your model
await postPipelineCallback(request.callback.url, {
status: "succeeded",
output: { metadata: { face_count: annotations.length }, annotations },
});
});For short work, answer with the output instead: res.json({ metadata: { … }, annotations: [ … ] }).
verifyPipelineEndpointRequest accepts a clock difference of 300 seconds by default (toleranceSeconds) and throws PipelineSignatureError when the signature is missing, stale or wrong.
Responses
| Response | Result |
|---|---|
2xx except 202 | The body is the step's output. An empty body is an empty output. |
202 | The step waits for a callback, until callback_timeout_ms passes. |
408, 429, 5xx, timeout or connection error | Editframe retries the attempt. A Retry-After header of up to one hour sets the delay. |
Redirect or other 4xx | The step fails without a retry. |
An output larger than the output limit, or one that is not valid, fails the step.
Callbacks
After a 202, post one of these bodies to callback.url. The URL authenticates the attempt, so send no API key.
{ "status": "heartbeat" }
{ "status": "succeeded", "output": { "metadata": {}, "annotations": [] } }
{ "status": "failed", "error": { "message": "Model unavailable", "code": "model_unavailable", "retryable": true } }- A heartbeat extends the deadline to
callback_timeout_msfrom now. Send one before the deadline when work takes longer. error.messagehas 1–1000 characters.error.codeis optional: lowercase letters, digits and underscores, starting with a letter, at most 64 characters. A retryable failure starts another attempt when attempts remain.- A callback can arrive before the
202response. Editframe records it.
| Callback response | Meaning |
|---|---|
202 { "recorded": true } | Editframe recorded the callback. |
202 { "recorded": false } | Editframe already had this result. |
409 { "error": "step_not_waiting" } | The attempt is not waiting anymore, for example because the run was cancelled or the attempt timed out. |
422 { "error": "invalid_callback", "issues": [...] } | The body is not valid. |
Concurrency
Each endpoint step sends at most with.max_concurrency requests at a time, and each endpoint receives at most its own max_concurrency across all steps. An attempt that answered 202 does not hold a slot while it waits for the callback.
Versions
A pipeline has versions numbered from 1. createPipeline creates version 1, and versions cannot be changed. To change a pipeline, create a version:
import { createPipelineVersion } from "@editframe/api/pipelines";
const { version, created } = await createPipelineVersion(client, pipeline.id, definition);
// created is false when an existing version has the same digest; that version is returned.npx editframe pipelines versions create PIPELINE_ID --definition definition.jsonA run uses the latest version unless you pass version. Pipeline names are unique among the pipelines of your organization that are not archived. An archived pipeline accepts no new versions or runs (409 pipeline_archived).
Pause a pipeline
Pause a pipeline to stop new work without cancelling runs, for example while your endpoint is down. A pause applies to every version of the pipeline.
import { pausePipeline, resumePipeline } from "@editframe/api/pipelines";
const paused = await pausePipeline(client, pipeline.id);
console.log(paused.paused_at); // when the pipeline was paused
await resumePipeline(client, pipeline.id);npx editframe pipelines pause PIPELINE_ID
npx editframe pipelines resume PIPELINE_IDWhile a pipeline is paused:
- It accepts new versions, runs and groups. New runs stay
pending. - No
pendingrun starts, and no step, retry attempt or segment window starts. - Attempts that started before the pause continue. Editframe records their endpoint callbacks and review decisions, and their runs then wait for the next step.
- A step that has not started gets no deadline, so the pause cannot make it time out. Attempts that started keep their deadlines, and open reviews keep their
expires_at. - You can cancel and retry runs. A retried run stays
pendinguntil you resume the pipeline. - A run that has not started records no usage.
When you resume the pipeline, the waiting runs and steps start immediately, within the limits of their groups and endpoints. Both requests return the pipeline. Pausing a paused pipeline, or resuming a pipeline that is not paused, changes nothing. An archived pipeline cannot be paused (409 pipeline_archived). Pause and resume send no webhooks.
Editframe records each pause and resume, with the API key or user that made it, in the change history of the pipeline in the dashboard. Editframe does not change or delete these records. A request that changes nothing records nothing.
listPipelines takes paused: true to list only paused pipelines (GET /api/v1/pipelines?paused=true). paused=false lists only the pipelines that are not paused.
Runs
Create a run with a pipeline and a video file. The Idempotency-Key header is optional, at most 255 characters, and unique in your organization: a repeated request with the same key and body returns the original run with status 200, and one with a different body returns 409 idempotency_conflict.
curl -X POST https://editframe.com/api/v1/pipeline-runs \
-H "Authorization: Bearer $EDITFRAME_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: speech-check:7b8c9d0e" \
-d '{"pipeline_id": "9a8b7c6d-…", "file_id": "7b8c9d0e-…", "external_id": "truck-1182"}'| Field | Required | Description |
|---|---|---|
pipeline_id | Yes, unless group_id is set | The pipeline |
version | No | A version number. Default: the latest version, or the group's version. |
group_id | No | An open group for the run to join. The run uses the group's pipeline version. |
file_id | One of file_id and source_url | A file of type video |
source_url | One of file_id and source_url | An HTTPS URL of a video to process in place. Editframe creates a file for it. See URL sources. |
external_id | No | Your own identifier, at most 255 characters. Editframe returns it on the run, in webhooks and in endpoint requests. |
metadata | No | String keys and values to find the run by later. See Limits. |
The response is the run with all of its steps. Steps start once the file's media is available. See Pipeline runs to follow the run.
A run's metadata cannot change after the run is created. It is not the file metadata that steps write. listPipelineRuns takes metadata and returns only the runs that have every entry you give:
const { runs } = await listPipelineRuns(client, { metadata: { customer: "acme" } });
// GET /api/v1/pipeline-runs?metadata[customer]=acmenpx editframe pipelines runs create PIPELINE_ID FILE_ID --metadata customer=acme --metadata truck=1182
npx editframe pipelines runs list --metadata customer=acmeGroups
A group holds runs of one pipeline version, for example the uploads of one night. Use a group to follow its runs together, to limit how many of them run at the same time, and to get one notification when they are all finished.
import {
closePipelineGroup,
createPipelineGroup,
createPipelineRun,
getPipelineGroupReport,
} from "@editframe/api/pipelines";
const { group } = await createPipelineGroup(
client,
{
pipeline_id: pipeline.id,
runs: fileIds.map((file_id) => ({ file_id })),
max_in_flight: 50,
metadata: { night: "2026-10-01" },
},
{ idempotencyKey: "nightly-2026-10-01" },
);
// A run joins a group only when you create it.
await createPipelineRun(client, { group_id: group.id, file_id: lateFileId });
// Close the group after its last run, so it can complete.
await closePipelineGroup(client, group.id);
const report = await getPipelineGroupReport(client, group.id);npx editframe pipelines groups create PIPELINE_ID --files file-ids.txt --max-in-flight 50 --metadata night=2026-10-01
npx editframe pipelines runs create PIPELINE_ID FILE_ID --group GROUP_ID
npx editframe pipelines groups close GROUP_ID
npx editframe pipelines groups report GROUP_ID| Group status | Meaning |
|---|---|
open | The group accepts new runs. |
closed | The group accepts no new runs, and at least one of its runs has not finished. |
completed | The group is closed, and every run in it finished. |
runshas 0–1000 entries, each with afile_idor asource_url, and an optionalexternal_idandmetadata. Thefile_idvalues are distinct. Runs do not get the group'smetadata. Unknown files return404 { "error": "file_not_found", "file_ids": [...] }.versionis the pipeline version of every run in the group. Default: the latest version.max_in_flightis 1–100,000, default 100. Other runs of the group staypendingand start oldest first as runs finish.- An open group does not complete, also when all of its runs finished. Close it with
closePipelineGroup, or create it withclose: true(--close) when the request has all of its runs. When you close a group whose runs all finished, it completes immediately. Closing a closed group changes nothing. - When you retry a run of a completed group, the group becomes
closedagain until that run finishes.completion_countis the number of times that the group completed. listPipelineGroupstakesstatus,pipeline_idandmetadatafilters.listPipelineRunstakesgroup_id.- In a
--filesfile, the CLI reads one file ID per line and ignores text after#.
You can change the max_in_flight of a group in any status. When you raise it, waiting runs start immediately. When you lower it, running runs continue; waiting runs start only when fewer runs than the new limit are running. A run that you retry later uses the limit of its group at that time.
import { updatePipelineGroup } from "@editframe/api/pipelines";
await updatePipelineGroup(client, group.id, { max_in_flight: 200 });npx editframe pipelines groups update GROUP_ID --max-in-flight 200The body must contain only max_in_flight. A missing value, an unknown field or a value out of range fails with 422 invalid_request. A group that is not in your organization returns 404. A change of max_in_flight sends no webhook.
Editframe records each close and each change of max_in_flight, with the API key or user that made it, in the change history of the group in the dashboard. Editframe does not change or delete these records. Closing a closed group, or a change to the current value, records nothing.
createPipelineRun error with group_id | Meaning |
|---|---|
404 group_not_found | The group does not exist in your organization. |
409 group_closed | The group is closed or completed, so it accepts no new runs. |
409 group_version_mismatch | pipeline_id or version is not the group's pipeline version. |
getPipelineGroupReport summarizes the runs of a group:
| Report field | Description |
|---|---|
runs | The number of runs in each status, and the total |
queue_wait_ms | p50, p95 and max of the time from creation to start, for the runs that started. null before a run starts. |
run_duration_ms | p50, p95 and max of the time from start to completion, for the runs that finished. null before a run finishes. |
input | media_minutes of the input files, from the measured_runs whose files have a known duration |
step_failures | The steps that failed, most failures first, each with its five most frequent error codes |
usage | pipeline_input_minutes, cpu_seconds and gpu_seconds recorded for the runs so far |
first_run_created_at, last_run_created_at, last_run_completed_at | Timestamps, or null |
Creators
Pipelines, versions, endpoints, runs and groups tell who created them:
| Field | Description |
|---|---|
created_by | { "type": "api_key", "id" } for the API key that sent the request, or { "type": "user", "id" } for a user who signed in to the dashboard or sent the request without an API key |
created_via | api or dashboard |
Both fields are null when Editframe did not record the creator. Runs and groups that an API key created before Editframe recorded creators have that API key in created_by. Other records created before then have null.
The dashboard shows the creator on the page of each record. A record that an Editframe administrator created while viewing your organization shows that an administrator created it.
created_by does not select webhooks. A run sends its webhooks to its API key: the key that created the run through the API, or the key chosen when the run was started in the dashboard.
Streamed uploads
A streamed upload sends a video as verified fragments, so a run can start before the upload finishes. Segment steps process each window as its media arrives. The file has the status receiving until the upload is sealed.
import { Client, createPipelineRun } from "@editframe/api/pipelines";
import { uploadFragmented } from "@editframe/api/pipelines/upload";
const result = await uploadFragmented(client, "dashcam.mp4", {
onCreated: async (file) => {
await createPipelineRun(
client,
{ pipeline_id: pipeline.id, file_id: file.id },
{ idempotencyKey: `upload:${file.id}` },
);
},
onProgress: (progress) => console.log(progress.watermarkMs), // verified media time
});
console.log(result.file.status); // "processing" after the sealnpx editframe pipelines upload dashcam.mp4 --run PIPELINE_ID --watchuploadFragmented accepts a file path, a Blob or File, or a stream. When an upload stops, resume it with fileId (SDK) or --resume FILE_ID (CLI) and the same media. A file has 1–8 tracks and at most one video track. In a browser, direct uploads need CORS on the storage bucket.
URL sources
A run can process a video that you already host at an HTTPS URL, without an upload. Editframe does not copy the media: built-in steps read the byte ranges they need from your URL, and endpoint steps receive your URL. Give source_url in place of file_id:
import { createPipelineRun } from "@editframe/api/pipelines";
const { run } = await createPipelineRun(
client,
{ pipeline_id: pipeline.id, source_url: "https://media.example.com/fleet/truck-1182.mp4" },
{ idempotencyKey: "speech-check:truck-1182" },
);
console.log(run.file_id); // the file Editframe created for the URLnpx editframe pipelines runs create PIPELINE_ID --source-url https://media.example.com/fleet/truck-1182.mp4 --watchWhen you create the run, Editframe checks that it can read the URL, reads its tracks and duration, and creates a file of type video for it. The response is the usual run, and its file_id is the new file. The file is ready at once, so every window of a segment step is ready when the run starts. Windows run in parallel up to the step's and endpoint's concurrency limits; nothing waits for media to arrive, as it does with a streamed upload.
Group entries take source_url in place of file_id too:
import { createPipelineGroup } from "@editframe/api/pipelines";
const { group } = await createPipelineGroup(client, {
pipeline_id: pipeline.id,
runs: [
{ source_url: "https://media.example.com/fleet/truck-1182.mp4", external_id: "truck-1182" },
{ source_url: "https://media.example.com/fleet/truck-1183.mp4", external_id: "truck-1183" },
],
close: true,
});npx editframe pipelines groups create PIPELINE_ID --source-url URL --source-url URL --closeWhen a URL cannot be read, Editframe creates nothing: not the run, and for a group request, not the group or any of its runs.
Each source_url in a run or group request creates a new file, and the URLs of one group request must differ. To process one video with several pipelines, create its file once with createFile and give its ID to each run:
import { createFile } from "@editframe/api";
import { createPipelineRun } from "@editframe/api/pipelines";
const file = await createFile(client, {
type: "video",
source_url: "https://media.example.com/fleet/truck-1182.mp4",
});
console.log(file.status, file.source_url); // "ready", and the URL after redirects
await createPipelineRun(client, { pipeline_id: speechCheck.id, file_id: file.id });
await createPipelineRun(client, { pipeline_id: redaction.id, file_id: file.id });See Files for the fields of createFile.
Requirements
The URL must meet these requirements when you create the file, and for as long as runs use it, including retries:
- HTTPS on a public address. Editframe follows up to 3 redirects when it creates the file and records the final URL. Steps read the recorded URL and do not follow redirects.
- Range requests. A request with a
Rangeheader is answered with206 Partial Contentand aContent-Rangeheader that gives the total size. - MP4 or MOV. The file is ISO BMFF: progressive, with the
moovbox anywhere in the file, or fragmented, with asidxormfraindex. - A known duration.
- Content that does not change. When it creates the file, Editframe records the size and the
ETag, or theLast-Modifiedheader when there is noETag. A step that finds another size,ETagorLast-Modifiedfails withsource_changed. - A URL that stays readable. A step that cannot read the URL fails with
source_unavailable. A signed URL must not expire while runs use it.
Each track's label is its name in the file: the MP4 udta name, or else the handler name. The with.track option of transcribe, sample_frames and detect_scenes steps selects a track by its label, as for streamed uploads, so give the tracks you select names of lowercase letters, digits and underscores that start with a letter, at most 32 characters. A track whose name does not fit, or that shares its name with another track, has no label. With ffmpeg, set handler names while copying the streams:
ffmpeg -i interview.mp4 -c copy -metadata:s:v:0 handler_name=camera -metadata:s:a:0 handler_name=voice interview-labelled.mp4URL errors
A URL that cannot be read fails the request with 422 and the code as error. createFile gives the reason as message:
{ "error": "source_range_unsupported", "message": "The server answered a Range request with 200 instead of 206." }createPipelineRun gives the code and the reason as an issue at source_url, and a group request gives an issue for each entry whose URL cannot be read, in entry order, with the first entry's code as error:
{
"error": "source_unreachable",
"issues": [
{ "path": "runs[3].source_url", "code": "source_unreachable", "message": "…" },
{ "path": "runs[7].source_url", "code": "source_http_error", "message": "…" }
]
}Giving both file_id and source_url fails with 422 invalid_request and the issue not_allowed at source_url; the same URL twice in one group request fails with duplicate.
| Code | Meaning |
|---|---|
source_url_invalid | The value is not an absolute HTTPS URL. |
source_url_forbidden | The URL's host is not a public address, for example a private network address. |
source_unreachable | Editframe could not resolve the host or connect to it. |
source_http_error | The server answered with an error status, such as 403 or 404, or redirected more than 3 times. |
source_range_unsupported | The server did not answer a Range request with 206 and the total size, or reported another size or ETag while Editframe read the file. |
source_container_unsupported | The file is not an MP4 or MOV file. |
source_index_too_large | The moov box is larger than 64 MiB. |
source_duration_unknown | The file does not give its duration. |
source_unseekable | A fragmented file has no sidx or mfra index, so media cannot be found by time. |
source_no_tracks | The file has no video or audio track. |
source_timeout | Reading the file's tracks took longer than 15 seconds. |
In the SDK, PipelineApiError has the code in code and the issues in issues, and the error that createFile throws names the code and the reason. Steps of a run whose source later changes or becomes unreadable fail with the step error codes source_changed and source_unavailable.
Outputs
A step output is a JSON object with metadata (an object, nested at most 32 levels) and annotations (an array). Both are optional. An output has at most 1 MiB and 10,000 annotations.
| Annotation field | Required | Description |
|---|---|---|
kind | Yes | Dot-separated lowercase names, for example face or speech.word, at most 128 characters |
start_ms, end_ms | Yes | Media time in milliseconds. end_ms is at least start_ms. |
label | No | At most 256 characters |
text | No | At most 10,000 characters |
confidence | No | 0–1 |
track_id | No | The track the annotation describes |
keyframes | No | At most 1000 boxes: { "t_ms", "x", "y", "w", "h" } |
Keyframe boxes use normalized frame coordinates from 0 to 1, with x + w and y + h at most 1. Their t_ms values increase and are between start_ms and end_ms.
Read the outputs of a run, one page per call, with each window's output for segment steps:
import { getPipelineRunOutputs, getFilePipelineMetadata } from "@editframe/api/pipelines";
const page = await getPipelineRunOutputs(client, run.id, "detector");
for (const { window, output } of page.outputs) {
console.log(window?.start_ms, output.annotations.length);
}
// The latest outputs a step wrote to a namespace on a file, from any run.
const latest = await getFilePipelineMetadata(client, fileId, "detector");A segment output's window has its index, start_ms and end_ms, and the times its latest attempt was planned (created_at), became ready (ready_at), was dispatched (started_at), began on a worker (picked_up_at) and finished (completed_at). A time is null until it happens.
listFilePipelineMetadata(client, fileId) lists the namespaces that steps have written on a file. Use iteratePipelineRunOutputs and iterateFilePipelineMetadata to read every page.
Limits
| Limit | Value |
|---|---|
| Steps per definition | 1–64 |
needs per step | 16 |
| Request body | 1 MiB |
| Pipeline name | 200 characters |
external_id, Idempotency-Key | 255 characters |
Runs in one createPipelineGroup request | 1000 |
Run and group metadata | 32 keys of 1–256 bytes, values of at most 1024 bytes, 8192 bytes as compact JSON |
| Step output | 1 MiB, 10,000 annotations |
| Frames per endpoint request | 1000 |
| Attempts per step or window | 3, then 3 more for each retry of the run |
| List page size | 1–100, default 25 |
Errors
Validation errors return 422 with the field paths and issue codes:
{
"error": "invalid_definition",
"issues": [{ "path": "steps.gate.when.path", "code": "not_upstream" }]
}| Issue code | Meaning |
|---|---|
required | The field is missing. |
invalid_type | The value has the wrong JSON type. |
invalid_value | The value is not accepted, for example an unknown or archived endpoint_id. |
out_of_range | A number, length or count is outside its range. |
not_allowed | The field is not allowed here, for example with on a probe step. |
unknown_field | The field does not exist. |
duplicate | The value repeats, for example a namespace. |
unknown_step | needs names a step that does not exist. |
cycle | The steps need each other in a cycle. |
not_upstream | A filter reads a namespace of a step it does not need. |
The error field is invalid_definition for definitions and invalid_request for other request bodies. Other errors return { "error": code }, for example 409 pipeline_name_taken, 404 pipeline_not_found, 404 pipeline_version_not_found or 404 file_not_found. In the SDK, failed requests throw PipelineApiError with status, code, issues and fileIds.
Routes
All routes are under https://editframe.com and use an API key as a Bearer token, except the callback route. Lists take limit and cursor and return next_cursor, which is null on the last page.
| Route | SDK function |
|---|---|
POST /api/v1/pipelines | createPipeline |
GET /api/v1/pipelines | listPipelines, iteratePipelines |
GET /api/v1/pipelines/:id | getPipeline |
POST /api/v1/pipelines/:id/archive | archivePipeline |
POST /api/v1/pipelines/:id/pause | pausePipeline |
POST /api/v1/pipelines/:id/resume | resumePipeline |
POST /api/v1/pipelines/:id/versions | createPipelineVersion |
GET /api/v1/pipelines/:id/versions | listPipelineVersions, iteratePipelineVersions |
GET /api/v1/pipelines/:id/versions/:version | getPipelineVersion |
POST /api/v1/pipeline-endpoints | createPipelineEndpoint |
GET /api/v1/pipeline-endpoints | listPipelineEndpoints, iteratePipelineEndpoints |
GET /api/v1/pipeline-endpoints/:id | getPipelineEndpoint |
PATCH /api/v1/pipeline-endpoints/:id | updatePipelineEndpoint |
POST /api/v1/pipeline-endpoints/:id/archive | archivePipelineEndpoint |
POST /api/v1/pipeline-runs | createPipelineRun |
GET /api/v1/pipeline-runs | listPipelineRuns, iteratePipelineRuns |
GET /api/v1/pipeline-runs/:id | getPipelineRun |
POST /api/v1/pipeline-runs/:id/cancel | cancelPipelineRun |
POST /api/v1/pipeline-runs/:id/retry | retryPipelineRun |
GET /api/v1/pipeline-runs/:id/outputs/:namespace | getPipelineRunOutputs, iteratePipelineRunOutputs |
POST /api/v1/pipeline-groups | createPipelineGroup |
GET /api/v1/pipeline-groups | listPipelineGroups, iteratePipelineGroups |
GET /api/v1/pipeline-groups/:id | getPipelineGroup |
PATCH /api/v1/pipeline-groups/:id | updatePipelineGroup |
POST /api/v1/pipeline-groups/:id/close | closePipelineGroup |
GET /api/v1/pipeline-groups/:id/report | getPipelineGroupReport |
GET /api/v1/pipeline-reviews | listPipelineReviews, iteratePipelineReviews |
GET /api/v1/pipeline-reviews/:id | getPipelineReview |
POST /api/v1/pipeline-reviews/:id/decision | approvePipelineReview, rejectPipelineReview |
GET /api/v1/files/:id/pipeline-metadata | listFilePipelineMetadata |
GET /api/v1/files/:id/pipeline-metadata/:namespace | getFilePipelineMetadata, iterateFilePipelineMetadata |
POST /api/v1/pipeline-callbacks/:token | postPipelineCallback |
GET /api/v1/pipelines and GET /api/v1/pipeline-endpoints take include_archived=true. GET /api/v1/pipelines also takes paused=true or paused=false. To be notified of run, step, review and group changes, see Webhooks.