# Embed the 3D scene viewport

> Frame Nodaro's 3D scene viewport in your app and drive it with postMessage, from the handshake and state messages to edit events, asset transport and limits.

Source: https://nodaro.ai/docs/developers/embed/scene3d

The **3D scene viewport** is Nodaro's 3D previsualization viewer, which you can frame inside your own app at `/embed/scene3d`. It is the same viewer the Nodaro editor uses: a three.js viewport, playback controls, an object list, numeric pose editors and a revision history. The frame is driven entirely by `postMessage`: your page keeps the scene, and the frame draws it and reports what the user did.

This is not the MiniApp embed. A MiniApp runs a published workflow; the viewport runs nothing. See [Embeds](https://nodaro.ai/docs/developers/embed) for the difference, and [3D scene format](https://nodaro.ai/docs/developers/embed/scene3d-format) for the data it draws.

## What the frame does and does not do

| | |
| --- | --- |
| **Reads** | Only the `parentOrigin` and `channel` in its URL, and `state` messages from that exact origin. |
| **Draws** | The scene you send: the viewport, playback and scrubbing, the object list, the pose editors of each object and of the camera, the revision history and the pending-revision notice. For a baked (version 2) scene: the baked camera, shot navigation, the semantic entities with their identity colors, and the overlay controls. |
| **Sends** | `ready` once it listens, then one `event` per user action, and one `asset-request` per declared asset of a version 2 scene. |
| **Never does** | Authenticate, read or write storage, call a `/v1` endpoint, keep anything across a reload, or move its own view forward after an edit. |

The frame is **stateless by design**. When the user commits an edit, the frame sends you a new, immutable revision, and keeps showing the old one until *you* send the result back. A frame that ran ahead of its parent could show a revision that never gets saved.

The frame holds no secret and is safe to load in a page you do not fully control. The scene data you send it is only as private as the origin you address it to, so always send to an exact origin; see the [parent checklist](#parent-checklist).

## Check that the viewport is available

On a deployment that includes the viewport, the [Generate 3D Scene](https://nodaro.ai/docs/nodes/video/generate-3d-scene) node advertises the `scene3d-embed-v1` capability in `GET /v1/nodes`. Check it before you offer the embedded editor.

## The URL

```text
https://app.nodaro.ai/embed/scene3d?parentOrigin=<origin>&channel=<uuid>
```

| Parameter | Required | Rule |
| --- | --- | --- |
| `parentOrigin` | Yes | The exact, normalized origin of the framing page: a scheme, a host and an optional port, nothing else. `new URL(value).origin` must equal the value. `http:` and `https:` only, up to 255 characters. A trailing slash, a path, a query, user information, `null` or any other scheme is refused. |
| `channel` | Yes | A fresh random UUID, from `crypto.randomUUID()`, one per mounted frame. It keeps two viewports on the same page, or a stale frame from a closed dialog, from repainting each other. |

`MessageEvent.origin` arrives normalized by the browser. Requiring the parameter in the same spelling lets the frame compare the two exactly instead of guessing. If a parameter is missing or malformed, the frame shows an error, sends nothing and listens to nothing.

## The handshake

1. Your page adds the frame with its URL.
2. The frame mounts and starts listening. It sends `nodaro:scene3d:ready`, and repeats it until you answer.
3. Your page answers with a `nodaro:scene3d:state` message. The frame validates the scene and draws it.
4. When the user acts, the frame sends a `nodaro:scene3d:event`.
5. Your page checks `expectedRevisionId`, applies the event and sends a new `state`: the new truth.

**Send your first `state` in response to `ready`, not on the iframe's `load` event.** `load` fires before the frame's listener is ready, so a message timed on it can be dropped. The frame repeats `ready` on a short, bounded schedule, which covers a parent that starts listening slightly late. It stops after your first message addressed to it, even one it refuses: a refusal still proves you are listening.

## Parent to frame: state

There is one message type, and it carries a full snapshot. There is no partial update: always send the complete state you want drawn.

```ts
{
type: "nodaro:scene3d:state",
version: 1 | 2,                     // 2 enables baked (version 2) scenes, see below
channel: string,                    // must equal the channel in the URL
scenePlan: Scene3DPlan,             // required, validated
selectedObjectIds?: string[],       // default []
lockedObjectIds?: string[],         // default []
history?: Scene3DRevisionEntry[],   // default []
pendingPlan?: Scene3DPlan,          // default absent
isGenerating?: boolean,             // default false
readOnly?: boolean,                 // default TRUE
}
```

A history entry:

```ts
{
revisionId: string,                     // UUID
scenePlan: Scene3DPlan,                 // validated like the active plan
source: "generate" | "edit" | "manual" | "upstream",
createdAt: string,                      // your timestamp, shown as it is
changeSummary?: string,
context?: { prompt?: string },          // only prompt is kept
}
```

`scenePlan` is the public `Scene3DPlan` of the `@nodaro/shared` package: the same shape a Generate 3D Scene or Edit 3D Scene job returns in `output_data.scenePlan`.

- **`readOnly` defaults to `true`.** Silence means look, do not touch. An editable frame must say `readOnly: false`.
- **`context` keeps only `prompt`,** which becomes the tooltip of the restore button. Other keys inside `context` are dropped silently. Unknown keys anywhere else are refused; see [Versioning](#versioning).
- **`isGenerating: true`** only adds a note that a revision is being generated. The scene stays live and, when it is not read-only, editable.
- **`pendingPlan`** covers a job that finished after the user edited. The frame shows a notice and, when editable, two buttons to choose a revision.

### What the frame ignores and what it refuses

| The frame receives | It |
| --- | --- |
| A message whose `event.source` is not the framing window | Ignores it, silently. |
| A message whose `event.origin` is not `parentOrigin` | Ignores it, silently. |
| Another `type`, another `channel`, or a payload that is not an object | Ignores it, silently. |
| An unknown `version` | Refuses it, visibly. |
| A malformed envelope, an unknown key or a list over its limit | Refuses it, visibly. |
| A `scenePlan`, `pendingPlan` or any `history[].scenePlan` that fails validation | Refuses it, visibly. |

Ignoring is silent, because another frame's traffic is not the user's problem. Refusing is visible, because it was *your* message that was wrong. **A refusal never destroys accepted data:** the last accepted snapshot stays on screen under a banner that names the reason, and the next valid `state` clears it.

## Frame to parent: ready and event

The frame posts both messages to the exact `parentOrigin` of its URL, never to `"*"`.

```ts
{
type: "nodaro:scene3d:ready",
version: 1,                              // the baseline, always 1
channel: string,
protocolVersions: [1, 2],                // every version this frame accepts
capabilities: {
assetTransport: true,                  // it can ask you for asset bytes
sceneSchemaVersions: [1, 2],           // the scene versions it can draw
},
}
```

**Match `ready` on `type` and `channel`, and read `protocolVersions`.** Do not compare the whole message. `version` stays `1`, so a parent written for version 1 keeps working; everything version 2 adds is announced in fields such a parent does not read.

```ts
{
type: "nodaro:scene3d:event",
version: 1 | 2,                     // 2 ONLY for edit-operations
channel: string,
expectedRevisionId: string | null,
event:
| { kind: "plan", plan: Scene3DPlan, changeSummary: string }
| { kind: "selection", objectIds: string[] }
| { kind: "locks", objectIds: string[] }
| { kind: "restore", revisionId: string }
| { kind: "resolve-pending", adopt: boolean }
// version 2 scenes only, see "Baked scenes" below
| { kind: "edit-operations", operations: Scene3DV2EditOperation[], expectedContentHash: string },
}
```

The envelope says `version: 2` only for `edit-operations`. Every other event kind still says `1`, so a version 1 parent never meets an unknown version on a message it understands.

**`expectedRevisionId` is the revision the frame showed when the user acted,** the base the action was computed from. Apply the event only if it still equals your active `scenePlan.revisionId`, and drop it otherwise. That one check turns a click on a snapshot you already replaced into nothing, instead of a silent rollback. It is `null` only before the frame accepted any state.

| `kind` | Meaning | What you do |
| --- | --- | --- |
| `plan` | A local edit, such as a numeric pose or a color change, produced a new immutable revision. `plan.parentRevisionId` is `expectedRevisionId`. No model ran and nothing was billed. | Make `plan` the active revision, add it to the history and send a new `state`. |
| `selection` | The user selected or deselected objects. | Mirror it, and send `state` or just remember it. |
| `locks` | The user locked or unlocked objects. A locked object is one an edit job must leave byte-identical. | Save it and send `state`. |
| `restore` | The user asked for an earlier revision from the history you sent. | Make it active and send `state`. |
| `resolve-pending` | `adopt: true` uses `pendingPlan`; `false` keeps the current plan. | Resolve it and send `state` without `pendingPlan`. |
| `edit-operations` | A version 2 overlay edit, as operations. The frame does not apply it. | Apply it with the shared applier, save the result and send the saved plan back. See [Edit a baked scene](#edit-a-baked-scene). |

## Read-only mode

`readOnly`, which defaults to `true`, keeps the whole viewer and withholds every write:

| Still works | Withheld |
| --- | --- |
| Play, pause and scrub | Numeric pose commits, for objects and the camera |
| Selecting in the viewport and in the list | Object and background colors |
| Reading the object list, the lock badges and the revision history | Lock toggles |
| The pending-revision notice | Restore, and the pending-revision buttons |

The only event a read-only frame emits is `selection`. The rule holds both ways: the controls that would change the scene are disabled or hidden, and the frame refuses to emit a change even if a control is driven directly. Read-only is a property of the frame, not a style.

## Baked scenes and the asset transport

A **version 1** scene is self-contained: primitives, keyframes and a camera, all inside the plan you send. A **version 2** scene is baked geometry. Its manifest carries semantic entities, shots, a baked camera track and a list of asset ids with SHA-256 digests, while the bytes themselves stay behind Nodaro's authenticated API.

The frame still has no session and makes no network call. Instead, it asks *you* for exactly the assets the manifest declares, and you fetch them with your own session.

### Turn it on

Send `version: 2` on your `state` message. A version 2 scene on a `version: 1` message is refused with `scenePlan — this scene uses schema version 2, which needs embed protocol version 2 (asset transport)`: a version 1 parent has no handler for asset requests, and the frame would wait forever. Version 1 scenes work with either version.

### The messages

```ts
// frame to parent
{
type: "nodaro:scene3d:asset-request",
version: 2,
channel: string,
requestId: string,                 // new for each request; send it back unchanged
revisionId: string,                // plan.revisionId
assetId: string,                   // an opaque id from plan.assets
kind: "glb" | "camera-track-json",
byteLength: number,                // what the manifest declares
sha256: string,                    // 64 lower-case hex characters
}

// parent to frame, success
{
type: "nodaro:scene3d:asset-response",
version: 2,
channel: string,
requestId: string,                 // echoed
revisionId: string,                // echoed
assetId: string,                   // echoed
ok: true,
bytes: ArrayBuffer,                // a structured clone: NOT a URL, NOT base64
}

// parent to frame, failure
{ /* same envelope */ ok: false, error: "short reason" }
```

`revisionId` is the current retained revision of the plan. Each retained revision pins all its assets, including bytes it reuses from an earlier revision.

### What the frame checks

The frame silently ignores normal traffic that is not for it:

- A response from another window, another origin or another `channel`.
- A `requestId` that is not outstanding, such as a late answer after a timeout, a duplicate, or bytes nobody asked for.

It fails that one asset, visibly, in these cases:

- `version` is not `2`, or a field is unknown or missing.
- `revisionId` or `assetId` is not the one requested.
- The answer is `ok: false`. The frame shows your `error`, shortened.
- `bytes` is not an `ArrayBuffer`.
- The length is not the manifest's `byteLength`.
- **The SHA-256 digest is not the manifest's digest.**

The digest check is the one that matters: it separates "the host delivered some bytes" from "these are the bytes this revision is made of". The renderer checks the digest a second time before parsing, so no path draws unverified geometry.

The frame also bounds itself:

- At most **4 requests are in flight**, and the rest wait in a queue.
- At most **64 requests** and **64 MiB** are allowed per revision.
- Each request has **20 seconds** to answer.
- Every outstanding request is rejected when a new revision arrives or the frame unmounts, so an answer for a scene the user left is never drawn.
- Identical assets, with the same id and digest, are requested once, so an overlay revision that reuses a GLB does not download it again.

### Rules for your page

1. **Authorize against what you sent.** Answer only when `request.revisionId` is the revision you sent (`plan.revisionId`), and `request.assetId` is in that plan's `assets` with the same `byteLength` and `sha256`. Otherwise answer `ok: false`. Skip this check, and your session becomes an oracle: whoever controls the framed page could ask for any revision or asset and read the answer.
2. **Never send a credential.** No token, no cookie and no signed URL: a URL that grants access is a bearer token spelled differently. Send bytes.
3. **Post to the frame's exact origin,** never to `"*"`.
4. **Do not transfer a buffer you keep.** A structured clone copies it; a transfer list would take it away from you.
5. **Drop requests for a revision you have replaced,** and keep at most one fetch in flight per revision and asset.

```ts

const client = createClient({ baseUrl: "https://app.nodaro.ai", auth: myAuth })

/** The plan you sent most recently. */
let active: Scene3DPlanV2

window.addEventListener("message", async (event) => {
if (event.source !== iframe.contentWindow) return
if (event.origin !== NODARO_ORIGIN) return
const data = event.data
if (data?.type !== "nodaro:scene3d:asset-request") return
if (data.channel !== channel || data.version !== 2) return

const reply = (body: Record<string, unknown>) =>
iframe.contentWindow?.postMessage(
{
type: "nodaro:scene3d:asset-response",
version: 2,
channel,
requestId: data.requestId,
revisionId: data.revisionId,
assetId: data.assetId,
...body,
},
NODARO_ORIGIN,
)

// 1. Authorize: the asset must belong to the plan we sent, and the request
//    must name the revision that plan pins the bytes to.
const asset = active.assets.find((a) => a.assetId === data.assetId)
const pinnedTo = active.revisionId
if (
!asset ||
data.revisionId !== pinnedTo ||
asset.byteLength !== data.byteLength ||
asset.sha256 !== data.sha256
) {
reply({ ok: false, error: "unknown asset" })
return
}

// 2. Fetch with OUR session, and send the bytes, never the URL or the token.
try {
const bytes = await client.scene3d.assetBytes(pinnedTo, asset)
reply({ ok: true, bytes })
} catch (error) {
reply({ ok: false, error: error instanceof Error ? error.message : "fetch failed" })
}
})
```

### Edit a baked scene

Version 2 edits are **overlays**, and the frame never applies one itself. A version 2 revision your server has not stored would look saved while its assets still belong to the revision it came from. So the frame sends the operations and keeps showing the revision you sent:

```ts
// event.event
{
kind: "edit-operations",
operations: [
{ op: "set-override", override: { kind: "entity-transform", entityId: "hero", space: "local", position: [3, 0.5, 0] } },
],
expectedContentHash: "<64 hex characters>",   // plan.provenance.contentHash
}
```

Pass all three stale-check values to the shared applier, the same one the API uses, then save the result and send it back:

```ts

const result = await applyScene3DV2EditOperations(active, message.event.operations, {
expectedRevisionId: message.expectedRevisionId,          // from the envelope
expectedContentHash: message.event.expectedContentHash,
lockedObjectIds,
})
if (!result.ok) return showError(result.message)           // stale_revision, locked, ...
```

An override names only the channels it changes, and the frame builds it that way on purpose. For an asset entity, the node transform in the GLB file is authoritative, so an edit that moved something must not also restate a rotation it never touched. To save an edit without a generation job, call `POST /v1/3d-scene/revisions/:revisionId/edits`, or `client.scene3d.applyEdits()` in the SDK; see the [3D scenes API](https://nodaro.ai/docs/developers/api/3d-scenes).

## Validation and limits

`scenePlan`, `pendingPlan` and every `history[].scenePlan` are parsed with the public `scene3DPlanSchema` of `@nodaro/shared`. The check covers the structure and the rules across fields: parent cycles, missing parents, keyframes after the last frame and the duration ceiling. A plan that fails is refused whole.

| Limit | Value |
| --- | --- |
| `history` entries | 12 |
| `selectedObjectIds` or `lockedObjectIds` entries | 100 |
| Length of an object id | 64 characters |
| Length of `changeSummary` and `context.prompt` | 2,000 characters |
| Length of `createdAt` | 64 characters |
| Length of `parentOrigin` | 255 characters |
| Frame width and height | 100 to 2,560 px on each axis |
| Objects per scene | 100 |
| Keyframes per object or per camera track | 240 |
| Entities in a version 2 scene | 100 |
| Assets, shots and overrides in a version 2 scene | 64, 32 and 200 |
| Asset bytes a version 2 scene may declare | 64 MiB |

The frame checks the list lengths before it parses anything, so an oversized payload is refused without being read. A version 2 manifest is a promise about bytes, so the declared totals are checked before a single asset is requested. A frame larger than 1,920 px on its longest side renders to MP4 at a higher price; see [Render Video](https://nodaro.ai/docs/nodes/video/render-video).

## Parent checklist

The frame guards its own inbox. Only your page can guard yours.

1. **Create a fresh `channel`** per frame with `crypto.randomUUID()`, and keep it.
2. **Build the URL with your own exact origin,** from `window.location.origin`, not from a string you assembled.
3. **Check four things on every message you receive.**
   - `event.source === iframe.contentWindow`.
   - `event.origin` is exactly the Nodaro origin you framed.
   - `data.channel` is your channel.
   - `data.version` is a version you implement: `1`, or `2` if you serve assets.
4. **Check `expectedRevisionId`** against your active revision before you apply a `plan`, `restore` or `resolve-pending` event, and drop the event if it does not match.
5. **Validate `plan` again with `scene3DPlanSchema`** before you store it. The frame validates what it draws; you are responsible for what you save.
6. **Send `state` on `ready`,** and again after every change you accept.
7. **Never post to `"*"`.** Address the Nodaro origin explicitly.
8. **Put nothing secret in a message.** The protocol carries scene geometry and revision ids: no tokens, no user identifiers, and no URL you would not put in a scene reference.
9. **Remove the listener when the dialog closes.** A stale listener plus a reused channel is how two dialogs start answering each other.
10. **If you serve version 2 scenes, authorize every asset request** against the plan you sent, as described in [Rules for your page](#rules-for-your-page).

## Worked example

```ts

const NODARO_ORIGIN = "https://app.nodaro.ai"
const channel = crypto.randomUUID()

const iframe = document.createElement("iframe")
iframe.src =
`${NODARO_ORIGIN}/embed/scene3d` +
`?parentOrigin=${encodeURIComponent(window.location.origin)}` +
`&channel=${channel}`
iframe.allow = "" // the frame needs no permissions

let active: Scene3DPlan = initialPlan                // your current revision
let history: RevisionEntry[] = []                    // newest last, at most 12

function pushState() {
iframe.contentWindow?.postMessage(
{
type: "nodaro:scene3d:state",
version: 1,
channel,
scenePlan: active,
selectedObjectIds: selection,
lockedObjectIds: locks,
history: history.slice(-12),
readOnly: false,          // leave it out and the frame is view-only
},
NODARO_ORIGIN,              // never "*"
)
}

function onMessage(e: MessageEvent) {
if (e.source !== iframe.contentWindow) return
if (e.origin !== NODARO_ORIGIN) return
const data = e.data
if (!data || typeof data !== "object") return
if (data.channel !== channel || data.version !== 1) return

if (data.type === "nodaro:scene3d:ready") {
pushState()                 // the frame listens: send the truth
return
}
if (data.type !== "nodaro:scene3d:event") return

// The action was computed from a revision we may have replaced already.
const mutates = data.event.kind !== "selection"
if (mutates && data.expectedRevisionId !== active.revisionId) return

switch (data.event.kind) {
case "selection":
selection = data.event.objectIds
return                    // no new state needed; the frame shows it already
case "locks":
locks = data.event.objectIds
break
case "plan": {
// Trust nothing you save.
const parsed = scene3DPlanSchema.safeParse(data.event.plan)
if (!parsed.success) return
active = parsed.data
history = [...history, {
revisionId: active.revisionId,
scenePlan: active,
source: "manual",
changeSummary: data.event.changeSummary,
createdAt: new Date().toISOString(),
}].slice(-12)
break
}
case "restore": {
const entry = history.find((h) => h.revisionId === data.event.revisionId)
if (!entry) return
active = entry.scenePlan
break
}
case "resolve-pending":
active = data.event.adopt ? pending! : active
pending = undefined
break
}
pushState()
}

window.addEventListener("message", onMessage)
document.body.appendChild(iframe)

// On teardown:
//   window.removeEventListener("message", onMessage)
//   iframe.remove()
```

Generating and editing scenes with a model, and rendering a scene to MP4, are ordinary API jobs: see [Generate 3D Scene](https://nodaro.ai/docs/nodes/video/generate-3d-scene), [Edit 3D Scene](https://nodaro.ai/docs/nodes/video/edit-3d-scene) and [Render Video](https://nodaro.ai/docs/nodes/video/render-video). The viewport is only the viewer: it never starts a job and never spends anything.

## Versioning

`version: 1` is the frozen first contract. The `state` envelope is strict: an unknown top-level key, or an unknown key inside a history entry, is refused rather than ignored, so a parent and a frame never half-agree about what a message means. The protocol grows by raising `version`, and a frame that does not implement a version refuses the message visibly instead of guessing.

`version: 2` adds to version 1 and replaces nothing. It brings baked scenes, the asset transport and the `edit-operations` event, and a parent that speaks only version 1 keeps working unchanged. The frame announces what it can do in `ready.protocolVersions` and `ready.capabilities`: read those fields instead of assuming, which keeps the next version additive too. If you need a field that does not exist, open an issue in the [public repository](https://github.com/nodaroai/app.nodaro.ai/issues) rather than sending it anyway: a refused message is the contract working.

## Frequently asked questions

### Does the 3D scene viewport need a token or a session?

No. The frame never authenticates, never reads or writes storage and never calls the Nodaro API. Your page sends the scene with postMessage, and for a baked scene it fetches the asset bytes with its own session and sends the bytes.

### Why does the viewport keep showing the old revision after an edit?

By design. The frame sends you the new revision as an event and waits. It shows the new revision only after your page saves it and sends it back in a state message, so the frame can never show a revision that was not saved.

### Is the embedded viewport editable?

Only when your state message says readOnly false. The default is read-only, where users can play, scrub and select, and the frame emits nothing but selection events.

### When should I send the first state message?

In response to the frame's ready message, not on the iframe's load event. The load event fires before the frame listens, so a message sent then can be lost.

### How do I know whether a deployment offers the viewport?

The Generate 3D Scene node advertises the scene3d-embed-v1 capability in GET /v1/nodes on a deployment that includes the viewport. Check it before you offer the embedded editor.
