Anjin Media
Docs navigation

Docs

Composition results

What a cut looks like once you read it back: the status it is in, the plan document that says exactly which seconds of which footage the edit is made of, every version that plan has been through, and the finished renders with their signed URLs and provenance sidecars.

Everything a composition produces is readable. The plan is a document, not an opaque job; the renders name the plan version they came from; and each finished render ships captions, an edit decision list and a quality report alongside the video. That is what makes an edit auditable after the fact rather than just watchable.

Cut statuses#

Five values, and none of them is a queue position:

StatusKindWhat it means
planningworkingA planner run is in flight - the first one, or a revision. plan is null only before the first plan is committed; a revision leaves the previous version readable while it runs. This is the one status that refuses a revise or a manual edit.
plannedrestingA plan exists and nothing is running. Read it, revise it, edit it or render it. cut.planned fires on the way in.
renderingworkingA render batch has claimed the cut. It stays here until every render in the batch is terminal.
completerestingEvery render is finished and at least one of them succeeded. The cut can be revised or rendered again from here.
failedrecoverablePlanning did not complete. The cut carries an error saying which stage failed and why, and cut.failed fires. Not a dead end: a revise moves it back to planning and tries again.

The moves between them are worth knowing precisely.

  • Planning settles itself. A planner run ends in planned or failed, and each emits its webhook - cut.planned or cut.failed - on the same code path as the status write.
  • A render batch claims the cut. Requesting renders moves the cut from planned or complete into rendering as a single atomic step. Only one request can win that claim, so two concurrent batches cannot both start: the loser gets 409 busy with cut is already rendering.
  • Whichever render finishes last settles the cut. While any render in the batch is still queued or processing, the cut stays rendering. Once none is, the cut becomes complete if at least one render succeeded, and drops back to planned if every one of them failed. Aspects can finish in any order and the answer is the same.
  • A revise moves the cut back to planning from anything except planning itself. It does not cancel renders already in flight; those keep the plan version they were started on and still settle normally.

Polling any of this works, but subscribing is the right shape: Webhooks covers the four cut and render events.

Reading a cut#

GET/v1/cuts/:id

The cut row, plus the latest plan document verbatim and the version number it belongs to. Both are null only until the first plan is committed - after that there is always a plan to read.

curl
CUT_ID=c7d2a35f-0e64-4b19-a8d3-1f5c9b027e64

curl -sS "https://anjin-media-api.fly.dev/v1/cuts/$CUT_ID" \
  -H "authorization: Bearer $ANJIN_API_KEY"
200 OK · abridged plan
{
  "id": "c7d2a35f-0e64-4b19-a8d3-1f5c9b027e64",
  "account_id": "5d2f7a10-8b3e-4c96-a1d7-6e0b4f38c215",
  "prompt": "Cut a 60 second answer to the data residency question.",
  "source_group_ids": ["9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340"],
  "settings": {
    "targetDurationS": 60,
    "durationTolerancePct": 15,
    "minSegmentDurationS": 3,
    "pace": "standard",
    "chronology": "flexible",
    "shrinkPauses": true,
    "maxPauseMs": 1500,
    "pauseFloorMs": 350,
    "snapWindowMs": 400,
    "minSilenceMs": 150,
    "plannerModelTier": "default",
    "autoRender": false,
    "aspects": ["9:16"],
    "economy": false,
    "fillerRemoval": false,
    "cropMode": "bars"
  },
  "status": "planned",
  "brand_kit_id": null,
  "error": null,
  "created_at": "2026-08-17T14:06:11.204Z",
  "updated_at": "2026-08-17T14:07:02.881Z",
  "plan": {
    "version": 2,
    "estimated_duration_s": 58.4,
    "title": "Where your data actually lives",
    "rationale": "Opens on the objection as asked, answers it, then lands the worked example.",
    "warnings": [],
    "settings": {
      "targetDurationS": 60,
      "durationTolerancePct": 15,
      "pace": "standard",
      "chronology": "flexible"
    },
    "entries": [
      {
        "n": 1,
        "role": "hook",
        "source_group": "9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340",
        "camera": "A",
        "source_start_ms": 1412000,
        "source_end_ms": 1425600,
        "text": "So the question we get every single time is: where does our footage actually sit?",
        "speaker": "B",
        "tightening": [],
        "pinned": false
      },
      {
        "n": 2,
        "role": "body",
        "source_group": "9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340",
        "camera": "B",
        "source_start_ms": 1426400,
        "source_end_ms": 1458900,
        "text": "It sits in the region you pick, and it never leaves it.",
        "speaker": "A",
        "speaker_name": "Priya Raman",
        "tightening": [
          { "op": "pause_shrink", "at_ms": 1441200, "removed_ms": 620 }
        ],
        "pinned": true
      }
    ]
  },
  "plan_version": 2
}

error is normally null. On a failed cut it names the stage that failed and the message. It can also be set on a perfectly healthy planned cut with stage: "auto_render": that is the plan telling you it committed but its automatic render did not start. Read status first - error alone does not mean failure.

Listing cuts

GET/v1/cuts

A keyset-paginated list, newest first, carrying four fields per cut. Pass limit (default 50, max 100) and follow next_cursor until it comes back null. Treat the cursor as opaque.

curl
curl -sS "https://anjin-media-api.fly.dev/v1/cuts?limit=2" \
  -H "authorization: Bearer $ANJIN_API_KEY"
200 OK
{
  "data": [
    {
      "id": "c7d2a35f-0e64-4b19-a8d3-1f5c9b027e64",
      "prompt": "Cut a 60 second answer to the data residency question.",
      "status": "planned",
      "created_at": "2026-08-17T14:06:11.204Z"
    },
    {
      "id": "2b8e4f01-9d63-4a57-b1c0-8e5a37d64f92",
      "prompt": "Ninety seconds on why the migration took nine months.",
      "status": "complete",
      "created_at": "2026-08-16T09:41:55.017Z"
    }
  ],
  "next_cursor": "MjAyNi0wOC0xNlQwOTo0MTo1NS4wMTdafDJiOGU0ZjAxLTlkNjMtNGE1Ny1iMWMwLThlNWEzN2Q2NGY5Mg"
}

The plan document#

The plan is returned exactly as it was written - no reshaping between storage and response - so an agent, a dashboard and an audit all read the same bytes.

FieldTypeDescription
versionintegerWhich version of this cut's plan the document is. Starts at 1 and increases by one for every revision and every manual edit.
estimated_duration_snumberThe predicted length of the finished edit - the sum of the entry spans minus everything their tightening ops remove. The QC report checks the rendered file against it.
settingsobjectThe settings the plan was built with, in their stored camelCase form. Recorded on the document so a plan read months later still says what shaped it.
titlestringA short title for the edit, written by the planner.
rationalestringOne or two sentences on why this shape answers the brief. It is the editorial argument, and it is worth reading before you revise.
warningsstring[]Everything the planner wants you to know: a duration target it could not hit, selections dropped for being shorter than min_segment_duration_s, and manually edited once a person has changed the plan by hand. Empty is the normal case.
entriesobject[]The edit itself, in running order. Fields below.

Entries

Each entry is one continuous window of one camera angle of one source group:

FieldTypeDescription
nintegerPosition in the edit, 1..N. It is a position, not an identity - an edit that removes or reorders entries renumbers all of them.
rolehook | body | closeWhat this segment is doing: the opening, the middle, the landing.
source_groupstring (uuid)Which source group the footage came from. In a cut spanning several groups this is what tells you which recording you are looking at.
camerastringThe angle letter within that group - A to H. Single-camera groups are always A.
source_start_msintegerWhere in the source the segment starts, in milliseconds.
source_end_msintegerWhere it ends. This pair is the provenance record: every second of the output points back at a timecode in footage you supplied.
textstringWhat is said in that window, from the transcript.
speakerstring | nullThe diarization label - A, B and so on - or null where the segment has no attributed speaker.
speaker_namestring · optionalThe display name for that label, present only once you have named the speaker on the group. Absent rather than null when you have not.
tighteningobject[]The edits made inside the segment: pause_shrink and, when you asked for it, filler_remove. Each carries at_ms and removed_ms, so the finished duration is derivable from the document alone.
crosstalk_adjustedobject · optionalPresent only when a boundary was moved off a collision with another speaker talking over it. It records which edge moved, where the boundary would otherwise have been, and the words that were dropped.
pinnedbooleanSet by a manual edit. A pinned entry is one you have decided about, and a later revision is asked to leave it alone.

Nothing in an entry is generated footage. The pair of timestamps is the whole provenance story: given the plan and the source group, you can find the exact frames the edit used. The transcript those text values come from is readable in full - Uploading video covers the transcript endpoint and its four formats.

Plan versions#

GET/v1/cuts/:id/plans/:version

Every plan a cut has ever had is kept. Versions start at 1 and go up by one each time the plan changes, whoever changed it:

OriginWritten byWhat it is
llmusually version 1The planner's first answer to the brief, written when the cut was created.
revisionPOST …/reviseThe planner's answer to an instruction about the previous version. It saw its own prior entries when it wrote this one.
manual_editPATCH …/planA person removed, reordered or pinned entries. No planner ran, and the document carries manually edited in its warnings.
curl
CUT_ID=c7d2a35f-0e64-4b19-a8d3-1f5c9b027e64

curl -sS "https://anjin-media-api.fly.dev/v1/cuts/$CUT_ID/plans/1" \
  -H "authorization: Bearer $ANJIN_API_KEY"

The response is that version's plan document on its own - the same shape as the plan field above, with no cut row wrapped around it. A version that does not exist is 404 not_found, and a version that is not a positive integer is 422 invalid_request.

Renders name their version too, so a cut with several versions and several batches stays unambiguous. Pin plan_version when you request a render and a revision landing in between cannot render something you have not read.

Renders#

GET/v1/cuts/:id/renders

One render row per aspect, per batch. A three-aspect request produces three rows from one plan, each moving through its own statuses:

StatusKindWhat it means
queuedstartThe row exists and a worker has not picked it up yet.
processingworkingA worker is assembling it: pulling the angles it needs, cutting to the timeline, burning captions, mixing and encoding.
completeterminalFinished. The row now carries duration_ms, a signed url and its sidecars, and render.completed has fired.
failedterminalThe render did not produce a file. The row carries an error naming the stage, and render.failed has fired.
curl
CUT_ID=c7d2a35f-0e64-4b19-a8d3-1f5c9b027e64

curl -sS "https://anjin-media-api.fly.dev/v1/cuts/$CUT_ID/renders" \
  -H "authorization: Bearer $ANJIN_API_KEY"
200 OK
{
  "data": [
    {
      "id": "4b1f8c07-92ad-4e35-b6c1-7d0a3e58f214",
      "aspect": "9:16",
      "status": "complete",
      "plan_version": 2,
      "duration_ms": 58400,
      "url": "https://<storage-host>/media-renders/<key>.mp4?X-Amz-Expires=3600&X-Amz-Signature=<sig>",
      "sidecars": {
        "srt": "https://<storage-host>/media-renders/<key>.srt?X-Amz-Signature=<sig>",
        "edl": "https://<storage-host>/media-renders/<key>.edl.json?X-Amz-Signature=<sig>",
        "qc": "https://<storage-host>/media-renders/<key>.qc.json?X-Amz-Signature=<sig>",
        "ass": "https://<storage-host>/media-renders/<key>.ass?X-Amz-Signature=<sig>"
      }
    },
    {
      "id": "8e5c2a41-63f7-4d90-8b2e-05ca9146d738",
      "aspect": "16:9",
      "status": "processing",
      "plan_version": 2,
      "duration_ms": null
    }
  ]
}

The list is not paginated: it returns every render the cut has, across every batch and every re-render, oldest first. A render that is not complete carries no url and no sidecars at all - the keys are absent rather than null, so checking status is the reliable test.

Sidecars

A finished render is a video and the evidence for it. Four sidecars always, and a fifth whenever the edit has speech:

KeyFormatWhat it is
srttext · alwaysSubRip captions timed to the finished edit, one cue per segment. Use these for a platform that wants its own caption track.
vtttext · alwaysThe same cues as WebVTT - the format an HTML5 <track> element takes, for captioning the render in a web player without conversion.
asstext · when there is speechThe styled subtitle file that was burned into the picture, including whatever your brand kit specified. Present whenever the edit had caption cues to burn - a silent edit has no ass entry at all.
edlJSON · alwaysThe edit decision list: the plan version, the assembled timeline, the caption cues and the crop decision made for each window. This is the file to hand a person who wants to rebuild the edit somewhere else.
qcJSON · alwaysThe automated check on the finished file - duration against the plan, long black frames, loudness, decodability, captions, and the crop coverage checks when window machinery ran.
qc.json · a passing report
{
  "passed": true,
  "checks": [
    { "name": "duration", "passed": true, "detail": "probed=58400ms expected=58400±1000" },
    { "name": "black_frames", "passed": true, "detail": "0 interval(s) ≥ 0.4s" },
    { "name": "loudness", "passed": true, "detail": "measured=-14.1 target=-14±1" },
    { "name": "decodable", "passed": true, "detail": "probe ok" },
    { "name": "captions", "passed": true, "detail": "expected=true burned=true" },
    { "name": "crop_coverage", "passed": true, "detail": "covered=58400ms timeline=58400ms windows=7" }
  ]
}

qc_passed also travels in the render.completed webhook, so a pipeline can gate on it without downloading anything.

Deleting a render

DELETE/v1/renders/:id

Needs the admin scope. Only a terminal render can be deleted: one that is queued or processing is held by a worker and refuses with 409 conflict. Deleting purges the output and every sidecar from storage; the plan it came from is untouched, so the same version can be rendered again.

Usage on responses#

Responses that meter usage carry additive fields on top of their own body. On a published plan, an Enterprise contract or a live trial:

  • minutes_plan - the account's plan id, null when it has none.
  • minutes_remaining - source minutes left in the current period, to one decimal; null when the allowance is custom. Rendering never moves it: source minutes are metered once, when footage is ingested.
  • low_minutes - true at ten percent of the allowance or less.

A legacy credit account gets credits_remaining and low_credits instead. All of it is informational. None of the fields gates a request, and if the allowance cannot be read they are simply omitted rather than the call failing - which is exactly why your own code should not treat their presence as guaranteed. Show them to a person; do not branch on them. For what the plans include and cost, see pricing; for the account-level usage totals, GET /v1/usage is documented in the API reference.

From here: Webhooks replaces the polling in every example above, Errors & limits covers every problem code named on this page, and MCP & agents exposes the same reads as agent tools.