# 3D scenes

> Generate an editable 3D scene from a prompt, edit it, render it to MP4, and run 3D Render Pro from TypeScript with client.scene3d and the 3D scene nodes.

Source: https://nodaro.ai/docs/developers/sdk/scenes-3d

**`client.scene3d`** makes editable 3D scenes: it authors a scene from a prompt, edits it by instruction or by exact operations, and renders a revision to an MP4. It also runs **3D Render Pro**, one operation that authors a scene and renders it, and reads the files a render delivered. The methods use the [Generate 3D Scene](https://nodaro.ai/docs/nodes/video/generate-3d-scene), [Edit 3D Scene](https://nodaro.ai/docs/nodes/video/edit-3d-scene), [Render Video](https://nodaro.ai/docs/nodes/video/render-video) and [3D Render Pro](https://nodaro.ai/docs/nodes/video/pro-3d-render) nodes. See the [3D scenes REST API](https://nodaro.ai/docs/developers/api/3d-scenes) for the endpoints.

## Methods

| Method | What it does |
| --- | --- |
| [`capabilities()`](#capabilities) | Read which engines and Pro options this install offers |
| [`generate(params)` and `generateAndWait()`](#generateparams-and-generateandwaitparams-options) | Author a new scene from a prompt |
| [`edit(params)` and `editAndWait()`](#editparams-and-editandwaitparams-options) | Make a new revision of a scene |
| [`render(params)` and `renderAndWait()`](#renderparams-and-renderandwaitparams-options) | Render a revision to an MP4, without authoring |
| [`applyEdits(revisionId, params)`](#applyeditsrevisionid-params) | Save exact edits without authoring or a charge |
| [`quotePro(params)`](#quoteproparams) | Price a 3D Render Pro run |
| [`runPro(params, options?)`](#runproparams-options) | Start a quoted 3D Render Pro run |
| [`renderProAndWait(params, options?)`](#renderproandwaitparams-options) | Quote, run and wait in one call |
| [`getDelivery(jobId)`](#getdeliveryjobid) | Read the files a render delivered |
| [`deliveryAssetBytes(jobId, asset, options?)`](#deliveryassetbytesjobid-asset-options) | Download one delivered file |
| [`retainedRecipe(jobId, options?)`](#retainedrecipejobid-options) | Read the recipe a refused Pro run kept |
| [`assetBytes(revisionId, asset, options?)`](#assetbytesrevisionid-asset-options) | Download a file of a scene revision |
| [`sourceBytes(revisionId, options?)`](#sourcebytesrevisionid-options) | Download a revision's native source file |

## From prompt to MP4

```ts
const created = await client.scene3d.generateAndWait({
prompt: "Orbit a single box on a floor over four seconds",
durationSeconds: 4,
fps: 24,
aspectRatio: "16:9",
})

const edited = await client.scene3d.editAndWait({
scenePlan: created.scenePlan,
expectedRevisionId: created.scenePlan.revisionId,
operations: [{ op: "set-camera", changes: { focalLengthMm: 50 } }],
})

const clip = await client.scene3d.renderAndWait({ planType: "3d-scene", plan: edited.scenePlan })
console.log(clip.videoUrl)
```

The same three steps work with [`client.nodes.runAndWait()`](https://nodaro.ai/docs/developers/sdk/nodes) and the node types `generate-3d-scene`, `edit-3d-scene` and `render-video`, which have typed parameters. To let people rotate and adjust a scene in your page, use the [3D preview embed](https://nodaro.ai/docs/developers/embed/scene3d). It needs no copy of the renderer and no tokens in its messages.

## client.scene3d

### capabilities()

Returns what this install can author and render (`GET /v1/3d-scene/capabilities`).

```ts
capabilities(): Promise<Scene3DCapabilities>
```

```ts
const caps = await client.scene3d.capabilities()
if (caps.advanced) console.log(caps.advanced.engines) // for example ["blender-cloud"]
if (caps.pro) console.log("3D Render Pro is available")
```

- **`basic`** is the deterministic engine: `{ available, sceneSchemaVersions }`.
- **`advanced`** lists optional engines, or is `null` when there are none. An explicit engine that is unavailable is refused before a Basic generation or its credit checks.
- **`pro`** describes 3D Render Pro: the engines, quality profiles, styles and aspect ratios this install serves. Offer only those. When `pro` is absent, the node is unavailable.

### generate(params) and generateAndWait(params, options?)

Authors a new scene from a prompt. `generate()` returns the job at once; `generateAndWait()` waits for it and resolves with `scenePlan` and an optional `changeSummary`.

```ts
generate(params: GenerateScene3DParams): Promise<RunNodeResult>
generateAndWait(params: GenerateScene3DParams, options?: RunAndWaitOptions): Promise<Scene3DJobOutput>
```

<TypeTable
type={{
prompt: { type: 'string', required: true, description: "What happens in the scene." },
durationSeconds: { type: 'number', description: "The length in seconds." },
fps: { type: 'number', description: "The frame rate." },
aspectRatio: { type: 'string', description: "The frame shape, such as 16:9." },
references: { type: 'Scene3DReference[]', description: "Image and video references, each with a role: appearance, layout or motion." },
inputAssets: { type: 'Scene3DInputAsset[]', description: "Up to 8 existing GLB models to use, each { id, revisionId, assetId, label? }. Needs an advanced engine that can import." },
engine: { type: '"basic" | "blender-cloud" | "blender-local"', default: '"basic"', description: "The authoring engine. See capabilities()." },
acceptedSceneSchemaVersions: { type: 'number[]', description: "The scene schema versions your client can render." },
maxRepairPasses: { type: 'number', description: "The correction budget of an advanced engine." },
llmModel: { type: 'string', description: "The language model that plans the scene." },
reasoningEffort: { type: 'string', description: "The planner's reasoning effort." },
workflowId: { type: 'string', description: "A workflow to list the run under." },
}}
/>

```ts
const { scenePlan } = await client.scene3d.generateAndWait({
prompt: "A paper boat drifts across a puddle as rain starts",
references: [{ id: "look", kind: "image", role: "appearance", url: moodImageUrl }],
})
```

**`inputAssets`** select GLB models you are allowed to use, by their immutable ids; `references` still carry the appearance images and motion videos. Do not send asset URLs or hashes: the server supplies the byte records. Basic and engines that cannot import refuse `inputAssets` before charging. Reuse the same selectors when you submit a Pro quote.

### edit(params) and editAndWait(params, options?)

Makes a **new revision** of a scene. The scene you pass is never changed. Give an instruction in `prompt`, or exact `operations`, never both.

```ts
edit(params: EditScene3DParams): Promise<RunNodeResult>
editAndWait(params: EditScene3DParams, options?: RunAndWaitOptions): Promise<Scene3DJobOutput>
```

<TypeTable
type={{
scenePlan: { type: 'Scene3DPlan', required: true, description: "The scene to start from." },
expectedRevisionId: { type: 'string', required: true, description: "The revision you are editing." },
prompt: { type: 'string', description: "An instruction, such as make the camera slower." },
operations: { type: 'Scene3DEditOperation[]', description: "Exact edits, such as { op: set-camera, changes: { focalLengthMm: 50 } }." },
references: { type: 'Scene3DReference[]', description: "References to add, merged by id." },
replaceReferences: { type: 'boolean', description: "Replace the whole reference set instead of merging. An empty list clears it." },
lockedObjectIds: { type: 'string[]', description: "Objects the edit must not change." },
selectedObjectIds: { type: 'string[]', description: "Objects the instruction is about." },
engine: { type: 'string', description: "The authoring engine." },
llmModel: { type: 'string', description: "The planner model." },
}}
/>

```ts
const { scenePlan: next, changeSummary } = await client.scene3d.editAndWait({
scenePlan,
expectedRevisionId: scenePlan.revisionId,
prompt: "Make the rain heavier and lower the camera",
})
```

### render(params) and renderAndWait(params, options?)

Renders the exact revision you pass to an MP4, without authoring or rebuilding anything (`POST /v1/render-video/plan`).

```ts
render(params: { planType: "3d-scene"; plan: Scene3DPlan; workflowId?: string; nodeId?: string }): Promise<RunNodeResult>
renderAndWait(params: RenderScene3DParams, options?: RunAndWaitOptions): Promise<NodeJobOutput>
```

<TypeTable
type={{
planType: { type: '"3d-scene"', required: true, description: "Marks the plan as a 3D scene." },
plan: { type: 'Scene3DPlan', required: true, description: "The revision to render." },
workflowId: { type: 'string', description: "A workflow to list the run under." },
nodeId: { type: 'string', description: "The node the run belongs to." },
}}
/>

```ts
const { videoUrl } = await client.scene3d.renderAndWait({ planType: "3d-scene", plan: scenePlan })
```

The price follows the plan's own `width` and `height`:

| Frame | Credits | Price identifier |
| --- | --- | --- |
| Up to 1,920 pixels on the longest side | 50 | `render-video` |
| Larger, up to 5.12 megapixels | 75 | `render-video:3d-large` |
| Above 5.12 megapixels | 125 | `render-video:3d-xlarge` |

Read the current prices of those identifiers with [`client.credits.modelCosts()`](https://nodaro.ai/docs/developers/sdk/models-and-credits).

### applyEdits(revisionId, params)

Saves exact edits to a kept revision without authoring and without a charge (`POST /v1/3d-scene/revisions/:id/edits`). It returns the new `scenePlan` and a `changeSummary`.

```ts
applyEdits(revisionId: string, params: {
newRevisionId: string
expectedContentHash: string
operations: Scene3DV2EditOperation[]
lockedObjectIds?: string[]
}): Promise<{ scenePlan: Scene3DPlanV2; changeSummary: string }>
```

<TypeTable
type={{
revisionId: { type: 'string', required: true, description: "The revision to edit." },
newRevisionId: { type: 'string', required: true, description: "The id of the new revision. Keep the same value when you retry the same edit." },
expectedContentHash: { type: 'string', required: true, description: "The content hash of the revision you are editing." },
operations: { type: 'Scene3DV2EditOperation[]', required: true, description: "The exact edits, for version 2 scenes." },
lockedObjectIds: { type: 'string[]', description: "Objects the edits must not change." },
}}
/>

```ts
const { scenePlan: saved } = await client.scene3d.applyEdits(revisionId, {
newRevisionId: crypto.randomUUID(),
expectedContentHash,
operations,
})
```

Adopt the returned scene only if the user is still editing the revision you started from. Geometry and camera files are reused. Posters, validation and native downloads are attached again only after they are made for the new revision.

## 3D Render Pro

3D Render Pro is one operation: a `source` goes in, and one job settles with both `scenePlan`, the exact composition, and `videoUrl`, the MP4. The result also carries the revision, a poster, `shotStills`, validation and renderer details. `client.nodes.run("pro-3d-render")` and `runAndWait` reach the same route with the same typed parameters.

An install without the engine answers `503 SCENE_CAPABILITY_UNAVAILABLE` and never falls back to Basic. An install without a price answers `503 price_not_configured` before it reserves anything. There is no model or effort setting: the planner is fixed by the server.

### quotePro(params)

Prices a run **without starting it**. It reserves and spends nothing. The answer has `quoteId`, a ceiling in `maxCredits`, a `breakdown` to show, and the input hash the run is later checked against.

```ts
quotePro(params: Pro3DRenderParams): Promise<Pro3DRenderQuote>
```

<TypeTable
type={{
source: { type: 'Pro3DRenderSource', required: true, description: "What to render. See the sources below." },
engine: { type: 'string', description: "An engine from capabilities().pro. An unknown or unavailable engine is refused, never downgraded." },
durationSeconds: { type: 'number', description: "The length. On a scene source, it re-times the scene; omit it to keep the scene's timing." },
fps: { type: 'number', description: "The frame rate." },
aspectRatio: { type: 'string', description: "The frame shape, from capabilities().pro." },
quality: { type: 'string', description: "A quality profile from capabilities().pro." },
style: { type: 'string', description: "A style from capabilities().pro." },
maxRepairPasses: { type: 'number', description: "The correction budget, 0 to 2. Each pass is paid." },
acceptedSceneSchemaVersions: { type: 'number[]', description: "The scene versions your client can render. A prompt source makes version 2, so include 2." },
localConnectionId: { type: 'string', description: "A paired desktop, for a local run." },
forcePrivate: { type: 'boolean', description: "Keep every file of the run out of publicly readable storage." },
}}
/>

`source` is one of three shapes:

- **`{ kind: "prompt", prompt, references? }`** authors a new scene.
- **`{ kind: "scene", revisionId, sourceJobId }`** without `editPrompt` exports a kept scene with no authoring charge. Adding `editPrompt` revises the scene first. `sourceJobId` is required for Basic scenes that exist only in job history.
- **`{ kind: "local-export", exportId, connectionId }`** uses a paired desktop.

```ts
const quote = await client.scene3d.quotePro(params)
showPrice(quote.maxCredits, quote.breakdown)
```

### runPro(params, options?)

Starts a quoted run. It needs the `quoteId` from `quotePro()`, so no run starts at a price nobody saw. It sends an `Idempotency-Key`: a new one per call, or yours in `options.idempotencyKey`. Reuse yours when you retry a call that timed out.

```ts
runPro(params: Pro3DRenderParams & { quoteId: string }, options?: { idempotencyKey?: string }): Promise<RunNodeResult>
```

<TypeTable
type={{
quoteId: { type: 'string', required: true, description: "The quote the run is admitted under." },
'...params': { type: 'Pro3DRenderParams', required: true, description: "The same body you quoted." },
idempotencyKey: { type: 'string', description: "Your retry token for this run." },
}}
/>

```ts
await client.scene3d.runPro({ ...params, quoteId: quote.quoteId })
```

### renderProAndWait(params, options?)

Quotes when `params` has no `quoteId`, runs, and waits: the whole operation in one call. It sends two requests at most and starts one paid job, admitted against a hash of exactly what was priced.

```ts
renderProAndWait(params: Pro3DRenderParams | Pro3DRenderRunParams, options?: RunAndWaitOptions & { idempotencyKey?: string }): Promise<Pro3DRenderJobOutput>
```

<TypeTable
type={{
params: { type: 'Pro3DRenderParams', required: true, description: "The run, with or without a quoteId." },
options: { type: 'RunAndWaitOptions & { idempotencyKey? }', description: "Polling options and a retry token." },
}}
/>

```ts
const caps = await client.scene3d.capabilities()
if (caps.pro?.available) {
const shot = await client.scene3d.renderProAndWait({
source: {
kind: "prompt",
prompt: "A red suitcase rolls behind a central pillar and reappears",
references: [{ id: "look", kind: "image", role: "appearance", url: appearanceImageUrl }],
},
durationSeconds: 30,
fps: 24,
aspectRatio: "21:9",
maxRepairPasses: 2,
acceptedSceneSchemaVersions: [2],
})
console.log(shot.videoUrl)        // the MP4
console.log(shot.sceneRevisionId) // export it again later, with no authoring charge
}
```

**Shot stills.** `shotStills` is a list of `{ shotIndex, frame, assetId, url }`, one still per shot at the frame the shot opens on, made by the same run at no extra cost. A single-shot scene has one, at frame 0. Results made before the field existed have none, so read `shot.shotStills ?? []`.

Each `url` is an **authenticated** endpoint of your install, not a public link. Fetch it with the same credentials you used for the run; an `img` tag, or a third-party service, gets a 401:

```ts
for (const still of shot.shotStills ?? []) {
const res = await fetch(still.url, { headers: { Authorization: `Bearer ${token}` } })
const bytes = await res.arrayBuffer() // use the bytes, or store them where your pipeline can read them
}
```

You can still use a still as a model reference: pass its URL as you read it, in `referenceImageUrls` or through the node's `stills` output. The platform grants that run a short read of that one file, in your name. The grant expires minutes later, so store the authenticated URL, never the grant.

### What a Pro run reports about itself

A run that authored a scene reports what it assumed and did. Read every field as optional: an install without an advanced engine, or an older result, has none of them.

```ts
const summary = shot.metadata?.summary // what the planner says it built
const repairs = shot.repairPasses      // 0 means accepted the first time
const retries = shot.admissionRetries  // planner retries before a build
const mechanical = shot.mechanicalPasses
const restored = shot.restoredAssertions
const assumptions = (shot.validation?.warnings ?? [])
.filter((w) => w.code === "SCENE_AUTHORING_ASSUMPTION")
.map((w) => w.message)
```

- **`SCENE_AUTHORING_ASSUMPTION`** warnings list what the prompt did not say, so the run decided. Warning codes can be added: treat an unknown code as information, not as an error.
- **`repairPasses`** counts repairs, not authoring passes, so `0` means the scene was accepted the first time. `admissionRetries` counts something else: recipes the planner was asked again before any build.
- **`mechanicalPasses`** counts repairs the engine applied from the compiler's own remedy, without the planner. They have their own quoted allowance of up to 2, released when unused, and each adds a `REMEDY_AUTO_APPLIED` warning. A run quoted before that allowance existed charged such passes as repairs; read the quote you were given to tell.
- **`restoredAssertions`** lists required checks the engine put back after the planner changed one it was not asked to change. Each also adds an `ASSERTION_RESTORED` warning.
- A render-only export authored nothing, so it has no counts and no summary. For `mechanicalPasses` especially, absent is not `0`.

`generateAndWait()` results carry the same fields when an advanced engine authored the scene. The Basic engine asks no model and carries none of them.

### A delivery the visual reviewer did not approve

A **completed** result can arrive without the visual reviewer's approval. The video is real and the credits were spent in both cases. `metadata.review.verdict` says which case it is:

- **`"refused"`**: the repair budget was spent, every required check passed, and the reviewer still objected. The scene was delivered with the refusal attached.
- **`"unavailable"`**: the review gave no usable verdict. `reason` is `"provider"` when it never reached its provider, and `"unusable"` when the answer was unusable. `attempts` says how many times it was asked. **Nobody judged this scene.**

```ts

const review = scene3DReviewVerdictOf(shot)
if (review) {
console.log(scene3DReviewNote(review)) // one user-safe sentence for either verdict
if (review.verdict === "unavailable") console.log(`unreviewed (${review.reason}) after ${review.attempts} attempts`)
for (const objection of review.objections) console.log(objection.category, objection.what, objection.correction)
}
```

Use the two helpers from `@nodaro/shared` instead of reading the fields yourself, because three readings look right and are not:

- **`validation.status` is still `"passed"`.** The required checks did pass, which is why the scene was delivered.
- **`objections` can be empty.** A refusal that named nothing specific is still a refusal, so counting `SCENE_REVIEW_REFUSED` warnings misses it.
- **Objections under `"unavailable"` are not the verdict.** They come from review batches that answered before one failed. An empty list there means silence, not approval.

Each objection is `{ category, what, correction?, frames }`. On the `"unavailable"` verdict, a `SCENE_REVIEW_UNAVAILABLE` warning leads `validation.warnings`. A visual refusal alone no longer fails the job. `SCENE_QUALITY_FAILED` means a required check failed or the compiler refused the recipe. See [3D Render Pro](https://nodaro.ai/docs/nodes/video/pro-3d-render) for what such a failed result keeps.

## Deliveries and files

These methods read files a run already delivered. They never start a render. Every read checks your access to both the delivery and its source again, even after the source revision was deleted.

### getDelivery(jobId)

Reads a delivery's record (`GET /v1/3d-scene/deliveries/:jobId`): its `sourceKind`, its exact source revision, and the files it pinned.

```ts
getDelivery(jobId: string): Promise<Scene3DDelivery>
```

<TypeTable
type={{
jobId: { type: 'string', required: true, description: "The job id of the render." },
}}
/>

```ts
const delivery = await client.scene3d.getDelivery(jobId)
const stills = delivery.assets.filter((a) => a.kind === "shot-still")
```

Four kinds of files appear: `poster` and `validation-report` on every delivery, `shot-still` once per shot, each with its `shotIndex`, `frame`, `width` and `height`, and `source-json` on a `refused-authoring` delivery only. `sourceKind` is `retained-revision`, `job-output` or `refused-authoring`. On a refused delivery, `sceneRevisionId` and `sourcePlanSha256` are `null` and there is no poster, because nothing compiled.

### deliveryAssetBytes(jobId, asset, options?)

Downloads one file the delivery lists, with fresh authentication and a size limit.

```ts
deliveryAssetBytes(jobId: string, asset: Scene3DDeliveryAsset, options?: { signal?: AbortSignal }): Promise<ArrayBuffer>
```

<TypeTable
type={{
jobId: { type: 'string', required: true, description: "The job id of the render." },
asset: { type: 'Scene3DDeliveryAsset', required: true, description: "A file descriptor from getDelivery(), passed as it is." },
signal: { type: 'AbortSignal', description: "Stops the download." },
}}
/>

```ts
const bytes = await client.scene3d.deliveryAssetBytes(jobId, stills[0])
```

### retainedRecipe(jobId, options?)

Reads the recipe a refused 3D Render Pro run kept, parsed, or `null` when there is none. A refused run's recipe never compiled, so there is no scene revision, no poster and no source file; the recipe is what it leaves behind.

```ts
retainedRecipe(jobId: string, options?: { signal?: AbortSignal }): Promise<unknown | null>
```

<TypeTable
type={{
jobId: { type: 'string', required: true, description: "The job id of the refused run." },
signal: { type: 'AbortSignal', description: "Stops the download." },
}}
/>

```ts
const recipe = await client.scene3d.retainedRecipe(jobId)
```

- **It needs edit access** to the job's workflow. A reader with less does not see the recipe at all, so the answer is `null`, not an error.
- **The failed job says whether to ask.** Its `validation.sourceRetained` is `true` when a recipe was kept.
- **It is evidence, not an input.** A refused run published no revision, so you cannot run it again from the recipe. Read it to see what was tried and to improve the next prompt. Reading costs no credits.

### assetBytes(revisionId, asset, options?)

Downloads a file of a kept scene revision: a GLB model, a camera track, the poster or the validation report. Pass the exact descriptor from that revision. The SDK limits the download to the declared size, and the scene renderer also checks the SHA-256 digest.

```ts
assetBytes(revisionId: string, asset: Scene3DAssetRef, options?: { signal?: AbortSignal }): Promise<ArrayBuffer>
```

<TypeTable
type={{
revisionId: { type: 'string', required: true, description: "The scene revision." },
asset: { type: 'Scene3DAssetRef', required: true, description: "A descriptor of kind glb, camera-track-json, poster or validation-report." },
signal: { type: 'AbortSignal', description: "Stops the download." },
}}
/>

```ts
const glb = await client.scene3d.assetBytes(scenePlan.revisionId, glbAsset)
```

### sourceBytes(revisionId, options?)

Downloads a revision's native source file, such as a `.blend` file, through its own authorization. It needs the same edit access as a retained recipe. A native file is available only when it represents exactly that accepted revision.

```ts
sourceBytes(revisionId: string, options?: { signal?: AbortSignal }): Promise<ArrayBuffer>
```

<TypeTable
type={{
revisionId: { type: 'string', required: true, description: "The scene revision." },
signal: { type: 'AbortSignal', description: "Stops the download." },
}}
/>

```ts
const blend = await client.scene3d.sourceBytes(revisionId)
```

Both byte methods use fresh credentials, respect cancellation, and throw the usual [typed errors](https://nodaro.ai/docs/developers/sdk/errors).

## Frequently asked questions

### How do I turn a prompt into a 3D animation with the SDK?

Run client.scene3d.generateAndWait with a prompt to get an editable scene plan, change it with editAndWait if you like, then render it to an MP4 with renderAndWait.

### What does rendering a 3D scene cost?

The price follows the plan's frame size: 50 credits up to 1,920 pixels on the longest side, 75 credits up to 5.12 megapixels, and 125 credits above that. Read the current prices from the model-cost API.

### What is 3D Render Pro?

One operation that authors a scene and renders it: the result carries both the scene plan and the MP4. Quote it first with quotePro. Check capabilities().pro to see whether your install offers it.

### Can I open a shot still URL in an img tag?

No. Shot stills and other delivery files are served by an authenticated endpoint. Fetch them with the same credentials you used to run the job, then show or store the bytes.
