# 3D シーンビューポートの埋め込み

> Nodaro の 3D シーンビューポートを自分のアプリにフレームとして埋め込み、postMessage で操作する方法を、ハンドシェイクや state メッセージから、編集イベント、アセット転送、上限まで説明します。

Source: https://nodaro.ai/ja/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` を、その後はユーザーの操作ごとに 1 つの `event` を、バージョン 2 のシーンで宣言されたアセットごとに 1 つの `asset-request` を送信します。 |
| **決して行わないこと** | 認証、ストレージの読み書き、`/v1` エンドポイントの呼び出し、リロードをまたいだ状態の保持、編集後に自分の表示を勝手に進めることです。 |

フレームは、設計上**ステートレス**です。ユーザーが編集を確定すると、フレームは新しい不変のリビジョンを送り、その結果を**自分のページ**が送り返すまで、古いリビジョンを表示したままにします。フレームが親より先に進んでしまうと、保存されないリビジョンを表示してしまう可能性があるためです。

フレームは秘密情報を一切持たないため、完全には管理できないページに読み込んでも安全です。送信するシーンデータの機密性は、送信先として指定したオリジンの機密性以上には保たれません。そのため、必ず正確なオリジンへ送信してください。[親ページのチェックリスト](#parent-checklist)を参照してください。

## ビューポートが利用できるか確認する
ビューポートを含むデプロイ環境では、[**3D シーン生成**（Generate 3D Scene）](https://nodaro.ai/docs/nodes/video/generate-3d-scene)ノードが、`GET /v1/nodes` の中で `scene3d-embed-v1` という機能（capability）を示します。埋め込みエディターを提供する前に確認してください。

## 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 で、マウントされたフレームごとに 1 つです。同じページ内の 2 つのビューポートや、閉じたダイアログの古いフレームが、互いの描画を上書きすることを防ぎます。 |

`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
メッセージの種類は 1 つだけで、完全なスナップショットを送ります。部分的な更新はありません。描画してほしい状態は、常に完全な形で送信してください。

```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 シーン編集**（Edit 3D Scene）のジョブが `output_data.scenePlan` に返すものと、同じ形をしています。

- **`readOnly` のデフォルトは `true` です。**何も指定しないということは、見るだけで触れないという意味です。編集可能なフレームにするには、`readOnly: false` を指定する必要があります。
- **`context` は `prompt` だけを保持します。**これは復元ボタンのツールチップになります。`context` 内のほかのキーは、黙って捨てられます。それ以外の場所にある未知のキーは拒否されます。[バージョニング](#versioning)を参照してください。
- **`isGenerating: true`** は、リビジョンを生成中であるという注記を追加するだけです。シーンはそのまま操作可能で、読み取り専用でない場合は編集もできます。
- **`pendingPlan`** は、ユーザーが編集した後に完了したジョブを扱います。フレームは通知を表示し、編集可能な場合は、どちらのリビジョンを使うか選ぶための 2 つのボタンを表示します。

### フレームが無視すること、拒否すること
| フレームが受け取るもの | 動作 |
| --- | --- |
| `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 が追加するものはすべて、バージョン 1 の親ページが読み取らないフィールドの中で通知されます。

```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` と等しい場合だけにし、それ以外は捨ててください。この 1 つの確認によって、すでに置き換えたスナップショットへのクリックが、気づかれないロールバックにならず、何も起こらない操作になります。`null` になるのは、フレームがまだ一度も `state` を受け入れていないときだけです。

| `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)を参照してください。 |

## 読み取り専用モード
`readOnly` は、デフォルトで `true` になっており、ビューアー全体はそのまま使えますが、書き込みはすべて行われません。

| 引き続き使えるもの | 行われないもの |
| --- | --- |
| 再生、一時停止、スクラブ | オブジェクトとカメラの、数値によるポーズの確定 |
| ビューポートと一覧での選択 | オブジェクトと背景の色の変更 |
| オブジェクトの一覧、ロックバッジ、リビジョン履歴の閲覧 | ロックの切り替え |
| 保留中リビジョンの通知 | 復元と、保留中リビジョンのボタン |

読み取り専用のフレームが送信するイベントは `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 件のリクエスト**までで、残りはキューで待機します。
- 1 つのリビジョンあたり、最大 **64 件のリクエスト**、**64 MiB** までです。
- 各リクエストの応答時間は **20 秒**です。
- 新しいリビジョンが届くか、フレームがアンマウントされると、応答待ちのリクエストはすべて拒否されます。そのため、ユーザーが離れたシーンの応答が描画されることはありません。
- 同じ ID と同じダイジェストを持つ、同一のアセットは 1 回しか要求されません。そのため、GLB を再利用するオーバーレイのリビジョンが、それを再度ダウンロードすることはありません。

### 親ページのルール
1. **送信した内容と照らし合わせて認可します。**応答するのは、`request.revisionId` が送信したリビジョン（`plan.revisionId`）であり、`request.assetId` がそのプランの `assets` に含まれ、`byteLength` と `sha256` が一致する場合だけです。それ以外は `ok: false` で応答します。この確認を省略すると、自分のセッションがオラクルになってしまいます。フレーム内のページを制御できる者なら誰でも、任意のリビジョンやアセットを要求し、その応答を読み取れてしまうからです。
2. **認証情報を絶対に送らないでください。**トークンも、クッキーも、署名付き URL も送りません。アクセスを与える URL は、表記が違うだけのベアラートークンです。送るのはバイトだけです。
3. **フレームの正確なオリジンへ送信してください。**`"*"` へは決して送らないでください。
4. **保持しておくバッファーを転送しないでください。**構造化クローンはバッファーをコピーしますが、転送リストは、それを自分の手元から取り去ってしまいます。
5. **置き換え済みのリビジョンへのリクエストは捨ててください。**リビジョンとアセットごとに、同時に実行中のフェッチは最大 1 件にしてください。

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

リビジョンの古さを確認する 3 つの値をすべて、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,560 px |
| シーンあたりのオブジェクト数 | 100 |
| オブジェクトまたはカメラトラックあたりのキーフレーム数 | 240 |
| バージョン 2 のシーンのエンティティ数 | 100 |
| バージョン 2 のシーンのアセット、ショット、オーバーライドの数 | 64、32、200 |
| バージョン 2 のシーンが宣言できるアセットのバイト数 | 64 MiB |

フレームは、何かをパースする前に、リストの長さを確認します。そのため、サイズが大きすぎるペイロードは、読み取られることなく拒否されます。バージョン 2 のマニフェストは、バイトについての約束であるため、宣言された合計値は、アセットを 1 つ要求する前に確認されます。長辺が 1,920 px を超えるフレームを MP4 にレンダリングすると、料金が高くなります。[**動画レンダリング**（Render Video）](https://nodaro.ai/docs/nodes/video/render-video)を参照してください。

## 親ページのチェックリスト
フレームは、自分の受信箱を自分で守ります。自分のページの受信箱を守れるのは、自分のページだけです。

1. **フレームごとに、新しい `channel`** を `crypto.randomUUID()` で作成し、保持します。
2. **URL は、自分で組み立てた文字列ではなく、`window.location.origin` から得た、自分の正確なオリジンで組み立てます。**
3. **受け取るメッセージごとに、次の 4 点を確認します。**
   - `event.source === iframe.contentWindow` であること。
   - `event.origin` が、フレームに指定した Nodaro のオリジンと正確に一致すること。
   - `data.channel` が自分の channel であること。
   - `data.version` が、自分が実装しているバージョン（`1`。アセットを提供する場合は `2` も）であること。
4. **`plan`、`restore`、`resolve-pending` のイベントを適用する前に、`expectedRevisionId` を自分の現在のリビジョンと比較して確認し、**一致しない場合はそのイベントを捨てます。
5. **保存する前に、`plan` を `scene3DPlanSchema` でもう一度検証します。**フレームが検証するのは自身が描画するものであり、保存するものに責任を持つのは自分のページです。
6. **`ready` を受け取ったときと、受け入れた変更のたびに、`state` を送信します。**
7. **`"*"` へは決して送信しないでください。**Nodaro のオリジンを明示的に指定します。
8. **メッセージには、秘密情報を一切含めないでください。**このプロトコルでやり取りするのは、シーンのジオメトリとリビジョン ID です。トークン、ユーザー識別子、シーンの参照に含めるつもりのない URL は含めません。
9. **ダイアログを閉じたら、リスナーを削除します。**古いリスナーと、再利用された channel が組み合わさると、2 つのダイアログが互いに応答し合ってしまいます。
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)で issue を立ててください。メッセージが拒否されるのは、契約が正しく機能している証拠です。

## Frequently asked questions

### 3D シーンビューポートには、トークンやセッションが必要ですか？

いいえ。フレームは認証を行わず、ストレージの読み書きも行わず、Nodaro の API を呼び出すこともありません。自分のページが postMessage でシーンを送り、ベイク済みのシーンの場合は、自分のセッションでアセットのバイトを取得して送ります。

### 編集した後も、ビューポートが古いリビジョンを表示したままなのはなぜですか？

意図的な設計です。フレームは新しいリビジョンをイベントとして送り、待機します。新しいリビジョンが表示されるのは、自分のページがそれを保存し、state メッセージで送り返した後だけです。そのため、フレームが、保存されていないリビジョンを表示することは決してありません。

### 埋め込んだビューポートは編集できますか？

自分の state メッセージで readOnly を false にしたときだけです。デフォルトは読み取り専用で、ユーザーは再生、スクラブ、選択ができますが、フレームが送信するのは selection イベントだけです。

### 最初の state メッセージは、いつ送ればよいですか？

iframe の load イベントではなく、フレームの ready メッセージへの応答として送ります。load イベントは、フレームが待ち受けを始める前に発生するため、そのタイミングで送ったメッセージは失われることがあります。

### デプロイ環境がビューポートを提供しているかどうかは、どうすればわかりますか？

ビューポートを含むデプロイ環境では、3D シーン生成（Generate 3D Scene）ノードが、GET /v1/nodes の中で scene3d-embed-v1 という機能（capability）を示します。埋め込みエディターを提供する前に確認してください。
