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.
Quick Links¶
- 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.
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.
| 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.
200 { "success": true, "beats_updated": 14 }
PATCH /productions/:id/beats/:beatId¶
Quota-exempt. Edit one beat.
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.
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.
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.
| 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:
- 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.
- 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¶
PATCH /productions/:id/script/scenes/:sceneId¶
PATCH /productions/:id/script/shots/:shotId¶
POST /productions/:id/script/scenes/:sceneId/regenerate¶
Token-gated. Regenerate a single scene from optional feedback, preserving the scenes around it.
Rate-limited per user per scene.
⚠️ This is not a recut.
regeneraterewrites the scene inside the production bible JSON. It does not re-run decomposition, soproduction_shots— the rows that keyframes, video and assembly actually read — are untouched. It changes the script view and nothing downstream. To change the shots, usePOST …/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.
Related Documentation¶
- 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