Anjin Media
Docs navigation

Docs

Creating a composition

One call carries the brief, the footage it may draw on and the settings that shape the result. Planning runs asynchronously, so the response is an id and a status - the plan itself arrives moments later, and you can revise it or edit it by hand before anything is rendered.

A cut is the unit of work: a prompt, one to four source groups, and the settings that decide how long the finished edit runs and how tightly it is assembled. Creating one queues a planner run and returns immediately. What comes back from that run is a plan document - an ordered list of segments with the exact source timecodes they were lifted from - not a file. Rendering is a separate, deliberate call.

The request#

POST/v1/cuts

Requires the write scope. Seven fields, three of them required:

FieldTypeDescription
promptstring · requiredThe editorial brief. One to 2,000 characters, and not blank - a prompt of nothing but whitespace is refused. Write it as a direction to an editor: what to open on, what to include, what to leave out.
source_group_idsstring[] · requiredOne to four source group ids. Each has to be owned by your account and ready. Several groups in one cut is how an edit spans more than one recording.
settingsobject · requiredThe shaping controls, listed in full below. It carries the one required setting, target_duration_s, so the object cannot be omitted.
aspectsstring[]One to three unique values from 16:9, 1:1 and 9:16. Optional - and required only when auto_render is true. Rendering later takes its own aspects, so setting this is not what produces a file.
crop_modestring · default "bars"How a wide source frame is fitted into a taller output. bars or stack - see Aspect and crop.
auto_renderboolean · default falseRender the first plan the moment it is written, without a second call. Makes aspects required, and fires once - see Rendering the first plan.
brand_kit_idstring (uuid)A brand kit you own. It is a property of the cut, not of a render request: caption styling, the logo watermark and the end card are applied to every render this cut ever produces.
curl
curl -sS https://anjin-media-api.fly.dev/v1/cuts \
  -H "authorization: Bearer $ANJIN_API_KEY" \
  -H "content-type: application/json" \
  -H "idempotency-key: 3f7c1a92-4b58-4e03-9d16-a25e8c740b31" \
  -d '{
    "prompt": "Cut a 60 second answer to the data residency question: open on the objection as the audience puts it, then the answer, then the one number that makes it land. Leave the pricing tangent out.",
    "source_group_ids": ["9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340"],
    "aspects": ["9:16", "16:9"],
    "crop_mode": "bars",
    "settings": {
      "target_duration_s": 60,
      "duration_tolerance_pct": 15,
      "min_segment_duration_s": 3,
      "pace": "standard",
      "chronology": "flexible",
      "shrink_pauses": true,
      "max_pause_ms": 1500,
      "filler_removal": false
    }
  }'
TypeScript
const API = "https://anjin-media-api.fly.dev";
const KEY = process.env.ANJIN_API_KEY!;

const res = await fetch(`${API}/v1/cuts`, {
  method: "POST",
  headers: {
    authorization: `Bearer ${KEY}`,
    "content-type": "application/json",
    "idempotency-key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    prompt:
      "Cut a 60 second answer to the data residency question: open on the " +
      "objection as the audience puts it, then the answer, then the one " +
      "number that makes it land. Leave the pricing tangent out.",
    source_group_ids: ["9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340"],
    aspects: ["9:16", "16:9"],
    crop_mode: "bars",
    settings: {
      target_duration_s: 60,
      duration_tolerance_pct: 15,
      min_segment_duration_s: 3,
      pace: "standard",
      chronology: "flexible",
      shrink_pauses: true,
      max_pause_ms: 1500,
      filler_removal: false,
    },
  }),
});
if (!res.ok) throw new Error(JSON.stringify(await res.json()));

const cut = (await res.json()) as { id: string; status: string };
console.log(cut.id, cut.status); // … planning
Python
import os, uuid, requests

API = "https://anjin-media-api.fly.dev"
HEADERS = {"authorization": f"Bearer {os.environ['ANJIN_API_KEY']}"}

res = requests.post(
    f"{API}/v1/cuts",
    headers={**HEADERS, "idempotency-key": str(uuid.uuid4())},
    json={
        "prompt": (
            "Cut a 60 second answer to the data residency question: open on "
            "the objection as the audience puts it, then the answer, then the "
            "one number that makes it land. Leave the pricing tangent out."
        ),
        "source_group_ids": ["9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340"],
        "aspects": ["9:16", "16:9"],
        "crop_mode": "bars",
        "settings": {
            "target_duration_s": 60,
            "duration_tolerance_pct": 15,
            "min_segment_duration_s": 3,
            "pace": "standard",
            "chronology": "flexible",
            "shrink_pauses": True,
            "max_pause_ms": 1500,
            "filler_removal": False,
        },
    },
)
res.raise_for_status()

cut = res.json()
print(cut["id"], cut["status"])  # … planning
202 Accepted
{
  "id": "c7d2a35f-0e64-4b19-a8d3-1f5c9b027e64",
  "status": "planning",
  "minutes_plan": "plan_creator",
  "minutes_remaining": 108.5,
  "low_minutes": false
}

The 202 means the work is queued, not done: the cut starts in planning and moves to planned or failed on its own. Composition results covers reading it back, and Webhooks covers being told instead of asking.

Idempotency

Send an idempotency-key header on every create. Planning is the expensive half of this API and a retried POST without one plans twice.

  • A key identifies the whole request - method, path and a hash of the body. Replaying it returns the original 2xx verbatim, with idempotency-replayed: true on the response.
  • Reusing a key with a different body is 422 idempotency_conflict, not a silent overwrite.
  • A duplicate arriving while the first is still in flight waits up to two seconds for the winner's response, then gets 409 idempotency_in_progress.
  • Only 2xx responses are stored. A refused request releases its key immediately, so fixing the body and retrying with the same key works.

The header is honoured on POST /v1/cuts and POST /v1/cuts/:id/revise, and not on PATCH /v1/cuts/:id/plan - that call runs synchronously and has nothing to duplicate. Errors & limits has the full list.

Settings#

FieldTypeDescription
target_duration_sinteger · required · 10–600How long the finished edit should be, in whole seconds. The planner fits to this figure and re-attempts when it lands outside the tolerance below. A non-integer, or anything outside the range, is refused.
duration_tolerance_pctnumber · 5–30 · default 15How far from the target a plan may land and still be accepted. Three fit attempts are made; a plan that still misses is kept and carries the warning duration target not met after 3 attempts.
min_segment_duration_snumber · 1–10 · default 3The shortest segment the plan may keep. A selection that tightens below this is dropped, and the drop is written into the plan's warnings naming the segments it lost.
pacerelaxed | standard | tight · default standardA bundle rather than a single dial: it sets the boundary snap window, the minimum silence that counts as a gap, and the default max_pause_ms. tight cuts closer to the words; relaxed leaves more air around them.
chronologyflexible | strict · default flexiblestrict keeps entries in source order. flexible lets the planner lead with the strongest moment and fill in behind it.
shrink_pausesboolean · default trueShortens long pauses inside a segment rather than losing the segment to them. Every shrink is recorded on the entry as a pause_shrink tightening op with the millisecond it happened at and the milliseconds removed.
max_pause_msnumber · 500–3000The longest pause left intact. Defaults from pace - 2000 relaxed, 1500 standard, 1000 tight - so set it only when you want something other than the pace you asked for.
filler_removalboolean · default falseExcises filler words, and only the ones sitting flanked by silence on both sides. Off by default because a filler cut out of the middle of a breath is audible.
economyboolean · default falseOnly has an effect together with auto_render: true. That pair sends planning through a batch request instead of the interactive fit loop: one attempt, no re-attempts, and a considerably longer wait. A batch plan that misses the duration target is kept with a warning saying so.
planner_model_tierdefault | economy · default defaultAccepted, validated and stored on the cut. It does not currently choose which model plans the cut - economy above is the flag that changes planning behaviour today.

Everything except target_duration_s has a default, so a minimal cut is a prompt, a group and a length. The other keys are for when the default answer is not the one you want.

GET /v1/cuts/:id · the settings it echoes
{
  "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", "16:9"],
    "economy": false,
    "fillerRemoval": false,
    "cropMode": "bars"
  }
}

Three of those fields - pauseFloorMs, snapWindowMs and minSilenceMs - have no request-side equivalent at all: they are the rest of the pace bundle, written out so the plan document records exactly what it was built with.

aspects, crop_mode and auto_render are top-level request fields that end up in the same stored object. They are also accepted nested inside settings, and if you send both the top-level one wins. Prefer the top-level spelling; it is the documented surface.

Aspect and crop#

Three aspects, each rendered at a fixed size from the 1080 working copy made at ingest:

aspectOutputUse
16:91920 × 1080Landscape. The shape most source footage already is.
1:11080 × 1080Square. Fits a feed without committing to a vertical crop.
9:161080 × 1920Vertical. The only aspect where crop_mode has a real decision to make.

Ask for one to three of them, unique. One render row is produced per aspect from the same plan, so the edit is identical and only the framing differs.

crop_mode

Fitting a 16:9 recording into a 9:16 frame means throwing away most of the width. crop_mode is how you say what should happen at that moment:

crop_modeApplies toWhat it does
barsdefaultLetterbox the frame rather than crop into it when a tight crop would lose someone. Nothing on screen is cut off. This is the safe answer for any framing you have not looked at yourself.
stack9:16 onlyWhen two speakers are genuinely side by side and facing the camera, crop each of them and stack the two crops vertically. Available today. On a window where the geometry does not hold, the render falls back to bars for that window rather than forcing it.

The decision is made per window rather than per render, so a single vertical output can letterbox a wide two-shot and crop tight on a single speaker moments later. stack only changes anything on a 9:16 render - it is accepted alongside other aspects in the same batch and simply has nothing to do on them.

422 Unprocessable Entity · application/problem+json
{
  "type": "https://anjin.media/errors/invalid_settings",
  "title": "Invalid Settings",
  "status": 422,
  "detail": "crop_mode: 'speaker' is not available yet - it ships with the ASD phase; use 'bars'",
  "code": "invalid_settings"
}

Rendering the first plan#

Set auto_render: true and the first plan goes straight to render, in the aspects you named, without a second call. That makes aspects required - the render has to know what to produce the moment planning succeeds - and omitting it is 422 invalid_settings with the detail aspects: required (1-3 unique values from 16:9|1:1|9:16) when auto_render is true.

request body · fire and forget
{
  "prompt": "Ninety seconds on why the migration took nine months.",
  "source_group_ids": ["9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340"],
  "auto_render": true,
  "aspects": ["9:16"],
  "settings": { "target_duration_s": 90 }
}

One-shot also means one attempt. If the auto-render is refused - a concurrency ceiling, say - the plan is still committed and the cut stays planned, with the reason recorded on the cut's error field under stage: "auto_render". A later revise does not retry it; ask for the render yourself.

GET /v1/cuts/:id · abridged, after a refused auto-render
{
  "id": "c7d2a35f-0e64-4b19-a8d3-1f5c9b027e64",
  "status": "planned",
  "error": {
    "stage": "auto_render",
    "code": "concurrency_limit",
    "message": "too many concurrent render operations - 3 in flight, 1 requested, limit 3",
    "retry_after_s": 5
  }
}

Leave auto_render off when you want a person or an agent to read the plan first. That is the shape the quickstart uses, and the one the MCP tools are built around.

Refusals#

Every error is an RFC 9457 problem document - application/problem+json, with a stable code and a human detail. Branch on code; the detail is for your logs and your users. These are the ones this endpoint produces:

codeStatusWhen
invalid_request422A top-level field is the wrong shape. The detail is one of prompt must be 1-2000 non-blank chars, source_group_ids must be 1-4 uuids or brand_kit_id must be a uuid.
invalid_settings422A settings value is out of range or not one of the allowed strings. The detail always names the key first: target_duration_s: required integer in [10, 600], pace: must be one of relaxed|standard|tight, duration_tolerance_pct: must be a number in [5, 30], shrink_pauses: must be boolean, and so on for every field in the table above.
not_found404one or more source groups not found or brand kit not found. A resource belonging to another account is invisible rather than forbidden, so this is what you get for an id you do not own as well as for one that does not exist.
group_not_ready422At least one group has not finished ingest and perception. The detail names the offenders: groups not ready: <id>, <id>. Wait for source.ready, or poll the group.
busy409plan is being generated - returned by revise and by the manual plan edit while the cut is still planning. Creating a new cut never returns this; it is a per-cut conflict, not a queue.
invalid_json400Body must be JSON. The body did not parse at all.
422 Unprocessable Entity · a missing length
{
  "type": "https://anjin.media/errors/invalid_settings",
  "title": "Invalid Settings",
  "status": 422,
  "detail": "target_duration_s: required integer in [10, 600]",
  "code": "invalid_settings"
}
422 Unprocessable Entity · footage still ingesting
{
  "type": "https://anjin.media/errors/group_not_ready",
  "title": "Group Not Ready",
  "status": 422,
  "detail": "groups not ready: 9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340",
  "code": "group_not_ready"
}

Validation runs before anything is written, so a refused create leaves no cut behind and nothing to clean up. Rate limits, the 429 family and the retry rules are on Errors & limits.

Revising the plan#

POST/v1/cuts/:id/revise

A revision is a plain-language note to the planner about the plan it just wrote. One to 500 characters, not blank. The planner re-runs against the same footage and the same settings, sees its previous entries, and writes a new plan version.

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

curl -sS "https://anjin-media-api.fly.dev/v1/cuts/$CUT_ID/revise" \
  -H "authorization: Bearer $ANJIN_API_KEY" \
  -H "content-type: application/json" \
  -H "idempotency-key: 8a10d6c4-2f79-4b53-90e1-6d4c7f2a5b38" \
  -d '{
    "instruction": "Open on the number, not the question. Cut the last segment and hold on the close."
  }'
202 Accepted
{
  "status": "planning",
  "minutes_plan": "plan_creator",
  "minutes_remaining": 108.5,
  "low_minutes": false
}

The response has no id in it - you already have one - just the status the cut has been moved into. Poll the cut's status back to planned, or subscribe to cut.planned. Do not wait on the plan field going non-null: a revising cut keeps handing back the previous version the whole time, so that test reads as finished immediately. See Composition results.

Two revisions of one cut cannot both run. The move into planning is a single atomic claim: the first request wins it, and the second is told the cut is busy rather than being allowed to queue behind it.

409 Conflict · application/problem+json
{
  "type": "https://anjin.media/errors/busy",
  "title": "Busy",
  "status": 409,
  "detail": "plan is being generated",
  "code": "busy"
}

The only status that refuses a revise is planning itself. A cut that is planned, complete, failed or even rendering accepts one - a revision during a render does not stop the render, it plans a new version alongside it, and the renders already in flight keep the version they were started on. Pin plan_version on your render requests and that stays unambiguous.

Editing the plan#

PATCH/v1/cuts/:id/plan

Not every change needs the planner. Dropping a weak segment, swapping two of them, marking one as settled - these are edits you already know the answer to, and this call applies them directly. It is synchronous, makes no planner call, and is free.

FieldTypeDescription
removenumber[]Entry numbers to drop. Applied first, so everything else in the same request works against what is left.
reordernumber[]The new running order, given as entry numbers. It must be a complete ordering of the numbers that remain after remove - every one of them, exactly once. A partial list is refused rather than interpreted.
pinnumber[]Mark entries as pinned. A pinned entry is one you have decided about; a later revision is told to leave it where it is.
unpinnumber[]Clear the pin again.

At least one of the four has to be present and non-empty, or the request is 422 invalid_request with at least one edit required.

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

curl -sS -X PATCH "https://anjin-media-api.fly.dev/v1/cuts/$CUT_ID/plan" \
  -H "authorization: Bearer $ANJIN_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "remove": [3],
    "reorder": [2, 1, 4],
    "pin": [2]
  }'

The order of operations inside one request is fixed: remove, then reorder, then pin and unpin. So a reorder in the same body as a remove lists the numbers that survive the removal - in the example above, entry 3 is dropped and 1, 2 and 4 are re-ordered as 2, 1, 4. Naming a number that is not there, or leaving one out, is refused:

422 Unprocessable Entity · application/problem+json
{
  "type": "https://anjin.media/errors/invalid_request",
  "title": "Invalid Request",
  "status": 422,
  "detail": "reorder must be a complete ordering of the remaining entry numbers",
  "code": "invalid_request"
}

The response is the new plan document, and four things have changed:

  • Entries are renumbered 1..N in their new order, so n always describes position rather than history. Your next edit uses the new numbers.
  • estimated_duration_s is recomputed from the surviving spans, minus whatever their tightening ops remove.
  • manually edited is appended to warnings - once, however many times you edit - so the plan says on its face that a person changed it.
  • The version number is bumped and the new version is recorded as a manual edit rather than a planner output.
200 OK · abridged
{
  "version": 3,
  "estimated_duration_s": 50.6,
  "title": "Where your data actually lives",
  "rationale": "Opens on the objection as asked, answers it, then lands the worked example.",
  "warnings": ["manually edited"],
  "entries": [
    {
      "n": 1,
      "role": "body",
      "source_group": "9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340",
      "camera": "A",
      "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
    },
    {
      "n": 2,
      "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": 3,
      "role": "close",
      "source_group": "9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340",
      "camera": "A",
      "source_start_ms": 1502300,
      "source_end_ms": 1507400,
      "text": "One region, one copy, and an audit trail you can hand your customer.",
      "speaker": "A",
      "speaker_name": "Priya Raman",
      "tightening": [],
      "pinned": false
    }
  ]
}

Three refusals are specific to this call: 409 busy while the cut is still planning, 409 no_plan when there is no plan to edit yet, and 409 conflict - plan changed concurrently - re-fetch and retry - when something else wrote a version while you were editing.

409 Conflict · application/problem+json
{
  "type": "https://anjin.media/errors/no_plan",
  "title": "No Plan",
  "status": 409,
  "detail": "no plan to edit yet",
  "code": "no_plan"
}

With a plan you are happy with, rendering is one more call: POST /v1/cuts/:id/renders, pinned to the version you just read. Composition results documents the plan document field by field, the render statuses and the sidecar files that come with a finished edit; pricing covers what the plans cost.