Studio productions
Read and edit Studio productions over REST: apply atomic operations, generate stills and clips, land finished jobs, plan the export and share or copy a film.
Available on Nodaro Cloud
The Studio productions API reads and writes the films you build in Nodaro Studio. A production is a Nodaro workflow whose settings hold an ordered list of shots. Each shot has a framed still, an optional animated clip, and the plan, looks, cast and voice that made them. A script, an AI assistant and the Studio editor all work on the same production through these routes, at the same time.
Studio productions run on Nodaro Cloud only. A deployment that does not serve them answers 404 on every route, so feature-detect once with the list route. On a self-hosted install, build the film as a workflow with nodes such as Scene, Generate Video and Combine Videos. The routes take a bearer token; OAuth app tokens need workflows:read to read and workflows:write to write. See Authentication.
Every route answers 404, never 403, when you cannot reach a production, so an id cannot be probed.
The editor's words and the document's keys
The editor and the document name the same things differently. Use the document's keys in code and the editor's words with people.
| In the editor | In the document |
|---|---|
| film | the production |
| scene, for example Scene 3 | a shots[] entry, addressed by its shotId |
| a scene's frame, and its takes | the shot's still results |
| a scene's motion, and its takes | the shot's clip results |
| the shots inside a motion | the shot's beats[] |
Endpoints
| Method | Path | What it does |
|---|---|---|
GET | /v1/studio/productions/skill | The authoring guide, the catalog, the plan's JSON Schema and the operation vocabulary. Free. |
GET | /v1/studio/productions/capabilities | Which optional operations this deployment supports. |
POST | /v1/studio/productions/validate | Validate a plan. Free, and it stores nothing. |
GET | /v1/studio/productions | List your productions, newest first. |
POST | /v1/studio/productions | Create a production, optionally from a plan. |
GET | /v1/studio/productions/:id | Read a production. It never writes. |
POST | /v1/studio/productions/:id/ops | Apply a batch of operations, or preview it. |
POST | /v1/studio/productions/:id/reconcile | Turn finished jobs into results. |
POST | /v1/studio/productions/:id/import | Add a plan's shots to the production. |
POST | /v1/studio/productions/:id/describe | Write the plan from a brief. |
POST | /v1/studio/productions/:id/generate | Generate a still or a clip for a shot, or quote it. |
POST | /v1/studio/productions/:id/frame | Take a frame from a clip. |
POST | /v1/studio/productions/:id/voice | Speak a line over a shot. |
POST | /v1/studio/productions/:id/revoice | Change the voices in a shot's clip. |
POST | /v1/studio/productions/:id/music | Generate the soundtrack. |
GET | /v1/studio/productions/:id/export-plan | The priced steps that assemble the film. |
POST | /v1/studio/productions/:id/share | Turn link sharing on or off. |
POST | /v1/studio/productions/:id/unshare | Turn link sharing off. |
POST | /v1/studio/productions/:id/clone | Copy a production. |
There is no delete route. Archiving is an operation, and it can be undone.
Read a production
Every response is wrapped as { "data": { "production": { … } } }. The SDK unwraps it: a method that returns a production resolves to the production itself. The production's top level holds:
| Field | What it holds |
|---|---|
id, name, version, updatedAt | The identity, and the counter every write checks. |
thumbnailUrl, shared, archived | The poster frame, whether link sharing is on, and whether it is archived. |
film, cast, folders, storyboard, music, musicPlan, cuts | The film look, the roles, the timeline folders, the brief, the score and the exported cuts. |
trash | { count, items? }: what was removed and can be restored. |
pending | { stills, clips, music, draft }: what is generating right now. |
shots[] | The shots in timeline order, each with still, clip, startFrame, endFrame, plan, beats, look, voice and more. |
GET /v1/studio/productions/:id takes detail=summary (the default: counts and the active URLs) or detail=full (every result in each shot's history, with the context that made it). shot_id returns one shot, the cheap way to read again after a generation. GET /v1/studio/productions takes limit, cursor and includeArchived.
Results accumulate. Generating never replaces: a shot's stills are every candidate it ever had, and the same for its clips. Deleting a still never touches the clips. A result is addressed by its result key, which is its job id when it has one and its URL otherwise, never by its position.
curl "https://app.nodaro.ai/v1/studio/productions/5d8f2b4a-7c1e-4a9d-8b3f-2e6c9a1d4f7b?detail=summary" \
-H "Authorization: Bearer $NODARO_API_KEY"import { createClient, StaticTokenAuth } from '@nodaro/sdk'
const client = createClient({
baseUrl: 'https://app.nodaro.ai',
auth: new StaticTokenAuth(process.env.NODARO_API_KEY!),
})
const production = await client.studio.productions.get(id, { detail: 'full' })
const shot = await client.studio.productions.get(id, { shotId: 'shot-2' })The SDK types the envelopes (version, rebased, receipts, warnings, a quote's credits, a run's jobIds) and leaves the production document as open JSON.
Edit with operations
Every change to a production is an operation: a named edit, such as renaming a shot, moving it, binding a cast role or keeping a candidate. POST /v1/studio/productions/:id/ops validates and applies a batch of them. The vocabulary is served, not printed here: read the operating part of GET …/skill, which always matches the deployment you are talking to.
# batch.json: { "ops": [ ...operations from GET /v1/studio/productions/skill... ], "baseVersion": 7 }
curl -X POST https://app.nodaro.ai/v1/studio/productions/5d8f2b4a-7c1e-4a9d-8b3f-2e6c9a1d4f7b/ops \
-H "Authorization: Bearer $NODARO_API_KEY" \
-H "Content-Type: application/json" \
-d @batch.jsonconst { production, version, rebased, receipts } =
await client.studio.productions.ops(id, { ops, baseVersion: 7 })The response is { production, version, rebased, receipts, warnings }. Six rules hold for every batch:
- All or nothing. One invalid operation refuses the whole batch and writes nothing. The error names it by its zero-based
opIndex. A batch holds up to 100 operations. - Rebased by default.
baseVersionis informational: a batch written against an older version is applied to the newest document, and the response saysrebased: true. Sendstrict: trueto get409 workflow_conflictinstead. - Address by stable key. A shot by its id, a folder or cut by its id, a cast row by its role slug, a result by its result key. Never by position, because the editor and a script may edit the same production at once.
- Mint your own ids. An operation that creates a shot, a folder, a cut or a copy takes the id you give it. Your local copy and the server then agree on every id.
- Deletes go to the bin. A removal moves what it removed to the production's trash, and an operation restores it. Only an explicit purge destroys anything.
- Adopt the response. Replace your copy with
productionrather than merging, and sendversionas the nextbaseVersion.
Sharing is not an operation; a batch that tries to change it is refused. receipts has one past-tense line per operation, { op, summary, ids?, impact? }: show it to anyone who wants to know what an assistant did. warnings lists things worth saying that are not failures, such as an operation that changed nothing.
Preview a batch before it lands
dryRun: true asks what a batch would do. The server runs the write itself, in the same context and with the same refusals, and stops before saving. The reply is { dryRun: true, version, receipts, warnings }, with no production. Each receipt adds class (S safe, D deletes, P changes who can reach the work, $ spends) and, where a delete can be undone, restorable: true.
A deployment that predates previews ignores dryRun and applies the batch. So prove the flag first, with an empty batch, which changes nothing on any deployment:
{ "ops": [], "dryRun": true }Only an answer that carries dryRun: true is a yes. Anything else is a no: tell the person you cannot preview here, and do not send the batch. Check the marker on the real preview's answer too, because a deployment being updated can serve the next request from another server. An answer with a production instead of the marker means the batch was applied: adopt it and do not send it again. The SDK does both checks for you and throws StudioPreviewUnavailable or StudioPreviewAppliedError.
const preview = await client.studio.productions.ops(id, { ops, baseVersion, dryRun: true })
for (const r of preview.receipts) console.log(r.class, r.summary, r.restorable ?? false)Generate stills and clips
Generation is run-then-poll. POST /v1/studio/productions/:id/generate with kind: "still" or kind: "clip" and a shotId submits the jobs, records them as pending on the production, and returns at once with { jobIds, lane?, deduped?, production? }.
- Built from the shot. The server assembles the request from the shot's plan, looks, cast and direction. A call from a script and a click in the editor therefore produce the same image.
overrideschanges one run without changing the shot. - Quote first.
dryRun: trueprices the run and writes nothing. The reply is{ dryRun: true, provider, count, credits, lane? }.credits: nullmeans the model has no price, which is unknown, not free. - Retry safely.
clientRequestId, 8 to 128 characters fromA-Za-z0-9_.:-that you mint, makes a retry safe: the same id returns the first call's jobs withdeduped: trueand charges nothing. Never retry a paid call without it. - Land the results.
POST /v1/studio/productions/:id/reconcileturns finished jobs into results and clears failed ones. It returns{ landed, pending, failed, warnings, production, version }. AGETnever writes, so reconcile when you are waiting on something. - The clip lane is chosen for you. For a clip, the model route (
generate-videoortext-to-video) follows from the shot's inputs and comes back aslane.modeonly says which set of inputs to direct from:"start"or"references".
curl -X POST https://app.nodaro.ai/v1/studio/productions/5d8f2b4a-7c1e-4a9d-8b3f-2e6c9a1d4f7b/generate \
-H "Authorization: Bearer $NODARO_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "kind": "still", "shotId": "shot-2", "count": 2, "clientRequestId": "still-shot-2-take-1" }'
curl -X POST https://app.nodaro.ai/v1/studio/productions/5d8f2b4a-7c1e-4a9d-8b3f-2e6c9a1d4f7b/reconcile \
-H "Authorization: Bearer $NODARO_API_KEY"import { isStudioGenerateEstimate } from '@nodaro/sdk'
const quote = await client.studio.productions.generateStill(id, 'shot-2', { count: 2, dryRun: true })
if (isStudioGenerateEstimate(quote)) console.log(quote.credits)
const run = await client.studio.productions.generateStill(id, 'shot-2', {
count: 2,
clientRequestId: crypto.randomUUID(),
})
// ...once the jobs finish:
const { landed, pending } = await client.studio.productions.reconcile(id)The other media routes work on one shot:
| Route | Body | What it does |
|---|---|---|
frame | { shotId, mode?, timestamp?, target?, clientRequestId? } | Takes a frame from a clip. Waits a few seconds and returns { production, url? }. |
voice | { shotId, text, voiceId?, voiceType?, ttsProvider?, delivery?, clientRequestId? } | Speaks a line over the shot. Waits and returns { production }. |
revoice | { shotId, plan, clientRequestId? } | Changes the voices in the shot's clip. Returns { jobId, production }; lands through reconcile. |
music | { prompt, duration?, instrumental?, vocalGender?, model?, clientRequestId? } | Generates the soundtrack. Returns { jobId, production }; lands through reconcile. |
clientRequestId is accepted on every paid route here, frame and voice included.
Linked clips and retakes
Some deployments support clips that run between two reviewed frames. Check GET /v1/studio/productions/capabilities first: operations.generateLinkedClips and operations.retakeLinkedClips say whether they are available.
- Quote a linked clip.
generatewithkind: "clip", theshotIdanddryRun: truechecks both frames and returns a quote with aninputHash. Send it back asexpectedInputHashon the real request. When the settings or the frames changed in between, the route answers409 sequence_quote_changedbefore it submits anything. - Retake a clip. Add
retakeResultKeyto quote a new take of an existing linked clip, with its original settings and frames. Then submit it with the quote'sinputHashand a freshclientRequestId. Do not sendmode,overridesorcount. A take whose original request or frames were not kept cannot be retaken.
From a brief to a production
Read the guide
GET /v1/studio/productions/skill returns { skill, catalog, schema, operating, generatedFrom }: how a plan is written, every picker, model and allowed value, the plan's JSON Schema, and the operation vocabulary.
Validate a plan
POST /v1/studio/productions/validate with { plan } returns { valid, errors, warnings, summary? }. Fix each error's path until valid is true. Cast names are checked against your own library.
Create the production
POST /v1/studio/productions with { name?, plan? } creates it and lands the plan. POST …/:id/import with { plan, mode: "append" } adds a plan's shots to an existing production. Neither generates media.
Or let the director write the plan
POST …/:id/describe with { brief, llmModel, mode?, label?, clientRequestId? } starts a director run and returns { jobId, production }. When the job is done, reconcile to land the drafted shots.
const { production } = await client.studio.productions.create({ name: 'The Lighthouse' })
const { jobId } = await client.studio.productions.describe(production.id, {
brief: 'A keeper, a storm, and a light that will not start.',
llmModel: 'gpt-5-mini',
mode: 'replace',
})
// poll jobId with client.jobs.getStatus, then:
await client.studio.productions.reconcile(production.id)Plan the export
GET /v1/studio/productions/:id/export-plan returns the ordered steps that assemble the film: the per-shot audio merges, the join, and an optional 4K finish with upscale. It runs nothing.
{
"canExport": true,
"steps": [
{
"id": "voice-shot-2",
"node": "merge-video-audio",
"label": "Voice over shot 2",
"credits": 2,
"params": { "videoUrl": "https://cdn.nodaro.ai/studio/shot-2.mp4", "audioUrl": "https://cdn.nodaro.ai/studio/vo.mp3" }
},
{
"id": "combine",
"node": "combine-videos",
"label": "Join 3 shots",
"credits": 4,
"params": {
"videoUrls": [{ "fromStep": "voice-shot-2" }, "https://cdn.nodaro.ai/studio/shot-3.mp4"],
"transition": "cut",
"audioMode": "keep"
}
}
],
"resultStepId": "combine",
"estimate": 6,
"unpriced": []
}Run the steps in order with the ordinary node routes, such as Merge Video & Audio and Combine Videos. A value of { "fromStep": "…" } is the output of an earlier step: substitute the URL that step produced. Then record the finished file on the production as a cut, with the operation that adds one.
canExport is false when there is nothing to assemble, that is, fewer than two clips. estimate is null when any step has no price, and unpriced names those models, because a partial sum would understate the cost.
Share and copy a production
- Share.
POST …/:id/sharewith{ shared: true }turns on the read-only share link;{ shared: false }orPOST …/:id/unshareturns it off. Only the owner or a workspace admin may change sharing (403 forbiddenotherwise). Whenoperations.revisionedSharingis available, addexpectedVersionto tie the change to the version you reviewed; a newer edit then answers409 workflow_conflict. - Allow editable copies. With
operations.editableSharedCopies, the owner can also sendallowEditableCopy: truewithshared: trueandexpectedVersion. Signed-in link viewers may then copy the plan, the prompts, the cast descriptions, the kept inputs and the take history. The bin and private review notes are not copied, and unsharing stops new copies. - Copy.
POST …/:id/clonewith{ name? }copies a production through your own view of it. The copy starts private and visible, and it uses your storage.
Export a timeline to an editing app
POST /v1/freecut-export turns a timeline of scene clips into an editing-project file, so you can finish the cut in an external editing app. It writes a FreeCut JSON (freecut-v1) or a Final Cut Pro XML (fcpxml-v1.10) file to your storage and returns its URL. It costs no credits and allows 10 requests per minute. Like the rest of this page, it needs Nodaro Cloud; other editions answer 404.
curl -X POST https://app.nodaro.ai/v1/freecut-export \
-H "Authorization: Bearer $NODARO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"format": "json",
"name": "The Lighthouse, cut 1",
"timeline": {
"musicAssetUrl": "https://cdn.nodaro.ai/studio/score.mp3",
"scenes": [
{
"sceneEntityId": "scene-1",
"compositeUrl": "https://cdn.nodaro.ai/studio/scene-1.mp4",
"shots": [{ "shot_id": "s1", "duration_seconds": 4 }]
},
{
"sceneEntityId": "scene-2",
"compositeUrl": "https://cdn.nodaro.ai/studio/scene-2.mp4",
"shots": [{
"shot_id": "s2",
"duration_seconds": 6,
"cut_decision": { "in_offset_sec": 0, "out_offset_sec": 0.5, "transition_to_next": "dissolve" }
}]
}
]
}
}'const file = await client.request('POST', '/v1/freecut-export', {
body: { format: 'json', name: 'The Lighthouse, cut 1', timeline },
}){
"url": "https://cdn.nodaro.ai/exports/7b2d4f6a/freecut-3c5e7a9b-1d2f-4a6c-8e3b-5f7a9c1e2d4b.json",
"format": "json",
"assetId": "1e3a5c7b-9d2f-4b4a-8c6e-5f7d9b1a3c2e"
}assetId is the file's entry in your library, or null when that entry could not be created; the url is valid either way.
| Field | What it holds |
|---|---|
format | Required. json for FreeCut JSON, or fcpxml for Final Cut Pro XML. |
timeline | Required. The timeline, below. |
name | A label for your records, up to 200 characters. |
timeline.scenes | Required. At least one scene, in playback order. Each scene becomes one clip on the video track. |
timeline.musicAssetUrl | The music track. An empty string, the default, leaves it out. |
timeline.narrationAssetUrl | A narration track, placed on its own audio lane. |
timeline.fadeOutDurationSec | The fade at the end of the music, 0.8 seconds by default. FreeCut JSON only. |
scene.sceneEntityId, scene.compositeUrl | Required. The scene's id and its merged clip. |
scene.shots | Required. At least one shot, { shot_id, duration_seconds, cut_decision? }. The shots' durations add up to the scene's length. |
cut_decision.in_offset_sec, cut_decision.out_offset_sec | Required in a cut decision. The trim at the start (read from a scene's first shot) and at the end (read from its last shot). |
cut_decision.transition_to_next | Required in a cut decision. hard_cut, dissolve, match_cut or overlap: the transition into the next scene. |
cut_decision.transition_duration_sec | Overrides the transition's default length: 0 for hard_cut and match_cut, 0.5 seconds for dissolve, 1 second for overlap. |
dissolve and overlap overlap the two clips by their length; hard_cut and match_cut butt them together. When no shot has a cut_decision, the export is a simple concatenation: one clip per scene, end to end, with hard cuts and the music across the whole timeline. Trims inside a scene are not applied, because each scene clip is already merged.
Share and remix records
A shot record saves the state of a shot you built: the picker choices, the prompts, the target models, the @-mentioned assets and the results. It sits behind a short, unguessable id that powers /s/:id share links and one-click remixes. These routes are separate from productions.
| Method | Path | Auth | What it does |
|---|---|---|---|
POST | /v1/shots | Bearer token | Create a record. Returns { id }. |
GET | /v1/shots/:id | None | Read a record. A private record answers 404 to everyone but its owner. Limited per IP address. |
PATCH | /v1/shots/:id | Owner | Update any fields, including visibility. |
DELETE | /v1/shots/:id | Owner | Delete the record. |
The body carries mode (single, multi-shot, frame-to-motion or storyboard), selectionState (each picker's chosen value, as { pickerNodeType: valueId } or { pickerNodeType: { field: valueId } }) and, optionally, freeText, negativePrompt, assembledPrompt, perModelPrompts, models, entityRefs and resultUrls. visibility is private by default; set it to public to make the record shareable.
resultUrls accept only plain public http or https URLs. Signed URLs are refused, so a token never leaks into a shared record. Records carry a schemaVersion. When a record names a catalog entry that no longer exists, skip it with a note instead of failing the remix.
curl -X POST https://app.nodaro.ai/v1/shots \
-H "Authorization: Bearer $NODARO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"mode": "single",
"selectionState": { "mood": "melancholy", "lighting": { "timeOfDay": "blue-hour" } },
"freeText": "a woman at a bus stop",
"models": ["nano-banana-pro"],
"visibility": "public"
}'const { id: shotId } = await client.shots.create({
mode: 'single',
selectionState: { mood: 'melancholy', lighting: { timeOfDay: 'blue-hour' } },
freeText: 'a woman at a bus stop',
})
await client.shots.update(shotId, { visibility: 'public' })
const { shot } = await client.shots.get(shotId)nodaro shots create --file shot.json --visibility public
nodaro shots get <id> --json
nodaro shots delete <id>Use it from MCP
AI assistants use the same productions through tools such as get_studio_production_skill, validate_studio_plan, create_studio_production, edit_studio_production, generate_studio_still, generate_studio_clip and plan_studio_export. Reading a production with write access also lands finished jobs. See Studio productions over MCP.
Errors
| Status | Code | Meaning |
|---|---|---|
400 | validation_error | The body or the plan is wrong. path names the field; on the export route, issues lists the problems. |
400 | op_invalid, op_target_missing | On …/ops: one operation was wrong, and opIndex names it. Nothing was written. Fix it and send the batch again. |
402 | insufficient_credits | The account cannot cover the generation. |
403 | forbidden | Only the owner or a workspace admin can change sharing. |
404 | not_found | No such production for this caller, or the deployment does not serve productions. |
404 | op_target_missing | On a generation or media route: the shot, result or role you named is not in the production. |
409 | workflow_conflict | strict: true or expectedVersion was sent and the production changed. Read it again and reapply. |
409 | production_busy | The production kept changing during the write. Read it again and retry. |
409 | sequence_quote_changed | A linked clip's settings or frames changed since the quote. Quote again. |
413 | storage_exceeded | The account is over its storage limit. |
429 | rate_limit_exceeded | More than 10 timeline exports in a minute. Wait for the time in Retry-After. |
In the SDK these arrive as typed errors: StudioOpError (with opIndex), WorkflowConflictError for both 409 codes of a write, InsufficientCreditsError, StorageExceededError and NotFoundError.
Frequently asked questions
Related
Studio productions
Pipelines
Jobs
Voice and media
Workflows
Last updated on
Recast
Recast an analyzed video with your own cast over REST: quote and buy a run, answer interactive gates, remix its audio, or import an authored script.
Voice and media
Voices, voice changing, multi-speaker recast, dubbing, video import, captions, trimming, audio tools and social publishing, all as REST endpoints on Nodaro.