# 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.

Source: https://nodaro.ai/docs/developers/api/studio-productions

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](https://nodaro.ai/docs/nodes/video/scene), [Generate Video](https://nodaro.ai/docs/nodes/video/generate-video) and [Combine Videos](https://nodaro.ai/docs/nodes/video/combine-videos). The routes take a bearer token; OAuth app tokens need `workflows:read` to read and `workflows:write` to write. See [Authentication](https://nodaro.ai/docs/developers/api/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**

```bash
curl "https://app.nodaro.ai/v1/studio/productions/5d8f2b4a-7c1e-4a9d-8b3f-2e6c9a1d4f7b?detail=summary" \
  -H "Authorization: Bearer $NODARO_API_KEY"
```

**TypeScript SDK**

```ts

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.

**curl**

```bash
# 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.json
```

**TypeScript SDK**

```ts
const { 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:

1. **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.
2. **Rebased by default.** `baseVersion` is informational: a batch written against an older version is applied to the newest document, and the response says `rebased: true`. Send `strict: true` to get `409 workflow_conflict` instead.
3. **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.
4. **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.
5. **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.
6. **Adopt the response.** Replace your copy with `production` rather than merging, and send `version` as the next `baseVersion`.

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:

```json
{ "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`.

```ts
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. `overrides` changes one run without changing the shot.
- **Quote first.** `dryRun: true` prices the run and writes nothing. The reply is `{ dryRun: true, provider, count, credits, lane? }`. `credits: null` means the model has no price, which is unknown, not free.
- **Retry safely.** `clientRequestId`, 8 to 128 characters from `A-Za-z0-9_.:-` that you mint, makes a retry safe: the same id returns the first call's jobs with `deduped: true` and charges nothing. Never retry a paid call without it.
- **Land the results.** `POST /v1/studio/productions/:id/reconcile` turns finished jobs into results and clears failed ones. It returns `{ landed, pending, failed, warnings, production, version }`. A `GET` never writes, so reconcile when you are waiting on something.
- **The clip lane is chosen for you.** For a clip, the model route (`generate-video` or `text-to-video`) follows from the shot's inputs and comes back as `lane`. `mode` only says which set of inputs to direct from: `"start"` or `"references"`.

**curl**

```bash
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"
```

**TypeScript SDK**

```ts

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.** `generate` with `kind: "clip"`, the `shotId` and `dryRun: true` checks both frames and returns a quote with an `inputHash`. Send it back as `expectedInputHash` on the real request. When the settings or the frames changed in between, the route answers `409 sequence_quote_changed` before it submits anything.
- **Retake a clip.** Add `retakeResultKey` to quote a new take of an existing linked clip, with its original settings and frames. Then submit it with the quote's `inputHash` and a fresh `clientRequestId`. Do not send `mode`, `overrides` or `count`. 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.

```ts
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.

```json
{
"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](https://nodaro.ai/docs/nodes/video/merge-video-audio) and [Combine Videos](https://nodaro.ai/docs/nodes/video/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/share` with `{ shared: true }` turns on the read-only share link; `{ shared: false }` or `POST …/:id/unshare` turns it off. Only the owner or a workspace admin may change sharing (`403 forbidden` otherwise). When `operations.revisionedSharing` is available, add `expectedVersion` to tie the change to the version you reviewed; a newer edit then answers `409 workflow_conflict`.
- **Allow editable copies.** With `operations.editableSharedCopies`, the owner can also send `allowEditableCopy: true` with `shared: true` and `expectedVersion`. 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/clone` with `{ 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**

```bash
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" }
}]
}
]
}
}'
```

**TypeScript SDK**

```ts
const file = await client.request('POST', '/v1/freecut-export', {
body: { format: 'json', name: 'The Lighthouse, cut 1', timeline },
})
```

```json
{
"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**

```bash
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"
}'
```

**TypeScript SDK**

```ts
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)
```

**CLI**

```bash
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](https://nodaro.ai/docs/mcp/studio-productions).

## 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

### What is a Studio production?

A film you build in Nodaro Studio. It is a workflow whose settings hold an ordered list of shots, which the editor calls scenes. Each shot has a framed still, an optional animated clip, and the plan, looks, cast and voice that made them.

### Why do I have to call reconcile after a generation?

Generation returns at once and runs in the background. POST /v1/studio/productions/:id/reconcile turns the finished jobs into results on their shots. A plain GET never writes, so a finished job stays pending until you reconcile.

### How do I retry a paid generation safely?

Send a clientRequestId, 8 to 128 characters you mint yourself. Sending the same id again returns the jobs the first call started, marked deduped, and charges nothing.

### Can I preview what a batch of operations will do?

Yes, with dryRun set to true. First send an empty batch with dryRun to prove the server supports previews, because a server without them would apply the batch.

### Can I export a Studio timeline to Final Cut Pro or another editing app?

Yes. POST /v1/freecut-export turns a timeline of scene clips into a FreeCut JSON or a Final Cut Pro XML file in your storage. It costs no credits.
