# Studio productions

> Create Studio productions from a plan, edit them with operations, generate stills and clips, review planned frames, and share or copy them from TypeScript.

Source: https://nodaro.ai/docs/developers/sdk/studio

A **Studio production** is a Nodaro workflow whose settings hold the shots of a film. Each shot has a framed still, an optional animated clip, and the plan, looks, cast bindings and voice that made them. **`client.studio`** reads and writes productions, so a script, an AI assistant and the Studio app work on one production. **`client.shots`** stores the shared shot records behind share links. The methods call the [Studio productions REST API](https://nodaro.ai/docs/developers/api/studio-productions). See [Studio productions over MCP](https://nodaro.ai/docs/mcp/studio-productions) for the authoring guide.

Studio productions run on Nodaro Cloud. Where the routes are not served, every method throws `NotFoundError`. To check once, call `client.studio.productions.list()`: a deployment with productions answers an empty page, and one without them throws `NotFoundError`.

## Two sets of methods

`client.studio` has two layers. Both work on the same productions.

| Layer | Use it for | Returns |
| --- | --- | --- |
| `client.studio.productions.*` | The production document: create from a plan, edit with operations, generate stills and clips, add voice and music, share and copy | The payload itself |
| `client.studio.*` | Planned frames: capabilities, keyframe generation and review, bundles, editor saves and link sharing with revision checks | The API's `{ data }` envelope |

**The envelopes are typed; the production document is not.** A production, a shot and an operation are open JSON, `Record<string, unknown>`. Everything you branch on is typed: `version`, `rebased`, `receipts`, `warnings`, a quote's `credits` and a run's `jobIds`. The operation vocabulary comes from the server: read it from `skill()`.

## Methods of client.studio.productions

| Method | What it does |
| --- | --- |
| [`skill()`](#productionsskill) | Read the authoring guide, catalog, plan schema and operating guide |
| [`validatePlan(plan)`](#productionsvalidateplanplan) | Check a plan, for free |
| [`list(opts?)`](#productionslistopts) | List your productions |
| [`get(productionId, opts?)`](#productionsgetproductionid-opts) | Read a production |
| [`exportPlan(productionId, opts?)`](#productionsexportplanproductionid-opts) | Plan and price the export steps |
| [`create(input?)`](#productionscreateinput) | Create a production, optionally from a plan |
| [`ops(productionId, input)`](#productionsopsproductionid-input) | Apply, or preview, a batch of operations |
| [`reconcile(productionId)`](#productionsreconcileproductionid) | Land finished generations |
| [`importPlan(productionId, plan, opts?)`](#productionsimportplanproductionid-plan-opts) | Add a plan's scenes to a production |
| [`describe(productionId, input)`](#productionsdescribeproductionid-input) | Turn a brief into scenes |
| [`generate()`, `generateStill()`, `generateClip()`](#generate-stills-and-clips) | Frame or animate a shot |
| [`frame(productionId, input)`](#productionsframeproductionid-input) | Take a still from a shot's clip |
| [`voice(productionId, input)`](#productionsvoiceproductionid-input) | Speak a shot's line |
| [`revoice(productionId, input)`](#productionsrevoiceproductionid-input) | Recast the voices of a shot's clip |
| [`music(productionId, input)`](#productionsmusicproductionid-input) | Score the film |
| [`share()`, `unshare()`, `clone()`](#share-and-copy) | Open or close the share link, or copy the production |

## client.studio.productions

### productions.skill()

Returns the authoring guide, the full catalog, the plan's JSON Schema and the operating guide, rendered from the version the server runs. It is free.

```ts
skill(): Promise<{ skill: string; catalog: string; schema: Record<string, unknown>; operating: string; generatedFrom: object }>
```

```ts
const { skill, schema, operating } = await client.studio.productions.skill()
```

`operating` lists the operations `ops()` accepts. Read it at run time instead of hard-coding the vocabulary.

### productions.validatePlan(plan)

Checks a plan before it becomes a production. It is free, stores nothing, and resolves cast names against your library. Loop on `errors` until `valid` is `true`, then call `create({ plan })`.

```ts
validatePlan(plan: Record<string, unknown>): Promise<{
valid: boolean
errors: Array<{ path: string; message: string; hint?: string }>
warnings: Array<{ path: string; message: string; hint?: string }>
summary?: { name?: string; scenes: number; shots: number; cast: number; bound: number }
}>
```

<TypeTable
type={{
plan: { type: 'Record<string, unknown>', required: true, description: "The production plan, in the shape of skill().schema." },
}}
/>

```ts
const check = await client.studio.productions.validatePlan(plan)
if (!check.valid) console.log(check.errors)
```

`summary.bound` counts the cast entries that matched a character in your library.

### productions.list(opts?)

Lists your productions, newest first.

```ts
list(opts?: { limit?: number; cursor?: string; includeArchived?: boolean }): Promise<{ data: StudioProduction[]; nextCursor?: string }>
```

<TypeTable
type={{
limit: { type: 'number', description: "The page size." },
cursor: { type: 'string', description: "The nextCursor of the previous page." },
includeArchived: { type: 'boolean', default: 'false', description: "Include the archived productions the dashboard hides." },
}}
/>

```ts
const { data: productions } = await client.studio.productions.list({ limit: 20 })
```

### productions.get(productionId, opts?)

Reads a production. It is a pure read and never lands a finished job, so call `reconcile()` first when you wait for one.

```ts
get(productionId: string, opts?: { detail?: "summary" | "full"; shotId?: string }): Promise<StudioProduction>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "The production id." },
detail: { type: '"summary" | "full"', default: '"summary"', description: "summary returns counts and the active URLs. full adds every result with the context that made it." },
shotId: { type: 'string', description: "Read one shot only: the cheap read after a generation." },
}}
/>

```ts
const production = await client.studio.productions.get(productionId, { detail: "full" })
```

### productions.exportPlan(productionId, opts?)

Returns the ordered steps that assemble the film, with their prices. It runs nothing and spends nothing: run the steps yourself with the ordinary node methods.

```ts
exportPlan(productionId: string, opts?: { upscale?: boolean }): Promise<{
canExport: boolean
steps: Array<{ id: string; label: string; node: string; creditModel: string; credits: number | null; params: Record<string, unknown> }>
resultStepId: string | null
estimate: number | null
unpriced: string[]
}>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "The production id." },
upscale: { type: 'boolean', default: 'false', description: "Plan a 4K finish too. It costs more, so it is always your choice." },
}}
/>

```ts
const plan = await client.studio.productions.exportPlan(productionId)
console.log(plan.canExport, plan.estimate)
for (const step of plan.steps) console.log(step.id, step.label, step.node, step.credits)
```

`canExport` is `false` when the production has fewer than two clips. `estimate` is `null` when any step has no price, because a partial sum would understate the cost, and `unpriced` names those steps' models. Each step's `node` is a node type, such as `merge-video-audio`, `combine-videos` or `video-upscale`.

### productions.create(input?)

Creates a production, and optionally lands a plan in the same call.

```ts
create(input?: { name?: string; plan?: Record<string, unknown> }): Promise<{
production: StudioProduction
warnings?: Array<{ path: string; message: string; hint?: string }>
summary?: { shotsAdded: number; castEnrolled: number; castBound: number }
}>
```

<TypeTable
type={{
name: { type: 'string', description: "The production's name." },
plan: { type: 'Record<string, unknown>', description: "A validated plan to land." },
}}
/>

```ts
const { production, summary } = await client.studio.productions.create({ name: "Rome chase", plan })
```

### productions.ops(productionId, input)

Applies a batch of **operations** to a production. Every change is an operation, addressed by a stable key, such as a shot id, a role slug or a result's job id, never by position.

```ts
ops(productionId: string, input: StudioOpsRequest): Promise<StudioOpsResponse>
ops(productionId: string, input: StudioOpsRequest & { dryRun: true }): Promise<StudioOpsDryRunResponse>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "The production id." },
ops: { type: 'unknown[]', required: true, description: "The operations, applied in order. At most 100 per request. The vocabulary is in skill().operating." },
baseVersion: { type: 'number', description: "The version you composed the batch against. By default it is informational: the batch applies to the newest version, and the answer says rebased." },
strict: { type: 'boolean', description: "Refuse to rebase. An older baseVersion then fails with 409 workflow_conflict." },
clientRequestId: { type: 'string', description: "Your token for this batch, 8 to 128 characters, so a retry is not applied twice." },
dryRun: { type: 'true', description: "Preview what the batch would do, without writing. Write the literal true in the call itself." },
}}
/>

```ts
const result = await client.studio.productions.ops(productionId, {
ops: [/* operations from the operating guide */],
baseVersion: version,
clientRequestId: crypto.randomUUID(),
})
version = result.version // carry it forward as the next baseVersion
for (const r of result.receipts) console.log(r.summary)
```

- **Atomic.** One bad operation refuses the whole batch with a [`StudioOpError`](https://nodaro.ai/docs/developers/sdk/errors#studio-batches) whose `opIndex` names it, and nothing is written.
- **Rebased.** Two people can edit one production at once. A batch composed against an older version still applies to the newest one, and `rebased` is `true`.
- **Receipts.** `receipts` has one past-tense line per operation, such as "Deleted take 2 of Shot 1 (in the bin)". Where an operation's effect reaches past what it names, its `impact` lists the `keyframeIds` and `shotIds` to refresh.
- **Adopt the answer.** Replace your copy with `production` and carry `version` forward. Do not merge into your old copy.

**Preview a batch.** With `dryRun: true`, the answer says what the batch **would** do, so a person can approve an assistant's edits first. It has `dryRun`, `version`, `receipts` and `warnings`, and no `production`. Each receipt adds `class`: `S` safe, `D` deletes, `P` changes who can reach the work, `$` spends credits. It also adds `restorable`, which is present only when the operation put something in the bin. Read it as `restorable ?? false`.

Write `dryRun: true` as a literal in the call's own object. Passed through a variable, it widens to `boolean`, and the call types as an apply while it still previews.

A preview sends **two** requests: first an empty batch that proves the deployment can preview, then your batch. A deployment that cannot preview would otherwise apply your batch without warning. Two errors can result:

```ts

try {
const preview = await client.studio.productions.ops(productionId, { ops, baseVersion, dryRun: true })
for (const r of preview.receipts) console.log(r.class, r.summary, r.restorable ?? false)
} catch (err) {
if (err instanceof StudioPreviewUnavailable) {
// Nothing was sent. Say that no preview is available; do not apply the batch instead.
} else if (err instanceof StudioPreviewAppliedError) {
// The batch was applied. Adopt err.applied.production and err.applied.version.
// Do not send it again. When err.applied is undefined, read the production first.
} else {
throw err
}
}
```

### productions.reconcile(productionId)

Lands every generation that finished since you last looked, and reports what is still running. It is the one call that turns finished jobs into results without the app open, and it writes only when something landed.

```ts
reconcile(productionId: string): Promise<{
landed: string[]
pending: string[]
failed: string[]
warnings: string[]
production: StudioProduction
version: number
}>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "The production id." },
}}
/>

```ts
const { landed, pending } = await client.studio.productions.reconcile(productionId)
```

`landed` lists the jobs whose media is now on the production, `pending` the jobs still running, and `failed` the jobs that failed or were cancelled.

### productions.importPlan(productionId, plan, opts?)

Adds a plan's scenes to an existing production.

```ts
importPlan(productionId: string, plan: Record<string, unknown>, opts?: { mode?: "append" }): Promise<{
production: StudioProduction
warnings?: Array<{ path: string; message: string; hint?: string }>
summary?: { shotsAdded: number; castEnrolled: number; castBound: number }
}>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "The production id." },
plan: { type: 'Record<string, unknown>', required: true, description: "The plan to add." },
mode: { type: '"append"', default: '"append"', description: "Add the scenes after the existing ones." },
}}
/>

```ts
await client.studio.productions.importPlan(productionId, extraScenesPlan)
```

### productions.describe(productionId, input)

Turns a brief into scenes with the Director. It starts a job and returns at once; the scenes land through `reconcile()`. The production comes back with the run recorded as a pending draft.

```ts
describe(productionId: string, input: {
brief: string
llmModel: string
mode?: "append" | "replace"
label?: string
clientRequestId?: string
}): Promise<{ production: StudioProduction; jobId: string }>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "The production id." },
brief: { type: 'string', required: true, description: "What the film is about." },
llmModel: { type: 'string', required: true, description: "The language model that drafts the scenes." },
mode: { type: '"append" | "replace"', description: "append adds the drafted scenes. replace rewrites the film." },
label: { type: 'string', description: "A name for the run." },
clientRequestId: { type: 'string', description: "Your retry token." },
}}
/>

```ts
const { jobId } = await client.studio.productions.describe(productionId, {
brief: "A courier races across Rome in the rain to deliver a violin.",
llmModel,
})
```

### Generate stills and clips

`generateStill()` frames a shot, `generateClip()` animates it, and `generate()` does either by `kind`. A run submits the jobs, records a pending marker on the production, and returns: nothing waits for minutes. The request is built on the server from the shot's own plan, looks and references, so a script and a click in the app produce the same media.

```ts
generate(productionId: string, input: StudioGenerateRequest): Promise<StudioGenerateResult>
generateStill(productionId: string, shotId: string, opts?: StudioGenerateOptions): Promise<StudioGenerateResult>
generateClip(productionId: string, shotId: string, opts?: StudioGenerateOptions): Promise<StudioGenerateResult>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "The production id." },
shotId: { type: 'string', required: true, description: "The shot to frame or animate." },
kind: { type: '"still" | "clip"', description: "generate() only: what to make." },
count: { type: 'number', description: "Stills: how many candidates. Results add up; a new run never replaces earlier ones." },
mode: { type: '"start" | "start-end" | "references"', description: "Clips: which inputs to send. start sends only the start frame, start-end both frames. Omitted, it follows the shot's saved inputs." },
dryRun: { type: 'boolean', description: "Return a price quote and submit nothing." },
clientRequestId: { type: 'string', description: "Your retry token." },
overrides: { type: 'Record<string, unknown>', description: "Changes for this run only, such as the model, the prompt, the aspect or the direction ids. The shot itself does not change." },
}}
/>

```ts

const quote = await client.studio.productions.generateStill(productionId, "shot-2", { count: 2, dryRun: true })
if (isStudioGenerateEstimate(quote)) console.log(quote.credits) // null means unpriced, not free

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

- **Quote first.** `dryRun: true` prices the run and writes nothing. Narrow the answer with `isStudioGenerateEstimate()`.
- **Retry safely.** With the same `clientRequestId`, a retry answers with the jobs the first call started, marked `deduped: true`, and submits and charges nothing. Never retry a paid call without one. Every paid call on this page accepts it, `frame()` and `voice()` included.
- **The lane is chosen for you.** For a clip, the video route is picked from the shot's inputs and returned as `lane`: `generate-video` or `text-to-video`.

### productions.frame(productionId, input)

Takes a still from a shot's active clip and puts it where `target` says. It waits for the job, which takes seconds, and returns the changed production and the image `url`.

```ts
frame(productionId: string, input: {
shotId: string
mode?: "first" | "last" | "timestamp"
timestamp?: number
target?: "new-shot" | "start-frame" | "end-frame" | "still"
clientRequestId?: string
}): Promise<StudioMediaResponse>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "The production id." },
shotId: { type: 'string', required: true, description: "The shot whose clip to use." },
mode: { type: '"first" | "last" | "timestamp"', description: "Which frame to take." },
timestamp: { type: 'number', description: "The time in seconds, with mode timestamp." },
target: { type: '"new-shot" | "start-frame" | "end-frame" | "still"', default: '"new-shot"', description: "Where the frame goes: a new shot after this one, this shot's start or end frame, or another still of this shot." },
clientRequestId: { type: 'string', description: "Your retry token." },
}}
/>

```ts
const { url } = await client.studio.productions.frame(productionId, { shotId: "shot-2", mode: "last" })
```

### productions.voice(productionId, input)

Speaks a shot's line and records it on the shot. It waits for the job.

```ts
voice(productionId: string, input: {
shotId: string
text: string
voiceId?: string
voiceType?: "premade" | "custom" | "library"
ttsProvider?: string
delivery?: Record<string, number>
clientRequestId?: string
}): Promise<StudioMediaResponse>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "The production id." },
shotId: { type: 'string', required: true, description: "The shot." },
text: { type: 'string', required: true, description: "The line to speak." },
voiceId: { type: 'string', description: "The voice." },
voiceType: { type: '"premade" | "custom" | "library"', description: "The kind of voice." },
ttsProvider: { type: 'string', description: "The speech model." },
delivery: { type: 'Record<string, number>', description: "Delivery settings, within the limits of the speech route." },
clientRequestId: { type: 'string', description: "Your retry token." },
}}
/>

```ts
await client.studio.productions.voice(productionId, { shotId: "shot-3", text: "We're out of time." })
```

### productions.revoice(productionId, input)

Recasts the voices of a shot's active clip. It takes minutes, so it returns a `jobId`, and the new clip lands through its marker.

```ts
revoice(productionId: string, input: { shotId: string; plan: Record<string, unknown>; clientRequestId?: string }): Promise<{ production: StudioProduction; jobId: string }>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "The production id." },
shotId: { type: 'string', required: true, description: "The shot." },
plan: { type: 'Record<string, unknown>', required: true, description: "The recast plan, in speaker order, as the voice recast route takes it." },
clientRequestId: { type: 'string', description: "Your retry token." },
}}
/>

```ts
const { jobId } = await client.studio.productions.revoice(productionId, {
shotId: "shot-3",
plan: recastPlan, // the speaker-ordered plan the voice recast route takes
})
```

### productions.music(productionId, input)

Scores the film. The finished track lands through its pending marker.

```ts
music(productionId: string, input: {
prompt: string
duration?: number
instrumental?: boolean
vocalGender?: string
model?: string
clientRequestId?: string
}): Promise<{ production: StudioProduction; jobId: string }>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "The production id." },
prompt: { type: 'string', required: true, description: "The music you want." },
duration: { type: 'number', description: "The length in seconds." },
instrumental: { type: 'boolean', description: "Music without vocals." },
vocalGender: { type: 'string', description: "The singer's voice, for music with vocals." },
model: { type: 'string', description: "The music model." },
clientRequestId: { type: 'string', description: "Your retry token." },
}}
/>

```ts
const { jobId } = await client.studio.productions.music(productionId, {
prompt: "Tense strings building to a chase",
instrumental: true,
})
```

### Share and copy

`share()` opens the share-by-link view and `unshare()` closes it again. Sharing is its own call, never an operation, so who can see the work never changes as a side effect of an edit. `clone()` copies a production you own or can see into your own Studio project.

```ts
share(productionId: string): Promise<StudioProduction>
unshare(productionId: string): Promise<StudioProduction>
clone(productionId: string, input?: { name?: string }): Promise<StudioProduction>
```

<TypeTable
type={{
productionId: { type: 'string', required: true, description: "The production id." },
name: { type: 'string', description: "clone only: the copy's name." },
}}
/>

```ts
await client.studio.productions.share(productionId)
const copy = await client.studio.productions.clone(productionId, { name: "Rome chase, take 2" })
```

A copy starts private and visible: sharing and archiving never carry over. It is copied through **your** view of the source, so someone else's bin does not come with it.

## client.studio: planned frames

These methods cover planned keyframes and their review. Check `capabilities()` before you offer a control, and call `reconcile()` once when you reopen a production, because a submission's answer may have been lost. Nothing here starts generation or accepts a candidate unless you call that method.

| Method | What it does |
| --- | --- |
| [`capabilities()`](#studiocapabilities) | Read the plan versions and the operations this deployment supports |
| [`skill()`, `list()`, `validatePlan()`, `create()`](#studioskill-list-validateplan-and-create) | The same reads and create as the productions layer, in the envelope |
| [`get(id, options?)`](#studiogetid-options) | Read a production with its capabilities |
| [`edit(id, input)`](#studioeditid-input) | Apply operations with revision conditions |
| [`saveEditorState(id, input)`](#studiosaveeditorstateid-input) | Save ordinary editor fields against the loaded revision |
| [`generateKeyframe(id, input)`](#studiogeneratekeyframeid-input) | Generate a planned frame, without accepting it |
| [`generateShot(id, input)`](#studiogenerateshotid-input) | Quote or submit a still or a clip |
| [`acceptKeyframe(id, review, concurrency?)`](#studioacceptkeyframeid-review-concurrency) | Accept a reviewed candidate |
| [`reconcile(id)`](#studioreconcileid) | Record finished jobs, without accepting anything |
| [`setShared(id, input)`](#studiosetsharedid-input) | Share or unshare, bound to a reviewed revision |
| [`clone(id, input?)`](#studiocloneid-input) | Copy a saved production |
| [`importBundle(input)`](#studioimportbundleinput) | Import a portable production |
| [`appendBundle(id, input)`](#studioappendbundleid-input) | Append a bundle to a production |

### studio.capabilities()

Returns the plan versions and which planned-frame operations this deployment supports.

```ts
capabilities(): Promise<{ data: StudioProductionCapabilities }>
```

```ts
const { data: caps } = await client.studio.capabilities()
if (caps.operations.generateKeyframes) showGenerateFrameButton()
```

`operations` has one flag per operation, such as `readKeyframes`, `editKeyframes`, `generateKeyframes`, `acceptKeyframes`, `rejectKeyframes`, `editSequencePlans`, `generateLinkedClips`, `retakeLinkedClips`, `saveEditorState`, `revisionedSharing`, `editableSharedCopies`, `cloneLinkedProductions`, `importPlannedBundles`, `importLinkedBundles`, `appendPlannedBundles` and `appendLinkedBundles`. `automaticAcceptance` and `unattendedGeneration` are always `false`.

### studio.skill(), list(), validatePlan() and create()

The same calls as `productions.skill()`, `list()`, `validatePlan()` and `create()`, returned in the `{ data }` envelope. In `list()`, the rows are in `response.data.data`.

```ts
skill(): Promise<{ data: Record<string, unknown> }>
list(options?: { limit?: number; cursor?: string; includeArchived?: boolean }): Promise<{ data: {
data: Array<{ id: string; name: string; version: number; updatedAt: string; thumbnailUrl: string | null; shared: boolean; archived: boolean; shotCount: number }>
nextCursor?: string
} }>
validatePlan(plan: Record<string, unknown>): Promise<{ data: { valid: boolean; errors: object[]; warnings: object[]; summary?: object } }>
create(input: { name?: string; plan?: Record<string, unknown> }): Promise<{ data: StudioProductionReply }>
```

<TypeTable
type={{
options: { type: '{ limit?, cursor?, includeArchived? }', description: "list(): paging and archived rows." },
plan: { type: 'Record<string, unknown>', description: "validatePlan() and create(): the plan." },
name: { type: 'string', description: "create(): the production's name." },
}}
/>

```ts
const { data: page } = await client.studio.list({ limit: 20 })
for (const row of page.data) console.log(row.name, row.shotCount)
```

### studio.get(id, options?)

Reads a production with its capabilities. It never lands jobs.

```ts
get(id: string, options?: { detail?: "summary" | "full"; shotId?: string }): Promise<{ data: StudioProductionReply }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The production id." },
detail: { type: '"summary" | "full"', description: "full adds every result with its context." },
shotId: { type: 'string', description: "Read one shot only." },
}}
/>

```ts
const { data: { production } } = await client.studio.get(productionId, { detail: "full" })
const frame = production.keyframes?.[0] // { id, label, revision, previewUrl, acceptedUrl, pending, ... }
```

### studio.edit(id, input)

Applies operations with revision conditions (`POST .../:id/ops`), in the envelope. Use a strict `baseVersion` for `remove_shot`, `restore_trashed` and `purge_trashed`, and detach a bound sequence segment before you remove its scene.

```ts
edit(id: string, input: { ops: Array<{ op: string; [field: string]: unknown }>; baseVersion?: number; strict?: boolean; clientRequestId?: string }): Promise<{ data: StudioProductionReply & { version: number; rebased: boolean; receipts: object[] } }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The production id." },
ops: { type: 'Array<{ op: string; ... }>', required: true, description: "The operations." },
baseVersion: { type: 'number', description: "The version you loaded." },
strict: { type: 'boolean', description: "Refuse to rebase on a newer version." },
clientRequestId: { type: 'string', description: "Your retry token." },
}}
/>

```ts
await client.studio.edit(productionId, {
ops: [{ op: "reject_keyframe_result", keyframeId, expectedRevision, resultKey, expectedAcceptedResultKey, reason: "Face drifted" }],
baseVersion: version,
strict: true,
})
```

A few planned-frame operations sent through `edit()`:

- **`reject_keyframe_result`** records **Needs revision** without generating. It needs `operations.rejectKeyframes`.
- **`update_sequence_plan`** edits a sequence's ordered segments, each `{ shotId, startKeyframeId, endKeyframeId }`, and keeps the scene ids. It needs `operations.editSequencePlans`.
- **`detach_sequence_segment`** makes one segment independent, with `mode` set to `clear` or `keep-accepted`. It needs `operations.editSequencePlans`.
- **`purge_trashed`** empties the bin entries you show, and `clear_trash` empties every bin, planned frames included.

### studio.saveEditorState(id, input)

Saves ordinary editor fields against the revision you loaded. Check `operations.saveEditorState` first. The save is always strict: a conflict fails with a 409, so keep the local draft and reload before you resolve it.

```ts
saveEditorState(id: string, input: { expectedVersion: number; graph: object; clientRequestId?: string })
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The production id." },
expectedVersion: { type: 'number', required: true, description: "The version you loaded." },
graph: { type: 'object', required: true, description: "The editor state to save." },
clientRequestId: { type: 'string', description: "Your retry token." },
}}
/>

```ts
await client.studio.saveEditorState(productionId, { expectedVersion: version, graph })
```

The save cannot change frame plans, acceptance, endpoint bindings, job history, protected bin entries or sharing. Use their own actions for those.

### studio.generateKeyframe(id, input)

Generates a planned frame without accepting it. There is no dry run for frames.

```ts
generateKeyframe(id: string, input: { keyframeId: string; expectedRevision: number; clientRequestId?: string; overrides?: Record<string, unknown> }): Promise<{ data: { jobIds: string[]; deduped?: true; lane?: string } }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The production id." },
keyframeId: { type: 'string', required: true, description: "The planned frame." },
expectedRevision: { type: 'number', required: true, description: "The frame's revision you read." },
clientRequestId: { type: 'string', description: "Your retry token. Keep the same value when you retry." },
overrides: { type: 'Record<string, unknown>', description: "Changes for this run only." },
}}
/>

```ts
const { data: caps } = await client.studio.capabilities()
const { data: { production } } = await client.studio.get(productionId, { detail: "full" })
const frame = production.keyframes?.[0]

if (frame && caps.operations.generateKeyframes) {
const { data: generation } = await client.studio.generateKeyframe(productionId, {
keyframeId: frame.id,
expectedRevision: frame.revision,
clientRequestId: crypto.randomUUID(),
})
// follow generation.jobIds with client.jobs, then call reconcile()
}
```

Generating does not accept a candidate and does not create a character portrait. A cast reference that has only a description needs no portrait.

### studio.generateShot(id, input)

Quotes or submits a still or a clip for a shot.

```ts
generateShot(id: string, input: StudioShotGenerationInput): Promise<{ data: StudioGenerationReply }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The production id." },
kind: { type: '"still" | "clip"', required: true, description: "What to make." },
shotId: { type: 'string', required: true, description: "The shot." },
count: { type: 'number', description: "How many candidates." },
mode: { type: '"start" | "start-end" | "references"', description: "Clips: which inputs to send." },
dryRun: { type: 'boolean', description: "Return a quote and submit nothing." },
expectedInputHash: { type: 'string', description: "Linked clips: the inputHash of the quote you reviewed." },
retakeResultKey: { type: 'string', description: "Linked clips: the take to do again, with its original request." },
clientRequestId: { type: 'string', description: "Your retry token." },
overrides: { type: 'Record<string, unknown>', description: "Changes for this run only." },
}}
/>

```ts
const { data: quote } = await client.studio.generateShot(productionId, { kind: "clip", shotId, dryRun: true })
if ("inputHash" in quote) {
await client.studio.generateShot(productionId, {
kind: "clip",
shotId,
expectedInputHash: quote.inputHash,
clientRequestId: crypto.randomUUID(),
})
}
```

**Linked clips.** A quote for a clip between planned frames includes `inputHash`, the accepted `endpointPins`, the normalized duration, resolution, aspect ratio and sound settings, and the `creditIdentifier` used for the price. Pass the reviewed `inputHash` as `expectedInputHash`. When the settings or the accepted frames changed since the quote, the call fails with `409 sequence_quote_changed` before anything is submitted; ask for a new quote. The credits in a quote are an estimate: the generation reserves the current price.

**Retakes.** When `operations.retakeLinkedClips` is `true`, pass `retakeResultKey` with `kind: "clip"` and `shotId`, and quote it with `dryRun: true`. Submit with the reviewed `expectedInputHash` and a new `clientRequestId`, and leave out `mode`, `overrides` and `count`. A retake reuses the original request and the kept frame images, even after the plan or the acceptance changed. Earlier takes stay in the history. A take without a verifiable original request or kept images is refused, and a retake does not reproduce the same video bytes.

### studio.acceptKeyframe(id, review, concurrency?)

Accepts a reviewed candidate for a planned frame. It is a separate, explicit step: generation never calls it.

```ts
acceptKeyframe(id: string, review: StudioKeyframeAcceptanceInput, concurrency?: { baseVersion?: number; strict?: boolean; clientRequestId?: string })
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The production id." },
keyframeId: { type: 'string', required: true, description: "The planned frame." },
expectedRevision: { type: 'number', required: true, description: "The frame revision you reviewed." },
resultKey: { type: 'string', required: true, description: "The candidate to accept." },
expectedAcceptedResultKey: { type: 'string | null', required: true, description: "The candidate accepted before, or null." },
requirementChecks: { type: 'Array<{ requirementId: string; outcome: "pass" | "waived" }>', required: true, description: "The outcome of each requirement check." },
waivedReason: { type: 'string', description: "Required when a check is waived." },
concurrency: { type: '{ baseVersion?, strict?, clientRequestId? }', description: "Revision conditions for the write." },
}}
/>

```ts
await client.studio.acceptKeyframe(productionId, {
keyframeId: frame.id,
expectedRevision: frame.revision,
resultKey,
expectedAcceptedResultKey: frame.acceptedResultKey,
requirementChecks, // one { requirementId, outcome } per requirement of the frame
})
```

A conflict throws the usual error. The SDK never picks another result or retries against a newer revision on its own.

### studio.reconcile(id)

Records finished jobs, without accepting any candidate and without starting generation. It also checks jobs of scenes in the bin: a finished clip stays in that scene's stored graph, and you recover it with `restore_trashed`.

```ts
reconcile(id: string): Promise<{ data: StudioProductionReply & { landed: string[]; pending: string[]; failed: string[]; version: number } }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The production id." },
}}
/>

```ts
const { data } = await client.studio.reconcile(productionId)
console.log(data.landed, data.pending)
```

### studio.setShared(id, input)

Shares or unshares a production, bound to the revision you reviewed. Check `operations.revisionedSharing` and pass `expectedVersion`: a concurrent edit then fails with `409 workflow_conflict`, and the SDK does not retry. Only callers allowed to change visibility can use it.

```ts
setShared(id: string, input: { shared: boolean; allowEditableCopy?: boolean; expectedVersion?: number }): Promise<{ data: StudioProductionReply }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The production id." },
shared: { type: 'boolean', required: true, description: "true opens the share link, false closes it." },
allowEditableCopy: { type: 'boolean', description: "Let signed-in viewers of the link copy the editable production. Needs operations.editableSharedCopies." },
expectedVersion: { type: 'number', description: "The version you reviewed." },
}}
/>

```ts
await client.studio.setShared(productionId, { shared: true, allowEditableCopy: true, expectedVersion: version })
```

With `allowEditableCopy`, an owner or workspace admin lets link viewers copy the saved plan, prompts, cast descriptions, kept reference inputs and take history. The bin and private review notes are never included. Copies start private, with no frames accepted. Turning copying off, or unsharing, blocks new copies; copies already made stay independent.

### studio.clone(id, input?)

Copies a saved production. Check `operations.cloneLinkedProductions` before you copy one with linked frames, and pass its loaded `expectedVersion`: a changed source fails with a 409.

```ts
clone(id: string, input?: { name?: string; projectId?: string; expectedVersion?: number }): Promise<{ data: StudioProductionReply }>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The production to copy." },
name: { type: 'string', description: "The copy's name." },
projectId: { type: 'string', description: "A project of yours to put the copy in." },
expectedVersion: { type: 'number', description: "The source version you loaded." },
}}
/>

```ts
const { data: { production: copy } } = await client.studio.clone(productionId, {
name: "Rome chase copy",
expectedVersion: version,
})
```

The copy starts private. It keeps the frame inputs, which count against your storage, gets new frame, scene and sequence ids, and carries no running jobs and no accepted frames. Review and accept its frames before you generate media that depends on them. Copying submits no generation.

### studio.importBundle(input)

Imports a portable production as a new, private production with new scene, frame and sequence ids (`POST .../import-bundle`). Check `operations.importPlannedBundles` for recipes and plans without media, and `importLinkedBundles` for bundles with kept frame media.

```ts
importBundle(input: { bundle: Record<string, unknown>; projectId?: string }): Promise<{ data: StudioProductionReply }>
```

<TypeTable
type={{
bundle: { type: 'Record<string, unknown>', required: true, description: "The portable production." },
projectId: { type: 'string', description: "A project of yours to import into." },
}}
/>

```ts
const { data: { production } } = await client.studio.importBundle({ bundle })
```

A linked bundle names its source production; the server checks that you own it and verifies every kept image before it copies anything. Missing access or forged provenance refuses the import before the new production is created. Neither kind of import carries acceptance or running jobs, and neither generates media.

### studio.appendBundle(id, input)

Appends a complete bundle to an editable production, with new ids and an exact revision check (`POST .../:id/import-bundle`). An OAuth token needs `workflows:write`, and you need edit access to the production. Check `appendPlannedBundles` or `appendLinkedBundles` first.

```ts
appendBundle(id: string, input: { bundle: Record<string, unknown>; expectedVersion: number; afterShotId?: string; applyFilm?: boolean }): Promise<{
data: { production: StudioProductionRecord; importedShotIds: string[]; importedKeyframeIds: string[] }
}>
```

<TypeTable
type={{
id: { type: 'string', required: true, description: "The production to append to." },
bundle: { type: 'Record<string, unknown>', required: true, description: "The bundle to append." },
expectedVersion: { type: 'number', required: true, description: "The production version you loaded." },
afterShotId: { type: 'string', description: "Insert after this shot. Omit it to append at the end." },
applyFilm: { type: 'boolean', description: "Adopt the bundle's film look. The music, cuts and film brief stay as they are." },
}}
/>

```ts
const { data } = await client.studio.appendBundle(productionId, { bundle, expectedVersion: version })
console.log(data.importedShotIds)
```

Existing scenes, frames, jobs, sharing and other settings stay; imported cast roles are merged. The imported frames need to be accepted again. An unknown `afterShotId` or a stale `expectedVersion` fails.

## client.shots

Shot records behind `/s/:id` share links, for Share and Remix. A shot stores a builder's state: picker selections, prompts, target models, `@` mention references and result URLs, under an unguessable 12-character id that is also the share key. Shots are **private** by default; sharing is a visibility change you make.

```ts
create(input?: CreateShotInput): Promise<{ id: string }>
get(id: string): Promise<{ shot: Shot }>
update(id: string, input: UpdateShotInput): Promise<{ shot: Shot }>
delete(id: string): Promise<void>
```

<TypeTable
type={{
id: { type: 'string', description: "get, update and delete: the shot id." },
mode: { type: '"single" | "multi-shot" | "frame-to-motion" | "storyboard"', description: "The builder mode." },
selectionState: { type: 'Record<string, unknown>', description: "The picker selections." },
freeText: { type: 'string', description: "Free prompt text." },
negativePrompt: { type: 'string', description: "What to avoid." },
assembledPrompt: { type: 'string', description: "The final prompt." },
perModelPrompts: { type: 'Record<string, string>', description: "A prompt per model." },
models: { type: 'string[]', description: "The target models." },
entityRefs: { type: 'Array<{ entitySlug, variantSlug?, role?, kind? }>', description: "The characters, locations, objects and creatures mentioned." },
resultUrls: { type: 'string[]', description: "Result URLs. They must be plain public http or https URLs: signed URLs are refused, so a token never leaks into a share record." },
visibility: { type: '"private" | "public"', default: '"private"', description: "Who can read the shot." },
}}
/>

```ts
const { id } = await client.shots.create({ mode: "single", freeText: "A lighthouse in a storm", models: ["nano-banana-2"] })
await client.shots.update(id, { visibility: "public" }) // anyone with the id can now read it
const { shot } = await client.shots.get(id)
```

A public shot is readable by anyone with its id. A private shot is readable only by its owner, and others get `NotFoundError`. Only the owner can update or delete a shot.

## Frequently asked questions

### What is a Studio production?

A production is a workflow whose settings hold the shots of a film: each shot has a framed still, an optional animated clip, and the plan, looks, cast and voice that made them. The Studio app and the SDK read and write the same production.

### Why does client.studio have two sets of methods?

client.studio.productions works on the production document: operations, stills, clips, voice and music, and it returns the payload. client.studio covers planned frames and their review, and it returns the API's data envelope. Both reach the same productions.

### How do I retry a generation without paying twice?

Pass a clientRequestId you create, and reuse it when you retry. The server answers with the jobs the first call started, marks the answer deduped, and charges nothing again.

### How do I check the price of a still or a clip first?

Call generateStill or generateClip with dryRun set to true. The answer is a quote with the credits, and nothing is submitted. Narrow it with isStudioGenerateEstimate.
