Skip to content

Content Studio — Production Pipeline API Reference

Summary

This is the API for turning a script into shots. It covers the route families introduced by the 26.32 pipeline revision — the model in which a production is a chain of scenes, each scene holds beats (the verbatim words and actions), and coverage derives shots from those beats rather than from a keyframe list.

Five families:

  • Scenes and versions — cut a scene again without losing the first cut
  • Beats — the verbatim dialogue and action a scene is made of
  • Coverage — derive shots from beats
  • Ensembles — crowds and groups as first-class entities
  • Entity versions — edit a character's appearance without invalidating approved art
  • Script editing — granular edits and the audit trail

For universal assets, worlds, brands and product placement, see the companion Proteus API reference.

Feature architecture: Scenes, Beats and Coverage.

  • Authentication
  • Token cost
  • Scenes and versions
  • Beats
  • Coverage
  • Ensembles
  • Entity versions
  • Script editing
  • Errors

Authentication

All endpoints require a bearer JWT and resolve the caller's organization.

Authorization: Bearer <jwt>
X-Organization-Id: <org-uuid>

The organization may also be supplied as the organization_id query parameter or the magieva-selected-org-id cookie. Precedence is header → query → cookie.

Every route under /productions/:id verifies the production belongs to the caller's organization before doing anything else. A production owned by another organization reads as 404, not 403 — existence is not disclosed.

Token cost

Most of this surface is metadata: it writes rows, flips pointers and reorders things, and calls no AI provider. Those endpoints are quota-exempt. The endpoints that run a model are gated by the standard quota middleware and answer 402 when the organization is out of tokens.

Endpoint Cost
POST /productions/:id/cover Token-gated — narrative LLM, one call per scene
POST /productions/:id/scenes/:sceneId/recut Token-gated — narrative LLM
POST /productions/:id/script/scenes/:sceneId/regenerate Token-gated — narrative LLM
POST /productions/:id/ensembles/:ensembleId/options/generate Token-gated — image generation
Everything else on this page Quota-exempt

A token-gated request places a hold for the estimated cost before the model runs, and settles it against actual usage when the work completes. A request that would exceed the organization's balance is refused with 402 before any provider is called.


Scenes and versions

A scene has a version chain. v01 is the ingested set of shots; v02 is the same dialogue re-cut into an entirely new set of shots. Both survive. Only one is is_current at a time, so downstream stages (keyframes, video, assembly) read exactly one cut per scene_number.

The words are provably unchanged across a recut because beats are copied verbatim, never regenerated.

GET /productions/:id/scenes/:sceneId/versions

Every cut of this scene's scene_number, newest first.

200

{
  "scene_number": 4,
  "versions": [
    {
      "scene_id": "…",
      "scene_version": 2,
      "is_current": true,
      "recut_intent": "tighter, more reaction shots",
      "recut_at": "2026-08-05T18:41:00Z",
      "shot_count": 11
    },
    { "scene_id": "…", "scene_version": 1, "is_current": false, "shot_count": 7 }
  ],
  "total": 2
}

404 if the scene does not exist in this production.

POST /productions/:id/scenes/:sceneId/recut

Token-gated. Fork this scene into a new cut and cover it afresh.

{ "intent": "tighter, more reaction shots", "pace": "brisk" }
Field Required Notes
intent Free-text guidance passed to coverage and recorded on the new row as recut_intent
pace brisk | measured | standard. Anything else falls back to the production's resolved pace

Pace is a coverage-density control: brisk means more setups and more reactions, measured means longer takes and fewer setups.

200

{
  "success": true,
  "scene_id": "…",
  "from_scene_id": "…",
  "shots": 11,
  "duration_seconds": 48.2,
  "repaired": false
}

409 when the scene has no beats:

{ "error": "This scene has no beats to re-cut. Re-ingest the script so its dialogue and action are recorded as beats first." }

Atomicity. The fork and the coverage are two steps. If coverage fails after the fork succeeded, the current-version pointer is rolled back to the previous cut, so a failed recut never leaves the producer looking at an empty scene they did not ask for.

POST /productions/:id/scenes/:sceneId/make-current

Quota-exempt — flips a pointer. Switch which cut of a scene is live.

200 { "success": true, "scene_id": "…" }

Making an older cut current is how you undo a recut. The newer cut is not deleted.


Beats

A beat is one unit of what happens in a scene: a line of dialogue, or an action. content is verbatim — the ingest records what the script says, and nothing in the pipeline rewrites it.

A dialogue beat must have a speaker — a character_id, or an ensemble_id for a group speaking in unison. An action beat may be unattributed.

GET /productions/:id/scenes/:sceneId/beats

200

{
  "beats": [
    {
      "id": "…",
      "beat_number": 1,
      "kind": "action",
      "content": "Rain hammers the loading dock.",
      "character_id": null,
      "ensemble_id": null,
      "est_duration_ms": 2400,
      "actual_duration_ms": null
    },
    {
      "id": "…",
      "beat_number": 2,
      "kind": "dialogue",
      "content": "You're late.",
      "character_id": "…",
      "emotion": "flat",
      "delivery_notes": "under her breath",
      "est_duration_ms": 900,
      "actual_duration_ms": 1120
    }
  ],
  "total": 2,
  "total_duration_ms": 3520
}

Two durations, and the difference matters. est_duration_ms is a plan-time estimate from word count and speaking rate. actual_duration_ms is measured from the rendered TTS audio. Shot durations should be reconciled against the actual value — video assembly trims each clip to the scripted duration, so relying on an estimate truncates the delivered video.

POST /productions/:id/scenes/:sceneId/beats/estimate

Quota-exempt — arithmetic, not a model call. Recompute est_duration_ms for every beat in the scene from word count, speaking rate and pauses.

{ "speaking_rate_wpm": 150 }

200 { "success": true, "beats_updated": 14 }

PATCH /productions/:id/beats/:beatId

Quota-exempt. Edit one beat.

{ "content": "You're late again.", "delivery_notes": "under her breath", "emotion": "flat" }

All three fields are optional, but content may not be set to an empty string — 400 content cannot be empty. A beat with no content is not a beat.


Coverage

Coverage is the step that turns beats into shots. It is deliberately separate from forking a scene: forking is cheap and reversible, covering costs a model call.

POST /productions/:id/cover

Token-gated. Cover every scene in the production.

{ "pace": "standard" }

200 { "success": true, "scenes_covered": 22, "shots": 147 }

This is the "the shots are nonsense, redo them" action. Unlike a recut it does not fork — it re-covers the current cut of each scene in place.


Ensembles

An ensemble is a crowd or group as a first-class entity. Before 26.32 the script ingest had exactly two buckets, character or prop, so "the Disciples" was forced into one character slot whose appearance described twelve people — while Andrew and Philip, named members of that same group, became unrelated character rows with no link back to it.

An ensemble is neither. It has a crowd_size and a shared appearance, it renders as a group reference rather than a single-figure sheet, and its named members stay full characters joined through production_ensemble_members. Shots therefore carry two participant lists — individuals and groups.

GET /productions/:id/ensembles

200 { "ensembles": [ … ], "total": 3 }

POST /productions/:id/ensembles

Quota-exempt — writes a row, renders nothing.

{
  "name": "The Disciples",
  "crowd_size": 12,
  "appearance": "first-century Judean labourers, sun-worn linen, bare feet",
  "description": "Travelling companions, wary of the crowd",
  "scene_context": "The upper room"
}
Field Required Notes
name
crowd_size Must be > 1 if given. Nullable — "a crowd" is a legitimate answer when the script never counts them
appearance The shared look: era, class, wardrobe, physical type. Not a description of any one member
description Narrative note
scene_context Where and when the group appears

201

PATCH /productions/:id/ensembles/:ensembleId

Quota-exempt. Update name, crowd size, appearance, description or scene context.

DELETE /productions/:id/ensembles/:ensembleId

Quota-exempt.

GET /productions/:id/ensembles/:ensembleId/design

The design state — design_status, whether a look is approved, and the approved reference image.

design_status is one of pending, generating, reviewing, approved, needs_refinement.

GET /productions/:id/ensembles/:ensembleId/options

Generated look options awaiting review.

POST /productions/:id/ensembles/:ensembleId/options/generate

Token-gated — image generation. Generate group-reference options for the shared appearance.

POST /productions/:id/ensembles/:ensembleId/options/:optId/approve

Quota-exempt. Approve one option as the ensemble's reference; sets design_status to approved.

POST /productions/:id/ensembles/:ensembleId/options/:optId/reject

Quota-exempt.

POST /productions/:id/ensembles/:ensembleId/members

Quota-exempt — writes a join row. Attach an existing character as a named member of the group.

{ "character_id": "…" }

DELETE /productions/:id/ensembles/:ensembleId/members/:characterId

Quota-exempt. Detach a member. The character row itself survives.

POST /productions/:id/ensembles/:ensembleId/materialize

Quota-exempt — creates character rows, renders nothing.

Turn some of the crowd into individually tracked people. Each materialised member becomes a real character row that can be cast, generated and approved on its own.

{ "tracked_size": 8 }
Field Required Notes
tracked_size Defaults to 8. Hard ceiling 24

Why there is a ceiling. Each tracked member is a real character row that wants its own concept generation, its own approval and its own continuity — so "track all 500" is not a crowd setting, it is a request to spend 500 image generations and hand the producer 500 cards to approve. Past roughly two dozen a group reads as a mass anyway. Requests above the ceiling are refused with that explanation rather than silently clamped, because giving someone 24 of the 500 they asked for without saying so is worse than saying no.


Entity versions

Two gaps this closes:

  1. There was no way to edit a character's appearance or a location's description. The routes were create and delete only, so the text driving every render came straight from the script ingest with no override.
  2. There was no version chain, so even once editing existed, changing a description would silently invalidate the concepts already approved against the old one, with no record of what changed.

A new version is a new row carrying its own description and reference. The previous version keeps its approved images, viewable forever; option tasks stay attached to the row that produced them.

:entityKind is one of characters, locations, props, ensembles. Anything else is 404 Unknown entity kind. The set is fixed so the table name can never be an injected identifier.

Characters and ensembles version their appearance; locations and props version their description. That is the field that actually drives generation for each type.

GET /productions/:id/:entityKind/:entityId/versions

200

{
  "lineage_id": "…",
  "versions": [
    {
      "entity_id": "…",
      "entity_version_no": 2,
      "is_current": true,
      "appearance_source": "authored",
      "version_note": "aged up, greying at the temples"
    },
    { "entity_id": "…", "entity_version_no": 1, "is_current": false, "appearance_source": "script" }
  ],
  "total": 2
}

appearance_source marks a description a human has taken ownership of. A later re-ingest of the script updates script rows and leaves authored ones alone — your edits are not clobbered by a re-ingest.

POST /productions/:id/:entityKind/:entityId/versions

Quota-exempt. Create the next version of an identity.

{
  "description": "aged up, greying at the temples",
  "name": "Marcus (older)",
  "reference_asset_id": "…",
  "version_note": "for the epilogue"
}

At least one of description, name or reference_asset_id must be present, otherwise 400:

{ "error": "A new version needs at least one change — a description, a name, or a reference image." }

POST /productions/:id/:entityKind/:entityId/versions/make-current

Quota-exempt — flips a pointer. Switch which version of the identity is live.


Script editing

Granular edits to the script without a full regeneration. These handlers update the production bible's script in place and increment its version. Every edit is written to the script_edits audit trail.

PATCH /productions/:id/script/dialogue/:dialogueId

{ "line": "new dialogue text", "emotion": "optional" }

PATCH /productions/:id/script/scenes/:sceneId

{ "title": "optional", "description": "optional" }

PATCH /productions/:id/script/shots/:shotId

{ "description": "optional", "cameraAngle": "optional" }

POST /productions/:id/script/scenes/:sceneId/regenerate

Token-gated. Regenerate a single scene from optional feedback, preserving the scenes around it.

{ "feedback": "optional guidance" }

Rate-limited per user per scene.

⚠️ This is not a recut. regenerate rewrites the scene inside the production bible JSON. It does not re-run decomposition, so production_shots — the rows that keyframes, video and assembly actually read — are untouched. It changes the script view and nothing downstream. To change the shots, use POST …/recut.

POST /productions/:id/script/scenes/:sceneId/revert

Quota-exempt — restores a stored prior scene. Reverts the last regeneration using the audit trail.

GET /productions/:id/script/edits

The edit history.

GET /productions/:id/script/versions

The script's version chain.


Errors

Status Meaning
400 Malformed id or body; empty beat content; a version with no change
401 Missing or invalid authentication
402 Token quota exceeded (token-gated endpoints only)
404 Not found — including resources owned by another organization, and unknown entity kinds
409 Conflict — recutting a scene with no beats
500 Server error

Error bodies are { "error": "…" }, sometimes with a detail field.


  • Scenes, beats and coverage — the feature architecture behind this API
  • Proteus API — universal assets, worlds, brands and product placement
  • Intelligence tokens — how token-gated endpoints are billed and quoted