Studio Productions API

/v1/studio/productions is the platform’s own reader and writer of a studio production: a Nodaro workflow whose settings.studio holds an ordered list of shots, each with a framed still, an optional animated clip, and the plan, looks, cast bindings, frames and voice that made them.

Before these routes existed, the only code that could read that document ran in the studio app’s browser tab. Now a script, an agent and the app all read and write the same production through one contract — which is what makes “generate the still for shot 3 and keep the second candidate” something you can do from a cron job.

Every route answers 404 — never 403 — to a caller who cannot reach the production, so an id cannot be probed for existence.

The envelope

Every response is wrapped:

{ "data": { "production": { "…": "the production" } } }

The SDK unwraps it for you: a method that returns “the production” resolves to the production object itself, and a method that returns a report resolves to the whole report object.

Errors are the platform’s usual shape:

{ "error": { "code": "production_busy", "message": "…" } }

The read shape

Every route returns the production in the same shape. Its top level:

Field Meaning
id, name, version, updatedAt Identity and the change counter every write swaps on.
thumbnailUrl, shared, archived The poster frame, the share-by-link flag, the soft hide.
film, cast, folders, storyboard, music, musicPlan, cuts The production’s own settings: the film look, the roles, the timeline folders, the brief, the score and the exported cuts.
trash { count, items? } — deletes are recoverable.
pending { stills, clips, music, draft } — what is in flight right now.
shots[] In timeline order: still, clip, startFrame, endFrame, plan, beats, look, voice, …

Two levels of detail:

shot_id returns a single shot, which is the cheap way to re-read after a generation.

Result histories accumulate. Generating never replaces: a shot’s stills are every candidate it has ever had, and the same for its clips. Stills and clips are independent — deleting one never touches the other. A result is addressed by its result key: its job id when it has one, its url otherwise. Never by position.

Routes

Method Path Body Returns
GET /v1/studio/productions/skill { skill, catalog, schema, operating, generatedFrom }
POST /v1/studio/productions/validate { plan } { valid, errors[], warnings[], summary? }
GET /v1/studio/productions ?limit&cursor&includeArchived { data[], nextCursor? }
POST /v1/studio/productions { name?, plan? } { production, warnings?, summary? }
GET /v1/studio/productions/:id ?detail&shot_id { production }
POST /v1/studio/productions/:id/ops { ops[], baseVersion?, strict?, dryRun?, clientRequestId? } { production, version, rebased, receipts[], warnings[] } or the preview
POST /v1/studio/productions/:id/reconcile { landed[], pending[], failed[], warnings[], production, version }
POST /v1/studio/productions/:id/import { plan, mode: "append" } { production, warnings, summary }
POST /v1/studio/productions/:id/describe { brief, llmModel, mode?, label?, clientRequestId? } { jobId, production }
POST /v1/studio/productions/:id/generate { kind, shotId, count?, mode?, overrides?, dryRun?, clientRequestId? } { jobIds[], lane?, deduped?, production? } or the quote
POST /v1/studio/productions/:id/frame { shotId, mode?, timestamp?, target?, clientRequestId? } { production, url? }
POST /v1/studio/productions/:id/voice { shotId, text, voiceId?, voiceType?, ttsProvider?, delivery?, clientRequestId? } { production }
POST /v1/studio/productions/:id/revoice { shotId, plan, clientRequestId? } { jobId, production }
POST /v1/studio/productions/:id/music { prompt, duration?, instrumental?, vocalGender?, model?, clientRequestId? } { jobId, production }
GET /v1/studio/productions/:id/export-plan ?upscale { canExport, steps[], resultStepId, estimate, unpriced[] }
POST /v1/studio/productions/:id/share { shared } { production }
POST /v1/studio/productions/:id/unshare { production }
POST /v1/studio/productions/:id/clone { name? } { production }

There is deliberately no delete route. The soft hide the dashboard honours is an operation, and it is reversible.

Writing: operations, not patches

Every change to a production is an operation: a named, self-describing edit — rename this shot, move it, bind this cast role, keep that candidate — that the route validates and applies. The vocabulary is served, not printed here: GET …/skill returns it as the operating part, rendered from the version that is live, so what you read is what the deployment you are talking to accepts.

An operation addresses 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. Not by position — because the editor, a script and an agent can hold the same production open at once, and an index is stale the moment any of them inserts something.

const { production, version, rebased, receipts } =
  await client.studio.productions.ops(productionId, {
    // The operations, in order, in the vocabulary `GET …/skill` serves.
    ops,
    baseVersion: 7,
  })

Rules that hold for every batch:

  1. Atomic. A batch applies completely or not at all. One invalid operation refuses the whole batch and writes nothing. Up to 100 operations per batch.
  2. Rebasing by default. baseVersion is informational: a batch composed against an older version is still applied to the newest document, and the response reports rebased: true. Pass strict: true to refuse instead and get a 409 conflict carrying the current state.
  3. Ids you will address later are yours to mint. An operation that creates something — a shot, a folder, a cut, a copy — takes the id you give it rather than minting one. Your optimistic local copy and the server then agree on every id.
  4. Deletes are recoverable. A removal moves what it removed to the production’s bin, and there is an operation that restores it. Only an explicit purge destroys anything.
  5. Sharing is not an operation. Who may see the work is changed through the share route by the owner, never as a side effect of a batch that was editing something else. A batch that tries to set it is refused.
  6. Adopt the response. production is canonical after the batch — replace your copy with it rather than merging into it — and version is the next baseVersion.

receipts is one line per operation — { op, summary, ids? }, past tense, naming the target the way a person would — which is what you show someone who wants to know what an agent just did. warnings is a flat list of things worth saying that are not failures: an operation that changed nothing, a rename that rewrote four prompts.

Previewing a batch

dryRun: true asks what a batch WOULD do. The route runs the write’s own attempt — the same operations, against the stored document, in the same context, with the same refusals in the same order — and stops before the swap. The preview is not a description of the write; it IS the write, up to the swap, which is why the two cannot say different things. It is what you put in front of a person before an assistant’s edits land.

{
  "dryRun": true,
  "version": 7,
  "receipts": [
    {
      "op": "remove_shot",
      "summary": "Deleted Shot 3 (in the bin).",
      "ids": ["6f1c9b2e-…"],
      "class": "D",
      "restorable": true
    }
  ],
  "warnings": []
}

There is no production and no rebased: no document was produced, and nothing was rebased onto anything. version is the version the preview was computed AGAINST — still the production’s current one — so it is the baseVersion for the batch you send next.

A previewed receipt is the apply’s own receipt plus the two things the person deciding needs:

Field Meaning
op, summary, ids, impact The receipt the apply reports, built by the same projection — so summary reads in the same past tense there and here, and impact carries the dependency scope ({ keyframeIds, shotIds }) where the operation reports one.
class The operation’s confirmation class, read from the vocabulary’s own table: S safe, D deletes, P changes who can reach the work, $ spends.
restorable Present, and true, only where the operation put something in the bin that can be restored. Absent everywhere else — including a delete that destroys, because there would be no entry to fetch back.

The refusals the batch itself earns, the preview earns too: strict: true still answers 409, a bad operation is still op_invalid / op_target_missing with its opIndex, and a body that names an audience key is still refused. The one refusal a preview cannot reach is contention: the apply retries the swap and gives up busy where a writer keeps beating it, and a preview never swaps. What the preview never does is write — no document is stored, no version is bumped, no timestamp moves. That is narrower than “touches nothing”, deliberately: the batch is resolved through the write’s own context, so a batch that reads planned-frame inputs retains those inputs exactly as the write it previews would, one moment earlier.

Prove the flag before you send the batch. This route parses its body in strip mode, so a deployment that predates the preview DROPS dryRun and applies the batch — which means a caller that learns by sending has already written the thing it meant to ask about, and no answer read afterwards undoes that.

So ask with an empty batch first. An empty batch changes nothing on every deployment ever built, which is what makes it safe to send blind:

POST /v1/studio/productions/:id/ops
{ "ops": [], "dryRun": true }

Key on the MARKER, not on the shape:

Read the marker on the real batch’s answer too. The ping proved the deployment that answered the ping, and a fleet mid-rollout can serve the next request from a different one. An answer that carries a production instead of the marker is a batch that was APPLIED: adopt its production and version the way an ordinary apply’s caller does, and do not send it again.

Send the ping as its own bare body — no operations, no clientRequestId, nothing of the batch it is asking about. Its only job is to make the deployment say whether it previews.

Generating, and landing the results

Generation is run-then-poll. A still or clip request submits the job(s), records a pending marker on the production, and returns immediately — nothing here blocks for minutes.

One route runs both kinds — POST …/:id/generate with kind: "still" | "clip" and the shot in the body. The SDK’s generateStill / generateClip are the same call with kind filled in.

const run = await client.studio.productions.generateStill(id, "shot-2", {
  count: 2,
  clientRequestId: crypto.randomUUID(),
})

// …later, once the jobs finish:
const { landed, pending } = await client.studio.productions.reconcile(id)

frame and voice are the short ones: they wait for their job (seconds) and return the changed production. revoice and music are the long ones: they answer jobId and land through their markers.

Story → production

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)

describe writes the plan from a brief. A plan you already have skips the run entirely: validate it for free, then land it with create({ plan }) or add its scenes to an existing production with importPlan. GET …/skill serves the authoring guide, the full catalog and the plan’s JSON Schema, rendered from the version that is live — so they describe the platform you are actually talking to.

Exporting

GET …/export-plan returns the ordered steps that assemble the film — the per-shot audio merges, the join, the optional 4K finish:

{
  "canExport": true,
  "steps": [
    {
      "id": "voice-shot-2",
      "node": "merge-video-audio",
      "label": "Voice over shot 2",
      "creditModel": "merge-video-audio",
      "credits": 2,
      "params": { "videoUrl": "https://…/shot-2.mp4", "audioUrl": "https://…/vo.mp3" }
    },
    {
      "id": "combine",
      "node": "combine-videos",
      "label": "Join 3 shots",
      "creditModel": "combine-videos",
      "credits": 4,
      "params": {
        "videoUrls": [{ "fromStep": "voice-shot-2" }, "https://…/shot-3.mp4"],
        "transition": "cut",
        "audioMode": "keep"
      }
    }
  ],
  "resultStepId": "combine",
  "estimate": 6,
  "unpriced": []
}

Run the steps in order with the ordinary generation verbs and record the finished file back on the production as a cut, with the operation that adds one. A videoUrl of { "fromStep": "…" } is the output of an earlier step — substitute the url that step produced. Step ids are derived from the production, not minted, so re-planning the same film gives the same references.

canExport is false when there is nothing to assemble (fewer than two clips). estimate is null when ANY step is unpriced — the unpriced models are named in unpriced, and a partial sum presented as the total would understate the spend. Assembly is step-wise on purpose: a single call that chained several minutes-long jobs would outlive any reasonable request timeout.

Errors

Status code What to do
400 validation_error The body or the plan is wrong; path names the field.
400 op_invalid, op_target_missing On …/ops: one operation was wrong; opIndex names it (zero-based). Nothing was written — fix that operation and send the batch again.
402 insufficient_credits Not enough credits for the batch.
403 forbidden Only the owner or a workspace admin can change who a production is shared with.
404 not_found No such production for this caller.
404 op_target_missing On the generating and media routes: the shot, result or role the call named is not in the production. No opIndex — there is no batch to index into.
409 workflow_conflict strict: true and the production had moved on. Re-read and re-apply.
409 production_busy The row kept changing under the write. Re-read and retry.
413 storage_exceeded The account’s storage limit.

In the SDK these arrive as typed errors: StudioOpError (with opIndex), WorkflowConflictError (both 409s — its code tells you which), InsufficientCreditsError, StorageExceededError, NotFoundError. The mapping is by SHAPE, not by code: any 4xx whose body carries a numeric opIndex is a StudioOpError. So the same op_target_missing reaches you as a StudioOpError off …/ops and as a NotFoundError off a generating or media route, because only the batch answer indexes.

import { StudioOpError, WorkflowConflictError } from "@nodaro/sdk"

try {
  await client.studio.productions.ops(id, { ops, baseVersion, strict: true })
} catch (err) {
  if (err instanceof StudioOpError) {
    console.error(`operation ${err.opIndex} was refused: ${err.message}`)
  } else if (err instanceof WorkflowConflictError) {
    const fresh = await client.studio.productions.get(id, { detail: "full" })
    // re-compose the batch against `fresh`, then retry
  }
}

SDK reference

Method Route
skill() GET …/skill
validatePlan(plan) POST …/validate
list({ limit?, cursor?, includeArchived? }) GET …
create({ name?, plan? }) POST …
get(id, { detail?, shotId? }) GET …/:id
ops(id, { ops, baseVersion?, strict?, dryRun?, clientRequestId? }) POST …/:id/ops
reconcile(id) POST …/:id/reconcile
importPlan(id, plan, { mode? }) POST …/:id/import
describe(id, { brief, llmModel, mode?, label?, clientRequestId? }) POST …/:id/describe
generate(id, { kind, shotId, … }) POST …/:id/generate
generateStill(id, shotId, { count?, overrides?, dryRun?, clientRequestId? }) POST …/:id/generate (kind: "still")
generateClip(id, shotId, { mode?, overrides?, dryRun?, clientRequestId? }) POST …/:id/generate (kind: "clip")
frame(id, { shotId, mode?, timestamp?, target? }) POST …/:id/frame
voice(id, { shotId, text, voiceId?, … }) POST …/:id/voice
revoice(id, { shotId, plan }) POST …/:id/revoice
music(id, { prompt, duration?, … }) POST …/:id/music
exportPlan(id, { upscale? }) GET …/:id/export-plan
share(id) POST …/:id/share
unshare(id) POST …/:id/unshare
clone(id, { name? }) POST …/:id/clone

See also: API Integration, SDK Reference.

Retake one linked clip

Check GET /v1/studio/productions/capabilities for operations.retakeLinkedClips. To price a native linked take, post to /:id/generate with kind: "clip", shotId, retakeResultKey and dryRun: true. Submit the same take with the quote’s inputHash in expectedInputHash and a fresh clientRequestId. Do not include mode, overrides, or count.

The server verifies the original job request and retained endpoint images. Current frame acceptance and scene plans do not replace these inputs. A changed quote returns 409 sequence_quote_changed before submission. Missing original metadata or retained images refuses the retake; this can include older or copied takes. Each accepted submission adds one take and keeps existing history.