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.
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 for the difference, and 3D scene 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.
Check that the viewport is available
On a deployment that includes the viewport, the Generate 3D Scene node advertises the scene3d-embed-v1 capability in GET /v1/nodes. Check it before you offer the embedded editor.
The URL
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
- Your page adds the frame with its URL.
- The frame mounts and starts listening. It sends
nodaro:scene3d:ready, and repeats it until you answer. - Your page answers with a
nodaro:scene3d:statemessage. The frame validates the scene and draws it. - When the user acts, the frame sends a
nodaro:scene3d:event. - Your page checks
expectedRevisionId, applies the event and sends a newstate: 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.
{
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:
{
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.
readOnlydefaults totrue. Silence means look, do not touch. An editable frame must sayreadOnly: false.contextkeeps onlyprompt, which becomes the tooltip of the restore button. Other keys insidecontextare dropped silently. Unknown keys anywhere else are refused; see Versioning.isGenerating: trueonly adds a note that a revision is being generated. The scene stays live and, when it is not read-only, editable.pendingPlancovers 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 "*".
{
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.
{
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. |
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
// 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
requestIdthat 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:
versionis not2, or a field is unknown or missing.revisionIdorassetIdis not the one requested.- The answer is
ok: false. The frame shows yourerror, shortened. bytesis not anArrayBuffer.- 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
- Authorize against what you sent. Answer only when
request.revisionIdis the revision you sent (plan.revisionId), andrequest.assetIdis in that plan'sassetswith the samebyteLengthandsha256. Otherwise answerok: 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. - 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.
- Post to the frame's exact origin, never to
"*". - Do not transfer a buffer you keep. A structured clone copies it; a transfer list would take it away from you.
- Drop requests for a revision you have replaced, and keep at most one fetch in flight per revision and asset.
import { createClient } from "@nodaro/sdk"
import type { Scene3DPlanV2 } from "@nodaro/shared"
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:
// 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:
import { applyScene3DV2EditOperations } from "@nodaro/shared"
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.
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.
Parent checklist
The frame guards its own inbox. Only your page can guard yours.
- Create a fresh
channelper frame withcrypto.randomUUID(), and keep it. - Build the URL with your own exact origin, from
window.location.origin, not from a string you assembled. - Check four things on every message you receive.
event.source === iframe.contentWindow.event.originis exactly the Nodaro origin you framed.data.channelis your channel.data.versionis a version you implement:1, or2if you serve assets.
- Check
expectedRevisionIdagainst your active revision before you apply aplan,restoreorresolve-pendingevent, and drop the event if it does not match. - Validate
planagain withscene3DPlanSchemabefore you store it. The frame validates what it draws; you are responsible for what you save. - Send
stateonready, and again after every change you accept. - Never post to
"*". Address the Nodaro origin explicitly. - 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.
- Remove the listener when the dialog closes. A stale listener plus a reused channel is how two dialogs start answering each other.
- If you serve version 2 scenes, authorize every asset request against the plan you sent, as described in Rules for your page.
Worked example
import { scene3DPlanSchema, type Scene3DPlan } from "@nodaro/shared"
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, Edit 3D Scene and 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 rather than sending it anyway: a refused message is the contract working.
Frequently asked questions
Related
3D scene format
Embeds
Generate 3D Scene
3D scenes
3D scenes
Last updated on
Embed a MiniApp
Run a published Nodaro MiniApp in your product with its embed code, or with your own form on the REST API, from reading its inputs to runs, results and errors.
3D scene format
How a Nodaro 3D scene plan is built, from version 1 primitives and keyframes to version 2 GLB assets, semantic entities, camera tracks, shots and edit overlays.