3D 장면 뷰포트 임베드
Nodaro의 3D 장면 뷰포트를 내 앱에 프레임으로 넣고 postMessage로 제어하세요. 핸드셰이크와 상태 메시지부터 편집 이벤트, 에셋 전송, 제한까지 설명합니다.
3D 장면 뷰포트는 Nodaro의 3D 프리비즈 뷰어로, 내 앱 안에 /embed/scene3d로 프레임을 넣을 수 있습니다. Nodaro 에디터가 쓰는 것과 같은 뷰어로, three.js 뷰포트, 재생 컨트롤, 오브젝트 목록, 숫자 포즈 편집기, 리비전 기록을 갖추고 있습니다. 프레임은 전적으로 postMessage로 제어됩니다. 내 페이지가 장면을 보관하고, 프레임은 그것을 그리며 사용자가 한 일을 알려 줍니다.
이것은 미니앱 임베드가 아닙니다. 미니앱은 게시된 워크플로를 실행하지만, 뷰포트는 아무것도 실행하지 않습니다. 차이는 임베드를, 뷰포트가 그리는 데이터는 3D 장면 형식을 참고하세요.
프레임이 하는 일과 하지 않는 일
| 읽는 것 | URL의 parentOrigin과 channel, 그리고 정확히 그 출처에서 온 state 메시지만 읽습니다. |
| 그리는 것 | 내가 보낸 장면을 그립니다. 뷰포트, 재생과 탐색, 오브젝트 목록, 각 오브젝트와 카메라의 포즈 편집기, 리비전 기록, 대기 중인 리비전 알림입니다. 베이크된 장면(버전 2)에서는 베이크된 카메라, 샷 탐색, 식별 색상이 있는 시맨틱 엔티티, 오버레이 컨트롤도 그립니다. |
| 보내는 것 | 수신을 시작하면 ready를 보내고, 그다음 사용자 동작마다 event를 하나씩, 버전 2 장면에서는 선언된 에셋마다 asset-request를 하나씩 보냅니다. |
| 절대 하지 않는 것 | 인증, 저장소 읽기와 쓰기, /v1 엔드포인트 호출, 새로 고침 후에도 무언가를 유지하는 일, 편집 후 스스로 화면을 다음 리비전으로 넘기는 일입니다. |
프레임은 의도적으로 상태를 갖지 않습니다. 사용자가 편집을 확정하면 프레임은 새로운 불변 리비전을 보내고, 내가 결과를 돌려보낼 때까지 이전 리비전을 계속 보여 줍니다. 부모보다 앞서 나가는 프레임은 끝내 저장되지 않는 리비전을 보여 줄 수 있기 때문입니다.
프레임은 비밀을 갖고 있지 않으므로, 내가 완전히 제어하지 않는 페이지에서 불러와도 안전합니다. 프레임에 보내는 장면 데이터의 기밀성은 메시지를 보내는 출처에 달려 있으므로, 항상 정확한 출처로 보내세요. 부모 페이지 체크리스트를 참고하세요.
뷰포트 사용 가능 여부 확인
뷰포트가 포함된 배포에서는 GET /v1/nodes에서 3D 장면 생성 노드가 scene3d-embed-v1 기능을 표시합니다. 임베드 편집기를 제공하기 전에 이를 확인하세요.
URL
https://app.nodaro.ai/embed/scene3d?parentOrigin=<origin>&channel=<uuid>| 매개변수 | 필수 | 규칙 |
|---|---|---|
parentOrigin | 예 | 프레임을 넣는 페이지의 정확하고 정규화된 출처입니다. 스킴, 호스트, 선택적 포트만 있고 그 밖에는 아무것도 없어야 합니다. new URL(value).origin이 값과 같아야 합니다. http:와 https:만 허용되며 최대 255자입니다. 끝의 슬래시, 경로, 쿼리, 사용자 정보, null, 그 밖의 스킴은 거부됩니다. |
channel | 예 | 마운트한 프레임마다 하나씩, crypto.randomUUID()로 만든 새로운 무작위 UUID입니다. 같은 페이지의 두 뷰포트나, 닫힌 대화 상자에 남은 오래된 프레임이 서로를 다시 그리지 않도록 막습니다. |
MessageEvent.origin은 브라우저가 정규화한 형태로 도착합니다. 매개변수를 같은 표기로 요구하면 프레임이 추측하지 않고 둘을 정확히 비교할 수 있습니다. 매개변수가 없거나 형식이 잘못되면 프레임은 오류를 표시하고, 아무것도 보내지 않으며, 아무것도 수신하지 않습니다.
핸드셰이크
- 내 페이지가 URL로 프레임을 추가합니다.
- 프레임이 마운트되고 수신을 시작합니다.
nodaro:scene3d:ready를 보내며, 내가 응답할 때까지 반복합니다. - 내 페이지가
nodaro:scene3d:state메시지로 응답합니다. 프레임이 장면을 검증하고 그립니다. - 사용자가 동작하면 프레임이
nodaro:scene3d:event를 보냅니다. - 내 페이지가
expectedRevisionId를 확인하고, 이벤트를 적용한 다음, 새로운 사실인 새state를 보냅니다.
첫 번째 state는 iframe의 load 이벤트가 아니라 ready에 대한 응답으로 보내세요. load는 프레임의 리스너가 준비되기 전에 발생하므로, 그 시점에 맞춘 메시지는 버려질 수 있습니다. 프레임은 짧고 제한된 일정으로 ready를 반복하므로, 부모가 조금 늦게 수신을 시작해도 괜찮습니다. 프레임에 보낸 첫 메시지를 받으면, 그 메시지가 거부되더라도 반복을 멈춥니다. 거부되었다는 것 자체가 내가 수신 중이라는 증거이기 때문입니다.
부모에서 프레임으로: state
메시지 타입은 하나이며, 전체 스냅샷을 담습니다. 부분 업데이트는 없으므로, 그리고 싶은 상태 전체를 항상 보내세요.
{
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
}기록 항목은 다음과 같습니다.
{
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은 @nodaro/shared 패키지의 공개 Scene3DPlan으로, 3D 장면 생성이나 3D 장면 편집 작업이 output_data.scenePlan으로 반환하는 것과 같은 형태입니다.
readOnly의 기본값은true입니다. 아무 말이 없으면 보기만 하고 건드리지 말라는 뜻입니다. 편집 가능한 프레임에는readOnly: false를 명시해야 합니다.context는prompt만 유지하며, 이 값은 복원 버튼의 툴팁이 됩니다.context안의 다른 키는 아무 알림 없이 버려집니다. 그 밖의 위치에 있는 알 수 없는 키는 거부됩니다. 버전 관리를 참고하세요.isGenerating: true는 리비전이 생성 중이라는 안내만 추가합니다. 장면은 계속 활성 상태이며, 읽기 전용이 아니면 편집할 수 있습니다.pendingPlan은 사용자가 편집한 뒤에 끝난 작업을 처리합니다. 프레임은 알림을 표시하며, 편집 가능한 경우 리비전을 고르는 버튼 두 개를 보여 줍니다.
프레임이 무시하는 것과 거부하는 것
| 프레임이 받는 것 | 처리 |
|---|---|
event.source가 프레임을 넣은 창이 아닌 메시지 | 아무 표시 없이 무시합니다. |
event.origin이 parentOrigin이 아닌 메시지 | 아무 표시 없이 무시합니다. |
다른 type, 다른 channel, 또는 객체가 아닌 페이로드 | 아무 표시 없이 무시합니다. |
알 수 없는 version | 눈에 보이게 거부합니다. |
| 형식이 잘못된 봉투, 알 수 없는 키, 또는 제한을 넘는 목록 | 눈에 보이게 거부합니다. |
검증에 실패한 scenePlan, pendingPlan, 또는 history[].scenePlan | 눈에 보이게 거부합니다. |
다른 프레임의 메시지는 사용자의 문제가 아니므로 무시는 조용히 이루어집니다. 잘못된 것은 내 메시지이므로 거부는 눈에 보이게 이루어집니다. 거부해도 이미 받아들인 데이터는 절대 사라지지 않습니다. 마지막으로 받아들인 스냅샷이 이유를 알려 주는 배너 아래에 그대로 표시되며, 다음 유효한 state가 오면 배너가 사라집니다.
프레임에서 부모로: ready와 event
프레임은 두 메시지를 모두 URL의 정확한 parentOrigin으로 보내며, "*"로는 절대 보내지 않습니다.
{
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
},
}ready는 type과 channel로 판별하고, protocolVersions를 읽으세요. 메시지 전체를 비교하지 마세요. version은 1로 유지되므로 버전 1용으로 작성한 부모는 계속 작동합니다. 버전 2가 추가하는 모든 것은 그런 부모가 읽지 않는 필드로 알려집니다.
{
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 },
}봉투에 version: 2가 붙는 것은 edit-operations뿐입니다. 다른 모든 이벤트 종류는 여전히 1이므로, 버전 1 부모는 자신이 이해하는 메시지에서 알 수 없는 버전을 만나는 일이 없습니다.
expectedRevisionId는 사용자가 동작했을 때 프레임이 보여 주던 리비전, 즉 동작을 계산한 기준입니다. 이 값이 여전히 내 활성 scenePlan.revisionId와 같을 때만 이벤트를 적용하고, 그렇지 않으면 버리세요. 이 확인 하나로, 이미 교체한 스냅샷에서의 클릭이 조용한 롤백이 되지 않고 아무 일도 일어나지 않게 됩니다. 이 값은 프레임이 아직 아무 상태도 받아들이지 않았을 때만 null입니다.
kind | 의미 | 할 일 |
|---|---|---|
plan | 숫자 포즈나 색상 변경 같은 로컬 편집이 새로운 불변 리비전을 만들었습니다. plan.parentRevisionId는 expectedRevisionId입니다. 모델은 실행되지 않았고 아무것도 청구되지 않았습니다. | plan을 활성 리비전으로 만들고, 기록에 추가한 다음, 새 state를 보내세요. |
selection | 사용자가 오브젝트를 선택하거나 선택 해제했습니다. | 그대로 반영하고, state를 보내거나 기억만 해 두세요. |
locks | 사용자가 오브젝트를 잠그거나 잠금 해제했습니다. 잠긴 오브젝트는 편집 작업이 바이트 단위까지 그대로 두어야 하는 오브젝트입니다. | 저장하고 state를 보내세요. |
restore | 사용자가 내가 보낸 기록에서 이전 리비전을 요청했습니다. | 그 리비전을 활성화하고 state를 보내세요. |
resolve-pending | adopt: true는 pendingPlan을 사용하고, false는 현재 플랜을 유지합니다. | 처리한 다음 pendingPlan 없이 state를 보내세요. |
edit-operations | 연산으로 표현된 버전 2 오버레이 편집입니다. 프레임은 이를 적용하지 않습니다. | 공유 적용 함수로 적용하고, 결과를 저장한 다음, 저장한 플랜을 돌려보내세요. 베이크된 장면 편집을 참고하세요. |
읽기 전용 모드
기본값이 true인 readOnly는 뷰어 전체를 유지하되 모든 쓰기를 막습니다.
| 계속 작동하는 것 | 막히는 것 |
|---|---|
| 재생, 일시 정지, 탐색 | 오브젝트와 카메라의 숫자 포즈 확정 |
| 뷰포트와 목록에서의 선택 | 오브젝트와 배경 색상 |
| 오브젝트 목록, 잠금 배지, 리비전 기록 보기 | 잠금 전환 |
| 대기 중인 리비전 알림 | 복원, 그리고 대기 중인 리비전 버튼 |
읽기 전용 프레임이 보내는 이벤트는 selection뿐입니다. 이 규칙은 양방향으로 지켜집니다. 장면을 바꿀 컨트롤은 비활성화되거나 숨겨지며, 컨트롤을 직접 조작하더라도 프레임은 변경을 보내지 않습니다. 읽기 전용은 스타일이 아니라 프레임의 속성입니다.
베이크된 장면과 에셋 전송
버전 1 장면은 그 자체로 완결됩니다. 기본 도형, 키프레임, 카메라가 모두 내가 보내는 플랜 안에 있습니다. 버전 2 장면은 베이크된 형상입니다. 매니페스트에는 시맨틱 엔티티, 샷, 베이크된 카메라 트랙, SHA-256 다이제스트가 붙은 에셋 ID 목록이 담기며, 바이트 자체는 Nodaro의 인증된 API 뒤에 남습니다.
프레임에는 여전히 세션이 없고 네트워크 호출도 하지 않습니다. 대신 매니페스트가 선언한 에셋만 정확히 나에게 요청하고, 나는 자체 세션으로 그것을 가져옵니다.
켜기
state 메시지에 version: 2를 보내세요. version: 1 메시지에 버전 2 장면을 담으면 scenePlan — this scene uses schema version 2, which needs embed protocol version 2 (asset transport)로 거부됩니다. 버전 1 부모에는 에셋 요청을 처리할 핸들러가 없어서 프레임이 영원히 기다리게 되기 때문입니다. 버전 1 장면은 어느 버전에서도 작동합니다.
메시지
// 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는 플랜의 현재 저장된 리비전입니다. 저장된 각 리비전은 이전 리비전에서 다시 사용하는 바이트를 포함해 모든 에셋을 고정합니다.
프레임이 확인하는 것
프레임은 자신을 위한 것이 아닌 일반적인 메시지를 아무 표시 없이 무시합니다.
- 다른 창, 다른 출처, 또는 다른
channel에서 온 응답. - 시간 초과 후 늦게 도착한 응답, 중복 응답, 아무도 요청하지 않은 바이트처럼 처리 중이 아닌
requestId.
다음 경우에는 해당 에셋 하나를 눈에 보이게 실패 처리합니다.
version이2가 아니거나, 알 수 없는 필드가 있거나 필드가 누락되었습니다.revisionId나assetId가 요청한 것과 다릅니다.- 응답이
ok: false입니다. 프레임은 내error를 줄여서 표시합니다. bytes가ArrayBuffer가 아닙니다.- 길이가 매니페스트의
byteLength와 다릅니다. - SHA-256 다이제스트가 매니페스트의 다이제스트와 다릅니다.
중요한 것은 다이제스트 확인입니다. 이 확인이 “호스트가 어떤 바이트를 전달했다”와 “이것이 이 리비전을 이루는 바이트다”를 구분합니다. 렌더러는 파싱하기 전에 다이제스트를 한 번 더 확인하므로, 어떤 경로로도 검증되지 않은 형상이 그려지지 않습니다.
프레임은 스스로에게도 제한을 둡니다.
- 동시에 처리 중인 요청은 최대 4개이며, 나머지는 대기열에서 기다립니다.
- 리비전당 최대 64개 요청과 64MiB까지 허용됩니다.
- 각 요청은 20초 안에 응답해야 합니다.
- 새 리비전이 도착하거나 프레임이 언마운트되면 처리 중인 모든 요청이 거부되므로, 사용자가 떠난 장면에 대한 응답은 절대 그려지지 않습니다.
- ID와 다이제스트가 같은 동일한 에셋은 한 번만 요청되므로, GLB를 다시 사용하는 오버레이 리비전은 그것을 다시 다운로드하지 않습니다.
내 페이지의 규칙
- 내가 보낸 것을 기준으로 승인하세요.
request.revisionId가 내가 보낸 리비전(plan.revisionId)이고,request.assetId가 그 플랜의assets에 같은byteLength와sha256으로 있을 때만 응답하세요. 그렇지 않으면ok: false로 응답하세요. 이 확인을 건너뛰면 내 세션이 오라클이 됩니다. 프레임을 넣은 페이지를 제어하는 사람은 누구든 아무 리비전이나 에셋을 요청해 그 응답을 읽을 수 있습니다. - 자격 증명을 절대 보내지 마세요. 토큰도, 쿠키도, 서명된 URL도 안 됩니다. 접근 권한을 주는 URL은 모양만 다른 Bearer 토큰입니다. 바이트를 보내세요.
- 프레임의 정확한 출처로 보내세요.
"*"로는 절대 보내지 마세요. - 내가 계속 쓸 버퍼는 전송하지 마세요. 구조화된 복제는 버퍼를 복사하지만, 전송 목록은 버퍼를 내게서 가져가 버립니다.
- 이미 교체한 리비전에 대한 요청은 버리세요. 그리고 리비전과 에셋마다 처리 중인 가져오기는 최대 하나만 두세요.
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" })
}
})베이크된 장면 편집
버전 2 편집은 오버레이이며, 프레임은 오버레이를 절대 스스로 적용하지 않습니다. 내 서버가 저장하지 않은 버전 2 리비전은 저장된 것처럼 보이지만, 그 에셋은 여전히 원래 리비전에 속해 있기 때문입니다. 그래서 프레임은 연산을 보내고, 내가 보낸 리비전을 계속 보여 줍니다.
// 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
}오래된 상태를 확인하는 세 가지 값을 모두 API가 쓰는 것과 같은 공유 적용 함수에 전달한 다음, 결과를 저장하고 돌려보내세요.
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, ...재정의는 자신이 바꾸는 채널만 지정하며, 프레임은 의도적으로 그렇게 만듭니다. 에셋 엔티티에서는 GLB 파일의 노드 변환이 기준이므로, 무언가를 이동한 편집이 건드리지도 않은 회전을 다시 지정해서는 안 됩니다. 생성 작업 없이 편집을 저장하려면 POST /v1/3d-scene/revisions/:revisionId/edits를 호출하거나, SDK에서 client.scene3d.applyEdits()를 사용하세요. 3D 장면 API를 참고하세요.
검증과 제한
scenePlan, pendingPlan, 그리고 모든 history[].scenePlan은 @nodaro/shared의 공개 scene3DPlanSchema로 파싱됩니다. 이 확인은 구조와 필드 간 규칙을 다룹니다. 부모 순환, 누락된 부모, 마지막 프레임 이후의 키프레임, 길이 상한을 검사합니다. 검증에 실패한 플랜은 통째로 거부됩니다.
| 제한 | 값 |
|---|---|
history 항목 | 12개 |
selectedObjectIds 또는 lockedObjectIds 항목 | 100개 |
| 오브젝트 ID 길이 | 64자 |
changeSummary와 context.prompt 길이 | 2,000자 |
createdAt 길이 | 64자 |
parentOrigin 길이 | 255자 |
| 프레임 너비와 높이 | 각 축 100~2,560px |
| 장면당 오브젝트 | 100개 |
| 오브젝트 또는 카메라 트랙당 키프레임 | 240개 |
| 버전 2 장면의 엔티티 | 100개 |
| 버전 2 장면의 에셋, 샷, 재정의 | 64개, 32개, 200개 |
| 버전 2 장면이 선언할 수 있는 에셋 바이트 | 64MiB |
프레임은 무엇이든 파싱하기 전에 목록 길이를 확인하므로, 너무 큰 페이로드는 읽지도 않고 거부됩니다. 버전 2 매니페스트는 바이트에 대한 약속이므로, 에셋을 하나라도 요청하기 전에 선언된 합계를 확인합니다. 긴 변이 1,920px보다 큰 프레임은 더 높은 가격으로 MP4로 렌더링됩니다. 동영상 렌더링을 참고하세요.
부모 페이지 체크리스트
프레임은 자신의 받은 편지함을 지킵니다. 내 받은 편지함은 내 페이지만 지킬 수 있습니다.
- 프레임마다
crypto.randomUUID()로 새channel을 만들고 보관하세요. - 직접 조합한 문자열이 아니라
window.location.origin에서 얻은 내 정확한 출처로 URL을 만드세요. - 받는 모든 메시지에서 네 가지를 확인하세요.
event.source === iframe.contentWindow.event.origin이 정확히 프레임으로 넣은 Nodaro 출처입니다.data.channel이 내 채널입니다.data.version이 내가 구현한 버전입니다.1, 또는 에셋을 제공한다면2입니다.
plan,restore,resolve-pending이벤트를 적용하기 전에expectedRevisionId를 내 활성 리비전과 비교하고, 일치하지 않으면 이벤트를 버리세요.- 저장하기 전에
scene3DPlanSchema로plan을 다시 검증하세요. 프레임은 자신이 그리는 것을 검증하고, 저장하는 것은 내 책임입니다. ready를 받으면state를 보내고, 변경을 받아들일 때마다 다시 보내세요."*"로 절대 보내지 마세요. Nodaro 출처를 명시적으로 지정하세요.- 메시지에 비밀을 넣지 마세요. 프로토콜은 장면 형상과 리비전 ID를 전달합니다. 토큰, 사용자 식별자, 장면 레퍼런스에 넣지 않을 URL은 넣지 마세요.
- 대화 상자가 닫히면 리스너를 제거하세요. 오래된 리스너와 재사용된 채널이 만나면 두 대화 상자가 서로에게 응답하기 시작합니다.
- 버전 2 장면을 제공한다면, 내 페이지의 규칙에 설명한 대로 내가 보낸 플랜을 기준으로 모든 에셋 요청을 승인하세요.
예제
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()모델로 장면을 생성하고 편집하는 일과 장면을 MP4로 렌더링하는 일은 일반 API 작업입니다. 3D 장면 생성, 3D 장면 편집, 동영상 렌더링을 참고하세요. 뷰포트는 뷰어일 뿐이며, 작업을 시작하지도 않고 아무것도 소비하지 않습니다.
버전 관리
version: 1은 고정된 첫 번째 계약입니다. state 봉투는 엄격합니다. 알 수 없는 최상위 키나 기록 항목 안의 알 수 없는 키는 무시되지 않고 거부되므로, 부모와 프레임이 메시지의 의미에 대해 절반만 합의하는 일이 없습니다. 프로토콜은 version을 올리는 방식으로 확장되며, 어떤 버전을 구현하지 않은 프레임은 추측하지 않고 그 메시지를 눈에 보이게 거부합니다.
version: 2는 버전 1에 기능을 더할 뿐 아무것도 대체하지 않습니다. 베이크된 장면, 에셋 전송, edit-operations 이벤트를 제공하며, 버전 1만 쓰는 부모는 바뀐 것 없이 계속 작동합니다. 프레임은 ready.protocolVersions와 ready.capabilities로 자신이 할 수 있는 일을 알립니다. 추측하지 말고 이 필드를 읽으세요. 그래야 다음 버전도 추가 방식으로 유지됩니다. 존재하지 않는 필드가 필요하다면 그냥 보내지 말고 공개 저장소에 이슈를 여세요. 메시지가 거부되는 것은 계약이 제대로 작동하고 있다는 뜻입니다.
자주 묻는 질문
3D 장면 뷰포트에 토큰이나 세션이 필요한가요?
아니요. 프레임은 인증을 하지 않고, 저장소를 읽거나 쓰지 않으며, Nodaro API를 호출하지 않습니다. 내 페이지가 postMessage로 장면을 보내고, 베이크된 장면이라면 내 페이지가 자체 세션으로 에셋 바이트를 가져와 그 바이트를 보냅니다.
편집 후에도 뷰포트에 이전 리비전이 계속 보이는 이유는 무엇인가요?
의도된 동작입니다. 프레임은 새 리비전을 이벤트로 보낸 뒤 기다립니다. 내 페이지가 새 리비전을 저장하고 상태 메시지로 돌려보낸 뒤에야 새 리비전을 보여 주므로, 프레임은 저장되지 않은 리비전을 절대 보여 줄 수 없습니다.
임베드한 뷰포트에서 편집할 수 있나요?
상태 메시지에 readOnly false가 있을 때만 가능합니다. 기본은 읽기 전용이며, 이때 사용자는 재생, 탐색, 선택을 할 수 있고 프레임은 선택 이벤트만 보냅니다.
첫 번째 상태 메시지는 언제 보내야 하나요?
iframe의 load 이벤트가 아니라 프레임의 ready 메시지에 대한 응답으로 보내세요. load 이벤트는 프레임이 수신을 시작하기 전에 발생하므로, 그때 보낸 메시지는 사라질 수 있습니다.
배포에서 뷰포트를 제공하는지 어떻게 알 수 있나요?
뷰포트가 포함된 배포에서는 GET /v1/nodes에서 3D 장면 생성 노드가 scene3d-embed-v1 기능을 표시합니다. 임베드 편집기를 제공하기 전에 이를 확인하세요.
관련 문서
3D 장면 형식
임베드
3D 장면 생성
3D 장면
3D 장면
최종 업데이트