# 3D 장면 뷰포트 임베드

> Nodaro의 3D 장면 뷰포트를 내 앱에 프레임으로 넣고 postMessage로 제어하세요. 핸드셰이크와 상태 메시지부터 편집 이벤트, 에셋 전송, 제한까지 설명합니다.

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

**3D 장면 뷰포트**는 Nodaro의 3D 프리비즈 뷰어로, 내 앱 안에 `/embed/scene3d`로 프레임을 넣을 수 있습니다. Nodaro 에디터가 쓰는 것과 같은 뷰어로, three.js 뷰포트, 재생 컨트롤, 오브젝트 목록, 숫자 포즈 편집기, 리비전 기록을 갖추고 있습니다. 프레임은 전적으로 `postMessage`로 제어됩니다. 내 페이지가 장면을 보관하고, 프레임은 그것을 그리며 사용자가 한 일을 알려 줍니다.

이것은 미니앱 임베드가 아닙니다. 미니앱은 게시된 워크플로를 실행하지만, 뷰포트는 아무것도 실행하지 않습니다. 차이는 [임베드](https://nodaro.ai/docs/developers/embed)를, 뷰포트가 그리는 데이터는 [3D 장면 형식](https://nodaro.ai/docs/developers/embed/scene3d-format)을 참고하세요.

## 프레임이 하는 일과 하지 않는 일
| | |
| --- | --- |
| **읽는 것** | URL의 `parentOrigin`과 `channel`, 그리고 정확히 그 출처에서 온 `state` 메시지만 읽습니다. |
| **그리는 것** | 내가 보낸 장면을 그립니다. 뷰포트, 재생과 탐색, 오브젝트 목록, 각 오브젝트와 카메라의 포즈 편집기, 리비전 기록, 대기 중인 리비전 알림입니다. 베이크된 장면(버전 2)에서는 베이크된 카메라, 샷 탐색, 식별 색상이 있는 시맨틱 엔티티, 오버레이 컨트롤도 그립니다. |
| **보내는 것** | 수신을 시작하면 `ready`를 보내고, 그다음 사용자 동작마다 `event`를 하나씩, 버전 2 장면에서는 선언된 에셋마다 `asset-request`를 하나씩 보냅니다. |
| **절대 하지 않는 것** | 인증, 저장소 읽기와 쓰기, `/v1` 엔드포인트 호출, 새로 고침 후에도 무언가를 유지하는 일, 편집 후 스스로 화면을 다음 리비전으로 넘기는 일입니다. |

프레임은 **의도적으로 상태를 갖지 않습니다**. 사용자가 편집을 확정하면 프레임은 새로운 불변 리비전을 보내고, *내가* 결과를 돌려보낼 때까지 이전 리비전을 계속 보여 줍니다. 부모보다 앞서 나가는 프레임은 끝내 저장되지 않는 리비전을 보여 줄 수 있기 때문입니다.

프레임은 비밀을 갖고 있지 않으므로, 내가 완전히 제어하지 않는 페이지에서 불러와도 안전합니다. 프레임에 보내는 장면 데이터의 기밀성은 메시지를 보내는 출처에 달려 있으므로, 항상 정확한 출처로 보내세요. [부모 페이지 체크리스트](#parent-checklist)를 참고하세요.

## 뷰포트 사용 가능 여부 확인
뷰포트가 포함된 배포에서는 `GET /v1/nodes`에서 [**3D 장면 생성**](https://nodaro.ai/docs/nodes/video/generate-3d-scene) 노드가 `scene3d-embed-v1` 기능을 표시합니다. 임베드 편집기를 제공하기 전에 이를 확인하세요.

## URL
```text
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`은 브라우저가 정규화한 형태로 도착합니다. 매개변수를 같은 표기로 요구하면 프레임이 추측하지 않고 둘을 정확히 비교할 수 있습니다. 매개변수가 없거나 형식이 잘못되면 프레임은 오류를 표시하고, 아무것도 보내지 않으며, 아무것도 수신하지 않습니다.

## 핸드셰이크
1. 내 페이지가 URL로 프레임을 추가합니다.
2. 프레임이 마운트되고 수신을 시작합니다. `nodaro:scene3d:ready`를 보내며, 내가 응답할 때까지 반복합니다.
3. 내 페이지가 `nodaro:scene3d:state` 메시지로 응답합니다. 프레임이 장면을 검증하고 그립니다.
4. 사용자가 동작하면 프레임이 `nodaro:scene3d:event`를 보냅니다.
5. 내 페이지가 `expectedRevisionId`를 확인하고, 이벤트를 적용한 다음, 새로운 사실인 새 `state`를 보냅니다.

**첫 번째 `state`는 iframe의 `load` 이벤트가 아니라 `ready`에 대한 응답으로 보내세요.** `load`는 프레임의 리스너가 준비되기 전에 발생하므로, 그 시점에 맞춘 메시지는 버려질 수 있습니다. 프레임은 짧고 제한된 일정으로 `ready`를 반복하므로, 부모가 조금 늦게 수신을 시작해도 괜찮습니다. 프레임에 보낸 첫 메시지를 받으면, 그 메시지가 거부되더라도 반복을 멈춥니다. 거부되었다는 것 자체가 내가 수신 중이라는 증거이기 때문입니다.

## 부모에서 프레임으로: state
메시지 타입은 하나이며, 전체 스냅샷을 담습니다. 부분 업데이트는 없으므로, 그리고 싶은 상태 전체를 항상 보내세요.

```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
}
```

기록 항목은 다음과 같습니다.

```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`은 `@nodaro/shared` 패키지의 공개 `Scene3DPlan`으로, **3D 장면 생성**이나 **3D 장면 편집** 작업이 `output_data.scenePlan`으로 반환하는 것과 같은 형태입니다.

- **`readOnly`의 기본값은 `true`입니다.** 아무 말이 없으면 보기만 하고 건드리지 말라는 뜻입니다. 편집 가능한 프레임에는 `readOnly: false`를 명시해야 합니다.
- **`context`는 `prompt`만 유지하며,** 이 값은 복원 버튼의 툴팁이 됩니다. `context` 안의 다른 키는 아무 알림 없이 버려집니다. 그 밖의 위치에 있는 알 수 없는 키는 거부됩니다. [버전 관리](#versioning)를 참고하세요.
- <strong>`isGenerating: true`</strong>는 리비전이 생성 중이라는 안내만 추가합니다. 장면은 계속 활성 상태이며, 읽기 전용이 아니면 편집할 수 있습니다.
- <strong>`pendingPlan`</strong>은 사용자가 편집한 뒤에 끝난 작업을 처리합니다. 프레임은 알림을 표시하며, 편집 가능한 경우 리비전을 고르는 버튼 두 개를 보여 줍니다.

### 프레임이 무시하는 것과 거부하는 것
| 프레임이 받는 것 | 처리 |
| --- | --- |
| `event.source`가 프레임을 넣은 창이 아닌 메시지 | 아무 표시 없이 무시합니다. |
| `event.origin`이 `parentOrigin`이 아닌 메시지 | 아무 표시 없이 무시합니다. |
| 다른 `type`, 다른 `channel`, 또는 객체가 아닌 페이로드 | 아무 표시 없이 무시합니다. |
| 알 수 없는 `version` | 눈에 보이게 거부합니다. |
| 형식이 잘못된 봉투, 알 수 없는 키, 또는 제한을 넘는 목록 | 눈에 보이게 거부합니다. |
| 검증에 실패한 `scenePlan`, `pendingPlan`, 또는 `history[].scenePlan` | 눈에 보이게 거부합니다. |

다른 프레임의 메시지는 사용자의 문제가 아니므로 무시는 조용히 이루어집니다. 잘못된 것은 *내* 메시지이므로 거부는 눈에 보이게 이루어집니다. **거부해도 이미 받아들인 데이터는 절대 사라지지 않습니다.** 마지막으로 받아들인 스냅샷이 이유를 알려 주는 배너 아래에 그대로 표시되며, 다음 유효한 `state`가 오면 배너가 사라집니다.

## 프레임에서 부모로: ready와 event
프레임은 두 메시지를 모두 URL의 정확한 `parentOrigin`으로 보내며, `"*"`로는 절대 보내지 않습니다.

```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
},
}
```

**`ready`는 `type`과 `channel`로 판별하고, `protocolVersions`를 읽으세요.** 메시지 전체를 비교하지 마세요. `version`은 `1`로 유지되므로 버전 1용으로 작성한 부모는 계속 작동합니다. 버전 2가 추가하는 모든 것은 그런 부모가 읽지 않는 필드로 알려집니다.

```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 },
}
```

봉투에 `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 오버레이 편집입니다. 프레임은 이를 적용하지 않습니다. | 공유 적용 함수로 적용하고, 결과를 저장한 다음, 저장한 플랜을 돌려보내세요. [베이크된 장면 편집](#edit-a-baked-scene)을 참고하세요. |

## 읽기 전용 모드
기본값이 `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 장면은 어느 버전에서도 작동합니다.

### 메시지
```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`는 플랜의 현재 저장된 리비전입니다. 저장된 각 리비전은 이전 리비전에서 다시 사용하는 바이트를 포함해 모든 에셋을 고정합니다.

### 프레임이 확인하는 것
프레임은 자신을 위한 것이 아닌 일반적인 메시지를 아무 표시 없이 무시합니다.

- 다른 창, 다른 출처, 또는 다른 `channel`에서 온 응답.
- 시간 초과 후 늦게 도착한 응답, 중복 응답, 아무도 요청하지 않은 바이트처럼 처리 중이 아닌 `requestId`.

다음 경우에는 해당 에셋 하나를 눈에 보이게 실패 처리합니다.

- `version`이 `2`가 아니거나, 알 수 없는 필드가 있거나 필드가 누락되었습니다.
- `revisionId`나 `assetId`가 요청한 것과 다릅니다.
- 응답이 `ok: false`입니다. 프레임은 내 `error`를 줄여서 표시합니다.
- `bytes`가 `ArrayBuffer`가 아닙니다.
- 길이가 매니페스트의 `byteLength`와 다릅니다.
- **SHA-256 다이제스트가 매니페스트의 다이제스트와 다릅니다.**

중요한 것은 다이제스트 확인입니다. 이 확인이 “호스트가 어떤 바이트를 전달했다”와 “이것이 이 리비전을 이루는 바이트다”를 구분합니다. 렌더러는 파싱하기 전에 다이제스트를 한 번 더 확인하므로, 어떤 경로로도 검증되지 않은 형상이 그려지지 않습니다.

프레임은 스스로에게도 제한을 둡니다.

- 동시에 처리 중인 요청은 최대 **4개**이며, 나머지는 대기열에서 기다립니다.
- 리비전당 최대 **64개 요청**과 **64MiB**까지 허용됩니다.
- 각 요청은 **20초** 안에 응답해야 합니다.
- 새 리비전이 도착하거나 프레임이 언마운트되면 처리 중인 모든 요청이 거부되므로, 사용자가 떠난 장면에 대한 응답은 절대 그려지지 않습니다.
- ID와 다이제스트가 같은 동일한 에셋은 한 번만 요청되므로, GLB를 다시 사용하는 오버레이 리비전은 그것을 다시 다운로드하지 않습니다.

### 내 페이지의 규칙
1. **내가 보낸 것을 기준으로 승인하세요.** `request.revisionId`가 내가 보낸 리비전(`plan.revisionId`)이고, `request.assetId`가 그 플랜의 `assets`에 같은 `byteLength`와 `sha256`으로 있을 때만 응답하세요. 그렇지 않으면 `ok: false`로 응답하세요. 이 확인을 건너뛰면 내 세션이 오라클이 됩니다. 프레임을 넣은 페이지를 제어하는 사람은 누구든 아무 리비전이나 에셋을 요청해 그 응답을 읽을 수 있습니다.
2. **자격 증명을 절대 보내지 마세요.** 토큰도, 쿠키도, 서명된 URL도 안 됩니다. 접근 권한을 주는 URL은 모양만 다른 Bearer 토큰입니다. 바이트를 보내세요.
3. **프레임의 정확한 출처로 보내세요.** `"*"`로는 절대 보내지 마세요.
4. **내가 계속 쓸 버퍼는 전송하지 마세요.** 구조화된 복제는 버퍼를 복사하지만, 전송 목록은 버퍼를 내게서 가져가 버립니다.
5. **이미 교체한 리비전에 대한 요청은 버리세요.** 그리고 리비전과 에셋마다 처리 중인 가져오기는 최대 하나만 두세요.

```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" })
}
})
```

### 베이크된 장면 편집
버전 2 편집은 **오버레이**이며, 프레임은 오버레이를 절대 스스로 적용하지 않습니다. 내 서버가 저장하지 않은 버전 2 리비전은 저장된 것처럼 보이지만, 그 에셋은 여전히 원래 리비전에 속해 있기 때문입니다. 그래서 프레임은 연산을 보내고, 내가 보낸 리비전을 계속 보여 줍니다.

```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
}
```

오래된 상태를 확인하는 세 가지 값을 모두 API가 쓰는 것과 같은 공유 적용 함수에 전달한 다음, 결과를 저장하고 돌려보내세요.

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

재정의는 자신이 바꾸는 채널만 지정하며, 프레임은 의도적으로 그렇게 만듭니다. 에셋 엔티티에서는 GLB 파일의 노드 변환이 기준이므로, 무언가를 이동한 편집이 건드리지도 않은 회전을 다시 지정해서는 안 됩니다. 생성 작업 없이 편집을 저장하려면 `POST /v1/3d-scene/revisions/:revisionId/edits`를 호출하거나, SDK에서 `client.scene3d.applyEdits()`를 사용하세요. [3D 장면 API](https://nodaro.ai/docs/developers/api/3d-scenes)를 참고하세요.

## 검증과 제한
`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로 렌더링됩니다. [**동영상 렌더링**](https://nodaro.ai/docs/nodes/video/render-video)을 참고하세요.

## 부모 페이지 체크리스트
프레임은 자신의 받은 편지함을 지킵니다. 내 받은 편지함은 내 페이지만 지킬 수 있습니다.

1. 프레임마다 `crypto.randomUUID()`로 **새 `channel`을 만들고** 보관하세요.
2. 직접 조합한 문자열이 아니라 `window.location.origin`에서 얻은 **내 정확한 출처로 URL을 만드세요.**
3. **받는 모든 메시지에서 네 가지를 확인하세요.**
   - `event.source === iframe.contentWindow`.
   - `event.origin`이 정확히 프레임으로 넣은 Nodaro 출처입니다.
   - `data.channel`이 내 채널입니다.
   - `data.version`이 내가 구현한 버전입니다. `1`, 또는 에셋을 제공한다면 `2`입니다.
4. `plan`, `restore`, `resolve-pending` 이벤트를 적용하기 전에 **`expectedRevisionId`를** 내 활성 리비전과 **비교하고,** 일치하지 않으면 이벤트를 버리세요.
5. 저장하기 전에 **`scene3DPlanSchema`로 `plan`을 다시 검증하세요.** 프레임은 자신이 그리는 것을 검증하고, 저장하는 것은 내 책임입니다.
6. **`ready`를 받으면 `state`를 보내고,** 변경을 받아들일 때마다 다시 보내세요.
7. **`"*"`로 절대 보내지 마세요.** Nodaro 출처를 명시적으로 지정하세요.
8. **메시지에 비밀을 넣지 마세요.** 프로토콜은 장면 형상과 리비전 ID를 전달합니다. 토큰, 사용자 식별자, 장면 레퍼런스에 넣지 않을 URL은 넣지 마세요.
9. **대화 상자가 닫히면 리스너를 제거하세요.** 오래된 리스너와 재사용된 채널이 만나면 두 대화 상자가 서로에게 응답하기 시작합니다.
10. **버전 2 장면을 제공한다면,** [내 페이지의 규칙](#rules-for-your-page)에 설명한 대로 내가 보낸 플랜을 기준으로 **모든 에셋 요청을 승인하세요.**

## 예제
```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()
```

모델로 장면을 생성하고 편집하는 일과 장면을 MP4로 렌더링하는 일은 일반 API 작업입니다. [**3D 장면 생성**](https://nodaro.ai/docs/nodes/video/generate-3d-scene), [**3D 장면 편집**](https://nodaro.ai/docs/nodes/video/edit-3d-scene), [**동영상 렌더링**](https://nodaro.ai/docs/nodes/video/render-video)을 참고하세요. 뷰포트는 뷰어일 뿐이며, 작업을 시작하지도 않고 아무것도 소비하지 않습니다.

## 버전 관리
`version: 1`은 고정된 첫 번째 계약입니다. `state` 봉투는 엄격합니다. 알 수 없는 최상위 키나 기록 항목 안의 알 수 없는 키는 무시되지 않고 거부되므로, 부모와 프레임이 메시지의 의미에 대해 절반만 합의하는 일이 없습니다. 프로토콜은 `version`을 올리는 방식으로 확장되며, 어떤 버전을 구현하지 않은 프레임은 추측하지 않고 그 메시지를 눈에 보이게 거부합니다.

`version: 2`는 버전 1에 기능을 더할 뿐 아무것도 대체하지 않습니다. 베이크된 장면, 에셋 전송, `edit-operations` 이벤트를 제공하며, 버전 1만 쓰는 부모는 바뀐 것 없이 계속 작동합니다. 프레임은 `ready.protocolVersions`와 `ready.capabilities`로 자신이 할 수 있는 일을 알립니다. 추측하지 말고 이 필드를 읽으세요. 그래야 다음 버전도 추가 방식으로 유지됩니다. 존재하지 않는 필드가 필요하다면 그냥 보내지 말고 [공개 저장소](https://github.com/nodaroai/app.nodaro.ai/issues)에 이슈를 여세요. 메시지가 거부되는 것은 계약이 제대로 작동하고 있다는 뜻입니다.

## 자주 묻는 질문

### 3D 장면 뷰포트에 토큰이나 세션이 필요한가요?

아니요. 프레임은 인증을 하지 않고, 저장소를 읽거나 쓰지 않으며, Nodaro API를 호출하지 않습니다. 내 페이지가 postMessage로 장면을 보내고, 베이크된 장면이라면 내 페이지가 자체 세션으로 에셋 바이트를 가져와 그 바이트를 보냅니다.

### 편집 후에도 뷰포트에 이전 리비전이 계속 보이는 이유는 무엇인가요?

의도된 동작입니다. 프레임은 새 리비전을 이벤트로 보낸 뒤 기다립니다. 내 페이지가 새 리비전을 저장하고 상태 메시지로 돌려보낸 뒤에야 새 리비전을 보여 주므로, 프레임은 저장되지 않은 리비전을 절대 보여 줄 수 없습니다.

### 임베드한 뷰포트에서 편집할 수 있나요?

상태 메시지에 readOnly false가 있을 때만 가능합니다. 기본은 읽기 전용이며, 이때 사용자는 재생, 탐색, 선택을 할 수 있고 프레임은 선택 이벤트만 보냅니다.

### 첫 번째 상태 메시지는 언제 보내야 하나요?

iframe의 load 이벤트가 아니라 프레임의 ready 메시지에 대한 응답으로 보내세요. load 이벤트는 프레임이 수신을 시작하기 전에 발생하므로, 그때 보낸 메시지는 사라질 수 있습니다.

### 배포에서 뷰포트를 제공하는지 어떻게 알 수 있나요?

뷰포트가 포함된 배포에서는 GET /v1/nodes에서 3D 장면 생성 노드가 scene3d-embed-v1 기능을 표시합니다. 임베드 편집기를 제공하기 전에 이를 확인하세요.
