Pipelines

Process video files through versioned graphs of analysis, review, redaction and publishing steps.

Pipelines are in preview, and Editframe enables them for each organization. Until they are enabled for your organization, every pipeline route returns 404. To ask for access, email hello@editframe.com.

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.

js
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:

bash
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.

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

Notes on the built-in steps:

  • probe is final: it runs once the whole file is available.
  • transcribe reads 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. truncated reports that annotations were left out.
  • sample_frames takes a frame at each multiple of every_ms. Endpoint steps that need it receive the frame URLs.
  • redact_render blurs every annotation of the selected kinds that 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. When nothing matches, the step succeeds without rendering, and the output's status is skipped. A failed render fails the step without an automatic retry.
  • publish returns playback URLs for the run's input file, not for a redact_render result. Use output_url of the redact_render output for the redacted video. 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.

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.

json
{
  "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.

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, so the run ends filtered. 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.

json
{
  "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, and the run ends filtered.
  • Expiry: when nobody decides within expires_after_ms, on_expiry applies. approve succeeds the step with the decision expired. reject skips the step. fail fails 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:

js
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_..."

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-.

Endpoints cannot be changed. To change one or to rotate its secret, 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:

json
{
  "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:

json
{
  "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"
  }
}
  • window is null for a file step. file.status is receiving while a streamed upload is still arriving, and ready after that. Durations are null until the upload is sealed. A file that was uploaded whole also has the probed width and height of video tracks, and sample_rate and channels of audio tracks.
  • deadline_at and callback.expires_at are callback_timeout_ms after the request.
  • metadata holds 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.
  • media URLs need no API key. They work until media.expires_at, at most four hours after the request. frames lists the frames of every sample_frames step the step needs, at most 1000 per request.
  • idempotency_key is the same for every delivery of one attempt. Use it to deduplicate.

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:

js
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

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.

json
{ "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_ms from now. Send one before the deadline when work takes longer.
  • error.message has 1–1000 characters. error.code is 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 202 response. Editframe records it.

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:

js
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.
bash
npx editframe pipelines versions create PIPELINE_ID --definition definition.json

A 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).

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.

bash
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"}'

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.

Batches

A batch starts one run for each of up to 1000 files, with a limit on the runs in flight:

js
import { createPipelineBatch, getPipelineBatch } from "@editframe/api/pipelines";

const { batch } = await createPipelineBatch(
  client,
  {
    pipeline_id: pipeline.id,
    runs: fileIds.map((file_id) => ({ file_id })),
    max_in_flight: 50,
  },
  { idempotencyKey: "nightly-2026-10-01" },
);

const progress = await getPipelineBatch(client, batch.id);
// { status: "running", run_count: 4000, succeeded_count: …, failed_count: …, filtered_count: …, cancelled_count: … }
bash
npx editframe pipelines batches create PIPELINE_ID --files file-ids.txt --max-in-flight 50
  • runs has 1–1000 entries with distinct file_id values, each with an optional external_id.
  • max_in_flight is 1–100,000, default 100. Other runs stay pending and start oldest first as runs finish.
  • The batch is completed when every run has finished. Unknown files return 404 { "error": "file_not_found", "file_ids": [...] }.
  • In a --files file, the CLI reads one file ID per line and ignores text after #.

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.

js
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 seal
bash
npx editframe pipelines upload dashcam.mp4 --run PIPELINE_ID --watch

uploadFragmented 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.

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.

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:

js
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");

listFilePipelineMetadata(client, fileId) lists the namespaces that steps have written on a file. Use iteratePipelineRunOutputs and iterateFilePipelineMetadata to read every page.

Limits

Errors

Validation errors return 422 with the field paths and issue codes:

json
{
  "error": "invalid_definition",
  "issues": [{ "path": "steps.gate.when.path", "code": "not_upstream" }]
}

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.

GET /api/v1/pipelines and GET /api/v1/pipeline-endpoints take include_archived=true. To be notified of run, step and batch changes, see Webhooks.