Anjin Media
Docs navigation

Docs

Uploading video

Footage lives in source groups: a name and one to eight files that belong together. Create a group and either upload the bytes to a signed URL or hand over an HTTPS URL for the platform to pull.

A source group is the unit a composition draws on. One group is usually one recording - a webinar, an interview, a shoot - and its files are the angles and audio tracks of that recording. Groups are persistent: ingest a recording once and every future brief can draw on it, and a single cut can span up to four groups. Once a group is ready, its transcript is queryable and its footage is addressable by timecode.

Two ways in#

POST/v1/source-groups

One call creates the group and all of its files. Each file takes either a filename or a source_url - one or the other, never both, and never neither.

FieldTypeDescription
namestringRequired. What the group is called in the dashboard and in listings.
filesarrayRequired. One to eight file objects. More than eight is 422 invalid_request. How many of them may carry the camera role is a plan entitlement - one on Creator and Pro, two on Studio, four on Enterprise - and more than that is 402 multicam_not_entitled where enforcement is on.
files[].filenamestringThe upload path. The extension decides how the file is stored, and the response carries a signed upload URL for it. Mutually exclusive with source_url.
files[].source_urlstringThe pull path. An HTTPS URL the platform fetches for you. Mutually exclusive with filename.
files[].rolestringcamera (default) or audio. Camera files become the angles the edit can cut between; an audio file is a separate recording of the same event.
files[].labelstringOptional. Your own name for the file - defaults to file-1, file-2 and so on.

Files in one group may mix the two paths. A group is only ready when every file in it has finished, whichever way it arrived.

Path A - upload the bytes

Name the file and the response carries a signed upload URL for it. Three calls: create, PUT, confirm.

curl · 1. create the group
curl -sS https://anjin-media-api.fly.dev/v1/source-groups \
  -H "authorization: Bearer $ANJIN_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "name": "Q3 product webinar",
    "files": [
      { "filename": "q3-product.mp4", "role": "camera" }
    ]
  }'
201 Created
{
  "id": "9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340",
  "files": [
    {
      "id": "3a91c7e8-51bd-4f02-9c6a-8e2d40b7f915",
      "upload": {
        "url": "https://<storage-host>/<signed-upload-path>?token=<token>",
        "token": "<token>",
        "key": "5d2f7a10-8b3e-4c96-a1d7-6e0b4f38c215/9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340/3a91c7e8-51bd-4f02-9c6a-8e2d40b7f915/original.mp4"
      }
    }
  ],
  "minutes_plan": "plan_creator",
  "minutes_remaining": 150,
  "low_minutes": false
}

Treat upload.url as opaque and PUT the bytes to it exactly as returned. token and key come back alongside it for clients that address storage directly - the object key is deterministic, derived from your account, the group, the file and the extension.

curl · 2. upload
UPLOAD_URL="https://<storage-host>/<signed-upload-path>?token=<token>"

curl -sS -X PUT "$UPLOAD_URL" \
  -H "content-type: video/mp4" \
  --data-binary @q3-product.mp4

Then confirm. This is the call that starts the work, so nothing is queued until you make it:

POST/v1/source-groups/:id/files/:fileId/complete

curl · 3. confirm
GROUP_ID=9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340
FILE_ID=3a91c7e8-51bd-4f02-9c6a-8e2d40b7f915

curl -sS -X POST \
  "https://anjin-media-api.fly.dev/v1/source-groups/$GROUP_ID/files/$FILE_ID/complete" \
  -H "authorization: Bearer $ANJIN_API_KEY"
202 Accepted
{
  "status": "queued",
  "minutes_plan": "plan_creator",
  "minutes_remaining": 150,
  "low_minutes": false
}
409 Conflict · application/problem+json
{
  "type": "https://anjin.media/errors/upload_incomplete",
  "title": "Upload Incomplete",
  "status": 409,
  "detail": "object not found in storage",
  "code": "upload_incomplete"
}

Path B - hand over a URL

Give a file a source_url instead and the platform fetches it. There is no PUT and no confirmation call: the group goes to ingesting the moment it is created. This is the fastest way in when the footage is already somewhere you can sign a URL for.

curl
curl -sS https://anjin-media-api.fly.dev/v1/source-groups \
  -H "authorization: Bearer $ANJIN_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "name": "Q3 product webinar",
    "files": [
      {
        "source_url": "https://files.example.com/webinars/q3-product.mp4",
        "role": "camera"
      }
    ]
  }'

The URL has to satisfy three conditions:

  • HTTPS only. Plain HTTP is refused.
  • Publicly resolvable. The hostname is resolved at request time and refused if it points at a private or loopback address.
  • Direct. Redirects are not followed - the check ran against the URL you gave, so a Location header would walk straight past it. Serve the bytes at the URL itself.

A URL that fails any of the first two is refused at request time, before any row is written:

422 Unprocessable Entity · application/problem+json
{
  "type": "https://anjin.media/errors/ssrf_blocked",
  "title": "Ssrf Blocked",
  "status": 422,
  "detail": "source_url must use https",
  "code": "ssrf_blocked"
}

No credentials are sent with the fetch, so use a pre-signed URL if the object is not public. Signed URLs expire - make sure yours outlives the download.

Several files in one group

Up to eight files per group, each a camera angle or an audio track. How many camera files one group may hold is a plan entitlement - one on Creator and Pro, two on Studio, four on Enterprise - and every camera file's minutes count towards the allowance. Multi-camera groups are synchronised during perception and given angle letters from A in the order they were given, and the planner may cut between them:

request body
{
  "name": "Panel - three cameras and a lapel mix",
  "files": [
    { "filename": "cam-wide.mp4", "role": "camera", "label": "wide" },
    { "filename": "cam-left.mp4", "role": "camera", "label": "left" },
    { "filename": "cam-right.mp4", "role": "camera", "label": "right" },
    { "filename": "board-mix.wav", "role": "audio", "label": "lapel mix" }
  ]
}

Formats and limits#

Seven extensions are accepted:

mp4 · mov · mkv · wav · mp3 · m4a · aac

Anything else on a filename is refused at request time with 422 unsupported_extension, and the detail names the extension it rejected. A source_url is not extension-checked when you post it - the file is fetched and probed instead, so an undecodable source fails during ingest rather than at the create call.

Per file, the ceilings are:

  • 16 GB of bytes.
  • 4 hours of duration.
  • 4K on the short edge - 2160 pixels, whichever way the frame is oriented.

These are enforced during ingest rather than at the create call, because two of the three are only knowable once the file has been probed. A file over any of them fails, and its group fails with it.

A single cut can draw on up to four groups. See Creating a composition for what happens once your footage is ready.

Ingest statuses#

A group walks four states on the way to being usable, and a fifth if something goes wrong:

StatusKindWhat it means
uploadingstartThe group exists and is waiting for bytes. A group whose files all came from a source_url never sits here.
ingestingworkingAt least one file is being fetched, probed and transcoded. A mezzanine and an audio track are produced per file.
perceivingworkingEvery file is transcoded. The group is now being transcribed with word-level timing, diarized into speakers, and - for a multi-camera group - synchronised across angles.
readyterminalUsable in a cut. duration_ms is filled in and source.ready fires.
failedterminalIngest or perception did not complete. The group carries an error and source.failed fires.

Each file has its own status too - pending, uploaded, ingesting, ready or failed - and the group moves on to perception only once every one of them is ready.

GET/v1/source-groups/:id

200 OK · abridged
{
  "id": "9f4b1d2e-6c3a-4d51-8f0b-2a7c5e91d340",
  "name": "Q3 product webinar",
  "status": "perceiving",
  "asr_status": "processing",
  "vad_status": "complete",
  "sync_status": "skipped",
  "duration_ms": null,
  "camera_count": 1,
  "total_duration_s": 3541.2,
  "source_files": [
    {
      "id": "3a91c7e8-51bd-4f02-9c6a-8e2d40b7f915",
      "role": "camera",
      "label": "file-1",
      "status": "ready"
    }
  ]
}

Three fields report the perception stages separately, so a group sitting in perceiving tells you which one it is waiting on: asr_status (transcription and diarization), vad_status (speech and silence detection) and sync_status - pending, complete, failed or skipped. A group with two or more camera files is held out of ready until its sync_status is complete; a single-camera group never runs the stage at all and stays skipped. Rather than polling, register an endpoint for source.ready and source.failed - see Webhooks.

Deleting a group

DELETE/v1/source-groups/:id

Deletion needs the admin scope. It removes the group and its files and purges the stored bytes - originals, mezzanines, audio tracks and proxies alike.

curl
curl -sS -X DELETE \
  "https://anjin-media-api.fly.dev/v1/source-groups/$GROUP_ID" \
  -H "authorization: Bearer $ANJIN_API_KEY"
202 Accepted
{ "status": "deleted" }

Only ingesting and perceiving refuse: a worker is holding those files, and pulling them out from under it would strand the job. Wait for the group to reach ready or failed, then delete. A group still uploading deletes cleanly - nothing is running against it yet - and any bytes already PUT to a signed URL but never confirmed are purged with it, so an abandoned upload does not linger in storage.

409 Conflict · application/problem+json
{
  "type": "https://anjin.media/errors/conflict",
  "title": "Conflict",
  "status": 409,
  "detail": "group is ingesting or perceiving - wait for it to finish or fail",
  "code": "conflict"
}

Transcripts#

GET/v1/source-groups/:id/transcript

Perception produces a word-timed, speaker-labelled transcript of the group. It is what the planner reads, and you can read it too - for search, for captions, or to check what is actually in an archive before briefing against it.

curl
curl -sS \
  "https://anjin-media-api.fly.dev/v1/source-groups/$GROUP_ID/transcript?format=segments" \
  -H "authorization: Bearer $ANJIN_API_KEY"

Four formats, chosen with ?format=. Anything else is 422 invalid_format:

formatReturnsWhat you get
segmentsJSON · defaultSpeaker-labelled passages with millisecond bounds and the word range each one spans. This is what the planner reads.
wordsJSONEvery word with its own timing, confidence, speaker label and a filler flag, plus the detected language.
srttextSubRip captions for the whole source, returned as plain text.
vtttextWebVTT captions for the whole source, returned as plain text.
format=segments · 200 OK
{
  "segments": [
    {
      "idx": 412,
      "speaker": "B",
      "start_ms": 1412000,
      "end_ms": 1425600,
      "text": "So the question we get every single time is: where does our footage actually sit?",
      "word_start_idx": 3980,
      "word_end_idx": 3995
    }
  ]
}
format=words · 200 OK
{
  "language": "en",
  "words": [
    {
      "idx": 3980,
      "text": "So",
      "start_ms": 1412000,
      "end_ms": 1412140,
      "confidence": 0.98,
      "speaker": "B",
      "is_filler": false
    }
  ]
}
format=srt · 200 OK
1
00:00:04,120 --> 00:00:08,940
Right, let us get started - thanks for joining.

2
00:00:09,180 --> 00:00:14,600
Today is about what shipped in Q3 and what that means for your team.

Speaker labels are diarization labels - A, B and so on - not names. Give them display names with PATCH /v1/source-groups/:id/speakers and the planner will use those names when it writes a cut plan.

The transcript is only available once transcription has finished. Ask earlier and the request tells you what stage it is at:

409 Conflict · application/problem+json
{
  "type": "https://anjin.media/errors/not_ready",
  "title": "Not Ready",
  "status": 409,
  "detail": "transcript is processing",
  "code": "not_ready"
}

With a ready group in hand, the quickstart takes you from here to a rendered file in five more calls; Authentication covers the scopes each call needs, and Errors & limits covers every problem code named on this page. For what an archive of ready groups is worth once you have one, see Working with an archive.