diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 390781c..b279d5d 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -52,10 +52,11 @@ jobs: - name: Install JS dependencies run: nix develop --command pnpm install --frozen-lockfile --ignore-scripts - - name: Apply autofix (control-plane / realtime / lp / web) + - name: Apply autofix (control-plane / realtime / presentation / lp / web) run: | nix run .#control-plane -- fix nix run .#realtime -- fix + nix run .#presentation -- fix nix run .#lp -- fix nix run .#web -- fix diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dca166d..4d8066e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI # オーケストレーション。変更のあった領域だけ reusable workflow を呼び、 # 最後に集約ゲート (job: ci) を必須チェックにする。 -# 各領域の実体: control-plane.yml / realtime.yml / web.yml / lp.yml / unity.yml。 +# 各領域の実体: control-plane.yml / presentation.yml / realtime.yml / web.yml / lp.yml / unity.yml。 # # paths-ignore は設けない。docs-only PR でも workflow を起動し、changes で全領域が # skip → 集約 job `CI` が成功することで、必須ステータスチェックが常に報告される。 @@ -38,6 +38,7 @@ jobs: control-plane: ${{ steps.filter.outputs.control-plane }} realtime: ${{ steps.filter.outputs.realtime }} web: ${{ steps.filter.outputs.web }} + presentation: ${{ steps.filter.outputs.presentation }} lp: ${{ steps.filter.outputs.lp }} unity: ${{ steps.filter.outputs.unity }} steps: @@ -53,7 +54,7 @@ jobs: shared: &shared - 'flake.nix' - 'flake.lock' - - 'scripts/lib/**' + - 'scripts/**' - '.github/workflows/ci.yml' js-shared: &js-shared - 'pnpm-lock.yaml' @@ -83,6 +84,14 @@ jobs: - 'scripts/ci/web.sh' - 'app/web/**' - 'packages/api-client-typescript/**' + presentation: + - *shared + - *js-shared + - '.github/workflows/presentation.yml' + - 'scripts/ci/presentation.sh' + - 'packages/contracts/**' + - 'packages/api-client-csharp/**' + - 'packages/presentation-*/**' lp: - *shared - *js-shared @@ -111,6 +120,12 @@ jobs: if: needs.changes.outputs.web == 'true' uses: ./.github/workflows/web.yml + presentation: + name: Presentation Packages + needs: changes + if: needs.changes.outputs.presentation == 'true' + uses: ./.github/workflows/presentation.yml + lp: name: LP needs: changes @@ -126,7 +141,7 @@ jobs: ci: name: CI # 必須ステータスチェック。全領域の結果を集約する。 - needs: [changes, control-plane, realtime, web, lp, unity] + needs: [changes, control-plane, realtime, web, presentation, lp, unity] if: always() runs-on: ubuntu-latest steps: diff --git a/.github/workflows/presentation.yml b/.github/workflows/presentation.yml new file mode 100644 index 0000000..47073fb --- /dev/null +++ b/.github/workflows/presentation.yml @@ -0,0 +1,25 @@ +name: Presentation Packages + +on: + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + name: presentation / check + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: DeterminateSystems/nix-installer-action@33c9ab3ef95cd57c164d9d6eb1f9a46338538d41 # main + - uses: DeterminateSystems/magic-nix-cache-action@55718cd9ebf1737a061487d5ada21abf31e99a98 # main + + - name: Install JS dependencies + run: nix develop --command pnpm install --frozen-lockfile + + - name: shared config / typecheck / lint / test + run: nix run .#presentation diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf28059..caf3567 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,17 +87,17 @@ nix run .#setup 公式のタスク入口は Nix flake apps です。複雑な処理の実体は `scripts/` にあり、 通常は内部スクリプトを直接実行せず、次の入口を使用します。 -| 用途 | コマンド | -| --- | --- | -| 開発環境 | `nix develop` | -| 依存関係と hook のセットアップ | `nix run .#setup` | -| 全体品質ゲート | `nix run .#check` | -| Control Plane check / test / build | `nix run .#control-plane` | -| Realtime check / test / build / race | `nix run .#realtime` | -| Web check / test / build | `nix run .#web` | -| LP test / check / build | `nix run .#lp` | -| Notion 同期 | `nix run .#notion-sync` | -| flake の評価と formatter 検証 | `nix flake check` | +| 用途 | コマンド | +| ------------------------------------ | ------------------------- | +| 開発環境 | `nix develop` | +| 依存関係と hook のセットアップ | `nix run .#setup` | +| 全体品質ゲート | `nix run .#check` | +| Control Plane check / test / build | `nix run .#control-plane` | +| Realtime check / test / build / race | `nix run .#realtime` | +| Web check / test / build | `nix run .#web` | +| LP test / check / build | `nix run .#lp` | +| Notion 同期 | `nix run .#notion-sync` | +| flake の評価と formatter 検証 | `nix flake check` | `nix flake check` は flake の評価と formatter を検証します。アプリケーションの 品質ゲートではありません。コード変更時の全体品質ゲートは @@ -130,16 +130,17 @@ generated-file notice を保持します。C# generator は未接続のため、 各 package の `package.json` にある script を JavaScript の実行方法の正とします。 テストフレームワークを推測してコマンドを追加しません。 -| 対象 | 現在のテスト | 今後の方針 | -| --- | --- | --- | -| `app/web/` | package script で実行 | 機能追加に合わせて拡張 | -| `app/server/realtime/` | Go `testing`、race detector | gRPC contract と session 実装に合わせて拡張 | -| `lp/` | package script、`svelte-check`、静的 build | ページ追加に合わせて拡張 | -| `app/unity/` | Unity Test Framework の EditMode/PlayMode | Unity Editor で実行 | +| 対象 | 現在のテスト | 今後の方針 | +| ---------------------- | ------------------------------------------ | ------------------------------------------- | +| `app/web/` | package script で実行 | 機能追加に合わせて拡張 | +| `app/server/realtime/` | Go `testing`、race detector | gRPC contract と session 実装に合わせて拡張 | +| `lp/` | package script、`svelte-check`、静的 build | ページ追加に合わせて拡張 | +| `app/unity/` | Unity Test Framework の EditMode/PlayMode | Unity Editor で実行 | -`nix run .#check` は現在、Control Plane、Realtime、LP、Web の検証を実行します。 -現在は Unity Editor テストを実行しません。ドキュメント link check や security -check も、この品質ゲートには含まれません。 +`nix run .#check` は現在、Control Plane、Presentation packages、Realtime、LP、Web +の検証を実行します。Presentation gate は共有 `packages/config` の hook fixture と設定 +check も実行します。現在は Unity Editor テストを実行しません。ドキュメント link +check や security check も、この品質ゲートには含まれません。 Unity の動作変更では、Unity `6000.3.14f1` の Editor で EditMode/PlayMode テストを 実行します。GitHub-hosted runner の Unity workflow は `dotnet format`、PowerShell @@ -155,6 +156,8 @@ skip された検証を成功したとは報告しません。 領域別の自動修正は次のコマンドで実行します。 ```bash +nix run .#control-plane -- fix +nix run .#presentation -- fix nix run .#realtime -- fix nix run .#lp -- fix nix run .#web -- fix @@ -167,10 +170,10 @@ Pull Request では `autofix.yml` が format と lint の自動修正を行う `nix develop` または `nix run .#setup` により、repository-local の `core.hooksPath=packages/config/githooks` が有効になります。 -現在 tracked されている hook は `prepare-commit-msg` です。commit message を変換 -しますが、pre-commit の format や staged file の検証を保証するものではありません。 -Vite+ の staged 設定が存在していても、設定された `core.hooksPath` と hook file を -確認せずに自動実行を前提にしません。 +tracked hook の `pre-commit` は repository root の Vite+ staged 設定を解決し、対象の +staged file に `vp check --fix` または `vp fmt` を実行します。`prepare-commit-msg` は +commit message を変換します。hook を前提にする前に、repository-local の +`core.hooksPath` が `packages/config/githooks` を指すことを確認します。 現在の変換例は次のとおりです。 diff --git a/app/server/control-plane/package.json b/app/server/control-plane/package.json index 6f34f31..be74084 100644 --- a/app/server/control-plane/package.json +++ b/app/server/control-plane/package.json @@ -20,7 +20,7 @@ "db:migrate:remote": "wrangler d1 migrations apply unframe-control-plane --remote", "r2:cors:apply": "wrangler r2 bucket cors set unframe-assets --file ./r2-cors.json", "r2:cors:list": "wrangler r2 bucket cors list unframe-assets", - "contracts:check": "pnpm --filter @unframe/contracts check:control-plane", + "contracts:check": "pnpm --filter @unframe/contracts check", "client:check": "pnpm --filter @unframe/api-client-typescript check", "check": "pnpm types:check && pnpm typecheck && pnpm lint && pnpm test && pnpm contracts:check && pnpm client:check && pnpm build" }, diff --git a/app/server/control-plane/test/modules/realtime-bootstrap/credential.test.ts b/app/server/control-plane/test/modules/realtime-bootstrap/credential.test.ts index dc1b0c8..c7769d1 100644 --- a/app/server/control-plane/test/modules/realtime-bootstrap/credential.test.ts +++ b/app/server/control-plane/test/modules/realtime-bootstrap/credential.test.ts @@ -10,6 +10,18 @@ const fromBase64Url = (value: string) => { return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)); }; +const generatePrivateJwk = async () => { + const generatedKey = await crypto.subtle.generateKey({ name: "Ed25519" }, true, [ + "sign", + "verify", + ]); + if (!("privateKey" in generatedKey)) throw new Error("Ed25519 must generate a key pair"); + + const exportedKey = await crypto.subtle.exportKey("jwk", generatedKey.privateKey); + if (exportedKey instanceof ArrayBuffer) throw new Error("JWK export must return a JSON key"); + return exportedKey; +}; + describe("RealtimeBootstrapCredentials", () => { it("rejects a signing key that is not an Ed25519 private JWK", () => { expect( @@ -26,8 +38,7 @@ describe("RealtimeBootstrapCredentials", () => { }); it("issues a verifiable EdDSA session credential and only publishes its public JWK", async () => { - const keyPair = await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]); - const privateJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey); + const privateJwk = await generatePrivateJwk(); const credentials = new RealtimeBootstrapCredentials(privateJwk, { issuer: "https://control-plane.example.com", keyId: "realtime-2026-08", @@ -107,8 +118,7 @@ describe("RealtimeBootstrapCredentials", () => { }); it("UTF-8 audienceを含むcredentialを発行する", async () => { - const keyPair = await crypto.subtle.generateKey({ name: "Ed25519" }, true, ["sign", "verify"]); - const privateJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey); + const privateJwk = await generatePrivateJwk(); const credentials = new RealtimeBootstrapCredentials(privateJwk, { issuer: "https://control-plane.example.com", keyId: "realtime-2026-08", diff --git a/app/server/realtime/ARCHITECTURE.md b/app/server/realtime/ARCHITECTURE.md index 0e7be74..32c8b95 100644 --- a/app/server/realtime/ARCHITECTURE.md +++ b/app/server/realtime/ARCHITECTURE.md @@ -21,30 +21,30 @@ - **未実装**: Target Architecture または実装段階にだけ存在する - **未決定**: contract、運用値、構成方式の決定が残っている -| 領域 | 状態 | 現在の境界・根拠 | -| --- | --- | --- | -| Venue Edge provisioning identity / credential / registration | 実装済み | `control-plane/src/modules/venue-edges/`、`migrations/0008_venue_edges.sql`、`migrations/0009_runtime_assignments.sql`。Edge ID / Bearer token は profile 固有の provisioning にだけ使用する | -| Runtime Assignment lifecycle / bootstrap | 部分実装 | `control-plane/src/modules/runtime-assignments/`、`realtime-bootstrap/` と session bootstrap route。Cloud / Venue Edge 共通の assign / read / bootstrap は `runtimeId`、`runtimeKind`、assignment epoch、Presentation revision を拘束する。target の PublishedPresentation / PublicationFence は未実装。renew / release API は Venue Edge profile だけにあり、Cloud lifecycle は未実装。lease 日時を canonical ISO へ正規化し、Venue Edge renew は5分以内、割当・bootstrapに使える Venue Edge heartbeat は直近60秒以内に制限する | -| JWT / JWKS / scope 検証と gRPC assignment fencing | 実装済み | `realtime/internal/auth/`、`realtime/internal/assignment/`、gRPC interceptor / service guard。audience は必須設定だが具体値は未決定。JWKS cache は5分で失効し、未知の key ID による refresh は30秒に1回へ制限し、refresh 失敗時は stale key を使用しない。lease 失効時は blocked send も終了する | -| 共通 Runtime Core composition | 部分実装 | `realtime/internal/runtimecore/` と `cmd/server/`。Cloud / Venue Edge 共通の Coordinator / Guard / gRPC composition は接続済みだが、Step / Cue、State、Snapshot、profile agent は未接続 | -| application readiness / gRPC health | 実装済み | local assignment lease と JWKS cache を期限付きで継続確認し、確認後だけ `SERVING` とすることで process 起動とは分離する。`NOT_SERVING` 遷移時の既存 idle stream 終了は Runtime lifecycle へ未接続 | -| stream observability | 部分実装 | structured log と active / completed / auth failure / resource exhausted metrics は gRPC interceptor に接続済み。exporter、trace、alert は未実装 | -| Control Plane checkpoint / completion | 部分実装 | assignment-fenced callback API と Realtime HTTP client / bounded buffer はあるが、Snapshot schema と session lifecycle へ未接続。現在の API 認証は既存 service identity で、Venue Edge credential / Cloud platform identity への profile 別接続は未実装 | -| Runtime message / rate abuse protection | 未実装 | protocol 固有の message size、participant ごとの rate、invalid-message count による切断 policy は未接続 | -| Manifest 検証、content-addressed cache、Range 対応 Asset handler | 部分実装 | `realtime/internal/asset/`。domain と `http.Handler` はあるが、実際の local HTTPS listener、証明書、Cloud Agent、runtime composition へ未接続 | -| Runtime pause / resume state | 部分実装 | `realtime/internal/session/runtime.go`。状態遷移 primitive はあるが、Presenter 接続、Step / Cue、Snapshot / checkpoint へ未接続 | -| Element State latest-wins mailbox | 部分実装 | `realtime/internal/state/mailbox.go`。field merge primitive はあるが、State gRPC fan-out へ未接続 | -| `RuntimeAssignment` / `runtimeId` / `runtimeKind` 一般化 | 実装済み | Control Plane repository / API、bootstrap / JWT、Realtime Guard で共通 contract を使用する | -| Fly.io Cloud Runtime | 部分実装 | Docker image、H2C service profile、共通 binary と application health はある。app / region / Machine / identity / autoscaling、登録・起動、公開 endpoint、deploy は未実装・未決定 | -| session 作成時の `Cloud` / `VenueEdge` 選択 | 部分実装 | Control Plane の generic assignment API / repository / bootstrap は両 kind を扱う。session 作成 API と UI からの選択・Runtime 自動選定は未実装 | -| Unity の Control / State gRPC 接続 | 未実装 | generated client の組み込み、2 connection lifecycle、nonce、再接続、State 適用は未着手 | -| Presenter Tracking / Input protocol | 未実装 | Pose sample、clock、rate、Unity送信、Runtime受信は Target のみ | -| Step / Cue / Action / Transition evaluator | 未実装 | canonical evaluation、Cue 選択、Action conflict、Surface transition、Timeline / Runtime Run と Element State 生成は未着手 | -| Snapshot / Replay / Connection Resume | 未実装 | target のsession-global reliable sequence、ProjectionAdvance、atomic cut、replay queue、Quest適用、durable / local checkpoint への配線は未着手 | -| Venue Edge Cloud Agent / local HTTPS listener / update | 未実装 | service manager、LAN bind、証明書、fingerprint rotation、health、更新・rollbackは未着手 | -| Cloud 配置の R2 / CDN signed URL 配信 | 未実装 | Manifest認可、signed URL発行、Quest download / readinessはTargetのみ | -| Asset cache容量・eviction | 未決定 | hard limit、low-disk threshold、退避順、active session pin、quotaを実測後に決定する | -| 1 / 10 / 25 / 50 Quest 実機計測 | 未実装 | latency、jitter、fan-out、Asset ready の基準値は未計測 | +| 領域 | 状態 | 現在の境界・根拠 | +| ---------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Venue Edge provisioning identity / credential / registration | 実装済み | `control-plane/src/modules/venue-edges/`、`migrations/0008_venue_edges.sql`、`migrations/0009_runtime_assignments.sql`。Edge ID / Bearer token は profile 固有の provisioning にだけ使用する | +| Runtime Assignment lifecycle / bootstrap | 部分実装 | `control-plane/src/modules/runtime-assignments/`、`realtime-bootstrap/` と session bootstrap route。Cloud / Venue Edge 共通の assign / read / bootstrap は `runtimeId`、`runtimeKind`、assignment epoch、Presentation revision を拘束する。target の PublishedPresentation / PublicationFence は未実装。renew / release API は Venue Edge profile だけにあり、Cloud lifecycle は未実装。lease 日時を canonical ISO へ正規化し、Venue Edge renew は5分以内、割当・bootstrapに使える Venue Edge heartbeat は直近60秒以内に制限する | +| JWT / JWKS / scope 検証と gRPC assignment fencing | 実装済み | `realtime/internal/auth/`、`realtime/internal/assignment/`、gRPC interceptor / service guard。audience は必須設定だが具体値は未決定。JWKS cache は5分で失効し、未知の key ID による refresh は30秒に1回へ制限し、refresh 失敗時は stale key を使用しない。lease 失効時は blocked send も終了する | +| 共通 Runtime Core composition | 部分実装 | `realtime/internal/runtimecore/` と `cmd/server/`。Cloud / Venue Edge 共通の Coordinator / Guard / gRPC composition は接続済みだが、Step / Cue、State、Snapshot、profile agent は未接続 | +| application readiness / gRPC health | 実装済み | local assignment lease と JWKS cache を期限付きで継続確認し、確認後だけ `SERVING` とすることで process 起動とは分離する。`NOT_SERVING` 遷移時の既存 idle stream 終了は Runtime lifecycle へ未接続 | +| stream observability | 部分実装 | structured log と active / completed / auth failure / resource exhausted metrics は gRPC interceptor に接続済み。exporter、trace、alert は未実装 | +| Control Plane checkpoint / completion | 部分実装 | assignment-fenced callback API と Realtime HTTP client / bounded buffer はあるが、Snapshot schema と session lifecycle へ未接続。現在の API 認証は既存 service identity で、Venue Edge credential / Cloud platform identity への profile 別接続は未実装 | +| Runtime message / rate abuse protection | 未実装 | protocol 固有の message size、participant ごとの rate、invalid-message count による切断 policy は未接続 | +| Manifest 検証、content-addressed cache、Range 対応 Asset handler | 部分実装 | `realtime/internal/asset/`。domain と `http.Handler` はあるが、実際の local HTTPS listener、証明書、Cloud Agent、runtime composition へ未接続 | +| Runtime pause / resume state | 部分実装 | `realtime/internal/session/runtime.go`。状態遷移 primitive はあるが、Presenter 接続、Step / Cue、Snapshot / checkpoint へ未接続 | +| Element State latest-wins mailbox | 部分実装 | `realtime/internal/state/mailbox.go`。field merge primitive はあるが、State gRPC fan-out へ未接続 | +| `RuntimeAssignment` / `runtimeId` / `runtimeKind` 一般化 | 実装済み | Control Plane repository / API、bootstrap / JWT、Realtime Guard で共通 contract を使用する | +| Fly.io Cloud Runtime | 部分実装 | Docker image、H2C service profile、共通 binary と application health はある。app / region / Machine / identity / autoscaling、登録・起動、公開 endpoint、deploy は未実装・未決定 | +| session 作成時の `Cloud` / `VenueEdge` 選択 | 部分実装 | Control Plane の generic assignment API / repository / bootstrap は両 kind を扱う。session 作成 API と UI からの選択・Runtime 自動選定は未実装 | +| Unity の Control / State gRPC 接続 | 未実装 | generated client の組み込み、2 connection lifecycle、nonce、再接続、State 適用は未着手 | +| Presenter Tracking / Input protocol | 未実装 | Pose sample、clock、rate、Unity送信、Runtime受信は Target のみ | +| Step / Cue / Action / Transition evaluator | 未実装 | canonical evaluation、Cue 選択、Action conflict、Surface transition、Timeline / Runtime Run と Element State 生成は未着手 | +| Snapshot / Replay / Connection Resume | 未実装 | target のsession-global reliable sequence、ProjectionAdvance、atomic cut、replay queue、Quest適用、durable / local checkpoint への配線は未着手 | +| Venue Edge Cloud Agent / local HTTPS listener / update | 未実装 | service manager、LAN bind、証明書、fingerprint rotation、health、更新・rollbackは未着手 | +| Cloud 配置の R2 / CDN signed URL 配信 | 未実装 | Manifest認可、signed URL発行、Quest download / readinessはTargetのみ | +| Asset cache容量・eviction | 未実装 | ADR-0012でbaseline policyを確定済み。hard limit 4 GiB、low-space reserve 512 MiB、persistent access sequenceによるunpinned LRU、active / waiting Sessionのselected Asset pinをM4〜M5で接続する。Quest実機計測に基づく別tierは後続判断とする | +| 1 / 10 / 25 / 50 Quest 実機計測 | 未実装 | latency、jitter、fan-out、Asset ready の基準値は未計測 | ## 1. 背景 @@ -366,6 +366,8 @@ ParticipantCalibration - Presenter Quest は最新の `presentationFromQuestLocal` を使って Pose を Presentation Space へ変換してから送信する。Viewer Quest は同じ変換の逆変換を使って Element State を自端末の tracking space へ配置する。 - participant 単独の再 calibration 後は、保持済みの canonical Element State を新しい変換で再描画するため、session 全体の Snapshot を再生成しない。 +Canonical Presentation Spaceのright-handed TRS、Unity handedness変換、Surface plane / ray inverse / Hit Region座標は [ADR-0010](../../../docs/decisions/0010-spatial-surface-coordinate-contract.md) を正本とする。interaction hit-testはcurrent `presentationOriginVersion`、participant calibration、同じruntime cutのeffective Node TransformとAnchor sampleが揃う場合だけ行い、不一致またはunavailable / staleな入力をno-hitとして扱う。現行Realtime foundationはこのcoordinate-aware projectionをまだ配信していない。 + ## 7. Session Runtime と Step / Cue 実行モデル ### 7.1 Runtime state @@ -392,7 +394,7 @@ stateDiagram-v2 - **Connection Resume**: participantの通信再接続。Snapshot / replayで同じruntimeへ復帰するが、runtime state自体は変更しない。 - **Runtime Resume**: presenterだけが要求できる`Paused -> Running`遷移。有効なpresenter connection、有効なassignment lease、Control Plane上で`Presenting`であることを確認してから適用する。 -Runtime Resumeはpresenter再接続だけでは自動実行しない。再接続したpresenterがSnapshotとpause理由を確認し、明示的な`ResumeRuntime` commandを送信する。Runtime Coreは`RuntimePaused`、`RuntimeResumed`、`RuntimeTerminating`をReliable Eventとして全participantへ配信する。 +Runtime Resumeはpresenter再接続だけでは自動実行しない。再接続したpresenterがSnapshotとpause理由を確認し、明示的な`ResumeRuntime` commandを送信する。Runtime Coreは`RuntimeStatusChanged(paused / running / terminating)`をReliable Eventとして全participantへ配信する。 Target progression clock は pause-aware な logical runtime time とする。`Running`中だけprocessのmonotonic clock差分で進め、`Paused`と`Terminating`では停止する。process固有のmonotonic timestamp、wall clock、`pausedAt`、累積pause durationはSnapshotへ保存しない。Runtime Resume時は保存済みlogical timeを新しいmonotonic clock基準へbindする。process recoveryではcheckpoint時に`Running`であってもlogical timeを進めず、`Paused / processRecovered`として復元する。現行`internal/session/runtime.go`の`time.Time`を使うpause primitiveは部分実装であり、このtarget clock contractが実装済みであるとはみなさない。 @@ -403,7 +405,7 @@ Target progression clock は pause-aware な logical runtime time とする。`R 3. Trigger が成立した Cue を idempotent に発火する。 4. Cue の Action batch を preflight し、reject する場合は canonical state を変更しない。 5. 即時 state 変更と Runtime Run の開始を atomic に確定し、Reliable Event と送信 frame へ反映する。 -6. active Run の effective value を logical runtime time から計算し、viewer ごとの State frame へまとめる。 +6. active Run の Timeline effective value は client が Delivery 済み immutable catalog と logical runtime time から local interpolation する。Runtime Core は Timeline 毎 frame値を State frame へ入れず、tracking 等の非 Timeline 連続状態だけを viewer ごとの State frame へまとめる。 7. Run 完了時は Timeline の最終 Node 値を commit し、Surface transition は Run を除去して interaction / hit region を有効化した後、completion event を確定する。 8. Snapshot は commit 済み canonical state と active Run を同じ cut で反映する。 9. Cue の定義に従って次の Step へ遷移する。 @@ -412,7 +414,7 @@ Target progression clock は pause-aware な logical runtime time とする。`R Runtime Core は正本の Cue 選択、Action preflight、atomic commit を session critical section 内で実行する。batch reject では Cue 消費、cooldown、state、Run、Step を変更せず、別 Cue へ fallback しない。invariant 違反または atomic commit 失敗では partial state を公開せず Runtime を `Paused` にし、理由を Reliable Control で通知する。 -Surface transition、Timeline、Media は Runtime Run として追跡し、Run ID、owner epoch、開始 logical runtime time、完了種別を Snapshot へ保存する。即時 canonical state と Run lifecycle は Reliable Event、effective value は State frame、commit 済み state と active Run は Snapshot へそれぞれ fan-out する。個別の conflict、補間、停止規則は Presentation Progression の正本に従う。 +Surface transition、Timeline、Media は Runtime Run として追跡し、Run ID、owner epoch、開始 logical runtime time、完了種別を Snapshot へ保存する。即時 canonical state と Run lifecycle は Reliable Event、Timeline 以外の latest-wins state は State frame、commit 済み state と active Run は Snapshot へそれぞれ fan-out する。State frame と corrective keyframe は Timeline-owned property を含めない。Run ID、停止理由、projection、version policy は [ADR-0007](../../../docs/decisions/0007-timeline-runtime-run-wire-contract.md) を正本とし、現行 `realtime.proto` には未実装である。個別の conflict、補間、停止規則は Presentation Progression の正本に従う。 Native UI の表示に必要な projection は、ProjectionProfileDescriptor が許可する `visibleVariableIds`、pause-aware logical clock sample、timer表示に必要な現在の Group / Step と `stepEnteredAtRuntimeTimeMilliseconds` を含む projected progression fields を含める。二重の timer state は投影しない。割り当て済み Runtime Core は formatter や文字列置換を評価せず、timer完了は canonical Cue evaluation として判定する。client は Projected Runtime Snapshot の clock と progression state から表示だけを計算する。具体的な Native UI / Semantic Tree の意味論は Presentation Architecture を正本とする。 @@ -441,7 +443,7 @@ State Connection - Control Connection終了時は`connectionId`と未使用の`stateConnectionNonce`を無効化する。 - connection間の到着順は仮定せず、Reliable sequenceと`baseReliableSequence`でapplication上の依存関係を解決する。 -現行の`realtime.proto`とRealtime実装は、単一双方向streamでpresenterの`PageChangeCommand`をserver採番の`PageChanged`へfan-outするfoundationだけを提供する。ここで定義するControl / State二接続、Snapshot / Replay、ProjectionAdvance、Runtime Run、Progression wireはtarget contractであり、現行protoまたは実装済み挙動ではない。 +現行の`realtime.proto`とRealtime実装は、単一双方向streamでpresenterの`PageChangeCommand`をserver採番の`PageChanged`へfan-outするfoundationだけを提供する。Control / State二接続、Snapshot / Replay、ProjectionAdvance、Runtime Run、Progression wireは [ADR-0007](../../../docs/decisions/0007-timeline-runtime-run-wire-contract.md) と [ADR-0008](../../../docs/decisions/0008-runtime-transport-contract.md) で Accepted のtarget contractだが、現行protoまたは実装済み挙動ではない。 実測で TCP retransmission、head-of-line blocking、write blocking、jitter が UX 上の問題になる場合のみ、State Connection を UDP / QUIC 系 transport へ置き換える。Control Connection は gRPC のまま維持する。 @@ -456,7 +458,7 @@ State Connection - Element active / visible の確定 - Transition 開始・完了 - Presentation Origin 更新 -- Runtime Paused / Resumed / Terminating +- `RuntimeStatusChanged`(paused / running / terminating) - session end - participant join / leave - snapshot / replay / resync @@ -477,7 +479,7 @@ ReliableEvent - gap 検知時は replay、保持範囲外なら Snapshot を取得する。 - exactly-once delivery は仮定せず、`eventId` で idempotent に適用する。 - profile projectionでparticipantに不可視なReliable Eventが発生しても、そのeventごとのControl itemを送信しない。Runtime Coreはconnectionごとに連続する不可視sequence範囲を保持し、次の可視Reliable Eventを送る直前に一つの`ProjectionAdvance { fromExclusive, throughSequence }`へ集約する。不可視eventだけを理由にnetwork writeを開始せず、後続の可視eventがなければmarkerも送らない。新しいConnection Snapshotはcutの`reliableSequence`で未送信範囲を置き換える。markerはpayload、resource ID、event kindを含まず、clientはmarkerと直後の可視eventをControl stream順に適用する。これにより可視同期境界ではcanonical event数の集約差分が分かり得るが、不可視eventごとの発生時刻とtraffic patternは公開しない。 -- `RuntimeProtocolLimits`はprotocol versionに紐付くcontractとしてReliable Eventのretention、connectionごとのreplay queue、idempotency window、message size、rate、State buffer、runtime microstep、Snapshot projectionの試行回数と総時間budgetの上限を所有する。超過、保持範囲外のreplay、projection queue overflow、無効inputの許容回数超過は値を推測して継続せず、当該connectionをresyncまたは`RESOURCE_EXHAUSTED` / protocol errorでfail closedにする。 +- `RuntimeProtocolLimits`はprotocol versionに紐付くcontractとしてReliable Eventのretention、connectionごとのreplay queue、idempotency window、message size、rate、State buffer、runtime microstep、Snapshot projectionの試行回数と総時間budgetの上限を所有する。v1 の retention、replay / catch-up queue、Snapshot retry、idempotency、State dependency buffer、microstep の値と超過時の挙動は [ADR-0008](../../../docs/decisions/0008-runtime-transport-contract.md) を正本とする。保持範囲外のreplay、projection queue overflow、無効inputの許容回数超過は値を推測して継続せず、当該connectionをresyncまたは`RESOURCE_EXHAUSTED` / protocol errorでfail closedにする。 同一logical runtime timeに複数のTimerまたはRun completionがある場合は、versionedなevent kind順、stable target ID順、Run ID順で処理する。zero-duration actionから生じる内部eventは同一event loopで処理するが、`RuntimeProtocolLimits`のmicrostep上限を超えた場合は無限遷移としてRuntimeを`Paused`にし、runtime faultをReliable Controlで通知する。 @@ -539,10 +541,20 @@ PresenterInputEvent ```text ElementStateFrame ├─ frameSequence -├─ producedAt -├─ oldestChangeAt +├─ producedAtRuntimeTime +├─ producedAtRuntimeMonotonic +├─ oldestChangeAtRuntimeMonotonic ├─ presentationOriginVersion ├─ baseReliableSequence +├─ anchorBindings[] +│ ├─ nodeId +│ └─ state +│ ├─ unavailable +│ └─ sample +│ ├─ trackingFrameSequence +│ ├─ observedAtRuntimeMonotonic +│ ├─ position? # followPositionだけ +│ └─ rotation? # followRotationだけ └─ elements[] ├─ elementId ├─ changedFields @@ -550,6 +562,7 @@ ElementStateFrame ├─ rotation ├─ scale ├─ active + ├─ visible ├─ animationState └─ playbackPosition ``` @@ -557,12 +570,12 @@ ElementStateFrame - 初期検証範囲は 20–60 Hz とする。 - viewerごとに、gRPCへ渡す前の未送信差分を保持するsingle-slot `StateMailbox`を持つ。 - `StateMailbox`はframeを丸ごと置き換えず、`elementId`とfieldごとに差分をmergeして最新値を残す。これにより、別々のframeで更新されたElementやfieldをcoalesceしても変更を失わない。 -- `StateMailbox`は未送信差分の最古時刻を`oldestChangeAt`として保持する。`frameSequence`と`producedAt`はmailboxから送信frameを確定する時点で採番・記録し、coalesceしただけではsequence gapを作らない。 +- `StateMailbox`はElementをelement / field単位、Anchor bindingをNode ID単位のsample / tombstone全体としてlatest-wins mergeし、position / rotationを別Tracking Frameから合成しない。両mapの未送信差分の最古monotonic時刻を`oldestChangeAtRuntimeMonotonic`として保持する。dequeueは一つのcritical sectionで両map、logical runtime time、Runtime monotonic time、Reliable / Origin fenceをimmutable frameへfreezeしてから`frameSequence`を採番し、coalesceしただけではsequence gapを作らない。 - viewerごとに`Send`を実行するgoroutineを一つに限定し、同時に複数のframeをgRPCへ渡さない。送信中のframeはimmutableとし、その間の更新は次の`StateMailbox`へmergeする。 - latest-winsが保証する範囲はgRPCの`Send`へ渡す前までとする。すでにHTTP/2またはTCP bufferへ渡した古いframeは取り消せない。 - 変更された Element と field だけを送る。 - 複数 Element を一つの frame に batch する。 -- `StateMailbox`から取り出す直前に`oldestChangeAt`を検査し、未送信時間が`stateMaxFrameAge`を超えた差分は送らずState Connectionを再確立する。 +- `StateMailbox`から取り出す直前に`oldestChangeAtRuntimeMonotonic`を検査し、未送信時間が`stateMaxFrameAge`を超えた差分は送らずState Connectionを再確立する。 - `Send`のwrite blockが`stateWriteBlockTimeout`を超えた場合も、該当viewerのState Connectionだけをcancelし、mailboxと送信中frameを破棄する。他viewerとReliable Controlをblockしない。 - runtimeが`Running`の場合、State Connectionの初回確立と再確立では、`StateReady`後に現在の全Element Stateをkeyframeとして一度送ってから差分配信を開始する。cancel時に送達不明となったframeは、このkeyframeで収束させる。 - 送信前のcoalesceは許容するが、受信した`frameSequence`にgapがある場合は送達済み差分の欠落とみなし、State Connectionを再確立してkeyframeを取得する。 @@ -570,13 +583,17 @@ ElementStateFrame - `baseReliableSequence`は、そのprojected State Frameが実際に前提にする、当該participantへ送信済みまたは同じ送信境界で先行するReliable sequenceである。participantに不可視なeventだけで値を進めない。clientの適用済みReliable sequenceより大きいframeは、その前提となる可視Reliable Eventまたは集約`ProjectionAdvance`を適用するまで、`elementId`とfieldごとの最新値だけをbufferする。 - `baseReliableSequence`がclientの適用済みsequenceより小さいframe、または`presentationOriginVersion`が一致しないframeはstaleとして破棄し、Control Connection上でState keyframeを要求する。 - Reliable Event適用後は、条件を満たしたbufferをfield単位でmergeして適用する。client側bufferの時間または容量上限を超えた場合はState Connectionを再確立し、Reliable ControlやSession全体をblockしない。 +- `anchorBindings`はraw Presenter poseの一覧ではなく、projection profileでvisibleかつAnchor parentを直接参照するNode IDだけをkeyにしたephemeral parent stateである。Nodeの`followPosition` / `followRotation`が要求する成分だけを運び、参照されないAnchor、profile外Node、追従しない成分を配信しない。追従しないpositionはzero translation、追従しないrotationはidentityとしてparent matrixを組み立てる。`trackingFrameSequence`は元Tracking Frame、`observedAtRuntimeMonotonic`はRuntimeが受理した時点の`producedAtRuntimeMonotonic`と同じmonotonic time domainとする。client申告の`capturedAt`をfreshness authorityにしない。 +- `anchorSampleMaxAgeMilliseconds`はprotocol versionに紐付く`500 ms`とする。frame生成時に`producedAtRuntimeMonotonic - observedAtRuntimeMonotonic > 500 ms`、tracking unavailable、またはOrigin version不一致ならRuntimeは`unavailable` tombstoneを送る。clientも同期済みRuntime monotonic timeで500 msを超えたsampleをlocal expiryし、次のfresh sampleまでAnchor childを描画せずhit-testしない。 +- 初回 / 再接続keyframeは全Element Stateに加えて、そのcutでvisibleな全Anchor-bound Nodeのfresh sampleまたは`unavailable`をexactly onceで含む。Projected Runtime Snapshot自体にはAnchorを含めず、clientは初回keyframeまたは後続fresh sampleの到着まで該当Nodeをunavailableにする。 +- clientは一つのState FrameのElement patch、Anchor patch、`baseReliableSequence`、`presentationOriginVersion`をatomicに適用する。各render / hit sampleでは最後に適用したframeSequence、Control cut、Origin、Anchor setをfreezeし、一つのlogical runtime timeでactive Runを評価する。visual Transformとauthoritative hit-testに別のcutを使わない。 ### 8.5 Clock synchronization 割り当て済み Runtime の clock を active session の基準とする。Quest は定期的な ping / pong で Runtime との clock offset と RTT を推定する。 - Cue / Transition / playback に Runtime 時刻を付ける。 -- Quest は `producedAt` と推定 offset を使って interpolation buffer を制御する。 +- Quest は`producedAtRuntimeMonotonic`と推定offsetをtransport / freshness判定に、`producedAtRuntimeTime`をTimeline / media interpolationに使い、二つのtime domainを減算しない。 - RTT や jitter の急増時も古い state を順に再生せず、最新 state へ追従する。 ## 9. 高トラフィックへの対応 @@ -589,10 +606,10 @@ Runtime instance の概算 egress は次で決まる。 49 viewer に対する raw payload の例: -| 条件 | 概算 egress | -| --- | ---: | -| 60 Hz × 10 Element × 64 B | 約 15 Mbps | -| 60 Hz × 20 Element × 100 B | 約 47 Mbps | +| 条件 | 概算 egress | +| -------------------------- | ----------: | +| 60 Hz × 10 Element × 64 B | 約 15 Mbps | +| 60 Hz × 20 Element × 100 B | 約 47 Mbps | | 60 Hz × 50 Element × 100 B | 約 118 Mbps | | 60 Hz × 50 Element × 256 B | 約 301 Mbps | @@ -652,10 +669,10 @@ Durable checkpointもcritical section内ではCanonicalRuntimeSnapshotのimmutab State Connectionだけが切断された場合、Control Connection上のsession-global Reliable sequence、PublicationFence、`presentationOriginVersion`が引き続き一致していれば、Snapshotを取り直さず新しい`stateConnectionNonce`でState Connectionを再確立できる。runtimeが`Running`なら再確立後のkeyframeを適用してから差分配信へ戻る。`Paused`ならframeを送らず、Runtime Resume時にkeyframeを送ってから差分配信を開始する。Control側にもgapがある場合、PublicationFenceが一致しない場合、または`presentationOriginVersion`が変わった場合は、通常のConnection Resumeとして新しいDeliveryManifest、Connection Snapshot、`connectionId`を取得する。 -- runtimeが`Paused`の場合も、leaseが有効なら既存participantのConnection Resumeと新しいviewerのjoinを許可する。Snapshotにpause理由と進行位置を含め、State Connectionは確立するが`RuntimeResumed`まで新しいElement State frameを送らない。 +- runtimeが`Paused`の場合も、leaseが有効なら既存participantのConnection Resumeと新しいviewerのjoinを許可する。Snapshotにpause理由と進行位置を含め、State Connectionは確立するが`RuntimeStatusChanged(running)`まで新しいElement State frameを送らない。 - lease期限切れによる`Paused`では新しいconnectionとjoinを受け入れない。lease更新後も自動的に`Running`へ戻さず、presenterのRuntime Resumeを要求する。 - pause開始時はlogical runtime clockを停止する。Transition / TimelineのdeadlineとMedia cursorは同じlogical time基準を使うため、Runごとの`elapsedBeforePause`やpause補正値を持たない。 -- Runtime Resume時は保存済みlogical timeを新しいmonotonic clock基準へbindしてから`RuntimeResumed`を配信する。pause中のwall-clock経過はTransition、Timeline、playbackへ加算しない。 +- Runtime Resume時は保存済みlogical timeを新しいmonotonic clock基準へbindしてから`RuntimeStatusChanged(running)`を配信する。pause中のwall-clock経過はTransition、Timeline、playbackへ加算しない。 Connection presenceは接続時点の`presenceAtCut`としてConnectionSnapshotEnvelopeにだけ含め、DurableCheckpointEnvelopeへ保存しない。process recovery後のconnection registryは空から再構築する。 @@ -714,9 +731,9 @@ cache/ - 同じ Asset を複数 Presentation / session で再利用する。 - Cloud からの取得には短命 signed URL を使い、Edge へ恒久 R2 credential を置かない。 -- cache capacity と eviction policy は実機の disk 容量と preload 計測後に決定する。 +- baseline cache capacity、reserve、pin / eviction policyは [ADR-0012](../../../docs/decisions/0012-texture-budget-residency-contract.md) を正本とし、実機計測による変更は新tier / policy versionで行う。 - active session が参照する Asset は eviction しない。 -- cache の hard limit、low-disk threshold、LRU 等の退避順、partial download の回収、複数 session の quota は未決定事項とする。 +- targetは4 GiB hard limit、512 MiB low-disk reserve、active / waiting Session reference count pin、unpinned content hashのdeterministic LRU、partial / mismatch staging cleanupを実装する。現行cacheにはcapacity / pin / LRUがなく、M5で接続する。 ### 11.4 Venue Edge の Quest 向け endpoint @@ -760,6 +777,8 @@ ViewerReadiness ├─ publicationManifestHash ├─ availableAssetHashes ├─ missingAssetHashes +├─ downloadReady +├─ residentReady ├─ presentationOriginVersion ├─ calibrationRevision ├─ calibrationReady @@ -769,6 +788,7 @@ ViewerReadiness - Quest cache に存在する hash は再取得しない。 - 大容量 Asset は session 開始より前に preload する。 +- `downloadReady`はselected Assetのsize / MIME / checksum検証、`residentReady`はADR-0012の全selected texture uploadとCPU readback解放を表す。両方とconnection / fence条件を満たすparticipantだけを`sessionReady`として開始policyのready集合へ数える。 - Cloud 配置では CDN、Venue Edge 配置では Asset Gateway が 50 台の同時 burst と download concurrency を制御する。 - session 開始条件を「全 participant ready」または明示した policy として定義する。 @@ -841,7 +861,7 @@ Internet 障害中の継続は best-effort であり、offline 対応を正式 ### 13.2 Presenter切断 - Runtime は現在のElement State、Transition経過時間、playback位置を固定し、`Running`から`Paused`へ遷移する。 -- viewerの既存connectionは維持し、`RuntimePaused`を配信する。leaseが有効なら新しいviewerもjoinできるが、Paused Snapshotを受け取った状態で待機する。 +- viewerの既存connectionは維持し、`RuntimeStatusChanged(paused)`を配信する。leaseが有効なら新しいviewerもjoinできるが、Paused Snapshotを受け取った状態で待機する。 - 一定時間presenterのConnection Resumeを待つ。再接続したpresenterへSnapshotとpause理由を返す。 - presenterが明示的な`ResumeRuntime`を送信し、assignment leaseとControl Plane session stateを確認できた場合だけ`Running`へ戻す。 - timeout後は`Terminating`へ遷移する。この遷移後はpresenterが戻ってもRuntime Resumeを拒否し、Control Planeへsession completionを送ってdurable stateを`Ended`にする。 @@ -884,15 +904,15 @@ MVP では session 開始前に配置先を選び、実行中に Runtime が復 Runtime Core に対する環境差は次の deployment profile で扱う。 -| 項目 | Fly.io Cloud Runtime | Venue Edge Runtime | -| --- | --- | --- | +| 項目 | Fly.io Cloud Runtime | Venue Edge Runtime | +| ---------- | ------------------------------------------------------------------------- | -------------------------------------------------------------- | | 起動・登録 | container image の release、Machine 起動、Control Plane への Runtime 登録 | installer / service manager、Edge Bearer credential による登録 | -| endpoint | Internet 上の公開 TLS gRPC endpoint | 会場 LAN 上の gRPC と local HTTPS endpoint | -| TLS trust | 公開 CA を基本とする | bootstrap で配布する fingerprint pinning | -| Asset | Quest が R2 / CDN signed URL から直接取得 | Edge が prefetch、検証、cache して LAN 配信 | -| health | Fly.io health check と application readiness | Cloud Agent による process、disk、LAN、cache readiness | -| 更新 | image release と Machine rollout | signed binary / image の段階更新と rollback | -| recovery | Machine 再起動・再配置と Cloud checkpoint | process supervisor と local checkpoint | +| endpoint | Internet 上の公開 TLS gRPC endpoint | 会場 LAN 上の gRPC と local HTTPS endpoint | +| TLS trust | 公開 CA を基本とする | bootstrap で配布する fingerprint pinning | +| Asset | Quest が R2 / CDN signed URL から直接取得 | Edge が prefetch、検証、cache して LAN 配信 | +| health | Fly.io health check と application readiness | Cloud Agent による process、disk、LAN、cache readiness | +| 更新 | image release と Machine rollout | signed binary / image の段階更新と rollback | +| recovery | Machine 再起動・再配置と Cloud checkpoint | process supervisor と local checkpoint | Cloud Runtime を先に実装し、Quest だけで end-to-end MVP を成立させる。Fly.io 上の初期 profile は単一 Session を明示的な Runtime instance へ割り当て、公開 TLS endpoint、application health、固定した Runtime identity を提供する。region、複数 Session、Machine affinity、autoscaling、durable Snapshot store、rolling update 中の Assignment の扱いは実測後に決定する。 @@ -1094,7 +1114,6 @@ Transport変更時も Protocol message と Session Runtime を transport-indepen - Cue priority、排他、再発火、debounce - Transition tick rateとElement種別ごとの配信rate - position / rotationの量子化精度 -- ConnectionSnapshotEnvelope / DurableCheckpointEnvelopeの正確なProtobuf schema、Reliable Event保持量、replay上限 - Cloud durable checkpointとVenue Edge local checkpointにおけるDurableCheckpointEnvelopeの保存先、書き込み頻度、atomic replacement、破損回復 - Presenter再接続timeout - `stateWriteBlockTimeout`と`stateMaxFrameAge`の初期値 @@ -1119,7 +1138,8 @@ Transport変更時も Protocol message と Session Runtime を transport-indepen - Internet grace periodとsession停止policy - assignment lease duration、renew interval、health freshness。現在のEdge固有renew APIは安全上の暫定上限を5分、割当・bootstrapに使うheartbeat ageは60秒とする - Edge tokenの有効期間、rotation interval、overlap期間 -- Asset cacheのhard capacity、low-disk threshold、eviction順、active session pin、partial download回収、session quota +- ADR-0012のbaseline cache policyをQuest実機計測後も維持するか、別capacity tierを追加するか +- session単位quotaをbaseline cache policyへ追加するか ### 18.4 将来の Runtime migration diff --git a/app/web/README.md b/app/web/README.md index 6e254bc..e14bec8 100644 --- a/app/web/README.md +++ b/app/web/README.md @@ -36,14 +36,14 @@ pnpm --filter @unframe/web run dev 利用できる fixture route は次のとおりです。 -| URL | 用途 | -| ------------------------------------------- | ----------------------------------- | -| `/home/` | 認証必須の Presentation 一覧 | +| URL | 用途 | +| ------------------------------------------- | ------------------------------------------------------------------ | +| `/home/` | 認証必須の Presentation 一覧 | | `/devices/`、`/rooms/` | デバイス・Session 管理の準備画面。`/rooms/` は移行前の fixture URL | -| `/editor/demo/?panel=properties` | 認証必須の POC Editor | -| `/device/?user_code=ABCD-EFGH` | Device Authorization のブラウザ承認 | -| `/login/`、`/signup/`、`/recover/` | public authentication routes | -| `/settings/profile/`、`/settings/security/` | account settings | +| `/editor/demo/?panel=properties` | 認証必須の POC Editor | +| `/device/?user_code=ABCD-EFGH` | Device Authorization のブラウザ承認 | +| `/login/`、`/signup/`、`/recover/` | public authentication routes | +| `/settings/profile/`、`/settings/security/` | account settings | application route の `beforeLoad` は未認証を LP 所有の `/` へ外部遷移させます。 diff --git a/app/web/tsconfig.json b/app/web/tsconfig.json index ed8ef56..36bee31 100644 --- a/app/web/tsconfig.json +++ b/app/web/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../packages/config/tsconfig.base.json", "compilerOptions": { "jsx": "react-jsx", + "lib": ["ES2022", "DOM", "DOM.Iterable"], "noEmit": true, "paths": { "@/*": ["./src/*"] diff --git a/docs/decisions/0004-monorepo-layout-and-nix-toolchain.md b/docs/decisions/0004-monorepo-layout-and-nix-toolchain.md index 7352430..f8bd964 100644 --- a/docs/decisions/0004-monorepo-layout-and-nix-toolchain.md +++ b/docs/decisions/0004-monorepo-layout-and-nix-toolchain.md @@ -67,7 +67,7 @@ - [x] `app/backend` → `app/server` のリネームと、参照(README / scripts)の追従。 - [x] 旧 OpenAPI contract と生成 client を削除し、`packages/contracts` を将来の共有境界として残す。 -- [x] `packages/config` を新設し、共有 tsconfig / Vite+ staged hook を集約。 +- [x] `packages/config` を新設し、runtime-neutral な共有 tsconfig、Vite+ staged 設定、repository-owned hook を集約。 - [x] `tools/notion-sync` を `scripts/docs/notion-sync` へ移設(完了)。 - [ ] `flake.lock` を生成し、`nix flake check` をローカルと CI で検証。 - [x] `app/web`(React 編集エディタ)のワークスペースを新設。 diff --git a/docs/decisions/0006-presentation-rendering-strategy.md b/docs/decisions/0006-presentation-rendering-strategy.md index 9605c77..ef8476a 100644 --- a/docs/decisions/0006-presentation-rendering-strategy.md +++ b/docs/decisions/0006-presentation-rendering-strategy.md @@ -3,7 +3,7 @@ - **Status**: Accepted - **Date**: 2026-08-25 - **Deciders**: Unframe 開発チーム -- **関連**: [Presentation Architecture](../presentation/ARCHITECTURE.md), [ADR-0005: 空間プレゼンテーションのドメインモデルを定義する](./0005-spatial-presentation-domain-model.md), [Repository Architecture](../../ARCHITECTURE.md), [Server Architecture](../../app/server/ARCHITECTURE.md) +- **関連**: [Presentation Architecture](../presentation/ARCHITECTURE.md), [ADR-0005: 空間プレゼンテーションのドメインモデルを定義する](./0005-spatial-presentation-domain-model.md), [ADR-0014: Presentation の描画方式を限定する](./0014-presentation-rendering-scope.md), [Repository Architecture](../../ARCHITECTURE.md), [Server Architecture](../../app/server/ARCHITECTURE.md) ## Context @@ -29,12 +29,12 @@ ADR-0005 は Group、Step、Cue、Trigger、Action を中心とした空間プ この ADR が受理するのは上位アーキテクチャであり、すべての下位 contract を同時に固定するものではない。 -| 対象 | 状態 | -| --- | --- | -| Architecture baseline | Accepted | -| Presentation Progression の意味モデル | v1 baseline | -| Progression wire / runtime schema | Draft | -| Authoring、Rendering、Delivery の下位 contract | Follow-up | +| 対象 | 状態 | +| ---------------------------------------------- | ----------- | +| Architecture baseline | Accepted | +| Presentation Progression の意味モデル | v1 baseline | +| Progression wire / runtime schema | Draft | +| Authoring、Rendering、Delivery の下位 contract | Follow-up | ### 基本原則 @@ -55,15 +55,15 @@ ADR-0005 は Group、Step、Cue、Trigger、Action を中心とした空間プ Presentation を次の契約へ分離する。 -| 契約 | 責務 | -| --- | --- | -| Authoring Source | Presentation の composition、Component、Theme、Asset 選択を人が記述する | -| Semantic Authoring IR | GUI と Code が共同編集する正規化モデルと authoring metadata を保持する | -| PresentationDefinition | Scene、Surface、State、Interaction、進行など Presentation の意味を保持し、v1 では canonical JSON として生成する | -| RenderBundle | Local Compiler が生成した Texture、Video、Native UI plan、Semantic Tree などを保持する | -| DeliveryManifest | target capability、認可、Asset binding、Signed URL を解決した Runtime projection を保持する | +| 契約 | 責務 | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------- | +| Authoring Source | Presentation の composition、Component、Theme、Asset 選択を人が記述する | +| Semantic Authoring IR | GUI と Code が共同編集する正規化モデルと authoring metadata を保持する | +| PresentationDefinition | Scene、Surface、State、Interaction、進行など Presentation の意味を保持し、v1 では canonical JSON として生成する | +| RenderBundle | Local Compiler が生成した Texture、Video、Native UI plan、Semantic Tree などを保持する | +| DeliveryManifest | target capability、認可、Asset binding、Signed URL を解決した Runtime projection を保持する | | Published Presentation | 整合する PresentationDefinition、RenderBundle、Asset Set、contract version を現在の公開済み実行物として一つだけ束ねる | -| Runtime State | Session 中の現在 Group、Step、Node、Surface State、Timeline、playback、presence を保持する | +| Runtime State | Session 中の現在 Group、Step、Node、Surface State、Timeline、playback、presence を保持する | Authoring Source と Semantic Authoring IR は編集のための情報を保持する。PresentationDefinition は実行可能な意味を保持する。RenderBundle と DeliveryManifest は再生成可能な派生物とし、PublishedPresentation はそれらの整合する組み合わせを一つの immutable value に固定する。Runtime State は実行中にだけ存在し、PresentationDefinition へ書き戻さない。 @@ -87,9 +87,9 @@ Component の抽象を超えた編集は Authoring 上で Detach し、Delivery Scene Graph は、空間配置を表す Spatial Tree と、Surface 内の 2D UI を表す Surface Tree から構成する。 -Group は物語上の進行スコープであり、Scene Graph の親子関係とは分離する。Spatial Tree は Stage、Anchor、Container、Model、Audio、SurfaceNode などの空間関係を保持する。SurfaceNode と Semantic Surface の組が 3D 空間と 2D UI を接続し、Semantic Surface が物理 size と logical size を持つ。 +Group は物語上の進行スコープであり、Scene Graph の親子関係とは分離する。Spatial Tree は Stage、Anchor、Container、Model、SurfaceNode などの空間関係を保持する。SurfaceNode と Semantic Surface の組が 3D 空間と 2D UI を接続し、Semantic Surface が物理 size と logical size を持つ。 -Runtime resource owner は `presentation` または一つの `group` に限定し、Step scope と Group ごとの ID namespace は作らない。Spatial Node、Timeline、Variable、Zone が owner を直接持ち、Semantic Surface、Interaction、Media、Render Surface は親から継承する。Group の owned-resource list は正本にせず、Compiler / Delivery が owner から activation index を派生生成する。 +Runtime resource owner は `presentation` または一つの `group` に限定し、Step scope と Group ごとの ID namespace は作らない。Spatial Node、Timeline、Variable、Zone が owner を直接持ち、Semantic Surface、Interaction、Video playback state、Render Surface は親から継承する。Group の owned-resource list は正本にせず、Compiler / Delivery が owner から activation index を派生生成する。 presentation-owned resource は presentation-owned resource、Group G-owned resource と Group G の Cue は presentation-owned または同じ Group G-owned resource だけを参照できる。ownership は Spatial parent と分離し、group-owned Node は presentation-owned Node を parent にできる。presentation-owned Runtime State と Run は Group をまたいで継続し、group-owned state は exit で破棄して reentry で reset する。Timeline も presentation / group の両 owner を許す。 @@ -99,19 +99,19 @@ Component は再利用と編集、SurfaceNode は空間配置と animation、Sem Surface は、Spatial Tree 上で Transform を所有する SurfaceNode、PresentationDefinition 上で State / Interaction を所有する Semantic Surface、RenderBundle 内部の派生的な Render Surface に分ける。v1 は SurfaceNode と Semantic Surface を 1:1、Semantic Surface と Render Surface を 1:N とする。 -canonical Runtime contract の SurfaceId は SemanticSurfaceId を意味する。Node Action と Timeline は SurfaceNode を含む SpatialNodeId、Surface State、Interaction、media Action、Progression は SemanticSurfaceId を参照する。RenderSurfaceId は compiler-derived な build-local ID とし、Trigger、Guard、Action、Snapshot、Reliable Event に含めない。 +canonical Runtime contract の SurfaceId は SemanticSurfaceId を意味する。Node Action と Timeline は SurfaceNode を含む SpatialNodeId、Surface State、Interaction、Video の media Action、Progression は SemanticSurfaceId を参照する。media Action は Video artifact を持つ Semantic Surface だけを対象とする。RenderSurfaceId は compiler-derived な build-local ID とし、Trigger、Guard、Action、Snapshot、Reliable Event に含めない。 ### Rendering Semantic Scene Graph を優先し、renderer を Local Compiler と Delivery の出力戦略とする。 -3D Model、Shape、Spatial Audio、Transform、Anchor tracking は Unity native で描画する。静的 UI と少数の有限状態 UI は Web で描画して Render Surface 単位に Texture 化する。継続的に変化する限定 UI は portable な Native UI とし、入力非依存の複雑な連続演出は Video とする。 +3D Model、Shape、Transform、Anchor tracking は Unity native で描画する。静的 UI と少数の有限状態 UI は Web で描画して Render Surface 単位に Texture 化する。Timer、Counter、短い動的 Text は portable な Native UI とし、入力非依存の複雑な連続演出は Video とする。独立した音声、BGM、効果音、空間音声は対象外とし、音声は Video artifact 内の audio track としてだけ扱う。 Semantic Surface は具体 renderer ではなく Render Intent を持つ。Concrete renderer と解像度は build 結果と target capability に基づいて RenderBundle と DeliveryManifest で確定する。 Surface State は意味論的な状態として保持し、Texture ID や Unity object を参照しない。Renderer artifact は RenderBundle が Surface State に対応付け、DeliveryManifestはtarget capabilityに応じてRender Surfaceの各到達可能stateへartifactまたは明示的なempty bindingを固定する。 -Embedded Browser は v1 の標準 renderer に含めない。Control Plane と Unity Runtime は Authoring Source、React、HTML、CSS、renderer source を実行しない。 +採用する描画方式と適用範囲は [ADR-0014](./0014-presentation-rendering-scope.md) に従う。`embedded-web`、WebView、Unity Runtime で任意の HTML / CSS / JavaScript / WebAssembly を実行する方式はプロジェクトの対象外とし、将来用の artifact kind や予約 field も作らない。Control Plane と Unity Runtime は Authoring Source、React、HTML、CSS、renderer source を実行しない。固定 Browser での Opaque TS / React / CSS 実行は build-time artifact generation の隔離境界に限って許可する。 ### Presentation Progression @@ -179,9 +179,9 @@ Tracking、input、clock、renderer の差によって Cue と State が分岐 この ADR は目標アーキテクチャを採用するものであり、すべてが現行コードへ実装済みであることを意味しない。 -2026-08-25 時点では、現行 PresentationDefinition は ADR-0005 の Group、Step、Cue、Element を中心とした JSON / OpenAPI 契約である。Semantic Authoring IR、Component package、Spatial Tree / Surface Tree、RenderBundle、DeliveryManifest、hybrid renderer、v1 Presentation Progression の wire / runtime schema は target design であり、完成した production contract ではない。 +2026-09-15 時点では、Authoring Source の virtual resolution、typecheck、Static DSL lowering から、固定 Browser capture、deterministic PNG encode、`definition.json`、`render-bundle.json`、`assets/*.png` の atomic output までを M1 Local Compiler として実装済みである。現行 contract と renderer は static な `baked-web` 初期 subset に限られる。完全版 contract、DeliveryManifest、Native UI / Video consumer、Unity hybrid renderer、v1 Presentation Progression の wire / runtime schema は target design であり、完成した production contract ではない。 -この ADR だけで既存 schema を置き換えたとはみなさない。実装時は契約変更、migration、OpenAPI / Protobuf artifact、Web / Unity / Realtime consumer、contract test を同期する。 +この ADR だけで既存 schema を置き換えたとはみなさない。実装時は契約変更、migration、OpenAPI / Protobuf artifact、Web / Unity / Realtime consumer、contract test を同期する。既存 Unity code に独立音声の実装が残っていても、Target contract の採用範囲には含めない。 ## Follow-ups @@ -197,11 +197,11 @@ Tracking、input、clock、renderer の差によって Cue と State が分岐 - [x] Surface transition、Action batch、active Timeline Run の conflict policy と Timeline の補間・停止規則を [Surface State](../presentation/ARCHITECTURE.md#124-surface-state)、[Action](../presentation/ARCHITECTURE.md#127-action)、[Timeline](../presentation/ARCHITECTURE.md#128-timeline) で定義する。 - [x] Surface State ごとの完成 Semantic Tree、Hit Region 整合、Native UI v1 subset、text binding、font asset、projection Variable / Clock 規則を [Presentation Architecture](../presentation/ARCHITECTURE.md#132-semantic-tree)、[Native UI Artifact](../presentation/ARCHITECTURE.md#143-native-ui-artifact)、[DeliveryManifest](../presentation/ARCHITECTURE.md#35-deliverymanifest) で定義する。 - [x] Surface transition の開始・完了、Surface interaction input / outcome、Interaction / Hit Region 有効化の wire contract を [Presentation Architecture](../presentation/ARCHITECTURE.md#surface-transition--interaction-wire-contract) で定義する。 -- [ ] Component から Render Surface への partition 規則、自動化範囲、author override を決定する。 -- [ ] ADR-0005 で固定済みの座標系を前提に、Transform 合成、Quaternion 乗算、matrix layout、Unity 変換、Surface / UV 変換の完全な規約を定義する。 +- [x] ComponentからRender Surfaceへのpartition規則、自動化範囲、author overrideを [ADR-0011](0011-surface-partition-contract.md) で定義する。 +- [x] ADR-0005の基礎座標系を前提に、Transform / matrix / Quaternion / Unity / Surface / UVの完全な規約を [ADR-0010](0010-spatial-surface-coordinate-contract.md) で定義する。 - [ ] SurfaceRenderIntent、Surface State、RenderBundle、DeliveryManifest の schema と versioning を定義する。 -- [ ] Texture build budget、resolution、mipmap、compression、preload、eviction policy を定義する。 -- [ ] role 別 Semantic schema と Hit Region の完全な schema を定義する。 +- [x] Texture build budget、resolution、mipmap、compression、preload、eviction policyを [ADR-0012](0012-texture-budget-residency-contract.md) で定義する。 +- [x] role 別 Semantic schema と Hit Region の完全なschemaを [ADR-0009](0009-semantic-tree-hit-region-contract.md) で定義する。 - [ ] Opaque renderer の Browser capability、module resolution、cache invalidation と Component / renderer drift 検証を設計する。 - [ ] DeliveryManifest Protobuf schema と capability negotiation を定義する。 - [ ] v1 Presentation Progression の意味論を Progression wire / Runtime contract、Realtime protocol、Snapshot、consumer へ落とし込む。 diff --git a/docs/decisions/0007-timeline-runtime-run-wire-contract.md b/docs/decisions/0007-timeline-runtime-run-wire-contract.md new file mode 100644 index 0000000..fa6567b --- /dev/null +++ b/docs/decisions/0007-timeline-runtime-run-wire-contract.md @@ -0,0 +1,107 @@ +# ADR-0007: Timeline と Runtime Run の semantic wire contract を定義する + +- **Status**: Accepted +- **Date**: 2026-08-29 +- **Deciders**: Unframe 開発チーム +- **関連**: [Presentation Architecture](../presentation/ARCHITECTURE.md), [Realtime Architecture](../../app/server/realtime/ARCHITECTURE.md), [Contracts Architecture](../../packages/contracts/ARCHITECTURE.md), [ADR-0006](./0006-presentation-rendering-strategy.md) + +## Context + +Timeline の時間補間を全 client の State Stream へ毎 frame 配送すると、authority、replay、projection、帯域の責務が混在する。一方で client が Timeline 定義または Run lifecycle を知らなければ、pause-aware clock 上で同じ表示を補間できない。本 ADR は M2 item 1 の実装前 **semantic wire contract** を固定する。field number を含む transport contract は [ADR-0008](./0008-runtime-transport-contract.md) で固定し、現行 `realtime.proto` は foundation のままである。 + +## Decision + +### Catalog、authority、State Stream + +- `TimelineDefinition` は immutable `PresentationDefinition` catalog の一部とし、`PublishedPresentation` が固定する。Delivery は ProjectionProfileDescriptor で可視な Timeline の subset だけを送る。lifecycle event に定義全体を複製しない。 +- Trigger、Cue、Run 作成・停止・完了、最終値 commit は割り当て済み **Runtime Core** だけが authority を持つ。client は Delivery 済み catalog、active Run、pause-aware logical runtime time から表示値を local interpolation する。 +- State Stream は tracking 等の **Timeline 以外**の連続状態と bounded corrective keyframe に限定する。Timeline の毎 frame 値は送らず、keyframe は authority、completion source、replay source にしない。 + +### Runtime Run と lifecycle + +field number は ADR-0008を正本とし、message の意味と presence は次に固定する。 + +```proto +message RuntimeRunId { + uint64 assignment_epoch = ...; + uint64 run_sequence = ...; +} +message RuntimeRunOwner { oneof scope { PresentationRunOwner presentation = ...; GroupRunOwner group = ...; } } +message PresentationRunOwner {} +message GroupRunOwner { string group_id = ...; uint64 group_entry_epoch = ...; } +message RuntimeRunCause { + string cue_id = ...; + string cause_event_id = ...; + string group_id = ...; + uint64 group_entry_epoch = ...; + string step_id = ...; + uint64 step_entry_epoch = ...; +} +enum RunCompletion { RUN_COMPLETION_UNSPECIFIED = 0; RUN_COMPLETION_BLOCKING = ...; RUN_COMPLETION_NON_BLOCKING = ...; } +enum TimelineCancelReason { TIMELINE_CANCEL_REASON_UNSPECIFIED = 0; EXPLICIT_STOP = ...; GROUP_EXIT = ...; PRESENTATION_ENDED = ...; } +message TimelineStarted { RuntimeRunId run_id = ...; string timeline_id = ...; RuntimeRunOwner owner = ...; RuntimeRunCause cause = ...; RunCompletion completion = ...; uint64 started_at_runtime_time_ms = ...; } +message TimelineCompleted { RuntimeRunId run_id = ...; string timeline_id = ...; } +message TimelineCanceled { RuntimeRunId run_id = ...; string timeline_id = ...; TimelineCancelReason reason = ...; } +message TimelineRunSnapshot { RuntimeRunId run_id = ...; string timeline_id = ...; RuntimeRunOwner owner = ...; RuntimeRunCause cause = ...; RunCompletion completion = ...; uint64 started_at_runtime_time_ms = ...; } +``` + +`RuntimeRunId` は session / assignment scoped であり、`run_sequence` は assignment 内で単調増加する。logical runtime time は pause-aware な非負 `uint64` milliseconds であり、`0` は有効である。`TimelineCompleted` / `TimelineCanceled` の completion / cancel 時刻は Reliable Event envelope の `occurredAtRuntimeTimeMilliseconds` を正本とし、payload に重複させない。`TimelineRunSnapshot` は Started 相当の active fields だけを持つ。required message は presence、string ID は non-empty を要求し、assignment epoch / run sequence の zero、`UNSPECIFIED` enum、空 oneof は invalid とする。 + +`TimelineCanceled.reason` は次の明示 stop reason のいずれかを持つ。 + +- `explicitStop` +- `groupExit` +- `presentationEnded` + +`active RuntimeRunSnapshot` は Snapshot と projected active-run view に含め、Run ID、Timeline ID、owner / cause epoch、completion、開始 logical time を表す。duration / track は immutable catalog から解決する。Run lifetime 全体を一つの commit とせず、開始は Run 追加・blocking set 更新・`TimelineStarted` を一つの mutation とする。normal completion は最終値 commit・Run 除去・blocking set 更新・`TimelineCompleted`・派生 progression transition を一つの mutation とする。`explicitStop` / `groupExit` は停止時の現在値 commit・Run 除去・`TimelineCanceled` を一つの mutation とする。`presentationEnded` は値を commit せず、run ID 順の `TimelineCanceled` 後に `PresentationEnded` を確定する。completion は Runtime Core の logical deadline だけが source であり、stale Run ID、owner epoch 不一致、重複 completion は state / event を変更しない。completion event はその completion が解除する派生 Group / Step event より先に投影する。 + +### Runtime status と fault + +```proto +message RuntimeStatusChanged { + oneof status { Running running = ...; Paused paused = ...; Terminating terminating = ...; } +} +message Running {} +message Paused { PauseReason reason = ...; } +message Terminating { TerminationReason reason = ...; } +enum PauseReason { PAUSE_REASON_UNSPECIFIED = 0; PRESENTER_DISCONNECTED = ...; ASSIGNMENT_LEASE_EXPIRED = ...; PROCESS_RECOVERED = ...; INVARIANT_VIOLATION = ...; ATOMIC_COMMIT_FAILED = ...; MICROSTEP_LIMIT_EXCEEDED = ...; RECOVERY_GAP = ...; } +enum TerminationReason { TERMINATION_REASON_UNSPECIFIED = 0; EXPLICIT_END = ...; CONTROL_PLANE_ENDED = ...; PAUSE_TIMEOUT = ...; } +``` + +Runtime fault は terminating ではなく `paused` とし、`PauseReason` で invariant violation、atomic commit failure、microstep overflow、recovery gap を区別する。`terminating` は `explicitEnd`、`controlPlaneEnded`、`pauseTimeout` の不可逆終了だけに用いる。recovery で `running` を暗黙再開せず、`processRecovered` の `paused` として復元する。 + +### Projection と compatibility + +Projection は hidden Timeline / Run / target ID を event、snapshot、State Stream に漏らさない。Timeline と全 target が同じ profile visibility closure を満たさない PublishedPresentation は session 開始前の build / Delivery validation error とする。partial projection と auto split は v1 で禁止し、split は将来 contract version の work とする。 + +Delivery / handshake target contract は `progressionContractVersion = 1` と required capability `TIMELINE_RUN_V1` を必須化する。unsupported major、unknown required capability は `FAILED_PRECONDITION` とし downgrade fallback を許可しない。proto3 の unknown optional field は許容して無視できるが、semantic required field の追加は version / capability を必須にする。unknown / `UNSPECIFIED` enum と未知の required event variant は fail closed で Connection Resume を要求する。 + +### Consumer responsibility + +| Consumer | Responsibility | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| TypeScript contracts / Compiler | catalog、visibility closure、version / required capability を検証し、portable fixture を提供する | +| Go Realtime | Runtime Core authority、atomic commit、Run allocator、stale completion reject、projection を実装する | +| C# generated client | protobuf version / enum を fail closed に decode し、Delivery / Runtime projection のみを公開する | +| Unity Runtime | catalog と projected Run を検証し local interpolation する。Timeline completion や corrective keyframe を authority としない | + +## Consequences + +- Timeline の帯域は Run lifecycle と Snapshot に収束し、表示の連続性は local interpolation が担う。 +- Runtime / Delivery / client の clock と completion source が分離される。 +- protobuf、cross-language fixture、capability negotiation、recovery implementation は M5 以降で追加する。本 ADR 自体は proto / code を変更しない。 + +## Alternatives Considered + +### Timeline frame を State Stream で配送する + +帯域と replay を増やし、keyframe を authority と誤認する境界を作るため採用しない。 + +### audience ごとの Timeline auto split + +同じ PublishedPresentation の意味と ID を配信時に変えるため v1 では採用しない。 + +## Follow-ups + +- M2 item 2 の field number、Reliable Event envelope、retention / replay、runtime microstep 上限は [ADR-0008](./0008-runtime-transport-contract.md) で Accepted とした。 +- canonical event-kind、stable target ID、Run ID 順の同一 logical time ordering と、`presentationEnded` が Timeline cancel を先に確定する順序は Presentation Architecture の既存規則を適用する。 diff --git a/docs/decisions/0008-runtime-transport-contract.md b/docs/decisions/0008-runtime-transport-contract.md new file mode 100644 index 0000000..3e9a119 --- /dev/null +++ b/docs/decisions/0008-runtime-transport-contract.md @@ -0,0 +1,331 @@ +# ADR-0008: Runtime transport、replay、Snapshot の v1 contract を定義する + +- **Status**: Accepted +- **Date**: 2026-08-29 +- **Deciders**: Unframe 開発チーム +- **関連**: [Presentation Architecture](../presentation/ARCHITECTURE.md), [Realtime Architecture](../../app/server/realtime/ARCHITECTURE.md), [Contracts Architecture](../../packages/contracts/ARCHITECTURE.md), [ADR-0007](./0007-timeline-runtime-run-wire-contract.md) + +## Context + +Reliable Event、Snapshot、State Stream の意味と atomic cut は Architecture に定義済みだが、exact envelope、field number、retention、catch-up failure、runtime microstep 上限が未確定である。このまま consumer を実装すると、Go、C#、Unity が異なる fence、gap、backpressure 規則を持ち得る。 + +本 ADR は M2 item 2 として transport contract を固定する。M2 では設計を Accepted にするだけとし、Protocol Buffers source、Go / C# generated artifact、cross-language fixture は M3 の semantic payload 確定後に M5 で一括実装する。現行 `realtime.proto` と単一 `Connect` RPC は foundation のままであり、ここに定義する二接続 protocol が実装済みであるとはみなさない。 + +## Decision + +### Transport と共通 fence + +v1 は gRPC 上に独立した Control Connection と State Connection を持つ。Control は reliable / ordered / replayable、State は latest-wins / non-replayable とする。両 connection は `connection_id` と一回使用の `state_connection_nonce` で同じ logical participant connection に束ねる。 + +```proto +message ControlClientItem { + oneof item { + ControlHandshake handshake = 1; + ReplayRequest replay_request = 2; + StateReady state_ready = 3; + // 20..99: idempotent presenter command + } + reserved 4 to 19; +} + +enum RuntimeCapability { + RUNTIME_CAPABILITY_UNSPECIFIED = 0; + RUNTIME_CAPABILITY_RUNTIME_TRANSPORT_V1 = 1; + RUNTIME_CAPABILITY_TIMELINE_RUN_V1 = 2; +} + +message ControlHandshake { + string protocol_version = 1; + uint32 progression_contract_version = 2; + repeated RuntimeCapability supported_capabilities = 3; +} + +message ControlConnected { + string protocol_version = 1; + uint32 progression_contract_version = 2; + repeated RuntimeCapability required_capabilities = 3; + string connection_id = 4; +} + +message StateReady { + uint64 applied_reliable_sequence = 1; + uint64 presentation_origin_version = 2; +} + +message StateConnectionNonce { + bytes nonce = 1; + uint32 expires_in_ms = 2; +} + +message StateClientItem { + oneof item { + StateHandshake handshake = 1; + // 20..99: Presenter tracking frame + } + reserved 2 to 19; +} + +message StateHandshake { + string protocol_version = 1; + uint32 progression_contract_version = 2; + repeated RuntimeCapability supported_capabilities = 3; + string connection_id = 4; + bytes state_connection_nonce = 5; +} +``` + +認証済み context が session、participant、role、Runtime assignment を所有し、client payload から identity を採用しない。`protocol_version` は `v1`、`progression_contract_version` は `1`、required capability は `RUNTIME_TRANSPORT_V1` と `TIMELINE_RUN_V1` とする。不一致またはrequired capability不足は最初のSnapshotを作る前に `FAILED_PRECONDITION` とする。clientが追加で送るunknown supported capabilityはauthorityやprojectionを拡張せず無視できるが、serverが返すunknown required capabilityはclientがfail closedにする。State handshakeはControlで選択済みのversion / capabilityと完全一致し、nonceは32 bytes、一回使用、30,000 ms以内でなければならない。M3 で追加する command / tracking payload はreserved rangeと衝突させない。 + +すべての projected event、Snapshot、State frame は次の fence を持つ。 + +```proto +message PublicationFence { + string presentation_id = 1; + uint64 publication_epoch = 2; + string publication_manifest_hash = 3; +} + +message RuntimeProjectionFence { + string session_id = 1; + PublicationFence publication = 2; + uint64 assignment_epoch = 3; + string projection_profile_id = 4; + uint64 presentation_origin_version = 5; +} +``` + +required message presence、non-empty ID / hash、zero ではない publication / assignment epoch を検証する。`presentation_origin_version = 0` は初期versionとして有効である。不一致、空 message、unknown / `UNSPECIFIED` enum は適用せず Connection Resume を要求する。 + +### Control item と Reliable Event + +server-to-client の ordered Control item は次に固定する。 + +```proto +message ControlServerItem { + oneof item { + ControlConnected connected = 1; + ConnectionSnapshotEnvelope connection_snapshot = 2; + ProjectedReliableEvent reliable_event = 3; + ProjectionAdvance projection_advance = 4; + CommandOutcome command_outcome = 5; + StateConnectionNonce state_connection_nonce = 6; + ResyncRequired resync_required = 7; + } +} + +message ProjectedReliableEvent { + uint64 sequence = 1; + string event_id = 2; + optional string cause_event_id = 3; + uint64 occurred_at_runtime_time_ms = 4; + RuntimeProjectionFence fence = 5; + oneof payload { + RuntimeStatusChanged runtime_status_changed = 20; + // 21..99: Runtime lifecycle / fault + // 100..199: Progression / Cue + // 200..299: Surface / Node / Variable + TimelineStarted timeline_started = 300; + TimelineCompleted timeline_completed = 301; + TimelineCanceled timeline_canceled = 302; + // 303..399: Runtime Run / Media + // 400..499: Presence / session lifecycle + } + reserved 6 to 19; + reserved 500 to 999; +} + +message ProjectionAdvance { + RuntimeProjectionFence fence = 1; + uint64 from_exclusive = 2; + uint64 through_sequence = 3; +} +``` + +`sequence` は canonical session-global sequence であり、projected item ごとに採番し直さない。`ProjectionAdvance` は直前に client が適用済みの sequence と `from_exclusive` が一致し、`through_sequence > from_exclusive` の場合だけ適用する。marker は不可視 event の payload、件数以外の属性、個別時刻を持たない。不可視範囲だけでは送信せず、次の可視 event の直前に最大一件へ集約する。 + +payload に `google.protobuf.Any`、JSON、opaque bytes を使用しない。M3 で確定する各 semantic payload は上記 range に named message として追加する。ADR-0007 の Runtime status / Timeline lifecycle は対応する range に割り当て、required payload variant を解釈できない consumer は fail closed とする。 + +ADR-0007 で semantic shape を決めた message の field number は次に固定する。 + +```proto +message RuntimeRunId { uint64 assignment_epoch = 1; uint64 run_sequence = 2; } +message RuntimeRunOwner { oneof scope { PresentationRunOwner presentation = 1; GroupRunOwner group = 2; } } +message PresentationRunOwner {} +message GroupRunOwner { string group_id = 1; uint64 group_entry_epoch = 2; } +message RuntimeRunCause { string cue_id = 1; string cause_event_id = 2; string group_id = 3; uint64 group_entry_epoch = 4; string step_id = 5; uint64 step_entry_epoch = 6; } +enum RunCompletion { RUN_COMPLETION_UNSPECIFIED = 0; RUN_COMPLETION_BLOCKING = 1; RUN_COMPLETION_NON_BLOCKING = 2; } +enum TimelineCancelReason { TIMELINE_CANCEL_REASON_UNSPECIFIED = 0; TIMELINE_CANCEL_REASON_EXPLICIT_STOP = 1; TIMELINE_CANCEL_REASON_GROUP_EXIT = 2; TIMELINE_CANCEL_REASON_PRESENTATION_ENDED = 3; } +message TimelineStarted { RuntimeRunId run_id = 1; string timeline_id = 2; RuntimeRunOwner owner = 3; RuntimeRunCause cause = 4; RunCompletion completion = 5; uint64 started_at_runtime_time_ms = 6; } +message TimelineCompleted { RuntimeRunId run_id = 1; string timeline_id = 2; } +message TimelineCanceled { RuntimeRunId run_id = 1; string timeline_id = 2; TimelineCancelReason reason = 3; } +message TimelineRunSnapshot { RuntimeRunId run_id = 1; string timeline_id = 2; RuntimeRunOwner owner = 3; RuntimeRunCause cause = 4; RunCompletion completion = 5; uint64 started_at_runtime_time_ms = 6; } +message RuntimeStatusChanged { oneof status { Running running = 1; Paused paused = 2; Terminating terminating = 3; } } +message Running {} +message Paused { PauseReason reason = 1; } +message Terminating { TerminationReason reason = 1; } +enum PauseReason { PAUSE_REASON_UNSPECIFIED = 0; PAUSE_REASON_PRESENTER_DISCONNECTED = 1; PAUSE_REASON_ASSIGNMENT_LEASE_EXPIRED = 2; PAUSE_REASON_PROCESS_RECOVERED = 3; PAUSE_REASON_INVARIANT_VIOLATION = 4; PAUSE_REASON_ATOMIC_COMMIT_FAILED = 5; PAUSE_REASON_MICROSTEP_LIMIT_EXCEEDED = 6; PAUSE_REASON_RECOVERY_GAP = 7; } +enum TerminationReason { TERMINATION_REASON_UNSPECIFIED = 0; TERMINATION_REASON_EXPLICIT_END = 1; TERMINATION_REASON_CONTROL_PLANE_ENDED = 2; TERMINATION_REASON_PAUSE_TIMEOUT = 3; } +``` + +### Replay と resync + +```proto +message ReplayRequest { + uint64 after_sequence = 1; +} + +message ResyncRequired { + ResyncReason reason = 1; + uint64 newest_reliable_sequence = 2; +} + +enum ResyncReason { + RESYNC_REASON_UNSPECIFIED = 0; + RESYNC_REASON_REPLAY_RANGE_UNAVAILABLE = 1; + RESYNC_REASON_PROJECTION_CHANGED = 2; + RESYNC_REASON_PUBLICATION_FENCE_CHANGED = 3; + RESYNC_REASON_PRESENTATION_ORIGIN_CHANGED = 4; + RESYNC_REASON_SNAPSHOT_CATCH_UP_EXHAUSTED = 5; +} +``` + +Replay は `after_sequence + 1` から contiguous に返す。範囲外、fence変更、payload compatibility failure では推測した sequence へ進めず `ResyncRequired` 後に live delivery を停止する。client は同じ connection で部分的な replay と新しい Snapshot を混在させず、新しい Connection Resume を開始する。`after_sequence = 0` と Snapshot の `reliable_sequence = 0` はevent未発生のcutとして有効だが、Reliable Event自身の `sequence = 0` はinvalidとする。 + +### Connection Snapshot と durable checkpoint + +```proto +message ConnectionSnapshotEnvelope { + uint32 schema_version = 1; + string connection_id = 2; + RuntimeProjectionFence fence = 3; + ProjectionInstance projection_instance = 4; + ProjectedPresenceState presence_at_cut = 5; + uint64 reliable_sequence = 6; + ProjectedRuntimeSnapshot snapshot = 7; +} + +message DurableCheckpointEnvelope { + uint32 schema_version = 1; + uint64 checkpoint_sequence = 2; + string session_id = 3; + string runtime_id = 4; + RuntimeKind runtime_kind = 5; + uint64 assignment_epoch = 6; + PublicationFence publication = 7; + string definition_hash = 8; + string render_bundle_hash = 9; + uint64 reliable_sequence = 10; + string canonical_snapshot_hash = 11; + bytes canonical_snapshot_payload = 12; +} +``` + +`schema_version` の現在値は `1` とする。Connection Snapshot は projected state と presence を持つ派生物で、durable restore に使用しない。Durable checkpoint のfield 12はM3で定義するnamed `CanonicalRuntimeSnapshot` messageのprotobuf wire bytesであり、任意application payloadではない。participant、connection、projection、presence、tracking sampleを含めない。`canonical_snapshot_hash` は受信したfield 12のbyte列そのものに対するSHA-256 lowercase hexとし、hashを検証してから同じschema versionの`CanonicalRuntimeSnapshot`としてparseし、semantic invariantを検証する。writerはunknown fieldを含めずdeterministic serializationを使用し、M5のbinary fixtureを正本にする。consumerは検証のためにparse / reserializeしたbytesをhash sourceにしない。 + +Connection envelopeのfield 6は`ProjectedRuntimeSnapshot.reliable_sequence`と、Durable envelopeのfield 10はparse後の`CanonicalRuntimeSnapshot.reliable_sequence`と必ず一致する。不一致はSnapshot全体をfail closedにし、外側または内側の値を選んで継続しない。外側sequenceはpayloadを適用する前のreplay cursor検査、内側sequenceはsnapshot invariantとportable fixtureに使用する。 + +Snapshot は `reliable_sequence = S` の immutable cut と `S + 1` subscriber 登録を同じ critical section で確定する。projection / serialization 中の event は bounded catch-up queue に保持する。queue overflow 時は生成物と subscriber を破棄して新しい cut から再試行し、部分 Snapshot を公開しない。 + +### State Stream + +```proto +enum StateFrameKind { + STATE_FRAME_KIND_UNSPECIFIED = 0; + STATE_FRAME_KIND_KEYFRAME = 1; + STATE_FRAME_KIND_DELTA = 2; +} + +message ElementStateFrame { + RuntimeProjectionFence fence = 1; + uint64 frame_sequence = 2; + uint64 produced_at_runtime_time_ms = 3; + uint64 oldest_change_at_runtime_monotonic_ms = 4; + uint64 base_reliable_sequence = 5; + StateFrameKind kind = 6; + repeated ElementStatePatch elements = 7; + repeated ProjectedAnchorBindingPatch anchor_bindings = 8; + uint64 produced_at_runtime_monotonic_ms = 9; +} + +message CoordinateVector3 { double x = 1; double y = 2; double z = 3; } +message CoordinateQuaternion { double x = 1; double y = 2; double z = 3; double w = 4; } +message AnchorBindingUnavailable {} +message ProjectedAnchorBindingSample { + uint64 tracking_frame_sequence = 1; + uint64 observed_at_runtime_monotonic_ms = 2; + optional CoordinateVector3 position = 3; + optional CoordinateQuaternion rotation = 4; +} +message ProjectedAnchorBindingPatch { + string node_id = 1; + oneof state { + AnchorBindingUnavailable unavailable = 2; + ProjectedAnchorBindingSample sample = 3; + } +} +``` + +`ElementStatePatch` の具体 field は M3 の Node / Media state contract と同時に確定する。patch は explicit presence を持つ scalar / message field を使用し、`active` と `visible` を別 field として保持する。`field_mask` と値の矛盾、duplicate element ID、profile 外 resource を拒否する。Timeline-owned property、crossfade weight、interaction enabled state は含めない。 + +`anchor_bindings`はraw Presenter poseのcatalogではなく、profile内のvisible Nodeが直接参照するAnchor parentをNode IDごとにprojectしたephemeral stateである。Nodeの`followPosition`がtrueの場合だけ`position`をrequired、falseなら禁止し、`followRotation`も同様に扱う。両方falseのAnchor parentはbuild errorとする。followしないposition成分はzero translation、followしないrotation成分はidentity Quaternionとしてparent matrixを構成し、その後にNode local matrixを乗じる。別Node、profile外Node、参照されないAnchor targetのposeを含めず、target名をState wireへ重複しない。`KEYFRAME`はvisibleな全Anchor-bound Nodeをexactly once、`DELTA`は変更sampleまたは`unavailable` tombstoneだけを含む。duplicate Node ID、空oneof、profile外Node、follow fieldの過不足、non-canonical Quaternionを拒否する。 + +`anchorSampleMaxAgeMilliseconds`は下表のAnchor binding sample ageであり、`500`に固定する。Runtimeはframe生成時、clientは同期済みRuntime monotonic timeでそれぞれageを検査する。超過、tracking unavailable、Origin version不一致では`unavailable`へ収束し、次のfresh sampleまで該当Nodeを描画もhit-testもしない。`observed_at_runtime_monotonic_ms`はRuntime受理時刻であり、client申告のcapture時刻をfreshness authorityにしない。 + +State frame は保存・replayしない。server はconnectionごとにsingle-slot mailboxを持ち、Element patchはelement / field単位、Anchor bindingはNode ID単位のsample / tombstone全体でlatest-wins mergeする。同じAnchor sampleのposition / rotationを別tracking frameから合成しない。dequeueは一つのcritical sectionでElement map、Anchor map、最古monotonic change、logical runtime time、Runtime monotonic time、reliable / Origin fenceをimmutable frameへfreezeし、その後に`frame_sequence`を採番する。keyframe生成も同じcutでcurrent Element / projected Anchor binding mapをfreezeする。送信中のframeは変更せず、後続変更は次のmailboxへmergeする。Runtimeが`Running`の場合だけ、初回と再接続時に`KEYFRAME`一件を適用してから`DELTA`を受理する。`Paused`中はState Connectionだけを確立してframeを送らず、`RuntimeStatusChanged(running)`の適用後に`KEYFRAME`一件を送る。gap、stale fence、古い `base_reliable_sequence`、buffer overflow では delta を補完せず State Connection を再確立する。Control Connection と他 participant は停止しない。 + +### RuntimeProtocolLimits v1 + +上限は protocol version `v1` の interoperability contract とし、server 設定で緩和・拡張しない。deployment固有のresource不足はSession assignment前に admission error とし、接続後により小さいlimitへ暗黙変更しない。 + +| Limit | v1 value | Rule | +| -------------------------------- | ------------------------------------------: | ------------------------------------------------------------------------------ | +| reliable event log | 4,096 events, 8 MiB, and 900,000 ms | canonical eventのcount、wire bytes、ageの先に達した境界より古いeventをeviction | +| one replay response | 1,024 Control items and 1 MiB serialized | 超える場合は `RESYNC_REASON_REPLAY_RANGE_UNAVAILABLE` で Snapshotへ切替 | +| per-connection catch-up queue | 1,024 Control items and 1 MiB serialized | どちらか超過で current Snapshot attempt を破棄 | +| Connection Snapshot attempts | 3 attempts including initial | attemptごとに新しい canonical cut を取得 | +| Connection Snapshot total budget | 250 ms monotonic elapsed | 超過で `RESYNC_REASON_SNAPSHOT_CATCH_UP_EXHAUSTED` | +| idempotency outcome window | 1,024 IDs and 900,000 ms | session / participant ごと。どちらかの境界で eviction | +| State dependency buffer | 4,096 element-field values and 500 ms | 超過で State Connection を再確立 | +| Anchor binding sample age | 500 ms | Runtime / clientで失効し`unavailable`へ収束 | +| runtime microsteps | 1,024 per external input or due-event drain | 次の microstep を適用せず Runtime を pause | + +MiB は `1,048,576` bytes、serialized size は protobuf message の wire sizeで計測する。event log の age は event の logical runtime time ではなく server monotonic retention age であり、Runtime Pause 中も eviction を継続する。durable checkpoint の保存期間・頻度は deployment policyであり、この replay retention から導出しない。 + +microstep は canonical mutation の結果として同じ drain 内に生成され、次の Cue / zero-duration completion / progression transition を評価する一回を指す。外部 input または logical deadline batch ごとに `0` から開始し、goroutine yield、Snapshot、network write で reset しない。1,024回目までに確定した atomic mutation は保持し、1,025回目は評価も適用もせず、`paused / microstepLimitExceeded` と fault event を一つの atomic mutationとして確定する。 + +### Compatibility と consumer responsibility + +- additive unknown optional protobuf field は保持または無視できる。 +- unknown enum、unknown required oneof variant、schema major、required capability は fail closed とする。 +- field number と意味を再利用しない。削除した field / enum number は `reserved` にする。 +- Control item、Snapshot、State frame は同じ `progressionContractVersion = 1` と required capability set に拘束する。streamごとの downgradeを許可しない。 +- Go Realtime は limits、atomic cut、projection、replay、mailbox、microstep fault の authorityを持つ。 +- TypeScript contracts / Compiler は Publication / Projection closure と required capabilities を検証する。 +- generated C# client は envelope / enum / gap / fence を検証し、Unity adapter は Snapshot → replay → keyframe の適用順を守る。 + +## Consequences + +- reliable / projected / latest-wins state の順序と failure が consumer 間で一致する。 +- Snapshot serialization は型付き protobuf に固定され、opaqueな application snapshotを transport contract として固定しない。 +- retention と microstep の bounded behavior を fixture と fault testで検証できる。 +- M5 では foundation protocol を target protocolへ置換する breaking changeになる。未完成の旧 `v1` を互換維持する bridge は追加しない。 + +## Alternatives Considered + +### event を participant ごとに連番化する + +projection後の番号では canonical cut と replay位置を共有できず、不可視 event の存在を扱う別の正本が必要になるため採用しない。 + +### Snapshot payload を任意 JSON / untyped bytes にする + +cross-language drift、required field、unknown variantをgeneratorで検出できないため採用しない。Durable envelopeのfield 12はnamed protobuf messageのwire bytesに限定し、exact hash sourceを保持するための型付きencodingである。 + +### unbounded replay または State queue + +slow clientがsession memoryとlatencyを支配するため採用しない。 + +## Follow-ups + +- M3 で semantic payload、`ProjectedRuntimeSnapshot`、`CanonicalRuntimeSnapshot`、`ElementStatePatch` を各縦断sliceと同時に確定する。 +- M5 で `.proto`、Go / C# generation、breaking-change report、cross-language binary fixtureを実装する。 +- `stateWriteBlockTimeout`、server-to-client message size、Tracking rateは実機計測を伴うRealtime implementationで別途固定する。 diff --git a/docs/decisions/0009-semantic-tree-hit-region-contract.md b/docs/decisions/0009-semantic-tree-hit-region-contract.md new file mode 100644 index 0000000..846e2e2 --- /dev/null +++ b/docs/decisions/0009-semantic-tree-hit-region-contract.md @@ -0,0 +1,191 @@ +# ADR-0009: Semantic role と Hit Region の portable schema を定義する + +- **Status**: Accepted +- **Date**: 2026-08-29 +- **Deciders**: Unframe 開発チーム +- **関連**: [Presentation Architecture](../presentation/ARCHITECTURE.md), [Presentation Design](../presentation/DESIGN.md), [Contracts Architecture](../../packages/contracts/ARCHITECTURE.md), [WAI-ARIA 1.2](https://www.w3.org/TR/wai-aria-1.2/) + +## Context + +現行 `SemanticNode` は `role` とすべての optional field を一つのflat objectに持つため、heading level、button label / interaction、image alternative、list / tableのrequired owned structureを型で区別できない。`RenderBundle.semanticsByState` もDefinitionと同じschemaを再利用し、Stateごとのinteraction enabled状態を表現しない。 + +現行Hit Regionはnormalized rectangle、Interaction、Semantic Node、event、priorityを持つが、stable region ID、canonical order、overlap tie-breakがない。`event` はInteraction definitionと重複し、二つの正本を作る。これらを確定しないままM3 Slice Bへ進むと、TypeScript、renderer、Delivery、Unityでaccessibility treeとhit-test結果が分岐する。 + +本ADRはM2 item 3としてportable semantic contractを固定する。M2では設計だけをAcceptedにし、Zod / JSON Schema、Core materialization、Compiler lowering、renderer output、fixtureはM3 Slice Bで同じ変更系列として実装する。現行schemaと実装はinitial flat subsetのままであり、本ADRのtarget contractが実装済みであるとはみなさない。 + +## Decision + +### Definition tree と Completed tree + +Authoring / PresentationDefinitionが持つ状態非依存の`SemanticTreeDefinition`と、CompilerがSurface Stateごとにmaterializeする`CompletedSemanticTree`を別schemaにする。 + +```ts +type SemanticNodeBase = { + id: SemanticNodeId; + parentId: SemanticNodeId | null; + order: number; +}; + +type SemanticTextFields = { + text: string; + language?: string; +}; + +type SemanticNodeDefinition = + | (SemanticNodeBase & SemanticTextFields & { role: "heading"; level: 1 | 2 | 3 | 4 | 5 | 6 }) + | (SemanticNodeBase & SemanticTextFields & { role: "paragraph" }) + | (SemanticNodeBase & { role: "image"; alt: string; language?: string }) + | (SemanticNodeBase & SemanticTextFields & { role: "button"; interactionId: InteractionId }) + | (SemanticNodeBase & { role: "list"; ordered: boolean }) + | (SemanticNodeBase & SemanticTextFields & { role: "listItem" }) + | (SemanticNodeBase & { role: "table"; label?: string; language?: string }) + | (SemanticNodeBase & { role: "row" }) + | (SemanticNodeBase & SemanticTextFields & { role: "cell" }) + | (SemanticNodeBase & SemanticTextFields & { role: "columnHeader" }) + | (SemanticNodeBase & SemanticTextFields & { role: "rowHeader" }); + +type SemanticTreeDefinition = { + rootNodeIds: SemanticNodeId[]; + nodes: Record; +}; + +type CompletedSemanticNode = + | Exclude + | (Extract & { stateEnabled: boolean }); + +type CompletedSemanticTree = { + rootNodeIds: SemanticNodeId[]; + nodes: Record; +}; + +type ProjectedSemanticNode = + | Exclude + | (Omit, "stateEnabled" | "interactionId"> & + ({ enabled: true; interactionId: InteractionId } | { enabled: false })); + +type ProjectedSemanticTree = { + rootNodeIds: SemanticNodeId[]; + nodes: Record; +}; +``` + +各variantは上記fieldだけを持つstrict objectとする。`order` はnon-negative integerで、同じparentのsibling間で一意とする。Node record keyと`node.id`は一致し、ID、text、alt、label、languageはUnicode scalar valueだけを含みlone surrogateを拒否する。 + +`language` はwell-formed BCP 47 language tagとし、空文字列を拒否する。tagのcasingは意味比較に使用せず、source spellingをportable artifactへ保持する。省略時は最も近いancestorのlanguage、なければbuild context localeを継承する。 + +heading、paragraph、button、listItem、cell、columnHeader、rowHeaderのaccessible textはnon-empty `text`を必須とする。Native UIのdynamic textを同じNodeへbindingする場合、この`text`はempty / unavailable時のcanonical accessibility fallbackであり、通常時のeffective semantic textはvisual textと同じformatted valueに置き換える。独立したsemantic binding fieldは作らず、delivery profileが選択した`NativeUIArtifact`内でそのNodeを参照する唯一の`NativeUINode` textをbindingの正本とする。CompilerとControl Planeはartifact内およびprofile横断で参照が一意であることを検証する。dynamic formatterの結果がemptyならclientはvisual textをemptyのまま扱い、effective semantic textだけをDefinitionのnon-empty fallbackへ戻す。 + +imageはnon-empty `alt`を必須とする。装飾専用imageはSemantic Treeへ含めない。buttonはnon-empty `text`と`interactionId`を必須とし、他roleは`interactionId`を持てない。tableの`label`はoptionalだが、存在する場合はnon-emptyとする。 + +### Tree structure + +v1のrequired parent / child relationを次に固定する。 + +| role | allowed parent | allowed direct children | +| ------------------------------------ | -------------- | ------------------------------------------------- | +| heading / paragraph / image / button | root only | none | +| list | root only | one or more `listItem` | +| listItem | `list` | none | +| table | root only | one or more `row` | +| row | `table` | one or more `cell` / `columnHeader` / `rowHeader` | +| cell / columnHeader / rowHeader | `row` | none | + +空Treeは許可する。空でないTreeは全Nodeが一つのrootから到達でき、parent cycleを持たず、`parentId === null`のNodeだけを`rootNodeIds`へ一度ずつ含める。rootとsiblingのcanonical orderは`order`昇順とし、同値はinvalidである。nested list、row group、rich cell content、interactive table / gridはv1に含めず、必要になった時点でroleとschema versionを追加する。 + +WAI-ARIAのrequired owned / context relationをportable subsetの根拠にするが、Semantic TreeをDOMまたはARIA attribute bagとして扱わない。rendererはDOMからroleやrelationを推測せず、このcontractからplatform accessibility representationを生成する。 + +### State materialization + +`SurfaceSemanticOverride`は既存のordered layer規則を維持する。`included`は全role、`text` / `language`はSemanticTextFieldsを持つrole、`alt` / `language`はimage、`label` / `language`はtableだけに指定できる。role、level、ordered、parent、order、interactionIdはoverrideできない。 + +override objectはtarget Nodeのroleを自身には複製しないため、Zodはfield集合だけをstrictに検証し、Presentation Coreがtarget Nodeをlookupしてroleに禁止されたpropertyをstable diagnosticで拒否する。M3ではroleごとのvalid / invalid override fixtureでこの境界を固定する。 + +`included: false`はdescendantを含めて除外する。除外後もrequired structureを満たさなければならず、list / table / rowを空にするStateはbuild errorとする。requiredなtext / altの`null`削除は禁止する。optionalなtable labelは`null`で削除できる。 + +CompilerはStateごとにoverrideを適用し、除外Nodeを除去してroot orderを再構築した後、buttonの`stateEnabled`を`SurfaceStateDefinition.enabledInteractionIds`から導出する。authorは`stateEnabled`を直接指定しない。Completed treeだけを`RenderBundle.semanticsByState`へ格納し、override layerをDelivery / Runtimeへ送らない。 + +各enabled Interactionは同じStateのCompleted treeに`stateEnabled: true`のbutton Nodeを一つ以上持つ。同じInteractionを複数buttonが参照できる。disabled Interactionを参照するbuttonは`stateEnabled: false`として残せるがHit Regionを持たない。Interactionを参照するbuttonがすべてStateから除外された場合、そのInteractionをenabledにできない。 + +### Hit Region schema + +```ts +type ResolvedInteractiveRegion = { + interactionId: InteractionId; + semanticNodeId: SemanticNodeId; + bounds: { + x: number; + y: number; + width: number; + height: number; + }; + coordinateSpace: "normalized"; + priority: UInt32; +}; +``` + +`UInt32`はwire上の`0..4_294_967_295`のintegerを表すportable scalarである。Hit Regionはruntime identityを持たず、array positionもcommandやSnapshotから参照しない。現行portable contractが任意number、renderer conformanceがnon-negative integerを要求するdriftはM3でcontract側を`UInt32`へ厳格化して解消する。`event`は持たず、Runtime Coreが`interactionId`からPresentationDefinitionのcanonical eventを解決する。異なるInteractionが同じeventを共有することは許可するが、authorityとhit-test結果は常にInteraction IDで扱う。regionを持つ全Interactionのeventは`SurfaceRenderIntent.interaction.events`に含まれなければならず、rendererはintentにないeventを追加できない。 + +canonical Interaction definitionはrequired `hitPriority: UInt32`を持ち、Authoring `InteractionDeclaration`も同じ値を明示する。Compilerはこの値をprivate / portable regionへcopyし、renderer、Delivery、clientは変更しない。M3では現行Interaction declaration / schemaをbreakingに拡張し、暗黙defaultやsemantic orderからの推測を追加しない。 + +`x` / `y` / `width` / `height` は有限値で、`0 <= x < 1`、`0 <= y < 1`、`0 < width <= 1 - x`、`0 < height <= 1 - y`を満たす。target pipelineではartifact producerがADR-0011のpartition-local private regionを一度だけclipし、Compiler aggregateがSemantic Surface全体のnormalized logical coordinateへ変換する。aggregate後に面積がないregionを出力しない。portable boundsはRender Surface、texture、pixel、UVの座標を持たない。logical / UV / Unity変換とclip authorityはADR-0010、private regionのexact shapeとaggregateはADR-0011を正本とする。 + +一つのInteraction / button Nodeが複数regionを持つこととregion同士のoverlapを許可する。`interactionId + semanticNodeId + x + y + width + height`が同じregionはpriorityにかかわらずduplicateとしてinvalidとする。artifact producerはpartition-local private region内のduplicateを拒否し、Presentation CoreはCompiler aggregate後にRenderBundle全体のcross-partition invariantとして再検証する。canonical array orderとhit-test winnerは`priority`降順、次に`interactionId`、`semanticNodeId`のRFC 8785と同じUTF-16 code-unit昇順、最後に`x`、`y`、`width`、`height`の数値昇順とする。local input pointは`0 <= x < 1`、`0 <= y < 1`とし、rectangleはleft / top inclusive、right / bottom exclusiveで判定する。候補の先頭regionの`interactionId`を選ぶ。 + +各regionは同じStateのCompleted treeにある`stateEnabled: true`のbutton Nodeを参照し、そのbuttonの`interactionId`とregionの`interactionId`が一致しなければならない。enabled Interactionは一つ以上のregionを持ち、disabled / unknown Interaction、excluded / non-button Nodeはregionを持てない。State recordは到達可能な全Stateをexactly onceで含む。 + +Hit Regionはclient-side affordanceでありauthorization boundaryではない。Surface interaction commandはpoint、region ID、Semantic Node ID、event、renderer artifactを送らず、Runtime Coreが認証済みrole、current State、enabled Interaction、transition、Publication / Origin fenceを再検証する。priorityやregion overlapによってRuntime authorityを変えない。 + +### Projection と compatibility + +Semantic Surfaceはhost Spatial Nodeの`ProjectionAudience`を全体として継承し、一つのSemantic Tree内にpresenter / viewer audienceを混在させない。Deliveryはprofileから不可視なSurface、Completed tree、Hit Regionをまとめて除外する。 + +`ProjectionProfileDescriptor.semanticSurfaces`をProjected tree / Hit Regionのdelivery carrierとする。keyはvisible Semantic Surface ID、値は全reachable Stateをexactly onceで持つ`semanticsByState: Record`と`interactionsByState: Record`である。`visibleSurfaceIds`とrecord keyは同じ集合でなければならない。RenderBundleはrole非依存のCompleted treeを保持し、Control Plane / Deliveryがprofile生成時にこのcarrierへprojectする。Delivery contract validationはviewer profileのbuttonにInteraction IDがなく、Hit Regionが空であることを検証する。 + +可視SurfaceではDeliveryがCompleted treeをSession roleごとの`ProjectedSemanticTree`へ変換する。button以外のrole / field / structureは変更しない。v1でShared progressionを変更できるparticipant inputはPresenterだけなので、Presenter projectionの`enabled`は`stateEnabled`と同じ値、Viewer projectionの`enabled`は常に`false`とする。Hit Regionはprojected buttonが`enabled: true`のものだけを送る。Viewerにはbuttonのlabelとroleをdisabled affordanceとして残すが、Interaction IDとHit Regionを配信しない。Projected buttonは`enabled: false`のとき`interactionId`を持たないstrict variantとし、Unity側で後からfieldを隠す処理をauthorization boundaryにしない。 + +Definition schemaとCompleted schemaを混同せず、unknown role、unknown required field、roleに禁止されたfield、unsupported schema versionはfail closedとする。同じversionで許可する追加は全consumerが安全に無視できるoptional metadataだけとし、role、required field、parent / child relation、hit-test規則の変更はbreaking changeとする。 + +現行`schemaVersion: 1`は未公開のinitial subsetであるため、M3ではflat schemaを新しいv1 shapeへ一括置換し、legacy unionやfallbackを追加しない。現行Renderer APIがnormalized `HitRegion`を直接返す一partition contractも同時にprivate region / Compiler aggregateへ置換する。fixture、generated JSON Schema、Core、Compiler、renderer、reference projectを同じcommit系列で更新する。 + +## Consumer responsibility + +| Consumer | Responsibility | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------- | +| Contracts | Definition / Completed role union、override、Hit Regionのportable Zod sourceとgenerated JSON Schema | +| Presentation Core | tree / role relation、State materialization、accessible value、button enabled、cross-artifact invariant | +| Compiler | authoring role lowering、stable ID、dynamic binding、State completeness、canonical order | +| Renderer API / Web | 完成Treeを変更せず、visible geometryをpartition-local private regionへ解決し、local ID / boundsを検証 | +| Control Plane / Delivery | audience / Session role / capability closureを検証し、Projected treeを生成してprofile外Tree / interaction / regionを配信前に除外 | +| Unity / Web preview | 同じCompleted treeからplatform semanticsを生成し、同じordered region hit-test fixtureを適用 | + +## Consequences + +- invalidなrole / property組合せと不完全なlist / tableをschema / semantic validationで拒否できる。 +- Stateごとのbutton `stateEnabled`、roleごとのprojected `enabled`、Hit Region availabilityが一方向に導出される。 +- Hit Regionからevent重複とarray-position identityを除き、renderer / Unityのoverlap結果を一致させられる。 +- current flat contractに対するbreaking implementationはM3 Slice Bで行い、本ADR自体はcode / generated artifactを変更しない。 + +## Alternatives Considered + +### flat nodeにoptional fieldを追加し続ける + +roleに不可能なproperty組合せを構造schemaで拒否できず、consumerごとの推測を残すため採用しない。 + +### renderer DOMからARIA treeを抽出する + +Opaque implementationとbrowser差がsemantic source of truthになり、Structured / Native UI / baked-webの意味が分岐するため採用しない。 + +### Hit Regionにeventを複製する + +Interaction definitionとの不一致時にauthorityを選ぶ必要が生じるため採用しない。 + +### overlapをbuild errorにする + +複雑なvisual affordanceを不必要に制限する。deterministic priority / ID順で解決できるため採用しない。 + +## Follow-ups + +- M3 Slice Bでcontract、Core、Compiler、renderer、fixture、reference projectをTDDで実装する。 +- M2 item 4でSemantic Surface logical coordinate、normalized point、UV、Unity local coordinateの完全な変換規則を固定する。 +- M5でDeliveryManifest / C# generated artifactへCompleted treeとHit Regionを接続する。 +- rich text、nested list、rowgroup、interactive grid、toggle button、viewer-local interactionは新しい利用要件とversioned contractを伴うfollow-upとする。 diff --git a/docs/decisions/0010-spatial-surface-coordinate-contract.md b/docs/decisions/0010-spatial-surface-coordinate-contract.md new file mode 100644 index 0000000..96a8966 --- /dev/null +++ b/docs/decisions/0010-spatial-surface-coordinate-contract.md @@ -0,0 +1,191 @@ +# ADR-0010: Spatial / Surface / Unity 座標変換を固定する + +- **Status**: Accepted +- **Date**: 2026-08-29 +- **Deciders**: Unframe 開発チーム +- **関連**: [ADR-0005](0005-spatial-presentation-domain-model.md), [ADR-0006](0006-presentation-rendering-strategy.md), [Presentation Architecture](../presentation/ARCHITECTURE.md), [Unity coordinate system](https://docs.unity3d.com/Manual/QuaternionAndEulerRotationsInUnity.html), [Unity Matrix4x4](https://docs.unity3d.com/ScriptReference/Matrix4x4.html), [Unity Mesh UV](https://docs.unity3d.com/ScriptReference/Mesh-uv.html) + +## Context + +ADR-0005はmeter、right-handed、Y-up、forward -Z、`[x, y, z, w]` Quaternionまでを固定したが、親子Transformの合成順、Quaternion符号、matrix layout、Unityのleft-handed座標への変換は未定義だった。Semantic Surfaceは左上原点のlogical coordinateと`contain / cover / stretch`を持つ一方、physical plane、normalized Hit Region、Render Surface、raster pixel、Unity UVの相互変換も未定義だった。 + +このままでは同じPresentationDefinitionから生成したWeb preview、RenderBundle、Unity sceneで位置、回転、crop、hit-testが一致しない。本ADRはM2 item 4としてportable coordinate contractを固定する。M2では文書だけをAcceptedにし、Contract / Coreのfixture、Compiler変換、renderer geometry、Unity importerは対応するM3〜M5 sliceで実装する。現行schemaとUnity sample importerが本ADRを満たすとはみなさない。 + +## Decision + +### Canonical Spatial coordinate + +Canonical Spatial coordinateは次を満たす。 + +- right-handed、+X right、+Y up、forward -Z +- positionとphysical sizeはmeter、scaleはdimensionless +- vectorはcolumn vectorとして左からmatrixを作用させる +- QuaternionはHamilton product、成分順`[x, y, z, w]`、active rotationとし、vectorは`q * [v, 0] * inverse(q)`で回転する +- Euler angleはportable contract、artifact、fixtureに使用しない + +Quaternionは有限かつ非ゼロでなければならない。Compilerの明示的なcanonicalizerはauthoring inputをunit lengthへ正規化し、同じrotationを表す`q`と`-q`から一つを選ぶ。`w > 0`を優先し、`w === 0`では`x`、`y`、`z`の順で最初のnon-zero成分が正になるよう必要なら全成分を反転する。canonical outputでは`-0`を`0`にする。Contractsのruntime schemaはtuple shapeとfinite numberだけを検証し、Presentation Coreと各wire mapperが`abs(norm - 1) <= 1e-9`とcanonical signをsemantic validationする。Compilerより外側のconsumerは不一致を拒否し、黙ってnormalize / sign flipしない。Timeline interpolationはADR-0007のshortest-path規則で補間した後、Runtime Coreの明示的なcanonicalizerで同じ処理を行ってからportable projectionを生成する。現行Control Plane / Webの`1e-4`とCoreのnorm-only検証はtarget contractとのdriftであり、consumer接続sliceで`1e-9`とsign検証へ統一する。 + +### TRS、親子合成、matrix layout + +local Transform `L = { position: p, rotation: q, scale: s }`のderived matrixを次に固定する。 + +```text +M_local = T(p) * R(q) * S(s) +M_world = M_parent_world * M_local +v_world = M_world * [v_local.x, v_local.y, v_local.z, 1]^T +node_origin_world = M_world * [0, 0, 0, 1]^T +q_world = q_parent * q_local +``` + +したがってlocal pointにはscale、rotation、translationの順で作用し、child local Transformを先に、parent Transformを後に作用させる。`q_world`はlocal orientationだけを合成する補助値であり、non-uniform scaleとrotationによるshearを含むworld matrixから抽出したrotationではない。scaleは各軸とも有限かつ正で、authoringによるreflectionとzero scaleを許可しない。non-uniform scaleを持つancestorとrotationの組合せはworld matrix上でshearを生み得るため、world Transformをposition / rotation / scaleへ再分解して正本にしない。Spatial treeの正本はlocal TRS、derived world値の正本は常にfull matrixとする。 + +v1のPresentationDefinition、RenderBundle、Realtime wireはderived world matrixをfieldとして保存せず、local TRSだけを保持する。matrixはCompiler / Runtime / Unity adapterのderived internal valueとcross-language fixtureに限定する。fixtureのportable matrixはbinary64の16要素column-major flat arrayとし、`index = row + column * 4`、translationは最後のcolumnに置く。matrix積の意味は上記のcolumn-vector式に従う。行優先APIのconsumerは境界で転置またはindex変換し、fixture順を変更しない。将来matrixをwire fieldへ追加する場合は新しいversioned contractを必要とする。 + +### Unity conversion + +Unityはleft-handed、+X right、+Y up、+Z forwardであるため、canonicalとUnityの境界はZ軸reflection `C = diag(1, 1, -1, 1)`を一度だけ適用する。 + +```text +positionUnity = [ x, y, -z ] +rotationUnity = [-x, -y, z, w] +scaleUnity = [ sx, sy, sz ] +matrixUnity = C * matrixCanonical * C +``` + +この変換は自己逆であり、UnityからCanonicalへ取り込むAnchor pose / Tracking poseにも同じ式を使う。Unity importerは変換済みlocal position、local rotation、local scaleを変換済みparentの`Transform`へ設定し、world TRSの再分解、Euler変換、追加のZ反転を行わない。`Quaternion(x, y, z, w)`の成分順はportable contractと同じだが、handedness変換なしに値を直接代入してはならない。 + +Canonical matrixのcolumn-major wire順はUnity `Matrix4x4`のindex規則と同じである。ただしhandednessが異なるため、raw 16値を直接コピーせず必ず`C * M * C`を適用する。doubleからUnity `float`へ変換するとき、有限なfloat範囲外または変換後にnon-finiteとなる値はcapability / import errorとし、clampしない。 + +### Semantic Surface logical / physical plane + +Semantic Surfaceのlogical sizeを`W, H`、physical sizeを`Pw, Ph`とする。すべて有限かつ正とする。logical coordinateは左上原点、+X right、+Y downで、content domainは`0 <= x < W`、`0 <= y < H`である。normalized logical coordinateは次とする。 + +```text +nx = x / W +ny = y / H +``` + +Surface local planeは中心原点、+X right、+Y up、Z=0、front normal +Zとする。Canonical forward -Z方向に置いたSurfaceを原点側から見たときfrontが見える。Unity変換後のfront normalは-Zとなり、UnityのXY quad規則と一致する。 + +fitごとのlogical-to-meter scaleを次に固定する。 + +```text +stretch: sx = Pw / W, sy = Ph / H +contain: s = min(Pw / W, Ph / H), sx = sy = s +cover: s = max(Pw / W, Ph / H), sx = sy = s + +localX = (x - W / 2) * sx +localY = (H / 2 - y) * sy +localZ = 0 +``` + +`contain`の余白は透明でsemantic contentもHit Regionも持たない。`cover`はphysical plane外をclipする。`stretch`だけがaspect ratioを変える。fit offsetは常に中央寄せとし、v1にauthor指定のalignment / focal pointを持たせない。 + +raycastで得たSurface local pointは逆変換する。 + +```text +x = localX / sx + W / 2 +y = H / 2 - localY / sy +``` + +physical plane、logical content domain、現在Stateのvisible geometryの順に判定し、domain外、letterbox、clip済み領域はhitしない。normalized pointへ変換した後のHit Region判定はADR-0009のhalf-open / priority規則だけを使い、境界epsilonやpixel roundingをauthorityにしない。 + +rayはfront faceだけをhit対象とする。Canonical Surface local spaceの有限なray originを`o`、有限で非ゼロのdirectionを`d`、front normalを`n = (0, 0, 1)`とする。`denominator = dot(n, d) = d.z`が`< 0`の場合だけ`intersectionT = -o.z / d.z`を計算し、finiteかつ`intersectionT >= 0`の場合だけ半直線上の候補`o + intersectionT * d`として評価する。back-face、parallel ray、負のintersection、non-finite inputはno-hitとする。Unityでは変換後normal`(0, 0, -1)`に等価な規則を適用する。intersectionのlocal pointは`-Pw / 2 <= localX < Pw / 2`かつ`-Ph / 2 < localY <= Ph / 2`を満たす場合だけplane内とし、logical top / leftをinclusive、bottom / rightをexclusiveに保つ。 + +### Render Surface、raster、UV + +Render Surfaceのlogical boundsを`B = { x: bx, y: by, width: bw, height: bh }`とする。boundsはSemantic Surface logical coordinateにあり、有限、正、Surface domain内でなければならない。Render Surface内のnormalized coordinateは次とする。 + +```text +rx = (x - bx) / bw +ry = (y - by) / bh +``` + +rendererのlayout / raster pixel coordinateは左上原点で、`pixelX = rx * pixelWidth`、`pixelY = ry * pixelHeight`とする。pixel domainはhalf-openである。raster artifactはRender Surface bounds全体をedge-to-edgeで表し、artifact内で別のcontain / coverを適用しない。 + +Semantic Surface全体のnormalized point`(nx, ny)`からpartition-local coordinateへ変換する場合は、いったん`x = nx * W`、`y = ny * H`へ戻して上式を適用する。逆変換は`nx = (bx + rx * bw) / W`、`ny = (by + ry * bh) / H`とする。Hit Region wireへ保存するのはこの逆変換後のSemantic Surface全体基準だけである。 + +Unity mesh UV0は左下原点なので、logical / rasterから次の一回のY flipで変換する。 + +```text +u = rx +v = 1 - ry +``` + +したがってlogical top-leftはUV `(0, 1)`、bottom-right boundaryは`(1, 0)`である。region rectangleのUV bottom-leftは`(rx, 1 - (ry + rh))`となる。texture importer、mesh、shaderの複数箇所で重ねてflipせず、Unity mesh / material adapterがこの変換を所有する。graphics API固有のrender-target orientationはportable contractへ露出させず、Unity renderer boundaryで吸収する。 + +### Crop、partition、Hit Region + +physical planeがlogical spaceで覆うviewportは逆fit式から導出する。geometryとHit Regionは次のintersectionをlogical coordinateで求めてからSemantic Surface全体の`W, H`でnormalized化する。 + +```text +inverseFit(physicalPlane) = + [W / 2 - Pw / (2 * sx), W / 2 + Pw / (2 * sx)) + x [H / 2 - Ph / (2 * sy), H / 2 + Ph / (2 * sy)) +``` + +```text +surfaceVisibleWindow = + SemanticSurface([0, W) x [0, H)) + intersect inverseFit(physicalPlane) + +partitionVisible = geometry + intersect RenderSurface.logicalBounds +``` + +`contain`のinverse physical viewportはlogical domainより広いためcontent側でclipされ、余りはletterboxになる。`cover`ではinverse physical viewportがlogical domainの部分集合となり、cropされたgeometryとregionを出力しない。複数Render Surfaceへpartitionしてもnormalized Hit RegionはSemantic Surface全体を分母にし、partition-local `rx / ry`やUVをDeliveryへ出さない。partition境界にまたがる一つのbuttonは複数regionへ分割でき、ADR-0009のduplicate / canonical order規則に従う。 + +clipのauthorityは一方向にする。CompilerはまずRender Surfaceに依存しない`surfaceVisibleWindow`を決定し、raw visual unionとのintersectionから各`RenderSurface.logicalBounds`を導出する。次にそのboundsをpartition clip windowとしてrenderer planへ渡すが、rendererが返すconcrete geometryを先にclipしない。artifact producer(Browser renderer、またはNative UI planを生成するCompiler stage)がこのwindowとのintersectionを一度だけ適用し、partition-local private geometry / regionを出力する。Compiler aggregate stageはregionを再clipせずSemantic Surface normalized coordinateへ変換し、Presentation Coreは出力がwindow内であることを再検証して、補正せず違反をbuild errorにする。 + +### Numeric and validation policy + +すべてのcoordinate、matrix、Quaternion計算はCompiler / rendererではIEEE 754 binary64で行い、入力・各portable出力でnon-finiteを拒否する。canonical JSONはJSON numberを使用し、`-0`を`0`へ正規化する。hit-testはDeliveryされたboundsをそのまま使い、consumerごとにboundsを再rasterizeしない。 + +Unityのauthoritative hit-testはPhysics / Colliderが返したfloat hit pointを直接authorityにしない。sampled Quest-local ray origin / directionをcurrent `ParticipantCalibration.presentationFromQuestLocal`でCanonical Presentation Spaceへ変換してbinary64へ昇格する。Surfaceのworld matrixは、一つにfreezeしたState Frame / Control cutとlogical timeに属する`ParticipantRuntimeView.nodeStates`、active Timeline Runのeffective local Transform、Projected Runtime SnapshotのPresentation Origin、Spatial parent chain、State Streamのfresh Anchor sampleからbinary64で再構築する。このinverse world matrix、plane intersection、inverse fit、normalized boundsの順に評価する。 + +State Frameは`frameSequence`、logicalな`producedAtRuntimeTime`、transport用`producedAtRuntimeMonotonic`、`baseReliableSequence`、`presentationOriginVersion`とAnchor binding patchをatomicに運ぶ。patchはraw pose catalogではなくvisibleなAnchor-bound Node IDをkeyにし、そのNodeの`followPosition` / `followRotation`が要求する成分だけを持つ。追従しないpositionはzero translation、追従しないrotationはidentityとしてparent matrixを構成する。sampleは元`trackingFrameSequence`、Runtime受理時の同じmonotonic domainの`observedAt`、Presentation Space parent poseを持ち、ADR-0008の`anchorSampleMaxAgeMilliseconds = 500`を超えるとRuntime / client双方でunavailableにする。Connection Resume後はProjected Runtime SnapshotからOrigin pose / versionを得た後、State Streamの初回keyframeまたは後続fresh sampleがbindingを確定するまで該当Nodeを描画もhit-testもしない。`presentationOriginVersion` / assignment epoch / cutが不一致、calibrationまたはbindingがunavailable / stale、nodeがinactive / invisibleの場合はno-hitとし、新しいprojection / stateを要求する。Unity Colliderはcandidate Surfaceのbroad phaseにだけ使用できる。比較はADR-0009と本ADRのhalf-open式をexactに適用し、epsilonを足さない。これにより同じquantized input ray、runtime sample、Delivery artifactに対する分類を固定する。 + +Cross-language fixtureは少なくともidentity、nested translation / rotation / non-uniform scale、Quaternion `q / -q`、Z reflection、contain letterbox、cover crop、stretch、Render Surface offset、UV Y flip、edge-exclusive hitを含む。各domain / region境界についてexact boundary、隣接する直前値、直後値を含める。TypeScriptのderived matrix / coordinateはexpected binary64に対してabsolute error `1e-9`以下、Unityのvisual Transform float結果は同じfixtureのexpected値に対してabsolute error `1e-5`以下で比較する。domain / hitの内外判定とUnity authoritative hit-testはbinary64のexact comparisonを使い、この許容差を使用しない。 + +## Consumer responsibility + +| Consumer | Responsibility | +| ------------------------ | -------------------------------------------------------------------------------------------------------- | +| Contracts | finite local TRS、positive scale / size、Quaternion tuple、logical boundsのportable runtime shape | +| Presentation Core | parent graph、Quaternion norm / sign、TRS / matrix invariant、bounds / fit / regionのsemantic validation | +| Compiler | Quaternion / local TRS canonicalization、world matrix、fit / partition / canonical clip windowの決定 | +| Renderer API / Web | top-left logical / pixel geometry、clip windowの一回適用、partition-local private region | +| Control Plane / Delivery | coordinate contract / capability versionの一致とportable artifactのfail-closed validation | +| Unity | Z reflection、Surface / UV、binary64 authoritative ray inverse / hit-testとvisual float rangeを検証 | + +## Consequences + +- Canonical worldとUnity worldのhandedness差を一つのself-inverse変換へ隔離できる。 +- local TRSとderived matrixのauthorityが明確になり、non-uniform scale下のlossyな再分解を避けられる。 +- Web layout、texture、Unity quad、normalized Hit Regionが同じlogical coordinateから導出される。 +- current schema / renderer / Unity importerへの実装変更とfixture追加は後続Milestoneで行う。 + +## Alternatives Considered + +### Canonical coordinateをUnity left-handedへ合わせる + +既にAcceptedのADR-0005と外部contractを破り、Web /標準的なright-handed toolchainとの境界を曖昧にするため採用しない。 + +### Unity importerごとにaxis変換を選ぶ + +Asset種別、Anchor、Timeline、Surfaceで変換が分岐し、double flipを検出できないため採用しない。 + +### Surface originを左上の3D local pointにする + +Spatial Transformのpivotとvisual centerがずれ、rotation / scaleの直感とUnity quadの利用を損なうため採用しない。2D logical originだけを左上に保つ。 + +### UV / pixel座標をHit Region wireへ送る + +resolution、partition、graphics APIへinteraction authorityが依存するため採用しない。Hit RegionはSemantic Surface normalized logical coordinateだけを使用する。 + +## Follow-ups + +- M3でContracts / Core / Compiler / rendererにcoordinate fixtureとstable diagnosticを追加する。 +- M4のreference projectでcontain / cover / stretchとpartition境界のbrowser render / hit fixtureを追加する。 +- M5でDelivery contract versionとUnity EditMode fixtureを接続する。 +- [x] M2 item 5で本ADRのvisible intersectionを前提にSurface Partitionとauthor overrideを [ADR-0011](0011-surface-partition-contract.md) へ固定した。 diff --git a/docs/decisions/0011-surface-partition-contract.md b/docs/decisions/0011-surface-partition-contract.md new file mode 100644 index 0000000..9e4371c --- /dev/null +++ b/docs/decisions/0011-surface-partition-contract.md @@ -0,0 +1,232 @@ +# ADR-0011: Surface Partition と author isolate override を固定する + +- **Status**: Accepted +- **Date**: 2026-08-29 +- **Deciders**: Unframe 開発チーム +- **関連**: [ADR-0006](0006-presentation-rendering-strategy.md), [ADR-0009](0009-semantic-tree-hit-region-contract.md), [ADR-0010](0010-spatial-surface-coordinate-contract.md), [Presentation Architecture](../presentation/ARCHITECTURE.md) + +## Context + +ArchitectureはSemantic Surfaceを一つ以上のRender Surfaceへlowerし、partition set / bounds / layerを全Stateで固定するとしている。しかし現行Compilerは各Semantic Surfaceをfull bounds / layer 0の一partitionへlowerするだけで、自動分割の入力、author override、paint order、ID導出、cross-partition Hit Region集約を定義していない。 + +Render Surfaceはbuild-localな描画partitionであり、Presentationの意味identityではない。過度な自動分割はartifact数とcompositing差を増やし、過度なauthor制御はderived ID / bounds / layerを意味contractへ逆流させる。本ADRはM2 item 5としてv1の保守的なpartition contractを固定する。M2では文書だけをAcceptedにし、Contracts / Authoring / Core / Compiler / Renderer / fixtureの実装はM3〜M4で同じ変更系列として行う。現行一partition実装が一般的な自動partitionを実装済みとはみなさない。 + +## Decision + +### Authority と入力 + +Compilerだけがpartition topology、renderer selection、logical bounds、layer、RenderSurfaceIdを決定する。Rendererは一つの`RenderSurfacePlan`を受け取り、partition追加・merge・ID変更を行わない。Deliveryはbuild済みpartitionごとにcompatible artifactを選択するだけで、target capabilityに応じてrepartitionしない。 + +partition入力は次のcanonical valueだけとする。 + +- expanded Component Structureとstable SurfaceContentNode ID +- 全reachable Surface Stateへmaterializeしたcontent visibility / geometry +- manifest / renderer lockから解決したrenderer identity、contract version、execution class +- update model、interaction、internal animation、media lifecycle +- layout後のcanonical paint order、visual bounds、compositing closure +- manifestが許可し、instance authorが指定したPart isolate override +- partition strategy versionとCompiler / build configuration + +object insertion order、filesystem走査順、renderer実行完了順、Browser DOM順、asset生成順、map iteration順を入力にしない。 + +### Render atom と required boundary + +Compilerは各StateのSurface content treeをcanonical parent / sibling orderで走査し、paintを生成するleafをsemantic `RenderAtom`へlowerする。layout後に各atomのcanonical paint index、visual bounds、compositing closureを確定し、全reachable Stateの結果をpartition入力へ統合する。structural Frameはatomを所有せず、descendant rendererへ必要なlayout / clip contextとして複製できる。一つのrenderable content Nodeはちょうど一つのatomを所有し、一つのSemantic Surface内の一partitionだけへ所属する。 + +各atomはstable content Node ID、canonical paint index、resolved renderer identity、entry identity、execution class、compositing group、全Stateのpresenceを持つ。target `RenderSurfacePlan`はexactly-once ownershipを表す`ownedContentNodeIds`と、描画に必要だがownershipへ数えない`contextNodeIds`を別fieldとして持つ。Compiler aggregate validatorは全partitionのowned setを検査し、renderable Nodeの未所属と重複所属を拒否する。現行APIの単一`contentNodeIds`は一partition subsetだけの暫定形であり、M3でこの二fieldへ置換する。 + +次のいずれかが異なる隣接atom間にはrequired boundaryを置く。 + +- renderer identity / contract version / entry module +- `baked-web` / `native-ui` / `video`のexecution class +- update modelまたはmedia lifecycleを独立artifactとして保つ必要 +- renderer capability上、一つのplanで処理できないrequired feature +- opaque renderer entry boundary + +filter、mask、clip、blend、backdrop、group opacityなど複数atomを一つのoffscreen compositionとして評価しなければpixel-equivalentにならない範囲をcompositing closureとする。closureの正本はrenderer outputではなく、style tokenとComponent Manifestを解決済みのSemantic Authoring IRにある次のcanonical paint IRとする。 + +```ts +type SemanticPaintIRV1 = { + atoms: readonly ResolvedRenderAtomV1[]; + closures: readonly CompositingClosureV1[]; +}; + +type ResolvedRenderAtomV1 = { + nodeId: SurfaceContentNodeId; + paintIndex: UInt32; + renderer: RenderSurfaceIdentityDescriptorV1["renderer"]; + executionClass: "baked-web" | "native-ui" | "video"; + partitionRendererKey: `sha256:${string}`; + compositingGroupKey: `sha256:${string}`; + presentByState: Readonly>; + visualBoundsByState: Readonly>; + effectPaddingByState: Readonly< + Record + >; +}; + +type CompositingClosureV1 = { + ownerNodeId: SurfaceContentNodeId; + operandNodeIds: readonly SurfaceContentNodeId[]; +} & ( + | { operator: "source-over" } + | { operator: "rect-clip"; clipBounds: LogicalBounds } + | { operator: "group-opacity"; opacity: number } +); +``` + +Structured treeはcanonical child orderのdepth-first pre-orderで走査し、各Node自身のpaintをdescendantより先に一atomとしてemitする。paintしないstructural Nodeはemitしない。Opaque entryは一atomとしてemitする。`paintIndex`はarray indexと等しい一意な`0..N-1`で、DOM、object insertion、renderer outputから再導出しない。ordinary source-over paintにはclosure recordを作らず、全atomで共通のno-closure keyを使う。各明示closureのoperandはpaint順、non-empty、duplicateなしとする。Compilerはoperandのpaint indexの最小値から最大値までをintervalにし、overlapするclosureを推移的にunionする。owner、canonical operator tree、operand順をhashした値をそのunion内atomの`compositingGroupKey`とする。 + +`presentByState`、`visualBoundsByState`、`effectPaddingByState`は全reachable Stateをexactly onceで持つ。presentならboundsはfiniteかつ正、absentならboundsは`null`かつpaddingは全て0とする。paddingはfiniteかつnon-negativeである。closure ownerは同じSurfaceのexpanded content tree、全operandは同じIRのatomを参照し、外部Surface / 未知Node参照を拒否する。`closures`はowner Node IDとoperatorのUTF-16 code-unit順、operand paint index列、operator固有値の順にsortする。各merged closure groupは、この検証・sort後のclosure listをADR-0010の数値規則とRFC 8785 property orderでcanonical JSON化してSHA-256する。no-closure keyはcanonical JSON `{"kind":"no-closure","version":1}`のSHA-256として全buildで固定する。 + +v1のclosed operator kindは`source-over`、axis-alignedな`rect-clip`、`group-opacity`だけである。`clipBounds`はfiniteかつ正のlogical bounds、`opacity`はfiniteな`0..1`とする。mask、filter、blend、backdrop、非矩形clip、未宣言・未知operator、closure外のNodeを暗黙参照するparameter、non-contiguous operandは`compiler-partition-compositing-unsupported`で拒否する。Opaque entryは一atom / 一closureとして扱い、別entryとのcross-boundary effectを許可しない。closure内部に任意boundaryを置かず、required renderer boundaryとcompositing closureが衝突する場合は`compiler-partition-compositing-boundary-conflict`でbuild errorとする。 + +### v1 automatic partition + +required boundaryとauthor isolate boundaryを反映したcanonical paint atom列を、同じCompiler internal `partitionRendererKey` / execution class / compositing groupが連続する最大runへ分割する。これをv1 automatic partitionとする。同じ要件のatomをbounds、Node数、texture size、heuristicだけを理由に分割しない。したがって現行standard Surfaceはoverrideがなければfull boundsの一partitionになる。 + +同じclassのatomが別partitionを挟む場合は再mergeしない。これにより各partitionはpaint order上の一つのcontiguous intervalとなり、overlap時も単純なback-to-front compositionで元のpaint orderを再現できる。Semantic Surfaceを越えるmerge、texture atlas、GPU batchingはpartition topologyの外側にあるAsset / Runtime最適化である。 + +### Author isolate override + +v1のauthor overrideは公開Partを境界としてisolated paint intervalを要求する一種類だけとする。次は既存のcontent / placement / style overrideを省略したpartition関連のcontract抜粋である。 + +```ts +type PartDeclaration = { + kind: "part"; + overridable: readonly ("content" | "placement" | "style" | "partition")[]; +}; + +type StructurePartBinding = { + partId: PartId; + targetNodeId: SurfaceContentNodeId; +}; + +type PartPartitionOverrideDeclaration = { + partId: PartId; + partition?: { kind: "isolate" }; +}; +``` + +ManifestはPartの公開名とpermission、Structure Part bindingは一つのstable subtree rootとの対応を所有する。Component consumerは`overridable`に`partition`があるPartだけをisolateできる。project-local Structure authorも同じPart contractを通し、任意のprivate Node IDをinstance overrideから指定しない。Opaque Component内部のpartitionはpackage authorがManifest / renderer entryで宣言し、consumerは公開Part以外を参照しない。 + +`isolate`は対象subtreeのpaint intervalの前後にboundaryを要求するが、subtree内部のrequired renderer boundaryを消さない。isolate対象Part同士は同じSurfaceでstructural ancestor / descendant、canonical paint intervalの重複、duplicate指定を許可しない。これは2D geometry boundsのoverlapを禁止する規則ではない。intervalがinterleaveまたは重複する場合は`compiler-partition-isolate-interval-conflict`で拒否する。対象subtreeがpaint atomを持たない、paint orderでcontiguousでない、compositing closureを横断する場合もstable diagnosticでbuild errorとする。 + +v1は`merge`、author指定renderer、bounds、layer、RenderSurfaceId、artifact ID、state別partition、interaction / semantic parent変更を持たない。未知Part、binding欠落、permission欠落、禁止fieldをfail closedで拒否する。override解決順はManifest Part declaration、Structure Part binding、Component Instance override、expanded content tree、partition loweringとする。 + +### Bounds、state、layer + +まずADR-0010の`surfaceVisibleWindow = SemanticSurface domain intersect inverseFit(physicalPlane)`をRenderSurfaceから独立に導出する。partitionのlogical boundsは、所有atomが全reachable Stateで生成し得るraw visual boundsとeffect paddingのaxis-aligned unionを求め、`surfaceVisibleWindow`とintersectionした最小rectangleとする。`RenderSurface.logicalBounds`自身をその導出入力にせず、bounds計算前にpixel roundingしない。全Stateでemptyのpartitionは生成せず、author isolateがemptyならbuild errorとする。 + +partition set、owned atom set、bounds、layer、Compiler internal `partitionRendererKey`は一つのbuild内の全reachable Stateで不変とする。各Stateはpartitionごとに一つ以上のartifact候補または明示的`empty` bindingをexactly onceで持つ。Stateごとのvisibilityはtopologyやboundsを変更しない。 + +partitionはcanonical paint interval順に`layer = 0..N-1`を重複なく割り当て、小さいlayerからback-to-frontに描画する。`renderSurfaceIds`もlayer昇順で保存し、同じlayerを許可しない。bounds overlapとtransparent gapは許可するが、renderable atomの未所属 / 重複所属、paint interval交差、layer gap / duplicateを拒否する。 + +### Derived identity と provenance + +RenderSurfaceIdはauthoring入力に持たせず、次のdescriptorのcanonical JSONをSHA-256し、lowercase hex digestへ`rs_` prefixを付けて導出する。`logicalBounds`を含む数値はADR-0010のcanonical JSON規則に従い、non-finiteを拒否して`-0`を`0`へ正規化する。 + +```ts +type RenderSurfaceIdentityDescriptorV1 = { + partitionStrategyVersion: 1; + semanticSurfaceId: SemanticSurfaceId; + renderer: { + id: string; + version: string; + contractVersion: string; + implementationHash: `sha256:${string}`; + entry: + { kind: "structured" } | { kind: "opaque"; entryId: string; moduleHash: `sha256:${string}` }; + }; + executionClass: "baked-web" | "native-ui" | "video"; + compositingGroupKey: `sha256:${string}`; + ownedContentNodeIds: SurfaceContentNodeId[]; // paint order + logicalBounds: LogicalBounds; + layer: UInt32; +}; +``` + +renderer identityは現行`RendererIdentity`の4 fieldをそのまま使い、entry、execution class、compositing groupもdescriptorへ別fieldで固定する。`compositingGroupKey`はcanonical compositing operator treeの`sha256:` hashであり、object identityや走査順を含めない。Compiler internal `partitionRendererKey`はdescriptorの`renderer`と`executionClass`だけを同じcanonical JSON / SHA-256規則でhashした`sha256:`値とし、implementation / entryの違いを必ず反映する。compositing semanticsは別の`compositingGroupKey`としてboundaryとdescriptorへ含める。ID formatは`rs_`と64桁lowercase hexからなり、M3でcanonical descriptor、partition renderer key、IDのgolden fixtureを追加する。 + +`ownedContentNodeIds`、`partitionRendererKey`、完全なdescriptorはCompiler internal build provenance sidecarにだけ保持し、portable RenderBundle / DeliveryManifestへ出さない。RenderBundleの各Render Surfaceは`partitionStrategyVersion`だけをportable partition provenanceとして保持する。Deliveryのartifact compatibilityは各artifact自身のkind、contract version、required featuresとtarget capabilityの一致だけを正本とし、partition provenanceを比較に使わない。structural context Nodeはrenderer planにだけ含め、ownershipへ重複計上しない。source、lockfile、Compiler / strategy version、configuration、layoutのいずれかがpartition descriptorを変えればIDは変わってよい。同じdescriptorから異なるID、または異なるdescriptorから同じIDが生成された場合はbuild errorとする。 + +### Renderer selection と failure + +Compiler pass順は、semantic atom化、renderer requirement解決、lock済みcatalogからのcandidate選択、canonical compositing closure確定、required / isolate boundary適用、contiguous最大run化、bounds / layer / ID導出とする。required input kind / update model / interaction / animation / featureを満たすcandidateだけを残し、明示`rendererPreference`を満たせない場合はfallbackしない。`auto`はcandidateの`id`、`version`、`contractVersion`、`implementationHash`、canonical entry、execution classのtupleを順にUTF-16 code-unit比較し、最初を選ぶ。candidate catalog / lock / configurationをbuild hashへ含める。 + +`fallbackPolicy: "degrade"`はManifest / renderer contractが同じsemantics、partition topology、state coverageを保つnamed fallbackを宣言した場合だけ使用できる。partition topology変更、semantic削除、interaction削除、未知rendererへのfallbackは拒否する。一partitionのplanning / renderer / capture / validation失敗でSurfaceとRenderBundle全体をatomicに失敗させ、partial artifactやorphan Assetをpublishable outputにしない。 + +DeliveryManifest生成時にreachable Stateの非empty bindingを満たすcompatible artifactがなければ`delivery-artifact-unavailable`でManifest全体をatomicに拒否する。RenderBundleがそのStateへ明示した`empty`以外へ代替せず、別renderer、別partition、別Stateのartifactへfallbackしない。本ADRのdiagnostic codeは既存Compiler / Coreと同じlower-kebabのlibrary diagnostic stringであり、wire enumではない。 + +非empty state bindingの`artifactIds`はCompilerが選択優先順で並べたnon-empty / duplicate-free listとする。Deliveryはこの順序を変更せず、target capabilityとcompatibleな最初のartifactを選ぶ。後続候補の解像度 / format preferenceはM2 item 6のbudget contractで固定するが、複数compatible候補のtie-break authorityは常にこのordered listであり、record iterationやAsset ID辞書順を使わない。 + +### Cross-partition semantics と Hit Region + +Semantic Tree、Interaction、Surface StateはSemantic Surface全体の正本であり、partitionへ複製して別identityを作らない。target Renderer APIはportable `HitRegion`ではなく、次のbuild-internal shapeをState ID keyed recordで返す。 + +```ts +type RendererPrivateHitRegion = { + interactionId: InteractionId; + semanticNodeId: SemanticNodeId; + bounds: { x: number; y: number; width: number; height: number }; + priority: UInt32; +}; + +type RendererPrivateHitRegionsByState = Readonly< + Record +>; +``` + +private `bounds`は`RenderSurface.logicalBounds`の左上を`(0, 0)`とするpartition-local logical coordinateであり、normalized `rx / ry`ではない。全値をfinite、`width / height > 0`、`0 <= x < bw`、`0 <= y < bh`、`x + width <= bw`、`y + height <= bh`とし、left / top inclusive、right / bottom exclusiveとする。`priority`はcanonical Interaction definitionの`hitPriority`をCompilerがcopyしてplanへ渡す値であり、rendererは生成・変更しない。`event`、State ID、RenderSurfaceId、layerをregion fieldへ重複させない。artifact producerがCompilerから受け取ったpartition clip windowとのintersectionを一度だけ適用し、clip後に面積0となるregionは出力しない。Compiler aggregate stageは再clipせずSemantic Surface全体のnormalized coordinateへ変換して全partitionのState別regionを結合する。Coreはaggregate後のportable `HitRegion`をreject-onlyで検証し、enabled Interactionのregion completenessをSurface全体で判定する。 + +Semantic Surface logical sizeを`W, H`、partition boundsを`bx, by, bw, bh`、private boundsを`x, y, width, height`としたaggregate式は`nx = (bx + x) / W`、`ny = (by + y) / H`、`nwidth = width / W`、`nheight = height / H`である。変換後もbinary64を保ち、pixel roundingや再clipを行わない。 + +現行Renderer APIの`RendererBuildSuccess.hitRegionsByState: HitRegion[]`は、一partition subsetがすでにSemantic Surface normalized regionを返す暫定contractである。M3では`RendererPrivateHitRegion`への置換、aggregate、portable `HitRegion`生成を同一sliceで移行し、local / normalizedの両方を同じfieldで許可しない。 + +同じbuttonが複数partitionまたはintervalにまたがる場合は、同じ`interactionId` / `semanticNodeId`を持つ複数regionとして表す。aggregate後にADR-0009のduplicate、enabled、button参照、priority / ID / bounds canonical orderをCoreがreject-onlyで検証する。RenderSurfaceId、layer、UV、pixel、partition-local coordinateをpublic Hit Regionへ含めず、visual layerをhit-test winnerに使用しない。 + +Native UI dynamic semantic textのprofile横断injective検証、enabled Interactionのregion completeness、crossfade中のinteraction無効化もSemantic Surface全体を単位に行う。 + +## Consumer responsibility + +| Consumer | Responsibility | +| ---------------------- | ----------------------------------------------------------------------------------------- | +| Authoring / Components | stable Part binding、partition permission、isolate overrideの宣言と静的検証 | +| Contracts | policy / provenance / UInt32 layer / state bindingのportable runtime shape | +| Presentation Core | atom coverage、state-invariant topology、layer / bounds / region aggregate invariant | +| Compiler | paint atoms、boundary、renderer選択、ID / bounds / layer、atomic aggregateの唯一authority | +| Renderer API / Web | 渡された一partitionだけを描画し、private geometry / regionとprovenanceを返す | +| Delivery / Unity | partitionごとのartifactを再選択せず固定し、layer順に合成する | + +## Consequences + +- defaultは一partitionのままで、現在用途のないheuristic分割を導入しない。 +- renderer / compositing上必要な境界と明示許可されたPart isolateだけを決定的に反映できる。 +- authorは意味IDやderived geometryを所有せず、Compilerの再生成可能性を保つ。 +- cross-partitionでもState、Semantic Tree、Interaction、hit-test authorityは一つのSemantic Surfaceに残る。 +- current Compiler / schema / Core / rendererはM3〜M4でtarget contractへ置換する。 + +## Alternatives Considered + +### texture size / Node countによるheuristic split + +budgetやBrowser差でtopologyとhashが変わり、次のM2 budget contractより先に閾値を埋め込むため採用しない。上限超過はbuild errorとし、明示isolateまたは別Semantic Surfaceで解決する。 + +### authorがRenderSurfaceId / bounds / layerを直接指定する + +derived renderer topologyをsemantic sourceへ逆流させ、layout / Compiler変更を困難にするため採用しない。 + +### Deliveryがdevice capabilityごとにrepartitionする + +participant間でvisual / Hit Region topologyが分岐し、PublishedPresentationのatomic hashを弱めるため採用しない。 + +### visual layerをhit-test priorityに使う + +renderer detailをinteraction authorityへ混入させるため採用しない。Hit RegionはADR-0009の明示priorityだけを使う。 + +## Follow-ups + +- M3 Slice AでPart binding / permission / isolate overrideをAuthoring / Components / CompilerへTDDで実装する。 +- M3 Slice BでADR-0009のHit Region schema移行とpartition aggregate fixtureを同時に実装する。 +- M4でmulti-partition reference project、Browser capture、deterministic ID / paint order fixtureを追加する。 +- M2 item 6でheuristic splitを追加せず、artifact / GPU / RAM budgetと超過diagnosticを固定する。 diff --git a/docs/decisions/0012-texture-budget-residency-contract.md b/docs/decisions/0012-texture-budget-residency-contract.md new file mode 100644 index 0000000..496c194 --- /dev/null +++ b/docs/decisions/0012-texture-budget-residency-contract.md @@ -0,0 +1,253 @@ +# ADR-0012: Texture build budget と runtime residency を固定する + +- **Status**: Accepted +- **Date**: 2026-08-29 +- **Deciders**: Unframe 開発チーム +- **関連**: [ADR-0006](0006-presentation-rendering-strategy.md), [ADR-0010](0010-spatial-surface-coordinate-contract.md), [ADR-0011](0011-surface-partition-contract.md), [Presentation Architecture](../presentation/ARCHITECTURE.md) + +## Context + +現行`presentation-assets`は一回のPNG encodeに4,096 x 4,096、16,777,216 pixels、64 MiB input、65 MiB outputのtrust-boundary hard capを持つ。しかしCompilerはcapture前に全体budgetを検査せず、State数、Render Surface数、capture RGBA、保持するencoded output、Delivery選択、Unity residencyを制限していない。このper-encode capは未trusted inputによる単一allocation防止であり、Presentationの品質・build・device budgetではない。 + +ArchitectureはTexture dimension / format / mipmap / GPU / RAM budget tierをCapability Profileに持たせる一方、exact resolution、State artifact数、decoded memory、crossfade peak、preload readiness、active pin / evictionを未定義としていた。本ADRはM2 item 6として保守的なBaked Web v1 contractを固定する。M2では文書だけをAcceptedにし、schema / Compiler / Delivery / Realtime cache / Unity Asset ManagerはM3〜M5で同じ変更系列として実装する。 + +## Decision + +### v1 scope と単位 + +v1 texture pathはBaked Webのdeterministic PNG transportとUnity `RGBA32` uploadだけを対象にする。Native UIは本ADRのtexture count / bytesへ課金せず、Font / glyph / Node limitを別artifact contractで検証する。Videoはdecoder surface / buffer / codecごとのbudget contractがないためv1 Deliveryでfail closedとし、texture式へ推測して混ぜない。 + +すべてのcountはnon-negative integer、byte値は`UInt64`、dimension / per-texture pixel / State countは`UInt32`、複数textureを加算するaggregate pixel countは`UInt64`とする。`1 MiB = 1,048,576 bytes`であり、MB表記やprocess RSSの概算をportable contractへ使用しない。加算・乗算はunsigned 64-bit checked arithmeticで行い、overflow自体をbudget超過として拒否する。 + +### Resolution と State artifact count + +各non-empty `(RenderSurfaceId, SurfaceStateId)`はexactly one `TextureArtifactV1`を持つ。`textures` arrayの長さは1、empty bindingはtextureを持たない。異なるStateのbytesが同じでもState coverageは別々に検証し、verified `checksum`が同じbinaryだけをstorage / residencyでdeduplicateする。`assetId`はBundle内の論理識別子であり、同じ`assetId`が異なるchecksum、size、media type、pixel metadataを指すBundleは`asset-descriptor-conflict`で拒否する。 + +v1のresolution policyは`longEdgePixels = 2_048`の一種類だけである。Render Surface logical boundsを`bw, bh`とし、次でpixel sizeを導出する。 + +```text +scale = 2_048 / max(bw, bh) +pixelWidth = max(1, floor(bw * scale + 0.5)) +pixelHeight = max(1, floor(bh * scale + 0.5)) +``` + +結果は各dimension `1..2_048`、総pixel数`1..4_194_304`でなければならない。author / renderer / Deliveryは任意pixel size、device別resize、別aspect、解像度variantを追加しない。resolution policy versionと導出結果をbuild configuration / provenanceへ含める。将来複数resolutionを追加する場合はartifact候補順、build budget、profile selectionを新versionで同時に更新し、v1へoptional fallbackとして混在させない。 + +一buildの構造上限は次とする。 + +| Limit | v1 value | +| ------------------------------------ | -------: | +| reachable States / Render Surface | 16 | +| Render Surfaces / Semantic Surface | 16 | +| Render Surfaces / RenderBundle | 64 | +| non-empty texture State bindings | 256 | +| texture variants / non-empty binding | 1 | + +同じchecksumへdeduplicateできても、capture workを必要とするnon-empty binding countとrendered pixelsはdeduplicate前に課金する。上限回避のためStateやpartitionを同一Assetへaliasしない。 + +### Texture format、mipmap、byte formula + +`TextureArtifactV1`は次のportable metadataを持つ。 + +```ts +type TextureArtifactV1 = { + assetId: AssetId; + mediaType: "image/png"; + encodedSizeBytes: UInt64; + pixelSize: readonly [width: UInt32, height: UInt32]; + pixelFormat: "rgba8-unorm-srgb"; + mipCount: 1; + checksum: ContentHash; + colorSpace: "srgb"; + alphaMode: "opaque" | "straight"; + decodedGpuBytes: UInt64; + peakLoadCpuBytes: UInt64; + memoryEstimateVersion: 1; +}; +``` + +PNGは現行encoder version 1の8-bit RGBA、sRGB chunk、filter 0、stored DEFLATEを使う。`premultiplied`はportable schemaでも拒否し、暗黙unpremultiplyを行わない。mipmap、runtime GPU compression、platform transcode、lossy compression、texture streamingはv1に含めない。Unityはmip chainなしの`RGBA32`としてloadし、upload後にCPU readback copyを破棄する。 + +widthを`w`、heightを`h`、`P = w * h`、`S = h * (4w + 1)`、`B = ceil(S / 65_535)`とすると、portable chargeは次である。 + +```text +rawRgbaBytes = 4 * P +encodedSizeBytes = 76 + S + 5 * B +decodedGpuBytes = rawRgbaBytes +peakLoadCpuBytes = encodedSizeBytes + 2 * rawRgbaBytes +``` + +`encodedSizeBytes`はencoderがallocation前に予測した値と実bytes lengthの両方に一致しなければならない。`peakLoadCpuBytes`はmanaged encoded buffer、decoded RGBA、upload stagingを各一つ課金するportable admission chargeであり、Unity process全体の実測RSSを表さない。implementationは一textureずつserialにdecode / uploadし、追加copyを恒常保持しない。platform / driver overheadはQuest実機profileで別途観測し、このcanonical chargeを暗黙補正しない。 + +Unity公式文書ではRGBA32は32 bits / pixel、mipmapはmemoryを約33%増やし、CPU readable copyはupload後に破棄できる。本ADRは近似の`4/3`を使わず、v1を`mipCount = 1`へ固定する。 + +- [Unity GPU texture formats](https://docs.unity3d.com/ja/6000.0/Manual/texture-formats-reference.html) +- [Unity mipmaps introduction](https://docs.unity3d.com/ja/2023.2/Manual/texture-mipmaps-introduction.html) +- [Unity Texture2D.Apply](https://docs.unity3d.com/2023.1/Documentation/ScriptReference/Texture2D.Apply.html) + +### Compiler build budget + +v1 default build policyは次を持つ。callerは小さくできるが大きくできない。resolution policy version、budget policy version、long edge、State / Surface / binding count、`maxTextureWidth`から`maxBuildAccountedPeakBytes`までのdeterministic count / byte field、renderer concurrencyの正規化済み実値を`NormalizedTextureBuildPolicyV1`としてRenderBundle `buildContext.textureBuildPolicy`へ記録する。`policyHash` field自身を除いたcanonical policy payloadのhashを`policyHash`へ格納し、build hash / provenance / Compiler cache keyへ含める。wall-time、host RSS、abortは同じ入力のartifact identityを変えないavailability guardとしてhashへ含めない。 + +| Field | v1 hard maximum | +| ---------------------------------- | --------------: | +| `maxTextureWidth` | 2,048 | +| `maxTextureHeight` | 2,048 | +| `maxTexturePixels` | 4,194,304 | +| `maxRenderedPixels` | 67,108,864 | +| `maxSurfaceCaptureBytes` | 268,435,456 | +| `maxBuildOutputBytes` | 268,435,456 | +| `maxBuildAccountedPeakBytes` | 536,870,912 | +| `maxBuildHostRssBytes` | 1,073,741,824 | +| `maxRendererSurfaceWallTimeMillis` | 30,000 | +| `maxBuildWallTimeMillis` | 600,000 | +| renderer concurrency | 1 | + +renderer concurrency 1はcapture / encode working setのaccounting前提であり、並列化する場合は新budget policy versionでpeak式を更新する。 + +Compilerはrendererを呼ぶ前にState / partition count、derived pixel dimensions、`sum(rawRgbaBytes)`、既知の構造上限をpreflightする。各surface renderer resultを受けた後、encode前にcapture bytesを再検証する。PNG plan後にunique verified `checksum`単位のoutputを加算し、次をchecked arithmeticで検証する。 + +```text +renderedPixels = sum(non-empty binding before dedup, w * h) +surfaceCaptureBytes = sum(current Render Surface captures, rawRgbaBytes) +buildOutputBytes = sum(unique output checksum, encodedSizeBytes) +buildAccountedPeakBytes = + retained unique-checksum buildOutputBytes + + current surfaceCaptureBytes + + current encode encodedSizeBytes +``` + +renderer / encodeは一Render Surfaceずつ実行し、次へ進む前にcaptureを解放する。Compiler APIは失敗時にpartial RenderBundle / Asset Setを返さず、CLIは将来temporary workspaceをcleanupして成功時だけatomic renameする。deterministic count / byte budgetはCompiler、format固有byte planは`presentation-assets`、wall-time / abort / process-tree RSS hard killはRenderer isolated hostが所有する。`maxBuildHostRssBytes`はCompilerとそのBrowser / codec child process treeのresident set合計をhostが観測するavailability guardであり、portable memory estimateやoutput identityには含めない。超過時はprocess treeを終了して`renderer-resource-exceeded`とし、partial outputをpublishしない。 + +### Delivery budget tier + +normalized Capability Profileは少なくとも次を持つ。 + +```ts +type TextureBudgetTierV1 = { + id: "texture-baseline-v1"; + supportedRendererKinds: readonly ["baked-web"]; + maxTextureWidth: 2_048; + maxTextureHeight: 2_048; + maxTextureGpuBytes: 268_435_456; + maxTextureLoadCpuBytes: 268_435_456; + supportedPixelFormats: readonly ["rgba8-unorm-srgb"]; + supportedMipCounts: readonly [1]; +}; +``` + +DeliveryはADR-0011のordered `artifactIds`を変えず、visibleな全reachable Stateの各non-empty bindingから最初のcompatible artifactを一つ固定する。`texture-baseline-v1`は`baked-web`だけを許可し、Native UI / Videoを含むprofileはrenderer固有budget tierがAcceptedになるまで`delivery-artifact-unavailable`で拒否する。v1では候補が一つなので、不適合ならfallbackせずrejectする。GPU chargeはselected textureのunique verified `checksum`ごとに`decodedGpuBytes`を一回、CPU chargeはserial loaderの最大`peakLoadCpuBytes`を一回数える。同じchecksumを持つdescriptorのencoded / decoded size、media type、pixel metadataが一致しなければ`asset-descriptor-conflict`で拒否する。次を両方満たさなければDeliveryManifest全体を`delivery-texture-budget-exceeded`でatomicに拒否する。 + +```text +sum(unique selected checksum, decodedGpuBytes) <= maxTextureGpuBytes +max(selected texture peakLoadCpuBytes) <= maxTextureLoadCpuBytes +``` + +Manifestの`assetAccess`はselected Assetの`mediaType` / `encodedSizeBytes`を、`textureResidency`はselected textureのpixel metadata、memory estimate、budget tier IDを運ぶ。Control PlaneはRenderBundle metadataと実Asset size / checksumを照合し、client申告値からtierを拡張しない。UnityはManifest内のdescriptor一致とbudgetを再検証する。`Texture.currentTextureMemory` / `targetTextureMemory`は観測値としてtelemetryに使えるが、Manifest admissionの正本にしない。 + +### Preload、readiness、crossfade + +v1はvisibleな全reachable Stateのselected textureをsession開始前にdownload、size / media type / checksum検証、serial decode、GPU uploadし、CPU readback copyを破棄する。readinessを次の段階に分ける。 + +1. `downloadReady`: 全selected encoded Assetがlocal cacheにあり検証済み。 +2. `residentReady`: 全selected textureがManifest metadataどおりGPU residentで、CPU readback copyが破棄済み。 +3. `sessionReady`: required Control / State connectionとfenceが揃い、`residentReady`である。 + +participantは`sessionReady`前にSession開始条件のready集合へ数えない。Sessionが全participantを待つか必須roleだけを待つかは既存の明示start policyに従い、texture loaderがpolicyを変更しない。active session中はselected texture hashを全てpinし、State change / crossfade中にevictしない。したがって一つのSemantic Surfaceで同時に一transitionだけというADR-0007 / Architectureの規則に対し、old / new Stateの二重residencyは上記GPU sumへすでに含まれ、transition開始時の追加allocationを行わない。resident不足時にcrossfadeをcutへ変換したりcanonical stateをclientだけで戻したりしない。 + +開始前のdownload / decode / upload失敗、metadata不一致、device allocation失敗はtyped resource failureとしてparticipant readinessをrejectする。別resolution、別renderer、別State artifactへ暗黙fallbackしない。開始後にdevice context lossなどでpinned residencyを失ったclientは描画とinput送信を停止し、Control / State connectionを`asset_residency_lost` reasonで閉じる。Runtimeは通常のparticipant disconnectとしてpresenceと既存Presenter lease policyを適用し、asset layerからcanonical progressionやSession pauseを直接変更しない。他participantのSessionは継続する。 + +### Cache と eviction + +encoded Asset cacheはGPU residencyと別budgetにし、verified checksumをcache keyにする。baseline Edge / Unity cache policyはhard limit 4 GiB、filesystem low-space reserve 512 MiBとする。cache cleanup後のverified file合計を`currentVerifiedBytes`、既存staging fileの実bytesを`currentStagingBytes`、commit済みdownload reservationのうちまだfilesystemへ書かれていないbytesを`currentReservedRemainingBytes`、selected Assetのうちcacheにも既存reservationにもないunique checksumのbytesを`requiredNewBytes`、unpinned LRUからevictするbytesを`evictedBytes`、admission直前の実filesystem free bytesを`freeBytes`とする。checked arithmeticで次を両方満たすまでevictし、満たせなければ`asset-cache-admission-exceeded`でSession readinessをrejectする。 + +```text +currentVerifiedBytes - evictedBytes + + currentStagingBytes + + currentReservedRemainingBytes + + requiredNewBytes + <= hardLimitBytes +freeBytes + evictedBytes + - currentReservedRemainingBytes + - requiredNewBytes + >= lowSpaceReserveBytes +``` + +cache metadata transactionはadmissionを直列化し、staging sweep、pin確認、同じchecksumの既存reservation共有、必要なLRU eviction、new byte reservation作成、Session pin reference追加を一つのtransactionでcommitする。reservation recordはchecksum、expected bytes、staging bytes、remaining bytes、consumer token集合、lease generationを持ち、共有時はconsumer tokenだけを追加する。downloadはcommit後にだけ開始する。書込みに応じて`currentStagingBytes`を増やしreservation remainingを同量減らし、検証成功時はstagingとreservationをverified entryへatomic rename / 移行する。一consumerのcancelはそのtokenとpin referenceだけを削除し、最後のconsumerがなくなった場合だけdownloadをcancelしてstaging / reservationをcleanupする。download自体のverification failureは全consumerのreadinessを失敗させてrecordをcleanupする。これにより複数Sessionが同じ空き容量や同じchecksumを重複予約せず、一方のcancelが共有downloadを破壊しない。 + +active / waiting SessionのDeliveryManifestがselectedしたverified checksumはpinする。eviction対象はunpinned、checksum検証済みのencoded fileだけで、persistent `lastAccessSequence: UInt64`昇順、同値はchecksumのUTF-16 code-unit昇順でLRU evictionする。cache metadata transactionを直列化し、checksum検証完了、または認可済みAsset response完了ごとにchecked incrementしたsequenceをatomicに永続化する。wall clock、filesystem mtime、directory走査順をLRU authorityにしない。 + +partial download、checksum mismatch、expired staging fileはLRUへ入れずcleanupする。staging metadataは`createdAtUnixMillis`と`leaseRenewedAtUnixMillis`を永続化し、leaseを15分、sweep intervalを5分、active downloadのrenew intervalを5分以下とする。稼働中のsweepはconsumer tokenがなく、または最後のlease更新から15分を超えたrecordだけを失敗としてcleanupする。process startup時は前processのin-flight requestが存在しないため、全stagingと全download reservation / consumer tokenを一transactionで削除してからdurable Session / Manifestのpinを再構築し、waiting Sessionは新しいadmissionを行う。これによりorphan reservationを`currentReservedRemainingBytes`へ残さない。wall-clock rollback時はactive processのmonotonic deadline、startup sweep、consumer cleanupを正本とし、期限判定が曖昧なstagingをverified entryへ昇格させない。 + +session終了 / cancel / waiting expiry後にreference countを減らし、0になったchecksumだけをevictできる。process restart時はdurable active / waiting SessionとDeliveryManifestからpin reference countを再構築してからadmission / evictionを許可する。再構築できない間はcacheをnot readyとしてevictionしない。GPU textureはactive session終了後にreference count 0のものをchecksum順にdestroyし、別sessionが共有するtextureを解放しない。 + +cache hard limit / reserveはEdge / client hostのdeployment configurationとしてbaselineより小さくできるが、Texture Capability Profileを変更しない。Runtime assignment / participant readinessはhost cache admissionの成功を別条件として検証する。cache missやevictionはRenderBundle identityを変えない。 + +### Diagnostics と failure atomicity + +stable library diagnosticはlower-kebabとし、少なくとも次を固定する。 + +- `compiler-budget-pixel-target-exceeded` +- `compiler-budget-state-count-exceeded` +- `compiler-budget-render-surface-count-exceeded` +- `compiler-budget-texture-binding-count-exceeded` +- `compiler-budget-rendered-pixels-exceeded` +- `compiler-budget-capture-bytes-exceeded` +- `compiler-budget-output-bytes-exceeded` +- `compiler-budget-accounted-peak-exceeded` +- `renderer-timeout` +- `renderer-resource-exceeded` +- `delivery-texture-budget-exceeded` +- `delivery-artifact-unavailable` +- `asset-cache-admission-exceeded` +- `asset-descriptor-conflict` +- `asset-download-verification-failed` +- `asset-texture-residency-failed` + +複数のpreflight違反はsemantic path、code、limit名のcanonical順で全件返す。renderer / encode / Delivery / cache / residency段階の失敗は、その境界のatomic unit全体を失敗させ、partial Bundle、Manifest、Readyを返さない。 + +## Consumer responsibility + +| Consumer | Responsibility | +| ---------------------------- | ----------------------------------------------------------------------------------------------- | +| Contracts | Texture metadata、UInt32 / UInt64、budget tier、readiness shapeのportable source | +| Presentation Core | State coverage、count、metadata式、unique Asset / aggregate budgetのreject-only validation | +| Compiler | resolution / count / pixel / capture / output / accounted peak、serial orchestrationのauthority | +| Presentation Assets | PNG exact byte plan、per-transform hard cap、checksum / encoded size consistency | +| Renderer API / isolated host | capture前budget、deadline / abort、process resource guard、partial result禁止 | +| Control Plane / Delivery | normalized tier、ordered candidate selection、GPU / CPU admission、Manifest atomicity | +| Realtime / Edge cache | encoded cache admission、pin、reference count、deterministic LRU、cleanup | +| Unity Asset Manager | serial decode / upload、non-readable化、residency readiness、pin / destroy、telemetry | + +## Consequences + +- 現行一texture / Stateの実装を維持しつつ、無制限なState / Surface / total outputをfail closedにできる。 +- PNG transport bytes、build working set、runtime GPU、load CPU、disk cacheを別budgetとして検証できる。 +- 全reachable StateをpreloadするためState transition / crossfade時にdownloadや追加GPU allocationを行わない。 +- 2K RGBA32 textureは1枚16 MiBであり、baseline GPU tierではcontent-address dedup後に最大16枚相当となる。actual presentation shapeはcountとbyteの両方で制限される。 +- 低解像度variant、mipmap、ASTC / KTX2、videoは実測と新contractなしに暗黙導入しない。 + +## Alternatives Considered + +### Stateごとに1K / 2Kを常時生成する + +現行resize / variant selectionがなく、build outputとresident候補を倍増させるため採用しない。複数resolutionはM4以降にprofile / candidate ordering / visual fixtureと同時導入する。 + +### Unityのruntime texture compressionへ依存する + +platform supportとdriverで成否が分岐し、portable bytes / memory estimateを固定できないため採用しない。v1はRGBA32を明示する。 + +### current texture memoryを見てから暗黙downscaleする + +participantごとに見た目とHit Region rasterが分岐し、Delivery admissionを後付けheuristicへ変えるため採用しない。 + +### 現在Stateと次Stateだけをon-demand preloadする + +任意Cue / inputによる遷移前にdownload完了をcanonical progressionと同期するprotocolがないため採用しない。v1は全reachable Stateを開始前にresident化する。 + +## Follow-ups + +- M3〜M4でTexture metadata、budget pure formula、Compiler preflight / aggregate、PNG alpha driftをTDDで実装する。 +- M4で2K reference fixtureとbudget境界 / dedup / deterministic diagnostic goldenを追加する。 +- M5でDelivery budget tier、download / resident readiness、Realtime cache pin / LRU、generated C# / Unity Asset Managerを接続する。 +- Quest実機でprocess / driver overhead、upload peak、256 MiB tierを計測し、変更が必要なら新budget tier / policy versionとして更新する。 diff --git a/docs/decisions/0013-local-compiler-project-filesystem-contract.md b/docs/decisions/0013-local-compiler-project-filesystem-contract.md new file mode 100644 index 0000000..8da7208 --- /dev/null +++ b/docs/decisions/0013-local-compiler-project-filesystem-contract.md @@ -0,0 +1,133 @@ +# ADR-0013: Local Compiler の project filesystem contract を固定する + +- **Status**: Accepted +- **Date**: 2026-08-29 +- **Deciders**: Unframe 開発チーム +- **関連**: [ADR-0006](0006-presentation-rendering-strategy.md), [Presentation Architecture](../presentation/ARCHITECTURE.md), [Presentation CLI Architecture](../../packages/presentation-cli/ARCHITECTURE.md) + +## Context + +M1 の Local Compiler は、virtual Authoring Project と injected host までを実装している。しかし実利用する project root、設定、locked package、artifact の公開先を未定義のままにすると、source identity、filesystem traversal、出力置換、cancel 時の可視性が host ごとに変わる。これは同一入力の artifact hash と、失敗時に partial output を公開しないという M1 の要件を満たせない。 + +本 ADR は M1 の local-only filesystem boundary を固定する。remote registry、plugin discovery、watch、cache、publish、複数 platform を対象にしない。 + +## Decision + +### Project root と入力 + +CLI は POSIX filesystem だけを M1 の対象にする。process command は absolute project directory を明示的に受け、その `realpath` を project root とする。上方向の discovery は行わず、同じ directory に `unframe.config.ts` と `unframe.lock` がともに存在しなければ I/O diagnostic で fail closed にする。root 外を参照する、入力中に symbolic link がある場合も I/O diagnostic とする。Windows path、junction、case-insensitive path の同一性は M1 では unsupported である。 + +discovery、config、lock、authoring source は regular file だけを受け入れる。reader は traversal 中と open 時に symbolic link を拒否し、root-relative POSIX path を正規化して `..`、empty segment、NUL、absolute path を拒否する。root から recursive scan した project-owned `.ts`、`.tsx`、`.d.ts` は、fatal UTF-8 decode 後に root-relative POSIX `fileName` と `sourceText` の一度だけの snapshot として virtual Authoring Project に渡す。`unframe.config.ts`、`.unframe`、`dist`、`.git`、`node_modules` は source input から除外する。directory scan と materialized source の順序は UTF-16 code-unit 昇順に固定し、filesystem の返却順を入力にしない。scan 対象の symbolic link、unsafe traversal、read race、UTF-8 failure は stable I/O discovery diagnostic で fail closed にする。`entryFile` はこの snapshot 内に存在しなければならない。 + +### `unframe.config.ts` + +`unframe.config.ts` は実行しない data-only file とする。AST から次だけを受け入れる。 + +```ts +export default { entryFile: "presentation.unframe.tsx" }; +``` + +`entryFile` は project root relative の POSIX path で、regular file を指さなければならない。import、call、identifier、spread、computed property、getter、任意の追加 property を拒否する。設定を TypeScript / JavaScript として evaluate せず、unsupported syntax と値の不正は stable config diagnostic とする。 + +### `unframe.lock` v1 + +`unframe.lock` v1 は UTF-8 の self-contained JSON object であり、network URL、registry lookup、tarball、ローカル package manager state を参照しない。M1 は remote install や plugin discovery を行わない。次が唯一受け入れる serialized shape である。`ContentHash` は `sha256:` に続く 64 桁の小文字 hexadecimal である。 + +```ts +type ContentHash = `sha256:${string}`; + +type UnframeLockV1 = { + schemaVersion: 1; + packageDependencies: readonly PackageIdentity[]; + packages: readonly LockedPackage[]; + themeHashes: readonly { themeId: string; hash: ContentHash }[]; + componentLocks: readonly { + componentId: string; + version: number; + lock: { + packageVersion: string; + packageIntegrity: ContentHash; + manifestHash: ContentHash; + structureHash: ContentHash; + }; + }[]; + assets: Readonly>; +}; + +type PackageIdentity = { + packageName: string; + packageVersion: string; + packageIntegrity: ContentHash; +}; + +type LockedPackage = PackageIdentity & { + files: readonly { fileName: string; sourceText: string }[]; + exports: readonly { subpath: string; targetFile: string }[]; + dependencies: readonly PackageIdentity[]; +}; +``` + +`packageDependencies` と `dependencies` は full package identity、`packages` は full identity、`files` は `fileName`、`exports` は `subpath`、`themeHashes` は `themeId`、`componentLocks` は `(componentId, version)` の UTF-16 code-unit 昇順で sort する。`assets` object は record key の UTF-16 code-unit 昇順で canonicalize する。これらの key は duplicate-free でなければならない。asset record key は asset catalog を参照するだけの key であり、checksum 由来とは定義しない。 + +すべての content hash は canonicalized semantic payload の SHA-256 を用いる。object key は UTF-16 code-unit 昇順、配列は上記の順序、JSON は canonical serialization を使用する。`packageIntegrity` は自身の `packageIntegrity` field だけを除いた flat `packageName`、`packageVersion`、`files`、`exports`、`dependencies` の canonical payload を hash し、dependency identityのintegrityを含める。theme hash は source location 等の metadata を除いた Theme declaration semantic payload、`manifestHash` と `structureHash` はそれぞれの declaration semantic payload を hash する。asset checksum は M1 では lock metadata-only であり、filesystem asset bytes の存在・内容を検証しない。source location、filesystem traversal order、staging directory、wall-clock、process ID は hash input に含めない。Declaration、Definition、RenderBundle は同じ canonical hash rule を用いる。 + +duplicate JSON key、不正 UTF-8、未知 required version、integrity mismatch、lock 内 package reference の欠落は lock diagnostic として fail closed にする。lock file が指す package source や metadata は source role と integrity を再検証してから virtual project に materialize する。 + +### Build output と atomic replacement + +`generation-id` は 16 random bytes を lowercase hexadecimal で表した `[0-9a-f]{32}` とし、artifact identityではない。M1 の build は project root の `.unframe/generations/.staging-/` に staging を作り、`definition.json`、`render-bundle.json`、`assets/${encodeURIComponent(assetId)}.png` だけを完全に書く。PNG path は内部Compilerが生成した asset ID だけから導出し、全 artifact path を辞書順にする。manifest、Delivery artifact、publish metadata は M1 の公開artifactではない。 + +公開前に全 hash と I/O close を検証して staging を `.unframe/generations//` へ rename する。公開先は root 固定の `dist` であり、CLI が管理する relative symbolic link とする。既存 `dist` が正確に3 segmentの relative target `.unframe/generations/`(`validated-id` は同じ grammar)を指す symlink でない場合は、置換・削除せず I/O diagnostic で拒否する。公開はこの同じ3 segment targetを持つ new symlink を作成して `rename` する一回の atomic replacement とする。root、`.unframe`、`generations`、generation directory はすべて root 内の non-symlink directory であることを `lstat` と open 時に検証し、外部symlinkとpath traversalを拒否する。build は公開済み generation を変更せず、staging / failed generation を公開しない。M1 は persistent managed marker も過去 generation の cleanup も導入せず、cleanup 対象は今回の process が作成した staging だけに限る。 + +同一 project に対する Unframe CLI の build は process 境界で直列化する。publication 中に別の非 Unframe process が project tree の directory entry を敵対的に差し替える場合に対する filesystem CAS は M1 の保証外とする。publication boundary は各 entry を commit point 直前まで再検証するが、この前提を越えて unmanaged entry と atomic replacement を同時に保護する kernel primitive は要求しない。 + +### Signal、cancel、Browser lifecycle + +process entry だけが `SIGINT` と `SIGTERM` listener を所有し、`AbortSignal` へ一回だけ変換する。library は global listener を追加・変更しない。process entry は listener を `finally` で必ず解除し、phase 境界で cancellation を確認する。同期 Compiler API は signal-aware ではないため signal を渡さない。Fixed Browser capture wrapper だけが同じ signal を capture に渡す。cancel を受けたら新規 phase を開始せず、active Browser context と Browser process を close し、今回の staging を cleanup する。signal による終了 code は 130 とする。 + +commit point は `dist` symlink の atomic replacement が成功した時点だけである。commit point 前の `syntax`、`type`、`semantic`、`renderer`、`io`、`cancel` failure は previous `dist` を維持し、partial artifact を公開しない。M1 は commit point 後の過去generation cleanupを行わない。 + +### Stable diagnostics + +CLI は source location と deterministic ordering を保持し、diagnostic JSON に次の union の `family` を必ず持たせる。text format は `path: family/code: message` の一行形式とする。 + +```ts +type DiagnosticFamily = "usage" | "syntax" | "type" | "semantic" | "renderer" | "io" | "cancel"; +``` + +- `usage`: command grammar または invocation +- `syntax`: config / authoring / lock の parse または static grammar +- `type`: module、symbol、TypeScript typecheck +- `semantic`: declaration、assembly、Definition / bundle invariant、integrity +- `renderer`: Browser provisioning、capture、encode、renderer cancellation +- `io`: discovery、UTF-8 / filesystem、staging、atomic replacement、cleanup +- `cancel`: signal による cancel + +これらは host exception text、absolute temporary path、process ID を stable message に含めない。`usage` は exit code `2`、`cancel` は exit code `130`、`io` は exit code `3`、その他の family は exit code `1` とし、既存の `cli-invalid-usage` code は `usage` family に属する。 + +## Alternatives Considered + +### Config を実行する + +任意の JavaScript config は project discovery 時に process / filesystem / network capability を導入し、Authoring declaration を実行しない M1 boundary と矛盾するため採用しない。 + +### Output directory を削除してから再生成する + +途中失敗と cancel で previous artifact が失われる。managed generations と symlink replacement は公開状態を一つの commit point に限定できるため採用する。 + +### Lock から registry を参照する + +network availability と registry の mutable state が同一入力の再現性を壊す。M1 は自己完結 lock に限定し、distribution / plugin workflow は後続 milestone で扱う。 + +## Consequences + +- **Positive**: check は filesystem input を検証しても Browser を起動せず、build だけが Fixed Browser と artifact publication を所有できる。 +- **Positive**: lock、config、source、render environment が artifact identity を決め、失敗・cancel 時にも previous output を保持できる。 +- **Negative**: M1 は POSIX-only であり、symlink を使う既存 project や動的 config を受け入れない。 +- **Neutral**: `dist` は directory ではなく managed symlink となるため、利用者は artifact を読むだけで直接編集しない。 + +## Follow-ups + +- [ ] M3 以降で package distribution、migration metadata、より広い authoring DSL を contract と実装で接続する。 +- [ ] M4 以降で renderer cache、opaque module resolution、resource budget を接続する。 +- [ ] M6 以降で watch、preview、plugin discovery、cross-platform filesystem support を設計する。 diff --git a/docs/decisions/0014-presentation-rendering-scope.md b/docs/decisions/0014-presentation-rendering-scope.md new file mode 100644 index 0000000..8013b09 --- /dev/null +++ b/docs/decisions/0014-presentation-rendering-scope.md @@ -0,0 +1,66 @@ +# ADR-0014: Presentation の描画方式を限定する + +- **Status**: Accepted +- **Date**: 2026-09-15 +- **Deciders**: Unframe 開発チーム +- **Related**: [ADR-0006](./0006-presentation-rendering-strategy.md), [Presentation Architecture](../presentation/ARCHITECTURE.md), [描画方式の検証条件](../presentation/UI_RENDERING_COMPARISON.md), [ADR-0012](./0012-texture-budget-residency-contract.md) + +## Decision Drivers + +- Unity Runtime で任意の HTML、CSS、JavaScript を実行せず、配信物と実行時状態を検証可能にする。 +- Web の表現力は build 時の決定論的な描画に利用する。 +- 空間表現、静的 UI、限定的な動的 UI、事前確定した連続映像を、それぞれ適した実行責務へ分ける。 +- Quest 実機での frame、memory、thermal、fidelity を方式ごとに測定し、配信可能な範囲を段階的に確定する。 + +## Context + +Presentation Architecture は `baked-web`、`native-ui`、`video` を Surface renderer として記載し、任意コードを含む Opaque renderer source は隔離した Browser で build artifact へ変換する境界を持つ。一方、過去の比較文書には Unity 上の WebView で任意の HTML / JavaScript を実行する `embedded-web` も将来候補として残っていた。 + +実行時 WebView を許すと、Web runtime、Texture 転送、sandbox、subresource integrity、lifecycle、時刻と入力の bridge を新たな Runtime contract として管理する必要がある。本プロジェクトの対象用途を、これらを必要としない描画方式の組み合わせに限定する。 + +## Options Considered + +- build 時の Web 描画と、Unity native の 3D / UI / Video を組み合わせる。 +- Unity Runtime に `embedded-web` を追加し、任意の HTML / CSS / JavaScript を実行する。 +- Surface UI をすべて Unity Native UI で再実装する。 + +## Decision + +Presentation の描画方式を次に限定する。 + +| 対象 | 方式 | +| ------------------------------------ | ---------------------------------------- | +| 3D Model、空間配置、移動、回転、拡縮 | Unity Native 3D | +| 静的 UI、少数の有限 Surface State | `baked-web` | +| Timer、Counter、短い動的 Text | versioned portable subset の `native-ui` | +| 入力非依存で事前確定できる連続映像 | `video` | + +`baked-web` は固定した build-time Browser で TS / React / CSS を描画し、Texture artifact を生成する方式である。Opaque renderer の任意コード実行もこの隔離した build 境界に限定し、Unity Runtime、Control Plane、Realtime へ source や Browser runtime を配信しない。 + +`native-ui` は Timer、Counter、短い動的 Text に必要な portable plan と binding の allowlist に限定する。任意 CSS、任意 layout engine、任意 script、汎用 Web API は含めない。 + +`video` は映像内容を事前生成できる場合に使用する。開始・停止と再生時刻は Presentation の進行に従う。codec、alpha、audio、seek、loop、device capability は Delivery contract で検証する。 + +独立した音声、BGM、効果音、空間音声はプロジェクトの対象外とする。音声を使用できるのは Video artifact に含まれる audio track だけであり、Video と同じ再生状態と時刻に従う。独立音声用の Asset kind、Spatial Node、Action、capability、予約 field、adapter boundary は作らない。 + +`embedded-web`、WebView、および Unity Runtime で任意の HTML / CSS / JavaScript / WebAssembly を実行する方式はプロジェクトの対象外とする。`embedded-web` 用の artifact kind、capability、fallback、予約 field、adapter boundary を作らない。導入するには本 ADR を置き換える新しい ADR と、Runtime・Delivery・security・運用上の根拠が必要になる。 + +この決定は方式の責務と適用範囲を確定する。各方式の実装完了、Quest 実機性能、Delivery 対応を確定するものではない。特に ADR-0012 の v1 Delivery baseline は `baked-web` の Texture artifact だけを対象とし、`native-ui` と `video` は各方式固有の budget tier と consumer 実装が受理されるまで Delivery で拒否する。 + +## Consequences + +- **Positive**: Runtime が実行する artifact と portable plan を閉じた union として検証できる。 +- **Positive**: Web の layout と typography を build 時に利用しながら、Unity Runtime の Web runtime 依存を排除できる。 +- **Positive**: Surface の空間 animation は Unity、意味状態は PresentationDefinition、描画は RenderBundle という既存境界を維持できる。 +- **Negative**: 任意の実行時 Web UI、DOM animation、Web API、WebAssembly を Presentation Surface として利用できない。 +- **Negative**: 動的 UI は portable subset の範囲に合わせ、範囲外の表現は有限 State、Video、Unity の空間演出へ分解する必要がある。 +- **Follow-up**: `native-ui` と `video` の contract と budget を定義し、Unity consumer の実装・実機検証を経て配信可能条件を確定する。 + +## Adoption and Exceptions + +- Surface の具体的な renderer kind は `baked-web`、`native-ui`、`video` だけを許可する。PresentationDefinition の `rendererPreference: "auto"` は Compiler に選択を委ねる指定として維持する。 +- `embedded-web`、WebView、汎用 Web runtime を示す schema field、package、Unity adapter をレビューで受け入れない。 +- 独立音声を示す Asset、Spatial Node、Action、capability、adapter をレビューで受け入れない。Video の audio feature は実際に audio track を含む Video artifact だけに使用する。 +- build-time Browser の入力、capability、再現性、resource budget は Local Compiler の trust boundary として検証する。 +- Quest 実機検証では採用済み方式の性能と適用条件を測定し、方式の追加候補を比較しない。 +- 描画方式を追加する場合は新しい ADR で本決定を置き換える。 diff --git a/docs/decisions/0015-presentation-definition-artifact-boundaries.md b/docs/decisions/0015-presentation-definition-artifact-boundaries.md new file mode 100644 index 0000000..3d86b37 --- /dev/null +++ b/docs/decisions/0015-presentation-definition-artifact-boundaries.md @@ -0,0 +1,83 @@ +# ADR-0015: Definition と素材集合の構造境界を定める + +- **Status**: Accepted(構造境界。具体契約は Presentation v2) +- **Date**: 2026-09-15 +- **Related**: [Presentation Architecture](../presentation/ARCHITECTURE.md), [ADR-0007](./0007-timeline-runtime-run-wire-contract.md), [ADR-0014](./0014-presentation-rendering-scope.md), [ADR-0016](./0016-model-animation-scope.md), [Contracts Architecture](../../packages/contracts/ARCHITECTURE.md) + +## Context + +現行の Definition / RenderBundle schema は静的 Surface の初期 subset である。Timeline catalog は Definition に属することまで決定済みだが配置が未定であり、公開物が参照する `assetSetHash` の入力形式も定義されていない。 + +意味モデル、描画成果物、素材 descriptor、公開・配信・実行時の値を分離し、完全な schema を具体化する際の境界を固定する。 + +## Decision + +### PresentationDefinition + +コンパイル後の Definition は次の構造を持つ。 + +```text +PresentationDefinition +├─ schemaVersion +├─ presentationId +├─ metadata +├─ stage +│ ├─ coordinateSystem +│ ├─ size +│ └─ zones +├─ scene +│ ├─ nodes +│ └─ surfaces +└─ flow + ├─ initialGroupId + ├─ groups → steps → cues + ├─ variables + └─ timelines +``` + +- `scene.nodes` は空間配置、owner、audience、初期 transform / 表示状態を持つ。`scene.surfaces` は内容ツリー、意味情報、State、Interaction、Render Intent を持つ。 +- SurfaceNode と Semantic Surface の 1:1 対応、ID と owner の規則を維持する。描画 partition と Artifact ID は RenderBundle に属する。 +- `flow.timelines` は Timeline ID をキーとする catalog とし、ADR-0007 の意味論を維持する。再生中の Run、開始時刻、現在値は Definition に入れない。 +- Group / Step / Cue の構造と、Variable の型・初期値・owner は既存設計を維持する。現在の Group / Step と Variable の現在値は Runtime State に属する。 +- Component Instance、公開 Action / Output は compile 時に展開する。Theme の参照は必要な具体値へ解決し、編集用 Token catalog や Component package を Definition に残さない。描画に必要な内容ツリーと解決済み値は保持し、Theme の build provenance は RenderBundle に記録する。 +- 素材は使用箇所から `assetId` で参照する。現行の top-level `assets` descriptor 辞書は、下記 AssetSetManifest へ移す。 +- 独立 AudioNode、音声専用の素材・操作は持たない。動画に含まれる音声は Video の一部とする。 +- ModelNode は Model Asset の内蔵 animation clip を参照できる。再生の採用範囲と空間 Transform との境界は [ADR-0016](./0016-model-animation-scope.md) に従う。Clip ID の binding と Runtime contract は [Presentation v2](../presentation/DATA_MODEL.md) に定義する。 + +### AssetSetManifest + +素材 descriptor の正本を、Definition と RenderBundle から独立した immutable な manifest に置く。 + +```text +AssetSetManifest +├─ schemaVersion +└─ assets: Record + └─ AssetDescriptor + ├─ checksum 素材の実バイト列の SHA-256 + ├─ mediaType + └─ encodedSizeBytes 素材の実バイト数 +``` + +`assets` は Definition と RenderBundle が参照する素材と、その配信に必要な依存素材の集合とする。未参照の編集用素材、source code、build cache は含めない。画像、フォント、動画、モデル等を同じ catalog で識別し、素材内部の形式・依存・再生条件は各型の契約で検証する。 + +`assetSetHash` は manifest 全体の canonical JSON の SHA-256 とする。manifest は自身の hash、取得 URL、session ID、publication epoch、ローカル filesystem path を持たない。Asset ID が同じまま実バイト列が変わる場合も `assetSetHash` と公開物の hash が変わり、異なる素材集合として識別される。 + +RenderBundle / Delivery の texture 等の descriptor に checksum や size を含める既存契約は維持する。これらは manifest と実素材から生成し、同じ Asset ID の descriptor が異なる組み合わせは拒否する。形式別の寸法、codec、font metadata と予算は各 artifact 契約が所有する。 + +### Build、Publication、Delivery、Runtime + +- Compiler は Definition、RenderBundle、AssetSetManifest と素材の実体を生成する。参照の存在・型・依存の完全性は、この成果物の組に対して検証する。 +- Control Plane は整合する成果物の hash を PublishedPresentation に固定する。PublicationFence、publish lock、Session binding は既存設計を維持する。 +- DeliveryManifest は参加者・端末ごとの選択結果と取得 URL を持つ。Runtime State は現在値と実行中の Run を持ち、コンパイル成果物へ書き戻さない。 + +## Consequences + +Definition の意味が同じでも素材内容が変われば、素材集合と公開物の identity が変わる。公開物全体の整合性を Definition の hash だけで判定できない。 + +現行 `definition.assets` を移すため、完全版 schema への移行は互換変更ではない。schema / contract version、Compiler、Core validator、fixture、consumer を同じ変更系列で更新する。現行 M1 の出力に manifest が追加済みであるとは扱わない。 + +## Adoption and Follow-ups + +構造の実装時は Zod / JSON Schema と cross-artifact validation を更新する。生成済み schema を直接編集しない。 + +Model / Clip binding、素材 profile、Surface State、各 artifact、capability / budget、version の具体契約は [Presentation v2](../presentation/DATA_MODEL.md) に定義する。Compiler と consumer の移行はこの契約に従って実装する。 diff --git a/docs/decisions/0016-model-animation-scope.md b/docs/decisions/0016-model-animation-scope.md new file mode 100644 index 0000000..0211576 --- /dev/null +++ b/docs/decisions/0016-model-animation-scope.md @@ -0,0 +1,39 @@ +# ADR-0016: モデル内蔵アニメーションの範囲を定める + +- **Status**: Accepted(機能範囲と責務。詳細 schema / wire は Presentation v2) +- **Date**: 2026-09-15 +- **Related**: [Presentation Architecture](../presentation/ARCHITECTURE.md), [ADR-0015](./0015-presentation-definition-artifact-boundaries.md), [ADR-0007](./0007-timeline-runtime-run-wire-contract.md) + +## Context + +既存 Timeline は Spatial Node 全体の transform / opacity を制御する。人物や機械など、モデル内部の動きを再生するクリップは別の契約を必要とする。 + +## Decision + +- モデル素材に含まれるアニメーションクリップの再生を対象に含める。再生・停止・一時停止・再開、ループ、速度指定、即時切り替え、クロスフェードを扱う。 +- 再生状態は ModelNode ごとに独立する。同じ素材を参照する別 ModelNode も、異なるクリップや時刻で同時に再生できる。 +- 通常は一つの ModelNode で一つのクリップを評価する。クロスフェード中だけ移行元・移行先の二つを評価する。 +- 自然終了時はクリップの最終姿勢を保持する。明示停止時は停止要求を適用した時点の姿勢を保持する。 +- クロスフェード中に別クリップの再生・切り替えを要求した場合は拒否し、自動 queue しない。 +- 部位マスク、アニメーションレイヤー、加算合成による同一モデル内の重ね合わせはプロジェクトの対象外とし、予約 field や拡張口を設けない。 +- クリップはモデル内部の姿勢を更新する。build 時に root motion を除去または無効化し、ModelNode の位置・回転へ適用しない。安全に変換・検証できない素材は build で拒否する。ModelNode 全体の transform は既存の Node 操作 / Timeline が所有し、内蔵クリップと並行して実行できる。具体的な変換手法と対象素材形式は後続で決める。 + +### Definition と Runtime の分離 + +Definition は対象 ModelNode と使用するクリップ・再生条件を意味的に参照する。素材とクリップの対応は公開物に固定し、Unity の実行時 object ID を使用しない。 + +Runtime Core が再生・切り替え等を確定し、Unity は配信済みクリップと canonical な再生状態・pause-aware logical time から内部姿勢を評価する。通常の同期は骨格の各関節値を毎 frame 配信する方式にしない。途中参加・再接続・Session pause / resume でも同じ再生状態から表示を復元できる契約とする。 + +再生状態には対象、クリップ、再生位置の基準、速度、ループを、クロスフェード中には移行元・移行先と遷移の基準を含める。自然終了または明示停止で Run を除去した後も保持姿勢を途中参加・再接続で復元できる情報を canonical state に残す。具体的な field、Action、Run、Event、Snapshot の追加は後続の契約で定義し、既存 Timeline / Video の wire がそのままモデルクリップを扱うとはみなさない。 + +## Consequences + +空間配置と内部姿勢の書き込み先が分かれ、歩行クリップと移動 Timeline を組み合わせられる。足滑りなどの見た目は、クリップ速度と移動量を合わせて検証する必要がある。root motion を位置移動の正本とする素材を、そのまま配置制御へ使用することはできない。 + +通常時の一クリップと遷移時の二クリップに制限するため、歩行と上半身の動きを独立したレイヤーで重ねる表現は扱わない。 + +## Adoption and Follow-ups + +現行 Model loader、Presentation schema、Runtime protocol にこの機能が実装済みとは扱わない。実装時に再生・pause / resume・自然終了 / 明示停止の姿勢保持・切り替え・crossfade 中の要求拒否・途中参加の fixture と、ModelNode transform をクリップが変更しないことを検証する。 + +配布形式、クリップ ID の対応、ループ・完了通知、保持姿勢と crossfade 停止後の切り替えは [Presentation Data Model](../presentation/DATA_MODEL.md) に従う。形式別 profile / resource budget と Action / Run / Event / Snapshot の schema / wire も同契約に定義する。後続の実装は importer、root motion 変換・検証、Runtime と consumer の接続である。対象外のレイヤーや合成機能でこれらを回避しない。 diff --git a/docs/plans/presentation-packages-implementation-plan.md b/docs/plans/presentation-packages-implementation-plan.md new file mode 100644 index 0000000..ae61f8a --- /dev/null +++ b/docs/plans/presentation-packages-implementation-plan.md @@ -0,0 +1,472 @@ +# Presentation Packages Implementation Plan + +- **Status**: Active +- **Date**: 2026-08-29 +- **Scope**: `packages/` に存在する Presentation 関連 package、共有 contract、生成 client、repository tooling +- **Current Goal scope**: Milestone 1 完了まで。Milestone 3〜6 は未完了の後続 Goal として保持する +- **Architecture source**: + - [Presentation Architecture](../presentation/ARCHITECTURE.md) + - [Presentation Implementation Design](../presentation/DESIGN.md) + - [ADR-0006](../decisions/0006-presentation-rendering-strategy.md) + - [ADR-0014](../decisions/0014-presentation-rendering-scope.md) + +## 1. 目的 + +現在の `packages/` は、Presentation package chain の初期 subset と、Authoring Source から実際の build artifact を生成する M1 Local Compiler を実装済みである。完全版のデータ契約は [Presentation v2](../presentation/DATA_MODEL.md) に定義する。Compiler / Core の v2 移行、Delivery の実行処理、C# generation は未完成である。 + +本計画は、未実装事項を package ごとの独立した TODO として消化するのではなく、各段階で利用可能な結果を残す縦断的な milestone として整理する。 + +この文書は実装順と完了条件を示す計画であり、型、wire field、budget、generator などの未決定事項を新たに確定する設計文書ではない。設計上の正本は上記 Architecture、Design、ADR、および各 package の `ARCHITECTURE.md` とする。 + +## 2. 現在地 + +Presentation Implementation Design が定義する初期実装順のうち、`packages/contracts/presentation` から `presentation-cli` までの M1 subset は実装済みである。その縦断経路と、先に残る分断は次のとおりである。 + +```text +presentation.unframe.tsx + ↓ 実装済み: virtual resolution、typecheck、Static DSL lowering、assembly +PresentationDefinition + ↓ 実装済み: Fixed Browser capture と deterministic PNG encode +RenderBundle + PNG + ↓ 実装済み: filesystem CLI の managed staging / atomic dist replacement +M1 local artifact + ↓ 未接続: cache、publish / delivery +consumer +``` + +### 2.1 Package inventory + +| Package | Current | 主な未実装 | +| ------------------------------------ | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `packages/contracts` | Control Plane OpenAPI、PresentationDefinition / baked-web RenderBundle 初期 schema、Realtime foundation | Cue / Trigger / Action / Timeline、DeliveryManifest、Snapshot / State Stream、完全な Runtime contract、cross-language fixture | +| `packages/api-client-csharp` | 生成先の責務を定義した placeholder | OpenAPI / Protobuf generator、C# artifact、compile / test、drift check、Unity 接続 | +| `packages/api-client-typescript` | Hono RPC と Better Auth client | Presentation CLI の publish adapter との接続。README の依存 version 記述の同期 | +| `packages/presentation-core` | 初期 Definition / RenderBundle 検証、Semantic Tree materialization、canonical JSON / hash | Cue / Action / Timeline、Projection、Runtime Snapshot、migration、完全な lifetime / visibility closure | +| `packages/presentation-authoring` | Manifest、Structure、Theme、Presentation declaration API | Static DSL の確定、Source との接続、Lossless Syntax Tree / source patch、lock / distribution | +| `packages/presentation-components` | static な標準 Surface / Frame / Text | Props / Slots / Variants、型付き Theme、Spatial、Interaction、Action / Output、Opaque component、migration | +| `packages/presentation-compiler` | virtual project resolution / typecheck、Static DSL lowering / normalization、assembly、Sourceからcompile | cache、M1より広いStatic DSL | +| `packages/presentation-renderer-api` | baked-web 初期 plugin contract と conformance harness | discovery / version negotiation、cancel / timeout / resource budget、Native UI / Video capability | +| `packages/presentation-renderer-web` | injected / Playwright Fixed Browser adapter による Frame / Text capture、Opaque bundle | Opaque execution / isolation、state variation、generic Primitive、interaction geometry | +| `packages/presentation-assets` | deterministic memory-only PNG encoder | resize、mipmap、font subset、video / model adapter、temporary workspace、cache | +| `packages/presentation-cli` | filesystem check / build、process signal / build lock、atomic output、TUI command selector | TUIとprocess commandの接続、watch / dev / preview / test / publish | +| `packages/config` | TypeScript 基底設定、Vite+ 設定、Git hooks | `pre-commit` と `vp staged` の接続、package check / test、共有 lint / formatter policy、CI filter 整備 | + +## 3. 実装原則 + +### 3.1 縦断スライスで進める + +Semantic 機能は、原則として次の依存順を一つの変更系列として実装する。 + +```text +contracts + → presentation-core + → presentation-authoring + → presentation-components + → presentation-compiler + → renderer / assets + → CLI / reference project +``` + +ある package だけに将来用の field や仮実装を追加しない。現在対応しない入力は stable diagnostic で明示的に拒否する。 + +### 3.2 Contract と application の責務を混在させない + +次は package の実装計画には含めず、package contract 完成後の application-owned work とする。 + +- Control Plane の Build / Publication / Delivery persistence +- Realtime の authoritative progression evaluator +- Unity の renderer graph、asset cache、reconnect lifecycle +- Web Editor の GUI authoring integration + +### 3.3 Current と Target を区別する + +初期 subset の成功を Target architecture 全体の完成として扱わない。README は利用可能な Current behavior を記載し、Target / Deferred は `ARCHITECTURE.md` と本計画に残す。 + +### 3.4 TDD と品質ゲート + +各スライスは Explore → Red → Green → Refactor で進める。作業途中では対象 package の check を実行し、予定した変更をすべて終えた後に repository-wide gate を実行する。 + +## 4. Milestone 0: 品質基盤の補修 + +### 目的 + +以降の package 変更に対し、drift、生成漏れ、hook の未接続、CI path filter 漏れを確実に検出できる状態を作る。 + +### 実装項目 + +1. Git hook の ownership を Vite+ dispatcher または repository-owned hooks のどちらかへ一本化する。 +2. `pre-commit` から対象を限定した `vp staged` を実行する。 +3. `packages/config` に hook fixture、設定解決 test、`check` script を追加する。 +4. 共有 oxc lint / formatter policy と runtime 別 TypeScript config の必要性を確定する。 +5. `packages/contracts` の typecheck / lint 対象を実在 source と全 generator に合わせる。 +6. `packages/api-client-csharp/**`、共有 scripts、Presentation Autofix の CI path filter を追加する。 +7. Git LFS hook が要求する toolchain を Nix environment と一致させる。 + +### 完了条件 + +- `packages/config` の単独 check が成功する。 +- staged file に対して formatter / lint が実際に起動することを fixture で検証できる。 +- Contracts source、generator、generated artifact の drift を CI が検出する。 +- package または共有 script のみを変更した場合も、必要な CI job が起動する。 + +### 完了記録(2026-08-29) + +- repository-owned `pre-commit` から実共有設定の `vp staged` を起動し、staged TypeScriptを整形するfixtureを追加した。 +- 共有TypeScript baseをES-onlyにし、DOMを必要とするconsumerだけが`lib`を明示する境界へ変更した。これに伴い、Coreのambient runtime global依存をpure implementationへ置換した。 +- Contractsの全source / generator / testをtypecheck・lint対象にし、既存のOpenAPI / Presentation drift checkを維持した。 +- `packages/config`、共有script、Presentation Autofix、C# client、Git LFS toolchainのCI / Nix到達性を補修した。 +- `pnpm --filter @unframe/config check`、`pnpm --filter @unframe/contracts check`、`nix run .#presentation`、Control Plane / Webの対象typecheck、`nix flake check`が成功した。 +- 独立再レビューでcorrectness、security、data loss、contract drift、Architectureに関する確認済みの未対応blockerがないことを確認した。 + +## 5. Milestone 1: Local Compiler の最初の完成形 + +### 目的 + +`.unframe.tsx` から deterministic な PresentationDefinition、RenderBundle、PNG Asset Set を生成する最初の実用可能な縦断経路を完成させる。 + +### 5.1 Reference Authoring Project + +- `examples/presentation/` に最小の `presentation.unframe.tsx`、Theme、Component、config、lock fixture を置く。 +- reference project を production の暗黙 default にせず、公開された acceptance fixture として扱う。 +- Source、Definition、RenderBundle、PNG の golden expectation を分離する。 + +### 5.2 Compiler source frontend + +- 明示的な project root と virtual filesystem input を定義する。 +- module / symbol resolution と TypeScript typecheck を実装する。 +- Static Authoring DSL の許可構文を検証する。 +- AST から Declaration Graph へ context-specific lowering する。 +- Source Map と stable source diagnostic を保持する。 +- Declaration Graph を現行の post-lowering `PresentationDeclaration` 相当へ normalize する。 +- Orchestrator、Theme、Manifest、Structure を実行しないことを回帰テストで保証する。 + +### 5.3 Fixed Browser build + +- Browser binary と version の provisioning 方針を決定する。 +- process lifecycle、fixed locale / timezone / font / viewport / color space を adapter に固定する。 +- network、filesystem、clock、randomness を既定 deny にする。 +- 現行 Frame / Text subset を実 Browser で capture する。 +- Browser identity と environment を renderer fingerprint、cache key、RenderBundle provenance に結合する。 + +### 5.4 CLI filesystem boundary + +- project discovery、`unframe.config.ts`、`unframe.lock` の読取り境界を [ADR-0013](../decisions/0013-local-compiler-project-filesystem-contract.md) に従って実装する。 +- `check` は Browser を起動せず Source frontend まで検証する。 +- `build` は `definition.json`、`render-bundle.json`、`assets/*.png` だけを一時 staging directory へ生成し、成功時だけ root 固定の`dist`をatomicに置き換える。manifest / Delivery artifactはM1の出力に含めない。 +- signal、Browser cleanup、失敗時の partial output 非公開を検証する。 + +### 5.5 実装進捗と未決定事項 + +2026-08-29 時点で、Compiler source frontend のうち次を実装済みである。 + +- logical project root と root-relative TS / TSX / declaration file の safe snapshot、syntax diagnostic +- locked virtual package の identity、file、exact export、direct dependency 検証 +- project / package owner ごとの relative import と direct locked package resolution +- virtual-only strict ES2022 typecheck と owner-aware stable diagnostic +- TypeChecker alias、package identity、exact export、declaration owner に基づく named value symbol provenance +- provenance 検証済み builder import、direct default root、JSON-like expression の Static DSL validation +- builder 実装を実行しない、UTF-16 source origin 付き個別 declaration file の plain Declaration Graph lowering +- builder call を実行しない単一 Declaration Graph の plain declaration value normalization +- normalized JSON path と value / property key / generated field origin を対応させる deterministic sidecar source map +- entry、Theme、Manifest、Structure の file role と root builder を照合する project-wide declaration collection +- package-owned source と補助 `.d.ts` を分離し、全 project file の失敗を集約する canonical diagnostic +- Authoring builder と共有する pure declaration guard による post-lowering validation(builder implementation 非実行) +- Object.prototype / null-prototype の plain data を descriptor-only で snapshot し、inherited getter と Proxy `get` trap を実行しない Compiler input boundary +- Manifest の `authoring.structure` entry を正本に、Presentation、Theme、Component `(componentId, version)`、Structure を source map 付きで決定論的に対応付ける declaration catalog +- parse、typecheck、lower、normalize、collect、pair を接続し、TypeScript内部状態を漏らさず plain catalog または source diagnostic を返す公開 `checkAuthoringProject` + +source map付きcatalogと明示的なTheme hash、Component package lock、Asset carrierを一意に対応付け、canonical `CompilerDeclarationProject`へ変換するpure assemblyを実装した。Source frontendからassembly / compileへの公開compositionも接続し、source / assembly / compileの失敗phaseを区別してchecked Definitionを再利用する。 + +Fixed Browser は `playwright-core@1.62.1` のmanaged Chromium headless shellを明示provisionし、Nixでruntime libraryとNoto Sans CJK JP fontconfigを固定する。adapterはcaptureごとの隔離context、network / filesystem deny、fixed clock / random、PNG decode前resource preflight、signal / cancel / close cleanupを所有する。font fingerprintは固定glyph baselineの実capture RGBAから内部導出し、Browser versionとともにrenderer identityへ反映する。通常package checkは実Browserを起動せずunit testを実行する。 + +2026-08-30 に [ADR-0013](../decisions/0013-local-compiler-project-filesystem-contract.md) の M1 filesystem contractを実装した。 + +- absolute real project root、data-only config、serialized `unframe.lock` v1、package / declaration integrityをfail closedで検証する。 +- `check` はBrowser factoryを読まず、`build`だけがFixed Browserを起動する。 +- project build lock、generation staging、成功時だけのatomic `dist` replacementにより、失敗時はpartial outputを公開しない。 +- Bun process entryが単一signalを所有し、cancel、late Browser launch、Browser cleanup、lock releaseをstable diagnosticへ反映する。 +- `examples/presentation`をtemp copyへ2回実buildし、Definition、RenderBundle、全PNGのrelative pathとSHA-256が一致するacceptanceを`nix run .#presentation`へ接続した。 +- `nix flake check`、`nix run .#presentation`、repository-wide `nix run .#check`が成功した。 + +Static DSL は 2026-08-29 に次の M1 contract で確定した。 + +- 各 declaration file は対応する public definition builder の直接呼出しを default export する。 +- builder 引数は JSON-like literal と provenance 検証済み named builder call だけを許可する。 +- JSX、任意関数、loop / branch、dynamic import、property access、spread、template expression、local builder alias は stable diagnostic で拒否する。 +- named import alias は元の locked package export provenance を保持する場合に限り許可する。 +- JSX-first authoring とより広い static expression は M1 より後へ延期する。 + +JSX-first authoring とより広い static expression は M1 に含めず、Milestone 3以降の後続Goalまで明示的にdeferする。M1実装の独立Review / Fix loopでは、確認済みの未対応P1/P2がない状態まで収束した。 + +### 完了条件 + +- [x] reference `.unframe.tsx` から CLI で Definition、RenderBundle、PNG を生成できる。 +- [x] 同一 toolchain と同一入力を二回 build した artifact hash が一致する。 +- [x] Authoring declaration codeを実行せずに buildできる。 +- [x] `usage`、syntax、type、semantic、renderer、I/O、cancel の failure family が区別された stable diagnostic になる。 +- [x] `nix run .#presentation` と repository-wide `nix run .#check` が成功する。 + +## 6. Milestone 2: Blocking contract の確定 + +実装前に、Presentation Architecture が指定する順序で次の contract を一つずつ確定する。 + +1. [x] Timeline の補間、停止理由、Runtime Run lifecycle の semantic wire contract([ADR-0007](../decisions/0007-timeline-runtime-run-wire-contract.md)、transport protobuf schema は Draft・未実装) +2. [x] Reliable Event / Snapshot / State Stream の transport schema、保持期間、runtime microstep 上限([ADR-0008](../decisions/0008-runtime-transport-contract.md)) +3. [x] role 別 Semantic Tree / Hit Region schema([ADR-0009](../decisions/0009-semantic-tree-hit-region-contract.md)) +4. [x] Transform、Quaternion、matrix、Unity、Surface / UV の座標規約([ADR-0010](../decisions/0010-spatial-surface-coordinate-contract.md)) +5. [x] Surface Partition と author override([ADR-0011](../decisions/0011-surface-partition-contract.md)) +6. [x] Texture state artifact数、GPU / RAM build budget([ADR-0012](../decisions/0012-texture-budget-residency-contract.md)) + +各項目では、意味、authority、source of truth、wire field、failure、compatibility、consumer の責務まで決める。判断を必要とする次項目へ進む前に、対応する Architecture / ADR を更新する。 + +### 6.1 Timeline / Runtime Run contract 監査 + +2026-08-29 時点で、Timeline の補間式、easing、number / Vector3 / Quaternion、Pause / Resume、完了時 commit、`explicitStop` / `groupExit` / `presentationEnded` の停止規則、Runtime Core authority は Architecture に定義済みである。一方、現行 Presentation contract は Timeline catalog を持たず、Realtime Protobuf は Handshake / PageChange foundation だけであるため、wire contract は未実装である。 + +Timeline catalog、local interpolation、State Stream の非 Timeline 限定、`RuntimeRunId`、lifecycle payload、reason、projection、capability policyは [ADR-0007](../decisions/0007-timeline-runtime-run-wire-contract.md) で semantic wire contract として Accepted とした。transport protobuf schema は Draft・未実装であり、現行 `realtime.proto` は foundation のままである。互換 downgrade fallback は追加しない。 + +Reliable Event / Snapshot / State Stream の envelope、field number、replay / catch-up / idempotency window、State keyframe、runtime microstep 上限は [ADR-0008](../decisions/0008-runtime-transport-contract.md) で Accepted とした。M2 では設計だけを固定し、現行 foundation proto の置換、Go / C# generation、cross-language fixture は semantic payload が揃う M5 で一括実装する。 + +Semantic roleごとのstrict union、Definition / Completed treeの分離、required owned structure、Stateごとのbutton enabled導出、normalized Hit Regionのbounds / priority / overlap / tie-breakは [ADR-0009](../decisions/0009-semantic-tree-hit-region-contract.md) でAcceptedとした。current flat schemaはM3 Slice Bでgenerated schema、Core、Compiler、renderer、fixtureと同時に置換する。 + +Spatial TRS / matrix合成、Quaternion canonical sign、Canonical↔UnityのZ reflection、Semantic Surface logical / physical fit、Render Surface / raster / UV / Hit Region変換は [ADR-0010](../decisions/0010-spatial-surface-coordinate-contract.md) でAcceptedとした。M2では設計だけを固定し、cross-language fixtureとContracts / Core / Compiler / renderer / Unity実装はM3〜M5で接続する。 + +Surface Partitionのcanonical paint run、required renderer / compositing boundary、公開Partの`isolate` override、state-invariant bounds / layer、derived ID、cross-partition Hit Region aggregateは [ADR-0011](../decisions/0011-surface-partition-contract.md) でAcceptedとした。current Compilerは一Surface一partitionのM1 subsetであり、target implementationはM3〜M4で接続する。 + +Texture state artifact数、2K resolution、PNG / RGBA32、mipmapなし、Compiler aggregate budget、Delivery GPU / load CPU tier、全State preload、readiness、active pin / LRU evictionは [ADR-0012](../decisions/0012-texture-budget-residency-contract.md) でAcceptedとした。current実装はper-encode PNG hard capだけを持ち、Compiler / Delivery / Realtime / Unityへのtarget実装はM3〜M5で接続する。 + +Native 3D、`baked-web`、限定 `native-ui`、`video` の責務と、Runtime Web を対象外にする境界は [ADR-0014](../decisions/0014-presentation-rendering-scope.md) でAcceptedとした。方式の採用は実装や実機性能の完了を意味しない。現行 Local Compiler は `baked-web` 初期 subset だけを実装し、ADR-0012 の v1 Delivery baseline も `baked-web` だけを対象とする。Native UI と Video は固有 budget と consumer が受理されるまで Delivery で拒否する。 + +M2のblocking contract 6項目はすべてAcceptedとなった。今回のGoalはM1 project assembly / reference Browser / CLIの完了までに限定した。M3〜M6は後続Goalとして未完了のまま保持し、このGoalでは実装を開始しない。 + +### 完了条件 + +- PresentationDefinition、RenderBundle、DeliveryManifest、Runtime State の境界が混在していない。 +- TypeScript、Go、C#、Unity のどの consumer が何を検証するかが明記されている。 +- unknown field、unknown enum、unsupported version の compatibility policy が定義されている。 + +## 7. Milestone 3: Semantic contract の拡張 + +次の順で縦断スライスを追加する。各スライスは Contractsからreference projectまでを同じ変更系列で接続する。 + +### Slice A: Theme と Structured composition + +- Token category、Named Style property schema +- Props / Slots / Parts / Variants の値注入 +- nested Structured Primitive +- component package lock、integrity、migration metadata +- generic rendererによるstyle解決 + +### Slice B: State、Interaction、Hit Region + +- State visual variation +- Interaction declaration と enabled interaction +- role別完成Semantic Tree +- normalized Hit Regionとresolved geometry +- renderer captureとsemantic bindingのcompleteness検証 + +### Slice C: Action、Output、Trigger、Cue + +- Component Action / Output からcanonical Action / Triggerへのlowering +- flat Scalar payloadとactor / subject resolution +- Guard、Cue consumption、Step entry rule +- transition-only Cueと空Action列 + +### Slice D: Timeline とRuntime projection + +- Timeline / Runtime Run model +- transition conflict、completion、stop reason +- ProjectionAudience、ProjectionProfile、ParticipantRuntimeView +- CanonicalRuntimeSnapshotのportable fixture + +### 完了条件 + +- 各sliceにvalid / invalid portable fixtureがある。 +- ContractsのZod/JSON Schema、Core invariant、Compiler loweringが同じ意味を検証する。 +- unsupported featureを拒否する暫定分岐が、そのsliceでは削除されている。 +- reference projectで新しい機能をbuildし、deterministic artifactを生成できる。 + +## 8. Milestone 4: Renderer とAsset pipeline の拡張 + +### Renderer + +- generic Structured Primitive graph +- Opaque React / CSS bundleとisolated Browser executionの接続 +- module / capability allowlist +- timeout、cancel、CPU / memory budget +- plugin discovery、version negotiation、cache invalidation +- visual regression baseline +- `embedded-web`、WebView、Runtime HTML / JavaScript renderer は対象外とし、拡張口を追加しない + +### Assets + +- resizeとresolution policy +- mipmap、texture compression、color / alpha conversion +- font resolution、subset、glyph coverage +- temporary workspaceと成功・失敗・cancel時cleanup +- content-addressed cache +- 必要なconsumerが確定した後のvideo / model adapter + +### 完了条件 + +- renderer / font / locale / config変更がcache identityへ反映される。 +- Opaque codeがallowlist外のnetwork、filesystem、moduleへアクセスできない。 +- raw capture、encoded artifact、descriptor checksumが一致する。 +- fixed baselineでvisual regressionを検出できる。 + +## 9. Milestone 5: Delivery / Runtime contract とC# generation + +### 9.1 Protocol Buffers + +- `proto/unframe/delivery/v1/delivery.proto` +- DeliveryManifest、ProjectionProfile、ProjectionInstance、AssetAccessBinding +- PublicationFence、assignment、origin version +- Reliable Event、ConnectionSnapshotEnvelope、DurableCheckpointEnvelope +- State Stream / State Frame、Snapshot / Replay / Resume +- capability negotiation + +### 9.2 Generated consumers + +- Go generated artifactとdrift check +- OpenAPI / Protobuf C# generatorとversion固定 +- source contract / generator provenance +- generated namespace / assembly boundary +- standalone C# compile / test +- TypeScript / Go / C# encode / decode conformance fixture +- breaking-change report + +### 9.3 Unity handoff + +- Unity Package Managerまたはgenerated source配置方式を決定する。 +- generated clientをUnity-owned adapterから利用する。 +- 既存 `PresentationImport/` はtransitional implementationとして維持し、一括置換しない。 + +### 完了条件 + +- Contract sourceからGo/C# artifactを再現可能に生成できる。 +- generated artifactの手編集とdriftをCIが検出する。 +- shared fixtureをTypeScript、Go、C#で相互運用できる。 +- Unity consumerが必要とするfence、projection、asset descriptorの型を失わない。 + +## 10. Milestone 6: CLI UX とapplication integrationへの接続 + +### CLI + +- `init`、`dev`、`test`、`preview` +- watch / incremental build +- local build cache +- plugin discoveryと`unframe.lock` +- credentialを保持しないControl Plane publish adapter +- TUI command selectionとheadless commandの接続 + +### Application handoff + +- Control Plane Build / Publication / Delivery +- Realtime Progression / Runtime protocol +- Unity Presentation Runtime +- Web EditorからLocal Compilerを呼ぶexecution topology + +`publish` commandはControl Plane側のPublication contractとactive-use lockが実装されるまで仮APIを作らない。Web Editor、Realtime、Unityのapplication実装は、それぞれのowning directoryの計画と品質ゲートで進める。 + +### 完了条件 + +- local authoring、check、build、previewが同じproject/config/lockを使用する。 +- local buildとpublish対象artifactのhashが一致する。 +- application integrationがpackage内部moduleへdeep importしない。 + +## 11. 横断的な検証戦略 + +| 境界 | 必須検証 | +| ---------------------------------- | ---------------------------------------------------------------- | +| Contract source → generated schema | drift check、valid / invalid fixture、compatibility check | +| Contract → Core | reference、tree、lifetime、canonicalization、diagnostic ordering | +| Source → Compiler | syntax、symbol、type、forbidden DSL、source map、非実行保証 | +| Compiler → Renderer | capability、provenance、input immutability、state completeness | +| Renderer → Assets | RGBA ownership、geometry、checksum、determinism | +| CLI → filesystem / Browser | atomic output、cleanup、signal、partial failure | +| TypeScript → Go / C# | shared fixture encode / decode、unknown field、version mismatch | + +作業中は変更対象のpackage checkを実行する。全変更終了後に次を実行する。 + +```sh +nix run .#presentation +nix run .#control-plane # Control Plane contractを変更した場合 +nix run .#realtime # Protobuf / Realtime contractを変更した場合 +nix flake check # Nix設定を変更した場合 +nix run .#check +``` + +Unity behaviorを変更した場合は、repositoryのstatic checkとは別にUnity EditorのEditMode / PlayMode testを実行する。 + +## 12. Review / Fix loop + +各MilestoneとSemantic sliceは、最初の実装とtest成功だけで完了にしない。変更範囲ごとに次のloopを実行し、確認済みの指摘が収束するまで繰り返す。 + +```text +Explore / Red / Green / Refactor + ↓ +対象限定check + ↓ +実装者によるdiff、contract、境界のself-review + ↓ +独立したreview + ↓ +指摘をcode、invariant、regression testに照らして検証 + ↓ +確認できた問題を修正し、必要なtestを追加 + ↓ +対象限定checkと変更箇所の再review + └──────── 未解決の確認済み指摘があれば繰り返す +``` + +### 12.1 Reviewの観点 + +- Architecture、ownership、依存方向に違反していないか。 +- Contract source、generated artifact、consumerが同期しているか。 +- Currentで未対応の機能を暗黙fallbackや任意objectで受理していないか。 +- trust boundaryでaccessor、Proxy、mutation、host callback、untrusted bytesを安全に扱っているか。 +- canonicalization、hash、diagnostic順序、artifact生成が決定的か。 +- failure、cleanup、cancel、partial output、resource budgetのtestがあるか。 +- public API、README、ArchitectureのCurrent記述が実装と一致しているか。 +- task外のrefactoringや将来用抽象化が混入していないか。 + +可能な場合、実装担当とは別のreviewerまたは調査sub-agentにreviewを依頼する。同じfileを複数担当が同時編集せず、reviewerは指摘と根拠の提示に限定し、修正担当を一つに保つ。 + +### 12.2 指摘への対応 + +Reviewコメントはそのまま採用せず、現在のcode、contract、invariant、再現testに対して妥当性を確認する。 + +1. 再現可能な問題には、原則として先に失敗するregression testを追加する。 +2. Contractの曖昧さが原因なら、実装上の推測で埋めずArchitectureまたはADRの判断へ戻す。 +3. 確認できなかった指摘は、検証内容と不採用理由をreview記録に残す。 +4. 修正によって新しく触れた範囲も再review対象に含める。 +5. testのskip、lint抑制、互換fallbackだけで指摘を閉じない。 + +### 12.3 Loopの終了条件 + +次をすべて満たした時点で、そのMilestoneまたはsliceのreview loopを終了する。 + +- correctness、security、data loss、contract drift、architecture違反に関する確認済みの未対応指摘がない。 +- 追加・変更したbehaviorをregression testが固定している。 +- 対象packageのtypecheck、lint、test、generation / drift checkが成功している。 +- generated artifact、fixture、README、Architectureの必要な更新が同期している。 +- 残る論点は、理由、影響、後続Milestoneを明記して明示的にdeferされている。 + +Review / Fix loop中は対象限定checkを使用する。loopが収束し、予定した変更をすべて終えた後に一度だけrepository-wide `nix run .#check`を実行する。requested commitがある場合は、この最終checkと最終diff reviewの成功後にcommitする。 + +GoalでMilestoneを実行する場合も、この終了条件をGoalの完了条件に含める。設計判断待ちを暫定実装で回避せず、判断が必要な時点で進捗、選択肢、影響範囲を提示して停止する。 + +## 13. 実行順チェックリスト + +- [x] Milestone 0: 品質基盤の補修 +- [x] Milestone 1: `.unframe.tsx`からartifactまでのLocal Compiler縦断経路 +- [x] Milestone 2: Blocking contractの確定 +- [ ] Milestone 3A: ThemeとStructured composition +- [ ] Milestone 3B: State、Interaction、Hit Region +- [ ] Milestone 3C: Action、Output、Trigger、Cue +- [ ] Milestone 3D: TimelineとRuntime projection +- [ ] Milestone 4: RendererとAsset pipeline +- [ ] Milestone 5: Delivery / Runtime contractとC# generation +- [ ] Milestone 6: CLI UXとapplication integrationへの接続 + +各milestoneを開始する前に、前段の成果物、未解決判断、品質ゲートを再確認する。後段でしか必要にならない抽象化、互換層、仮のconsumerは先行実装しない。 diff --git a/docs/presentation/ARCHITECTURE.md b/docs/presentation/ARCHITECTURE.md index e109cc8..8633548 100644 --- a/docs/presentation/ARCHITECTURE.md +++ b/docs/presentation/ARCHITECTURE.md @@ -6,15 +6,21 @@ - **Maturity**: - Architecture baseline: adopted - Presentation Progression semantic model: v1 baseline - - Progression wire / Runtime contract: draft - - Authoring、Rendering、Delivery の下位契約: follow-up + - Progression semantic wire contract: Accepted(v2 Protobuf 定義済み、consumer は未接続) + - コンパイル後の構造・配信・Runtime: Presentation v2。Authoring / build host の実装設計は後続 - **Related**: - [Presentation Implementation Design](./DESIGN.md) + - [Presentation Surface 描画方式の検証条件](./UI_RENDERING_COMPARISON.md) - [ADR-0005: 空間プレゼンテーションのドメインモデルを定義する](../decisions/0005-spatial-presentation-domain-model.md) - [ADR-0006: プレゼンテーションアーキテクチャを定義する](../decisions/0006-presentation-rendering-strategy.md) + - [ADR-0014: Presentation の描画方式を限定する](../decisions/0014-presentation-rendering-scope.md) + - [ADR-0015: Definition と素材集合の構造境界を定める](../decisions/0015-presentation-definition-artifact-boundaries.md) + - [ADR-0016: モデル内蔵アニメーションの範囲を定める](../decisions/0016-model-animation-scope.md) - [Repository Architecture](../../ARCHITECTURE.md) - [Server Architecture](../../app/server/ARCHITECTURE.md) +具体的なデータ構造と実行規則は [Presentation v2 データ契約](./DATA_MODEL.md) を正本とする。本書の旧型例と異なる場合は v2 を優先する。 + ## 1. この文書の位置付け この文書は、Unframe のプレゼンテーションについて、コード、GUI、描画、配布、Runtime を一つの形式へ押し込まず、共通の意味モデルを中心に接続する目標アーキテクチャを定義する。 @@ -83,7 +89,7 @@ Session path の authority は Cloud または Venue Edge に配置された割 - Component の公開契約と renderer 実装を分離する。 - GUI と Code は同じ Semantic Authoring IR を編集する。 - GUI が任意の TSX、CSS、JavaScript を完全に逆解析できるとはみなさない。 -- Local Compiler は Presentation Orchestrator、Theme Declaration、Component Manifest、Component Structure を parse / typecheck し、検証済み AST から Declaration Graph へ静的に lower する。これらを JavaScript として実行しない。 +- Local Compiler は Presentation Orchestrator、Theme Declaration、Component Manifest、Component Structure を parse / typecheck し、検証済み AST から Declaration Graph へ静的に lower する。これらを JavaScript として実行しない。M1 の project discovery、config / lock、hash、managed output は [ADR-0013](../decisions/0013-local-compiler-project-filesystem-contract.md) を正本とする。 - Opaque renderer だけが通常の TS / React / CSS を bundle して renderer artifact を生成する。renderer の実行環境は Authoring DSL の lowering から分離する。 - Control Plane と Unity Runtime は Authoring Source、React、CSS、renderer source を実行しない。 - PresentationDefinition JSON、Texture、Video、Protobuf は生成物であり、編集元の正本にはしない。 @@ -134,16 +140,28 @@ Presentation の意味モデルである。 ```text PresentationDefinition -├─ Metadata -├─ Stage / Coordinate Space / Zone -├─ Semantic Scene Graph -├─ Group / Flow / State / Action -├─ Semantic Surface -├─ Interaction -└─ Opaque Asset References +├─ schemaVersion +├─ presentationId +├─ metadata +├─ stage +│ ├─ coordinateSystem +│ ├─ size +│ └─ zones +├─ scene +│ ├─ nodes +│ └─ surfaces +└─ flow + ├─ initialGroupId + ├─ groups → steps → cues + ├─ variables + └─ timelines ``` -エンコード方式自体は意味モデルの一部ではないが、v1 の Local Compiler は PresentationDefinition を canonical `presentation.definition.json` として生成する。この JSON は永続化、検証、export、interop に使う最終的な意味 artifact であるが、GUI / Code 編集の Authoring Source ではない。 +`scene.nodes` は空間配置、owner、audience、初期 transform / 表示状態、`scene.surfaces` は内容ツリー、意味情報、State、Interaction、Render Intent を保持する。`flow.timelines` は Timeline catalog を保持し、実行中の Run、現在時刻、現在値を含めない。完全な構造境界は [ADR-0015](../decisions/0015-presentation-definition-artifact-boundaries.md) を正本とする。 + +素材の使用箇所は `assetId` だけを参照する。checksum、media type、encoded size を持つ素材 descriptor は、Definition と RenderBundle から独立した immutable な `AssetSetManifest` に置く。Compiler は Definition、RenderBundle、AssetSetManifest、素材実体の参照完全性を検証し、Control Plane はそれぞれの hash を PublishedPresentation に固定する。現行 M1 の `definition.assets` と出力 layout にこの分離が実装済みとは扱わない。独立音声用 descriptor は持たず、Video 内の audio track は Video asset の一部として検証する。 + +エンコード方式自体は意味モデルの一部ではないが、v1 の Local Compiler は PresentationDefinition JSON を canonical に生成する。この JSON は永続化、検証、export、interop に使う最終的な意味 artifact であるが、GUI / Code 編集の Authoring Source ではない。M1 local outputでの file name は `definition.json` であり、後続の full Delivery target の layout と混同しない。 ### 3.4 RenderBundle @@ -195,15 +213,23 @@ type ProjectionProfileKey = { }; type DeliveredRenderSurface = { + semanticSurfaceId: SemanticSurfaceId; + logicalBounds: LogicalBounds; + layer: UInt32; rendererKind: "baked-web" | "native-ui" | "video"; - rendererContractVersion: number; + artifactContractVersion: UInt32; stateBindings: Record< SurfaceStateId, - | { kind: "empty" } - | { kind: "artifact"; artifactId: RendererArtifactId } + { kind: "empty" } | { kind: "artifact"; artifactId: RendererArtifactId } >; }; +type ProjectedSemanticSurface = { + renderSurfaceIds: RenderSurfaceId[]; + semanticsByState: Record; + interactionsByState: Record; +}; + type ProjectionProfileDescriptor = { projectionProfileId: ProjectionProfileId; key: ProjectionProfileKey; @@ -211,6 +237,7 @@ type ProjectionProfileDescriptor = { visibleSurfaceIds: SemanticSurfaceId[]; visibleVariableIds: VariableId[]; renderSurfaces: Record; + semanticSurfaces: Record; localOverlays: LocalOverlayDefinition[]; }; @@ -223,10 +250,28 @@ type ProjectionInstance = { type AssetAccessBinding = { assetId: AssetId; checksum: ContentHash; + mediaType: string; + encodedSizeBytes: UInt64; url: string; expiresAt: string; }; +type TextureResidencyBindingV1 = { + assetId: AssetId; + checksum: ContentHash; + pixelSize: readonly [width: UInt32, height: UInt32]; + pixelFormat: "rgba8-unorm-srgb"; + mipCount: 1; + decodedGpuBytes: UInt64; + peakLoadCpuBytes: UInt64; + memoryEstimateVersion: 1; +}; + +type TextureResidencyPlanV1 = { + budgetTierId: "texture-baseline-v1"; + textures: TextureResidencyBindingV1[]; +}; + type DeliveryManifest = { deliveryContractVersion: number; sessionId: SessionId; @@ -236,12 +281,15 @@ type DeliveryManifest = { projectionProfile: ProjectionProfileDescriptor; projectionInstance: ProjectionInstance; assetAccess: AssetAccessBinding[]; + textureResidency: TextureResidencyPlanV1; }; ``` +各`ProjectedSemanticSurface.renderSurfaceIds`はimmutable RenderBundleの該当Semantic Surfaceに属する完全なpartition集合をlayer昇順でexactly onceに持つ。`ProjectionProfileDescriptor.renderSurfaces`のkey集合はvisible Semantic Surfaceごとのordered IDのunionと一致し、各`DeliveredRenderSurface`の`semanticSurfaceId`、`logicalBounds`、`layer`はRenderBundle metadataと完全一致しなければならない。不可視Surfaceのpartition、欠落・余分・duplicate ID、layer gap / duplicate、metadata drift、compatible artifact不在はprofile単位でatomicに拒否する。Deliveryは`rendererKind`、artifact自身の`contractVersion` / `requiredFeatures`、target capabilityから`artifactContractVersion`とbindingを選び、build時renderer identityのstring `contractVersion`とは比較しない。 + Control Plane は同じ `ProjectionProfileKey` から同じ `ProjectionProfileDescriptor` を生成し、profile 単位で cache / 共有できるようにする。`projectionContractVersion` は visibility closure と renderer 選択規則を含む projection algorithm の互換性境界であり、cache namespace の一部とする。`ProjectionProfileId` は descriptor の canonical content に対応し、同じ PublicationFence、projection contract version、role、正規化済み capability profile の participant ごとに作り直さない。 -Profile は `participantId`、`assignmentEpoch`、endpoint、credential、Signed URL を含まない。これらの participant / assignment 固有値と期限付き Asset access binding は DeliveryManifest の instance 側で解決する。client が申告した capability を authorization に使用せず、Control Plane が正規化・検証した `CapabilityProfileId` は renderer compatibility の選択だけに使用する。 +Profile は `participantId`、`assignmentEpoch`、endpoint、credential、Signed URL を含まない。これらの participant / assignment 固有値と期限付き Asset access binding は DeliveryManifest の instance 側で解決する。`assetAccess`はProjectionがselectedしたAsset ID集合と完全一致し、各Asset IDをexactly once、Asset IDのUTF-16 code-unit昇順で持つ。duplicate、missing / extra binding、同じAsset IDのdescriptor不一致はManifest全体をrejectする。各bindingはdownload検証に必要なmedia type / encoded sizeを運び、`textureResidency`はselected textureをchecksum、Asset ID順でexactly onceに持つ。Unityは両者とProjection内のartifact descriptorが一致すること、GPU sum / CPU maxが`budgetTierId`の上限内であることを再検証する。client が申告した capability を authorization に使用せず、Control Plane が正規化・検証した `CapabilityProfileId` は renderer compatibility の選択だけに使用する。 `DeliveredRenderSurface` は Render Surface ごと、かつ到達可能な Surface State ごとに選択結果を固定する。`empty` 以外の各 state は同じ renderer contract で解釈できる artifact を一つ持たなければならず、Unity が実行時に候補から再選択しない。これにより、state A と state B が異なる artifact を必要とする場合も一つの profile で完全に配信できる。 @@ -286,11 +334,11 @@ active-use lock 中も Draft 編集と build は許可する。publish は `expe Session 中に変化する状態であり、PresentationDefinition や RenderBundle へ書き戻さない。 -| Layer | Authority | Producer | 保持・復元 | -| ------------------------ | -------------------- | ----------------------------- | ------------------------------------------------------- | -| Shared Runtime State | 割り当て済み Runtime Core | 割り当て済み Runtime Core | Snapshot / Reliable Event。高頻度値は State Stream | -| Participant Runtime View | なし。派生 view | Runtime Core(profile は Control Plane) | profile と Shared Runtime State から再生成 | -| Client-local State | Unity client / device | Unity client | 必要な場合だけ端末内で保持 | +| Layer | Authority | Producer | 保持・復元 | +| ------------------------ | ------------------------- | ---------------------------------------- | -------------------------------------------------- | +| Shared Runtime State | 割り当て済み Runtime Core | 割り当て済み Runtime Core | Snapshot / Reliable Event。高頻度値は State Stream | +| Participant Runtime View | なし。派生 view | Runtime Core(profile は Control Plane) | profile と Shared Runtime State から再生成 | +| Client-local State | Unity client / device | Unity client | 必要な場合だけ端末内で保持 | #### Shared Runtime State @@ -328,6 +376,7 @@ type ParticipantRuntimeView = { variables: Record; activeRuns: RuntimeRunSnapshot[]; clock: RuntimeClockSnapshot; + presentationOrigin: PresentationOrigin; enabledLogicalInputs: LogicalEventName[]; }; @@ -339,14 +388,14 @@ type ProjectedRuntimeSnapshot = { }; ``` +`presentationOrigin`はcanonical Snapshot cutからparticipantへ投影し、`presentationOriginVersion`とposeを同じimmutable valueで運ぶ。Presenter Anchor poseはephemeral tracking stateでありProjected Runtime Snapshotへ保存しない。State Streamはraw pose catalogではなく、profile内でvisibleなAnchor-bound Nodeごとにfollow指定された成分だけを初回keyframe / deltaへ投影する。該当Nodeは初回keyframeまたは後続fresh sampleを受け取るまでunavailableとし、描画とinteractionを開始しない。 + Control Plane は PublishedPresentation、role、正規化済み capability profile から静的な `ProjectionProfileDescriptor` を生成する。割り当て済み Runtime Core はその profile、`ProjectionInstance`、Shared Runtime State を組み合わせ、participant ごとの Runtime View、Projected Runtime Snapshot、Reliable Event、State Frame を生成する。`variables` には descriptor の `visibleVariableIds` だけを含める。`clock` は Shared Runtime State の pause-aware logical clock の sample であり、client は `running` 中だけ受信 sample を基準に local monotonic clock で Native UI の表示を補間し、`paused` と `terminating` では補間を止める。timer の完了判定は常に割り当て済み Runtime Core だけが行い、再接続時は新しい Runtime View の clock sample から表示を再開する。Unity client は受信後に unauthorized resource を非表示化する authority を持たず、配信前の projection で除外する。 Role による visibility と ResourceOwner による lifetime は別概念とする。 ```ts -type ProjectionAudience = - | { kind: "all" } - | { kind: "role"; role: "presenter" | "viewer" }; +type ProjectionAudience = { kind: "all" } | { kind: "role"; role: "presenter" | "viewer" }; type ProjectableResourceRef = | { kind: "node"; id: SpatialNodeId } @@ -410,14 +459,9 @@ export default definePresentation({ ), flow: ({ event, instance }) => [ - event("presenter.next") - .in("intro-idle") - .do(instance("hero").action("show")) - .to("intro-shown"), - - event(instance("timer").output("completed")) - .in("intro-shown") - .to("intro-completed"), + event("presenter.next").in("intro-idle").do(instance("hero").action("show")).to("intro-shown"), + + event(instance("timer").output("completed")).in("intro-shown").to("intro-completed"), ], }); ``` @@ -503,6 +547,8 @@ export const Hero = defineComponentManifest({ GUI は Manifest から Inspector と編集可能範囲を構築する。Structured Component の `renderers` は対応可能な generic renderer を宣言する compatibility metadata であり、Component 固有 implementation entry ではない。renderer 実装を解析して公開契約を推測しない。 +Partの`overridable`は`content`、`placement`、`style`に加えて`partition`を宣言できる。`partition` permissionを持つ公開Partだけがinstance側の`{ kind: "isolate" }`を受けられ、Structure Part bindingが一つのstable subtree rootへ解決する。authorはRenderSurfaceId、bounds、layer、rendererを指定しない。完全なpartition override contractは [ADR-0011](../decisions/0011-surface-partition-contract.md) を正本とする。 + ### 5.2 Structured Component source boundary - GUI が内部構造を理解できる。 @@ -640,12 +686,12 @@ Output payload は v1 では有限個の名前付き Scalar field だけを持 Output reference は compile 時に次の規則で canonical Trigger へ一意に置き換える。 -| Output producer | Canonical Trigger | Actor | -| --- | --- | --- | +| Output producer | Canonical Trigger | Actor | +| -------------------- | ------------------------------------------------------------- | ----------------------- | | `surfaceInteraction` | 同じ canonical Interaction ID を参照する `surfaceInteraction` | v1 は認証済み Presenter | -| `timelineCompleted` | 同じ canonical Timeline ID を参照する `timelineCompleted` | System | -| `mediaCompleted` | 同じ canonical Surface ID を参照する `mediaCompleted` | System | -| `timer` | `afterMilliseconds` を保持する Step timer | System | +| `timelineCompleted` | 同じ canonical Timeline ID を参照する `timelineCompleted` | System | +| `mediaCompleted` | 同じ canonical Surface ID を参照する `mediaCompleted` | System | +| `timer` | `afterMilliseconds` を保持する Step timer | System | `timer` は Component の mount 時刻ではなく、Output reference を含む Cue が属する Step の entry を基準にする。Step exit で破棄し、Step reentry では新しい `stepEntryEpoch` に属する timer として開始する。Native UI の Runtime Clock はこの同じ Step timer を表示へ binding できるが、完了判定と Output 発生は割り当て済み Runtime Core が行う。Component Output から別の `semanticEvent` を producer として参照することは v1 では許可しない。これにより producer chain と event cycle を作らない。 @@ -673,13 +719,13 @@ Canonical Cue 自体は Action を伴わず Step または Group だけを遷移 Authoring Source はすべて同じ方法で実行せず、意味を宣言する source と任意の描画実装を分離する。 -| Source | Compiler path | Output | -| --- | --- | --- | -| Presentation Orchestrator | parse / typecheck 後、AST から静的に lower | Presentation Declaration Graph | -| Theme Declaration | parse / typecheck 後、AST から静的に lower | Theme Declaration Graph | -| Component Manifest | parse / typecheck 後、AST から静的に lower | Component Declaration Graph(contract / semantics) | -| Structured Component Structure | parse / typecheck 後、AST から静的に lower | Component Declaration Graph fragment | -| Opaque Component renderer | 通常の TS / React / CSS として bundle | renderer artifact | +| Source | Compiler path | Output | +| ------------------------------ | ------------------------------------------ | --------------------------------------------------- | +| Presentation Orchestrator | parse / typecheck 後、AST から静的に lower | Presentation Declaration Graph | +| Theme Declaration | parse / typecheck 後、AST から静的に lower | Theme Declaration Graph | +| Component Manifest | parse / typecheck 後、AST から静的に lower | Component Declaration Graph(contract / semantics) | +| Structured Component Structure | parse / typecheck 後、AST から静的に lower | Component Declaration Graph fragment | +| Opaque Component renderer | 通常の TS / React / CSS として bundle | renderer artifact | Declaration Graph は context-specific な root を持つ同じ宣言モデルとして、Orchestrator、Theme、Manifest、Structure の参照を接続する。Opaque renderer の React tree、DOM、CSS、実行結果は Declaration Graph ではない。Opaque renderer と Component semantics は Manifest の binding key だけで接続する。 @@ -768,7 +814,6 @@ Group └─ Spatial Tree ├─ Container3D ├─ Model - ├─ Audio └─ SurfaceNode ── 1:1 ── Semantic Surface ├─ Surface Tree │ ├─ Frame @@ -788,14 +833,16 @@ Surface は次の三層を別の canonical identity として扱う。 v1 は一つの SurfaceNode と一つの Semantic Surface を 1:1 に対応させ、一つの Semantic Surface を一つ以上の Render Surface へ lower する。同じ Semantic Surface を複数の SurfaceNode へ配置する mirroring は含めず、再利用や複数配置は Component Instance と SurfaceNode をそれぞれ作成して表現する。 +ModelNode は Model Asset に内蔵された animation clip を再生できる。通常は一つの ModelNode で同時に一つの clip だけを再生し、clip crossfade 中だけ遷移元と遷移先の二つを許可する。別 ModelNode の clip は同時に再生できる。部位 mask、animation layer、additive clip 合成は対象外とし、将来用 field や拡張口を作らない。詳細な採用範囲は [ADR-0016](../decisions/0016-model-animation-scope.md) に従う。 + +clip は Model 内部の姿勢だけを変更する。build 時に root motion を除去または無効化し、安全に変換・検証できない素材を拒否する。ModelNode の position / rotation / scale は Timeline と Node 操作が所有する。自然終了は最終姿勢、明示停止は停止時点の姿勢を保持する。crossfade 中の別 clip 要求は拒否し、自動 queue しない。Run 除去後も保持姿勢を途中参加・再接続で復元できる canonical state が必要になる。この方針は Target であり、現行 Action / Runtime Run union に Model animation を追加済みとは扱わない。Clip ID binding、保持姿勢、配布形式の構造は [Presentation Data Model](./DATA_MODEL.md) に従う。root motion の規則、Action、Run、Snapshot、Reliable Event の field と wire は同 v2 契約に定義する。 + ### 7.1 Group Group は物語上の進行スコープであり、Scene Graph の親子構造や resource ID namespace ではない。v1 の Runtime resource owner は `presentation` または一つの `group` に限定し、`step` scope は作らない。Step は Cue の有効範囲であり、resource lifetime ではない。 ```ts -type ResourceOwner = - | { kind: "presentation" } - | { kind: "group"; groupId: GroupId }; +type ResourceOwner = { kind: "presentation" } | { kind: "group"; groupId: GroupId }; type OwnedResource = { owner: ResourceOwner; @@ -806,26 +853,26 @@ ownership の正本は各独立 resource の `owner` とし、Group に `ownedNo owner を直接持つ独立 resource と、owner を継承する resource を次に固定する。 -| Resource | owner | -| --- | --- | -| Component Instance | authoring declaration で所有 | -| Spatial Node、Timeline、Variable、Zone | `owner` を直接所有 | -| Semantic Surface | host SurfaceNode から継承 | -| SurfaceContentNode、Interaction | Semantic Surface から継承 | -| Media Runtime State / Run | Semantic Surface から継承 | -| Render Surface / renderer artifact | Semantic Surface から継承 | -| Step / Cue | 構造上所属する Group に固定 | -| Asset、Theme、Component package | Runtime lifecycle scope の対象外 | +| Resource | owner | +| -------------------------------------- | -------------------------------- | +| Component Instance | authoring declaration で所有 | +| Spatial Node、Timeline、Variable、Zone | `owner` を直接所有 | +| Semantic Surface | host SurfaceNode から継承 | +| SurfaceContentNode、Interaction | Semantic Surface から継承 | +| Media Runtime State / Run | Semantic Surface から継承 | +| Render Surface / renderer artifact | Semantic Surface から継承 | +| Step / Cue | 構造上所属する Group に固定 | +| Asset、Theme、Component package | Runtime lifecycle scope の対象外 | ResourceOwner は immutable な Definition contract であり、Action や Runtime State から変更しない。owner は Spatial parent、`active`、`visible`、現在の Group と別概念とする。 参照は短い lifetime から同じまたは長い lifetime への方向だけを許可する。 -| 参照元 | 参照可能な target | -| --- | --- | -| presentation-owned resource | presentation-owned resource | -| group G-owned resource | presentation-owned resource / group G-owned resource | -| Group G の Step / Cue | presentation-owned resource / group G-owned resource | +| 参照元 | 参照可能な target | +| --------------------------- | ---------------------------------------------------- | +| presentation-owned resource | presentation-owned resource | +| group G-owned resource | presentation-owned resource / group G-owned resource | +| Group G の Step / Cue | presentation-owned resource / group G-owned resource | presentation-owned resource から group-owned resource、Group A から Group B の resource への参照は build error とする。この規則は Spatial parent、Timeline target、Variable、Zone、Surface、Interaction、Action、Guard、Trigger の参照に共通して適用する。 @@ -833,21 +880,21 @@ presentation-owned resource から group-owned resource、Group A から Group B Spatial ownership と Spatial parent は分離する。group-owned Node は presentation-owned Node、Stage、Anchor を parent にできるが、presentation-owned Node は group-owned Node を parent にできず、異なる Group の Node 間に parent relation を作れない。Group は単一の Root Spatial Node を所有する必要はなく、Compiler は owner と parent relation から複数の group root を導出できる。 -Presentation 全体で継続する背景、共有 HUD、累積 Variable、ambient media、Timeline は presentation-owned とする。Group の Step / Cue は presentation-owned resource を変更でき、その結果は次の Group にも残る。独立した global Cue は v1 に含めず、presentation-owned Surface の Interaction も現在の Group / Step に対応する Cue がある場合だけ Progression へ影響する。 +Presentation 全体で継続する背景、共有 HUD、累積 Variable、継続再生する Video、Timeline は presentation-owned とする。Group の Step / Cue は presentation-owned resource を変更でき、その結果は次の Group にも残る。独立した global Cue は v1 に含めず、presentation-owned Surface の Interaction も現在の Group / Step に対応する Cue がある場合だけ Progression へ影響する。 ### 7.2 Stable identity と Spatial Node Graph Authoring と PresentationDefinition の ID、Compiler が生成する ID を分離する。 -| ID | 所有 contract | 安定性 | -| --- | --- | --- | -| `SpatialNodeId` | Authoring Source / PresentationDefinition | author-stable | -| `SurfaceNodeId` | Authoring Source / PresentationDefinition | author-stable、`SpatialNodeId` の kind-safe subtype | -| `SemanticSurfaceId` | Authoring Source / PresentationDefinition | author-stable | -| `SurfaceContentNodeId` | Authoring Source / PresentationDefinition | Semantic Surface 内で author-stable | -| `SemanticNodeId` | Structured Component Structure / Opaque Manifest semantic adapter | Semantic Surface 内で author-stable | -| `RenderSurfaceId` | RenderBundle | compiler-derived、build-local | -| `RendererArtifactId` | RenderBundle | compiler-derived、build-local | +| ID | 所有 contract | 安定性 | +| ---------------------- | ----------------------------------------------------------------- | --------------------------------------------------- | +| `SpatialNodeId` | Authoring Source / PresentationDefinition | author-stable | +| `SurfaceNodeId` | Authoring Source / PresentationDefinition | author-stable、`SpatialNodeId` の kind-safe subtype | +| `SemanticSurfaceId` | Authoring Source / PresentationDefinition | author-stable | +| `SurfaceContentNodeId` | Authoring Source / PresentationDefinition | Semantic Surface 内で author-stable | +| `SemanticNodeId` | Structured Component Structure / Opaque Manifest semantic adapter | Semantic Surface 内で author-stable | +| `RenderSurfaceId` | RenderBundle | compiler-derived、build-local | +| `RendererArtifactId` | RenderBundle | compiler-derived、build-local | 既存の canonical Trigger、Guard、Action、Snapshot に現れる `SurfaceId` は `SemanticSurfaceId` を意味する。`NodeId` は `SpatialNodeId` を意味し、Surface Tree 内部の `SurfaceContentNodeId` を含めない。RenderSurfaceId は authoring API と PresentationDefinition に現れない。 @@ -892,7 +939,7 @@ Code 上の入れ子表現は parse 時に `parent` へ正規化する。`node` - Scale は無次元倍率とする。 - Presentation Origin、Stage、Spatial Node、Body Anchor を親座標として扱う。 -ここまでの座標規約は ADR-0005 で固定済みである。Transform の合成順、Quaternion の乗算順、matrix layout、Unity との変換、Surface logical coordinate と UV の完全な変換規則は下位 contract で固定する。 +基礎座標規約はADR-0005、Transformの`T * R * S`、parent-first matrix積、Hamilton Quaternionとcanonical sign、column-major matrix、Unity境界のZ reflectionは [ADR-0010](../decisions/0010-spatial-surface-coordinate-contract.md) を正本とする。Spatial treeはlocal TRSを正本とし、non-uniform scaleとrotationから生じ得るworld shearを含むderived world値はmatrixを正本としてTRSへ再分解しない。 Shared Spatial Tree の Anchor owner は v1 では Session の Presenter だけとする。`ParticipantId` は Session 実行時の identity であり、PresentationDefinition、RenderBundle、PublishedPresentation へ埋め込まない。Viewer 自身の head / hand へ配置する UI は Shared Spatial Tree の node とせず、ProjectionProfileDescriptor の Local Overlay definition と Client-local State の `self` Anchor で表現する。 @@ -921,14 +968,16 @@ type SemanticSurface = { logicalSize: [number, number]; fit: "contain" | "cover" | "stretch"; rootFrameId: SurfaceContentNodeId; - baseSemanticTree: SemanticTree; + baseSemanticTree: SemanticTreeDefinition; initialStateId: SurfaceStateId; states: Record; renderIntent: SurfaceRenderIntent; }; ``` -`baseSemanticTree` は Surface の全 State が共有する意味構造の正本である。State は `SurfaceSemanticOverride` により既存 Node の可視性と text / language / alt だけを変更でき、Node ID、role、parent、order、interaction の所属を変更したり、新しい Semantic Node を生成したりできない。これにより State 間で意味対象と Hit Region の参照先を安定させる。 +Definition の Surface 要素 ID、親子関係、要素 kind と `baseSemanticTree` の ID / topology は全 State で固定し、State ごとの追加・削除を許可しない。State は既存要素の内容、装飾、表示だけを変更する。非表示指定により派生 `CompletedSemanticTree` から Node を除外できるが、Definition の基底 Node を削除したことにはしない。連続的な Runtime 更新は限定 Native UI の text value だけに許可し、構造、装飾、表示を Runtime 値で変更しない。具体的な State override は [Presentation Data Model](./DATA_MODEL.md) を正本とする。 + +Surface local planeは中心原点、+X right、+Y up、front normal +Zとする。logical-to-meter変換は`fit`に従って中央寄せし、`contain`の余白はnon-content、`cover`のplane外はclip、`stretch`だけはaspect ratioを変更する。Render Surface内のrasterは左上原点、Unity UV0は左下原点として`u = rx`、`v = 1 - ry`で一度だけflipする。raycast、fit inverse、crop、Semantic Surface normalized Hit Regionの完全な式と境界規則は [ADR-0010](../decisions/0010-spatial-surface-coordinate-contract.md) を正本とする。 Compiler は次の invariant を検証する。 @@ -945,6 +994,7 @@ Compiler は次の invariant を検証する。 一つの Semantic Surface は一つ以上の Render Surface へ lower する。Native UI、Baked Web、Video はいずれも Render Surface の renderer artifact として扱い、Semantic Surface と並列の意味 identity を作らない。 ```ts +// Target M3 portable shape. Current generated schema remains the M1 subset. type RenderSurface = { id: RenderSurfaceId; semanticSurfaceId: SemanticSurfaceId; @@ -954,44 +1004,47 @@ type RenderSurface = { width: number; height: number; }; - layer: number; + layer: UInt32; + partitionStrategyVersion: 1; artifacts: Record; stateBindings: Record; }; type RenderSurfaceStateBinding = - | { kind: "empty" } - | { kind: "artifacts"; artifactIds: RendererArtifactId[] }; + { kind: "empty" } | { kind: "artifacts"; artifactIds: RendererArtifactId[] }; ``` -`logicalBounds` は親 Semantic Surface の logical coordinate space で表す。RenderSurfaceId は同じ source、lockfile、Compiler version、configuration に対して決定的に生成するが、author-stable ID ではなく、Compiler version や partition strategy が変われば変更できる。 +`logicalBounds` は親 Semantic Surface の logical coordinate spaceで表す。RenderSurfaceIdはauthor-stable IDではなく、partition strategy、SemanticSurfaceId、renderer contract、paint順のowned Node、bounds、layerのcanonical descriptorから導出するbuild-local IDである。完全なalgorithmとprovenanceは [ADR-0011](../decisions/0011-surface-partition-contract.md) を正本とする。 lowering は次を満たさなければならない。 - 一つの Render Surface は一つの Semantic Surface だけに所属し、Semantic Surface boundary を越えて内容を統合しない。 - 複数 Surface の texture atlas や GPU batching は Asset / Runtime 最適化であり、Render Surface identity を統合しない。 - Render Surface の集合、bounds、layer はすべての Surface State に対して同じ build 内で固定する。状態ごとに内容が存在しない partition は明示的な empty binding を持てる。 +- renderable content NodeはCompiler internal planの`ownedContentNodeIds`で一partitionだけが所有し、structural context Nodeは`contextNodeIds`としてrenderer planへ複製できるがownershipへ重複計上しない。どちらもportable RenderBundle / DeliveryManifestへ出さない。 +- partitionはcanonical paint interval順にlayer `0..N-1`を重複なく持ち、小さい値からback-to-frontに合成する。bounds overlapとtransparent gapは許可する。 - すべての到達可能な Surface State について、各 Render Surface が選択可能な artifact、native plan、または明示的な empty binding を持つ。 - `artifacts` binding の `artifactIds` は空でなく、同じ Render Surface の `artifacts` に存在しなければならない。 - DeliveryManifest は target capability に応じ、各 Render Surface の到達可能な Surface State ごとに互換な artifact を一つ、または RenderBundle が宣言した `empty` を選択する。非 empty state の選択は同じ renderer kind / contract version で解釈できなければならない。 +- compatible artifactが存在しない場合は暗黙fallbackせず、`delivery-artifact-unavailable`でDeliveryManifest全体をatomicに拒否する。 Runtime contract が参照できる ID を次に固定する。 -| Contract | 参照可能な target | -| --- | --- | -| `node.patch`、Timeline track | SurfaceNode を含む `SpatialNodeId` | -| `surface.setState`、Surface Interaction、media Action | `SemanticSurfaceId` | -| Guard、Progression、Snapshot、Reliable Event | `SemanticSurfaceId` / `SpatialNodeId` | -| RenderBundle、Delivery renderer graph | `RenderSurfaceId` | -| Authoring 内部編集 | `SurfaceContentNodeId` | +| Contract | 参照可能な target | +| ----------------------------------------------------- | ------------------------------------- | +| `node.patch`、Timeline track | SurfaceNode を含む `SpatialNodeId` | +| `surface.setState`、Surface Interaction、media Action | `SemanticSurfaceId` | +| Guard、Progression、Snapshot、Reliable Event | `SemanticSurfaceId` / `SpatialNodeId` | +| RenderBundle、Delivery renderer graph | `RenderSurfaceId` | +| Authoring 内部編集 | `SurfaceContentNodeId` | RenderSurfaceId は Trigger、Guard、Action、Timeline、Snapshot、Reliable Event に含めない。SurfaceContentNodeId も公開 Interaction や Semantic Node へ明示的に lower された場合を除き、Runtime progression から直接参照しない。 一つの Semantic Surface が複数 Render Surface へ分割されても、`surface.setState` は一回の canonical state change とする。すべての partition は同じ transition run と `runId` に従って原子的に切り替え、Render Surface ごとの独立した canonical state を作らない。Surface 全体の Transform と opacity は SurfaceNode に一度だけ適用する。 -`media.play`、`media.pause`、`media.seek` と `mediaCompleted` も SemanticSurfaceId を参照し、同じ Semantic Surface の media partition を一つの canonical media run として扱う。独立した再生位置や完了判定が必要な media は別 Semantic Surface に分ける。Render Surface や renderer acknowledgement を media authority にしない。 +`media.play`、`media.pause`、`media.seek` と `mediaCompleted` は、Video artifact を持つ SemanticSurfaceId だけを参照する。Compiler は Video を持たない Surface への media Action / Trigger を build error とする。同じ Semantic Surface の Video partition は一つの canonical media run として扱い、独立した再生位置や完了判定が必要な Video は別 Semantic Surface に分ける。Render Surface や renderer acknowledgement を media authority にしない。本書の `Media` / `media` runtime state、run、event はすべてこの Video playback を意味し、独立音声を含まない。 -Render Surface の具体的な partition algorithm、自動化範囲、author override、artifact format は Rendering / Delivery follow-up で定義する。 +v1はrequired renderer / compositing boundaryとManifestが許可した公開Partの`isolate`だけでcanonical paint atom列を最大runへ分割する。同じ要件のatomをtexture sizeやNode数のheuristicだけで分けず、authorはRenderSurfaceId、bounds、layer、rendererを指定しない。Compilerが全partitionのprivate regionをSemantic Surface normalized Hit Regionへaggregateし、Coreがreject-onlyで検証する。詳細は [ADR-0011](../decisions/0011-surface-partition-contract.md) を正本とする。 ## 8. Frame Layout @@ -1089,7 +1142,6 @@ Semantic Surface - Model - Shape - Light -- Spatial Audio - Transform - Anchor tracking @@ -1106,16 +1158,22 @@ Semantic Surface - Timer - Counter - 短い動的 Text -- Runtime data や user input で継続的に変化する限定 UI 任意の CSS を実行せず、versioned portable UI contract と、更新可能 property の allowlist を使用する。 ### 10.4 Video - 複雑だが事前に確定できる連続演出に使用する。 +- 音声は Video artifact 内の audio track としてだけ使用し、映像と同じ再生状態と時刻に従う。 - Codec、alpha、audio、seek、loop、device capability は Delivery 契約で検証する。 -### 10.5 Component と Surface +### 10.5 対象外の Runtime Web + +`embedded-web`、WebView、Unity Runtime で任意の HTML / CSS / JavaScript / WebAssembly を実行する方式は採用しない。将来用の renderer kind、capability、fallback、予約 field、Unity adapter boundary も作らない。固定 Browser で Opaque TS / React / CSS を実行できるのは、`baked-web` artifact を生成する隔離済み build-time boundary だけである。 + +独立した音声、BGM、効果音、空間音声も採用しない。Video の audio track とは別の独立音声用 Asset kind、Spatial Node、Action、capability、予約 field、Unity adapter boundary は作らない。 + +### 10.6 Component と Surface - Component は再利用と編集の境界である。 - Semantic Surface は意味状態と interaction の境界、Render Surface は描画 partition、SurfaceNode は空間 animation の境界である。 @@ -1139,29 +1197,27 @@ type SurfaceRenderIntent = { }; interaction: - | { kind: "none" } - | { kind: "regions"; events: LogicalEventName[] } - | { kind: "native-input" }; + { kind: "none" } | { kind: "regions"; events: LogicalEventName[] } | { kind: "native-input" }; internalAnimation: - | { kind: "none" } - | { kind: "precomputed"; durationSeconds: number } - | { kind: "runtime" }; + { kind: "none" } | { kind: "precomputed"; durationSeconds: number } | { kind: "runtime" }; rendererPreference: "auto" | "baked-web" | "native-ui" | "video"; fallbackPolicy: "reject" | "degrade"; }; ``` +click `InteractionDefinition`とAuthoring `InteractionDeclaration`はrequired `hitPriority: UInt32`を持つ。これはoverlap時のhit-test winnerを決めるsemantic authorityであり、Compilerがrenderer plan / Hit Regionへcopyする。rendererやDeliveryはpriorityを生成・変更せず、未指定値へ暗黙defaultを置かない。M3のschema移行で現行declarationへbreakingに追加する。 + Renderer の基本選択規則は次のとおりとする。 -| Surface の特性 | 基本 renderer | -| --- | --- | -| 静的な Typography、Card、Table | `baked-web` | -| 少数の有限状態 | `baked-web` + state artifacts | -| 入力非依存の連続演出 | `video` | -| Timer、Counter、入力値などの継続変化 | `native-ui` | -| Surface 全体の移動、回転、拡縮、Fade | Unity SurfaceNode | +| Surface の特性 | 基本 renderer | +| ------------------------------------ | ----------------------------- | +| 静的な Typography、Card、Table | `baked-web` | +| 少数の有限状態 | `baked-web` + state artifacts | +| 入力非依存の連続演出 | `video` | +| Timer、Counter、短い動的 Text | `native-ui` | +| Surface 全体の移動、回転、拡縮、Fade | Unity SurfaceNode | `rendererPreference` は authoring 上の希望であり、target capability と build 結果を踏まえた concrete renderer は RenderBundle と DeliveryManifest で確定する。 @@ -1235,9 +1291,7 @@ type CueDefinition = { trigger: Trigger; fixedPayload?: CanonicalEventPayload; guard?: Guard; - firePolicy: - | { kind: "oncePerStepEntry" } - | { kind: "repeatable"; cooldownMilliseconds: number }; + firePolicy: { kind: "oncePerStepEntry" } | { kind: "repeatable"; cooldownMilliseconds: number }; actions: Action[]; next: | { kind: "stay" } @@ -1289,7 +1343,7 @@ type RuntimeClockSnapshot = { lifecycle: | { kind: "running" } | { kind: "paused"; reason: PauseReason } - | { kind: "terminating" }; + | { kind: "terminating"; reason: TerminationReason }; }; type StepTimerState = @@ -1354,6 +1408,8 @@ type RuntimeRunSnapshot = }); ``` +`RuntimeStatusChanged` は lifecycle を `running`、pause reason を持つ `paused`、termination reason を持つ `terminating` として discriminated に送る。invariant violation、atomic commit failure、microstep overflow、recovery gap は Runtime fault であり、terminating ではなく `paused` の reason として区別する。Timeline / Runtime Run の semantic policy は [ADR-0007](../decisions/0007-timeline-runtime-run-wire-contract.md)、transport / replay / recovery policy は [ADR-0008](../decisions/0008-runtime-transport-contract.md) を正本とする。 + `runtimeTimeMilliseconds` は Session の pause-aware logical clock とし、`running` 中だけ割り当て済み Runtime Core の monotonic clock 差分で進め、`paused` と `terminating` では停止する。process 固有の monotonic timestamp、wall clock、`pausedAt`、累積 pause duration は Snapshot に保存しない。Runtime Resume では保存済み logical time を新しい monotonic clock の基準へ bind する。process recovery では保存時の lifecycle が `running` でも logical time を進めず、`paused / processRecovered` として復元する。 Timer、Cue 消費状態、cooldown は `stepEntryEpoch` に属する。`oncePerStepEntry` で受理した Cue だけを `consumedCueIds` に記録し、cooldown は次に受理可能な logical runtime time を保持する。Timer は Step entry 時に一度だけ arm し、deadline 到達時に一度だけ event 化する。Guard 不成立または別 Cue の選択により受理されなかった場合も `fired` とし、同じ Step entry で暗黙に再試行しない。Step exit と self transition では旧 StepExecutionSnapshot を破棄する。 @@ -1362,7 +1418,7 @@ Surface transition、Timeline、Media は共通の **Runtime Run** として追 Surface transition の duration と easing は Run が正本とする。Timeline の duration と absolute track は Session が固定した PublishedPresentation の TimelineDefinition から解決し、開始時の client 描画値を保存しない。Media は logical runtime time 上の reference position、または明示的に pause した position を保持する。Global Pause は clock の停止で表現し、Run ごとの pause 補正値を持たない。 -`RuntimeRunId` は assignment epoch と単調増加する run sequence から一意に生成し、Snapshot は allocator の最終 sequence を保持する。serialized encoding は下位 wire contract で固定する。Run completion は `runId` と owner epoch が現在の active Run に一致する場合だけ適用し、Renderer acknowledgement を completion source にしない。 +`RuntimeRunId` は assignment epoch と単調増加する run sequence から一意に生成する session / assignment scoped value である。wire は `uint64 assignment_epoch` と `uint64 run_sequence` を持つ protobuf message とし、Snapshot は allocator の最終 sequence を保持する。Run completion は `runId` と owner epoch が現在の active Run に一致する場合だけ適用し、Renderer acknowledgement と corrective keyframe を completion source にしない。Timeline Run の lifecycle、停止理由、projection、compatibility は [ADR-0007](../decisions/0007-timeline-runtime-run-wire-contract.md) を正本とする。 blocking Run は Progression Phase の `blockingRunIds` と一対一に対応する。completion ごとに active Run と blocking set から除去し、最後の blocking Run が完了した時だけ `pendingNext` を atomic に適用する。存在しない Run、完了済み Run、古い Group / Step epoch に対する completion は stale として状態を変更しない。 @@ -1375,6 +1431,7 @@ Surface State は `hidden`、`shown`、`selected`、`correct` のような意味 ```ts type SurfaceStateDefinition = { id: SurfaceStateId; + contentOverrides: Record; semanticOverrides: SurfaceSemanticOverride[]; enabledInteractionIds: InteractionId[]; }; @@ -1436,9 +1493,7 @@ type RuntimeActor = source: SystemEventSource; }; -type TriggerActorSelector = - | { kind: "presenter" } - | { kind: "system"; source?: SystemEventSource }; +type TriggerActorSelector = { kind: "presenter" } | { kind: "system"; source?: SystemEventSource }; type TrackedSubjectSelector = | { @@ -1598,14 +1653,14 @@ type Action = Runtime は Action を適用前に property claim へ正規化する。 -| Action | claim | -| --- | --- | -| `surface.setState` | 対象 Surface の state と transition | -| `node.patch` | 指定した Node field。`transform` は position、rotation、scale のすべて | -| `variable.set` | 対象 Variable の value | -| `timeline.play` | Timeline lifecycle と全 track の `target/property` | -| `timeline.stop` | Timeline lifecycle と active Run が所有する全 `target/property` | -| `media.play` / `pause` / `seek` | 対象 Surface の media lifecycle | +| Action | claim | +| ------------------------------- | ---------------------------------------------------------------------- | +| `surface.setState` | 対象 Surface の state と transition | +| `node.patch` | 指定した Node field。`transform` は position、rotation、scale のすべて | +| `variable.set` | 対象 Variable の value | +| `timeline.play` | Timeline lifecycle と全 track の `target/property` | +| `timeline.stop` | Timeline lifecycle と active Run が所有する全 `target/property` | +| `media.play` / `pause` / `seek` | 対象 Surface の media lifecycle | 同じ batch 内で claim が重なる場合は、操作内容が同じでも batch 全体を reject する。active Timeline Run が所有する property への `node.patch`、同じ property を所有する別 Timeline の開始、active な同一 Timeline の再開始も reject する。`timeline.stop` は対象 Run の claim を停止処理のために引き継げるが、同じ batch にある別 Action とその claim が重なる場合は reject する。停止と値変更を順に行う場合は、別の Cue または Step として表現する。 @@ -1619,7 +1674,7 @@ event 依存値の型不一致、同じ State への不正な transition、activ Action 同士の順序に意味を持たせない。依存した順次演出は次の Step、Timeline keyframe、または `timelineCompleted` Trigger で表現する。 -v1 の Action conflict は action 配列順で解決しない。同一 Surface への複数 `surface.setState`、同一 Variable への複数書き込み、同一 Node field への複数 patch、Node patch と Timeline の同一 property 所有、同一 Timeline の play / stop、同一 media target への競合操作は batch validation で reject する。異なる field への Node patch だけは一つの patch として統合できる。`replace`、additive animation、暗黙的な last-write-wins は将来拡張とする。 +v1 の Action conflict は action 配列順で解決しない。同一 Surface への複数 `surface.setState`、同一 Variable への複数書き込み、同一 Node field への複数 patch、Node patch と Timeline の同一 property 所有、同一 Timeline の play / stop、同一 media target への競合操作は batch validation で reject する。異なる field への Node patch だけは一つの patch として統合できる。Timeline Run 間の `replace`、Spatial property の additive 合成、暗黙的な last-write-wins は将来拡張とする。この記述は Model animation clip の layer / additive 合成を将来拡張に含めない。 ### 12.8 Timeline @@ -1639,11 +1694,7 @@ type TimelineDefinition = { tracks: Array<{ target: { nodeId: NodeId; - property: - | "opacity" - | "transform.position" - | "transform.rotation" - | "transform.scale"; + property: "opacity" | "transform.position" | "transform.rotation" | "transform.scale"; }; keyframes: TimelineKeyframe[]; }>; @@ -1661,7 +1712,7 @@ type TimelineDefinition = { - group-owned Timeline は同じ Group または presentation-owned Spatial Node、presentation-owned Timeline は presentation-owned Spatial Node だけを target にできる。 - group-owned Timeline は Group exit 時に停止する。presentation-owned Timeline は明示的な `timeline.stop` または Presentation 終了まで Group をまたいで継続できる。Step 遷移だけではどちらも停止しない。 - Timeline の開始・完了は Session の pause-aware logical runtime clock で決定する。Renderer acknowledgement を完了条件にしない。 -- Runtime は開始時刻と Timeline 定義を配信し、各 client はローカル補間する。補間結果を毎 frame Reliable Event として送信しない。 +- Runtime は Delivery 済み immutable Timeline catalog と開始時刻を用い、各 client はローカル補間する。Timeline の補間結果を毎 frame Reliable Event または State Stream として送信しない。State Stream は tracking 等の非 Timeline latest-wins state に限定し、Timeline-owned property を含めない。 Pause 中は Session の logical runtime clock 自体を進めないため、Timeline、Surface transition、playing Media の基準時刻を個別に補正しない。Runtime Resume 後は同じ logical runtime time から進行を再開する。 @@ -1748,7 +1799,7 @@ StepEntered PresentationEnded ``` -participant へ送る projected Reliable Event の論理 envelope は次を持つ。具体的な Protobuf field number、retention、batching は下位 transport contract で固定するが、この fence と identity を省略しない。 +participant へ送る projected Reliable Event の論理 envelope は次を持つ。具体的な Protobuf field number、retention、batching、Snapshot / State keyframe、microstep 上限は [ADR-0008](../decisions/0008-runtime-transport-contract.md) を正本とし、この fence と identity を省略しない。 ```ts type ProjectedReliableEvent = { @@ -1767,7 +1818,7 @@ type ProjectedReliableEvent = { `sequence` は projection 前の session-global な単調増加値、`eventId` は冪等適用、`causeEventId` は canonical event 間の因果関係に使用する。Texture variant、Hit Region geometry、Unity renderer 情報は payload に含めない。client は Session、PublicationFence、assignment epoch、projection profile、Presentation Origin のいずれかが現在の connection / DeliveryManifest と一致しない event を適用せず、Connection Resume を要求する。 -Pose、Timeline の毎 frame 補間値、連続的な Element State frame は latest-wins の State Stream とし、event log へ保存しない。離散的な Cue 採用と最終状態は Reliable Event と Snapshot の両方へ反映する。 +Pose と Timeline 以外の連続的な Element State frame は latest-wins の State Stream とし、event log へ保存しない。Timeline の毎 frame補間値は Delivery 済み immutable catalog、active Run、logical runtime time から client が再計算する。離散的な Cue 採用、Run lifecycle、最終状態は Reliable Event と Snapshot の両方へ反映する。 #### Surface transition / interaction wire contract @@ -1978,6 +2029,7 @@ Conformance test は、今回の progression 規則について次を検証す - Surface: 遷移中の追加入力、同じ State への cut / crossfade、crossfade easing、interaction と hit region の有効化時点、cut が一件の `SurfaceStateChanged` だけを生成すること、crossfade が重複する State event を生成しないこと、completion が派生する Group / Step event より先に並ぶこと、reject が sequence と state を変更しないこと、同じ fingerprint の `clientEventId` 再送が同じ outcome へ収束すること、異なる fingerprint の ID 再利用と Presentation Origin 不一致が fail closed になること、Snapshotから同じInteraction有効状態を導出できること。 - Action: property claim conflict、batch reject の atomicity、expected reject と Runtime fault の状態遷移。 - Timeline: exact endpoint、全 easing と number / Vector3 / Quaternion の組み合わせ、Quaternion の反対符号と near-linear 補間、Pause / Resume、明示 stop / Group exit での現在値 commit、Presentation 終了時の cancel 順序。 +- Model animation: 自然終了と明示停止後の姿勢保持、crossfade 中の別 clip 要求拒否、Run 除去後の復元、root motion が ModelNode transform を変更しないこと。 - Semantic Tree / Native UI: 空 Tree、root canonical order、State materialization、override の field presence / `null` 削除、enabled / disabled Interaction と Hit Region 整合、tree limit と capability reject、Variable closure、静的literal / boolean labelのreject、single-line置換後のstring許容range、動的valueのtruncate、boolean / number / timer format、Pause / 再接続時のclock表示、全 State のfont face / glyph closure、Native UI / effective Semantic Tree textのartifact内・profile横断injective一致、Unity と Web preview のformatter fixture一致。 加えて、event の重複、Cue 競合、cooldown、Timer fire済み状態、`transitioning` 中にdeadlineへ達したTimerとRun completionが新しいCueを開始しないこと、同一deadlineの順序、active Run の復元と stale completion、scope を越える不正参照、Stage / Node / Presenter Anchor parentの保持と循環拒否、ProjectionAudienceを越える参照拒否、Presenter / Viewer の role spoof、client 起点の System event、actor と subject の不正な組み合わせ、Presenter Anchor unavailable、projection contract versionごとのcache分離、profile の共有と participant 固有値の隔離、Surface Stateごとのartifact選択、unauthorized resource の配信前除外、projection profile / assignment mismatch、Client-local State が Shared State に混入しないこと、connection presence / tracking stateを除外したdurable restore、projection中のreplay queue overflowが再試行上限内では新しいcutへ収束し、上限超過時は購読を解放して型付きerrorで終了すること、process recovery後のPaused化、recovery log gapのfail closed、presentation-owned state の Group 間継続、group-owned state の exit 時破棄と reentry reset、Snapshot + Replay 後の状態一致を検証する。 @@ -2040,20 +2092,59 @@ presenter.enterZone Texture 化された Surface でも内容の意味を失わないよう、表示 artifact と Semantic Tree を分離する。 ```ts -type SemanticNode = { +type SemanticNodeBase = { id: SemanticNodeId; parentId: SemanticNodeId | null; order: number; - role: "heading" | "paragraph" | "image" | "button" | "table" | "list" | "listItem"; - text?: string; - language?: string; - alt?: string; - interactionId?: InteractionId; }; -type SemanticTree = { +type SemanticNodeDefinition = + | (SemanticNodeBase & { + role: "heading"; + level: 1 | 2 | 3 | 4 | 5 | 6; + text: string; + language?: string; + }) + | (SemanticNodeBase & { role: "paragraph"; text: string; language?: string }) + | (SemanticNodeBase & { role: "image"; alt: string; language?: string }) + | (SemanticNodeBase & { + role: "button"; + text: string; + language?: string; + interactionId: InteractionId; + }) + | (SemanticNodeBase & { role: "list"; ordered: boolean }) + | (SemanticNodeBase & { role: "listItem"; text: string; language?: string }) + | (SemanticNodeBase & { role: "table"; label?: string; language?: string }) + | (SemanticNodeBase & { role: "row" }) + | (SemanticNodeBase & { + role: "cell" | "columnHeader" | "rowHeader"; + text: string; + language?: string; + }); + +type SemanticTreeDefinition = { + rootNodeIds: SemanticNodeId[]; + nodes: Record; +}; + +type CompletedSemanticNode = + | Exclude + | (Extract & { stateEnabled: boolean }); + +type CompletedSemanticTree = { + rootNodeIds: SemanticNodeId[]; + nodes: Record; +}; + +type ProjectedSemanticNode = + | Exclude + | (Omit, "stateEnabled" | "interactionId"> & + ({ enabled: true; interactionId: InteractionId } | { enabled: false })); + +type ProjectedSemanticTree = { rootNodeIds: SemanticNodeId[]; - nodes: Record; + nodes: Record; }; type SurfaceSemanticOverride = { @@ -2061,19 +2152,20 @@ type SurfaceSemanticOverride = { SemanticNodeId, { included?: boolean; - text?: string | null; + text?: string; language?: string | null; - alt?: string | null; + alt?: string; + label?: string | null; } >; }; ``` -Semantic Tree は検索、翻訳、読み上げ、caption、presenter notes、Agent editing、accessibility の基礎として使用する。 +Semantic Tree は検索、翻訳、読み上げ、caption、presenter notes、Agent editing、accessibility の基礎として使用する。roleごとのrequired field、Definition / Completed tree、list / table structure、accessible value、language継承は [ADR-0009](../decisions/0009-semantic-tree-hit-region-contract.md) を正本とする。 -`SemanticTree` は空 Tree を許可する。空でない tree は tree 内で一意な stable Node ID、存在する parent、循環しない親子関係、同じ parent 内で一意な `order` を持つ。`parentId === null` の Node は `rootNodeIds` に一度だけ含まなければならず、`rootNodeIds` に含まれる Node の `parentId` は `null` でなければならない。root 以外の Node は一つの parent を持ち、すべての Node はいずれかの root から到達可能とする。root の順序は `Node.order` 昇順とし、canonical serialization もこの順序で `rootNodeIds` を出力する。`interactionId` は同じ Semantic Surface の Interaction を参照し、Interaction を持たない Node は指定しない。 +`SemanticTreeDefinition` と `CompletedSemanticTree` は空 Tree を許可する。空でない tree は tree 内で一意な stable Node ID、存在する parent、循環しない親子関係、roleごとのrequired parent / children、同じ parent 内で一意な `order` を持つ。`parentId === null` の Node は `rootNodeIds` に一度だけ含め、root以外のNodeは一つのparentを持ち、すべてのNodeはいずれかのrootから到達可能とする。materializerの戻り値自体がrootとsiblingを`Node.order`昇順に並べ、canonical serializationだけに並べ替えを委ねない。`interactionId`はbuttonだけが持ち、同じSemantic SurfaceのInteractionを参照する。text-bearing roleはnon-empty `text`を持ち、dynamic Native UI textがある場合はempty / unavailable時のaccessibility fallbackとして使う。bindingの正本は選択された`NativeUIArtifact`内でそのNodeを一意に参照するtext Nodeであり、独立したsemantic binding fieldは作らない。 -`SurfaceStateDefinition.semanticOverrides` は ordered な override layers である。Compiler は `baseSemanticTree` に layers を順に適用して State ごとの完成 Tree を materialize し、`RenderBundle.semanticsByState` には完成 Tree だけを格納する。差分や適用処理を Runtime へ配信しない。override は base Tree に存在する Node だけを参照でき、全 layer を通じて同じ Node/property を重複して変更できない。override property は field が存在しない場合だけ base 値を保持し、`text`、`language`、`alt` が `null` の場合は base 値を削除する。`included: false` は対象 Node とすべての descendant を完成 Tree から除外し、除外された Node の descendant を個別に再 include できない。`included` 以外の property は明示的または暗黙に include される Node にだけ指定できる。これら、または State 間の ID / role / parent / order / interaction 変更は build error とする。 +`SurfaceStateDefinition.semanticOverrides` は ordered な override layers である。Compiler は `baseSemanticTree` に layers を順に適用して State ごとの完成 Tree を materializeし、buttonの`stateEnabled`をStateのenabled Interaction集合から導出して、`RenderBundle.semanticsByState`には`CompletedSemanticTree`だけを格納する。DeliveryはSession roleからprojected `enabled`を導出し、viewerのInteraction ID / Hit Regionを配信前に除外する。差分や適用処理をRuntimeへ配信しない。overrideはbase Treeに存在するNodeとroleが許すpropertyだけを参照でき、全layerを通じて同じNode/propertyを重複して変更できない。fieldが存在しない場合だけbase値を保持し、requiredなtext / altは削除できず、optionalなtable labelだけを`null`で削除できる。`included: false`は対象Nodeとすべてのdescendantを派生 Completed Tree から除外するが、Definition の基底 Node は維持する。required list / table structureを壊したり、除外されたNodeのdescendantを個別に再includeしたりできない。State 間の基底 Node ID / role / parent / order / interaction変更はbuild errorとする。 Structured Component の Semantic Tree は Component Structure の semantic Primitive から生成し、Opaque Component は Manifest の `semantics` から生成する。renderer は layout と Hit Region の concrete geometry を解決するだけで、DOM、React tree、CSS、Texture、実行結果から意味を抽出・補完しない。 @@ -2092,18 +2184,36 @@ type ResolvedInteractiveRegion = { height: number; }; coordinateSpace: "normalized"; - event: LogicalEventName; - priority: number; + priority: UInt32; }; ``` -Hit region は Semantic Surface State ごとに解決し、bounds は Render Surface ではなく Semantic Surface 全体の normalized coordinate space で表す。UV 原点、fit/crop、overlap priority、visibility を Delivery contract で固定する。v1 は矩形 click interaction に限定する。 +Hit region は Semantic Surface State ごとに解決し、bounds は Render Surface ではなく Semantic Surface 全体の normalized coordinate space で表す。`UInt32`は`0..4_294_967_295`のintegerとし、overlapはpriority降順、Interaction / Semantic Node ID、boundsのcanonical順で解決する。eventは重複保持せずInteraction definitionから解決する。boundsの有限範囲、duplicate key、half-open hit-test、State completenessは [ADR-0009](../decisions/0009-semantic-tree-hit-region-contract.md) を正本とする。UV原点とfit/crop変換は次の座標contractで固定する。v1は矩形click interactionに限定する。 -各 State の Hit Region は同じ State の完成 Semantic Tree に含まれ、かつ `SurfaceStateDefinition.enabledInteractionIds` に含まれる `interactionId` だけを参照できる。参照先 Node が除外される場合、その Node の Hit Region も存在できない。Baked hit region として公開する enabled Interaction は少なくとも一つの region を持ち、disabled Interaction の region は禁止する。存在しない Node / Interaction、または Node の `interactionId` と異なる `interactionId` を持つ region は build error とする。 +各 State の Hit Region は同じ State の完成 Semantic Tree に含まれ、かつ `SurfaceStateDefinition.enabledInteractionIds` に含まれる `interactionId` だけを参照できる。State で表示しない Node の Hit Region は存在できない。Baked hit region として公開する enabled Interaction は少なくとも一つの region を持ち、disabled Interaction の region は禁止する。存在しない Node / Interaction、または Node の `interactionId` と異なる `interactionId` を持つ region は build error とする。 ## 14. RenderBundle ```ts +type NormalizedTextureBuildPolicyV1 = { + policyVersion: 1; + resolutionPolicyVersion: 1; + policyHash: ContentHash; + longEdgePixels: UInt32; + maxStatesPerRenderSurface: UInt32; + maxRenderSurfacesPerSemanticSurface: UInt32; + maxRenderSurfacesPerBundle: UInt32; + maxTextureBindings: UInt32; + maxTextureWidth: UInt32; + maxTextureHeight: UInt32; + maxTexturePixels: UInt32; + maxRenderedPixels: UInt64; + maxSurfaceCaptureBytes: UInt64; + maxBuildOutputBytes: UInt64; + maxBuildAccountedPeakBytes: UInt64; + rendererConcurrency: 1; +}; + type RenderBundle = { schemaVersion: 1; bundleId: string; @@ -2122,12 +2232,15 @@ type RenderBundle = { colorScheme: "light" | "dark"; themeId: ThemeId; themeHash: string; + textureBuildPolicy: NormalizedTextureBuildPolicyV1; }; surfaces: Record; }; ``` +`NormalizedTextureBuildPolicyV1`はADR-0012のresolution policy version、budget policy version、long edge、State / Surface / binding count、texture dimension / pixels、rendered pixels、capture bytes、output bytes、accounted peak bytes、renderer concurrencyの正規化済み実値を持つ。`policyHash` fieldを除いたcanonical payloadのhashを`textureBuildPolicy.policyHash`として保持し、RenderBundle hashとCompiler cache keyの入力にする。wall-time、host RSS、abortはavailability guardであり、このportable objectやartifact identityへ含めない。callerが上限を縮小した場合も実際に使用した値を記録し、default値へ丸めない。per-texture pixel countは`UInt32`、`maxRenderedPixels`を含むaggregate pixel countは`UInt64`とする。 + ### 14.1 Compiled Semantic Surface ```ts @@ -2137,7 +2250,7 @@ type CompiledSemanticSurface = { physicalSizeMeters: [number, number]; renderSurfaceIds: RenderSurfaceId[]; renderSurfaces: Record; - semanticsByState: Record; + semanticsByState: Record; interactionsByState: Record; }; ``` @@ -2150,9 +2263,23 @@ RenderBundle の `surfaces` key は PresentationDefinition の SemanticSurfaceId - `NativeUIArtifact` - `VideoArtifact` +全artifact kindは次のcommon compatibility envelopeを持つ。 + +```ts +type SurfaceRendererArtifactCompatibility = + | { kind: "baked-web"; contractVersion: 1; requiredFeatures: BakedWebFeature[] } + | { kind: "native-ui"; contractVersion: 1; requiredFeatures: NativeUIFeature[] } + | { kind: "video"; contractVersion: 1; requiredFeatures: VideoFeature[] }; + +type BakedWebFeature = "png" | "srgb" | "alpha-opaque" | "alpha-straight"; +type VideoFeature = "h264" | "vp9" | "av1" | "alpha" | "audio"; +``` + +各concrete artifactの`kind`、`contractVersion`、UTF-16 code-unit昇順かつduplicate-freeな`requiredFeatures`はこのenvelopeと一致し、未知version / featureをfail closedで拒否する。Baked Webは`png`、`srgb`、alpha featureのいずれか一つをexactly onceで持ち、premultiplied alphaはv1で許可しない。Videoはcodec featureを一つだけ持ち、`alpha` / `audio`は実際に含むtrackだけに付ける。Deliveryはnormalized Capability Profileが許可するkind / version / feature closureと照合し、選択したartifactの`contractVersion`を`DeliveredRenderSurface.artifactContractVersion`へcopyする。build-time `RendererIdentity.contractVersion: string`はrenderer plugin APIの互換性であり、artifact compatibilityへ流用しない。 + ### 14.2 Baked Web Artifact -Surface State ごとに一つ以上の解像度 artifact を持つ。 +次の1K / 2K複数候補はpost-v1 target例であり、Baked Web v1はADR-0012に従い各non-empty Stateへ2K-long-edge textureをexactly oneだけ持つ。複数resolutionは新artifact / budget policy versionでのみ導入する。 ```text BakedWebArtifact @@ -2270,17 +2397,25 @@ type NativeUINode = `NativeUIArtifact` は State ごとの完全 plan である。`rootNodeId` と全 child reference は `nodes` 内に存在し、一つの root から全 Node が一度だけ到達可能で、cycle、複数 parent、child order の重複を許可しない。bounds は Render Surface の logical coordinate で表し、全値は有限、size は非負とする。sRGB RGBA の各 channel は `0..1` とする。text の `fontSize` と `lineHeight` は有限かつ正、`maxCodePoints` は正の整数とする。Node 数、tree depth、text数、glyph数、文字列の Unicode code point 数は artifact contract と target capability が定める上限以下でなければならない。`clip` を使う plan は `clip`、`ellipsis` を使う plan は `ellipsis`、fallback font を持つ plan は `explicitFontFallback` を必ず `requiredFeatures` に含める。未知の required feature や contract version は fail closed とする。 -Runtime binding の対象は `text.value` だけである。literal、宣言済み Runtime Variable、現在 Step の timer 残時間以外の source、任意式、JSON path、style / geometry / visibility / font の Runtime 変更は build error とする。Variable binding は string、boolean、有限 number だけを受け、`null` は拒否する。single-line 禁止 / 置換対象は U+000A、U+000B、U+000C、U+000D、U+0085、U+2028、U+2029 に固定する。literal と boolean label はこの一覧を含められず、整形後の表示 code point 数が `maxCodePoints` 以下でなければ build error とする。したがって静的値は Runtime で truncate されず、literal は `SemanticNode.text` と常に一致する。dynamic string は最初にこの一覧を U+0020 に置換し、次に結果を `allowedCodePointRanges` で検査して範囲外の code point を U+FFFD に置換する。したがって置換後の U+0020 が許容範囲外ならU+FFFDになる。boolean は明示した `trueLabel` / `falseLabel`、number は grouping なしの ASCII 数字、`fractionDigits` 0〜3、half-away-from-zero の丸めに固定する。 +Runtime binding の対象は `text.value` だけである。literal、宣言済み Runtime Variable、現在 Step の timer 残時間以外の source、任意式、JSON path、style / geometry / visibility / font の Runtime 変更は build error とする。Variable binding は string、boolean、有限 number だけを受け、`null` は拒否する。single-line 禁止 / 置換対象は U+000A、U+000B、U+000C、U+000D、U+0085、U+2028、U+2029 に固定する。literal と boolean label はこの一覧を含められず、整形後の表示 code point 数が `maxCodePoints` 以下でなければ build error とする。したがって静的値は Runtime で truncate されず、literal は `CompletedSemanticNode.text` と常に一致する。dynamic string は最初にこの一覧を U+0020 に置換し、次に結果を `allowedCodePointRanges` で検査して範囲外の code point を U+FFFD に置換する。したがって置換後の U+0020 が許容範囲外ならU+FFFDになる。boolean は明示した `trueLabel` / `falseLabel`、number は grouping なしの ASCII 数字、`fractionDigits` 0〜3、half-away-from-zero の丸めに固定する。 timer の `durationMilliseconds` は有限かつ正とし、現在 Group / Step が binding の `groupId` / `stepId` と一致するときは armed / fired、Cue の Guard 成否を問わず、`remaining = max(0, durationMilliseconds - (clock.runtimeTimeMilliseconds - progression.stepEnteredAtRuntimeTimeMilliseconds))` を表示値に使う。Group または Step が不一致のときだけ `whenStepInactive` に従い、空文字列またはゼロを表示する。残時間は秒へ切り上げて 0 で clamp する。`mm:ss` は分が二桁を超えても必要桁まで増やして秒を二桁で表示し、`hh:mm:ss` は時間を必要桁まで増やして分・秒を二桁で表示する。`whenStepInactive: "zero"` は同じ timer format でゼロを表示する。Compiler は `stepTimerRemaining` が実在する `groupId` / `stepId` / `cueId` の timer Trigger を参照し、`durationMilliseconds` が一致することを検証する。timer owner は Cue の所属 Group から導出し、binding は host SurfaceNode が同じ Group-owned の場合だけ許可する。presentation-owned Surface は group-owned timer を参照できない。Cue 自体は ProjectionAudience を持たないため、timer表示は visible Surface の audience projection に従う。client は `ParticipantRuntimeView.clock` と `progression.stepEnteredAtRuntimeTimeMilliseconds` から表示だけを計算し、timer state を別途投影しない。完了判定と Cue 発火は常に割り当て済み Runtime Core だけが行う。共通 truncation は動的 string / number / timer の format 結果だけに `maxCodePoints` を適用する。超過時は `overflow: "clip"` なら先頭 `maxCodePoints` code point、`overflow: "ellipsis"` なら先頭 `maxCodePoints - 1` code point と U+2026 を使い、`maxCodePoints === 1` では U+2026 だけを使う。Unity と Web preview は同じ fixture に対して同じ pure formatter 結果を返さなければならない。 Authoring では各 text と各 Surface State が Font Asset または Theme Font Token を自由に指定できる。Compiler は State ごとの Native UI artifact に concrete Font Asset と、authoring で明示した fallback Font Asset 列を解決する。`supportedCodePointRanges` は各 delivered font face が実際に描画可能な code point を表す。text は `primary`、`fallbacks` の順に、対象 glyph を最初に持つ face を使う。system font への暗黙 fallback と synthetic bold / italic は許可しない。literal、boolean label、string binding の `allowedCodePointRanges` と formatterで生成し得る U+0020 / U+FFFD、number の ASCII digit / `-` / `.`, timer の ASCII digit / `:`, U+2026 は font closure に含まれなければならない。全 reachable State の plan が参照する primary / fallback font face、supported code point range、required Native UI feature は Asset / capability closure に含まれなければならない。解決できない font / fallback / glyph coverage、または target capability が必要な font を満たさない場合は Delivery 前に reject する。font の選択は artifact の State 切替でだけ変わり、Runtime Variable / Clock binding から変更できない。 -各 Native UI text はその State の完成 Semantic Tree に含まれる `semanticNodeId` を一つだけ参照しなければならない。resolved State の一つの Native UI plan 内では、この対応は injective とし、複数 Text Node が同じ `semanticNodeId` を参照できない。さらに、同じ Semantic Surface / State に対して一つの ProjectionProfile が同時選択する全 Native UI artifact を横断しても injective とする。Compiler は artifact ごとの制約を、Control Plane は profile selection 後のartifact組合せを検証する。異なる ProjectionProfile は別々に検証する。literal は対応する `SemanticNode.text` と同じ値を持つ。dynamic value は `SemanticNode.text` を omit し、client が同じ formatted value を visual text と Participant Runtime View から派生する effective Semantic Tree text の両方へ適用する。canonical Semantic Tree の構造と ID は変えず、renderer が意味を推測しない。effective text は Snapshot の Variable / clock / progression から再現できる。Native UI v1 は non-interactive なので、参照する Semantic Node は `interactionId` を持てない。crossfade 中の effective semantic value は 12.4 の canonical Surface State 規則に従い、遷移先 State を有効な意味状態として使用し、旧 / 新 plan 間で別の Variable / clock snapshot を使わない。 +各 Native UI text はその State の完成 Semantic Tree に含まれる `semanticNodeId` を一つだけ参照しなければならない。resolved State の一つの Native UI plan 内では、この対応は injective とし、複数 Text Node が同じ `semanticNodeId` を参照できない。さらに、同じ Semantic Surface / State に対して一つの ProjectionProfile が同時選択する全 Native UI artifact を横断しても injective とする。Compiler は artifact ごとの制約を、Control Plane は profile selection 後のartifact組合せを検証する。異なる ProjectionProfile は別々に検証する。literal は対応する `CompletedSemanticNode.text` と同じ値を持つ。dynamic valueでは`CompletedSemanticNode.text`をnon-empty accessibility fallbackとして保持し、clientが同じnon-empty formatted valueをvisual textとParticipant Runtime Viewから派生するeffective Semantic Tree textへ適用する。formatted valueがemptyまたは利用不能ならvisual textはその結果に従い、effective semantic textだけをfallbackへ戻す。canonical Semantic Treeの構造とIDは変えず、rendererが意味を推測しない。effective textはSnapshotのVariable / clock / progressionから再現できる。Native UI v1はnon-interactiveなので、参照するSemantic Nodeは`interactionId`を持てない。crossfade中のeffective semantic valueは12.4のcanonical Surface State規則に従い、遷移先Stateを有効な意味状態として使用し、旧 / 新plan間で別のVariable / clock snapshotを使わない。 ### 14.4 Video Artifact -Video Artifact は Asset ID、checksum、duration、loop、alpha、audio、codec capability を保持する。 +Video Artifact は Asset ID、checksum、duration、loop、alpha、audio、codec capability を保持する。`audio` は Video file 内に audio track があることを示し、独立した Audio Asset や音声再生 state を参照しない。 + +### 14.5 Texture budget と residency + +Baked Web v1は各non-empty Render Surface Stateにexactly one 2K-long-edge PNG / RGBA32 textureを持ち、`mipCount = 1`、runtime compressionなしとする。State / partition / binding count、pixel、capture、output、Compiler accounted peak、Delivery GPU / load CPUは [ADR-0012](../decisions/0012-texture-budget-residency-contract.md) のchecked byte式とbaseline tierを正本とする。現行PNG encoderの4K / 64 MiB hard capは単一allocationのtrust-boundaryであり、このPresentation budgetを代替しない。 + +Deliveryはvisibleな全reachable Stateのselected textureをGPU 256 MiB、serial load CPU 256 MiB以内で固定し、Unityはsession開始前にdownload / checksum / decode / uploadを完了してCPU readback copyを破棄する。`downloadReady`、`residentReady`、`sessionReady`を分け、active Session中は全selected hashをpinする。crossfadeのold / newはpreloaded集合内にあり、transition開始時に追加download / allocationしない。encoded cacheはGPU residencyと分離し、baseline 4 GiB hard limit / 512 MiB reserve、active pin、unpinned deterministic LRUを使う。 + +この描画方針の採用は、すべての方式が v1 Delivery で利用可能であることを意味しない。ADR-0012 の baseline tier は `baked-web` Texture だけを対象とする。Native UI は固有の resource budget tier と consumer 実装が受理されるまで、Video は decoder budget tier と consumer 実装が受理されるまで v1 Delivery で拒否する。budget超過時に暗黙downscale、mipmap削除、renderer変更、crossfadeからcutへの変更を行わず、Bundle / Manifest / readinessのatomic unitをfail closedにする。 ## 15. コンパイルと配信 @@ -2337,6 +2472,8 @@ Unity Runtime Static lowering が参照できる入力は、Authoring Source、lock された Component package、Theme、Asset metadata、Compiler configuration に限定する。同じ source、lockfile、compiler version、configuration から同じ Declaration Graph と PresentationDefinition JSON を生成する。Opaque renderer artifact の Browser 実行は別の隔離境界とし、その capability と再現性は Rendering / Delivery contract で固定する。 +M1 の local process は POSIX filesystem に限定し、明示された absolute project directory の realpath を root とする。同じ root の`unframe.config.ts`と`unframe.lock`を読み、上方探索はしない。config は AST で読む data-only `export default { entryFile }`、lock は `schemaVersion: 1`、package identity、self-contained package source、integrityを記録する UTF-8 JSON とし、いずれも実行・network lookup・symbolic link traversal を許可しない。`check` はこの入力と Source frontend までを検証して Browser を起動せず、`build` だけが Fixed Browser capture を行う。M1 の公開artifactは`definition.json`、`render-bundle.json`、`assets/*.png`だけであり、root固定の`dist`を今回生成したstagingからatomic replacementして公開する。`usage`、`syntax`、`type`、`semantic`、`renderer`、`io`、`cancel`の failureは family と exit code を区別し、commit point 前に previous output を維持する。正確な lock shape、hash、signal、diagnostic family は ADR-0013 に従う。 + ### Control Plane - Authoring source や renderer implementation を実行しない。 @@ -2362,7 +2499,7 @@ Control Plane は Presentation ごとに現在の PublishedPresentation を一 - calibration、viewport、selection、personal annotation、Local Overlay state を Client-local State として所有する。 - TSX、CSS、React、Component implementation を解釈しない。 -## 16. 想定ファイル構成 +## 16. 目標アーキテクチャの想定ファイル構成 ```text presentation/ @@ -2383,7 +2520,7 @@ presentation/ ├─ .unframe-cache/ │ └─ renderers/ │ └─ CustomChart.web.bundle.js -└─ dist/ +└─ dist/ # post-M1 full Delivery target ├─ presentation.definition.json ├─ presentation.render-bundle.json ├─ presentation.delivery-manifest.pb @@ -2392,11 +2529,23 @@ presentation/ `.unframe-cache/renderers/` は Local Compiler が Opaque renderer source から再生成できる中間 bundle を保持できるが、version control や publish には含めない。PublishedPresentation に含める renderer artifact は RenderBundle の一部として hash と provenance を固定する。 -`dist/presentation.definition.json` は v1 の最終的な PresentationDefinition artifact である。ただし、GUI / Code 編集を JSON だけで継続することは保証せず、Authoring Source と Semantic Authoring IR の対応情報は別に保持する。 +### M1 Local Compiler output + +M1 の `dist` は managed generation を指す symlink であり、次だけを公開する。これは上記の system target layout と別の local-only artifact layout であり、Delivery artifactを含まない。 + +```text +dist/ +├─ definition.json +├─ render-bundle.json +└─ assets/ + └─ .png +``` + +`definition.json` は v1 の最終的な PresentationDefinition artifact である。ただし、GUI / Code 編集を JSON だけで継続することは保証せず、Authoring Source と Semantic Authoring IR の対応情報は別に保持する。 ## 17. 現行実装との関係 -この文書は目標アーキテクチャである。2026-08-25 時点の現行実装について、次を区別する。 +この文書は目標アーキテクチャである。2026-08-30 時点の現行実装について、次を区別する。 ### Current @@ -2408,26 +2557,30 @@ presentation/ - Asset URL や object key は Definition に保存せず、Asset IDで参照する。 - 現行 Web Editor は Slide ベースの PoC model を使用しており、target PresentationDefinitionへ未接続である。 - Unity の手書き importer は target PresentationDefinition の完成 consumer ではない。 +- M1 Local Compilerは、reference Authoring Sourceを実行せず、明示filesystem rootのconfig / lockからDefinition、RenderBundle、PNGを生成する。`check`はBrowserを起動せず、`build`はFixed Browserを使用してmanaged `dist`を成功時だけatomic replacementする。 + +### Target(目標。初期contract subsetの一部だけが現行実装) -### Target, not implemented +以下は target architecture であり、Current 節または各項目に実装済みと明記した部分以外を、現行機能として扱わない。 - Semantic Authoring IR - `.unframe.tsx` Orchestrator -- Orchestrator / Manifest / Structure AST の static lowering、Declaration Graph normalization、Opaque renderer bundling の build pipeline -- Canonical `presentation.definition.json` の deterministic serialization +- Orchestrator / Manifest / Structure AST の static lowering、Declaration Graph normalization、Source frontendからCompiler compositionへの接続 +- Canonical PresentationDefinition JSON の deterministic serialization(Core API、post-lowering declaration、Authoring SourceからのCompiler artifact生成、M1 filesystem config / lockとatomic outputは実装済み。M1 local outputのfile nameは`definition.json`) - Component Manifest と package format - Structured / Opaque authoring mode -- Spatial Tree / Surface Tree の canonical schema +- Spatial Tree / Surface Tree のcanonical schema(Stage、SurfaceNode、Frame / Text、State、baked-web Render Intentの初期subsetはJSON Schema Draft 2020-12として実装済み) +- Model Asset 内蔵 animation clip の限定再生(v2 contract 定義済み、Runtime / consumer は未実装) - Frame Layout、Theme、Token、Named Style - Surface Render Intent -- RenderBundle +- RenderBundle(baked-web artifactの初期subsetは実装済み) - 単一の PublishedPresentation、PublicationFence、Waiting Session の owner cancel / bounded expiry、非終了 Session と直列化した publish lock - Surface State artifact、semantic tree、hit region - v1 Presentation Progression semantic model の実装と、Progression wire / Runtime contract - Native UI portable contract - DeliveryManifest Protobuf - Unity の hybrid renderer graph -- Deterministic Local Compiler +- M1を超えるLocal Compiler cache、watch / dev / preview / publish、完全なSemantic / Runtime contract 既存の `PresentationDefinition` schema をこの文書だけで置き換えたとはみなさない。実装時は契約変更、migration、OpenAPI/Protobuf artifact、Web/Unity consumer、contract testを同期する。 @@ -2489,33 +2642,29 @@ presentation/ - [x] Surface transition、Action batch、active Timeline Run の conflict policy と Timeline の補間・停止規則を 12.4、12.7、12.8、12.12 で定義した。 - [x] State ごとの完成 Semantic Tree、Hit Region 整合、Native UI v1 subset、text binding、font asset、projection Variable / Clock 規則を 3.5、3.7、7.4、13.2〜13.3、14.3 で定義した。 - [x] Surface transition の開始・完了、Surface interaction input / outcome、Interaction / Hit Region 有効化の wire contract を 12.11 で定義した。 +- [x] Spatial TRS / matrix / Quaternion、Unity handedness、Surface logical / physical / raster / UV変換を [ADR-0010](../decisions/0010-spatial-surface-coordinate-contract.md) で定義した。 +- [x] Surface Partitionのautomatic boundary、Part isolate override、derived ID / layer / region aggregateを [ADR-0011](../decisions/0011-surface-partition-contract.md) で定義した。 +- [x] Texture State artifact数、2K PNG / RGBA32、build / Delivery budget、preload / readiness / evictionを [ADR-0012](../decisions/0012-texture-budget-residency-contract.md) で定義した。 ### Progression wire / Runtime contract の blocking follow-ups -1. Timeline の補間結果、停止理由、Run lifecycle の wire 表現 -2. Reliable Event / Snapshot / State Stream の transport schema、保持期間、runtime microstep 上限 +1. [x] Timeline の補間結果、停止理由、Run lifecycle の semantic wire contract は [ADR-0007](../decisions/0007-timeline-runtime-run-wire-contract.md) で Accepted とした(具体的な Protobuf は v2 に定義済み、consumer は未接続)。 +2. [x] Reliable Event / Snapshot / State Stream の transport schema、保持期間、runtime microstep 上限は [ADR-0008](../decisions/0008-runtime-transport-contract.md) で Accepted とした(v2 proto 定義済み、generated consumer は M5 で接続する)。 ### Rendering / Delivery の follow-ups -1. role 別 Semantic schema と Hit Region の完全 schema -2. Transform 合成、Quaternion 乗算、matrix layout、Unity 変換、Surface / UV 変換の完全な座標規約 -3. Component から Surface への分割規則、partition の自動化範囲、author override -4. Texture state artifact 数と GPU / RAM build budget -5. Resolution、mipmap、compression、preload、eviction policy +1. [x] role 別 Semantic schema と Hit Region の完全 schemaは [ADR-0009](../decisions/0009-semantic-tree-hit-region-contract.md) でAcceptedとした(current flat schemaの実装置換はM3 Slice B)。 +2. [x] Transform / Quaternion / matrix / Unity / Surface / UV座標規約は [ADR-0010](../decisions/0010-spatial-surface-coordinate-contract.md) でAcceptedとした(fixtureとconsumer実装はM3〜M5)。 +3. [x] ComponentからSurfaceへのpartition規則とauthor overrideは [ADR-0011](../decisions/0011-surface-partition-contract.md) でAcceptedとした(実装はM3〜M4)。 +4. [x] Texture state artifact数とGPU / RAM build budgetは [ADR-0012](../decisions/0012-texture-budget-residency-contract.md) でAcceptedとした(実装はM3〜M5)。 +5. [x] Resolution、mipmap、compression、preload、eviction policyも [ADR-0012](../decisions/0012-texture-budget-residency-contract.md) でAcceptedとした。 6. Component Manifest と renderer implementation の drift 検証 7. Opaque renderer の Browser capability、module resolution、cache invalidation と Compiler / Browser / Font / Locale の再現性 -8. DeliveryManifest Protobuf schema、capability negotiation、visual regression test +8. DeliveryManifest v2 / capability 契約に従う実装、visual regression test ## 20. 次の設計対象 -次は Surface Partition ではなく、Progression wire / Runtime contract の blocking follow-ups を順に閉じる。推奨順序は次のとおりである。 - -1. Timeline / Runtime Run の wire contract -2. Reliable Event / Snapshot / State Stream の transport contract -3. role 別 Semantic schema / Hit Region schema -4. Spatial / Surface coordinate convention -5. Surface Partition -6. Texture / GPU / RAM budget +blockingなTimeline / transport / Semantic / coordinate / partition / texture budget contractはすべてAcceptedになった。M1のproject assembly / reference Browser / CLIは実装済みである。コンパイル後の Model animation、Asset Set、Native UI / Video、Delivery / Runtime の具体契約は [Presentation v2](./DATA_MODEL.md) を正本とし、M3以降で Compiler と consumer を接続する。 中心となる思想は次のとおりである。 diff --git a/docs/presentation/CONTRACT_ARTIFACTS.md b/docs/presentation/CONTRACT_ARTIFACTS.md new file mode 100644 index 0000000..0280911 --- /dev/null +++ b/docs/presentation/CONTRACT_ARTIFACTS.md @@ -0,0 +1,215 @@ +# Presentation v2 契約成果物 + +- **Status**: Normative target contract +- **Source**: `packages/contracts/src/presentation/v2/` +- **Scope**: Compiler 出力と publish 入力。現行 v1 consumer の移行は対象外。 + +## 成果物と leaf + +| Schema | 意味 | +| -------------------------------- | ------------------------------------------------------------ | +| `presentationDefinitionV2Schema` | Stage、Scene、Surface、Flow、Variable、Cue、Action、Timeline | +| `renderBundleV2Schema` | Baked Web、Native UI、Video、Model の実行用成果物 | +| `assetSetManifestV2Schema` | 配信する全 Asset descriptor の正本 | +| `buildManifestV2Schema` | build の source revision、成果物 hash、contract version | +| `publishedPresentationV2Schema` | 公開した build と `PublicationFence` | +| `capabilityProfileV2Schema` | 正規化済み端末能力と resource limit | + +全 object は strict とする。ID は ASCII の英数字で始まり、以降を英数字と `._:/-` に限定した +1〜128文字、media type は `image/png | image/jpeg | font/ttf | font/otf | video/mp4 | +model/gltf-binary`、Content Hash は `sha256:` と小文字16進64桁とする。 +数値は有限値とする。個数、byte、 +index、version、millisecond は `0..Number.MAX_SAFE_INTEGER` の整数、 +duration、limit、epoch、contract version は正とする。`sourceAnimationIndex` と Hit Region +priority は UInt32 とする。未知 variant、required feature、追加 property を拒否する。 + +version は Definition 2、RenderBundle 2、AssetSet 2、Delivery 2、Runtime 2、Progression 1、 +Projection 1 に固定し、該当 field は literal とする。 + +## Definition + +`scene.nodes` は `container | model | surface | shape | light` の完全 union とする。Model は一つの +GLB Asset を参照し、SurfaceNode と SemanticSurface は一対一で対応する。Spatial TRS は local +値で、meter、正規化済み `[x,y,z,w]` Quaternion、正の scale を使う。Hamilton 積と canonical +sign は ADR-0010 に従う。SurfaceNode は leaf とする。parent は存在し、node parent graph は +非循環、同じ parent の `order` は一意とする。owner と +audience は Architecture の参照 closure を満たす。 + +3D Shape は box または sphere とし、sRGBA color を持つ固定 unlit shader で描画する。任意 shader、 +texture、shadow cast/receive は持たず、両 shadow field は false とする。Directional Light の強度は +lux、Point/Spot Light は candela、range は meter、Spot angle は degree とする。Light は明示した +`castsShadows` だけを使用し、renderer 固有の強度係数や暗黙 shadow default を使わない。 + +Surface content は `frame | text | image | shape | video` の完全 union とする。record key と Node +ID は一致し、一つの root Frame から全 Node へ一度だけ到達可能とする。Node ID、kind、parent、 +children、order、Frame layout kind、Video Asset は State 間で固定する。State override は対象 kind +の variant が列挙する field だけを変更する。Shape の寸法は変更できるが discriminator は固定する。 + +`semanticNodeId` は content と基底 Semantic Node の対応を明示する。省略できるのは装飾 content +だけとする。全 Semantic Node は content 一つから参照され、同じ Semantic Node を複数 content が +参照しない。対応可能な組合せは `frame -> list/table/row/button`、`text -> heading/paragraph/button/ +listItem/cell/columnHeader/rowHeader`、`image -> image/button`、`shape -> button`、`video -> image/button` +とする。button の content 対応、button の `interactionId`、Interaction definition を一意に閉じ、 +Compiler は対応 content の解決済み bounds から Hit Region を作る。文言、DOM、描画結果から対応を +推測しない。State で不可視になった content に対応する semantic node は同じ State の override で +除外し、その Interaction を enabled にしない。 + +Native UI へ lower する全 Text は `semanticNodeId` 必須とする。装飾 Text を含む partition は +Native UI artifact に変換せず build error とし、Compiler が意味 ID を生成または推測しない。 + +Layout は次に固定する。 + +- `absolute` の x/y/width/height は Surface logical unit とする。 +- `stack` child は width/height 必須とする。宣言軸の寸法を基準に並べ、正の余りを `grow` 比で + 加える。負の余りは再配分せず overflow とする。 +- `grid` の row/column は1始まり、span は track 内に収める。fixed track は logical unit、 + fraction track は正の余りを比率配分する。child は width/height 必須で、`stretch` の軸だけ + cell 内寸へ置き換える。 +- child placement kind は親 layout と一致させ、root は `absolute` とする。`clip: true` は Frame + border box で子孫を clip する。Text overflow は Text bounds 内だけに適用する。 + +sRGBA channel と opacity は `0..1` とする。Node opacity は内容または Model material の出力 alpha +に乗算する。Text は regular/bold、明示 Font/fallback、単一行 alignment、clip/ellipsis に限定する。 +動的 Text は型別 formatter、文字範囲、boolean label、number fraction digits、または Step timer +の完全な情報を Definition に持ち、同じ値を Native UI artifact へ lower する。Runtime が連続変更 +できるのはこの `text.value` だけである。Text は正の `maxCodePoints` を必ず持ち、Compiler は +Native UI artifact の同 field へそのまま copy する。formatter 結果の truncate 上限を推測しない。 + +Semantic Tree は ADR-0009 の role union とする。role の親子制約、空でない accessible text/alt、 +BCP 47 language、sibling order、button Interaction を検証する。override は role、level、order、 +parent、Interaction を変えない。`included: false` は派生 Completed Tree から対象と子孫を除くが、 +基底 Node は維持する。list、table、row を空にしない。 + +Variable の type と initialValue は一致させる。Cue は priority 降順、同値を order 昇順で選ぶ。 +Trigger actor と producer を一致させる。`fixedPayload`、Guard、Action value は宣言済み scalar 型に +一致させる。`eq`/`neq` は同型、大小比較は有限 number だけを受ける。Cue の Action は同じ +pre-event state に対する atomic batch であり、property claim の重複を拒否する。 + +v2 の Trigger payload は次に固定する。 + +| Trigger | 参照可能な source payload | +| ----------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `logicalInput` / `surfaceInteraction` / `zoneEdge` / `motion` | なし | +| `timer` / `timelineCompleted` / `mediaCompleted` / `modelClipCompleted` | なし | +| `semanticEvent` | なし。`event` は同じ Definition のいずれかの `Interaction.event` と一致必須 | + +Component Output lowering は producer を上表の具体 Trigger へ変換し、Output の定数 payload を +Cue の `fixedPayload` に置く。`fixedPayload` は source payload との merge ではなく完全な置換で +あり、上表の source payload はすべて空なので collision 規則や precedence を持たない。 +`eventPayload` reference は `fixedPayload` がある Cue だけで使用でき、存在する field の実値から +導出した scalar type と一致させる。`fixedPayload` がない Cue の `eventPayload` reference、存在しない +field、別 Cue の field は build error とする。`semanticEvent` は Interaction catalog にない任意名を +受け付けず、別の event registry や Runtime からの任意イベント注入を作らない。 + +Timeline は absolute 値を持つ。track は2個以上の keyframe を持ち、0から duration まで整数 +millisecond で厳密に増加する。最終以外は `easingToNext` 必須、最終は指定禁止とする。opacity は +number、position/scale は Vector3、rotation は非ゼロ正規化 Quaternion とする。target/property +の重複と owner 違反を拒否する。 + +Model clip Action は `play | pause | resume | stop` とし、speed は有限かつ正とする。一つの Model +instance は通常一つの clip、crossfade 中だけ元先二つを評価する。crossfade 中の play は同じ要求も +拒否し queue しない。自然終了は最終姿勢、stop は現在姿勢を保持し、停止した二 clip mixture も +保持する。その mixture から新たな crossfade は開始できない。layer、body mask、additive animation、 +root-motion placement の field は作らない。 + +`media.play/pause/seek` の target と Video renderer の Surface は、内容ツリーに Video Node を +ちょうど一つ持たなければならない。複数 Video、Video がない Surface、content ID を target とする +構成を拒否する。Video 再生位置は Runtime State が所有する。 + +## RenderBundle と AssetSet + +各 RenderSurface は全 reachable State に binding をちょうど一つ持つ。空表示は `empty` を明示し、 +それ以外は存在する互換 artifact を参照する。partition は ADR-0011 の bounds、clip、完全被覆、 +非重複、canonical layer order を満たす。 + +Baked Web は PNG/sRGB と opaque/straight alpha の一方を使う。各 non-empty State は texture 一つ、 +`mipCount: 1` とし、AssetSet と descriptor が一致する。texture build policy は Bundle hash に含める。 + +Native UI は group と単一行 text だけを持つ。一つの root から全 Node へ一度だけ到達し、cycle と +複数 parent を許さない。文字置換、boolean label、half-away-from-zero の number 丸め、timer、 +glyph closure、font fallback、semantic text、truncate は Architecture §14.3 に従う。使用 feature +と `requiredFeatures` を完全一致させる。 + +Video は一つの Video Asset、duration、loop、codec、alpha、file 内 audio track の有無を持つ。 +独立 Audio Asset、Action、State、capability は持たない。codec feature は一つだけとし、 +`alpha`/`audio` は対応 track が実在する場合だけ要求する。v2 の基本 Delivery profile が admit +するのは H.264、alpha なしだけとし、同 profile は VP9、AV1、alpha を supported と申告しない。 +これらの feature は Video artifact contract には維持するが、別の形式・budget profile が Accepted +になるまで Delivery で拒否する。基本 profile の MP4 映像 track は AVC High Profile、level 4.1 +以下、8 bit、4:2:0、progressive に限定する。 +interlace、10/12 bit、4:2:2、4:4:4を拒否する。音声 track は任意で一つだけとし、存在する場合は +AAC-LC(MPEG-4 Audio Object Type 2)、48 kHz、mono または stereo に限定する。Compiler は MP4 +container と各 elementary stream を parse してこの値を検証し、file extension や codec 名だけで +判定しない。 + +Model は自己完結した `model/gltf-binary` とする。material は glTF 2.0 PBR metallic-roughness または +unlit、alpha は opaque/mask/blend に限定し、`alphaCutoff` は mask の場合だけ存在させる。 +Model material は base color factor と texture の alpha を先に合成し、mask はその値へ cutoff を +適用する。残った fragment の alpha に ModelNode opacity を乗算する。元が blend、または Node +opacity が1未満の場合は描画時に source-alpha / one-minus-source-alpha blend と depth write off を +使う。opacity が1の opaque/mask は depth write on を維持する。これは配布 material の alpha mode +を書き換えず、Node ごとの effective pass を決める規則である。3D Shape の color alpha と Node +opacity にも同じ effective blend/depth 規則を適用する。skin、morph、animation、material、 +alpha と required feature を一致させる。未知 required extension、外部 URI、script、Unity component、 +Animator Controller を拒否する。 + +GLB は glTF 2.0 core と `KHR_materials_unlit` だけを許可する。`extensionsRequired` と +`extensionsUsed` の各要素はこの allowlist 内で重複なしとし、UTF-16 code unit 昇順にする。 +Draco/Meshopt 圧縮、texture transform、clearcoat、transmission、lights、vendor extension は v2 で +受理しない。buffer と image は GLB 内に埋め込み、image media type は PNG または JPEG とする。 + +Model の node/primitive/vertex/triangle 数、skin ごとの最大 bone 数、primitive ごとの最大 morph +target 数、animation clip 数は、配布する GLB の default scene から到達する object を一度だけ数え、 +参照される accessor の `count` を合計して Bundle に記録する。三角形数は triangle primitive の +index count(non-indexed は vertex count)を3で割った整数とし、三角形化できない mode を拒否する。 +同じ mesh を複数 Node が instance 化する場合は各 instance の実行時負荷としてそれぞれ数える。 +clip 数は `clips` record の要素数と一致させる。Capability admission はこの値を使用する。 + +Import 時は選択 glTF scene の `nodes` 配列が直接指す全 Node を scene root とし、全 clip から +それらを target とする translation/rotation channel を除去する。scale と子孫 target の channel は +内部姿勢として維持する。Compiler は scene を ModelNode 所有の不変 wrapper 配下に置き、残る +channel が wrapper を target にしないことを検証する。対応を一意に決められない source は拒否する。 +Bundle は `rootMotion: "removed"` を記録し、consumer は clip 出力を ModelNode TRS に適用しない。 + +AssetSetManifest は配信 Asset の推移的 closure だけを持ち、self hash、URL、path、Session、 +publication を持たない。Definition/Bundle から参照されない編集素材と、独立 audio、HTML、script +など allowlist 外の素材を拒否する。`assetSetHash` は manifest の RFC 8785 JCS bytes から外側で +計算する。 + +## 検証順序と diagnostic + +consumer は次の順に検証し、違反時は publish/admission を停止する。複数 diagnostic は artifact 名、 +JSON Pointer、code の順で並べる。 + +1. `canonical.invalid`: object 化する前の UTF-8 JSON bytes を読み、duplicate JSON key、lone + surrogate、負のゼロ、JCS 化不能入力を拒否する。通常の `JSON.parse` で duplicate key を失って + から検証してはならない。 +2. `structure.invalid`: Zod schema で field、variant、scalar、version、hash を検証。 +3. `identity.invalid`: record key/ID の一致と ordered ID list の重複なしを検証。 +4. `reference.invalid`: 全 ID と Asset の参照 closure を検証。 +5. `graph.invalid`: tree、topology、order、layout、owner、audience、semantic、partition を検証。 +6. `behavior.invalid`: Variable、Trigger、Guard、Action、State、Timeline、Media、Model playback を検証。 + Guard は深さ16、総 predicate 256以下とする。 +7. `artifact.invalid`: State binding、feature、format、budget policy、Model import、Native UI closure、 + descriptor 一致を検証。feature array は重複なし、UTF-16 code unit 昇順とする。 +8. `hash.invalid`: Definition、Bundle、AssetSet の JCS hash と BuildManifest、revision、version を検証。 +9. `capability.unsupported`: 固定 version と全 feature、個別値と合計値が対応 limit tier 以下か検証。 +10. `publication.invalid`: PublishedPresentation と BuildManifest を比較し、 + `publicationManifestHash` 以外の全 field を含む JCS bytes から同 hash を再計算する。epoch が正かつ + 単調増加することを確認して atomic replace する。 + +Capability limit は正規化済み admission 設定であり端末測定完了を意味しない。`supported: false` の +方式も完全な limit を持つが、その方式の選択は拒否する。 + +limit の集計単位は次に固定する。 + +- Texture の width/height/pixels は一枚、bindings/GPU bytes は選択した全 reachable texture の + 合計、serial-load CPU bytes は一枚を decode/upload する最大値、encoded cache bytes と reserve + は端末 cache 全体に適用する。 +- Native UI の nodes/tree depth/text nodes は一 artifact、code points は一 Text、glyphs と Font + Assets は同時選択された全 artifact の重複を除いた closure に適用する。 +- Video の width/height/pixels/decoded frame bytes は一 Video、encoded bytes は選択 Asset の合計、 + concurrent decoders は同時に playing または paused で保持する decoder 数に適用する。 +- Model の assets は選択した一意な Asset 数、instances は ModelNode 数、encoded bytes は一意な + Asset の合計とする。nodes/primitives/vertices/triangles は instance 展開後の合計、bones は一 skin、 + morph targets は一 primitive、animation clips は一 Asset に適用する。 diff --git a/docs/presentation/CONTRACT_RUNTIME.md b/docs/presentation/CONTRACT_RUNTIME.md new file mode 100644 index 0000000..e5a3b4f --- /dev/null +++ b/docs/presentation/CONTRACT_RUNTIME.md @@ -0,0 +1,276 @@ +# Presentation Delivery / Runtime v2 contract + +- **Status**: Normative target contract +- **Wire source**: `packages/contracts/proto/unframe/{presentation,delivery,realtime}/v2/` +- **Related**: [Architecture](./ARCHITECTURE.md), [Data model](./DATA_MODEL.md), [ADR-0007](../decisions/0007-timeline-runtime-run-wire-contract.md), [ADR-0008](../decisions/0008-runtime-transport-contract.md), [ADR-0009](../decisions/0009-semantic-tree-hit-region-contract.md), [ADR-0010](../decisions/0010-spatial-surface-coordinate-contract.md), [ADR-0011](../decisions/0011-surface-partition-contract.md), [ADR-0012](../decisions/0012-texture-budget-residency-contract.md), [ADR-0015](../decisions/0015-presentation-definition-artifact-boundaries.md), [ADR-0016](../decisions/0016-model-animation-scope.md) + +この文書は Delivery と Runtime の v2 wire を実装する際の required presence、検証、状態遷移、失敗結果を固定する。`realtime.v1` は未完成の foundation であり、v2 への互換 adapter、fallback、downgrade は作らない。 + +## 1. Version と数値 + +有効な組み合わせは次の一つだけである。 + +| 項目 | 値 | +| -------------------------------------- | -----------: | +| Delivery schema / contract | `2` / `2` | +| Capability schema | `2` | +| Runtime protocol / contract | `"v2"` / `2` | +| Progression contract | `1` | +| Projection contract | `1` | +| Projected runtime catalog contract | `2` | +| Connection / canonical snapshot schema | `2` | +| Durable checkpoint schema | `2` | + +同じ major 内で未知の通常fieldはwire decoderが保持または無視してよい。ただしrequired `oneof`は、同じmessageに未知fieldが共存していても既知variantが一つもdecodeされなければ空oneofとしてrejectする。これにより未知variantを既知messageとして誤適用しない。未知の enum 値、未知の required capability、上表と異なるversionも解釈せずfail closedにする。required fieldやvariantを増やす変更は新majorを必要とする。 + +Protoの`uint32`は`0..4_294_967_295`、`uint64`は`0..18_446_744_073_709_551_615`である。JSONへ変換するconsumerはすべての整数を`0..9_007_199_254_740_991`に制限し、超過をrejectする。JSON numberをwireへ変換する際はfinite、integer、non-negative、安全整数を順に検証する。`double`はfiniteでなければならず、`NaN`、正負Infinity、`-0`をrejectする。ただし次段落のMedia/Model clip sample positionだけは入力境界で`-0`を`0`へcanonicalizeして受理する。 + +logical runtime time、duration、deadline、retention、expiryは非負の整数millisecondであり、duration、retention、expiryは明記がない限り正とする。Compilerが秒単位の素材durationを変換する場合、binary64で`seconds * 1000`を計算しhalf-away-from-zeroで整数化し、正のdurationが`0`へ丸められた場合は`1`へclampする。Media/Model clipのsample positionだけは速度適用後の正確な位置を保つfiniteな`double` millisecondとし、`-0`を`0`へcanonicalizeする。sample positionを整数へ丸めない。 + +`ScalarValue`はstring、finite number、boolean、明示nullのexactly one variantを持つ。空oneofはnullではない。`ScalarType`と値variantは一致しなければならない。 + +## 2. 共通presence、ID、順序 + +次をすべてのmessageへ適用する。 + +- non-`optional` Proto3 scalarはwire上の省略とdefault値を区別しないため、省略されたdefault値もそのfieldの値として受理する。意味上presenceを区別する必要があるscalarはschemaで`optional`にし、presenceを検証する。正値などのrange制約があるfieldはdefault値`0`をrange違反としてrejectする。 +- message、`oneof`、ID、hash、URL、media type、tier IDはrequiredである。scalarのpresenceは`optional`と宣言したfieldだけ検証し、省略可能性と値域は各fieldの規則に従う。 +- IDはASCII正規表現`^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$`に一致する1..128 bytesとする。正規化や大小文字変換をせずbyte-exactに比較する。人向けの表示文字列、Semantic text、labelにはこのID制約を適用しない。 +- content hashとchecksumは`sha256:`に64桁lowercase hexadecimalを続けたexactly 71 bytesとする。 +- `publication_epoch`、`assignment_epoch`、`run_sequence`、event `sequence`、`group_entry_epoch`、`step_entry_epoch`、`checkpoint_sequence`は正の値とする。origin version、replay cursor、snapshot reliable sequence、ingress sequenceは`0`を有効な初期値とする。 +- keyed repeated entryはkeyがnon-emptyかつduplicate-freeで、UTF-16 code-unit昇順にserializeする。明示された表示順、layer順、keyframe順、fallback font順は意味を持つため並べ替えない。 +- feature/capability配列は`UNSPECIFIED`を含まず、numeric enum値昇順、duplicate-freeとする。 +- Quaternionはfinite、unit lengthとの差が`1e-9`以下、canonical signでなければならない。Transformのscale各成分はfiniteかつ正、opacityとblend weightは`0..1`である。 + +required messageの欠落、空oneof、unknown enum、重複key、非canonical順、参照不整合をconsumerが補完・並べ替え・正規化して継続してはならない。 + +## 3. Publication と Delivery + +`PublicationFence`はPresentationごとに一つのimmutableな公開物を指す。Delivery、Control、State、Snapshot、Eventのfenceはbyte-exactに一致しなければならない。manifest hashはDefinition、RenderBundle、AssetSet、contract versionsを束ねる公開manifestのcanonical JSONから算出する。 + +`DeliveryManifest`はDefinition全体を配布しない。次だけを含む。 + +1. Publicationとartifact hash。 +2. Control Planeが認証済みclient buildとdevice classから正規化した`CapabilityProfile`。 +3. roleとcapabilityから生成した共有可能な`ProjectionProfileDescriptor`。 +4. participantとassignmentに固有な`ProjectionInstance`。 +5. selected assetだけの期限付きaccess binding。 +6. selected graphに対応するresidency plan。 + +`CapabilityProfile`のversionsは`delivery=2 / runtime=2 / progression=1 / projection=1`で固定する。`supported=false`のrenderer/modelはcontract version `1`と空feature集合を持ち、対応するartifactを選択できない。`supported=true`ではcontract version `1`、そのkindが必要とするfeature、正のlimitを必須とする。Model formatは`GLTF_BINARY`だけを許す。WebView、embedded HTML/JS/Wasm、独立音声のcapabilityや予約値を追加しない。 + +すべてのlimitとtier IDはrequiredである。`TextureLimits`、`NativeUiLimits`、`VideoLimits`、`ModelLimits`の数値は正でなければならない。`encoded_cache_reserve_bytes < max_encoded_cache_bytes`を要求する。`max_blended_clips_per_model`はcapabilityによらず`2`、通常時は`1`であり、layer、mask、additive blendを表すfieldは存在しない。 + +Limitの集計単位は次に固定する。Texture width/height/pixelsは一texture、bindingsとGPU bytesはprofile全体のunique selected texture、serial CPUは一textureのmax、encoded cacheは全selected Assetで判定する。Native UI node/depth/text/code pointは一artifact、glyphとfont Assetはprofile全体のunique集合で判定する。Video width/height/pixelsは一video、encoded bytesはunique selected videoのsum、decoded frame bytesは同時decoder instance集合のsumである。Model Asset、instance、encoded bytes、nodes、primitives、vertices、trianglesはprofile全体のunique Asset集計、bonesは一skin、morph targetは一primitive、animation clipは一Assetの上限である。 + +### 3.1 Projection profile + +Profile keyはPublication、projection contract、role、capability profile IDからなる。同じkeyは同じcanonical descriptorとprofile IDを生成する。profile IDにはparticipant、assignment、endpoint、credential、URL、expiryを含めない。 + +`projection_profile_id`はdescriptor自身のID fieldを除いた次のidentity JSON mappingをRFC 8785でcanonicalizeし、そのUTF-8 bytesをSHA-256したlowercase digestへ`pp_`を付けた67-byte stringとする。 + +- messageはJSON object、keyはdescriptorに記載されたProto fieldの`snake_case`名とする。`projection_profile_id`だけを再帰の開始前に除外する。unknown fieldを含むdescriptorはidentity生成対象としてrejectする。 +- non-`optional` scalarはdefault値を含め常に出力する。presentな`optional` scalarだけを出力し、absentならkeyごと除外する。required messageは常に出力する。 +- `oneof`は選択されたfieldのkeyと値だけを出力する。空oneofは生成前にrejectする。空marker messageの値は`{}`とする。 +- stringとboolは同じJSON primitive、`uint32`はJSON number、`uint64`はleading zeroのない10進JSON string、enumはその非負numeric valueをJSON number、finite `double`は`-0`を除くJSON numberとする。bytes fieldはProjectionProfileDescriptor closureに存在してはならない。 +- repeated fieldは空でも必ずJSON arrayとして出力する。`visible_node_ids`、`visible_surface_ids`、`visible_variable_ids`など集合を表すrepeated stringはUTF-16 code-unit昇順、featureはnumeric昇順、keyed entryは前節のkey順とする。children、fallback font、layer、keyframeなどsemantic orderを持つfieldはwire順を維持する。生成前にduplicateと非canonical順をrejectする。 + +このmappingはprofile identity専用で、標準protobuf JSON mappingやprotobufのdeterministic serializationを正本にしない。 + +Profileは次のclosureをexactに満たす。 + +- `visible_node_ids`はroleに可視なNodeと、その可視Nodeに必要な可視Spatial ancestorの集合。 +- `visible_surface_ids`は可視host NodeのSemantic Surface集合。 +- `render_surfaces`は各visible Surfaceの全partitionをlayer `0..N-1`でexactly once含む。 +- `semantic_surfaces`はvisible Surfaceをexactly once含み、全reachable Stateの完成済みprojected semantic treeと有効Hit Regionを含む。 +- `visible_variable_ids`はselected Native UI artifactのbinding closureと一致する。 +- `runtime_catalog`は投影されたNode、Surface、Variable、Timeline、Model clipのうちclientの表示、補間、復元に必要な項目だけを含む。 +- catalogのTimelineはすべてのtarget Nodeがvisibleな場合だけ含める。partial track、target削除、Timeline分割は禁止する。 +- node、surface、variable、Timeline、model clip entryはpresentationまたは一つのGroup ownerを明示する。model clip entryはvisible ModelNodeが参照できる全clipを、`model_node_id / clip_id`の組でexactly once含む。 + +Viewerのbuttonは`interaction_enabled=false`かつ`interaction_id` absentである。Presenterでもdisabled buttonは同じ形とする。enabled buttonだけがinteraction IDを持ち、同じStateのHit Regionはenabled interactionだけを参照する。crossfade中はRuntime stateから全Interactionを無効とする。 + +### 3.2 Artifact selection + +各Render Surfaceの全reachable Stateは`empty`またはselected artifactをexactly once持つ。non-empty Stateのartifactは同じrenderer kindとcontract versionで解釈できる。DeliveryはRenderBundleのordered candidate列から最初のcompatible candidateを選び、順序変更、端末側再選択、暗黙downscale、renderer変更、crossfadeからcutへの変更をしない。 + +選択したartifactのID、kind、contract version、required features、asset descriptorはRenderBundleと一致する。Baked WebはPNG、sRGB、opaqueまたはstraight alphaの一つを必須とし、premultiplied alphaを拒否する。Native UIはtree、font closure、binding上限を満たす。v2のVideo profileはMP4、AVC High profile level 4.1以下、8-bit 4:2:0 progressive、alphaなしとする。Capability enumは将来のprofile識別用にVP9、AV1、alphaも予約するが、v2で受理するCapability Profileはこれらをsupported featureとして申告してはならず、artifactも使用してはならない。任意の音声は同じvideo内の単一AAC-LC object type 2 track、48 kHz、monoまたはstereoだけを許す。独立Audio Assetやaudio runtime stateは存在しない。Modelは自己完結GLBで、外部URI、runtime script、Unity component、root motionを拒否する。 + +`asset_access`のkey集合はselected artifact、font、video、modelが参照するAssetのtransitive closureと一致する。各Assetを一回、Asset ID順に持ち、descriptorはAssetSetと一致する。URLはabsolute HTTPS、expiryはmanifest生成時より後で、manifest自体のidentityには含めない。 + +### 3.3 Residency admission + +Residency entryの集合はselected asset集合の対応kindと一致する。集計はchecked unsigned arithmeticで行う。 + +- Texture GPU chargeはunique checksumごとの`decoded_gpu_bytes`のsum、CPU chargeは`peak_load_cpu_bytes`のmax。 +- Model encoded bytes、nodes、primitives、vertices、trianglesはunique checksumごとのsum。instance countはvisible ModelNode数、bone/morph/clipは上記のper-container最大値を使う。 +- Native UIの`font_asset_ids`はselected Native UI artifactの全`ResolvedFont`が参照するAsset IDのunique集合をID順に持ち、`font_asset_count`はその集合に対応するunique checksum件数と一致する。glyph closureは各text sourceから、literalは実際のUnicode scalar、string variableは全allowed range、booleanは両label、numberはASCII `-0123456789.`、timerはASCII `0123456789:`を列挙する。string variableの集合には置換用U+0020とU+FFFD、`overflow=ellipsis`のtextにはU+2026も追加する。各scalarをprimary、fallback順で最初にsupportするFontFaceへ割り当て、`(font checksum, Unicode scalar)`で重複排除する。supportするfontがなければDeliveryをrejectする。`GlyphResidencyKey`はchecksumとscalarを持ち、font checksum昇順、同一font内はscalar数値昇順とする。件数を`max_glyphs`、font集合件数を`max_font_assets`と照合する。nodes、depth、text nodes、code pointsはartifactごとの実測値の最大をplanへ記録し、対応limitと照合する。 +- Video bindingごとの`decoded_frame_bytes = width * height * 4`とし、portable decode surfaceをRGBA32一枚としてchecked unsigned arithmeticで算出する。encoded bytesはunique checksumのsum。decoder admissionは、presentation-owned Surfaceとcurrent Group-owned Surfaceだけを候補に、各Surfaceの到達可能Stateから選べるVideo artifactまたは非Video状態の直積を列挙する。各組合せではactive Video Surface instance一つをdecoder一つとして数え、同じchecksumの複数Surfaceも別decoderとする。全Groupと全組合せのcount最大値を`maximum_concurrent_decoders`、`decoded_frame_bytes`合計の最大値を`maximum_decoded_frame_bytes`とし、Capabilityの対応limit以下でなければならない。 + +集計値がmanifest記載値、Capability limit、artifact metadataのいずれかと一致しない場合、Manifest全体をrejectする。開始前に全assetをdownload、checksum検証、decode/import、GPU/decoder/font admissionし、`downloadReady`、`residentReady`、`sessionReady`を別々に管理する。すべて成立する前にControl inputを有効化しない。active Session中のselected checksumをpinし、residency喪失時は描画とinput送信を止めState/Controlを`FAILED_PRECONDITION / asset_residency_lost`で閉じる。 + +encoded cache admission、reservation共有、pin、LRU、staging leaseはADR-0012の式をそのまま適用する。cache missやevictionでprofile、artifact identity、runtime stateを変更しない。 + +## 4. Control / State 二接続 + +Controlはreliable、ordered、replayableで、Stateはlatest-wins、non-replayableである。各streamの最初のitemはHandshake exactly onceとし、それ以前の別payload、二回目のHandshake、空itemを`INVALID_ARGUMENT / handshake_order_invalid`で閉じる。 + +認証済みcontextだけがsession、participant、role、assignmentを所有する。client payloadからidentityやroleを採用しない。Control handshakeは`protocol_version="v2"`、progression `1`、required capability全件を満たす。State handshakeはControlで選択済みの値、connection IDと一致し、Controlが発行した32-byte nonceを30,000 ms以内に一回だけ使用する。nonceの再利用、別connection利用、expiryは`UNAUTHENTICATED / state_nonce_invalid`で閉じる。 + +serverはControl接続後に`ControlConnected`、Snapshot、State nonceの順に送る。Stateは`StateConnected`後、Control clientがSnapshotを適用して`StateReady`を送り、そのsequenceとorigin versionがcurrent cutに一致してからframeを送る。不一致は`FAILED_PRECONDITION / state_ready_fence_mismatch`とする。 + +### 4.1 Protocol limits + +`RuntimeProtocolLimits`の全fieldはrequiredで、接続中に変更しない。baselineは次の値に固定する。 + +| Limit | 値 | +| -------------------------- | -------------------------------------------------------------: | +| ID UTF-8 bytes | 128 | +| Control item | 1,048,576 bytes | +| State item | 262,144 bytes | +| Reliable event log | 4,096件、8 MiB、900,000 msのいずれかの境界より古いeventをevict | +| Replay response | 1,024件、1 MiB | +| Catch-up queue | 1,024件、1 MiB | +| Idempotency retention | 1,024件、900,000 msのいずれかの境界でevict | +| Snapshot catch-up | 初回を含む3回、合計250 ms | +| Runtime microsteps / drain | 1,024 | +| State dependency buffer | 4,096 field value、500 ms | +| Tracking samples / frame | 4 | +| Tracking frames / second | 90 | +| Anchor sample max age | 500 ms | + +server implementationは上表の値を緩和・縮小せず、Handshakeで明示する。item byte上限とtracking rateはv2で上表の値に固定する。変更には新contractを必要とする。超過itemは`RESOURCE_EXHAUSTED / message_limit_exceeded`とする。Tracking rateはserver monotonic timeの各rolling 1,000 msで90 frameまでを受理し、超過frameをdropする。3つの連続するrolling windowで一件以上dropした場合は`RESOURCE_EXHAUSTED / state_rate_exceeded`でStateだけを閉じる。 + +### 4.2 Command とidempotency + +PresenterだけがLogical Input、Surface Interaction、Runtime Controlを送れる。Viewer commandは`PERMISSION_DENIED / presenter_required`。origin version不一致はoutcomeを返さず`FAILED_PRECONDITION / presentation_origin_mismatch`でControlを閉じる。 + +`client_event_id`はSessionとparticipant範囲で接続をまたぐidempotency keyである。fingerprint bytesはASCII `unframe-command-v2`、NUL、command variant名、NULに続けて、`client_event_id`と診断用capture時刻を除く各stringをfield number順の`uint32 big-endian byte-length + UTF-8 bytes`、各enumを`uint32 big-endian`、各`uint64`をbig-endianで連結した値とする。fingerprintはそのSHA-256である。同じkey / fingerprintは保存済みOutcomeを返し、event、sequence、ingressを増やさない。異なるfingerprintでの再利用は`INVALID_ARGUMENT / idempotency_key_reused`で閉じる。 + +Logical InputとSurface InteractionはRuntimeがpaused、terminating、transitioningの場合、targetを探索する前に`RUNTIME_NOT_ACCEPTING_INPUT`とする。Logical inputが現在Stepで利用不能なら`INPUT_UNAVAILABLE`。Surfaceが未知、不可視、inactive、transition中、現在Stateでinteraction無効のいずれでも`INTERACTION_UNAVAILABLE`に丸める。これらのrejectはingress、Cue消費、cooldown、state、event sequenceを変更しない。 + +Runtime Controlはこのavailability判定の例外である。pauseはrunningから`paused/explicitPause`、resumeはpausedからinvariant、assignment lease、Publication fenceを再検証してrunning、endはrunning/pausedからterminatingへ遷移する。pause済みへのpause、runningへのresume、terminatingへの任意controlはそれぞれ`CommandNoOp`を返しeventを作らない。NoOpもidempotency windowへ保存する。 + +Accepted outcomeはcanonical input eventの受理だけを表し、その`canonical_event_id / reliable_sequence`は`LogicalInputAccepted`または`SurfaceInteractionAccepted`またはRuntime controlに対応する`RuntimeStatusChanged`を指す。Logical/Surface inputのaccepted outcomeはcue evaluationのexactly oneを持つ。候補なしは`CueNotSelected`、commit成功は`CueCommitted`、選択後のbatch rejectは`CueBatchRejected`と型付き理由を返す。batch rejectはCue消費、cooldown、resource state、Run、Reliable sequenceを変更せず、別Cueへfallbackしない。Runtime Controlではcue evaluationを禁止する。NoOpはcanonical event IDやsequenceを持たない。 + +## 5. Reliable Event、replay、projection + +Reliable sequenceはsession-globalで正に単調増加する。Event IDはSession内で一意、cause IDは既存のcanonical eventを参照する。payload variant、fence、required IDが未知または不整合ならeventを適用せずConnection Resumeを開始する。 + +SurfaceTransitionStarted、Mediaの開始・pause・resume・seek、Model clipの開始・pause・resume・crossfade開始・完了は、event適用後の完全な`RuntimeRunSnapshot`を持つ。event内に同じtarget、Run ID、playback、durationを重複するfieldがある場合はsnapshotとbyte-exactまたは数値exactに一致しなければならない。client reducerはこのsnapshotでactive Run entryを置換でき、DeliveryされたFlow/Cue全体を必要としない。TimelineStartedはADR-0007で固定済みのowner、cause、completion、started timeから同じcomplete entryを構築する。 + +不可視eventはpayloadを送らず、次の可視event直前に一件の`ProjectionAdvance`へ集約する。`from_exclusive`はclient cursorと一致し、`through_sequence > from_exclusive`でなければならない。不可視eventしか増えていない間はmarkerを単独送信しない。 + +Replayは`after_sequence + 1`からcontiguousに返す。範囲外、projection、publication、originの変更時は`ResyncRequired`を一件送りlive deliveryを止める。同じstreamで部分replayと新Snapshotを混在させない。 + +同一logical timeのcanonical eventは次の順で処理する。 + +1. Surface transition、Timeline、Media、Model clipのcompletionをkindの上記列挙順、stable target ID、Run ID順。 +2. TimerをCue ID順。 +3. 外部ingressをingress sequence順。 + +一つのmutationが複数eventを生成する場合は、Run completion/cancel、resource commit、GroupExited、GroupEntered、StepEntered、PresentationEndedの順とする。Presentation終了ではTimeline、Media、ModelClipCanceledをRun ID順で確定した後にPresentationEndedを置く。Surface transition completionは派生Group/Step eventより先に置く。 + +`GroupEntered.initialization`はentry後の投影済みgroup-owned Node、Surface、Media、Variable、Model clip stateを各catalog key順にexactly once持つ。presentation-owned stateを重複しない。clientはこの集合をatomicに追加してからGroupEnteredを適用済みとし、Definitionのinitial valueを必要としない。GroupExitedではcatalogのownerが同じGroupである全stateをatomicに除去する。Snapshotでstateがないgroup-owned resourceはinactive/hiddenではなく未所有であり、描画、hit-test、Action targetにできない。presentation-ownedまたはcurrent Group-ownedなのにSnapshotからstateが欠ける場合はinvalid SnapshotとしてConnection Resumeする。 + +## 6. Snapshot、checkpoint、State frame + +Canonical snapshotはShared Runtime Stateだけを持つ。participant、connection、projection、presence、tracking sample、edge detector、renderer artifact、URLを含めない。Projected snapshotはprofileのvisible closureだけを持ち、canonical restoreへ使用しない。 + +Snapshot cutはlogical time `T`以下の全internal completionを処理後、reliable sequence `S`のstate freezeと`S+1` subscriber登録を同じcritical sectionで行う。serializationとprojectionはlock外のimmutable valueだけを読む。catch-up overflowではpartial resultを捨て、新cutから初回を含む最大3回かつ合計250 msまで再試行し、超過は`RESOURCE_EXHAUSTED / snapshot_catch_up_exhausted`とする。 + +Durable checkpoint field 12だけは例外的にtyped `CanonicalRuntimeSnapshot`のdeterministic protobuf bytesを保持する。任意bytesではない。writerはunknown fieldを含めず、field 11は受信したfield 12そのものの`sha256:` hashとする。hash検証後にschema 2としてparseする。外側のsession、runtime、assignment、Publication、Definition/Bundle hashをロード対象の信頼済み値と照合し、外側の`reliable_sequence`と内側の同fieldを一致させる。内側ではRun ID assignment、owner epoch、catalog参照と全invariantを検証する。parse/re-serialize結果をhash sourceにしない。 + +recoveryは同じassignment epochでだけ行う。保存時にrunningでもclockを進めず`paused/processRecovered`へ変更する。active Runとarmed timerからscheduleを再構築し、contiguous event logでgapを埋められなければ`paused/recoveryGap`のまま継続を拒否する。 + +State frameはlatest-winsでreplayしない。keyframeは全visible latest-wins elementと全visible Anchor binding、deltaは前回送信後に変わったentryだけを持つ。frame sequenceは正に単調増加し、delta gap、fence不一致、`base_reliable_sequence`がControl適用cursorより新しい場合はframeを捨て、次のkeyframeを要求する。Timeline、Surface crossfade、Media、Model clipの毎frame補間値を送らない。 + +Node keyframe patchはactive、visible、opacity、transformの全fieldを持つ。delta patchは一つ以上の変更fieldを持つ。empty patch、同じframeのduplicate element ID、profile外Node、Timelineが現在所有するpropertyを含むpatchをrejectする。State mailboxはelement/field単位でlatest-wins mergeし、送信中のimmutable frameを書き換えない。 + +TrackingはPresenterのState streamだけが送れる。frame sequenceは正に単調増加、sample targetはduplicate-free、最大4件である。frameはfiniteでcanonicalな`presentation_from_quest_local` rigid Poseをrequiredで持つ。capture timestampは診断だけに使い、freshnessやcanonical ingress orderに使わない。Runtimeは同じframeのcalibrationでQuest-local poseをPresentation Spaceへ変換し、受理時のRuntime monotonic時刻をfreshness authorityとしてfresh sampleからtracking evaluatorをseedする。recovery直後のsampleで疑似edgeを生成しない。 + +Anchor patchはvisibleなAnchor-bound Node IDをkeyにし、`unavailable`または`sample`のexactly oneを持つ。sampleはfollowするposition/rotationだけをoptional fieldで持つ。500 msを超えたsample、cut/origin/assignment不一致をunavailableとし、対象Nodeを描画もhit-testもしない。別AnchorやStageへfallbackしない。 + +## 7. Runtime state とAction transaction + +Runtime CoreだけがTrigger、Guard、Cue選択、Action、Run allocator、completionを確定する。一つのexternal inputまたはdue-event drainはArchitecture §12.9の順序に従い、1,024回目までのmicrostepを確定できる。1,025回目は評価・適用せず、それ以前の確定済みmutationを維持して`paused/microstepLimitExceeded`へ一つのatomic mutationで遷移する。 + +Action batchは全Actionと`next`をpre-event snapshotに対して解決し、property claimを作ってから一度だけcommitする。同一claim、active Runとの競合、型不一致、存在しないlive target、同じStateへのcrossfade、active transitionへのsetState、crossfade中のmodel clip要求が一つでもあれば全体をrejectする。Action配列順、last-write-wins、queue、interrupt、replaceで解決しない。永続化を含むcommit失敗はstate/eventを公開せず`paused/atomicCommitFailed`にする。 + +Published artifact上存在すべきtargetや型がRuntimeで欠落する場合は入力rejectではなくinvariant faultである。RuntimeStatusChangedを確定し、以後Presenterの明示resumeと再検証まで新規inputを受理しない。 + +### 7.1 Progression とresource lifetime + +Presentation開始時にpresentation-owned stateを一度初期化し、initial Groupへentryする。最初に接続するclientは完成後のConnection Snapshotを受け、初期化event列のreplayを必要としない。Group entryでepochを増やし、group-owned Node、Surface、Variable、Media、Model clipをDefinition初期値へresetし、initial Step entryでstep epochを増やす。inactive GroupのstateをSnapshotに残さない。 + +Group exitはInteraction無効化、group-owned non-blocking Runのcancel、Node deactivate、group-owned state破棄、GroupExitedの順。presentation-owned stateとRunは維持する。Step exitではStepExecutionを破棄するが、Runはowner規則に従う。 + +blocking Run中はphaseをtransitioningにし、通常inputを評価しない。期限に達したTimerはfiredにするがCueを評価・再試行しない。Run completionはstateとblocking setだけを更新し、completion Triggerから新Cueを開始しない。最後のblocking Run後にpending nextを一度適用する。 + +### 7.2 Surface transition + +CutはRunを作らずStateをatomicに変更する。同じStateへのcutはeventなしの成功no-op。Crossfadeは正のdurationを持つblocking Runで、受理時点でcanonical Stateを遷移先へ変更する。active transition中の追加setStateと同じStateへのcrossfadeをrejectする。 + +`u = clamp((runtimeTime - startedAt) / duration, 0, 1)`とし、旧weightは`1-easing(u)`、新weightは`easing(u)`。deadlineは`startedAt + duration`でchecked additionする。完了時にRunとtransition IDを除去してInteractionを有効化する。Renderer acknowledgementをcompletion sourceにしない。 + +### 7.3 Timeline + +Timeline local timeは`t = clamp(runtimeTime - startedAt, 0, duration)`。easing、Vector、Quaternion補間はADR-0007の式をexactに用いる。completionでは全track終値をNode stateへ一transactionでcommitしてRunを除去する。explicit stopとGroup exitは停止時`t`の値をcommitする。Presentation endは値をcommitせずcancelする。inactive stopはeventなしの成功no-opである。 + +### 7.4 Video media + +Media playbackのraw位置はplaying時に`raw = positionAtReference + (runtimeTime-referenceRuntimeTime)`、paused時に固定positionとする。loopなら`position = raw mod duration`、non-loopなら`position = clamp(raw, 0, duration)`とする。Global pauseはlogical clock停止だけで表す。play中へのplay、paused中へのpauseは成功no-op、停止状態へのpauseはbatch rejectとする。停止状態へのplayは`MediaStoppedState.held_position_ms`から新しいRunとして開始する。`media.seek.positionSeconds`はActionValue解決後にfiniteかつnon-negativeを検査し、`position_ms = positionSeconds * 1000`をbinary64のまま求める。`0..duration`だけを受理し、playingなら同じruntime timeへrebase、pausedなら固定位置を置換する。停止状態へのseekはRunを作らず、`MediaStoppedState.held_position_ms`をseek位置へ置換する。explicit stopは現在位置を`MediaStopped.held_position_ms`と`MediaStoppedState.held_position_ms`へ保存する。自然完了は`MediaCompleted.held_position_ms = duration`を通知し、同じevent適用で`MediaStoppedState.held_position_ms = 0`へresetするため、seekを挟まない再playは位置`0`から始まる。 + +非loop動画がdurationへ達したらRunを除去し最終位置を保持してMediaCompletedを一度生成する。loop動画は`position mod duration`で継続し自然完了しない。Group exitはRunをcancelしてgroup stateを破棄する。Presentation endでは値をcommitせずcancelする。音声trackは同じposition、pause、seek、loopに従う。 + +### 7.5 Model clip + +ModelNodeごとの状態は次のexactly oneである。 + +- `default_pose` +- `held_clip(clipId, position)` +- `held_blend(from clip/position, to clip/position, toWeight)` +- `active(runId)`。対応するactive Runはsingleまたはcrossfade。 + +single playback位置は、playingなら`raw = positionAtReference + (runtimeTime-referenceRuntimeTime) * speed`、pausedなら固定positionとする。speedはfiniteかつ正。loopは`raw mod duration`、non-loopは`clamp(raw, 0, duration)`。non-loopがdurationへ達するとRunを除去し最終`held_clip`を保存し、ModelClipCompletedを一度生成する。 + +cut play actionはdefault、held_clip、held_blend、active singleから即時切替でき、既存Runがあれば停止時姿勢の中間commitを公開せず同じtransaction内で新しいRun IDへ置換する。crossfadeはheld_clipまたはactive singleからだけ開始でき、active singleも新しいRun IDへ置換する。`conflict: reject`はactive crossfadeまたは同じbatchで同一`modelClip(modelNodeId)` claimへ複数操作がある場合をrejectする意味であり、明示したcutによるactive singleの置換を禁止しない。`modelClip(modelNodeId)`と`nodeField(modelNodeId, transform/opacity/visible)`は独立claimなので、TimelineによるModelNodeのTRS/opacity変更とclip内部姿勢の再生は同時に許す。defaultをsourceにするcrossfadeは禁止し、最初のplayはcutだけを許す。held_blendまたはactive crossfadeからcrossfadeを要求すると常にbatch rejectする。 + +Crossfade source/targetそれぞれのclip位置を上の式で評価する。`transitionElapsedAtReference = transition_clock.playing.position_at_reference_ms`、`transitionReference = transition_clock.playing.reference_runtime_time_ms`として、`u = clamp((transitionElapsedAtReference + runtimeTime-transitionReference)/duration, 0, 1)`、target weight=`easing(u)`とする。paused中は`transitionElapsedAtReference = transition_clock.paused.position_ms`として`u`を固定する。pauseは現在elapsedを`transition_clock.paused.position_ms`へ保存し、resumeは同じ値を`transition_clock.playing.position_at_reference_ms`へ保存して`reference_runtime_time_ms`だけを現在runtime timeへ更新する。`from_is_held=true`はheld_clipから開始したsourceを表し、その`from.playback`は常に`PausedClock`で固定する。`from_is_held=false`のsourceとtargetだけをpause/resume時にrebaseする。常に二クリップ以下で、layer、mask、additive、root motionは適用しない。source clipが先に自然終端へ達しても独立したcompleted eventを生成せず、終端poseをfade完了まで保持する。non-loop targetが先に自然終端へ達した場合も終端poseを保持し、fade完了時にまずModelClipCrossfadeCompletedを生成して同じRun IDをsingle targetへ遷移し、同じlogical tick内の直後にModelClipCompletedを生成してRunを除去し`held_clip`を保存する。 + +pauseはsingleなら現在clip位置、crossfadeなら進行中のclip位置と現在transition elapsedを各`PausedClock`へrebaseしRunを保持する。resumeは同じ値を現在runtime time基準の`PlayingClock`へrebaseする。`from_is_held=true`のsourceは両操作で固定したままとする。既にpausedへのpause、playingへのresumeはeventなし成功no-op。 + +pause/resumeはactive Runにだけ適用でき、default、held_clip、held_blendではbatch rejectする。stopはactive single/crossfadeに上記規則を適用し、defaultまたはheld状態ではeventなし成功no-opとする。`loop=true`かつ`completion=blocking`は自然にblocking setを解除できないためbuild errorとし、loop Runはnon-blockingだけを許す。non-loop blocking Runは自然終了までblockingで、transitioning中の別Cueからpause/stopできない。Session全体のpause/resumeはlogical clockを止めるだけで個別Run phaseを変更しない。 + +explicit stopはsingleなら`held_clip`、crossfadeなら両clipの現在位置と現在target weightを`held_blend`に保存してRunを除去する。held_blendから新crossfadeは禁止し、cut playだけで抜けられる。自然終了、停止、crossfade完了のstale callbackはRun ID、assignment、owner epoch不一致なら無視する。 + +Group exitはgroup-owned Runを`ModelClipCanceled/GROUP_EXIT`として除去した後にNode stateごと破棄する。Presentation endは保持姿勢をcommitせず、全active model RunをRun ID順に`ModelClipCanceled/PRESENTATION_ENDED`として除去する。これらを明示停止の`ModelClipStopped`と混同しない。 + +Model clipは骨格内部だけを評価し、ModelNode Transformを変更しない。素材のroot motionが除去済みであることをDelivery前に検証する。異なるModelNodeは独立して同時再生できる。 + +## 8. gRPC status とstable reason + +| Status | reason | 条件 | +| --------------------- | -------------------------------- | ---------------------------------------- | +| `INVALID_ARGUMENT` | `handshake_order_invalid` | handshake順、空payload、重複handshake | +| `INVALID_ARGUMENT` | `message_invalid` | required presence、enum、range、参照不能 | +| `INVALID_ARGUMENT` | `idempotency_key_reused` | keyを別fingerprintで再利用 | +| `UNAUTHENTICATED` | `authentication_required` | 認証なし・期限切れ | +| `UNAUTHENTICATED` | `state_nonce_invalid` | nonce不正、期限切れ、再利用 | +| `PERMISSION_DENIED` | `presenter_required` | Viewerがcommand/tracking送信 | +| `FAILED_PRECONDITION` | `protocol_incompatible` | version/capability不一致 | +| `FAILED_PRECONDITION` | `publication_fence_mismatch` | Publication不一致 | +| `FAILED_PRECONDITION` | `presentation_origin_mismatch` | input origin不一致 | +| `FAILED_PRECONDITION` | `state_ready_fence_mismatch` | StateReady cut不一致 | +| `FAILED_PRECONDITION` | `asset_residency_lost` | active asset residency喪失 | +| `RESOURCE_EXHAUSTED` | `reliable_subscriber_slow` | reliable送信queue上限 | +| `RESOURCE_EXHAUSTED` | `snapshot_catch_up_exhausted` | Snapshot再試行上限 | +| `RESOURCE_EXHAUSTED` | `message_limit_exceeded` | item byte上限 | +| `RESOURCE_EXHAUSTED` | `state_rate_exceeded` | 継続的State rate超過 | +| `UNAVAILABLE` | `runtime_assignment_unavailable` | assignmentへ到達不能 | + +status codeは標準`grpc-status`で返す。reasonはtrailing metadata key `unframe-reason`の単一ASCII lower-snake valueとして表の値を返す。同じresponseにこのkeyを複数付与してはならない。`grpc-message`は診断専用で、その本文の解析をcontractにしない。`CommandRejected`は回復可能な入力結果だけに使い、認証、role、fence、protocol、idempotency違反を丸めない。 + +## 9. 完了条件 + +実装はProto descriptor compileに加え、JSON/Proto parity、deterministic binary fixture、全enum/oneof fail-closed、Delivery closureとbudget境界、replay gap、Snapshot cut、idempotency、Action transaction、Surface/Timeline/Video/Model clipの状態遷移、pause/recovery、State frame gapを同じfixtureで検証する。生成Go/C# client接続とUnity実機budget計測は別作業だが、ここで定義したfieldや挙動をconsumer判断で変更しない。 diff --git a/docs/presentation/DATA_MODEL.md b/docs/presentation/DATA_MODEL.md new file mode 100644 index 0000000..131364e --- /dev/null +++ b/docs/presentation/DATA_MODEL.md @@ -0,0 +1,50 @@ +# Presentation データ契約 v2 + +## 正本と読み方 + +v2 は今回の機能範囲に対するデータ契約である。構造は Zod / Protobuf、構造だけでは表せない参照整合性・計算・状態遷移・拒否条件は以下の仕様を正本とする。両方を満たして初めて有効なデータとなる。 + +| 対象 | 構造の正本 | 意味規則 | +| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------- | +| Definition / RenderBundle / AssetSet / Build / Publication / Capability | [Zod v2](../../packages/contracts/src/presentation/v2/index.ts) | [成果物契約](./CONTRACT_ARTIFACTS.md) | +| 配信と再接続に共通の型 | [presentation v2](../../packages/contracts/proto/unframe/presentation/v2/runtime.proto) | [配信・実行契約](./CONTRACT_RUNTIME.md) | +| DeliveryManifest | [delivery v2](../../packages/contracts/proto/unframe/delivery/v2/delivery.proto) | 同上 | +| Command / Event / Run / Snapshot / StateFrame | [realtime v2](../../packages/contracts/proto/unframe/realtime/v2/realtime.proto) | 同上 | + +型・意味規則が旧 Architecture の例や初期 v1 と異なる場合は、この v2 契約を優先する。Architecture と ADR は設計理由を説明する。v1 は既存 Compiler / Core が利用する初期 subset であり、v2 の代替表現ではない。アプリケーションを v2 へ移す実装は別途必要である。 + +## 情報の配置 + +```text +PresentationDefinition +├─ schemaVersion / presentationId / metadata +├─ stage: coordinateSystem / size / zones +├─ scene: nodes / surfaces +└─ flow: initialGroupId / groups / variables / timelines + +RenderBundle 描画成果物、モデル素材と clip の対応、build provenance +AssetSetManifest Asset ID → checksum / mediaType / encodedSizeBytes +BuildManifest 一回の build が生成した上記成果物の hash と version +PublishedPresentation 公開 epoch と build の固定参照 +DeliveryManifest 参加者・端末別に選択した公開物と取得情報 +Runtime State 進行・変数・Node・再生・遷移の現在値 +``` + +Definition にダウンロード URL、端末 capability、現在の再生時刻を入れない。AssetSet に自身の hash を入れず、公開物がその canonical hash を参照する。配信 URL の更新は公開された素材の identity を変えない。 + +## 採用範囲 + +- 3D は Native 3D、静的・有限 State の UI は baked-web、連続更新は限定 Native UI の短文・数値・timer、事前計算した内部アニメーションは Video。 +- 任意の HTML / JS を実行する WebView、独立音声・BGM・効果音・空間音声は対象外。Video 内の音声は動画と一体で再生する。 +- モデルは通常一 clip、crossfade 中だけ二 clip。部位 mask / layer / additive は対象外。モデル内部の姿勢と空間 Node の TRS を分離する。 +- Surface State は固定した内容ツリーへの型付き override。Node の追加・削除・並べ替えは行わない。 + +採用理由は [描画方式](../decisions/0014-presentation-rendering-scope.md)、[成果物境界](../decisions/0015-presentation-definition-artifact-boundaries.md)、[モデルアニメーション](../decisions/0016-model-animation-scope.md) を参照する。 + +## 検証と実装の境界 + +[契約 package](../../packages/contracts/README.md) に生成・drift check・fixture の実行方法を記載する。JSON Schema と descriptor は生成物であり、手編集しない。 + +構造テストの合格は、GLB importer、動画 decoder、font atlas、Runtime reducer、再接続、Quest の実機動作が実装済みであることを意味しない。意味検証は Core、投影と admission は Delivery、素材変換は Compiler、描画は各 consumer の責務となる。端末の上限値は検証済み CapabilityProfile の必須入力であり、fixture の合成値を製品設定に使わない。 + +Core の最初の v2 実装は [公開物の整合性検証](../../packages/presentation-core/ARCHITECTURE.md#10-v2-公開物の整合性検証) である。成果物間の参照・hash を検証し、完全な意味検証や既存 Compiler / consumer の v2 接続とは区別する。 diff --git a/docs/presentation/DESIGN.md b/docs/presentation/DESIGN.md index 25170df..ab5ee71 100644 --- a/docs/presentation/DESIGN.md +++ b/docs/presentation/DESIGN.md @@ -1,12 +1,15 @@ # Unframe Presentation Implementation Design - **Status**: Draft -- **Date**: 2026-08-25 +- **Date**: 2026-08-28 - **Scope**: Presentation authoring、compiler、renderer、publication、delivery、runtime を実装するための repository layout と ownership - **Related**: - [Presentation Architecture](./ARCHITECTURE.md) - [Repository Architecture](../../ARCHITECTURE.md) - [ADR-0006: プレゼンテーションアーキテクチャを定義する](../decisions/0006-presentation-rendering-strategy.md) + - [ADR-0014: Presentation の描画方式を限定する](../decisions/0014-presentation-rendering-scope.md) + +具体的な成果物・配信・Runtime のデータ契約は [Presentation v2](./DATA_MODEL.md) を正本とする。本書は実装配置と責務を説明する。 ## 1. この文書の位置付け @@ -30,6 +33,8 @@ 新しい directory は、独立した責務、依存方向、公開 API、品質ゲートのいずれかを持つ場合に作る。単にファイル数を減らすための階層や、将来使うかもしれない空 package は作らない。 +実装前に責務をレビューする必要がある Target package は、`Proposal / Target, not implemented` と明記した `ARCHITECTURE.md` だけを先行して置ける。この directory は `package.json`、public entrypoint、workspace package を持つまでは実装済み package とみなさない。 + ### 2.2 Package は実行環境をまたいで implementation を共有しない TypeScript package を Go Realtime や Unity C# から直接利用しない。複数言語間では JSON Schema、OpenAPI、Protocol Buffers、fixture などの contract artifact を共有する。 @@ -52,6 +57,21 @@ CLI は compiler orchestration の利用者向け entrypoint とし、compile lo 本書に記載する新規 directory は Target layout であり、現在すべて存在することを意味しない。既存の Control Plane schema、Web Editor PoC、Unity importer、Realtime foundation は、それぞれの移行境界が設計されるまで維持する。 +### 2.6 Initial dependency baseline + +最初の Presentation package chain では、外部依存の責務を次のように固定する。 + +- portable Presentation contract は Zod 4 schema を正本とし、TypeScript 型を推論し、JSON Schema Draft 2020-12 を生成する。JSON Schema は別の手書き正本にしない。 +- Presentation TypeScript packageのruntime trust boundaryは、構造、scalar、tuple、enum、local cross-field constraintをZod 4で検証する。公開型は可能な限りschemaから推論し、同じshapeを手書きtype guardとZodへ二重定義しない。Zodを使わない`typeof`、`Array.isArray`、`Object.keys`等の直接shape validationは原則禁止する。 +- caller-owned object、plugin output、host callbackをZodへ直接渡さない。accessor、Proxy、sparse array、symbol、cycle、prototype、typed-array ownershipを扱うdescriptor-safe snapshot / copyを先に行い、再構築したdataだけをZodへ渡す。この安全化処理と、参照解決、重複、tree cycle、byte arithmetic、determinism、複数artifact間の一致などのalgorithmic semantic invariantは手書きで保持できるが、Zodが表現するlocal shape validationとは分離する。 +- semantic canonicalization は RFC 8785 実装の `canonicalize`、content hash は `@noble/hashes` を使う。Presentation 固有の set 正規化は Core が直列化前に行う。 +- Authoring TS / TSX の構文解析は固定 version の classic TypeScript Compiler API を直接使う。`ts-morph` と TypeScript 7 `unstable/sync` は初期の pure parser boundary に追加しない。 +- Opaque renderer source の bundle は Rolldown の programmatic API と固定内部 plugin を使う。任意 plugin、Node API、network import、package 外参照は受け入れず、Browser execution / capture の具体方式は別に決める。 +- user-facing TUI は Bun runtime、`@opentui/core`、`@opentui/solid`、`@opentui/keymap`、Solid を使う。pnpm は package manager と lockfile 管理を継続し、headless API は native TUI import から分離する。 +- Control Plane HTTP API は現行どおり Hono / OpenAPIHono を application boundary とする。Presentation package から Hono へ依存しない。 + +これらは各 package の内部責務を代替しない。依存 library へ渡す前後の trust boundary、determinism、capability allowlist、diagnostics は owning package が保持する。 + ## 3. Target repository layout ```text @@ -103,7 +123,7 @@ unframe/   └─ decisions/ # cross-boundary decisions and rationale ``` -Target directory は担当実装を開始する時点で追加する。empty placeholder、空の `package.json`、将来用の `src/` は先行して作らない。 +Target implementation directory は担当実装を開始する時点で追加する。責務レビュー用の `ARCHITECTURE.md` を除き、empty placeholder、空の `package.json`、将来用の `src/` は先行して作らない。 ## 4. Shared package responsibilities @@ -240,7 +260,7 @@ Compiler と concrete renderer の間の plugin contract を所有する。 この package は `presentation-core`、`presentation-renderer-api`、`presentation-assets`に依存する。Capture結果のresize、encode、checksumは`presentation-assets`へ委譲する。Compilerからはpluginとして注入され、Compilerへimplementationを逆流させない。 -Native UI、Video、その他の renderer は、実装開始時にそれぞれ独立 package として追加する。`presentation-renderer-web` に仮実装を置かない。 +Native UI と Video renderer は、実装開始時にそれぞれ独立 package として追加する。`presentation-renderer-web` に仮実装を置かない。`embedded-web`、WebView、任意の Runtime HTML / JavaScript renderer package は追加しない。 ### 4.6 `packages/presentation-assets` @@ -298,14 +318,16 @@ Authoring Project から PresentationDefinition と RenderBundle を生成する - Web Editor UI - Runtime progression evaluation -Compiler は `presentation-core`、`presentation-authoring`、`presentation-renderer-api`、`presentation-assets`に依存する。concrete renderer はhostから注入する。 +Compiler は `presentation-core`、`presentation-authoring`、`presentation-renderer-api`、`presentation-assets`と固定 version の TypeScript に依存する。concrete renderer はhostから注入する。 ### 4.8 `packages/presentation-cli` -Authoring Projectを操作する利用者向け executable を所有する。 +Authoring Projectを操作する利用者向け executable と、automation 向け headless application boundary を所有する。 **Responsibilities** +- Bun / OpenTUI Solid による interactive command selection と terminal lifecycle +- native TUI に依存しない headless `check` / `build` API - `init`、`dev`、`check`、`build`、`test`、`preview`、`publish` command - project config、lockfile、cache、output directoryの解決 - Compilerとrenderer pluginのcomposition @@ -320,7 +342,9 @@ Authoring Projectを操作する利用者向け executable を所有する。 - renderer artifact generation - durable PublishedPresentation state、publicationEpoch、active-use lock -CLIは`presentation-compiler`と、既定で有効にするconcrete rendererに依存する。CLIからpackage内部の非公開moduleをimportしない。 +CLIは`presentation-compiler`と、既定で有効にするconcrete rendererに依存する。TUI adapter は Bun と OpenTUI stack に閉じ、headless root export から Zig native core を読み込まない。CLIからpackage内部の非公開moduleをimportしない。 + +Current implementation は filesystem host と Fixed Browser を接続した `check` / `build` process entry、atomic output、および command selection 用の TUI shell を持つ。TUI と process command の接続、publish、preview は未実装である。 ## 5. Contract and generated artifact ownership @@ -443,6 +467,7 @@ presentation-compiler ──────────→ presentation-assets presentation-cli ───────────────→ presentation-compiler presentation-cli ───────────────→ presentation-renderer-web presentation-cli ───────────────→ Control Plane API client adapter +presentation-cli TUI ───────────→ Bun / OpenTUI core / Solid / keymap Web Editor ─────────────────────→ presentation-core / presentation-authoring Control Plane ──────────────────→ presentation-core / generated contracts @@ -465,7 +490,7 @@ Unity ──────────────────────── ## 8. Standard package layout -各新規TypeScript packageは、必要になった範囲で次の形を使用する。 +各新規TypeScript packageは、必要になった範囲で次の形を使用する。実装前は責務レビュー用の `ARCHITECTURE.md` だけが存在してよい。 ```text packages// @@ -525,12 +550,13 @@ README.md Cross-boundaryな選択理由は`docs/decisions/`のADRへ置く。`ARCHITECTURE.md`はprompt historyや実装手順ではなく、そのdirectoryを単独で理解するためのcurrent / target boundaryを記述する。 -### 10.1 Planned `ARCHITECTURE.md` +### 10.1 Package responsibility `ARCHITECTURE.md` -実装開始時に次の境界へ追加または既存文書を更新する。 +次の境界は、実装に先立って責務、依存、invariant、検証方針を提案する `ARCHITECTURE.md` を持つ。未実装 package の文書は Target であることを明示し、実装開始時に Current 状態を更新する。 ```text packages/contracts/ARCHITECTURE.md +packages/api-client-csharp/ARCHITECTURE.md packages/presentation-core/ARCHITECTURE.md packages/presentation-authoring/ARCHITECTURE.md packages/presentation-components/ARCHITECTURE.md @@ -546,6 +572,8 @@ app/server/integration/ARCHITECTURE.md app/unity/ARCHITECTURE.md ``` +現行の `packages/api-client-typescript` は Control Plane の Hono RPC / Better Auth client、`packages/config` は repository tooling の共有設定であり、Presentation authoring / compiler / renderer の Target package layer には含めない。それぞれの境界をさらに分割する必要が生じた場合は、その package 自体の設計から `ARCHITECTURE.md` の要否を判断する。 + `src/validation/`や`src/cache/`など、同じpackageの内部directoryすべてに`ARCHITECTURE.md`を置かない。独立した公開境界、別runtime、別言語、別品質ゲートへ分割された場合にだけ追加する。 ## 11. Implementation order @@ -570,13 +598,15 @@ Directoryとpackageは次の順序で実装を開始する。 最初のmilestoneは、手書きのreference Authoring Projectから、CLIを通じてcanonical PresentationDefinition JSONと一つのbaked-web Surfaceを含むRenderBundleをdeterministicに生成することとする。Publish、Realtime、Unity、GUI editingはこのmilestoneの完了条件に含めない。 +2026-09-15 時点で M1 の static Authoring Source から Fixed Browser capture、Definition / RenderBundle / PNG の atomic output までの経路は実装済みである。Opaque renderer は bundle まで、CLI の TUI は command selection までであり、publish、preview、完全な Opaque execution は未実装である。 + ## 12. Deferred decisions 次は各packageの`ARCHITECTURE.md`または別ADRで決める。 -- Semantic contract sourceに使用するschema libraryとJSON Schema generation方法 +- Cue、Theme、Token、Named Styleを含む完全版Semantic contractのschema設計 - Lossless Syntax Treeとsource patchingの実装 -- Opaque rendererを実行するBrowser process / isolateとcapability +- Opaque renderer bundleを実行するBrowser process / isolateとruntime capability - Component package distributionと`unframe.lock`の形式 - Compiler plugin discoveryとversion negotiation - Browser capture processの分離方法 diff --git a/docs/presentation/UI_RENDERING_COMPARISON.md b/docs/presentation/UI_RENDERING_COMPARISON.md new file mode 100644 index 0000000..dbbb2a8 --- /dev/null +++ b/docs/presentation/UI_RENDERING_COMPARISON.md @@ -0,0 +1,82 @@ +# Presentation Surface 描画方式の検証条件 + +- **Status**: Accepted(描画方式は確定。実機 profile と測定結果は未確定) +- **Date**: 2026-09-15 +- **Scope**: 2026 年 11 月の MR プレゼンで使用する Presentation Surface +- **Related**: + - [Issue #73: 発表の絵コンテ v0](https://github.com/unframe-dev/unframe/issues/73) + - [Issue #74: Web と Unity の UI 統一方式の比較条件](https://github.com/unframe-dev/unframe/issues/74) + - [Issue #76: 実機デモの制約と必要な MR 表現](https://github.com/unframe-dev/unframe/issues/76) + - [Issue #77: UI 表現方式の決定](https://github.com/unframe-dev/unframe/issues/77) + - [Issue #78: UI 統一方式の実機 PoC](https://github.com/unframe-dev/unframe/issues/78) + - [ADR-0014: Presentation の描画方式を限定する](../decisions/0014-presentation-rendering-scope.md) + - [ADR-0012: Texture budget と residency contract](../decisions/0012-texture-budget-residency-contract.md) + +## 1. 目的 + +[ADR-0014](../decisions/0014-presentation-rendering-scope.md) で採用した Native 3D、`baked-web`、`native-ui`、`video` について、適用条件と Quest 実機で残る性能リスクを検証する。方式の採否を比較し直す文書ではない。 + +Issue #73 と #76 から確定した入力を PoC profile として記録し、Issue #78 の実機結果を Issue #77 から参照できる形で残す。 + +対象は Presentation 内に配置する Surface である。Web Editor など Unframe 自身の操作 UI は対象外とする。Surface の移動、回転、拡縮、表示、opacity は Unity の Spatial Node / Timeline が担当する。 + +## 2. 採用方式と現在地 + +| 方式 | 適用範囲 | 現在地 | +| ----------- | ---------------------------------- | ------------------------------------------------------------------------ | +| Native 3D | Model、空間配置、移動、回転、拡縮 | Unity target。Presentation Runtime とは未接続 | +| `baked-web` | 静的 UI、少数の有限 Surface State | Local Compiler の初期 subset を実装済み。Delivery / Unity は未接続 | +| `native-ui` | Timer、Counter、短い動的 Text | portable contract / Unity adapter / 固有 budget は未実装 | +| `video` | 入力非依存で事前確定できる連続映像 | artifact / Unity adapter / decoder budget は未実装。v1 Delivery は非対応 | + +`embedded-web`、WebView、Unity Runtime で任意の HTML / CSS / JavaScript / WebAssembly を実行する方式は対象外であり、検証候補に含めない。`baked-web` の Opaque TS / React / CSS は固定した build-time Browser 内だけで実行し、生成済み Texture を配信する。 + +描画方式の採用は、実機性能や配信対応の完了を意味しない。ADR-0012 の v1 Delivery baseline は `baked-web` Texture だけを対象とする。`native-ui` と `video` は方式固有の budget と consumer 実装が受理されるまで Delivery で拒否する。 + +## 3. PoC profile と fixture + +性能測定を始める前に、次を一つの profile として固定する。未確定欄があれば結果を参考値として扱い、合否判定に使用しない。 + +- 絵コンテから選んだ代表 scene、Surface 内容、State、遷移、表示時間 +- Runtime 値の型、範囲、更新頻度、format、authority、client 間の同期許容差 +- Quest 機種、OS、Unity / XR package、build type、Graphics API、refresh rate、passthrough +- 同時表示 Surface 数、logical / physical size、解像度、State / clip 数、3D scene 負荷 +- cold / warm start、連続稼働時間、pause / resume、renderer 再生成、再接続の回数 +- CPU / GPU frame time、dropped frame、RAM、GPU memory、storage、startup / update / recovery の上限 +- reference Browser、viewport、DPR、font、locale、timezone、color space、pixel / color 差と SSIM 閾値 + +必要な方式だけを同じ standalone Unity scene と deterministic scenario driver で検証する。 + +| Fixture | 検証する内容 | +| -------------------- | -------------------------------------------------------------------------- | +| Finite State Surface | `baked-web` の State artifact、cut / crossfade、Spatial animation | +| Dynamic Text Surface | 必須用途がある場合の Timer、Counter、短い Text と portable binding | +| Precomputed Motion | 必須用途がある場合の Video、canonical playback epoch、seek / loop | +| Spatial Scene | Native 3D と Surface を含む最大負荷の移動、回転、拡縮 | +| Recovery | pause、renderer 破棄、resume、Snapshot 相当値の再適用後の State / 時刻復元 | +| Baseline | 同じ scene から対象を除いた CPU、GPU、memory の差分 | + +この PoC は renderer と実機負荷を検証する。未完成の Delivery / Realtime へ接続せず、end-to-end Delivery、network latency、production recovery の成立証明にはしない。 + +## 4. 測定と判定 + +各試行で device / OS、battery と給電、室温と thermal state、Unity / XR / graphics 設定、artifact checksum、renderer / codec version を記録する。端末、build、fixture、設定が異なる結果を同じ集計へ混ぜない。 + +- fidelity: reference と Quest capture、文字、alpha、color、font の差 +- performance: CPU / GPU frame time、dropped / synthetic frame、RAM、GPU memory、thermal throttling +- timing: first correct frame、State / Runtime 値の更新、client 間同期、Video 再生位置 +- recovery: pause / resume、renderer 再生成、再接続後の復元時間と成功率 +- artifact: build 時間、size、State / Surface 数、同一入力を 3 回 build した checksum +- operation: build、端末更新、debug、依存物 / license、未解決事項 + +`baked-web` は ADR-0012 の 2K-long-edge PNG / RGBA32、GPU 256 MiB、serial load CPU 256 MiB を hard gate として別途検査する。これは Quest process 全体の実測 memory 上限ではない。Native UI と Video の上限は Texture budget から推測せず、各方式の contract と実測に基づいて決める。 + +すべての方式は PresentationDefinition の意味状態、canonical time、および方式ごとに許可された Runtime input(tracking / anchor sample など)から表示を再現でき、artifact / font / codec / plan の integrity と互換性を配信前に検証できなければならない。portable subset や性能上限に収まらない表現は contract を暗黙に広げず、採用済み方式の責務へ分解して再設計する。 + +Session 中の renderer 変更、暗黙 downscale、mipmap 削除、artifact 差し替え、crossfade から cut への変更を fallback として許可しない。 + +## 5. 出力と完了条件 + +PoC 結果には fixture と artifact の参照、全測定値、baseline 差、試行数、失敗数、gate の pass / fail、visual 差、実機未検証項目、検証できた Current / Target contract の範囲を記録する。 + +PoC profile の全項目が確定値または根拠付き `N/A` であり、実際に使用する各方式の必要 fixture と gate の結果、未検証項目、11 月の demo に残す risk owner が揃った時点で完了とする。 diff --git a/examples/presentation/README.md b/examples/presentation/README.md new file mode 100644 index 0000000..ce5267b --- /dev/null +++ b/examples/presentation/README.md @@ -0,0 +1,19 @@ +# Presentation reference project + +This is the M1 filesystem Authoring Project acceptance fixture. `unframe.lock` embeds and pins the +minimal `@unframe/presentation` declaration package and the hashes of the theme and +structured surface declarations. Generated `.unframe/` and `dist` output are not source. + +From the repository root, `check` validates the static Authoring Source without starting a Browser. +`build` uses the provisioned Fixed Browser and atomically publishes `definition.json`, +`render-bundle.json`, and PNG assets through the managed `dist` symlink. + +```bash +pnpm --filter @unframe/presentation-cli run presentation -- check "$PWD/examples/presentation" +nix develop --command scripts/dev/install-presentation-browser.sh +nix develop --command env PLAYWRIGHT_BROWSERS_PATH="$PWD/.cache/playwright" \ + pnpm --filter @unframe/presentation-cli run presentation -- build "$PWD/examples/presentation" +``` + +`nix run .#presentation` copies this fixture to a temporary project, runs `check`, builds twice over +the existing managed output, and compares every artifact relative path and SHA-256 digest. diff --git a/examples/presentation/presentation.unframe.tsx b/examples/presentation/presentation.unframe.tsx new file mode 100644 index 0000000..a241a5d --- /dev/null +++ b/examples/presentation/presentation.unframe.tsx @@ -0,0 +1,58 @@ +import { definePresentation } from "@unframe/presentation"; + +export default definePresentation({ + id: "reference-presentation", + metadata: { title: "Unframe Reference" }, + stage: { + coordinateSystem: { unit: "meter", handedness: "right", upAxis: "+Y", forwardAxis: "-Z" }, + size: [1, 1, 1], + }, + theme: { themeId: "reference-theme" }, + scene: { + spatial: [ + { + id: "surface-node", + kind: "spatial", + name: "Reference surface", + owner: { kind: "presentation" }, + audience: { kind: "all" }, + parent: { kind: "stage" }, + order: 0, + transform: { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] }, + active: true, + visible: true, + opacity: 1, + }, + ], + components: [ + { + id: "reference-surface", + kind: "component-instance", + componentId: "reference-surface", + version: 1, + owner: { kind: "presentation" }, + spatialNodeId: "surface-node", + packageLock: { + packageVersion: "1.0.0", + packageIntegrity: + "sha256:c548b342a5dda6934c12afa6d5fb6e8ba01ba808375a7cda5a02867441213bf7", + manifestHash: "sha256:e88ff72e1a1a4b10d580d7b6a16ab6ac6e1c001041797db151ce5ca31f086f62", + structureHash: "sha256:27ea8d8ee71ccd11ef9a2199b462f746e1a236f42689c774bb41cc4977432b1c", + }, + props: {}, + slots: {}, + variants: {}, + partOverrides: [], + }, + ], + }, + assets: [], + flow: { + initialGroupId: "main", + groups: { + main: { id: "main", initialStepId: "start", steps: { start: { id: "start", cues: [] } } }, + }, + variables: {}, + }, + operations: [], +}); diff --git a/examples/presentation/reference-surface.manifest.ts b/examples/presentation/reference-surface.manifest.ts new file mode 100644 index 0000000..75c56ff --- /dev/null +++ b/examples/presentation/reference-surface.manifest.ts @@ -0,0 +1,15 @@ +import { defineComponentManifest } from "@unframe/presentation"; + +export default defineComponentManifest({ + componentId: "reference-surface", + version: 1, + authoring: { mode: "structured", structure: "./reference-surface.structure.tsx" }, + props: {}, + slots: {}, + parts: {}, + variants: {}, + states: { default: { kind: "state", initial: true } }, + actions: {}, + outputs: {}, + renderers: ["baked-web"], +}); diff --git a/examples/presentation/reference-surface.structure.tsx b/examples/presentation/reference-surface.structure.tsx new file mode 100644 index 0000000..e14f7a1 --- /dev/null +++ b/examples/presentation/reference-surface.structure.tsx @@ -0,0 +1,51 @@ +import { defineComponentStructure } from "@unframe/presentation"; + +export default defineComponentStructure({ + id: "reference-surface", + componentId: "reference-surface", + root: { + id: "reference-surface-root", + kind: "surface", + physicalSizeMeters: [1, 1], + logicalSize: [1, 1], + fit: "contain", + root: { + id: "reference-frame", + kind: "frame", + layout: { kind: "absolute", x: 0, y: 0, width: 1, height: 1 }, + children: [ + { + id: "reference-text", + kind: "text", + value: "Unframe", + layout: { kind: "absolute", x: 0, y: 0, width: 1, height: 1 }, + }, + ], + }, + baseSemanticTree: { + rootNodeIds: ["reference-text"], + nodes: { + "reference-text": { + id: "reference-text", + parentId: null, + order: 0, + role: "paragraph", + text: "Unframe", + }, + }, + }, + interactions: {}, + initialStateId: "default", + states: { default: { id: "default", semanticOverrides: [], enabledInteractionIds: [] } }, + renderIntent: { + updateModel: "static", + interaction: "none", + internalAnimation: "none", + rendererPreference: "baked-web", + fallbackPolicy: "reject", + }, + }, + partBindings: {}, + slotPlacements: {}, + timelines: [], +}); diff --git a/examples/presentation/reference-theme.unframe.ts b/examples/presentation/reference-theme.unframe.ts new file mode 100644 index 0000000..49a13b9 --- /dev/null +++ b/examples/presentation/reference-theme.unframe.ts @@ -0,0 +1,3 @@ +import { defineTheme } from "@unframe/presentation"; + +export default defineTheme({ id: "reference-theme", tokens: {}, namedStyles: {} }); diff --git a/examples/presentation/unframe.config.ts b/examples/presentation/unframe.config.ts new file mode 100644 index 0000000..6f560c9 --- /dev/null +++ b/examples/presentation/unframe.config.ts @@ -0,0 +1 @@ +export default { entryFile: "presentation.unframe.tsx" }; diff --git a/examples/presentation/unframe.lock b/examples/presentation/unframe.lock new file mode 100644 index 0000000..4c58ce6 --- /dev/null +++ b/examples/presentation/unframe.lock @@ -0,0 +1,41 @@ +{ + "schemaVersion": 1, + "packageDependencies": [ + { + "packageName": "@unframe/presentation", + "packageVersion": "1.0.0", + "packageIntegrity": "sha256:c548b342a5dda6934c12afa6d5fb6e8ba01ba808375a7cda5a02867441213bf7" + } + ], + "packages": [ + { + "packageName": "@unframe/presentation", + "packageVersion": "1.0.0", + "packageIntegrity": "sha256:c548b342a5dda6934c12afa6d5fb6e8ba01ba808375a7cda5a02867441213bf7", + "files": [ + { + "fileName": "index.ts", + "sourceText": "export const definePresentation = (value: T): T => value;\nexport const defineTheme = (value: T): T => value;\nexport const defineComponentManifest = (value: T): T => value;\nexport const defineComponentStructure = (value: T): T => value;\n" + } + ], + "exports": [{ "subpath": ".", "targetFile": "index.ts" }], + "dependencies": [] + } + ], + "themeHashes": [ + { "themeId": "reference-theme", "hash": "sha256:ad2ce030f24df3aeb16f38bf7913b40a40195c28f553a1015d7bf943ec30b244" } + ], + "componentLocks": [ + { + "componentId": "reference-surface", + "version": 1, + "lock": { + "packageVersion": "1.0.0", + "packageIntegrity": "sha256:c548b342a5dda6934c12afa6d5fb6e8ba01ba808375a7cda5a02867441213bf7", + "manifestHash": "sha256:e88ff72e1a1a4b10d580d7b6a16ab6ac6e1c001041797db151ce5ca31f086f62", + "structureHash": "sha256:27ea8d8ee71ccd11ef9a2199b462f746e1a236f42689c774bb41cc4977432b1c" + } + } + ], + "assets": {} +} diff --git a/flake.nix b/flake.nix index 38381b0..da93c01 100644 --- a/flake.nix +++ b/flake.nix @@ -22,6 +22,7 @@ # ツールチェイン。旧 mise.toml のツール固定を置換する。 toolchain = [ + pkgs.bun pkgs.nodejs_22 pkgs.pnpm pkgs.go @@ -32,6 +33,7 @@ pkgs.dotnet-sdk_8 pkgs.powershell pkgs.git + pkgs.git-lfs pkgs.coreutils pkgs.bash ]; @@ -40,12 +42,43 @@ # Linux では nix-ld 経由で GNU 動的リンカーを使用する。 # /lib64 の shim 自体は NixOS 側の programs.nix-ld.enable で有効化する。 nixLdPackages = pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.nix-ld ]; + chromiumRuntime = pkgs.lib.optionals pkgs.stdenv.isLinux [ + pkgs.glib + pkgs.nspr + pkgs.nss + pkgs.atk + pkgs.at-spi2-atk + pkgs.dbus.lib + pkgs.libX11 + pkgs.libXcomposite + pkgs.libXdamage + pkgs.libXext + pkgs.libXfixes + pkgs.libXrandr + pkgs.libxcb + pkgs.libgbm + pkgs.mesa + pkgs.expat + pkgs.libxkbcommon + pkgs.systemd + pkgs.alsa-lib + pkgs.fontconfig + pkgs.noto-fonts-cjk-sans + ]; + presentationFontconfig = pkgs.writeText "unframe-presentation-fontconfig.conf" '' + + + + ${pkgs.noto-fonts-cjk-sans}/share/fonts + + ''; nixLdEnvironment = pkgs.lib.optionalAttrs pkgs.stdenv.isLinux { NIX_LD = pkgs.stdenv.cc.bintools.dynamicLinker; - NIX_LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ + NIX_LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath ([ pkgs.glibc pkgs.stdenv.cc.cc - ]; + ] ++ chromiumRuntime); + FONTCONFIG_FILE = presentationFontconfig; }; # scripts/ の実処理を flake app としてラップする。 @@ -57,6 +90,11 @@ name = "unframe-${name}"; runtimeInputs = toolchain; text = '' + ${pkgs.lib.concatStringsSep "\n" ( + pkgs.lib.mapAttrsToList ( + variable: value: "export ${variable}=${pkgs.lib.escapeShellArg value}" + ) nixLdEnvironment + )} root="''${REPO_ROOT:-$(git rev-parse --show-toplevel)}" exec "''${root}/scripts/${script}" "$@" ''; @@ -92,6 +130,10 @@ name = "control-plane"; script = "ci/control-plane.sh"; }; + presentation = mkApp { + name = "presentation"; + script = "ci/presentation.sh"; + }; realtime = mkApp { name = "realtime"; script = "ci/realtime.sh"; diff --git a/packages/api-client-csharp/ARCHITECTURE.md b/packages/api-client-csharp/ARCHITECTURE.md new file mode 100644 index 0000000..31051a4 --- /dev/null +++ b/packages/api-client-csharp/ARCHITECTURE.md @@ -0,0 +1,64 @@ +# C# Generated Client Architecture + +- **Status**: Current placeholder / Target generation boundary +- **Scope**: OpenAPI と Protocol Buffers から生成する C# artifact +- **Related**: + - [Contracts Architecture](../contracts/ARCHITECTURE.md) + - [Presentation Implementation Design](../../docs/presentation/DESIGN.md) + +## 1. Role + +`api-client-csharp` は Unity / C# consumer が Control Plane、Delivery、Realtime contract を利用するための generated artifact boundary である。Source contract の正本でも Unity integration layer でもない。 + +現状は生成先の placeholder だけが存在し、C# generation と Unity consumer 接続は未実装である。 + +## 2. Target contents + +- Control Plane OpenAPI から生成した model / client +- Delivery / Realtime Protobuf から生成した message / service artifact +- generator version と source contract version の追跡情報 +- generated code を検証する compile / conformance test + +Generated file は手編集しない。必要な behavior 変更は `packages/contracts` の source、Control Plane route source、generator configuration のいずれかで行い、再生成する。 + +## 3. Ownership split + +この package は wire model と generated client surface だけを所有する。 + +- GameObject、Material、Texture への変換: Unity Presentation Runtime +- Asset download、cache、preload、eviction: Unity +- authentication credential の安全な保存: Unity application adapter +- retry、reconnect、snapshot application: Unity runtime layer +- portable wire compatibility policy: `packages/contracts` +- semantic invariant の定義と authoritative evaluation: `presentation-core`、Compiler、Realtime など各 ownership boundary + +C# consumer は generated model が保持する contract と fence を検証に利用するが、この package や Unity adapter が canonical semantic rule の正本になることはない。 + +Generated partial class や手書き Unity adapter を generated source directory に混在させない。 + +## 4. Invariants + +- generated artifact から source contract と generator version を追跡できる。 +- generation は再現可能で、drift check を CI で実行できる。 +- Unity-specific runtime object を wire model に追加しない。 +- `packages/contracts` が定義する DeliveryManifest / Runtime message の unknown / future field policy を generated API が保持できる。 +- PublicationFence、assignment、projection profile の fence を consumer が検証できる型を失わない。 + +## 5. Dependency rules + +依存方向は `packages/contracts` の source からこの package を生成し、Unity がこの package を参照する一方向とする。この package は Unity project、Control Plane implementation、Realtime implementation に依存しない。 + +## 6. Validation strategy + +- source と generated artifact の drift check +- standalone C# compile +- TypeScript / Go と共有する wire fixture の decode / encode test +- Unity consumer の adapter contract test +- generated API の breaking change report + +## 7. Deferred decisions + +- OpenAPI / Protobuf generator と version +- package distribution と Unity Package Manager 接続 +- generated namespace と assembly boundary +- Unity projectへ組み込む migration path diff --git a/packages/api-client-typescript/README.md b/packages/api-client-typescript/README.md index 1baf9c9..a5f61b2 100644 --- a/packages/api-client-typescript/README.md +++ b/packages/api-client-typescript/README.md @@ -19,7 +19,7 @@ if (response.ok) { ## Better Auth client -認証は Control Plane OpenAPI とは別の、Better Auth `1.6.26` に固定したバージョン付き契約境界です。Google / email-password sign-in、email verification、password reset、session、device authorization、TOTP / backup code MFA を型付きで利用できます。 +認証は Control Plane OpenAPI とは別の、Better Auth `1.7.1` に固定したバージョン付き契約境界です。Google / email-password sign-in、email verification、password reset、session、device authorization、TOTP / backup code MFA を型付きで利用できます。 ```ts import { createControlPlaneAuthClient } from "@unframe/api-client-typescript"; diff --git a/packages/api-client-typescript/tsconfig.json b/packages/api-client-typescript/tsconfig.json index aeba325..6d6150b 100644 --- a/packages/api-client-typescript/tsconfig.json +++ b/packages/api-client-typescript/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../config/tsconfig.base.json", "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], "noEmit": true }, "include": ["src"] diff --git a/packages/config/README.md b/packages/config/README.md index e289a10..bd240cc 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -3,10 +3,23 @@ ワークスペースで共有する TypeScript 設定と git hooks を置きます。 各アプリ・パッケージはここから設定を extends / import します。 +## TypeScript と formatter / lint policy + +`tsconfig.base.json` は strictness と module resolution を共有する非runtime基底設定です。 +Node、Workers、Browser、Unity の runtime globals や emit 方針は共有せず、各所有 +package の `tsconfig.json` で `types`、`lib`、`noEmit` を明示します。これにより一つの +共有設定が別 runtime の global を誤って許可することを防ぎます。 + +formatter と lint は Vite+ (`vp`) に一本化します。共有の oxc 設定は、現在の package +で共通ルールを追加する具体的な用途がないため置きません。各 package は対象 source、 +script、test を package script の `vp check` に列挙します。 + ## git hooks (`githooks/`) -commit message 先頭に gitmoji を強制する `prepare-commit-msg` フックを提供します。 -conventional commit prefix を gitmoji に置換します。 +`pre-commit` は repository root から `pnpm exec vp staged` を実行します。 +Vite+ の `vite.config.ts` が対象拡張子ごとに formatter / lint を選択します。commit message +先頭に gitmoji を強制する `prepare-commit-msg` も提供します。conventional commit prefix を +gitmoji に置換します。 | 入力 | 結果 | | ---------------------- | ---------------------- | diff --git a/packages/config/githooks/pre-commit b/packages/config/githooks/pre-commit new file mode 100755 index 0000000..259ba73 --- /dev/null +++ b/packages/config/githooks/pre-commit @@ -0,0 +1,6 @@ +#!/bin/sh +set -eu + +repository_root=$(git rev-parse --show-toplevel) +cd "$repository_root" +pnpm exec vp staged diff --git a/packages/config/package.json b/packages/config/package.json index c9d9cae..bf930be 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -6,7 +6,16 @@ "exports": { "./tsconfig.base.json": "./tsconfig.base.json" }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "lint": "vp check vite.config.ts test", + "test": "tsx test/pre-commit.fixture.test.ts", + "check": "pnpm typecheck && pnpm test && pnpm lint" + }, "devDependencies": { + "@types/node": "^24.0.0", + "tsx": "^4.22.1", + "typescript": "^7.0.0", "vite-plus": "catalog:" } } diff --git a/packages/config/test/pre-commit.fixture.test.ts b/packages/config/test/pre-commit.fixture.test.ts new file mode 100644 index 0000000..5235cf0 --- /dev/null +++ b/packages/config/test/pre-commit.fixture.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const repositoryRoot = resolve(import.meta.dirname, "../../.."); + +const run = (fixtureRoot: string, command: string, args: string[]) => + execFileSync(command, args, { + cwd: fixtureRoot, + encoding: "utf8", + env: { + ...process.env, + PATH: `${join(repositoryRoot, "node_modules", ".bin")}:${process.env["PATH"] ?? ""}`, + }, + }); + +const main = async () => { + const fixtureRoot = await mkdtemp(join(tmpdir(), "unframe-config-hook-")); + + try { + run(fixtureRoot, "git", ["init", "--quiet"]); + run(fixtureRoot, "git", ["config", "user.name", "fixture"]); + run(fixtureRoot, "git", ["config", "user.email", "fixture@example.invalid"]); + await mkdir(join(fixtureRoot, "empty-hooks")); + run(fixtureRoot, "git", ["config", "core.hooksPath", "empty-hooks"]); + run(fixtureRoot, "git", ["commit", "--quiet", "--allow-empty", "-m", "fixture baseline"]); + run(fixtureRoot, "git", [ + "config", + "core.hooksPath", + join(repositoryRoot, "packages/config/githooks"), + ]); + + await writeFile(join(fixtureRoot, "package.json"), '{ "private": true }\n'); + await mkdir(join(fixtureRoot, "packages/config"), { recursive: true }); + await copyFile(join(repositoryRoot, "vite.config.ts"), join(fixtureRoot, "vite.config.ts")); + await copyFile( + join(repositoryRoot, "packages/config/vite.config.ts"), + join(fixtureRoot, "packages/config/vite.config.ts"), + ); + await symlink(join(repositoryRoot, "node_modules"), join(fixtureRoot, "node_modules"), "dir"); + await writeFile(join(fixtureRoot, "sample.ts"), "const sample={value:1}\n"); + + run(fixtureRoot, "git", [ + "add", + "package.json", + "vite.config.ts", + "packages/config/vite.config.ts", + "sample.ts", + ]); + run(fixtureRoot, "git", ["commit", "--quiet", "-m", "test: staged fixture"]); + + assert.equal( + await readFile(join(fixtureRoot, "sample.ts"), "utf8"), + "const sample = { value: 1 };\n", + ); + } finally { + await rm(fixtureRoot, { force: true, recursive: true }); + } +}; + +void main(); diff --git a/packages/config/tsconfig.base.json b/packages/config/tsconfig.base.json index 99ffe7e..456e6ea 100644 --- a/packages/config/tsconfig.base.json +++ b/packages/config/tsconfig.base.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "lib": ["ES2022"], "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, diff --git a/packages/config/tsconfig.json b/packages/config/tsconfig.json new file mode 100644 index 0000000..f2ff7ee --- /dev/null +++ b/packages/config/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["node"] + }, + "include": ["vite.config.ts", "test/**/*.ts"] +} diff --git a/packages/config/vite.config.ts b/packages/config/vite.config.ts index 7ec50a0..e0046a0 100644 --- a/packages/config/vite.config.ts +++ b/packages/config/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vite-plus"; export default defineConfig({ staged: { - "*": "vp check --fix", + "**/*.{cjs,cts,js,json,jsonc,jsx,mjs,mts,svelte,ts,tsx,yaml,yml}": "vp check --fix", + "**/*.{css,html,md,mdx}": "vp fmt", }, }); diff --git a/packages/contracts/ARCHITECTURE.md b/packages/contracts/ARCHITECTURE.md new file mode 100644 index 0000000..b5869e1 --- /dev/null +++ b/packages/contracts/ARCHITECTURE.md @@ -0,0 +1,107 @@ +# Contracts Architecture + +- **Status**: Current boundary with Presentation v2 contracts +- **Scope**: Application、language、runtime をまたぐ serialized artifact と wire contract +- **Related**: + - [Presentation Architecture](../../docs/presentation/ARCHITECTURE.md) + - [Presentation Implementation Design](../../docs/presentation/DESIGN.md) + - [ADR-0006](../../docs/decisions/0006-presentation-rendering-strategy.md) + - [ADR-0007](../../docs/decisions/0007-timeline-runtime-run-wire-contract.md) + - [ADR-0008](../../docs/decisions/0008-runtime-transport-contract.md) + - [ADR-0009](../../docs/decisions/0009-semantic-tree-hit-region-contract.md) + - [ADR-0010](../../docs/decisions/0010-spatial-surface-coordinate-contract.md) + - [ADR-0011](../../docs/decisions/0011-surface-partition-contract.md) + - [ADR-0012](../../docs/decisions/0012-texture-budget-residency-contract.md) + - [ADR-0014](../../docs/decisions/0014-presentation-rendering-scope.md) + - [ADR-0015](../../docs/decisions/0015-presentation-definition-artifact-boundaries.md) + +## 1. Role + +`packages/contracts` は、TypeScript、Go、C# の実装が同じ意味を交換するための portable contract source と generated artifact の境界である。ここでは転送・保存される構造を定義し、構造だけでは表現できない意味検証や application policy は所有しない。 + +この package の source of truth は contract ごとに異なる。 + +- Control Plane OpenAPI は Control Plane の型付き route と runtime schema から生成する。 +- Realtime / Delivery の wire contract は Protocol Buffers source を正本とする。 +- Target の PresentationDefinition / RenderBundle / AssetSetManifest は `src/presentation/` 配下の Zod 4 schema を正本とし、portable JSON Schemaを生成する。完全版は `src/presentation/v2/` に置き、詳細な意味規則は [Presentation v2](../../docs/presentation/DATA_MODEL.md) を正本とする。 + +## 2. Owned boundaries + +### Current + +- `openapi/control-plane.openapi.json`: generated OpenAPI document +- `src/control-plane.openapi.ts`: generated TypeScript OpenAPI types +- `proto/unframe/realtime/v1/realtime.proto`: Realtime gRPC source +- source と generated artifact の drift check + +現行 TypeScript runtime client は OpenAPI path type ではなく、Control Plane が公開する Hono RPC `AppType` を利用する。OpenAPI artifact は language-neutral consumer のための境界として維持する。 + +### Presentation v2(契約定義済み、consumer 未接続) + +- `src/presentation/v2/`: Definition、RenderBundle、AssetSet、Build、Publication、Capability の Zod と導出型 +- `presentation/v2/`: 生成 JSON Schema、Protobuf descriptor、portable fixture +- `proto/unframe/presentation/v2/runtime.proto`: 共通型と投影 catalog +- `proto/unframe/delivery/v2/delivery.proto`: DeliveryManifest と capability / residency +- `proto/unframe/realtime/v2/realtime.proto`: Command、Event、Run、Snapshot、State Stream +- `scripts/generate-presentation-v2.ts`: JSON Schema / descriptor の生成と drift check + +既存 `src/presentation/` 直下と `presentation/` 直下の v1 artifact は M1 consumer の初期 subset である。v2 の正本として参照しない。 + +## 3. Ownership split + +`packages/contracts` が所有するのは portable な構造と wire compatibility である。次は各 consumer が所有する。 + +- semantic invariant、reference validation、canonicalization: `presentation-core` +- HTTP route behavior、authorization、publication policy: Control Plane +- progression evaluation、actor resolution、snapshot cut: Realtime +- Unity object、renderer graph、runtime cache: Unity + +Schema generator は `presentation-core` を import しない。`presentation-core` が generated TypeScript contract を利用する一方向に固定し、serialized contract と semantic implementation の循環した正本を作らない。 + +## 4. Generated artifact destinations + +```text +Zod Presentation schema +├─ infer → TypeScript model → presentation-core / Control Plane +└─ generate → JSON Schema artifact + +Protocol Buffers +├─ generate → Go artifact → app/server/realtime/internal/gen/ +└─ generate → C# artifact → packages/api-client-csharp/ +``` + +Generated file は手編集しない。生成先には generator、source contract、version、drift check を追跡できる情報を残す。 + +## 5. Invariants + +- PresentationDefinition、RenderBundle、DeliveryManifest、Runtime State を一つの schema に統合しない。 +- Authoring Source、React、DOM、Unity object、D1 / R2 representation を portable contract に含めない。 +- Surface artifact の対象は `baked-web`、限定 `native-ui`、`video` とする。`embedded-web` / WebView はプロジェクト対象外であり、artifact、capability、runtime bridge の拡張口や予約 field を設けない。ビルド時の Opaque renderer の Browser 実行は配布・実行時の契約に含めない。 +- 完全版 Definition の `flow.timelines` と、素材 descriptor を分離する AssetSetManifest の構造境界は ADR-0015 に従う。v2 AssetSet の schema と構造検証は実装済みである。現行 `definition.assets` からの consumer 移行と Compiler の AssetSet 生成は未実装である。 +- Model animation は [ADR-0016](../../docs/decisions/0016-model-animation-scope.md) に従い Model Asset 内蔵 clip に限定し、通常一つ、crossfade 中だけ遷移元・遷移先の二つを同じ ModelNode で再生できる。部位 mask / layer / additive、root motion による Node Transform 変更は許可せず、予約 field も作らない。Clip ID binding と保持姿勢の構造は [Presentation Data Model](../../docs/presentation/DATA_MODEL.md) に従う。具体的な Action / Run / wire は v2 に定義する。root motion 素材の変換と consumer 接続は未実装である。 +- RenderBundle は Signed URL を持たない。取得 binding は Delivery 時に解決する。 +- canonical Runtime model と connection / durable envelope を分離する。 +- wire field の追加だけで semantic compatibility を保証したことにしない。 +- Zod validatorと生成JSON Schemaは同じportable fixtureに対して同じ構造判定を行う。 +- Go / C# consumerはPresentationDefinition全体ではなく、Protocol Buffersで定義したDelivery / Runtime projectionを利用する。 + +## 6. Dependency rules + +Target の Presentation schema と Protocol Buffers は、consumer が generated artifact または schema を参照する向きだけを許可する。これらの source / generator から application implementation、`presentation-core`、renderer、Compiler、Unity adapter への依存は禁止する。 + +現行 Control Plane OpenAPI は application の型付き route が source of truth であり、`scripts/generate-control-plane.ts` は route application を読み込んで文書と型を生成する repository adapter である。この Current generation path は Target の portable Presentation schema generator と同一視せず、Control Plane implementation を `packages/contracts` の runtime dependency として公開しない。 + +## 7. Validation strategy + +- Zod source と generated JSON Schema artifact の drift check +- schema の valid / invalid fixture +- canonical JSON と hash の cross-language fixture +- Protobuf compatibility check +- TypeScript、Go、C# consumer が利用する用途別fixtureのconformance test +- Current contract を Target contract へ置き換える変更では、Web、Control Plane、Realtime、Unity の consumer を同じ変更単位で検証する + +## 8. Current gap + +PresentationDefinition / baked-web first RenderBundle のZod 4 source、最小fixture、生成JSON Schema、schema validationとdrift checkは実装済みである。初期subsetの参照整合性とcanonicalizationは`presentation-core`に実装済みである。Timeline catalog / Runtime Run wire の accepted semantics は [ADR-0007](../../docs/decisions/0007-timeline-runtime-run-wire-contract.md)、Reliable Event / Snapshot / State Stream の exact envelope、retention、microstep上限は [ADR-0008](../../docs/decisions/0008-runtime-transport-contract.md)、role別Semantic Tree / Hit Regionのtarget schemaは [ADR-0009](../../docs/decisions/0009-semantic-tree-hit-region-contract.md)、Spatial / Surface / Unity座標変換は [ADR-0010](../../docs/decisions/0010-spatial-surface-coordinate-contract.md)、Surface Partition / Part isolate overrideは [ADR-0011](../../docs/decisions/0011-surface-partition-contract.md)、Texture metadata / budget / residencyは [ADR-0012](../../docs/decisions/0012-texture-budget-residency-contract.md) に定義した。現行texture schemaはsize / mip / memory estimateを持たず`premultiplied`を許可する初期subsetである。現行Compilerは一Surface一partition、Semantic Tree / Hit Regionはflat initial subset、Quaternionはshapeとnorm検証だけ、Unity sample importerは旧contractのままであり、M3〜M5でfixtureと実装を接続する。v2 は完全版の構造と意味規則を定義し、JSON Schema / Protobuf descriptor と fixture を生成・検証する。Delivery projection、version negotiation、v2 Go / C# consumer generation と実行処理は未実装である。`realtime/v1/realtime.proto` は既存 foundation として残る。 + +最初のmilestoneではCueの詳細contractをまだ固定しない。schemaは`cues`を空配列に限定し、任意のCue objectを受け入れない。Frame layoutとText placementは`absolute` subsetのみを構造契約に含める。参照整合性、所有権、tree不変条件、Quaternion正規化、Scalar値の型整合性は`presentation-core`のsemantic validationへ委譲する。 diff --git a/packages/contracts/README.md b/packages/contracts/README.md index 6f5e3b0..4de1db7 100644 --- a/packages/contracts/README.md +++ b/packages/contracts/README.md @@ -16,7 +16,46 @@ pnpm --filter @unframe/contracts check:control-plane Control Plane の `src/openapi.ts`、共有 schema、HTTP routeを変更した場合は型を再生成し、drift checkを通してください。TypeScript runtime client は生成 path 型ではなく Hono RPC の `AppType` を使います。生成物は Unity / C# など言語非依存の契約境界として維持します。 -## Realtime Protocol Buffers +## Presentation v2 + +完成版の構造は `src/presentation/v2/` の Zod と `proto/unframe/{presentation,delivery,realtime}/v2/` の Protobuf が正本です。型は `@unframe/contracts/presentation/v2` から import できます。参照整合性・状態遷移・拒否条件は [Presentation データ契約](../../docs/presentation/DATA_MODEL.md) を併読してください。v1 consumer の移行と実機対応は含みません。 + +repository root の Nix development shell で実行します。 + +```sh +pnpm --filter @unframe/contracts generate:presentation:v2 +pnpm --filter @unframe/contracts check:presentation:v2 +pnpm --filter @unframe/contracts test:presentation:v2 +``` + +`presentation/v2/*.schema.json` と `contract.pb` は生成物です。fixture は合成データで、モデル・動画・フォントの実バイトや実機測定値を含みません。構造の受理・拒否と生成物の一致を検証し、素材の変換・描画成功とは区別します。 + +## Presentation v1(既存 consumer の初期 subset) + +`src/presentation/definition.ts` と `src/presentation/render-bundle.ts` の Zod 4 schema が source of +truth です。前者は renderer-independent な PresentationDefinition、後者は baked-web artifact を含む +RenderBundle の最初の serialized shape を定義します。型は同じschemaから`z.infer`で導出し、 +`@unframe/contracts/presentation` からimportできます。`presentation/*.schema.json`はZodから生成する +JSON Schema Draft 2020-12 artifactであり、手編集しません。 + +```sh +pnpm --filter @unframe/contracts generate:presentation +pnpm --filter @unframe/contracts check:presentation +pnpm --filter @unframe/contracts test:presentation +``` + +`fixtures/minimal.*.v1.json` は一つの Stage、SurfaceNode、Semantic Surface、root Frame/Text、 +State、baked-web intent、空 Cue の Group/Step を表す最小fixtureです。Zodと生成JSON Schemaの両方で +同じvalid/invalid結果になることを検証します。schema はportable な構造だけを検証し、ID の相互参照、ownership、State の意味的整合性、canonicalization +は `presentation-core` が担当します。これらのTarget schemaは既存Control Plane OpenAPI形式を +置き換えず、consumer migrationもまだ含みません。 + +最初のmilestoneではCueの詳細なTrigger / Guard / Action contractは未実装です。`cues` は +`maxItems: 0` とし、任意objectを受け入れません。Frameは`absolute` layout、Textは親Frame内の +`absolute` placementを持つ親子構造に限定します。ID参照、treeの循環、Quaternionの正規化、Scalar型とinitialValueの一致は +構造schemaの外であり、`presentation-core` が検証します。 + +## Realtime v1(既存 foundation) `proto/unframe/realtime/v1/realtime.proto` は Realtime gRPC protocol の source of truth です。Go generated code は `app/server/realtime/internal/gen/realtime/v1/` に出力します。generated files は手で編集しません。 diff --git a/packages/contracts/package.json b/packages/contracts/package.json index ebc0849..eae2dea 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -4,14 +4,34 @@ "private": true, "type": "module", "exports": { - "./control-plane": "./src/control-plane.openapi.ts" + "./control-plane": "./src/control-plane.openapi.ts", + "./presentation": "./src/presentation/index.ts", + "./presentation/v2": "./src/presentation/v2/index.ts" }, "scripts": { "generate:control-plane": "tsx scripts/generate-control-plane.ts", - "check:control-plane": "tsx scripts/generate-control-plane.ts --check" + "check:control-plane": "tsx scripts/generate-control-plane.ts --check", + "generate:presentation": "tsx scripts/generate-presentation.ts", + "check:presentation": "tsx scripts/generate-presentation.ts --check", + "test:presentation": "tsx test/presentation.contract.test.ts", + "typecheck": "tsc --noEmit -p tsconfig.json", + "lint": "vp check src/presentation scripts test", + "check": "pnpm check:control-plane && pnpm check:presentation && pnpm check:presentation:v2 && pnpm typecheck && pnpm test:presentation && pnpm test:presentation:v2 && pnpm lint", + "generate:presentation:v2": "tsx scripts/generate-presentation-v2.ts", + "check:presentation:v2": "tsx scripts/generate-presentation-v2.ts --check", + "test:presentation:v2": "tsx test/presentation-v2.contract.test.ts" + }, + "dependencies": { + "zod": "4.4.3" }, "devDependencies": { + "@cloudflare/workers-types": "5.20260825.1", + "@types/node": "^24.0.0", + "ajv": "8.17.1", + "canonicalize": "3.0.0", "openapi-typescript": "^7.12.0", - "tsx": "^4.22.1" + "tsx": "^4.22.1", + "typescript": "^5.9.3", + "vite-plus": "catalog:" } } diff --git a/packages/contracts/presentation/fixtures/minimal.presentation-definition.v1.json b/packages/contracts/presentation/fixtures/minimal.presentation-definition.v1.json new file mode 100644 index 0000000..e78b936 --- /dev/null +++ b/packages/contracts/presentation/fixtures/minimal.presentation-definition.v1.json @@ -0,0 +1,118 @@ +{ + "schemaVersion": 1, + "presentationId": "presentation-intro", + "metadata": { "title": "Intro" }, + "stage": { + "coordinateSystem": { + "unit": "meter", + "handedness": "right", + "upAxis": "+Y", + "forwardAxis": "-Z" + }, + "size": [4, 3, 2], + "zones": {} + }, + "assets": {}, + "scene": { + "nodes": { + "surface-node-title": { + "id": "surface-node-title", + "name": "Title surface", + "kind": "surface", + "owner": { "kind": "presentation" }, + "audience": { "kind": "all" }, + "parent": { "kind": "stage" }, + "order": 0, + "transform": { + "position": [0, 0, 0], + "rotation": [0, 0, 0, 1], + "scale": [1, 1, 1] + }, + "active": true, + "visible": true, + "opacity": 1, + "surfaceId": "surface-title" + } + }, + "surfaces": { + "surface-title": { + "id": "surface-title", + "hostNodeId": "surface-node-title", + "physicalSizeMeters": [1.6, 0.9], + "logicalSize": [1920, 1080], + "fit": "contain", + "rootFrameId": "frame-root", + "contentNodes": { + "frame-root": { + "id": "frame-root", + "kind": "frame", + "parentId": null, + "order": 0, + "layout": { "kind": "absolute" }, + "children": ["text-title"] + }, + "text-title": { + "id": "text-title", + "kind": "text", + "parentId": "frame-root", + "order": 0, + "placement": { + "kind": "absolute", + "x": 120, + "y": 80, + "width": 1680, + "height": 200 + }, + "text": "Hello" + } + }, + "baseSemanticTree": { + "rootNodeIds": ["semantic-title"], + "nodes": { + "semantic-title": { + "id": "semantic-title", + "parentId": null, + "order": 0, + "role": "heading", + "text": "Hello" + } + } + }, + "interactions": {}, + "initialStateId": "state-default", + "states": { + "state-default": { + "id": "state-default", + "semanticOverrides": [], + "enabledInteractionIds": [] + } + }, + "renderIntent": { + "updateModel": { "kind": "static" }, + "interaction": { "kind": "none" }, + "internalAnimation": { "kind": "none" }, + "rendererPreference": "baked-web", + "fallbackPolicy": "reject" + } + } + } + }, + "flow": { + "initialGroupId": "group-intro", + "groups": { + "group-intro": { + "id": "group-intro", + "initialStepId": "step-intro", + "steps": { "step-intro": { "id": "step-intro", "cues": [] } } + } + }, + "variables": { + "optional-subtitle": { + "id": "optional-subtitle", + "owner": { "kind": "presentation" }, + "type": "null", + "initialValue": null + } + } + } +} diff --git a/packages/contracts/presentation/fixtures/minimal.render-bundle.v1.json b/packages/contracts/presentation/fixtures/minimal.render-bundle.v1.json new file mode 100644 index 0000000..ba4b332 --- /dev/null +++ b/packages/contracts/presentation/fixtures/minimal.render-bundle.v1.json @@ -0,0 +1,76 @@ +{ + "schemaVersion": 1, + "bundleId": "bundle-intro", + "sourceHash": "sha256:source", + "definitionHash": "sha256:definition", + "compiler": { + "name": "@unframe/presentation-compiler", + "version": "0.1.0", + "environmentHash": "sha256:environment" + }, + "buildContext": { + "locale": "ja-JP", + "timezone": "Asia/Tokyo", + "colorScheme": "dark", + "themeId": "theme-default", + "themeHash": "sha256:theme" + }, + "surfaces": { + "surface-title": { + "semanticSurfaceId": "surface-title", + "logicalSize": [1920, 1080], + "physicalSizeMeters": [1.6, 0.9], + "renderSurfaceIds": ["render-surface-title"], + "renderSurfaces": { + "render-surface-title": { + "id": "render-surface-title", + "semanticSurfaceId": "surface-title", + "logicalBounds": { "x": 0, "y": 0, "width": 1920, "height": 1080 }, + "layer": 0, + "artifacts": { + "artifact-title-default": { + "id": "artifact-title-default", + "kind": "baked-web", + "states": { + "state-default": { + "stateId": "state-default", + "textures": [ + { + "assetId": "asset-title-default-2k", + "mediaType": "image/png", + "pixelSize": [2048, 1152], + "checksum": "sha256:texture", + "colorSpace": "srgb", + "alphaMode": "straight" + } + ] + } + } + } + }, + "stateBindings": { + "state-default": { + "kind": "artifacts", + "artifactIds": ["artifact-title-default"] + } + } + } + }, + "semanticsByState": { + "state-default": { + "rootNodeIds": ["semantic-title"], + "nodes": { + "semantic-title": { + "id": "semantic-title", + "parentId": null, + "order": 0, + "role": "heading", + "text": "Hello" + } + } + } + }, + "interactionsByState": { "state-default": [] } + } + } +} diff --git a/packages/contracts/presentation/presentation-definition.schema.json b/packages/contracts/presentation/presentation-definition.schema.json new file mode 100644 index 0000000..780c75e --- /dev/null +++ b/packages/contracts/presentation/presentation-definition.schema.json @@ -0,0 +1,1130 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contracts.unframe.dev/presentation/presentation-definition.v1.schema.json", + "title": "SerializedPresentationDefinitionV1", + "type": "object", + "properties": { + "schemaVersion": { + "type": "number", + "const": 1 + }, + "presentationId": { + "$ref": "#/$defs/__schema0" + }, + "metadata": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "title" + ], + "additionalProperties": false + }, + "stage": { + "type": "object", + "properties": { + "coordinateSystem": { + "type": "object", + "properties": { + "unit": { + "type": "string", + "const": "meter" + }, + "handedness": { + "type": "string", + "const": "right" + }, + "upAxis": { + "type": "string", + "const": "+Y" + }, + "forwardAxis": { + "type": "string", + "const": "-Z" + } + }, + "required": [ + "unit", + "handedness", + "upAxis", + "forwardAxis" + ], + "additionalProperties": false + }, + "size": { + "$ref": "#/$defs/__schema1" + }, + "zones": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/__schema0" + }, + "owner": { + "$ref": "#/$defs/__schema3" + }, + "center": { + "$ref": "#/$defs/__schema4" + }, + "size": { + "$ref": "#/$defs/__schema1" + } + }, + "required": [ + "id", + "owner", + "center", + "size" + ], + "additionalProperties": false + } + } + }, + "required": [ + "coordinateSystem", + "size", + "zones" + ], + "additionalProperties": false + }, + "assets": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/__schema0" + }, + "mediaType": { + "$ref": "#/$defs/__schema0" + }, + "checksum": { + "$ref": "#/$defs/__schema0" + } + }, + "required": [ + "id", + "mediaType", + "checksum" + ], + "additionalProperties": false + } + }, + "scene": { + "type": "object", + "properties": { + "nodes": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/__schema5" + }, + "additionalProperties": { + "$ref": "#/$defs/__schema6" + }, + "minProperties": 1 + }, + "surfaces": { + "type": "object", + "propertyNames": { + "$ref": "#/$defs/__schema7" + }, + "additionalProperties": { + "$ref": "#/$defs/__schema8" + }, + "minProperties": 1 + } + }, + "required": [ + "nodes", + "surfaces" + ], + "additionalProperties": false + }, + "flow": { + "type": "object", + "properties": { + "initialGroupId": { + "$ref": "#/$defs/__schema0" + }, + "groups": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/__schema0" + }, + "initialStepId": { + "$ref": "#/$defs/__schema0" + }, + "steps": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/__schema0" + }, + "cues": { + "maxItems": 0, + "type": "array", + "items": { + "$ref": "#/$defs/__schema10" + } + } + }, + "required": [ + "id", + "cues" + ], + "additionalProperties": false + } + } + }, + "required": [ + "id", + "initialStepId", + "steps" + ], + "additionalProperties": false + } + }, + "variables": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/__schema0" + }, + "owner": { + "$ref": "#/$defs/__schema3" + }, + "type": { + "type": "string", + "enum": [ + "string", + "boolean", + "number", + "null" + ] + }, + "initialValue": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "owner", + "type", + "initialValue" + ], + "additionalProperties": false + } + } + }, + "required": [ + "initialGroupId", + "groups", + "variables" + ], + "additionalProperties": false + } + }, + "required": [ + "schemaVersion", + "presentationId", + "metadata", + "stage", + "assets", + "scene", + "flow" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "type": "string", + "minLength": 1 + }, + "__schema1": { + "type": "array", + "prefixItems": [ + { + "$ref": "#/$defs/__schema2" + }, + { + "$ref": "#/$defs/__schema2" + }, + { + "$ref": "#/$defs/__schema2" + } + ], + "items": false, + "minItems": 3, + "maxItems": 3 + }, + "__schema2": { + "type": "number", + "exclusiveMinimum": 0 + }, + "__schema3": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "presentation" + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "group" + }, + "groupId": { + "$ref": "#/$defs/__schema0" + } + }, + "required": [ + "kind", + "groupId" + ], + "additionalProperties": false + } + ] + }, + "__schema4": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ], + "items": false, + "minItems": 3, + "maxItems": 3 + }, + "__schema5": { + "type": "string" + }, + "__schema6": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/__schema0" + }, + "name": { + "type": "string" + }, + "kind": { + "type": "string", + "const": "surface" + }, + "owner": { + "$ref": "#/$defs/__schema3" + }, + "audience": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "all" + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "role" + }, + "role": { + "type": "string", + "enum": [ + "presenter", + "viewer" + ] + } + }, + "required": [ + "kind", + "role" + ], + "additionalProperties": false + } + ] + }, + "parent": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "stage" + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "node" + }, + "nodeId": { + "$ref": "#/$defs/__schema0" + } + }, + "required": [ + "kind", + "nodeId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "anchor" + }, + "target": { + "type": "string", + "enum": [ + "head", + "leftHand", + "rightHand", + "body" + ] + }, + "owner": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "presenter" + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + }, + "followPosition": { + "type": "boolean" + }, + "followRotation": { + "type": "boolean" + } + }, + "required": [ + "kind", + "target", + "owner", + "followPosition", + "followRotation" + ], + "additionalProperties": false + } + ] + }, + "order": { + "type": "number" + }, + "transform": { + "type": "object", + "properties": { + "position": { + "$ref": "#/$defs/__schema4" + }, + "rotation": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ], + "items": false, + "minItems": 4, + "maxItems": 4 + }, + "scale": { + "$ref": "#/$defs/__schema1" + } + }, + "required": [ + "position", + "rotation", + "scale" + ], + "additionalProperties": false + }, + "active": { + "type": "boolean" + }, + "visible": { + "type": "boolean" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "surfaceId": { + "$ref": "#/$defs/__schema0" + } + }, + "required": [ + "id", + "kind", + "owner", + "audience", + "parent", + "order", + "transform", + "active", + "visible", + "opacity", + "surfaceId" + ], + "additionalProperties": false + }, + "__schema7": { + "type": "string" + }, + "__schema8": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/__schema0" + }, + "hostNodeId": { + "$ref": "#/$defs/__schema0" + }, + "physicalSizeMeters": { + "$ref": "#/$defs/__schema9" + }, + "logicalSize": { + "$ref": "#/$defs/__schema9" + }, + "fit": { + "type": "string", + "enum": [ + "contain", + "cover", + "stretch" + ] + }, + "rootFrameId": { + "$ref": "#/$defs/__schema0" + }, + "contentNodes": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/__schema0" + }, + "kind": { + "type": "string", + "const": "frame" + }, + "parentId": { + "anyOf": [ + { + "$ref": "#/$defs/__schema0" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "number" + }, + "layout": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "absolute" + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + }, + "children": { + "type": "array", + "items": { + "$ref": "#/$defs/__schema0" + } + } + }, + "required": [ + "id", + "kind", + "parentId", + "order", + "layout", + "children" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/__schema0" + }, + "kind": { + "type": "string", + "const": "text" + }, + "parentId": { + "anyOf": [ + { + "$ref": "#/$defs/__schema0" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "number" + }, + "placement": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "absolute" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "width": { + "$ref": "#/$defs/__schema2" + }, + "height": { + "$ref": "#/$defs/__schema2" + } + }, + "required": [ + "kind", + "x", + "y", + "width", + "height" + ], + "additionalProperties": false + }, + "text": { + "type": "string" + } + }, + "required": [ + "id", + "kind", + "parentId", + "order", + "placement", + "text" + ], + "additionalProperties": false + } + ] + } + }, + "baseSemanticTree": { + "type": "object", + "properties": { + "rootNodeIds": { + "type": "array", + "items": { + "$ref": "#/$defs/__schema0" + } + }, + "nodes": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/__schema0" + }, + "parentId": { + "anyOf": [ + { + "$ref": "#/$defs/__schema0" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "number" + }, + "role": { + "type": "string", + "enum": [ + "heading", + "paragraph", + "image", + "button", + "table", + "list", + "listItem" + ] + }, + "text": { + "type": "string" + }, + "language": { + "type": "string" + }, + "alt": { + "type": "string" + }, + "interactionId": { + "$ref": "#/$defs/__schema0" + } + }, + "required": [ + "id", + "parentId", + "order", + "role" + ], + "additionalProperties": false + } + } + }, + "required": [ + "rootNodeIds", + "nodes" + ], + "additionalProperties": false + }, + "interactions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/__schema0" + }, + "kind": { + "type": "string", + "const": "click" + }, + "event": { + "$ref": "#/$defs/__schema0" + } + }, + "required": [ + "id", + "kind", + "event" + ], + "additionalProperties": false + } + }, + "initialStateId": { + "$ref": "#/$defs/__schema0" + }, + "states": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/__schema0" + }, + "semanticOverrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "nodes": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "included": { + "type": "boolean" + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "language": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "alt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "required": [ + "nodes" + ], + "additionalProperties": false + } + }, + "enabledInteractionIds": { + "type": "array", + "items": { + "$ref": "#/$defs/__schema0" + }, + "uniqueItems": true + } + }, + "required": [ + "id", + "semanticOverrides", + "enabledInteractionIds" + ], + "additionalProperties": false + } + }, + "renderIntent": { + "type": "object", + "properties": { + "updateModel": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "static" + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "finite-state" + }, + "stateIds": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/__schema0" + } + } + }, + "required": [ + "kind", + "stateIds" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "continuous" + }, + "source": { + "type": "string", + "enum": [ + "timeline", + "runtime-data", + "user-input" + ] + }, + "maximumUpdateRateHz": { + "$ref": "#/$defs/__schema2" + } + }, + "required": [ + "kind", + "source" + ], + "additionalProperties": false + } + ] + }, + "interaction": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "none" + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "regions" + }, + "events": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/__schema0" + } + } + }, + "required": [ + "kind", + "events" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "native-input" + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + } + ] + }, + "internalAnimation": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "none" + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "precomputed" + }, + "durationSeconds": { + "$ref": "#/$defs/__schema2" + } + }, + "required": [ + "kind", + "durationSeconds" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "runtime" + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + } + ] + }, + "rendererPreference": { + "type": "string", + "enum": [ + "auto", + "baked-web", + "native-ui", + "video" + ] + }, + "fallbackPolicy": { + "type": "string", + "enum": [ + "reject", + "degrade" + ] + } + }, + "required": [ + "updateModel", + "interaction", + "internalAnimation", + "rendererPreference", + "fallbackPolicy" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "hostNodeId", + "physicalSizeMeters", + "logicalSize", + "fit", + "rootFrameId", + "contentNodes", + "baseSemanticTree", + "interactions", + "initialStateId", + "states", + "renderIntent" + ], + "additionalProperties": false + }, + "__schema9": { + "type": "array", + "prefixItems": [ + { + "$ref": "#/$defs/__schema2" + }, + { + "$ref": "#/$defs/__schema2" + } + ], + "items": false, + "minItems": 2, + "maxItems": 2 + }, + "__schema10": {} + } +} diff --git a/packages/contracts/presentation/render-bundle.schema.json b/packages/contracts/presentation/render-bundle.schema.json new file mode 100644 index 0000000..bbbfeaa --- /dev/null +++ b/packages/contracts/presentation/render-bundle.schema.json @@ -0,0 +1,457 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contracts.unframe.dev/presentation/render-bundle.v1.schema.json", + "title": "SerializedRenderBundleV1", + "type": "object", + "properties": { + "schemaVersion": { + "type": "number", + "const": 1 + }, + "bundleId": { + "$ref": "#/$defs/__schema0" + }, + "sourceHash": { + "$ref": "#/$defs/__schema0" + }, + "definitionHash": { + "$ref": "#/$defs/__schema0" + }, + "compiler": { + "type": "object", + "properties": { + "name": { + "$ref": "#/$defs/__schema0" + }, + "version": { + "$ref": "#/$defs/__schema0" + }, + "environmentHash": { + "$ref": "#/$defs/__schema0" + } + }, + "required": [ + "name", + "version", + "environmentHash" + ], + "additionalProperties": false + }, + "buildContext": { + "type": "object", + "properties": { + "locale": { + "$ref": "#/$defs/__schema0" + }, + "timezone": { + "$ref": "#/$defs/__schema0" + }, + "colorScheme": { + "type": "string", + "enum": [ + "light", + "dark" + ] + }, + "themeId": { + "$ref": "#/$defs/__schema0" + }, + "themeHash": { + "$ref": "#/$defs/__schema0" + } + }, + "required": [ + "locale", + "timezone", + "colorScheme", + "themeId", + "themeHash" + ], + "additionalProperties": false + }, + "surfaces": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "semanticSurfaceId": { + "$ref": "#/$defs/__schema0" + }, + "logicalSize": { + "$ref": "#/$defs/__schema1" + }, + "physicalSizeMeters": { + "$ref": "#/$defs/__schema1" + }, + "renderSurfaceIds": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/__schema0" + } + }, + "renderSurfaces": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/__schema0" + }, + "semanticSurfaceId": { + "$ref": "#/$defs/__schema0" + }, + "logicalBounds": { + "$ref": "#/$defs/__schema3" + }, + "layer": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "artifacts": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/__schema0" + }, + "kind": { + "type": "string", + "const": "baked-web" + }, + "states": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "stateId": { + "$ref": "#/$defs/__schema0" + }, + "textures": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/__schema4" + } + } + }, + "required": [ + "stateId", + "textures" + ], + "additionalProperties": false + } + } + }, + "required": [ + "id", + "kind", + "states" + ], + "additionalProperties": false + } + }, + "stateBindings": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "empty" + } + }, + "required": [ + "kind" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "artifacts" + }, + "artifactIds": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/__schema0" + } + } + }, + "required": [ + "kind", + "artifactIds" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "id", + "semanticSurfaceId", + "logicalBounds", + "layer", + "artifacts", + "stateBindings" + ], + "additionalProperties": false + } + }, + "semanticsByState": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "rootNodeIds": { + "type": "array", + "items": { + "$ref": "#/$defs/__schema0" + } + }, + "nodes": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/__schema0" + }, + "parentId": { + "anyOf": [ + { + "$ref": "#/$defs/__schema0" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "number" + }, + "role": { + "type": "string", + "enum": [ + "heading", + "paragraph", + "image", + "button", + "table", + "list", + "listItem" + ] + }, + "text": { + "type": "string" + }, + "language": { + "type": "string" + }, + "alt": { + "type": "string" + }, + "interactionId": { + "$ref": "#/$defs/__schema0" + } + }, + "required": [ + "id", + "parentId", + "order", + "role" + ], + "additionalProperties": false + } + } + }, + "required": [ + "rootNodeIds", + "nodes" + ], + "additionalProperties": false + } + }, + "interactionsByState": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "interactionId": { + "$ref": "#/$defs/__schema0" + }, + "semanticNodeId": { + "$ref": "#/$defs/__schema0" + }, + "bounds": { + "$ref": "#/$defs/__schema3" + }, + "coordinateSpace": { + "type": "string", + "const": "normalized" + }, + "event": { + "$ref": "#/$defs/__schema0" + }, + "priority": { + "type": "number" + } + }, + "required": [ + "interactionId", + "semanticNodeId", + "bounds", + "coordinateSpace", + "event", + "priority" + ], + "additionalProperties": false + } + } + } + }, + "required": [ + "semanticSurfaceId", + "logicalSize", + "physicalSizeMeters", + "renderSurfaceIds", + "renderSurfaces", + "semanticsByState", + "interactionsByState" + ], + "additionalProperties": false + } + } + }, + "required": [ + "schemaVersion", + "bundleId", + "sourceHash", + "definitionHash", + "compiler", + "buildContext", + "surfaces" + ], + "additionalProperties": false, + "$defs": { + "__schema0": { + "type": "string", + "minLength": 1 + }, + "__schema1": { + "type": "array", + "prefixItems": [ + { + "$ref": "#/$defs/__schema2" + }, + { + "$ref": "#/$defs/__schema2" + } + ], + "items": false, + "minItems": 2, + "maxItems": 2 + }, + "__schema2": { + "type": "number", + "exclusiveMinimum": 0 + }, + "__schema3": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "width": { + "$ref": "#/$defs/__schema2" + }, + "height": { + "$ref": "#/$defs/__schema2" + } + }, + "required": [ + "x", + "y", + "width", + "height" + ], + "additionalProperties": false + }, + "__schema4": { + "type": "object", + "properties": { + "assetId": { + "$ref": "#/$defs/__schema0" + }, + "mediaType": { + "type": "string", + "const": "image/png" + }, + "pixelSize": { + "$ref": "#/$defs/__schema1" + }, + "checksum": { + "$ref": "#/$defs/__schema0" + }, + "colorSpace": { + "type": "string", + "const": "srgb" + }, + "alphaMode": { + "type": "string", + "enum": [ + "opaque", + "straight", + "premultiplied" + ] + } + }, + "required": [ + "assetId", + "mediaType", + "pixelSize", + "checksum", + "colorSpace", + "alphaMode" + ], + "additionalProperties": false + } + } +} diff --git a/packages/contracts/presentation/v2/asset-set-manifest.schema.json b/packages/contracts/presentation/v2/asset-set-manifest.schema.json new file mode 100644 index 0000000..6817f3c --- /dev/null +++ b/packages/contracts/presentation/v2/asset-set-manifest.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "schemaVersion": { + "type": "number", + "const": 2 + }, + "assets": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "checksum": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "mediaType": { + "type": "string", + "enum": [ + "image/png", + "image/jpeg", + "font/ttf", + "font/otf", + "video/mp4", + "model/gltf-binary" + ] + }, + "encodedSizeBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["checksum", "mediaType", "encodedSizeBytes"], + "additionalProperties": false + } + } + }, + "required": ["schemaVersion", "assets"], + "additionalProperties": false, + "$id": "https://contracts.unframe.dev/presentation/asset-set-manifest.v2.schema.json" +} diff --git a/packages/contracts/presentation/v2/build-manifest.schema.json b/packages/contracts/presentation/v2/build-manifest.schema.json new file mode 100644 index 0000000..d0444cf --- /dev/null +++ b/packages/contracts/presentation/v2/build-manifest.schema.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "schemaVersion": { + "type": "number", + "const": 2 + }, + "buildId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "presentationId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "sourceDraftRevision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "definitionHash": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "renderBundleHash": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "assetSetHash": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "contractVersions": { + "type": "object", + "properties": { + "definition": { + "type": "number", + "const": 2 + }, + "renderBundle": { + "type": "number", + "const": 2 + }, + "assetSet": { + "type": "number", + "const": 2 + }, + "delivery": { + "type": "number", + "const": 2 + }, + "runtime": { + "type": "number", + "const": 2 + }, + "progression": { + "type": "number", + "const": 1 + }, + "projection": { + "type": "number", + "const": 1 + } + }, + "required": [ + "definition", + "renderBundle", + "assetSet", + "delivery", + "runtime", + "progression", + "projection" + ], + "additionalProperties": false + } + }, + "required": [ + "schemaVersion", + "buildId", + "presentationId", + "sourceDraftRevision", + "definitionHash", + "renderBundleHash", + "assetSetHash", + "contractVersions" + ], + "additionalProperties": false, + "$id": "https://contracts.unframe.dev/presentation/build-manifest.v2.schema.json" +} diff --git a/packages/contracts/presentation/v2/capability-profile.schema.json b/packages/contracts/presentation/v2/capability-profile.schema.json new file mode 100644 index 0000000..38a1770 --- /dev/null +++ b/packages/contracts/presentation/v2/capability-profile.schema.json @@ -0,0 +1,401 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "schemaVersion": { + "type": "number", + "const": 2 + }, + "capabilityProfileId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "contractVersions": { + "type": "object", + "properties": { + "delivery": { + "type": "number", + "const": 2 + }, + "runtime": { + "type": "number", + "const": 2 + }, + "progression": { + "type": "number", + "const": 1 + }, + "projection": { + "type": "number", + "const": 1 + } + }, + "required": ["delivery", "runtime", "progression", "projection"], + "additionalProperties": false + }, + "renderers": { + "type": "object", + "properties": { + "bakedWeb": { + "type": "object", + "properties": { + "supported": { + "type": "boolean" + }, + "contractVersion": { + "type": "number", + "const": 1 + }, + "features": { + "type": "array", + "items": { + "type": "string", + "enum": ["alpha-opaque", "alpha-straight", "png", "srgb"] + } + } + }, + "required": ["supported", "contractVersion", "features"], + "additionalProperties": false + }, + "nativeUi": { + "type": "object", + "properties": { + "supported": { + "type": "boolean" + }, + "contractVersion": { + "type": "number", + "const": 1 + }, + "features": { + "type": "array", + "items": { + "type": "string", + "enum": ["clip", "ellipsis", "explicitFontFallback"] + } + } + }, + "required": ["supported", "contractVersion", "features"], + "additionalProperties": false + }, + "video": { + "type": "object", + "properties": { + "supported": { + "type": "boolean" + }, + "contractVersion": { + "type": "number", + "const": 1 + }, + "features": { + "type": "array", + "items": { + "type": "string", + "enum": ["alpha", "audio", "h264", "vp9", "av1"] + } + } + }, + "required": ["supported", "contractVersion", "features"], + "additionalProperties": false + } + }, + "required": ["bakedWeb", "nativeUi", "video"], + "additionalProperties": false + }, + "model": { + "type": "object", + "properties": { + "supported": { + "type": "boolean" + }, + "contractVersion": { + "type": "number", + "const": 1 + }, + "formats": { + "type": "array", + "prefixItems": [ + { + "type": "string", + "const": "model/gltf-binary" + } + ], + "items": false, + "minItems": 1, + "maxItems": 1 + }, + "features": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "alphaBlend", + "alphaMask", + "animation", + "morphTargets", + "pbrMetallicRoughness", + "skin", + "unlit" + ] + } + } + }, + "required": ["supported", "contractVersion", "formats", "features"], + "additionalProperties": false + }, + "limits": { + "type": "object", + "properties": { + "texture": { + "type": "object", + "properties": { + "tierId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "maxTextureWidth": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxTextureHeight": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxTexturePixels": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxTextureBindings": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxGpuBytes": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxSerialLoadCpuBytes": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxEncodedCacheBytes": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "encodedCacheReserveBytes": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "tierId", + "maxTextureWidth", + "maxTextureHeight", + "maxTexturePixels", + "maxTextureBindings", + "maxGpuBytes", + "maxSerialLoadCpuBytes", + "maxEncodedCacheBytes", + "encodedCacheReserveBytes" + ], + "additionalProperties": false + }, + "nativeUi": { + "type": "object", + "properties": { + "tierId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "maxNodesPerArtifact": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxTreeDepth": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxTextNodesPerArtifact": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxCodePointsPerText": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxGlyphs": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxFontAssets": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "tierId", + "maxNodesPerArtifact", + "maxTreeDepth", + "maxTextNodesPerArtifact", + "maxCodePointsPerText", + "maxGlyphs", + "maxFontAssets" + ], + "additionalProperties": false + }, + "video": { + "type": "object", + "properties": { + "tierId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "maxWidth": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxHeight": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxPixels": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxConcurrentDecoders": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxEncodedBytes": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxDecodedFrameBytes": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "tierId", + "maxWidth", + "maxHeight", + "maxPixels", + "maxConcurrentDecoders", + "maxEncodedBytes", + "maxDecodedFrameBytes" + ], + "additionalProperties": false + }, + "model": { + "type": "object", + "properties": { + "tierId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "maxAssets": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxInstances": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxEncodedBytes": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxNodes": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxPrimitives": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxVertices": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxTriangles": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxBonesPerSkin": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxMorphTargetsPerPrimitive": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxAnimationClipsPerAsset": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "tierId", + "maxAssets", + "maxInstances", + "maxEncodedBytes", + "maxNodes", + "maxPrimitives", + "maxVertices", + "maxTriangles", + "maxBonesPerSkin", + "maxMorphTargetsPerPrimitive", + "maxAnimationClipsPerAsset" + ], + "additionalProperties": false + } + }, + "required": ["texture", "nativeUi", "video", "model"], + "additionalProperties": false + }, + "localOverlaySupported": { + "type": "boolean" + } + }, + "required": [ + "schemaVersion", + "capabilityProfileId", + "contractVersions", + "renderers", + "model", + "limits", + "localOverlaySupported" + ], + "additionalProperties": false, + "$id": "https://contracts.unframe.dev/presentation/capability-profile.v2.schema.json" +} diff --git a/packages/contracts/presentation/v2/contract.pb b/packages/contracts/presentation/v2/contract.pb new file mode 100644 index 0000000..a882fdb Binary files /dev/null and b/packages/contracts/presentation/v2/contract.pb differ diff --git a/packages/contracts/presentation/v2/fixtures/README.md b/packages/contracts/presentation/v2/fixtures/README.md new file mode 100644 index 0000000..e404eca --- /dev/null +++ b/packages/contracts/presentation/v2/fixtures/README.md @@ -0,0 +1,7 @@ +# v2 contract fixtures + +JSON は ModelNode、一つずつの baked-web / native-ui / video Surface、数値 Variable、モデル clip 再生 Cue を持つ合成公開物です。Definition → Bundle → Build → Publication の canonical hash と Asset descriptor の一致をテストします。 + +素材 checksum、モデル件数、Capability の値は構造検証用の合成値です。実バイトは付属せず、素材検査・GPU admission・動画再生の成功例ではありません。製品の端末 profile として使用しないでください。 + +`wire/` は停止した二 clip の保持姿勢、一時停止した crossfade、明示 null、clip を持たないモデルの配信 Node の独立した Protobuf encoding 例です。Run reducer や参照先素材は含まず、serialize / deserialize で各状態を失わないことを検証します。 diff --git a/packages/contracts/presentation/v2/fixtures/asset-set-manifest.json b/packages/contracts/presentation/v2/fixtures/asset-set-manifest.json new file mode 100644 index 0000000..6a465b5 --- /dev/null +++ b/packages/contracts/presentation/v2/fixtures/asset-set-manifest.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 2, + "assets": { + "image": { + "checksum": "sha256:affa657b3c3e9337bdf3c879c0561b74d54a9197fdc6acd834dcdc02837454fc", + "mediaType": "image/png", + "encodedSizeBytes": 64 + }, + "texture": { + "checksum": "sha256:8bb284b35b1014ff30cfb0da419d98e11d78e32ce8c8a545d6b45bc448b10b1c", + "mediaType": "image/png", + "encodedSizeBytes": 64 + }, + "font": { + "checksum": "sha256:637c82de478ca913fe74688e4ec9d50ff9de7cea2f408f1e4f534bd5e8921d2a", + "mediaType": "font/ttf", + "encodedSizeBytes": 64 + }, + "model-asset": { + "checksum": "sha256:fd74b90fd019e393d01a960cefb7b188bd78ff80f4ef64d0f5bdf41f60029131", + "mediaType": "model/gltf-binary", + "encodedSizeBytes": 64 + }, + "video-asset": { + "checksum": "sha256:1e44022b2f4e8d4ba9fb6b9a56e459ce780b0ed64005df00cf45de9f930ecf49", + "mediaType": "video/mp4", + "encodedSizeBytes": 64 + } + } +} diff --git a/packages/contracts/presentation/v2/fixtures/build-manifest.json b/packages/contracts/presentation/v2/fixtures/build-manifest.json new file mode 100644 index 0000000..7ea38d6 --- /dev/null +++ b/packages/contracts/presentation/v2/fixtures/build-manifest.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 2, + "buildId": "build-1", + "presentationId": "demo", + "sourceDraftRevision": 1, + "definitionHash": "sha256:b05561ad3155507af6a11a19688607b04a1e4841f91df4470bdd42ad74468e40", + "renderBundleHash": "sha256:87765cac5f489f2fb6e176ffa4b743cbc8426c55ad6073d1c501c53ef162adf2", + "assetSetHash": "sha256:204a4e2ca13609d961ffb7bbb69646c240286a4242bf0650e725890c78a9e7b8", + "contractVersions": { + "definition": 2, + "renderBundle": 2, + "assetSet": 2, + "delivery": 2, + "runtime": 2, + "progression": 1, + "projection": 1 + } +} diff --git a/packages/contracts/presentation/v2/fixtures/capability-profile.json b/packages/contracts/presentation/v2/fixtures/capability-profile.json new file mode 100644 index 0000000..2f7370f --- /dev/null +++ b/packages/contracts/presentation/v2/fixtures/capability-profile.json @@ -0,0 +1,78 @@ +{ + "schemaVersion": 2, + "capabilityProfileId": "synthetic-test-only", + "contractVersions": { + "delivery": 2, + "runtime": 2, + "progression": 1, + "projection": 1 + }, + "renderers": { + "bakedWeb": { + "supported": true, + "contractVersion": 1, + "features": ["alpha-opaque", "alpha-straight", "png", "srgb"] + }, + "nativeUi": { + "supported": true, + "contractVersion": 1, + "features": ["clip", "ellipsis", "explicitFontFallback"] + }, + "video": { + "supported": true, + "contractVersion": 1, + "features": ["audio", "h264"] + } + }, + "model": { + "supported": true, + "contractVersion": 1, + "formats": ["model/gltf-binary"], + "features": ["animation", "morphTargets", "skin", "unlit"] + }, + "limits": { + "texture": { + "tierId": "synthetic-test-only", + "maxTextureWidth": 2048, + "maxTextureHeight": 2048, + "maxTexturePixels": 4194304, + "maxTextureBindings": 1000000, + "maxGpuBytes": 67108864, + "maxSerialLoadCpuBytes": 67108864, + "maxEncodedCacheBytes": 67108864, + "encodedCacheReserveBytes": 1024 + }, + "nativeUi": { + "tierId": "synthetic-test-only", + "maxNodesPerArtifact": 1000000, + "maxTreeDepth": 1000000, + "maxTextNodesPerArtifact": 1000000, + "maxCodePointsPerText": 1000000, + "maxGlyphs": 1000000, + "maxFontAssets": 1000000 + }, + "video": { + "tierId": "synthetic-test-only", + "maxWidth": 1000000, + "maxHeight": 1000000, + "maxPixels": 1000000, + "maxConcurrentDecoders": 1000000, + "maxEncodedBytes": 1000000, + "maxDecodedFrameBytes": 1000000 + }, + "model": { + "tierId": "synthetic-test-only", + "maxAssets": 1000000, + "maxInstances": 1000000, + "maxEncodedBytes": 1000000, + "maxVertices": 1000000, + "maxTriangles": 1000000, + "maxNodes": 1000000, + "maxPrimitives": 1000000, + "maxBonesPerSkin": 1000000, + "maxMorphTargetsPerPrimitive": 1000000, + "maxAnimationClipsPerAsset": 1000000 + } + }, + "localOverlaySupported": false +} diff --git a/packages/contracts/presentation/v2/fixtures/presentation-definition.json b/packages/contracts/presentation/v2/fixtures/presentation-definition.json new file mode 100644 index 0000000..a6cda80 --- /dev/null +++ b/packages/contracts/presentation/v2/fixtures/presentation-definition.json @@ -0,0 +1,604 @@ +{ + "schemaVersion": 2, + "presentationId": "demo", + "metadata": { + "title": "Contract fixture" + }, + "stage": { + "coordinateSystem": { + "unit": "meter", + "handedness": "right", + "upAxis": "+Y", + "forwardAxis": "-Z" + }, + "size": [10, 10, 10], + "zones": {} + }, + "scene": { + "nodes": { + "model": { + "owner": { + "kind": "presentation" + }, + "audience": { + "kind": "all" + }, + "parent": { + "kind": "stage" + }, + "order": 0, + "transform": { + "position": [0, 0, 0], + "rotation": [0, 0, 0, 1], + "scale": [1, 1, 1] + }, + "active": true, + "visible": true, + "opacity": 1, + "id": "model", + "kind": "model", + "assetId": "model-asset" + }, + "node-baked": { + "owner": { + "kind": "presentation" + }, + "audience": { + "kind": "all" + }, + "parent": { + "kind": "stage" + }, + "order": 1, + "transform": { + "position": [0, 0, 0], + "rotation": [0, 0, 0, 1], + "scale": [1, 1, 1] + }, + "active": true, + "visible": true, + "opacity": 1, + "id": "node-baked", + "kind": "surface", + "surfaceId": "baked" + }, + "node-native": { + "owner": { + "kind": "presentation" + }, + "audience": { + "kind": "all" + }, + "parent": { + "kind": "stage" + }, + "order": 2, + "transform": { + "position": [0, 0, 0], + "rotation": [0, 0, 0, 1], + "scale": [1, 1, 1] + }, + "active": true, + "visible": true, + "opacity": 1, + "id": "node-native", + "kind": "surface", + "surfaceId": "native" + }, + "node-video": { + "owner": { + "kind": "presentation" + }, + "audience": { + "kind": "all" + }, + "parent": { + "kind": "stage" + }, + "order": 3, + "transform": { + "position": [0, 0, 0], + "rotation": [0, 0, 0, 1], + "scale": [1, 1, 1] + }, + "active": true, + "visible": true, + "opacity": 1, + "id": "node-video", + "kind": "surface", + "surfaceId": "video" + } + }, + "surfaces": { + "baked": { + "id": "baked", + "hostNodeId": "node-baked", + "physicalSizeMeters": [1, 1], + "logicalSize": [100, 100], + "fit": "contain", + "rootFrameId": "root", + "contentNodes": { + "root": { + "visible": true, + "opacity": 1, + "placement": { + "kind": "absolute", + "x": 0, + "y": 0, + "width": 100, + "height": 100 + }, + "id": "root", + "parentId": null, + "order": 0, + "kind": "frame", + "children": ["text", "image", "shape"], + "layout": { + "kind": "absolute" + }, + "backgroundColor": { + "red": 1, + "green": 1, + "blue": 1, + "alpha": 1 + }, + "border": { + "color": { + "red": 1, + "green": 1, + "blue": 1, + "alpha": 1 + }, + "width": 0, + "radius": 0 + }, + "clip": true + }, + "text": { + "visible": true, + "opacity": 1, + "placement": { + "kind": "absolute", + "x": 0, + "y": 0, + "width": 100, + "height": 100 + }, + "parentId": "root", + "order": 0, + "id": "text", + "kind": "text", + "semanticNodeId": "label", + "maxCodePoints": 16, + "value": { + "kind": "literal", + "value": "Hello" + }, + "style": { + "fontAssetId": "font", + "fallbackFontAssetIds": [], + "fontSize": 20, + "lineHeight": 24, + "color": { + "red": 1, + "green": 1, + "blue": 1, + "alpha": 1 + }, + "weight": "regular", + "align": "start", + "overflow": "clip" + } + }, + "image": { + "visible": true, + "opacity": 1, + "placement": { + "kind": "absolute", + "x": 0, + "y": 0, + "width": 100, + "height": 100 + }, + "parentId": "root", + "order": 1, + "id": "image", + "kind": "image", + "assetId": "image", + "style": { + "fit": "contain", + "tint": { + "red": 1, + "green": 1, + "blue": 1, + "alpha": 1 + }, + "border": { + "color": { + "red": 1, + "green": 1, + "blue": 1, + "alpha": 1 + }, + "width": 0, + "radius": 0 + } + } + }, + "shape": { + "visible": true, + "opacity": 1, + "placement": { + "kind": "absolute", + "x": 0, + "y": 0, + "width": 100, + "height": 100 + }, + "parentId": "root", + "order": 2, + "id": "shape", + "kind": "shape", + "geometry": { + "kind": "rectangle", + "width": 10, + "height": 10, + "radius": 0 + }, + "style": { + "fill": { + "red": 1, + "green": 1, + "blue": 1, + "alpha": 1 + }, + "stroke": { + "red": 1, + "green": 1, + "blue": 1, + "alpha": 1 + }, + "strokeWidth": 0 + } + } + }, + "baseSemanticTree": { + "rootNodeIds": ["label"], + "nodes": { + "label": { + "id": "label", + "parentId": null, + "order": 0, + "role": "paragraph", + "text": "Hello" + } + } + }, + "interactions": {}, + "initialStateId": "default", + "states": { + "default": { + "id": "default", + "contentOverrides": {}, + "semanticOverrides": [], + "enabledInteractionIds": [] + } + }, + "renderIntent": { + "updateModel": { + "kind": "static" + }, + "interaction": { + "kind": "none" + }, + "internalAnimation": { + "kind": "none" + }, + "rendererPreference": "baked-web", + "fallbackPolicy": "reject" + } + }, + "native": { + "id": "native", + "hostNodeId": "node-native", + "physicalSizeMeters": [1, 1], + "logicalSize": [100, 100], + "fit": "contain", + "rootFrameId": "root", + "contentNodes": { + "root": { + "visible": true, + "opacity": 1, + "placement": { + "kind": "absolute", + "x": 0, + "y": 0, + "width": 100, + "height": 100 + }, + "id": "root", + "parentId": null, + "order": 0, + "kind": "frame", + "children": ["text"], + "layout": { + "kind": "absolute" + }, + "backgroundColor": { + "red": 1, + "green": 1, + "blue": 1, + "alpha": 0 + }, + "border": { + "color": { + "red": 1, + "green": 1, + "blue": 1, + "alpha": 1 + }, + "width": 0, + "radius": 0 + }, + "clip": true + }, + "text": { + "visible": true, + "opacity": 1, + "placement": { + "kind": "absolute", + "x": 0, + "y": 0, + "width": 100, + "height": 100 + }, + "parentId": "root", + "order": 0, + "id": "text", + "kind": "text", + "semanticNodeId": "label", + "maxCodePoints": 16, + "value": { + "kind": "variableNumber", + "variableId": "counter", + "expectedType": "number", + "format": { + "kind": "number", + "fractionDigits": 0 + } + }, + "style": { + "fontAssetId": "font", + "fallbackFontAssetIds": [], + "fontSize": 20, + "lineHeight": 24, + "color": { + "red": 1, + "green": 1, + "blue": 1, + "alpha": 1 + }, + "weight": "regular", + "align": "start", + "overflow": "clip" + } + } + }, + "baseSemanticTree": { + "rootNodeIds": ["label"], + "nodes": { + "label": { + "id": "label", + "parentId": null, + "order": 0, + "role": "paragraph", + "text": "0" + } + } + }, + "interactions": {}, + "initialStateId": "default", + "states": { + "default": { + "id": "default", + "contentOverrides": {}, + "semanticOverrides": [], + "enabledInteractionIds": [] + } + }, + "renderIntent": { + "updateModel": { + "kind": "continuous-native-text", + "maximumUpdateRateHz": 1 + }, + "interaction": { + "kind": "none" + }, + "internalAnimation": { + "kind": "none" + }, + "rendererPreference": "native-ui", + "fallbackPolicy": "reject" + } + }, + "video": { + "id": "video", + "hostNodeId": "node-video", + "physicalSizeMeters": [1, 1], + "logicalSize": [100, 100], + "fit": "contain", + "rootFrameId": "root", + "contentNodes": { + "root": { + "visible": true, + "opacity": 1, + "placement": { + "kind": "absolute", + "x": 0, + "y": 0, + "width": 100, + "height": 100 + }, + "id": "root", + "parentId": null, + "order": 0, + "kind": "frame", + "children": ["video"], + "layout": { + "kind": "absolute" + }, + "backgroundColor": { + "red": 1, + "green": 1, + "blue": 1, + "alpha": 1 + }, + "border": { + "color": { + "red": 1, + "green": 1, + "blue": 1, + "alpha": 1 + }, + "width": 0, + "radius": 0 + }, + "clip": true + }, + "video": { + "visible": true, + "opacity": 1, + "placement": { + "kind": "absolute", + "x": 0, + "y": 0, + "width": 100, + "height": 100 + }, + "parentId": "root", + "order": 0, + "id": "video", + "kind": "video", + "assetId": "video-asset", + "loop": false, + "style": { + "fit": "contain", + "tint": { + "red": 1, + "green": 1, + "blue": 1, + "alpha": 1 + }, + "border": { + "color": { + "red": 1, + "green": 1, + "blue": 1, + "alpha": 1 + }, + "width": 0, + "radius": 0 + } + }, + "semanticNodeId": "label" + } + }, + "baseSemanticTree": { + "rootNodeIds": ["label"], + "nodes": { + "label": { + "id": "label", + "parentId": null, + "order": 0, + "role": "image", + "alt": "Demonstration video" + } + } + }, + "interactions": {}, + "initialStateId": "default", + "states": { + "default": { + "id": "default", + "contentOverrides": {}, + "semanticOverrides": [], + "enabledInteractionIds": [] + } + }, + "renderIntent": { + "updateModel": { + "kind": "static" + }, + "interaction": { + "kind": "none" + }, + "internalAnimation": { + "kind": "precomputed-video", + "durationMilliseconds": 1000 + }, + "rendererPreference": "video", + "fallbackPolicy": "reject" + } + } + } + }, + "flow": { + "initialGroupId": "intro", + "groups": { + "intro": { + "id": "intro", + "initialStepId": "start", + "steps": { + "start": { + "id": "start", + "cues": [ + { + "id": "wave", + "priority": 0, + "order": 0, + "trigger": { + "kind": "logicalInput", + "action": "next", + "actor": { + "kind": "presenter" + } + }, + "firePolicy": { + "kind": "oncePerStepEntry" + }, + "actions": [ + { + "kind": "modelClip.play", + "nodeId": "model", + "clipId": "wave", + "speed": 1, + "loop": false, + "completion": "nonBlocking", + "transition": { + "kind": "immediate" + }, + "conflict": "reject" + } + ], + "next": { + "kind": "stay" + } + } + ] + } + } + } + }, + "variables": { + "counter": { + "id": "counter", + "owner": { + "kind": "presentation" + }, + "type": "number", + "initialValue": 0 + } + }, + "timelines": {} + } +} diff --git a/packages/contracts/presentation/v2/fixtures/published-presentation.json b/packages/contracts/presentation/v2/fixtures/published-presentation.json new file mode 100644 index 0000000..cc258bb --- /dev/null +++ b/packages/contracts/presentation/v2/fixtures/published-presentation.json @@ -0,0 +1,20 @@ +{ + "schemaVersion": 2, + "buildId": "build-1", + "presentationId": "demo", + "sourceDraftRevision": 1, + "definitionHash": "sha256:b05561ad3155507af6a11a19688607b04a1e4841f91df4470bdd42ad74468e40", + "renderBundleHash": "sha256:87765cac5f489f2fb6e176ffa4b743cbc8426c55ad6073d1c501c53ef162adf2", + "assetSetHash": "sha256:204a4e2ca13609d961ffb7bbb69646c240286a4242bf0650e725890c78a9e7b8", + "contractVersions": { + "definition": 2, + "renderBundle": 2, + "assetSet": 2, + "delivery": 2, + "runtime": 2, + "progression": 1, + "projection": 1 + }, + "publicationEpoch": 1, + "publicationManifestHash": "sha256:316a7ce5837a607b56fecbd336df73ac266e2373b9cc27b4c34bdf0a6226e15c" +} diff --git a/packages/contracts/presentation/v2/fixtures/render-bundle.json b/packages/contracts/presentation/v2/fixtures/render-bundle.json new file mode 100644 index 0000000..76a02a8 --- /dev/null +++ b/packages/contracts/presentation/v2/fixtures/render-bundle.json @@ -0,0 +1,300 @@ +{ + "schemaVersion": 2, + "bundleId": "build-1", + "sourceHash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "definitionHash": "sha256:b05561ad3155507af6a11a19688607b04a1e4841f91df4470bdd42ad74468e40", + "compiler": { + "name": "fixture", + "version": "2", + "environmentHash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "buildContext": { + "locale": "en-US", + "timezone": "UTC", + "colorScheme": "light", + "themeId": "default", + "themeHash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "textureBuildPolicy": { + "policyVersion": 1, + "resolutionPolicyVersion": 1, + "longEdgePixels": 2048, + "maxStatesPerRenderSurface": 32, + "maxRenderSurfacesPerSemanticSurface": 8, + "maxRenderSurfacesPerBundle": 64, + "maxTextureBindings": 64, + "maxTextureWidth": 2048, + "maxTextureHeight": 2048, + "maxTexturePixels": 4194304, + "maxRenderedPixels": 67108864, + "maxSurfaceCaptureBytes": 268435456, + "maxBuildOutputBytes": 268435456, + "maxBuildAccountedPeakBytes": 536870912, + "rendererConcurrency": 1, + "policyHash": "sha256:fedd67d8c18f7b9a2118ac566df5ad5fd48edebecd85353c86fbfad9cbb81dfc" + } + }, + "surfaces": { + "baked": { + "semanticSurfaceId": "baked", + "logicalSize": [100, 100], + "physicalSizeMeters": [1, 1], + "renderSurfaceIds": ["render-baked"], + "renderSurfaces": { + "render-baked": { + "id": "render-baked", + "semanticSurfaceId": "baked", + "logicalBounds": { + "x": 0, + "y": 0, + "width": 100, + "height": 100 + }, + "layer": 0, + "artifacts": { + "artifact-baked": { + "id": "artifact-baked", + "kind": "baked-web", + "contractVersion": 1, + "requiredFeatures": ["alpha-opaque", "png", "srgb"], + "states": { + "default": { + "stateId": "default", + "texture": { + "assetId": "texture", + "checksum": "sha256:8bb284b35b1014ff30cfb0da419d98e11d78e32ce8c8a545d6b45bc448b10b1c", + "mediaType": "image/png", + "encodedSizeBytes": 64, + "pixelSize": [2048, 2048], + "colorSpace": "srgb", + "alphaMode": "opaque", + "mipCount": 1, + "gpuBytes": 16777216 + } + } + } + } + }, + "stateBindings": { + "default": { + "kind": "artifacts", + "artifactIds": ["artifact-baked"] + } + } + } + }, + "semanticsByState": { + "default": { + "rootNodeIds": ["label"], + "nodes": { + "label": { + "id": "label", + "parentId": null, + "order": 0, + "role": "paragraph", + "text": "Hello" + } + } + } + }, + "interactionsByState": { + "default": [] + } + }, + "native": { + "semanticSurfaceId": "native", + "logicalSize": [100, 100], + "physicalSizeMeters": [1, 1], + "renderSurfaceIds": ["render-native"], + "renderSurfaces": { + "render-native": { + "id": "render-native", + "semanticSurfaceId": "native", + "logicalBounds": { + "x": 0, + "y": 0, + "width": 100, + "height": 100 + }, + "layer": 0, + "artifacts": { + "artifact-native": { + "id": "artifact-native", + "kind": "native-ui", + "contractVersion": 1, + "requiredFeatures": ["clip"], + "rootNodeId": "root", + "nodes": { + "root": { + "id": "root", + "kind": "group", + "bounds": { + "x": 0, + "y": 0, + "width": 100, + "height": 100, + "coordinateSpace": "renderSurfaceLogical" + }, + "clip": true, + "children": ["text"] + }, + "text": { + "id": "text", + "kind": "text", + "bounds": { + "x": 0, + "y": 0, + "width": 100, + "height": 100, + "coordinateSpace": "renderSurfaceLogical" + }, + "semanticNodeId": "label", + "value": { + "kind": "variableNumber", + "variableId": "counter", + "expectedType": "number", + "format": { + "kind": "number", + "fractionDigits": 0 + } + }, + "font": { + "primary": { + "assetId": "font", + "supportedCodePointRanges": [[32, 126]] + }, + "fallbacks": [] + }, + "color": { + "red": 1, + "green": 1, + "blue": 1, + "alpha": 1 + }, + "fontSize": 20, + "lineHeight": 24, + "align": "start", + "overflow": "clip", + "maxCodePoints": 16 + } + } + } + }, + "stateBindings": { + "default": { + "kind": "artifacts", + "artifactIds": ["artifact-native"] + } + } + } + }, + "semanticsByState": { + "default": { + "rootNodeIds": ["label"], + "nodes": { + "label": { + "id": "label", + "parentId": null, + "order": 0, + "role": "paragraph", + "text": "0" + } + } + } + }, + "interactionsByState": { + "default": [] + } + }, + "video": { + "semanticSurfaceId": "video", + "logicalSize": [100, 100], + "physicalSizeMeters": [1, 1], + "renderSurfaceIds": ["render-video"], + "renderSurfaces": { + "render-video": { + "id": "render-video", + "semanticSurfaceId": "video", + "logicalBounds": { + "x": 0, + "y": 0, + "width": 100, + "height": 100 + }, + "layer": 0, + "artifacts": { + "artifact-video": { + "id": "artifact-video", + "kind": "video", + "contractVersion": 1, + "requiredFeatures": ["h264"], + "assetId": "video-asset", + "checksum": "sha256:1e44022b2f4e8d4ba9fb6b9a56e459ce780b0ed64005df00cf45de9f930ecf49", + "mediaType": "video/mp4", + "encodedSizeBytes": 64, + "codec": "h264", + "durationMilliseconds": 1000, + "loop": false, + "alpha": false, + "audio": false, + "pixelSize": [100, 100] + } + }, + "stateBindings": { + "default": { + "kind": "artifacts", + "artifactIds": ["artifact-video"] + } + } + } + }, + "semanticsByState": { + "default": { + "rootNodeIds": ["label"], + "nodes": { + "label": { + "id": "label", + "parentId": null, + "order": 0, + "role": "image", + "alt": "Demonstration video" + } + } + } + }, + "interactionsByState": { + "default": [] + } + } + }, + "models": { + "model-asset": { + "assetId": "model-asset", + "checksum": "sha256:fd74b90fd019e393d01a960cefb7b188bd78ff80f4ef64d0f5bdf41f60029131", + "mediaType": "model/gltf-binary", + "encodedSizeBytes": 64, + "requiredFeatures": ["animation", "unlit"], + "rootMotion": "removed", + "nodeCount": 1, + "primitiveCount": 1, + "vertexCount": 3, + "triangleCount": 1, + "maxBonesPerSkin": 0, + "maxMorphTargetsPerPrimitive": 0, + "animationClipCount": 1, + "materials": { + "basic": { + "sourceMaterialIndex": 0, + "shaderModel": "unlit", + "alphaMode": "opaque", + "doubleSided": false + } + }, + "clips": { + "wave": { + "sourceAnimationIndex": 0, + "durationMilliseconds": 1000 + } + } + } + } +} diff --git a/packages/contracts/presentation/v2/fixtures/wire/clipless-model-node.textproto b/packages/contracts/presentation/v2/fixtures/wire/clipless-model-node.textproto new file mode 100644 index 0000000..ee59047 --- /dev/null +++ b/packages/contracts/presentation/v2/fixtures/wire/clipless-model-node.textproto @@ -0,0 +1,5 @@ +node_id: "statue" +parent { stage {} } +order: 1 +owner { presentation {} } +model { model_asset_id: "statue-asset" } diff --git a/packages/contracts/presentation/v2/fixtures/wire/held-model-blend.textproto b/packages/contracts/presentation/v2/fixtures/wire/held-model-blend.textproto new file mode 100644 index 0000000..31e7641 --- /dev/null +++ b/packages/contracts/presentation/v2/fixtures/wire/held-model-blend.textproto @@ -0,0 +1,6 @@ +model_node_id: "model" +held_blend { + from { clip_id: "idle" position_ms: 200 } + to { clip_id: "wave" position_ms: 300 } + to_weight: 0.25 +} diff --git a/packages/contracts/presentation/v2/fixtures/wire/null-scalar.textproto b/packages/contracts/presentation/v2/fixtures/wire/null-scalar.textproto new file mode 100644 index 0000000..9bbdcf0 --- /dev/null +++ b/packages/contracts/presentation/v2/fixtures/wire/null-scalar.textproto @@ -0,0 +1 @@ +null_value {} diff --git a/packages/contracts/presentation/v2/fixtures/wire/paused-crossfade-held-source.textproto b/packages/contracts/presentation/v2/fixtures/wire/paused-crossfade-held-source.textproto new file mode 100644 index 0000000..1631173 --- /dev/null +++ b/packages/contracts/presentation/v2/fixtures/wire/paused-crossfade-held-source.textproto @@ -0,0 +1,19 @@ +model_node_id: "model" +crossfade { + from_is_held: true + from { + clip_id: "idle" + playback { paused { position_ms: 200 } } + speed: 1 + loop: false + } + to { + clip_id: "wave" + playback { paused { position_ms: 300 } } + speed: 0.5 + loop: false + } + transition_clock { paused { position_ms: 100 } } + duration_ms: 400 + easing: EASING_LINEAR +} diff --git a/packages/contracts/presentation/v2/fixtures/wire/paused-model-crossfade.textproto b/packages/contracts/presentation/v2/fixtures/wire/paused-model-crossfade.textproto new file mode 100644 index 0000000..bdfdc79 --- /dev/null +++ b/packages/contracts/presentation/v2/fixtures/wire/paused-model-crossfade.textproto @@ -0,0 +1,18 @@ +model_node_id: "model" +crossfade { + from { + clip_id: "idle" + playback { paused { position_ms: 200 } } + speed: 1 + loop: true + } + to { + clip_id: "wave" + playback { paused { position_ms: 300 } } + speed: 0.5 + loop: false + } + transition_clock { paused { position_ms: 100 } } + duration_ms: 400 + easing: EASING_LINEAR +} diff --git a/packages/contracts/presentation/v2/presentation-definition.schema.json b/packages/contracts/presentation/v2/presentation-definition.schema.json new file mode 100644 index 0000000..ca2ded9 --- /dev/null +++ b/packages/contracts/presentation/v2/presentation-definition.schema.json @@ -0,0 +1,7785 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "schemaVersion": { + "type": "number", + "const": 2 + }, + "presentationId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "metadata": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1 + } + }, + "required": ["title"], + "additionalProperties": false + }, + "stage": { + "type": "object", + "properties": { + "coordinateSystem": { + "type": "object", + "properties": { + "unit": { + "type": "string", + "const": "meter" + }, + "handedness": { + "type": "string", + "const": "right" + }, + "upAxis": { + "type": "string", + "const": "+Y" + }, + "forwardAxis": { + "type": "string", + "const": "-Z" + } + }, + "required": ["unit", "handedness", "upAxis", "forwardAxis"], + "additionalProperties": false + }, + "size": { + "type": "array", + "prefixItems": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "number", + "exclusiveMinimum": 0 + } + ], + "items": false, + "minItems": 3, + "maxItems": 3 + }, + "zones": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "owner": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "presentation" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "group" + }, + "groupId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "groupId"], + "additionalProperties": false + } + ] + }, + "center": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ], + "items": false, + "minItems": 3, + "maxItems": 3 + }, + "size": { + "type": "array", + "prefixItems": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "number", + "exclusiveMinimum": 0 + } + ], + "items": false, + "minItems": 3, + "maxItems": 3 + } + }, + "required": ["id", "owner", "center", "size"], + "additionalProperties": false + } + } + }, + "required": ["coordinateSystem", "size", "zones"], + "additionalProperties": false + }, + "scene": { + "type": "object", + "properties": { + "nodes": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "owner": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "presentation" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "group" + }, + "groupId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "groupId"], + "additionalProperties": false + } + ] + }, + "audience": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "all" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "role" + }, + "role": { + "type": "string", + "enum": ["presenter", "viewer"] + } + }, + "required": ["kind", "role"], + "additionalProperties": false + } + ] + }, + "parent": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "stage" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "node" + }, + "nodeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "nodeId"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "anchor" + }, + "target": { + "type": "string", + "enum": ["head", "leftHand", "rightHand", "body"] + }, + "owner": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "presenter" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + "followPosition": { + "type": "boolean" + }, + "followRotation": { + "type": "boolean" + } + }, + "required": ["kind", "target", "owner", "followPosition", "followRotation"], + "additionalProperties": false + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "transform": { + "type": "object", + "properties": { + "position": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ], + "items": false, + "minItems": 3, + "maxItems": 3 + }, + "rotation": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ], + "items": false, + "minItems": 4, + "maxItems": 4 + }, + "scale": { + "type": "array", + "prefixItems": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "number", + "exclusiveMinimum": 0 + } + ], + "items": false, + "minItems": 3, + "maxItems": 3 + } + }, + "required": ["position", "rotation", "scale"], + "additionalProperties": false + }, + "active": { + "type": "boolean" + }, + "visible": { + "type": "boolean" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "kind": { + "type": "string", + "const": "container" + } + }, + "required": [ + "id", + "owner", + "audience", + "parent", + "order", + "transform", + "active", + "visible", + "opacity", + "kind" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "owner": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "presentation" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "group" + }, + "groupId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "groupId"], + "additionalProperties": false + } + ] + }, + "audience": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "all" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "role" + }, + "role": { + "type": "string", + "enum": ["presenter", "viewer"] + } + }, + "required": ["kind", "role"], + "additionalProperties": false + } + ] + }, + "parent": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "stage" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "node" + }, + "nodeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "nodeId"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "anchor" + }, + "target": { + "type": "string", + "enum": ["head", "leftHand", "rightHand", "body"] + }, + "owner": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "presenter" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + "followPosition": { + "type": "boolean" + }, + "followRotation": { + "type": "boolean" + } + }, + "required": ["kind", "target", "owner", "followPosition", "followRotation"], + "additionalProperties": false + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "transform": { + "type": "object", + "properties": { + "position": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ], + "items": false, + "minItems": 3, + "maxItems": 3 + }, + "rotation": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ], + "items": false, + "minItems": 4, + "maxItems": 4 + }, + "scale": { + "type": "array", + "prefixItems": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "number", + "exclusiveMinimum": 0 + } + ], + "items": false, + "minItems": 3, + "maxItems": 3 + } + }, + "required": ["position", "rotation", "scale"], + "additionalProperties": false + }, + "active": { + "type": "boolean" + }, + "visible": { + "type": "boolean" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "kind": { + "type": "string", + "const": "model" + }, + "assetId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": [ + "id", + "owner", + "audience", + "parent", + "order", + "transform", + "active", + "visible", + "opacity", + "kind", + "assetId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "owner": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "presentation" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "group" + }, + "groupId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "groupId"], + "additionalProperties": false + } + ] + }, + "audience": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "all" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "role" + }, + "role": { + "type": "string", + "enum": ["presenter", "viewer"] + } + }, + "required": ["kind", "role"], + "additionalProperties": false + } + ] + }, + "parent": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "stage" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "node" + }, + "nodeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "nodeId"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "anchor" + }, + "target": { + "type": "string", + "enum": ["head", "leftHand", "rightHand", "body"] + }, + "owner": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "presenter" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + "followPosition": { + "type": "boolean" + }, + "followRotation": { + "type": "boolean" + } + }, + "required": ["kind", "target", "owner", "followPosition", "followRotation"], + "additionalProperties": false + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "transform": { + "type": "object", + "properties": { + "position": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ], + "items": false, + "minItems": 3, + "maxItems": 3 + }, + "rotation": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ], + "items": false, + "minItems": 4, + "maxItems": 4 + }, + "scale": { + "type": "array", + "prefixItems": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "number", + "exclusiveMinimum": 0 + } + ], + "items": false, + "minItems": 3, + "maxItems": 3 + } + }, + "required": ["position", "rotation", "scale"], + "additionalProperties": false + }, + "active": { + "type": "boolean" + }, + "visible": { + "type": "boolean" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "kind": { + "type": "string", + "const": "surface" + }, + "surfaceId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": [ + "id", + "owner", + "audience", + "parent", + "order", + "transform", + "active", + "visible", + "opacity", + "kind", + "surfaceId" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "owner": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "presentation" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "group" + }, + "groupId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "groupId"], + "additionalProperties": false + } + ] + }, + "audience": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "all" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "role" + }, + "role": { + "type": "string", + "enum": ["presenter", "viewer"] + } + }, + "required": ["kind", "role"], + "additionalProperties": false + } + ] + }, + "parent": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "stage" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "node" + }, + "nodeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "nodeId"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "anchor" + }, + "target": { + "type": "string", + "enum": ["head", "leftHand", "rightHand", "body"] + }, + "owner": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "presenter" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + "followPosition": { + "type": "boolean" + }, + "followRotation": { + "type": "boolean" + } + }, + "required": ["kind", "target", "owner", "followPosition", "followRotation"], + "additionalProperties": false + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "transform": { + "type": "object", + "properties": { + "position": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ], + "items": false, + "minItems": 3, + "maxItems": 3 + }, + "rotation": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ], + "items": false, + "minItems": 4, + "maxItems": 4 + }, + "scale": { + "type": "array", + "prefixItems": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "number", + "exclusiveMinimum": 0 + } + ], + "items": false, + "minItems": 3, + "maxItems": 3 + } + }, + "required": ["position", "rotation", "scale"], + "additionalProperties": false + }, + "active": { + "type": "boolean" + }, + "visible": { + "type": "boolean" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "kind": { + "type": "string", + "const": "shape" + }, + "geometry": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "box" + }, + "size": { + "type": "array", + "prefixItems": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "number", + "exclusiveMinimum": 0 + } + ], + "items": false, + "minItems": 3, + "maxItems": 3 + } + }, + "required": ["kind", "size"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "sphere" + }, + "radius": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["kind", "radius"], + "additionalProperties": false + } + ] + }, + "material": { + "type": "object", + "properties": { + "shaderModel": { + "type": "string", + "const": "unlit" + }, + "color": { + "type": "object", + "properties": { + "red": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["red", "green", "blue", "alpha"], + "additionalProperties": false + }, + "doubleSided": { + "type": "boolean" + }, + "castsShadows": { + "type": "boolean", + "const": false + }, + "receivesShadows": { + "type": "boolean", + "const": false + } + }, + "required": [ + "shaderModel", + "color", + "doubleSided", + "castsShadows", + "receivesShadows" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "owner", + "audience", + "parent", + "order", + "transform", + "active", + "visible", + "opacity", + "kind", + "geometry", + "material" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "owner": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "presentation" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "group" + }, + "groupId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "groupId"], + "additionalProperties": false + } + ] + }, + "audience": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "all" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "role" + }, + "role": { + "type": "string", + "enum": ["presenter", "viewer"] + } + }, + "required": ["kind", "role"], + "additionalProperties": false + } + ] + }, + "parent": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "stage" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "node" + }, + "nodeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "nodeId"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "anchor" + }, + "target": { + "type": "string", + "enum": ["head", "leftHand", "rightHand", "body"] + }, + "owner": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "presenter" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + "followPosition": { + "type": "boolean" + }, + "followRotation": { + "type": "boolean" + } + }, + "required": ["kind", "target", "owner", "followPosition", "followRotation"], + "additionalProperties": false + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "transform": { + "type": "object", + "properties": { + "position": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ], + "items": false, + "minItems": 3, + "maxItems": 3 + }, + "rotation": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ], + "items": false, + "minItems": 4, + "maxItems": 4 + }, + "scale": { + "type": "array", + "prefixItems": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "number", + "exclusiveMinimum": 0 + } + ], + "items": false, + "minItems": 3, + "maxItems": 3 + } + }, + "required": ["position", "rotation", "scale"], + "additionalProperties": false + }, + "active": { + "type": "boolean" + }, + "visible": { + "type": "boolean" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "kind": { + "type": "string", + "const": "light" + }, + "light": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "directional" + }, + "color": { + "type": "object", + "properties": { + "red": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["red", "green", "blue"], + "additionalProperties": false + }, + "intensityLux": { + "type": "number", + "minimum": 0 + }, + "castsShadows": { + "type": "boolean" + } + }, + "required": ["kind", "color", "intensityLux", "castsShadows"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "point" + }, + "color": { + "type": "object", + "properties": { + "red": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["red", "green", "blue"], + "additionalProperties": false + }, + "intensityCandela": { + "type": "number", + "minimum": 0 + }, + "rangeMeters": { + "type": "number", + "exclusiveMinimum": 0 + }, + "castsShadows": { + "type": "boolean" + } + }, + "required": [ + "kind", + "color", + "intensityCandela", + "rangeMeters", + "castsShadows" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "spot" + }, + "color": { + "type": "object", + "properties": { + "red": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["red", "green", "blue"], + "additionalProperties": false + }, + "intensityCandela": { + "type": "number", + "minimum": 0 + }, + "rangeMeters": { + "type": "number", + "exclusiveMinimum": 0 + }, + "outerAngleDegrees": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 180 + }, + "innerAngleDegrees": { + "type": "number", + "minimum": 0, + "maximum": 180 + }, + "castsShadows": { + "type": "boolean" + } + }, + "required": [ + "kind", + "color", + "intensityCandela", + "rangeMeters", + "outerAngleDegrees", + "innerAngleDegrees", + "castsShadows" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "id", + "owner", + "audience", + "parent", + "order", + "transform", + "active", + "visible", + "opacity", + "kind", + "light" + ], + "additionalProperties": false + } + ] + } + }, + "surfaces": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "hostNodeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "physicalSizeMeters": { + "type": "array", + "prefixItems": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "number", + "exclusiveMinimum": 0 + } + ], + "items": false, + "minItems": 2, + "maxItems": 2 + }, + "logicalSize": { + "type": "array", + "prefixItems": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "number", + "exclusiveMinimum": 0 + } + ], + "items": false, + "minItems": 2, + "maxItems": 2 + }, + "fit": { + "type": "string", + "enum": ["contain", "cover", "stretch"] + }, + "rootFrameId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "contentNodes": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "semanticNodeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "visible": { + "type": "boolean" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "placement": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "absolute" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["kind", "x", "y", "width", "height"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "stack" + }, + "grow": { + "type": "number", + "minimum": 0 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "alignSelf": { + "type": "string", + "enum": ["auto", "start", "center", "end", "stretch"] + }, + "margin": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "grow", + "width", + "height", + "alignSelf", + "margin" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "grid" + }, + "column": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "row": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "columnSpan": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "rowSpan": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "alignSelf": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + }, + "justifySelf": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + }, + "margin": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "column", + "row", + "columnSpan", + "rowSpan", + "width", + "height", + "alignSelf", + "justifySelf", + "margin" + ], + "additionalProperties": false + } + ] + }, + "kind": { + "type": "string", + "const": "frame" + }, + "children": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "layout": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "absolute" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "stack" + }, + "direction": { + "type": "string", + "enum": ["horizontal", "vertical"] + }, + "gap": { + "type": "number", + "minimum": 0 + }, + "padding": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + }, + "alignItems": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + }, + "justifyContent": { + "type": "string", + "enum": ["start", "center", "end", "spaceBetween"] + } + }, + "required": [ + "kind", + "direction", + "gap", + "padding", + "alignItems", + "justifyContent" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "grid" + }, + "columns": { + "minItems": 1, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "fixed" + }, + "size": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["kind", "size"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "fraction" + }, + "fraction": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["kind", "fraction"], + "additionalProperties": false + } + ] + } + }, + "rows": { + "minItems": 1, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "fixed" + }, + "size": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["kind", "size"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "fraction" + }, + "fraction": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["kind", "fraction"], + "additionalProperties": false + } + ] + } + }, + "columnGap": { + "type": "number", + "minimum": 0 + }, + "rowGap": { + "type": "number", + "minimum": 0 + }, + "padding": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "columns", + "rows", + "columnGap", + "rowGap", + "padding" + ], + "additionalProperties": false + } + ] + }, + "backgroundColor": { + "type": "object", + "properties": { + "red": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["red", "green", "blue", "alpha"], + "additionalProperties": false + }, + "border": { + "type": "object", + "properties": { + "color": { + "type": "object", + "properties": { + "red": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["red", "green", "blue", "alpha"], + "additionalProperties": false + }, + "width": { + "type": "number", + "minimum": 0 + }, + "radius": { + "type": "number", + "minimum": 0 + } + }, + "required": ["color", "width", "radius"], + "additionalProperties": false + }, + "clip": { + "type": "boolean" + } + }, + "required": [ + "id", + "parentId", + "order", + "visible", + "opacity", + "placement", + "kind", + "children", + "layout", + "backgroundColor", + "border", + "clip" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "semanticNodeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "visible": { + "type": "boolean" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "placement": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "absolute" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["kind", "x", "y", "width", "height"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "stack" + }, + "grow": { + "type": "number", + "minimum": 0 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "alignSelf": { + "type": "string", + "enum": ["auto", "start", "center", "end", "stretch"] + }, + "margin": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "grow", + "width", + "height", + "alignSelf", + "margin" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "grid" + }, + "column": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "row": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "columnSpan": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "rowSpan": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "alignSelf": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + }, + "justifySelf": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + }, + "margin": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "column", + "row", + "columnSpan", + "rowSpan", + "width", + "height", + "alignSelf", + "justifySelf", + "margin" + ], + "additionalProperties": false + } + ] + }, + "kind": { + "type": "string", + "const": "text" + }, + "value": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "literal" + }, + "value": { + "type": "string" + } + }, + "required": ["kind", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "variableString" + }, + "variableId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "expectedType": { + "type": "string", + "const": "string" + }, + "format": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "string" + }, + "allowedCodePointRanges": { + "minItems": 1, + "type": "array", + "items": { + "type": "array", + "prefixItems": [ + { + "type": "integer", + "minimum": 0, + "maximum": 1114111 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 1114111 + } + ], + "items": false, + "minItems": 2, + "maxItems": 2 + } + } + }, + "required": ["kind", "allowedCodePointRanges"], + "additionalProperties": false + } + }, + "required": ["kind", "variableId", "expectedType", "format"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "variableBoolean" + }, + "variableId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "expectedType": { + "type": "string", + "const": "boolean" + }, + "format": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "boolean" + }, + "trueLabel": { + "type": "string" + }, + "falseLabel": { + "type": "string" + } + }, + "required": ["kind", "trueLabel", "falseLabel"], + "additionalProperties": false + } + }, + "required": ["kind", "variableId", "expectedType", "format"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "variableNumber" + }, + "variableId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "expectedType": { + "type": "string", + "const": "number" + }, + "format": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "number" + }, + "fractionDigits": { + "anyOf": [ + { + "type": "number", + "const": 0 + }, + { + "type": "number", + "const": 1 + }, + { + "type": "number", + "const": 2 + }, + { + "type": "number", + "const": 3 + } + ] + } + }, + "required": ["kind", "fractionDigits"], + "additionalProperties": false + } + }, + "required": ["kind", "variableId", "expectedType", "format"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "stepTimerRemaining" + }, + "groupId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "stepId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "cueId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "durationMilliseconds": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "whenStepInactive": { + "type": "string", + "enum": ["empty", "zero"] + }, + "format": { + "type": "string", + "enum": ["mm:ss", "hh:mm:ss"] + } + }, + "required": [ + "kind", + "groupId", + "stepId", + "cueId", + "durationMilliseconds", + "whenStepInactive", + "format" + ], + "additionalProperties": false + } + ] + }, + "maxCodePoints": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "style": { + "type": "object", + "properties": { + "fontAssetId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "fallbackFontAssetIds": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "fontSize": { + "type": "number", + "exclusiveMinimum": 0 + }, + "lineHeight": { + "type": "number", + "exclusiveMinimum": 0 + }, + "color": { + "type": "object", + "properties": { + "red": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["red", "green", "blue", "alpha"], + "additionalProperties": false + }, + "weight": { + "type": "string", + "enum": ["regular", "bold"] + }, + "align": { + "type": "string", + "enum": ["start", "center", "end"] + }, + "overflow": { + "type": "string", + "enum": ["clip", "ellipsis"] + } + }, + "required": [ + "fontAssetId", + "fallbackFontAssetIds", + "fontSize", + "lineHeight", + "color", + "weight", + "align", + "overflow" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "parentId", + "order", + "visible", + "opacity", + "placement", + "kind", + "value", + "maxCodePoints", + "style" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "semanticNodeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "visible": { + "type": "boolean" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "placement": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "absolute" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["kind", "x", "y", "width", "height"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "stack" + }, + "grow": { + "type": "number", + "minimum": 0 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "alignSelf": { + "type": "string", + "enum": ["auto", "start", "center", "end", "stretch"] + }, + "margin": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "grow", + "width", + "height", + "alignSelf", + "margin" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "grid" + }, + "column": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "row": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "columnSpan": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "rowSpan": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "alignSelf": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + }, + "justifySelf": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + }, + "margin": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "column", + "row", + "columnSpan", + "rowSpan", + "width", + "height", + "alignSelf", + "justifySelf", + "margin" + ], + "additionalProperties": false + } + ] + }, + "kind": { + "type": "string", + "const": "image" + }, + "assetId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "style": { + "type": "object", + "properties": { + "fit": { + "type": "string", + "enum": ["contain", "cover", "stretch"] + }, + "tint": { + "type": "object", + "properties": { + "red": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["red", "green", "blue", "alpha"], + "additionalProperties": false + }, + "border": { + "type": "object", + "properties": { + "color": { + "type": "object", + "properties": { + "red": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["red", "green", "blue", "alpha"], + "additionalProperties": false + }, + "width": { + "type": "number", + "minimum": 0 + }, + "radius": { + "type": "number", + "minimum": 0 + } + }, + "required": ["color", "width", "radius"], + "additionalProperties": false + } + }, + "required": ["fit", "tint", "border"], + "additionalProperties": false + } + }, + "required": [ + "id", + "parentId", + "order", + "visible", + "opacity", + "placement", + "kind", + "assetId", + "style" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "semanticNodeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "visible": { + "type": "boolean" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "placement": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "absolute" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["kind", "x", "y", "width", "height"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "stack" + }, + "grow": { + "type": "number", + "minimum": 0 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "alignSelf": { + "type": "string", + "enum": ["auto", "start", "center", "end", "stretch"] + }, + "margin": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "grow", + "width", + "height", + "alignSelf", + "margin" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "grid" + }, + "column": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "row": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "columnSpan": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "rowSpan": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "alignSelf": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + }, + "justifySelf": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + }, + "margin": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "column", + "row", + "columnSpan", + "rowSpan", + "width", + "height", + "alignSelf", + "justifySelf", + "margin" + ], + "additionalProperties": false + } + ] + }, + "kind": { + "type": "string", + "const": "shape" + }, + "geometry": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "rectangle" + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "radius": { + "type": "number", + "minimum": 0 + } + }, + "required": ["kind", "width", "height", "radius"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "ellipse" + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["kind", "width", "height"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "line" + }, + "endX": { + "type": "number" + }, + "endY": { + "type": "number" + } + }, + "required": ["kind", "endX", "endY"], + "additionalProperties": false + } + ] + }, + "style": { + "type": "object", + "properties": { + "fill": { + "type": "object", + "properties": { + "red": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["red", "green", "blue", "alpha"], + "additionalProperties": false + }, + "stroke": { + "type": "object", + "properties": { + "red": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["red", "green", "blue", "alpha"], + "additionalProperties": false + }, + "strokeWidth": { + "type": "number", + "minimum": 0 + } + }, + "required": ["fill", "stroke", "strokeWidth"], + "additionalProperties": false + } + }, + "required": [ + "id", + "parentId", + "order", + "visible", + "opacity", + "placement", + "kind", + "geometry", + "style" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "semanticNodeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "visible": { + "type": "boolean" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "placement": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "absolute" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["kind", "x", "y", "width", "height"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "stack" + }, + "grow": { + "type": "number", + "minimum": 0 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "alignSelf": { + "type": "string", + "enum": ["auto", "start", "center", "end", "stretch"] + }, + "margin": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "grow", + "width", + "height", + "alignSelf", + "margin" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "grid" + }, + "column": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "row": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "columnSpan": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "rowSpan": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "alignSelf": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + }, + "justifySelf": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + }, + "margin": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "column", + "row", + "columnSpan", + "rowSpan", + "width", + "height", + "alignSelf", + "justifySelf", + "margin" + ], + "additionalProperties": false + } + ] + }, + "kind": { + "type": "string", + "const": "video" + }, + "assetId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "loop": { + "type": "boolean" + }, + "style": { + "type": "object", + "properties": { + "fit": { + "type": "string", + "enum": ["contain", "cover", "stretch"] + }, + "tint": { + "type": "object", + "properties": { + "red": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["red", "green", "blue", "alpha"], + "additionalProperties": false + }, + "border": { + "type": "object", + "properties": { + "color": { + "type": "object", + "properties": { + "red": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["red", "green", "blue", "alpha"], + "additionalProperties": false + }, + "width": { + "type": "number", + "minimum": 0 + }, + "radius": { + "type": "number", + "minimum": 0 + } + }, + "required": ["color", "width", "radius"], + "additionalProperties": false + } + }, + "required": ["fit", "tint", "border"], + "additionalProperties": false + } + }, + "required": [ + "id", + "parentId", + "order", + "visible", + "opacity", + "placement", + "kind", + "assetId", + "loop", + "style" + ], + "additionalProperties": false + } + ] + } + }, + "baseSemanticTree": { + "type": "object", + "properties": { + "rootNodeIds": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "nodes": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "role": { + "type": "string", + "const": "heading" + }, + "level": { + "anyOf": [ + { + "type": "number", + "const": 1 + }, + { + "type": "number", + "const": 2 + }, + { + "type": "number", + "const": 3 + }, + { + "type": "number", + "const": 4 + }, + { + "type": "number", + "const": 5 + }, + { + "type": "number", + "const": 6 + } + ] + }, + "text": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["id", "parentId", "order", "role", "level", "text"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "role": { + "type": "string", + "const": "paragraph" + }, + "text": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["id", "parentId", "order", "role", "text"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "role": { + "type": "string", + "const": "image" + }, + "alt": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["id", "parentId", "order", "role", "alt"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "role": { + "type": "string", + "const": "button" + }, + "interactionId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "text": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["id", "parentId", "order", "role", "interactionId", "text"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "role": { + "type": "string", + "const": "list" + }, + "ordered": { + "type": "boolean" + } + }, + "required": ["id", "parentId", "order", "role", "ordered"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "role": { + "type": "string", + "const": "listItem" + }, + "text": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["id", "parentId", "order", "role", "text"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "role": { + "type": "string", + "const": "table" + }, + "label": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["id", "parentId", "order", "role"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "role": { + "type": "string", + "const": "row" + } + }, + "required": ["id", "parentId", "order", "role"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "role": { + "type": "string", + "const": "cell" + }, + "text": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["id", "parentId", "order", "role", "text"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "role": { + "type": "string", + "const": "columnHeader" + }, + "text": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["id", "parentId", "order", "role", "text"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "role": { + "type": "string", + "const": "rowHeader" + }, + "text": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["id", "parentId", "order", "role", "text"], + "additionalProperties": false + } + ] + } + } + }, + "required": ["rootNodeIds", "nodes"], + "additionalProperties": false + }, + "interactions": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "kind": { + "type": "string", + "const": "click" + }, + "event": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "hitPriority": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + } + }, + "required": ["id", "kind", "event", "hitPriority"], + "additionalProperties": false + } + }, + "initialStateId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "states": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "contentOverrides": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "properties": { + "visible": { + "type": "boolean" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "placement": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "absolute" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["kind", "x", "y", "width", "height"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "stack" + }, + "grow": { + "type": "number", + "minimum": 0 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "alignSelf": { + "type": "string", + "enum": ["auto", "start", "center", "end", "stretch"] + }, + "margin": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "grow", + "width", + "height", + "alignSelf", + "margin" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "grid" + }, + "column": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "row": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "columnSpan": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "rowSpan": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "alignSelf": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + }, + "justifySelf": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + }, + "margin": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "column", + "row", + "columnSpan", + "rowSpan", + "width", + "height", + "alignSelf", + "justifySelf", + "margin" + ], + "additionalProperties": false + } + ] + }, + "kind": { + "type": "string", + "const": "frame" + }, + "layout": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "absolute" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "stack" + }, + "direction": { + "type": "string", + "enum": ["horizontal", "vertical"] + }, + "gap": { + "type": "number", + "minimum": 0 + }, + "padding": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + }, + "alignItems": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + }, + "justifyContent": { + "type": "string", + "enum": ["start", "center", "end", "spaceBetween"] + } + }, + "required": [ + "kind", + "direction", + "gap", + "padding", + "alignItems", + "justifyContent" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "grid" + }, + "columns": { + "minItems": 1, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "fixed" + }, + "size": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["kind", "size"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "fraction" + }, + "fraction": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["kind", "fraction"], + "additionalProperties": false + } + ] + } + }, + "rows": { + "minItems": 1, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "fixed" + }, + "size": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["kind", "size"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "fraction" + }, + "fraction": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["kind", "fraction"], + "additionalProperties": false + } + ] + } + }, + "columnGap": { + "type": "number", + "minimum": 0 + }, + "rowGap": { + "type": "number", + "minimum": 0 + }, + "padding": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "columns", + "rows", + "columnGap", + "rowGap", + "padding" + ], + "additionalProperties": false + } + ] + }, + "backgroundColor": { + "type": "object", + "properties": { + "red": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["red", "green", "blue", "alpha"], + "additionalProperties": false + }, + "border": { + "type": "object", + "properties": { + "color": { + "type": "object", + "properties": { + "red": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["red", "green", "blue", "alpha"], + "additionalProperties": false + }, + "width": { + "type": "number", + "minimum": 0 + }, + "radius": { + "type": "number", + "minimum": 0 + } + }, + "required": ["color", "width", "radius"], + "additionalProperties": false + }, + "clip": { + "type": "boolean" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "visible": { + "type": "boolean" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "placement": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "absolute" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["kind", "x", "y", "width", "height"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "stack" + }, + "grow": { + "type": "number", + "minimum": 0 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "alignSelf": { + "type": "string", + "enum": ["auto", "start", "center", "end", "stretch"] + }, + "margin": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "grow", + "width", + "height", + "alignSelf", + "margin" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "grid" + }, + "column": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "row": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "columnSpan": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "rowSpan": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "alignSelf": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + }, + "justifySelf": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + }, + "margin": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "column", + "row", + "columnSpan", + "rowSpan", + "width", + "height", + "alignSelf", + "justifySelf", + "margin" + ], + "additionalProperties": false + } + ] + }, + "kind": { + "type": "string", + "const": "text" + }, + "value": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "literal" + }, + "value": { + "type": "string" + } + }, + "required": ["kind", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "variableString" + }, + "variableId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "expectedType": { + "type": "string", + "const": "string" + }, + "format": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "string" + }, + "allowedCodePointRanges": { + "minItems": 1, + "type": "array", + "items": { + "type": "array", + "prefixItems": [ + { + "type": "integer", + "minimum": 0, + "maximum": 1114111 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 1114111 + } + ], + "items": false, + "minItems": 2, + "maxItems": 2 + } + } + }, + "required": ["kind", "allowedCodePointRanges"], + "additionalProperties": false + } + }, + "required": ["kind", "variableId", "expectedType", "format"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "variableBoolean" + }, + "variableId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "expectedType": { + "type": "string", + "const": "boolean" + }, + "format": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "boolean" + }, + "trueLabel": { + "type": "string" + }, + "falseLabel": { + "type": "string" + } + }, + "required": ["kind", "trueLabel", "falseLabel"], + "additionalProperties": false + } + }, + "required": ["kind", "variableId", "expectedType", "format"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "variableNumber" + }, + "variableId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "expectedType": { + "type": "string", + "const": "number" + }, + "format": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "number" + }, + "fractionDigits": { + "anyOf": [ + { + "type": "number", + "const": 0 + }, + { + "type": "number", + "const": 1 + }, + { + "type": "number", + "const": 2 + }, + { + "type": "number", + "const": 3 + } + ] + } + }, + "required": ["kind", "fractionDigits"], + "additionalProperties": false + } + }, + "required": ["kind", "variableId", "expectedType", "format"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "stepTimerRemaining" + }, + "groupId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "stepId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "cueId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "durationMilliseconds": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "whenStepInactive": { + "type": "string", + "enum": ["empty", "zero"] + }, + "format": { + "type": "string", + "enum": ["mm:ss", "hh:mm:ss"] + } + }, + "required": [ + "kind", + "groupId", + "stepId", + "cueId", + "durationMilliseconds", + "whenStepInactive", + "format" + ], + "additionalProperties": false + } + ] + }, + "style": { + "type": "object", + "properties": { + "fontAssetId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "fallbackFontAssetIds": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "fontSize": { + "type": "number", + "exclusiveMinimum": 0 + }, + "lineHeight": { + "type": "number", + "exclusiveMinimum": 0 + }, + "color": { + "type": "object", + "properties": { + "red": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["red", "green", "blue", "alpha"], + "additionalProperties": false + }, + "weight": { + "type": "string", + "enum": ["regular", "bold"] + }, + "align": { + "type": "string", + "enum": ["start", "center", "end"] + }, + "overflow": { + "type": "string", + "enum": ["clip", "ellipsis"] + } + }, + "required": [ + "fontAssetId", + "fallbackFontAssetIds", + "fontSize", + "lineHeight", + "color", + "weight", + "align", + "overflow" + ], + "additionalProperties": false + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "visible": { + "type": "boolean" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "placement": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "absolute" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["kind", "x", "y", "width", "height"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "stack" + }, + "grow": { + "type": "number", + "minimum": 0 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "alignSelf": { + "type": "string", + "enum": ["auto", "start", "center", "end", "stretch"] + }, + "margin": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "grow", + "width", + "height", + "alignSelf", + "margin" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "grid" + }, + "column": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "row": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "columnSpan": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "rowSpan": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "alignSelf": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + }, + "justifySelf": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + }, + "margin": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "column", + "row", + "columnSpan", + "rowSpan", + "width", + "height", + "alignSelf", + "justifySelf", + "margin" + ], + "additionalProperties": false + } + ] + }, + "kind": { + "type": "string", + "const": "image" + }, + "assetId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "style": { + "type": "object", + "properties": { + "fit": { + "type": "string", + "enum": ["contain", "cover", "stretch"] + }, + "tint": { + "type": "object", + "properties": { + "red": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["red", "green", "blue", "alpha"], + "additionalProperties": false + }, + "border": { + "type": "object", + "properties": { + "color": { + "type": "object", + "properties": { + "red": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["red", "green", "blue", "alpha"], + "additionalProperties": false + }, + "width": { + "type": "number", + "minimum": 0 + }, + "radius": { + "type": "number", + "minimum": 0 + } + }, + "required": ["color", "width", "radius"], + "additionalProperties": false + } + }, + "required": ["fit", "tint", "border"], + "additionalProperties": false + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "visible": { + "type": "boolean" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "placement": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "absolute" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["kind", "x", "y", "width", "height"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "stack" + }, + "grow": { + "type": "number", + "minimum": 0 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "alignSelf": { + "type": "string", + "enum": ["auto", "start", "center", "end", "stretch"] + }, + "margin": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "grow", + "width", + "height", + "alignSelf", + "margin" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "grid" + }, + "column": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "row": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "columnSpan": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "rowSpan": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "alignSelf": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + }, + "justifySelf": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + }, + "margin": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "column", + "row", + "columnSpan", + "rowSpan", + "width", + "height", + "alignSelf", + "justifySelf", + "margin" + ], + "additionalProperties": false + } + ] + }, + "kind": { + "type": "string", + "const": "shape" + }, + "geometry": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "rectangle" + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "radius": { + "type": "number", + "minimum": 0 + } + }, + "required": ["kind", "width", "height", "radius"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "ellipse" + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["kind", "width", "height"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "line" + }, + "endX": { + "type": "number" + }, + "endY": { + "type": "number" + } + }, + "required": ["kind", "endX", "endY"], + "additionalProperties": false + } + ] + }, + "style": { + "type": "object", + "properties": { + "fill": { + "type": "object", + "properties": { + "red": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["red", "green", "blue", "alpha"], + "additionalProperties": false + }, + "stroke": { + "type": "object", + "properties": { + "red": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["red", "green", "blue", "alpha"], + "additionalProperties": false + }, + "strokeWidth": { + "type": "number", + "minimum": 0 + } + }, + "required": ["fill", "stroke", "strokeWidth"], + "additionalProperties": false + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "visible": { + "type": "boolean" + }, + "opacity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "placement": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "absolute" + }, + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["kind", "x", "y", "width", "height"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "stack" + }, + "grow": { + "type": "number", + "minimum": 0 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "alignSelf": { + "type": "string", + "enum": ["auto", "start", "center", "end", "stretch"] + }, + "margin": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "grow", + "width", + "height", + "alignSelf", + "margin" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "grid" + }, + "column": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "row": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "columnSpan": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "rowSpan": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + }, + "alignSelf": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + }, + "justifySelf": { + "type": "string", + "enum": ["start", "center", "end", "stretch"] + }, + "margin": { + "type": "object", + "properties": { + "top": { + "type": "number", + "minimum": 0 + }, + "right": { + "type": "number", + "minimum": 0 + }, + "bottom": { + "type": "number", + "minimum": 0 + }, + "left": { + "type": "number", + "minimum": 0 + } + }, + "required": ["top", "right", "bottom", "left"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "column", + "row", + "columnSpan", + "rowSpan", + "width", + "height", + "alignSelf", + "justifySelf", + "margin" + ], + "additionalProperties": false + } + ] + }, + "kind": { + "type": "string", + "const": "video" + }, + "style": { + "type": "object", + "properties": { + "fit": { + "type": "string", + "enum": ["contain", "cover", "stretch"] + }, + "tint": { + "type": "object", + "properties": { + "red": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["red", "green", "blue", "alpha"], + "additionalProperties": false + }, + "border": { + "type": "object", + "properties": { + "color": { + "type": "object", + "properties": { + "red": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["red", "green", "blue", "alpha"], + "additionalProperties": false + }, + "width": { + "type": "number", + "minimum": 0 + }, + "radius": { + "type": "number", + "minimum": 0 + } + }, + "required": ["color", "width", "radius"], + "additionalProperties": false + } + }, + "required": ["fit", "tint", "border"], + "additionalProperties": false + } + }, + "required": ["kind"], + "additionalProperties": false + } + ] + } + }, + "semanticOverrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "nodes": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "included": { + "type": "boolean" + }, + "text": { + "type": "string", + "minLength": 1 + }, + "language": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "alt": { + "type": "string", + "minLength": 1 + }, + "label": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + } + }, + "required": ["nodes"], + "additionalProperties": false + } + }, + "enabledInteractionIds": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + } + }, + "required": [ + "id", + "contentOverrides", + "semanticOverrides", + "enabledInteractionIds" + ], + "additionalProperties": false + } + }, + "renderIntent": { + "type": "object", + "properties": { + "updateModel": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "static" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "finite-state" + }, + "stateIds": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + } + }, + "required": ["kind", "stateIds"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "continuous-native-text" + }, + "maximumUpdateRateHz": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["kind", "maximumUpdateRateHz"], + "additionalProperties": false + } + ] + }, + "interaction": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "none" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "regions" + }, + "events": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + } + }, + "required": ["kind", "events"], + "additionalProperties": false + } + ] + }, + "internalAnimation": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "none" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "precomputed-video" + }, + "durationMilliseconds": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": ["kind", "durationMilliseconds"], + "additionalProperties": false + } + ] + }, + "rendererPreference": { + "type": "string", + "enum": ["auto", "baked-web", "native-ui", "video"] + }, + "fallbackPolicy": { + "type": "string", + "enum": ["reject", "degrade"] + } + }, + "required": [ + "updateModel", + "interaction", + "internalAnimation", + "rendererPreference", + "fallbackPolicy" + ], + "additionalProperties": false + } + }, + "required": [ + "id", + "hostNodeId", + "physicalSizeMeters", + "logicalSize", + "fit", + "rootFrameId", + "contentNodes", + "baseSemanticTree", + "interactions", + "initialStateId", + "states", + "renderIntent" + ], + "additionalProperties": false + } + } + }, + "required": ["nodes", "surfaces"], + "additionalProperties": false + }, + "flow": { + "type": "object", + "properties": { + "initialGroupId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "groups": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "initialStepId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "steps": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "cues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "trigger": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "logicalInput" + }, + "action": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "actor": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "presenter" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "system" + }, + "source": { + "type": "string", + "enum": [ + "tracking", + "timer", + "timeline", + "media", + "runtime" + ] + } + }, + "required": ["kind"], + "additionalProperties": false + } + ] + } + }, + "required": ["kind", "action", "actor"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "semanticEvent" + }, + "event": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "actor": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "presenter" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "system" + }, + "source": { + "type": "string", + "enum": [ + "tracking", + "timer", + "timeline", + "media", + "runtime" + ] + } + }, + "required": ["kind"], + "additionalProperties": false + } + ] + } + }, + "required": ["kind", "event", "actor"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "surfaceInteraction" + }, + "actor": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "presenter" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "system" + }, + "source": { + "type": "string", + "enum": [ + "tracking", + "timer", + "timeline", + "media", + "runtime" + ] + } + }, + "required": ["kind"], + "additionalProperties": false + } + ] + }, + "surfaceId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "interactionId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "actor", "surfaceId", "interactionId"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "zoneEdge" + }, + "actor": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "system" + }, + "source": { + "type": "string", + "const": "tracking" + } + }, + "required": ["kind", "source"], + "additionalProperties": false + }, + "subject": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "participant" + }, + "owner": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "presenter" + } + }, + "required": ["kind"], + "additionalProperties": false + } + }, + "required": ["kind", "owner"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "anchor" + }, + "owner": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "presenter" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + "target": { + "type": "string", + "enum": ["head", "leftHand", "rightHand", "body"] + } + }, + "required": ["kind", "owner", "target"], + "additionalProperties": false + } + ] + }, + "zoneId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "edge": { + "type": "string", + "enum": ["enter", "exit"] + }, + "dwellMilliseconds": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "hysteresisMeters": { + "type": "number", + "minimum": 0 + } + }, + "required": ["kind", "actor", "subject", "zoneId", "edge"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "motion" + }, + "actor": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "system" + }, + "source": { + "type": "string", + "const": "tracking" + } + }, + "required": ["kind", "source"], + "additionalProperties": false + }, + "subject": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "participant" + }, + "owner": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "presenter" + } + }, + "required": ["kind"], + "additionalProperties": false + } + }, + "required": ["kind", "owner"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "anchor" + }, + "owner": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "presenter" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + "target": { + "type": "string", + "enum": ["head", "leftHand", "rightHand", "body"] + } + }, + "required": ["kind", "owner", "target"], + "additionalProperties": false + } + ] + }, + "minimumDistanceMeters": { + "type": "number", + "exclusiveMinimum": 0 + }, + "windowMilliseconds": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "kind", + "actor", + "subject", + "minimumDistanceMeters", + "windowMilliseconds" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "timer" + }, + "afterMilliseconds": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": ["kind", "afterMilliseconds"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "timelineCompleted" + }, + "timelineId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "timelineId"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "mediaCompleted" + }, + "surfaceId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "surfaceId"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "modelClipCompleted" + }, + "nodeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "clipId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "nodeId", "clipId"], + "additionalProperties": false + } + ] + }, + "fixedPayload": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + "guard": { + "$ref": "#/$defs/__schema0" + }, + "firePolicy": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "oncePerStepEntry" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "repeatable" + }, + "cooldownMilliseconds": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["kind", "cooldownMilliseconds"], + "additionalProperties": false + } + ] + }, + "actions": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "surface.setState" + }, + "surfaceId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "stateId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "transition": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "cut" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "crossfade" + }, + "durationMilliseconds": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "easing": { + "type": "string", + "enum": [ + "linear", + "cubicIn", + "cubicOut", + "cubicInOut" + ] + }, + "completion": { + "type": "string", + "const": "blocking" + } + }, + "required": [ + "kind", + "durationMilliseconds", + "easing", + "completion" + ], + "additionalProperties": false + } + ] + } + }, + "required": ["kind", "surfaceId", "stateId"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "node.patch" + }, + "nodeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "patch": { + "type": "object", + "properties": { + "active": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "literal" + }, + "value": { + "type": "boolean" + } + }, + "required": ["kind", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "eventPayload" + }, + "field": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "field"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "variable" + }, + "variableId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "variableId"], + "additionalProperties": false + } + ] + }, + "visible": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "literal" + }, + "value": { + "type": "boolean" + } + }, + "required": ["kind", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "eventPayload" + }, + "field": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "field"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "variable" + }, + "variableId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "variableId"], + "additionalProperties": false + } + ] + }, + "opacity": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "literal" + }, + "value": { + "type": "number" + } + }, + "required": ["kind", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "eventPayload" + }, + "field": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "field"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "variable" + }, + "variableId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "variableId"], + "additionalProperties": false + } + ] + }, + "transform": { + "type": "object", + "properties": { + "position": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ], + "items": false, + "minItems": 3, + "maxItems": 3 + }, + "rotation": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ], + "items": false, + "minItems": 4, + "maxItems": 4 + }, + "scale": { + "type": "array", + "prefixItems": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "number", + "exclusiveMinimum": 0 + } + ], + "items": false, + "minItems": 3, + "maxItems": 3 + } + }, + "required": ["position", "rotation", "scale"], + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "required": ["kind", "nodeId", "patch"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "timeline.play" + }, + "timelineId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "completion": { + "type": "string", + "enum": ["blocking", "nonBlocking"] + }, + "conflict": { + "type": "string", + "const": "reject" + } + }, + "required": ["kind", "timelineId", "completion", "conflict"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "timeline.stop" + }, + "timelineId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "timelineId"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "variable.set" + }, + "variableId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "value": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "literal" + }, + "value": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + "required": ["kind", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "eventPayload" + }, + "field": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "field"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "variable" + }, + "variableId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "variableId"], + "additionalProperties": false + } + ] + } + }, + "required": ["kind", "variableId", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "media.play" + }, + "surfaceId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "surfaceId"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "media.pause" + }, + "surfaceId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "surfaceId"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "media.seek" + }, + "surfaceId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "positionSeconds": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "literal" + }, + "value": { + "type": "number" + } + }, + "required": ["kind", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "eventPayload" + }, + "field": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "field"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "variable" + }, + "variableId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "variableId"], + "additionalProperties": false + } + ] + } + }, + "required": ["kind", "surfaceId", "positionSeconds"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "modelClip.play" + }, + "nodeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "clipId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "speed": { + "type": "number", + "exclusiveMinimum": 0 + }, + "loop": { + "type": "boolean" + }, + "completion": { + "type": "string", + "enum": ["blocking", "nonBlocking"] + }, + "transition": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "immediate" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "crossfade" + }, + "durationMilliseconds": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "easing": { + "type": "string", + "enum": [ + "linear", + "cubicIn", + "cubicOut", + "cubicInOut" + ] + } + }, + "required": ["kind", "durationMilliseconds", "easing"], + "additionalProperties": false + } + ] + }, + "conflict": { + "type": "string", + "const": "reject" + } + }, + "required": [ + "kind", + "nodeId", + "clipId", + "speed", + "loop", + "completion", + "transition", + "conflict" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "modelClip.pause" + }, + "nodeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "nodeId"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "modelClip.resume" + }, + "nodeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "nodeId"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "modelClip.stop" + }, + "nodeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "nodeId"], + "additionalProperties": false + } + ] + } + }, + "next": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "stay" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "step" + }, + "stepId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "stepId"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "group" + }, + "groupId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "groupId"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "end" + } + }, + "required": ["kind"], + "additionalProperties": false + } + ] + } + }, + "required": [ + "id", + "priority", + "order", + "trigger", + "firePolicy", + "actions", + "next" + ], + "additionalProperties": false + } + } + }, + "required": ["id", "cues"], + "additionalProperties": false + } + } + }, + "required": ["id", "initialStepId", "steps"], + "additionalProperties": false + } + }, + "variables": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "owner": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "presentation" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "group" + }, + "groupId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "groupId"], + "additionalProperties": false + } + ] + }, + "type": { + "type": "string", + "enum": ["null", "boolean", "number", "string"] + }, + "initialValue": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + "required": ["id", "owner", "type", "initialValue"], + "additionalProperties": false + } + }, + "timelines": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "owner": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "presentation" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "group" + }, + "groupId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "groupId"], + "additionalProperties": false + } + ] + }, + "durationMilliseconds": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "tracks": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "target": { + "type": "object", + "properties": { + "nodeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "property": { + "type": "string", + "enum": [ + "opacity", + "transform.position", + "transform.rotation", + "transform.scale" + ] + } + }, + "required": ["nodeId", "property"], + "additionalProperties": false + }, + "keyframes": { + "minItems": 2, + "type": "array", + "items": { + "type": "object", + "properties": { + "timeMilliseconds": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "value": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ], + "items": false, + "minItems": 3, + "maxItems": 3 + }, + { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ], + "items": false, + "minItems": 4, + "maxItems": 4 + } + ] + }, + "easingToNext": { + "type": "string", + "enum": ["linear", "cubicIn", "cubicOut", "cubicInOut"] + } + }, + "required": ["timeMilliseconds", "value"], + "additionalProperties": false + } + } + }, + "required": ["target", "keyframes"], + "additionalProperties": false + } + } + }, + "required": ["id", "owner", "durationMilliseconds", "tracks"], + "additionalProperties": false + } + } + }, + "required": ["initialGroupId", "groups", "variables", "timelines"], + "additionalProperties": false + } + }, + "required": ["schemaVersion", "presentationId", "metadata", "stage", "scene", "flow"], + "additionalProperties": false, + "$defs": { + "__schema0": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "all" + }, + "guards": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/__schema0" + } + } + }, + "required": ["kind", "guards"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "any" + }, + "guards": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/$defs/__schema0" + } + } + }, + "required": ["kind", "guards"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "not" + }, + "guard": { + "$ref": "#/$defs/__schema0" + } + }, + "required": ["kind", "guard"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "compare" + }, + "left": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "variable" + }, + "variableId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "variableId"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "eventPayload" + }, + "field": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "field"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "surfaceState" + }, + "surfaceId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "required": ["kind", "surfaceId"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "nodeField" + }, + "nodeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "field": { + "type": "string", + "enum": ["active", "visible", "opacity"] + } + }, + "required": ["kind", "nodeId", "field"], + "additionalProperties": false + } + ] + }, + "operator": { + "type": "string", + "enum": ["eq", "neq", "gt", "gte", "lt", "lte"] + }, + "right": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + "required": ["kind", "left", "operator", "right"], + "additionalProperties": false + } + ] + } + }, + "$id": "https://contracts.unframe.dev/presentation/presentation-definition.v2.schema.json" +} diff --git a/packages/contracts/presentation/v2/published-presentation.schema.json b/packages/contracts/presentation/v2/published-presentation.schema.json new file mode 100644 index 0000000..f4cbe17 --- /dev/null +++ b/packages/contracts/presentation/v2/published-presentation.schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "schemaVersion": { + "type": "number", + "const": 2 + }, + "presentationId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "publicationEpoch": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "publicationManifestHash": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "sourceDraftRevision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "buildId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "definitionHash": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "renderBundleHash": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "assetSetHash": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "contractVersions": { + "type": "object", + "properties": { + "definition": { + "type": "number", + "const": 2 + }, + "renderBundle": { + "type": "number", + "const": 2 + }, + "assetSet": { + "type": "number", + "const": 2 + }, + "delivery": { + "type": "number", + "const": 2 + }, + "runtime": { + "type": "number", + "const": 2 + }, + "progression": { + "type": "number", + "const": 1 + }, + "projection": { + "type": "number", + "const": 1 + } + }, + "required": [ + "definition", + "renderBundle", + "assetSet", + "delivery", + "runtime", + "progression", + "projection" + ], + "additionalProperties": false + } + }, + "required": [ + "schemaVersion", + "presentationId", + "publicationEpoch", + "publicationManifestHash", + "sourceDraftRevision", + "buildId", + "definitionHash", + "renderBundleHash", + "assetSetHash", + "contractVersions" + ], + "additionalProperties": false, + "$id": "https://contracts.unframe.dev/presentation/published-presentation.v2.schema.json" +} diff --git a/packages/contracts/presentation/v2/render-bundle.schema.json b/packages/contracts/presentation/v2/render-bundle.schema.json new file mode 100644 index 0000000..36e51de --- /dev/null +++ b/packages/contracts/presentation/v2/render-bundle.schema.json @@ -0,0 +1,1779 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "schemaVersion": { + "type": "number", + "const": 2 + }, + "bundleId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "sourceHash": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "definitionHash": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "compiler": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "version": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "environmentHash": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + } + }, + "required": ["name", "version", "environmentHash"], + "additionalProperties": false + }, + "buildContext": { + "type": "object", + "properties": { + "locale": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "timezone": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "colorScheme": { + "type": "string", + "enum": ["light", "dark"] + }, + "themeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "themeHash": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "textureBuildPolicy": { + "type": "object", + "properties": { + "policyVersion": { + "type": "number", + "const": 1 + }, + "resolutionPolicyVersion": { + "type": "number", + "const": 1 + }, + "policyHash": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "longEdgePixels": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxStatesPerRenderSurface": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxRenderSurfacesPerSemanticSurface": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxRenderSurfacesPerBundle": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxTextureBindings": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxTextureWidth": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxTextureHeight": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxTexturePixels": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxRenderedPixels": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxSurfaceCaptureBytes": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxBuildOutputBytes": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxBuildAccountedPeakBytes": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "rendererConcurrency": { + "type": "number", + "const": 1 + } + }, + "required": [ + "policyVersion", + "resolutionPolicyVersion", + "policyHash", + "longEdgePixels", + "maxStatesPerRenderSurface", + "maxRenderSurfacesPerSemanticSurface", + "maxRenderSurfacesPerBundle", + "maxTextureBindings", + "maxTextureWidth", + "maxTextureHeight", + "maxTexturePixels", + "maxRenderedPixels", + "maxSurfaceCaptureBytes", + "maxBuildOutputBytes", + "maxBuildAccountedPeakBytes", + "rendererConcurrency" + ], + "additionalProperties": false + } + }, + "required": [ + "locale", + "timezone", + "colorScheme", + "themeId", + "themeHash", + "textureBuildPolicy" + ], + "additionalProperties": false + }, + "surfaces": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "semanticSurfaceId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "logicalSize": { + "type": "array", + "prefixItems": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "number", + "exclusiveMinimum": 0 + } + ], + "items": false, + "minItems": 2, + "maxItems": 2 + }, + "physicalSizeMeters": { + "type": "array", + "prefixItems": [ + { + "type": "number", + "exclusiveMinimum": 0 + }, + { + "type": "number", + "exclusiveMinimum": 0 + } + ], + "items": false, + "minItems": 2, + "maxItems": 2 + }, + "renderSurfaceIds": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "renderSurfaces": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "semanticSurfaceId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "logicalBounds": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0 + } + }, + "required": ["x", "y", "width", "height"], + "additionalProperties": false + }, + "layer": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "artifacts": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "kind": { + "type": "string", + "const": "baked-web" + }, + "contractVersion": { + "type": "number", + "const": 1 + }, + "requiredFeatures": { + "type": "array", + "items": { + "type": "string", + "enum": ["alpha-opaque", "alpha-straight", "png", "srgb"] + } + }, + "states": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "stateId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "texture": { + "type": "object", + "properties": { + "assetId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "mediaType": { + "type": "string", + "const": "image/png" + }, + "pixelSize": { + "type": "array", + "prefixItems": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + ], + "items": false, + "minItems": 2, + "maxItems": 2 + }, + "checksum": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "encodedSizeBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "colorSpace": { + "type": "string", + "const": "srgb" + }, + "alphaMode": { + "type": "string", + "enum": ["opaque", "straight"] + }, + "mipCount": { + "type": "number", + "const": 1 + }, + "gpuBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "assetId", + "mediaType", + "pixelSize", + "checksum", + "encodedSizeBytes", + "colorSpace", + "alphaMode", + "mipCount", + "gpuBytes" + ], + "additionalProperties": false + } + }, + "required": ["stateId", "texture"], + "additionalProperties": false + } + } + }, + "required": ["id", "kind", "contractVersion", "requiredFeatures", "states"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "kind": { + "type": "string", + "const": "native-ui" + }, + "contractVersion": { + "type": "number", + "const": 1 + }, + "requiredFeatures": { + "type": "array", + "items": { + "type": "string", + "enum": ["clip", "ellipsis", "explicitFontFallback"] + } + }, + "rootNodeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "nodes": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "group" + }, + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "bounds": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "width": { + "type": "number", + "minimum": 0 + }, + "height": { + "type": "number", + "minimum": 0 + }, + "coordinateSpace": { + "type": "string", + "const": "renderSurfaceLogical" + } + }, + "required": ["x", "y", "width", "height", "coordinateSpace"], + "additionalProperties": false + }, + "clip": { + "type": "boolean" + }, + "children": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + } + }, + "required": ["kind", "id", "bounds", "clip", "children"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "text" + }, + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "bounds": { + "type": "object", + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "width": { + "type": "number", + "minimum": 0 + }, + "height": { + "type": "number", + "minimum": 0 + }, + "coordinateSpace": { + "type": "string", + "const": "renderSurfaceLogical" + } + }, + "required": ["x", "y", "width", "height", "coordinateSpace"], + "additionalProperties": false + }, + "semanticNodeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "value": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "literal" + }, + "value": { + "type": "string" + } + }, + "required": ["kind", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "variableString" + }, + "variableId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "expectedType": { + "type": "string", + "const": "string" + }, + "format": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "string" + }, + "allowedCodePointRanges": { + "minItems": 1, + "type": "array", + "items": { + "type": "array", + "prefixItems": [ + { + "type": "integer", + "minimum": 0, + "maximum": 1114111 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 1114111 + } + ], + "items": false, + "minItems": 2, + "maxItems": 2 + } + } + }, + "required": ["kind", "allowedCodePointRanges"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "variableId", + "expectedType", + "format" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "variableBoolean" + }, + "variableId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "expectedType": { + "type": "string", + "const": "boolean" + }, + "format": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "boolean" + }, + "trueLabel": { + "type": "string" + }, + "falseLabel": { + "type": "string" + } + }, + "required": ["kind", "trueLabel", "falseLabel"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "variableId", + "expectedType", + "format" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "variableNumber" + }, + "variableId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "expectedType": { + "type": "string", + "const": "number" + }, + "format": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "number" + }, + "fractionDigits": { + "anyOf": [ + { + "type": "number", + "const": 0 + }, + { + "type": "number", + "const": 1 + }, + { + "type": "number", + "const": 2 + }, + { + "type": "number", + "const": 3 + } + ] + } + }, + "required": ["kind", "fractionDigits"], + "additionalProperties": false + } + }, + "required": [ + "kind", + "variableId", + "expectedType", + "format" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "stepTimerRemaining" + }, + "groupId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "stepId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "cueId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "durationMilliseconds": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "whenStepInactive": { + "type": "string", + "enum": ["empty", "zero"] + }, + "format": { + "type": "string", + "enum": ["mm:ss", "hh:mm:ss"] + } + }, + "required": [ + "kind", + "groupId", + "stepId", + "cueId", + "durationMilliseconds", + "whenStepInactive", + "format" + ], + "additionalProperties": false + } + ] + }, + "font": { + "type": "object", + "properties": { + "primary": { + "type": "object", + "properties": { + "assetId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "supportedCodePointRanges": { + "minItems": 1, + "type": "array", + "items": { + "type": "array", + "prefixItems": [ + { + "type": "integer", + "minimum": 0, + "maximum": 1114111 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 1114111 + } + ], + "items": false, + "minItems": 2, + "maxItems": 2 + } + } + }, + "required": ["assetId", "supportedCodePointRanges"], + "additionalProperties": false + }, + "fallbacks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "assetId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "supportedCodePointRanges": { + "minItems": 1, + "type": "array", + "items": { + "type": "array", + "prefixItems": [ + { + "type": "integer", + "minimum": 0, + "maximum": 1114111 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 1114111 + } + ], + "items": false, + "minItems": 2, + "maxItems": 2 + } + } + }, + "required": ["assetId", "supportedCodePointRanges"], + "additionalProperties": false + } + } + }, + "required": ["primary", "fallbacks"], + "additionalProperties": false + }, + "color": { + "type": "object", + "properties": { + "red": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "green": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "blue": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "alpha": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "required": ["red", "green", "blue", "alpha"], + "additionalProperties": false + }, + "fontSize": { + "type": "number", + "exclusiveMinimum": 0 + }, + "lineHeight": { + "type": "number", + "exclusiveMinimum": 0 + }, + "align": { + "type": "string", + "enum": ["start", "center", "end"] + }, + "overflow": { + "type": "string", + "enum": ["clip", "ellipsis"] + }, + "maxCodePoints": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "kind", + "id", + "bounds", + "semanticNodeId", + "value", + "font", + "color", + "fontSize", + "lineHeight", + "align", + "overflow", + "maxCodePoints" + ], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "id", + "kind", + "contractVersion", + "requiredFeatures", + "rootNodeId", + "nodes" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "kind": { + "type": "string", + "const": "video" + }, + "contractVersion": { + "type": "number", + "const": 1 + }, + "requiredFeatures": { + "type": "array", + "items": { + "type": "string", + "enum": ["alpha", "audio", "h264", "vp9", "av1"] + } + }, + "assetId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "checksum": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "encodedSizeBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "mediaType": { + "type": "string", + "const": "video/mp4" + }, + "codec": { + "type": "string", + "enum": ["h264", "vp9", "av1"] + }, + "durationMilliseconds": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "loop": { + "type": "boolean" + }, + "alpha": { + "type": "boolean" + }, + "audio": { + "type": "boolean" + }, + "pixelSize": { + "type": "array", + "prefixItems": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + ], + "items": false, + "minItems": 2, + "maxItems": 2 + } + }, + "required": [ + "id", + "kind", + "contractVersion", + "requiredFeatures", + "assetId", + "checksum", + "encodedSizeBytes", + "mediaType", + "codec", + "durationMilliseconds", + "loop", + "alpha", + "audio", + "pixelSize" + ], + "additionalProperties": false + } + ] + } + }, + "stateBindings": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "empty" + } + }, + "required": ["kind"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "artifacts" + }, + "artifactIds": { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + } + }, + "required": ["kind", "artifactIds"], + "additionalProperties": false + } + ] + } + } + }, + "required": [ + "id", + "semanticSurfaceId", + "logicalBounds", + "layer", + "artifacts", + "stateBindings" + ], + "additionalProperties": false + } + }, + "semanticsByState": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "rootNodeIds": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + } + }, + "nodes": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "role": { + "type": "string", + "const": "heading" + }, + "level": { + "anyOf": [ + { + "type": "number", + "const": 1 + }, + { + "type": "number", + "const": 2 + }, + { + "type": "number", + "const": 3 + }, + { + "type": "number", + "const": 4 + }, + { + "type": "number", + "const": 5 + }, + { + "type": "number", + "const": 6 + } + ] + }, + "text": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["id", "parentId", "order", "role", "level", "text"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "role": { + "type": "string", + "const": "paragraph" + }, + "text": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["id", "parentId", "order", "role", "text"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "role": { + "type": "string", + "const": "image" + }, + "alt": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["id", "parentId", "order", "role", "alt"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "role": { + "type": "string", + "const": "button" + }, + "interactionId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "stateEnabled": { + "type": "boolean" + }, + "text": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "id", + "parentId", + "order", + "role", + "interactionId", + "stateEnabled", + "text" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "role": { + "type": "string", + "const": "list" + }, + "ordered": { + "type": "boolean" + } + }, + "required": ["id", "parentId", "order", "role", "ordered"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "role": { + "type": "string", + "const": "listItem" + }, + "text": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["id", "parentId", "order", "role", "text"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "role": { + "type": "string", + "const": "table" + }, + "label": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["id", "parentId", "order", "role"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "role": { + "type": "string", + "const": "row" + } + }, + "required": ["id", "parentId", "order", "role"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "role": { + "type": "string", + "const": "cell" + }, + "text": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["id", "parentId", "order", "role", "text"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "role": { + "type": "string", + "const": "columnHeader" + }, + "text": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["id", "parentId", "order", "role", "text"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + { + "type": "null" + } + ] + }, + "order": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "role": { + "type": "string", + "const": "rowHeader" + }, + "text": { + "type": "string", + "minLength": 1 + }, + "language": { + "type": "string", + "minLength": 1 + } + }, + "required": ["id", "parentId", "order", "role", "text"], + "additionalProperties": false + } + ] + } + } + }, + "required": ["rootNodeIds", "nodes"], + "additionalProperties": false + } + }, + "interactionsByState": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "properties": { + "interactionId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "semanticNodeId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "bounds": { + "type": "object", + "properties": { + "x": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "y": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 1 + }, + "height": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 1 + } + }, + "required": ["x", "y", "width", "height"], + "additionalProperties": false + }, + "coordinateSpace": { + "type": "string", + "const": "normalized" + }, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + } + }, + "required": [ + "interactionId", + "semanticNodeId", + "bounds", + "coordinateSpace", + "priority" + ], + "additionalProperties": false + } + } + } + }, + "required": [ + "semanticSurfaceId", + "logicalSize", + "physicalSizeMeters", + "renderSurfaceIds", + "renderSurfaces", + "semanticsByState", + "interactionsByState" + ], + "additionalProperties": false + } + }, + "models": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "assetId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "mediaType": { + "type": "string", + "const": "model/gltf-binary" + }, + "checksum": { + "type": "string", + "pattern": "^sha256:[0-9a-f]{64}$" + }, + "encodedSizeBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "nodeCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "primitiveCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "vertexCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "triangleCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "maxBonesPerSkin": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "maxMorphTargetsPerPrimitive": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "animationClipCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "requiredFeatures": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "alphaBlend", + "alphaMask", + "animation", + "morphTargets", + "pbrMetallicRoughness", + "skin", + "unlit" + ] + } + }, + "rootMotion": { + "type": "string", + "const": "removed" + }, + "materials": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "sourceMaterialIndex": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "shaderModel": { + "type": "string", + "enum": ["pbrMetallicRoughness", "unlit"] + }, + "alphaMode": { + "type": "string", + "enum": ["opaque", "mask", "blend"] + }, + "alphaCutoff": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "doubleSided": { + "type": "boolean" + } + }, + "required": ["sourceMaterialIndex", "shaderModel", "alphaMode", "doubleSided"], + "additionalProperties": false + } + }, + "clips": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$" + }, + "additionalProperties": { + "type": "object", + "properties": { + "sourceAnimationIndex": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "durationMilliseconds": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": ["sourceAnimationIndex", "durationMilliseconds"], + "additionalProperties": false + } + } + }, + "required": [ + "assetId", + "mediaType", + "checksum", + "encodedSizeBytes", + "nodeCount", + "primitiveCount", + "vertexCount", + "triangleCount", + "maxBonesPerSkin", + "maxMorphTargetsPerPrimitive", + "animationClipCount", + "requiredFeatures", + "rootMotion", + "materials", + "clips" + ], + "additionalProperties": false + } + } + }, + "required": [ + "schemaVersion", + "bundleId", + "sourceHash", + "definitionHash", + "compiler", + "buildContext", + "surfaces", + "models" + ], + "additionalProperties": false, + "$id": "https://contracts.unframe.dev/presentation/render-bundle.v2.schema.json" +} diff --git a/packages/contracts/proto/unframe/delivery/v2/delivery.proto b/packages/contracts/proto/unframe/delivery/v2/delivery.proto new file mode 100644 index 0000000..b7bcc9a --- /dev/null +++ b/packages/contracts/proto/unframe/delivery/v2/delivery.proto @@ -0,0 +1,557 @@ +syntax = "proto3"; + +package unframe.delivery.v2; + +import "unframe/presentation/v2/runtime.proto"; + +option go_package = "github.com/unframe-dev/unframe/app/server/realtime/internal/gen/delivery/v2;deliveryv2"; + +enum RendererKind { + RENDERER_KIND_UNSPECIFIED = 0; + RENDERER_KIND_BAKED_WEB = 1; + RENDERER_KIND_NATIVE_UI = 2; + RENDERER_KIND_VIDEO = 3; +} + +enum BakedWebFeature { + BAKED_WEB_FEATURE_UNSPECIFIED = 0; + BAKED_WEB_FEATURE_PNG = 1; + BAKED_WEB_FEATURE_SRGB = 2; + BAKED_WEB_FEATURE_ALPHA_OPAQUE = 3; + BAKED_WEB_FEATURE_ALPHA_STRAIGHT = 4; +} + +enum NativeUiFeature { + NATIVE_UI_FEATURE_UNSPECIFIED = 0; + NATIVE_UI_FEATURE_CLIP = 1; + NATIVE_UI_FEATURE_ELLIPSIS = 2; + NATIVE_UI_FEATURE_EXPLICIT_FONT_FALLBACK = 3; +} + +enum VideoFeature { + VIDEO_FEATURE_UNSPECIFIED = 0; + VIDEO_FEATURE_H264 = 1; + VIDEO_FEATURE_VP9 = 2; + VIDEO_FEATURE_AV1 = 3; + VIDEO_FEATURE_ALPHA = 4; + VIDEO_FEATURE_AUDIO = 5; +} + +enum ModelFeature { + MODEL_FEATURE_UNSPECIFIED = 0; + MODEL_FEATURE_ANIMATION = 1; + MODEL_FEATURE_SKIN = 2; + MODEL_FEATURE_MORPH_TARGETS = 3; + MODEL_FEATURE_ALPHA_BLEND = 4; + MODEL_FEATURE_ALPHA_MASK = 5; + MODEL_FEATURE_PBR_METALLIC_ROUGHNESS = 6; + MODEL_FEATURE_UNLIT = 7; +} + +enum ModelFormat { + MODEL_FORMAT_UNSPECIFIED = 0; + MODEL_FORMAT_GLTF_BINARY = 1; +} + +message TextureCapability { + bool supported = 1; + uint32 contract_version = 2; + repeated BakedWebFeature features = 3; +} + +message NativeUiCapability { + bool supported = 1; + uint32 contract_version = 2; + repeated NativeUiFeature features = 3; +} + +message VideoCapability { + bool supported = 1; + uint32 contract_version = 2; + repeated VideoFeature features = 3; +} + +message ModelCapability { + bool supported = 1; + uint32 contract_version = 2; + repeated ModelFormat formats = 3; + repeated ModelFeature features = 4; +} + +message ContractVersions { + uint32 delivery = 1; + uint32 runtime = 2; + uint32 progression = 3; + uint32 projection = 4; +} + +message RendererCapabilities { + TextureCapability baked_web = 1; + NativeUiCapability native_ui = 2; + VideoCapability video = 3; +} + +message TextureLimits { + string tier_id = 1; + uint64 max_texture_width = 2; + uint64 max_texture_height = 3; + uint64 max_texture_pixels = 4; + uint64 max_texture_bindings = 5; + uint64 max_gpu_bytes = 6; + uint64 max_serial_load_cpu_bytes = 7; + uint64 max_encoded_cache_bytes = 8; + uint64 encoded_cache_reserve_bytes = 9; +} + +message NativeUiLimits { + string tier_id = 1; + uint64 max_nodes_per_artifact = 2; + uint64 max_tree_depth = 3; + uint64 max_text_nodes_per_artifact = 4; + uint64 max_code_points_per_text = 5; + uint64 max_glyphs = 6; + uint64 max_font_assets = 7; +} + +message VideoLimits { + string tier_id = 1; + uint64 max_width = 2; + uint64 max_height = 3; + uint64 max_pixels = 4; + uint64 max_concurrent_decoders = 5; + uint64 max_encoded_bytes = 6; + uint64 max_decoded_frame_bytes = 7; +} + +message ModelLimits { + string tier_id = 1; + uint64 max_assets = 2; + uint64 max_instances = 3; + uint64 max_encoded_bytes = 4; + uint64 max_nodes = 5; + uint64 max_primitives = 6; + uint64 max_vertices = 7; + uint64 max_triangles = 8; + uint64 max_bones_per_skin = 9; + uint64 max_morph_targets_per_primitive = 10; + uint64 max_animation_clips_per_asset = 11; +} + +message CapabilityLimits { + TextureLimits texture = 1; + NativeUiLimits native_ui = 2; + VideoLimits video = 3; + ModelLimits model = 4; +} + +message CapabilityProfile { + uint32 schema_version = 1; + string capability_profile_id = 2; + ContractVersions contract_versions = 3; + RendererCapabilities renderers = 4; + ModelCapability model = 5; + CapabilityLimits limits = 6; + bool local_overlay_supported = 7; +} + +message ProjectionProfileKey { + unframe.presentation.v2.PublicationFence publication = 1; + uint32 projection_contract_version = 2; + unframe.presentation.v2.SessionRole role = 3; + string capability_profile_id = 4; +} + +message LogicalBounds { + double x = 1; + double y = 2; + double width = 3; + double height = 4; +} + +message PixelSize { + uint64 width = 1; + uint64 height = 2; +} + +message EmptyStateBinding {} + +message ArtifactStateBinding { + string artifact_id = 1; +} + +message DeliveredStateBinding { + string state_id = 1; + oneof binding { + EmptyStateBinding empty = 2; + ArtifactStateBinding artifact = 3; + } +} + +enum TextureAlphaMode { + TEXTURE_ALPHA_MODE_UNSPECIFIED = 0; + TEXTURE_ALPHA_MODE_OPAQUE = 1; + TEXTURE_ALPHA_MODE_STRAIGHT = 2; +} + +message TextureArtifact { + string asset_id = 1; + string checksum = 2; + uint64 encoded_size_bytes = 3; + PixelSize pixel_size = 4; + TextureAlphaMode alpha_mode = 5; + uint32 mip_count = 6; + uint64 decoded_gpu_bytes = 7; +} + +message BakedWebStateTexture { + string state_id = 1; + TextureArtifact texture = 2; +} + +message BakedWebArtifact { + string artifact_id = 1; + uint32 contract_version = 2; + repeated BakedWebFeature required_features = 3; + repeated BakedWebStateTexture states = 4; +} + +enum NativeTextAlign { + NATIVE_TEXT_ALIGN_UNSPECIFIED = 0; + NATIVE_TEXT_ALIGN_START = 1; + NATIVE_TEXT_ALIGN_CENTER = 2; + NATIVE_TEXT_ALIGN_END = 3; +} + +enum NativeTextOverflow { + NATIVE_TEXT_OVERFLOW_UNSPECIFIED = 0; + NATIVE_TEXT_OVERFLOW_CLIP = 1; + NATIVE_TEXT_OVERFLOW_ELLIPSIS = 2; +} + +message CodePointRange { + uint32 first = 1; + uint32 last = 2; +} + +message FontFace { + string asset_id = 1; + repeated CodePointRange supported_code_point_ranges = 2; +} + +message ResolvedFont { + FontFace primary = 1; + repeated FontFace fallbacks = 2; +} + +message SrgbaColor { + double red = 1; + double green = 2; + double blue = 3; + double alpha = 4; +} + +message LiteralText { string value = 1; } + +message StringVariableText { + string variable_id = 1; + repeated CodePointRange allowed_code_point_ranges = 2; +} + +message BooleanVariableText { + string variable_id = 1; + string true_label = 2; + string false_label = 3; +} + +message NumberVariableText { + string variable_id = 1; + uint32 fraction_digits = 2; +} + +enum TimerFormat { + TIMER_FORMAT_UNSPECIFIED = 0; + TIMER_FORMAT_MM_SS = 1; + TIMER_FORMAT_HH_MM_SS = 2; +} + +enum InactiveTimerValue { + INACTIVE_TIMER_VALUE_UNSPECIFIED = 0; + INACTIVE_TIMER_VALUE_EMPTY = 1; + INACTIVE_TIMER_VALUE_ZERO = 2; +} + +message StepTimerText { + string group_id = 1; + string step_id = 2; + string cue_id = 3; + uint64 duration_ms = 4; + InactiveTimerValue when_step_inactive = 5; + TimerFormat format = 6; +} + +message NativeTextValue { + oneof source { + LiteralText literal = 1; + StringVariableText string_variable = 2; + BooleanVariableText boolean_variable = 3; + NumberVariableText number_variable = 4; + StepTimerText step_timer = 5; + } +} + +message NativeUiGroup { + string node_id = 1; + LogicalBounds bounds = 2; + bool clip = 3; + repeated string child_node_ids = 4; +} + +message NativeUiText { + string node_id = 1; + LogicalBounds bounds = 2; + string semantic_node_id = 3; + NativeTextValue value = 4; + ResolvedFont font = 5; + SrgbaColor color = 6; + double font_size = 7; + double line_height = 8; + NativeTextAlign align = 9; + NativeTextOverflow overflow = 10; + uint64 max_code_points = 11; +} + +message NativeUiNode { + oneof node { + NativeUiGroup group = 1; + NativeUiText text = 2; + } +} + +message NativeUiArtifact { + string artifact_id = 1; + uint32 contract_version = 2; + repeated NativeUiFeature required_features = 3; + string root_node_id = 4; + repeated NativeUiNode nodes = 5; +} + +enum VideoCodec { + VIDEO_CODEC_UNSPECIFIED = 0; + VIDEO_CODEC_H264 = 1; + VIDEO_CODEC_VP9 = 2; + VIDEO_CODEC_AV1 = 3; +} + +message VideoArtifact { + string artifact_id = 1; + uint32 contract_version = 2; + repeated VideoFeature required_features = 3; + string video_asset_id = 4; + string checksum = 5; + PixelSize pixel_size = 6; + uint64 duration_ms = 9; + bool loop = 11; + bool has_audio = 12; + uint64 encoded_size_bytes = 13; + VideoCodec codec = 14; + bool has_alpha = 15; + reserved 7, 8, 10; +} + +message DeliveredArtifact { + oneof artifact { + BakedWebArtifact baked_web = 1; + NativeUiArtifact native_ui = 2; + VideoArtifact video = 3; + } +} + +message DeliveredRenderSurface { + string render_surface_id = 1; + string semantic_surface_id = 2; + LogicalBounds logical_bounds = 3; + uint32 layer = 4; + RendererKind renderer_kind = 5; + uint32 artifact_contract_version = 6; + repeated DeliveredStateBinding state_bindings = 7; + repeated DeliveredArtifact artifacts = 8; +} + +enum SemanticRole { + SEMANTIC_ROLE_UNSPECIFIED = 0; + SEMANTIC_ROLE_HEADING = 1; + SEMANTIC_ROLE_PARAGRAPH = 2; + SEMANTIC_ROLE_IMAGE = 3; + SEMANTIC_ROLE_BUTTON = 4; + SEMANTIC_ROLE_LIST = 5; + SEMANTIC_ROLE_LIST_ITEM = 6; + SEMANTIC_ROLE_TABLE = 7; + SEMANTIC_ROLE_ROW = 8; + SEMANTIC_ROLE_CELL = 9; + SEMANTIC_ROLE_COLUMN_HEADER = 10; + SEMANTIC_ROLE_ROW_HEADER = 11; +} + +message ProjectedSemanticNode { + string semantic_node_id = 1; + optional string parent_node_id = 2; + uint32 order = 3; + SemanticRole role = 4; + optional string text = 5; + optional string language = 6; + optional string alt = 7; + optional string label = 8; + optional uint32 heading_level = 9; + optional bool ordered = 10; + bool interaction_enabled = 11; + optional string interaction_id = 12; +} + +message ProjectedSemanticTree { + repeated string root_node_ids = 1; + repeated ProjectedSemanticNode nodes = 2; +} + +message InteractiveRegion { + string interaction_id = 1; + string semantic_node_id = 2; + LogicalBounds normalized_bounds = 3; + uint32 priority = 4; +} + +message SurfaceSemanticState { + string state_id = 1; + ProjectedSemanticTree semantic_tree = 2; + repeated InteractiveRegion interactive_regions = 3; +} + +message ProjectedSemanticSurface { + string semantic_surface_id = 1; + repeated string render_surface_ids = 2; + repeated SurfaceSemanticState states = 3; +} + +message LocalOverlayDefinition { + string overlay_id = 1; + unframe.presentation.v2.AnchorTarget self_anchor = 2; + unframe.presentation.v2.Transform transform = 3; + repeated string render_surface_ids = 4; +} + +message ProjectionProfileDescriptor { + string projection_profile_id = 1; + ProjectionProfileKey key = 2; + repeated string visible_node_ids = 3; + repeated string visible_surface_ids = 4; + repeated string visible_variable_ids = 5; + repeated DeliveredRenderSurface render_surfaces = 6; + repeated ProjectedSemanticSurface semantic_surfaces = 7; + repeated LocalOverlayDefinition local_overlays = 8; + repeated unframe.presentation.v2.RuntimeCapability required_runtime_capabilities = 9; + unframe.presentation.v2.ProjectedRuntimeCatalog runtime_catalog = 10; +} + +message AssetAccessBinding { + string asset_id = 1; + string checksum = 2; + string media_type = 3; + uint64 encoded_size_bytes = 4; + string url = 5; + uint64 expires_at_unix_ms = 6; +} + +message TextureResidencyBinding { + string asset_id = 1; + string checksum = 2; + PixelSize pixel_size = 3; + uint64 decoded_gpu_bytes = 4; + uint64 peak_load_cpu_bytes = 5; +} + +message TextureResidencyPlan { + string budget_tier_id = 1; + repeated TextureResidencyBinding textures = 2; + uint64 total_decoded_gpu_bytes = 3; + uint64 maximum_peak_load_cpu_bytes = 4; +} + +message ModelResidencyBinding { + string asset_id = 1; + string checksum = 2; + uint64 encoded_size_bytes = 3; + ModelFormat format = 4; + repeated ModelFeature required_features = 5; + uint64 node_count = 6; + uint64 primitive_count = 7; + uint64 vertex_count = 8; + uint64 triangle_count = 9; + uint64 maximum_bones_per_skin = 10; + uint64 maximum_morph_targets_per_primitive = 11; + uint64 animation_clip_count = 12; +} + +message ModelResidencyPlan { + string budget_tier_id = 1; + repeated ModelResidencyBinding models = 2; + uint64 total_encoded_bytes = 3; + uint64 total_vertices = 4; + uint64 total_triangles = 5; + uint64 model_instance_count = 6; + uint64 total_nodes = 7; + uint64 total_primitives = 8; +} + +message NativeUiResidencyPlan { + string budget_tier_id = 1; + repeated string font_asset_ids = 2; + uint64 maximum_nodes_per_artifact = 5; + uint64 maximum_tree_depth = 6; + uint64 maximum_text_nodes_per_artifact = 7; + repeated GlyphResidencyKey glyphs = 8; + uint64 maximum_code_points_per_text = 9; + uint64 font_asset_count = 10; + reserved 3, 4; +} + +message GlyphResidencyKey { + string font_checksum = 1; + uint32 unicode_scalar = 2; +} + +message VideoResidencyBinding { + string asset_id = 1; + string checksum = 2; + uint64 encoded_size_bytes = 3; + uint64 decoded_frame_bytes = 4; +} + +message VideoResidencyPlan { + string budget_tier_id = 1; + repeated VideoResidencyBinding videos = 2; + uint64 total_encoded_bytes = 3; + uint64 maximum_concurrent_decoders = 4; + uint64 maximum_decoded_frame_bytes = 5; +} + +message ResidencyPlan { + TextureResidencyPlan textures = 1; + ModelResidencyPlan models = 2; + NativeUiResidencyPlan native_ui = 3; + VideoResidencyPlan video = 4; + uint64 total_selected_encoded_bytes = 5; +} + +message DeliveryManifest { + uint32 schema_version = 1; + uint32 delivery_contract_version = 2; + string session_id = 3; + unframe.presentation.v2.PublicationFence publication = 4; + string definition_hash = 5; + string render_bundle_hash = 6; + string asset_set_hash = 7; + CapabilityProfile capability_profile = 8; + ProjectionProfileDescriptor projection_profile = 9; + unframe.presentation.v2.ProjectionInstance projection_instance = 10; + repeated AssetAccessBinding asset_access = 11; + ResidencyPlan residency = 12; +} diff --git a/packages/contracts/proto/unframe/presentation/v2/runtime.proto b/packages/contracts/proto/unframe/presentation/v2/runtime.proto new file mode 100644 index 0000000..97c928e --- /dev/null +++ b/packages/contracts/proto/unframe/presentation/v2/runtime.proto @@ -0,0 +1,330 @@ +syntax = "proto3"; + +package unframe.presentation.v2; + +option go_package = "github.com/unframe-dev/unframe/app/server/realtime/internal/gen/presentation/v2;presentationv2"; + +// Shared portable types used by the immutable delivery projection and the +// mutable realtime protocol. This is the first complete contract major; the +// incomplete realtime.v1 foundation is not wire-compatible with it. + +enum SessionRole { + SESSION_ROLE_UNSPECIFIED = 0; + SESSION_ROLE_PRESENTER = 1; + SESSION_ROLE_VIEWER = 2; +} + +enum RuntimeCapability { + RUNTIME_CAPABILITY_UNSPECIFIED = 0; + RUNTIME_CAPABILITY_RUNTIME_TRANSPORT_V2 = 1; + RUNTIME_CAPABILITY_TIMELINE_RUN_V2 = 2; + RUNTIME_CAPABILITY_SURFACE_TRANSITION_V2 = 3; + RUNTIME_CAPABILITY_VIDEO_PLAYBACK_V2 = 4; + RUNTIME_CAPABILITY_MODEL_CLIP_V2 = 5; + RUNTIME_CAPABILITY_TRACKING_V2 = 6; +} + +message PublicationFence { + string presentation_id = 1; + uint64 publication_epoch = 2; + string publication_manifest_hash = 3; +} + +message ProjectionInstance { + string projection_profile_id = 1; + string participant_id = 2; + uint64 assignment_epoch = 3; +} + +message RuntimeProjectionFence { + string session_id = 1; + PublicationFence publication = 2; + uint64 assignment_epoch = 3; + string projection_profile_id = 4; + uint64 presentation_origin_version = 5; +} + +message Vector2 { + double x = 1; + double y = 2; +} + +message Vector3 { + double x = 1; + double y = 2; + double z = 3; +} + +message Quaternion { + double x = 1; + double y = 2; + double z = 3; + double w = 4; +} + +message Transform { + Vector3 position = 1; + Quaternion rotation = 2; + Vector3 scale = 3; +} + +message Pose { + Vector3 position = 1; + Quaternion rotation = 2; +} + +message ScalarValue { + oneof value { + string string_value = 1; + double number_value = 2; + bool boolean_value = 3; + NullScalar null_value = 4; + } +} + +message NullScalar {} + +enum ScalarType { + SCALAR_TYPE_UNSPECIFIED = 0; + SCALAR_TYPE_STRING = 1; + SCALAR_TYPE_NUMBER = 2; + SCALAR_TYPE_BOOLEAN = 3; + SCALAR_TYPE_NULL = 4; +} + +enum Easing { + EASING_UNSPECIFIED = 0; + EASING_LINEAR = 1; + EASING_CUBIC_IN = 2; + EASING_CUBIC_OUT = 3; + EASING_CUBIC_IN_OUT = 4; +} + +message RuntimeRunId { + uint64 assignment_epoch = 1; + uint64 run_sequence = 2; +} + +message RuntimeRunOwner { + oneof scope { + PresentationRunOwner presentation = 1; + GroupRunOwner group = 2; + } +} + +message PresentationRunOwner {} + +message GroupRunOwner { + string group_id = 1; + uint64 group_entry_epoch = 2; +} + +message RuntimeRunCause { + string cue_id = 1; + string cause_event_id = 2; + string group_id = 3; + uint64 group_entry_epoch = 4; + string step_id = 5; + uint64 step_entry_epoch = 6; +} + +enum RunCompletion { + RUN_COMPLETION_UNSPECIFIED = 0; + RUN_COMPLETION_BLOCKING = 1; + RUN_COMPLETION_NON_BLOCKING = 2; +} + +message ResourceOwner { + oneof scope { + PresentationResourceOwner presentation = 1; + GroupResourceOwner group = 2; + } +} + +message PresentationResourceOwner {} +message GroupResourceOwner { string group_id = 1; } + +enum TimelineProperty { + TIMELINE_PROPERTY_UNSPECIFIED = 0; + TIMELINE_PROPERTY_OPACITY = 1; + TIMELINE_PROPERTY_TRANSFORM_POSITION = 2; + TIMELINE_PROPERTY_TRANSFORM_ROTATION = 3; + TIMELINE_PROPERTY_TRANSFORM_SCALE = 4; +} + +message TimelineTrackTarget { + string node_id = 1; + TimelineProperty property = 2; +} + +message NumberKeyframeValue { double value = 1; } +message Vector3KeyframeValue { Vector3 value = 1; } +message QuaternionKeyframeValue { Quaternion value = 1; } + +message TimelineKeyframe { + uint64 time_ms = 1; + oneof value { + NumberKeyframeValue number = 2; + Vector3KeyframeValue vector3 = 3; + QuaternionKeyframeValue quaternion = 4; + } + optional Easing easing_to_next = 5; +} + +message ProjectedTimelineTrack { + TimelineTrackTarget target = 1; + repeated TimelineKeyframe keyframes = 2; +} + +message ProjectedTimelineDefinition { + string timeline_id = 1; + uint64 duration_ms = 2; + repeated ProjectedTimelineTrack tracks = 3; + ResourceOwner owner = 4; +} + +message ProjectedVariableDefinition { + string variable_id = 1; + ScalarType type = 2; + ResourceOwner owner = 3; +} + +enum AnchorTarget { + ANCHOR_TARGET_UNSPECIFIED = 0; + ANCHOR_TARGET_HEAD = 1; + ANCHOR_TARGET_LEFT_HAND = 2; + ANCHOR_TARGET_RIGHT_HAND = 3; + ANCHOR_TARGET_BODY = 4; +} + +message StageParent {} +message NodeParent { string node_id = 1; } + +message PresenterAnchorParent { + AnchorTarget target = 1; + bool follow_position = 2; + bool follow_rotation = 3; +} + +message SpatialParent { + oneof parent { + StageParent stage = 1; + NodeParent node = 2; + PresenterAnchorParent presenter_anchor = 3; + } +} + +message ContainerNode {} +message SurfaceNode { string semantic_surface_id = 1; } +message ModelNode { string model_asset_id = 1; } + +message BoxGeometry { Vector3 size = 1; } +message SphereGeometry { double radius = 1; } + +message SrgbaColor { + double red = 1; + double green = 2; + double blue = 3; + double alpha = 4; +} + +message SrgbColor { + double red = 1; + double green = 2; + double blue = 3; +} + +message UnlitShapeMaterial { + SrgbaColor color = 1; + bool double_sided = 2; + bool casts_shadows = 3; + bool receives_shadows = 4; +} + +message ShapeNode { + oneof geometry { + BoxGeometry box = 1; + SphereGeometry sphere = 2; + } + UnlitShapeMaterial material = 3; +} + +message DirectionalLight { + SrgbColor color = 1; + double intensity_lux = 2; + bool casts_shadows = 3; +} + +message PointLight { + SrgbColor color = 1; + double intensity_candela = 2; + double range_meters = 3; + bool casts_shadows = 4; +} + +message SpotLight { + SrgbColor color = 1; + double intensity_candela = 2; + double range_meters = 3; + double outer_angle_degrees = 4; + double inner_angle_degrees = 5; + bool casts_shadows = 6; +} + +message LightNode { + oneof light { + DirectionalLight directional = 1; + PointLight point = 2; + SpotLight spot = 3; + } +} + +message ProjectedNodeDefinition { + string node_id = 1; + SpatialParent parent = 2; + uint32 order = 3; + ResourceOwner owner = 4; + oneof node { + ContainerNode container = 10; + SurfaceNode surface = 11; + ModelNode model = 12; + ShapeNode shape = 13; + LightNode light = 14; + } +} + +enum SurfaceFit { + SURFACE_FIT_UNSPECIFIED = 0; + SURFACE_FIT_CONTAIN = 1; + SURFACE_FIT_COVER = 2; + SURFACE_FIT_STRETCH = 3; +} + +message ProjectedSurfaceDefinition { + string surface_id = 1; + string host_node_id = 2; + repeated string reachable_state_ids = 3; + bool has_video = 4; + Vector2 physical_size_meters = 5; + Vector2 logical_size = 6; + SurfaceFit fit = 7; + ResourceOwner owner = 8; +} + +message ProjectedModelClipDefinition { + string model_node_id = 1; + string model_asset_id = 2; + string clip_id = 3; + uint32 source_animation_index = 4; + uint64 duration_ms = 5; + ResourceOwner owner = 6; +} + +message ProjectedRuntimeCatalog { + uint32 catalog_contract_version = 1; + repeated ProjectedNodeDefinition nodes = 2; + repeated ProjectedSurfaceDefinition surfaces = 3; + repeated ProjectedVariableDefinition variables = 4; + repeated ProjectedTimelineDefinition timelines = 5; + repeated ProjectedModelClipDefinition model_clips = 6; +} diff --git a/packages/contracts/proto/unframe/realtime/v2/realtime.proto b/packages/contracts/proto/unframe/realtime/v2/realtime.proto new file mode 100644 index 0000000..ec19f14 --- /dev/null +++ b/packages/contracts/proto/unframe/realtime/v2/realtime.proto @@ -0,0 +1,861 @@ +syntax = "proto3"; + +package unframe.realtime.v2; + +import "unframe/presentation/v2/runtime.proto"; + +option go_package = "github.com/unframe-dev/unframe/app/server/realtime/internal/gen/realtime/v2;realtimev2"; + +service RealtimeServiceV2 { + rpc ConnectControl(stream ControlClientItem) returns (stream ControlServerItem); + rpc ConnectState(stream StateClientItem) returns (stream StateServerItem); +} + +message ControlClientItem { + oneof item { + ControlHandshake handshake = 1; + ReplayRequest replay_request = 2; + StateReady state_ready = 3; + LogicalInputCommand logical_input = 20; + SurfaceInteractionCommand surface_interaction = 21; + RuntimeControlCommand runtime_control = 22; + } + reserved 4 to 19; + reserved 23 to 99; +} + +message ControlHandshake { + string protocol_version = 1; + uint32 progression_contract_version = 2; + repeated unframe.presentation.v2.RuntimeCapability supported_capabilities = 3; + optional ResumeCursor resume = 4; +} + +message ResumeCursor { + string prior_connection_id = 1; + uint64 applied_reliable_sequence = 2; + unframe.presentation.v2.RuntimeProjectionFence fence = 3; +} + +message RuntimeProtocolLimits { + uint32 maximum_id_utf8_bytes = 1; + uint32 maximum_control_item_bytes = 2; + uint32 maximum_state_item_bytes = 3; + uint32 reliable_event_retention_count = 4; + uint64 reliable_event_retention_bytes = 5; + uint64 reliable_event_retention_ms = 6; + uint32 maximum_replay_items = 7; + uint64 maximum_replay_bytes = 8; + uint32 idempotency_retention_count = 9; + uint64 idempotency_retention_ms = 10; + uint32 snapshot_catch_up_max_attempts = 11; + uint64 snapshot_catch_up_total_budget_ms = 12; + uint32 runtime_microstep_limit = 13; + uint32 maximum_tracking_samples_per_frame = 14; + uint32 maximum_tracking_frames_per_second = 15; + uint64 anchor_sample_max_age_ms = 16; + uint32 catch_up_queue_maximum_items = 17; + uint64 catch_up_queue_maximum_bytes = 18; + uint32 state_dependency_buffer_maximum_values = 19; + uint64 state_dependency_buffer_maximum_ms = 20; +} + +message ControlConnected { + string protocol_version = 1; + uint32 progression_contract_version = 2; + repeated unframe.presentation.v2.RuntimeCapability required_capabilities = 3; + string connection_id = 4; + unframe.presentation.v2.ProjectionInstance projection_instance = 5; + RuntimeProtocolLimits limits = 6; +} + +message StateReady { + uint64 applied_reliable_sequence = 1; + uint64 presentation_origin_version = 2; +} + +message StateConnectionNonce { + bytes nonce = 1; + uint32 expires_in_ms = 2; +} + +message ReplayRequest { uint64 after_sequence = 1; } + +message LogicalInputCommand { + string client_event_id = 1; + string logical_event_name = 2; + uint64 presentation_origin_version = 3; + optional uint64 captured_at_client_monotonic_ms = 4; +} + +message SurfaceInteractionCommand { + string client_event_id = 1; + string surface_id = 2; + string interaction_id = 3; + uint64 presentation_origin_version = 4; + optional uint64 captured_at_client_monotonic_ms = 5; +} + +enum RuntimeControlKind { + RUNTIME_CONTROL_KIND_UNSPECIFIED = 0; + RUNTIME_CONTROL_KIND_PAUSE = 1; + RUNTIME_CONTROL_KIND_RESUME = 2; + RUNTIME_CONTROL_KIND_END = 3; +} + +message RuntimeControlCommand { + string client_event_id = 1; + RuntimeControlKind kind = 2; + uint64 presentation_origin_version = 3; +} + +message CommandOutcome { + string client_event_id = 1; + oneof result { + CommandAccepted accepted = 2; + CommandRejected rejected = 3; + CommandNoOp no_op = 4; + } +} + +message CommandAccepted { + string canonical_event_id = 1; + uint64 reliable_sequence = 2; + oneof cue_evaluation { + CueNotSelected cue_not_selected = 3; + CueCommitted cue_committed = 4; + CueBatchRejected cue_batch_rejected = 5; + } +} + +message CueNotSelected {} +message CueCommitted { string cue_id = 1; } + +enum CommandRejectionReason { + COMMAND_REJECTION_REASON_UNSPECIFIED = 0; + COMMAND_REJECTION_REASON_INPUT_UNAVAILABLE = 1; + COMMAND_REJECTION_REASON_INTERACTION_UNAVAILABLE = 2; + COMMAND_REJECTION_REASON_RUNTIME_NOT_ACCEPTING_INPUT = 3; +} + +message CommandRejected { CommandRejectionReason reason = 1; } + +enum CueRejectionReason { + CUE_REJECTION_REASON_UNSPECIFIED = 0; + CUE_REJECTION_REASON_ACTION_BATCH_CONFLICT = 1; + CUE_REJECTION_REASON_RESOLVED_VALUE_INVALID = 2; + CUE_REJECTION_REASON_SURFACE_TRANSITION_ACTIVE = 3; + CUE_REJECTION_REASON_SAME_STATE_CROSSFADE = 4; + CUE_REJECTION_REASON_MODEL_CROSSFADE_ACTIVE = 5; +} + +message CueBatchRejected { + string cue_id = 1; + CueRejectionReason reason = 2; +} + +enum CommandNoOpReason { + COMMAND_NO_OP_REASON_UNSPECIFIED = 0; + COMMAND_NO_OP_REASON_ALREADY_PAUSED = 1; + COMMAND_NO_OP_REASON_ALREADY_RUNNING = 2; + COMMAND_NO_OP_REASON_ALREADY_TERMINATING = 3; +} + +message CommandNoOp { CommandNoOpReason reason = 1; } + +message ControlServerItem { + oneof item { + ControlConnected connected = 1; + ConnectionSnapshotEnvelope connection_snapshot = 2; + ProjectedReliableEvent reliable_event = 3; + ProjectionAdvance projection_advance = 4; + CommandOutcome command_outcome = 5; + StateConnectionNonce state_connection_nonce = 6; + ResyncRequired resync_required = 7; + } +} + +message ProjectedReliableEvent { + uint64 sequence = 1; + string event_id = 2; + optional string cause_event_id = 3; + uint64 occurred_at_runtime_time_ms = 4; + unframe.presentation.v2.RuntimeProjectionFence fence = 5; + oneof payload { + RuntimeStatusChanged runtime_status_changed = 20; + PresentationOriginChanged presentation_origin_changed = 21; + GroupEntered group_entered = 100; + GroupExited group_exited = 101; + StepEntered step_entered = 102; + CueAccepted cue_accepted = 103; + PresentationEnded presentation_ended = 104; + LogicalInputAccepted logical_input_accepted = 105; + SurfaceInteractionAccepted surface_interaction_accepted = 200; + SurfaceStateChanged surface_state_changed = 201; + SurfaceTransitionStarted surface_transition_started = 202; + SurfaceTransitionCompleted surface_transition_completed = 203; + NodeStateCommitted node_state_committed = 204; + VariableChanged variable_changed = 205; + TimelineStarted timeline_started = 300; + TimelineCompleted timeline_completed = 301; + TimelineCanceled timeline_canceled = 302; + MediaStarted media_started = 303; + MediaPaused media_paused = 304; + MediaResumed media_resumed = 305; + MediaSeeked media_seeked = 306; + MediaCompleted media_completed = 307; + MediaCanceled media_canceled = 308; + MediaStopped media_stopped = 309; + ModelClipStarted model_clip_started = 320; + ModelClipPaused model_clip_paused = 321; + ModelClipResumed model_clip_resumed = 322; + ModelClipStopped model_clip_stopped = 323; + ModelClipCompleted model_clip_completed = 324; + ModelClipCrossfadeStarted model_clip_crossfade_started = 325; + ModelClipCrossfadeCompleted model_clip_crossfade_completed = 326; + ModelClipCanceled model_clip_canceled = 327; + ParticipantPresenceChanged participant_presence_changed = 400; + } + reserved 6 to 19; + reserved 22 to 99; + reserved 106 to 199; + reserved 206 to 299; + reserved 310 to 319; + reserved 328 to 399; + reserved 401 to 999; +} + +message ProjectionAdvance { + unframe.presentation.v2.RuntimeProjectionFence fence = 1; + uint64 from_exclusive = 2; + uint64 through_sequence = 3; +} + +enum PauseReason { + PAUSE_REASON_UNSPECIFIED = 0; + PAUSE_REASON_PRESENTER_DISCONNECTED = 1; + PAUSE_REASON_ASSIGNMENT_LEASE_EXPIRED = 2; + PAUSE_REASON_PROCESS_RECOVERED = 3; + PAUSE_REASON_INVARIANT_VIOLATION = 4; + PAUSE_REASON_ATOMIC_COMMIT_FAILED = 5; + PAUSE_REASON_MICROSTEP_LIMIT_EXCEEDED = 6; + PAUSE_REASON_RECOVERY_GAP = 7; + PAUSE_REASON_EXPLICIT_PAUSE = 8; +} + +enum TerminationReason { + TERMINATION_REASON_UNSPECIFIED = 0; + TERMINATION_REASON_EXPLICIT_END = 1; + TERMINATION_REASON_CONTROL_PLANE_ENDED = 2; + TERMINATION_REASON_PAUSE_TIMEOUT = 3; +} + +message Running {} +message Paused { PauseReason reason = 1; } +message Terminating { TerminationReason reason = 1; } + +message RuntimeStatusChanged { + oneof status { + Running running = 1; + Paused paused = 2; + Terminating terminating = 3; + } +} + +message GroupEntered { + string group_id = 1; + uint64 group_entry_epoch = 2; + GroupRuntimeInitialization initialization = 3; +} + +message GroupRuntimeInitialization { + repeated NodeRuntimeState node_states = 1; + repeated SurfaceRuntimeState surface_states = 2; + repeated MediaRuntimeState media_states = 3; + repeated VariableState variables = 4; + repeated ModelClipRuntimeState model_clip_states = 5; +} + +message GroupExited { + string group_id = 1; + uint64 group_entry_epoch = 2; +} + +message StepEntered { + string group_id = 1; + uint64 group_entry_epoch = 2; + string step_id = 3; + uint64 step_entry_epoch = 4; + uint64 entered_at_runtime_time_ms = 5; +} + +message CueAccepted { + string group_id = 1; + string step_id = 2; + string cue_id = 3; +} + +message PresentationEnded { TerminationReason reason = 1; } +message LogicalInputAccepted { string logical_event_name = 1; } + +message SurfaceInteractionAccepted { + string surface_id = 1; + string interaction_id = 2; +} + +message SurfaceStateChanged { + string surface_id = 1; + string from_state_id = 2; + string state_id = 3; +} + +message SurfaceTransitionStarted { + string surface_id = 1; + unframe.presentation.v2.RuntimeRunId run_id = 2; + string from_state_id = 3; + string state_id = 4; + uint64 started_at_runtime_time_ms = 5; + uint64 duration_ms = 6; + unframe.presentation.v2.Easing easing = 7; + RuntimeRunSnapshot run = 8; +} + +message SurfaceTransitionCompleted { + string surface_id = 1; + unframe.presentation.v2.RuntimeRunId run_id = 2; + string state_id = 3; +} + +message NodeRuntimeState { + string node_id = 1; + bool active = 2; + bool visible = 3; + double opacity = 4; + unframe.presentation.v2.Transform transform = 5; +} + +message NodeStateCommitted { NodeRuntimeState state = 1; } + +message VariableState { + string variable_id = 1; + unframe.presentation.v2.ScalarValue value = 2; +} + +message VariableChanged { VariableState state = 1; } + +message TimelineStarted { + unframe.presentation.v2.RuntimeRunId run_id = 1; + string timeline_id = 2; + unframe.presentation.v2.RuntimeRunOwner owner = 3; + unframe.presentation.v2.RuntimeRunCause cause = 4; + unframe.presentation.v2.RunCompletion completion = 5; + uint64 started_at_runtime_time_ms = 6; +} + +message TimelineCompleted { + unframe.presentation.v2.RuntimeRunId run_id = 1; + string timeline_id = 2; +} + +enum TimelineCancelReason { + TIMELINE_CANCEL_REASON_UNSPECIFIED = 0; + TIMELINE_CANCEL_REASON_EXPLICIT_STOP = 1; + TIMELINE_CANCEL_REASON_GROUP_EXIT = 2; + TIMELINE_CANCEL_REASON_PRESENTATION_ENDED = 3; +} + +message TimelineCanceled { + unframe.presentation.v2.RuntimeRunId run_id = 1; + string timeline_id = 2; + TimelineCancelReason reason = 3; +} + +message PlaybackClock { + oneof clock { + PlayingClock playing = 1; + PausedClock paused = 2; + } +} + +message PlayingClock { + double position_at_reference_ms = 1; + uint64 reference_runtime_time_ms = 2; +} + +message PausedClock { double position_ms = 1; } + +message MediaStarted { + unframe.presentation.v2.RuntimeRunId run_id = 1; + string surface_id = 2; + PlaybackClock playback = 3; + RuntimeRunSnapshot run = 4; +} + +message MediaPaused { + unframe.presentation.v2.RuntimeRunId run_id = 1; + string surface_id = 2; + double position_ms = 3; + RuntimeRunSnapshot run = 4; +} + +message MediaResumed { + unframe.presentation.v2.RuntimeRunId run_id = 1; + string surface_id = 2; + PlayingClock playback = 3; + RuntimeRunSnapshot run = 4; +} + +message MediaSeeked { + unframe.presentation.v2.RuntimeRunId run_id = 1; + string surface_id = 2; + PlaybackClock playback = 3; + RuntimeRunSnapshot run = 4; +} + +message MediaCompleted { + unframe.presentation.v2.RuntimeRunId run_id = 1; + string surface_id = 2; + double held_position_ms = 3; +} + +message MediaStopped { + unframe.presentation.v2.RuntimeRunId run_id = 1; + string surface_id = 2; + double held_position_ms = 3; +} + +enum MediaCancelReason { + MEDIA_CANCEL_REASON_UNSPECIFIED = 0; + MEDIA_CANCEL_REASON_GROUP_EXIT = 1; + MEDIA_CANCEL_REASON_PRESENTATION_ENDED = 2; +} + +message MediaCanceled { + unframe.presentation.v2.RuntimeRunId run_id = 1; + string surface_id = 2; + MediaCancelReason reason = 3; +} + +message ClipPlayback { + string clip_id = 1; + PlaybackClock playback = 2; + double speed = 3; + bool loop = 4; +} + +message ClipHeldPose { + string clip_id = 1; + double position_ms = 2; +} + +message BlendHeldPose { + ClipHeldPose from = 1; + ClipHeldPose to = 2; + double to_weight = 3; +} + +message ModelClipStarted { + unframe.presentation.v2.RuntimeRunId run_id = 1; + string model_node_id = 2; + ClipPlayback playback = 3; + RuntimeRunSnapshot run = 4; +} + +message ModelClipPaused { + unframe.presentation.v2.RuntimeRunId run_id = 1; + string model_node_id = 2; + RuntimeRunSnapshot run = 3; +} + +message ModelClipResumed { + unframe.presentation.v2.RuntimeRunId run_id = 1; + string model_node_id = 2; + RuntimeRunSnapshot run = 3; +} + +message ModelClipStopped { + unframe.presentation.v2.RuntimeRunId run_id = 1; + string model_node_id = 2; + oneof held_pose { + ClipHeldPose clip = 3; + BlendHeldPose blend = 4; + } +} + +message ModelClipCompleted { + unframe.presentation.v2.RuntimeRunId run_id = 1; + string model_node_id = 2; + ClipHeldPose held_pose = 3; +} + +message ModelClipCrossfadeStarted { + unframe.presentation.v2.RuntimeRunId run_id = 1; + string model_node_id = 2; + ModelClipCrossfade crossfade = 3; + RuntimeRunSnapshot run = 4; +} + +message ModelClipCrossfadeCompleted { + unframe.presentation.v2.RuntimeRunId run_id = 1; + string model_node_id = 2; + ClipPlayback playback = 3; + RuntimeRunSnapshot run = 4; +} + +enum ModelClipCancelReason { + MODEL_CLIP_CANCEL_REASON_UNSPECIFIED = 0; + MODEL_CLIP_CANCEL_REASON_GROUP_EXIT = 1; + MODEL_CLIP_CANCEL_REASON_PRESENTATION_ENDED = 2; +} + +message ModelClipCanceled { + unframe.presentation.v2.RuntimeRunId run_id = 1; + string model_node_id = 2; + ModelClipCancelReason reason = 3; +} + +message ParticipantPresenceChanged { + string participant_id = 1; + unframe.presentation.v2.SessionRole role = 2; + bool connected = 3; +} + +message PresentationOrigin { + uint64 version = 1; + unframe.presentation.v2.Pose pose = 2; +} + +message PresentationOriginChanged { PresentationOrigin origin = 1; } + +message RuntimeClockSnapshot { + uint64 runtime_time_ms = 1; + oneof status { + Running running = 2; + Paused paused = 3; + Terminating terminating = 4; + } +} + +message StableProgression {} + +message TransitioningProgression { + repeated unframe.presentation.v2.RuntimeRunId blocking_run_ids = 1; + ProgressionNext pending_next = 2; +} + +message ProgressionNext { + oneof destination { + NextStep step = 1; + NextGroup group = 2; + EndPresentation end = 3; + StayOnStep stay = 4; + } +} + +message NextStep { string step_id = 1; } +message NextGroup { string group_id = 1; } +message EndPresentation {} +message StayOnStep {} + +message ProgressionRuntimeState { + string current_group_id = 1; + uint64 group_entry_epoch = 2; + string current_step_id = 3; + uint64 step_entry_epoch = 4; + uint64 step_entered_at_runtime_time_ms = 5; + oneof phase { + StableProgression stable = 6; + TransitioningProgression transitioning = 7; + } +} + +message ArmedTimer { + string cue_id = 1; + uint64 deadline_runtime_time_ms = 2; + bool fired = 3; +} + +message CueCooldown { + string cue_id = 1; + uint64 next_eligible_runtime_time_ms = 2; +} + +message StepExecutionSnapshot { + uint64 step_entry_epoch = 1; + repeated string consumed_cue_ids = 2; + repeated CueCooldown cooldowns = 3; + repeated ArmedTimer timers = 4; +} + +message SurfaceRuntimeState { + string surface_id = 1; + string state_id = 2; + optional unframe.presentation.v2.RuntimeRunId transition_run_id = 3; +} + +message MediaRuntimeState { + string surface_id = 1; + oneof state { + MediaStoppedState stopped = 2; + MediaActive active = 3; + } +} + +message MediaStoppedState { double held_position_ms = 1; } + +message MediaActive { + unframe.presentation.v2.RuntimeRunId run_id = 1; + PlaybackClock playback = 2; +} + +message DefaultModelPose {} + +message ModelClipRuntimeState { + string model_node_id = 1; + oneof state { + DefaultModelPose default_pose = 2; + ClipHeldPose held_clip = 3; + BlendHeldPose held_blend = 4; + ModelClipActive active = 5; + } +} + +message ModelClipActive { unframe.presentation.v2.RuntimeRunId run_id = 1; } + +message SurfaceTransitionRunSnapshot { + string surface_id = 1; + string from_state_id = 2; + string to_state_id = 3; + uint64 duration_ms = 4; + unframe.presentation.v2.Easing easing = 5; +} + +message TimelineRunSnapshot { string timeline_id = 1; } + +message MediaRunSnapshot { + string surface_id = 1; + PlaybackClock playback = 2; +} + +message ModelClipCrossfade { + ClipPlayback from = 1; + ClipPlayback to = 2; + PlaybackClock transition_clock = 3; + uint64 duration_ms = 4; + unframe.presentation.v2.Easing easing = 5; + bool from_is_held = 6; +} + +message ModelClipRunSnapshot { + string model_node_id = 1; + oneof phase { + ClipPlayback single = 2; + ModelClipCrossfade crossfade = 3; + } +} + +message RuntimeRunSnapshot { + unframe.presentation.v2.RuntimeRunId run_id = 1; + unframe.presentation.v2.RuntimeRunOwner owner = 2; + unframe.presentation.v2.RuntimeRunCause cause = 3; + unframe.presentation.v2.RunCompletion completion = 4; + uint64 started_at_runtime_time_ms = 5; + oneof run { + SurfaceTransitionRunSnapshot surface_transition = 20; + TimelineRunSnapshot timeline = 21; + MediaRunSnapshot media = 22; + ModelClipRunSnapshot model_clip = 23; + } +} + +message CanonicalRuntimeSnapshot { + uint32 schema_version = 1; + uint64 reliable_sequence = 2; + uint64 last_ingress_sequence = 3; + uint64 last_allocated_run_sequence = 4; + RuntimeClockSnapshot clock = 5; + ProgressionRuntimeState progression = 6; + StepExecutionSnapshot step_execution = 7; + repeated SurfaceRuntimeState surface_states = 8; + repeated NodeRuntimeState node_states = 9; + repeated MediaRuntimeState media_states = 10; + repeated VariableState variables = 11; + repeated RuntimeRunSnapshot active_runs = 12; + PresentationOrigin presentation_origin = 13; + repeated string recent_event_ids = 14; + repeated ModelClipRuntimeState model_clip_states = 15; +} + +message ParticipantRuntimeView { + string projection_profile_id = 1; + uint64 assignment_epoch = 2; + uint64 base_reliable_sequence = 3; + ProgressionRuntimeState progression = 4; + repeated NodeRuntimeState node_states = 5; + repeated SurfaceRuntimeState surface_states = 6; + repeated MediaRuntimeState media_states = 7; + repeated VariableState variables = 8; + repeated RuntimeRunSnapshot active_runs = 9; + RuntimeClockSnapshot clock = 10; + PresentationOrigin presentation_origin = 11; + repeated string enabled_logical_inputs = 12; + repeated ModelClipRuntimeState model_clip_states = 13; +} + +message ProjectedRuntimeSnapshot { + string projection_profile_id = 1; + uint64 assignment_epoch = 2; + uint64 reliable_sequence = 3; + ParticipantRuntimeView runtime_view = 4; +} + +message ProjectedParticipantPresence { + string participant_id = 1; + unframe.presentation.v2.SessionRole role = 2; + bool connected = 3; +} + +message ProjectedPresenceState { repeated ProjectedParticipantPresence participants = 1; } + +message ConnectionSnapshotEnvelope { + uint32 schema_version = 1; + string connection_id = 2; + unframe.presentation.v2.RuntimeProjectionFence fence = 3; + unframe.presentation.v2.ProjectionInstance projection_instance = 4; + ProjectedPresenceState presence_at_cut = 5; + uint64 reliable_sequence = 6; + ProjectedRuntimeSnapshot snapshot = 7; +} + +enum RuntimeKind { + RUNTIME_KIND_UNSPECIFIED = 0; + RUNTIME_KIND_CLOUD = 1; + RUNTIME_KIND_VENUE_EDGE = 2; +} + +message DurableCheckpointEnvelope { + uint32 schema_version = 1; + uint64 checkpoint_sequence = 2; + string session_id = 3; + string runtime_id = 4; + RuntimeKind runtime_kind = 5; + uint64 assignment_epoch = 6; + unframe.presentation.v2.PublicationFence publication = 7; + string definition_hash = 8; + string render_bundle_hash = 9; + uint64 reliable_sequence = 10; + string canonical_snapshot_hash = 11; + bytes canonical_snapshot_payload = 12; +} + +enum ResyncReason { + RESYNC_REASON_UNSPECIFIED = 0; + RESYNC_REASON_REPLAY_RANGE_UNAVAILABLE = 1; + RESYNC_REASON_PROJECTION_CHANGED = 2; + RESYNC_REASON_PUBLICATION_FENCE_CHANGED = 3; + RESYNC_REASON_PRESENTATION_ORIGIN_CHANGED = 4; + RESYNC_REASON_SNAPSHOT_CATCH_UP_EXHAUSTED = 5; +} + +message ResyncRequired { + ResyncReason reason = 1; + uint64 newest_reliable_sequence = 2; +} + +message StateClientItem { + oneof item { + StateHandshake handshake = 1; + TrackingFrame tracking_frame = 20; + } + reserved 2 to 19; + reserved 21 to 99; +} + +message StateHandshake { + string protocol_version = 1; + uint32 progression_contract_version = 2; + repeated unframe.presentation.v2.RuntimeCapability supported_capabilities = 3; + string connection_id = 4; + bytes state_connection_nonce = 5; +} + +message StateConnected { + string connection_id = 1; + uint64 next_state_frame_sequence = 2; +} + +enum TrackedTarget { + TRACKED_TARGET_UNSPECIFIED = 0; + TRACKED_TARGET_HEAD = 1; + TRACKED_TARGET_LEFT_HAND = 2; + TRACKED_TARGET_RIGHT_HAND = 3; + TRACKED_TARGET_BODY = 4; +} + +message TrackedPoseSample { + TrackedTarget target = 1; + unframe.presentation.v2.Pose quest_local_pose = 2; + bool position_available = 3; + bool rotation_available = 4; +} + +message TrackingFrame { + uint64 frame_sequence = 1; + uint64 captured_at_client_monotonic_ms = 2; + repeated TrackedPoseSample samples = 3; + unframe.presentation.v2.Pose presentation_from_quest_local = 4; +} + +message StateServerItem { + oneof item { + StateConnected connected = 1; + ElementStateFrame state_frame = 2; + } +} + +enum StateFrameKind { + STATE_FRAME_KIND_UNSPECIFIED = 0; + STATE_FRAME_KIND_KEYFRAME = 1; + STATE_FRAME_KIND_DELTA = 2; +} + +message NodeStatePatch { + optional bool active = 1; + optional bool visible = 2; + optional double opacity = 3; + optional unframe.presentation.v2.Transform transform = 4; +} + +message ElementStatePatch { + string element_id = 1; + NodeStatePatch node = 2; +} + +message AnchorBindingUnavailable {} + +message ProjectedAnchorBindingSample { + uint64 tracking_frame_sequence = 1; + uint64 observed_at_runtime_monotonic_ms = 2; + optional unframe.presentation.v2.Vector3 position = 3; + optional unframe.presentation.v2.Quaternion rotation = 4; +} + +message ProjectedAnchorBindingPatch { + string node_id = 1; + oneof state { + AnchorBindingUnavailable unavailable = 2; + ProjectedAnchorBindingSample sample = 3; + } +} + +message ElementStateFrame { + unframe.presentation.v2.RuntimeProjectionFence fence = 1; + uint64 frame_sequence = 2; + uint64 produced_at_runtime_time_ms = 3; + uint64 oldest_change_at_runtime_monotonic_ms = 4; + uint64 base_reliable_sequence = 5; + StateFrameKind kind = 6; + repeated ElementStatePatch elements = 7; + repeated ProjectedAnchorBindingPatch anchor_bindings = 8; + uint64 produced_at_runtime_monotonic_ms = 9; +} diff --git a/packages/contracts/scripts/generate-control-plane.ts b/packages/contracts/scripts/generate-control-plane.ts index 92a7aa3..427a61d 100644 --- a/packages/contracts/scripts/generate-control-plane.ts +++ b/packages/contracts/scripts/generate-control-plane.ts @@ -3,6 +3,20 @@ import { resolve } from "node:path"; import openapiTS, { astToString } from "openapi-typescript"; import { createOpenAPIDocument } from "../../../app/server/control-plane/src/app"; -const root = resolve(import.meta.dirname, ".."); const specPath = resolve(root, "openapi/control-plane.openapi.json"); const typePath = resolve(root, "src/control-plane.openapi.ts"); -const document = `${JSON.stringify(createOpenAPIDocument(), null, 2)}\n`; const types = `// Generated from openapi/control-plane.openapi.json. Do not edit.\n${astToString(await openapiTS(JSON.parse(document)))}`; -if (process.argv.includes("--check")) { if (await readFile(specPath, "utf8") !== document || await readFile(typePath, "utf8") !== types) process.exitCode = 1; } else { await mkdir(resolve(root, "openapi"), { recursive: true }); await mkdir(resolve(root, "src"), { recursive: true }); await writeFile(specPath, document); await writeFile(typePath, types); } +const root = resolve(import.meta.dirname, ".."); +const specPath = resolve(root, "openapi/control-plane.openapi.json"); +const typePath = resolve(root, "src/control-plane.openapi.ts"); +const document = `${JSON.stringify(createOpenAPIDocument(), null, 2)}\n`; +const types = `// Generated from openapi/control-plane.openapi.json. Do not edit.\n${astToString(await openapiTS(JSON.parse(document)))}`; +if (process.argv.includes("--check")) { + if ( + (await readFile(specPath, "utf8")) !== document || + (await readFile(typePath, "utf8")) !== types + ) + process.exitCode = 1; +} else { + await mkdir(resolve(root, "openapi"), { recursive: true }); + await mkdir(resolve(root, "src"), { recursive: true }); + await writeFile(specPath, document); + await writeFile(typePath, types); +} diff --git a/packages/contracts/scripts/generate-presentation-v2.ts b/packages/contracts/scripts/generate-presentation-v2.ts new file mode 100644 index 0000000..ddaa3f0 --- /dev/null +++ b/packages/contracts/scripts/generate-presentation-v2.ts @@ -0,0 +1,101 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import * as z from "zod"; + +import { + assetSetManifestV2Schema, + buildManifestV2Schema, + capabilityProfileV2Schema, + presentationDefinitionV2Schema, + publishedPresentationV2Schema, + renderBundleV2Schema, +} from "../src/presentation/v2/index"; + +const root = resolve(import.meta.dirname, ".."); +const check = process.argv.includes("--check"); +const schemas: ReadonlyArray = [ + ["presentation-definition", presentationDefinitionV2Schema], + ["render-bundle", renderBundleV2Schema], + ["asset-set-manifest", assetSetManifestV2Schema], + ["build-manifest", buildManifestV2Schema], + ["published-presentation", publishedPresentationV2Schema], + ["capability-profile", capabilityProfileV2Schema], +]; + +async function output(relative: string, bytes: string | Uint8Array): Promise { + const path = resolve(root, relative); + const expected = Buffer.from(bytes); + if (check) { + let actual: Buffer | undefined; + try { + actual = await readFile(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if ( + !actual || + actual.length !== expected.length || + !actual.every((byte, index) => byte === expected[index]) + ) { + process.stderr.write(`Contract artifact is stale: ${relative}\n`); + process.exitCode = 1; + } + } else { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, expected); + } +} + +for (const [name, schema] of schemas) { + const jsonSchema = z.toJSONSchema(schema, { + target: "draft-2020-12", + unrepresentable: "throw", + override: ({ jsonSchema: node }) => { + if (!Array.isArray(node.prefixItems)) return; + node.items = false; + node.minItems = node.prefixItems.length; + node.maxItems = node.prefixItems.length; + }, + }); + await output( + `presentation/v2/${name}.schema.json`, + execFileSync( + "pnpm", + ["exec", "vp", "fmt", `--stdin-filepath=presentation/v2/${name}.schema.json`], + { + cwd: root, + input: `${JSON.stringify( + { + ...jsonSchema, + $id: `https://contracts.unframe.dev/presentation/${name}.v2.schema.json`, + }, + null, + 2, + )}\n`, + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + }, + ), + ); +} + +const temporary = await mkdtemp(resolve(tmpdir(), "unframe-contract-v2-")); +try { + execFileSync( + "protoc", + [ + `--proto_path=${resolve(root, "proto")}`, + "--include_imports", + `--descriptor_set_out=${resolve(temporary, "contract.pb")}`, + "unframe/presentation/v2/runtime.proto", + "unframe/delivery/v2/delivery.proto", + "unframe/realtime/v2/realtime.proto", + ], + { cwd: resolve(root, "proto"), maxBuffer: 16 * 1024 * 1024 }, + ); + await output("presentation/v2/contract.pb", await readFile(resolve(temporary, "contract.pb"))); +} finally { + await rm(temporary, { recursive: true, force: true }); +} diff --git a/packages/contracts/scripts/generate-presentation.ts b/packages/contracts/scripts/generate-presentation.ts new file mode 100644 index 0000000..5d56414 --- /dev/null +++ b/packages/contracts/scripts/generate-presentation.ts @@ -0,0 +1,23 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { + presentationDefinitionJsonSchema, + renderBundleJsonSchema, +} from "../src/presentation/index"; + +const root = resolve(import.meta.dirname, ".."); +const outputs = [ + ["presentation/presentation-definition.schema.json", presentationDefinitionJsonSchema], + ["presentation/render-bundle.schema.json", renderBundleJsonSchema], +] as const; + +for (const [relativePath, schema] of outputs) { + const outputPath = resolve(root, relativePath); + const output = `${JSON.stringify(schema, null, 2)}\n`; + if (process.argv.includes("--check")) { + if ((await readFile(outputPath, "utf8")) !== output) process.exitCode = 1; + } else { + await writeFile(outputPath, output); + } +} diff --git a/packages/contracts/src/presentation/common.ts b/packages/contracts/src/presentation/common.ts new file mode 100644 index 0000000..928d99d --- /dev/null +++ b/packages/contracts/src/presentation/common.ts @@ -0,0 +1,35 @@ +import * as z from "zod"; + +export const idSchema = z.string().min(1); +export const positiveNumberSchema = z.number().positive(); +export const vector2Schema = z.tuple([positiveNumberSchema, positiveNumberSchema]); +export const vector3Schema = z.tuple([z.number(), z.number(), z.number()]); +export const positiveVector3Schema = z.tuple([ + positiveNumberSchema, + positiveNumberSchema, + positiveNumberSchema, +]); +export const quaternionSchema = z.tuple([z.number(), z.number(), z.number(), z.number()]); + +export const semanticNodeSchema = z.strictObject({ + id: idSchema, + parentId: idSchema.nullable(), + order: z.number(), + role: z.enum(["heading", "paragraph", "image", "button", "table", "list", "listItem"]), + text: z.string().optional(), + language: z.string().optional(), + alt: z.string().optional(), + interactionId: idSchema.optional(), +}); + +export const semanticTreeSchema = z.strictObject({ + rootNodeIds: z.array(idSchema), + nodes: z.record(z.string(), semanticNodeSchema), +}); + +export const boundsSchema = z.strictObject({ + x: z.number(), + y: z.number(), + width: positiveNumberSchema, + height: positiveNumberSchema, +}); diff --git a/packages/contracts/src/presentation/definition.ts b/packages/contracts/src/presentation/definition.ts new file mode 100644 index 0000000..facc427 --- /dev/null +++ b/packages/contracts/src/presentation/definition.ts @@ -0,0 +1,206 @@ +import * as z from "zod"; + +import { + idSchema, + positiveNumberSchema, + positiveVector3Schema, + quaternionSchema, + semanticTreeSchema, + vector2Schema, + vector3Schema, +} from "./common"; + +const ownerSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("presentation") }), + z.strictObject({ kind: z.literal("group"), groupId: idSchema }), +]); + +const audienceSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("all") }), + z.strictObject({ kind: z.literal("role"), role: z.enum(["presenter", "viewer"]) }), +]); + +const parentSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("stage") }), + z.strictObject({ kind: z.literal("node"), nodeId: idSchema }), + z.strictObject({ + kind: z.literal("anchor"), + target: z.enum(["head", "leftHand", "rightHand", "body"]), + owner: z.strictObject({ kind: z.literal("presenter") }), + followPosition: z.boolean(), + followRotation: z.boolean(), + }), +]); + +const absolutePlacementSchema = z.strictObject({ + kind: z.literal("absolute"), + x: z.number(), + y: z.number(), + width: positiveNumberSchema, + height: positiveNumberSchema, +}); + +const frameSchema = z.strictObject({ + id: idSchema, + kind: z.literal("frame"), + parentId: idSchema.nullable(), + order: z.number(), + layout: z.strictObject({ kind: z.literal("absolute") }), + children: z.array(idSchema), +}); + +const textSchema = z.strictObject({ + id: idSchema, + kind: z.literal("text"), + parentId: idSchema.nullable(), + order: z.number(), + placement: absolutePlacementSchema, + text: z.string(), +}); + +const contentNodeSchema = z.discriminatedUnion("kind", [frameSchema, textSchema]); + +const interactionSchema = z.strictObject({ + id: idSchema, + kind: z.literal("click"), + event: idSchema, +}); + +const nodeOverrideSchema = z.strictObject({ + included: z.boolean().optional(), + text: z.string().nullable().optional(), + language: z.string().nullable().optional(), + alt: z.string().nullable().optional(), +}); + +const uniqueIdArraySchema = z + .array(idSchema) + .refine((ids) => new Set(ids).size === ids.length, "Interaction IDs must be unique") + .meta({ uniqueItems: true }); + +const stateSchema = z.strictObject({ + id: idSchema, + semanticOverrides: z.array(z.strictObject({ nodes: z.record(z.string(), nodeOverrideSchema) })), + enabledInteractionIds: uniqueIdArraySchema, +}); + +const renderIntentSchema = z.strictObject({ + updateModel: z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("static") }), + z.strictObject({ kind: z.literal("finite-state"), stateIds: z.array(idSchema).min(1) }), + z.strictObject({ + kind: z.literal("continuous"), + source: z.enum(["timeline", "runtime-data", "user-input"]), + maximumUpdateRateHz: positiveNumberSchema.optional(), + }), + ]), + interaction: z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("none") }), + z.strictObject({ kind: z.literal("regions"), events: z.array(idSchema).min(1) }), + z.strictObject({ kind: z.literal("native-input") }), + ]), + internalAnimation: z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("none") }), + z.strictObject({ kind: z.literal("precomputed"), durationSeconds: positiveNumberSchema }), + z.strictObject({ kind: z.literal("runtime") }), + ]), + rendererPreference: z.enum(["auto", "baked-web", "native-ui", "video"]), + fallbackPolicy: z.enum(["reject", "degrade"]), +}); + +export const semanticSurfaceSchema = z.strictObject({ + id: idSchema, + hostNodeId: idSchema, + physicalSizeMeters: vector2Schema, + logicalSize: vector2Schema, + fit: z.enum(["contain", "cover", "stretch"]), + rootFrameId: idSchema, + contentNodes: z.record(z.string(), contentNodeSchema), + baseSemanticTree: semanticTreeSchema, + interactions: z.record(z.string(), interactionSchema), + initialStateId: idSchema, + states: z.record(z.string(), stateSchema), + renderIntent: renderIntentSchema, +}); + +const surfaceNodeSchema = z.strictObject({ + id: idSchema, + name: z.string().optional(), + kind: z.literal("surface"), + owner: ownerSchema, + audience: audienceSchema, + parent: parentSchema, + order: z.number(), + transform: z.strictObject({ + position: vector3Schema, + rotation: quaternionSchema, + scale: positiveVector3Schema, + }), + active: z.boolean(), + visible: z.boolean(), + opacity: z.number().min(0).max(1), + surfaceId: idSchema, +}); + +const variableSchema = z.strictObject({ + id: idSchema, + owner: ownerSchema, + type: z.enum(["string", "boolean", "number", "null"]), + initialValue: z.union([z.string(), z.boolean(), z.number(), z.null()]), +}); + +const stepSchema = z.strictObject({ + id: idSchema, + cues: z.array(z.unknown()).max(0), +}); + +const groupSchema = z.strictObject({ + id: idSchema, + initialStepId: idSchema, + steps: z.record(z.string(), stepSchema), +}); + +export const presentationDefinitionSchema = z.strictObject({ + schemaVersion: z.literal(1), + presentationId: idSchema, + metadata: z.strictObject({ title: z.string().min(1) }), + stage: z.strictObject({ + coordinateSystem: z.strictObject({ + unit: z.literal("meter"), + handedness: z.literal("right"), + upAxis: z.literal("+Y"), + forwardAxis: z.literal("-Z"), + }), + size: positiveVector3Schema, + zones: z.record( + z.string(), + z.strictObject({ + id: idSchema, + owner: ownerSchema, + center: vector3Schema, + size: positiveVector3Schema, + }), + ), + }), + assets: z.record( + z.string(), + z.strictObject({ id: idSchema, mediaType: idSchema, checksum: idSchema }), + ), + scene: z.strictObject({ + nodes: z + .record(z.string(), surfaceNodeSchema) + .refine((nodes) => Object.keys(nodes).length > 0, "A scene must contain a node") + .meta({ minProperties: 1 }), + surfaces: z + .record(z.string(), semanticSurfaceSchema) + .refine((surfaces) => Object.keys(surfaces).length > 0, "A scene must contain a surface") + .meta({ minProperties: 1 }), + }), + flow: z.strictObject({ + initialGroupId: idSchema, + groups: z.record(z.string(), groupSchema), + variables: z.record(z.string(), variableSchema), + }), +}); + +export type SerializedPresentationDefinitionV1 = z.infer; diff --git a/packages/contracts/src/presentation/index.ts b/packages/contracts/src/presentation/index.ts new file mode 100644 index 0000000..c6d0870 --- /dev/null +++ b/packages/contracts/src/presentation/index.ts @@ -0,0 +1,6 @@ +export { idSchema } from "./common"; +export { presentationDefinitionSchema, semanticSurfaceSchema } from "./definition"; +export { presentationDefinitionJsonSchema, renderBundleJsonSchema } from "./json-schema"; +export { renderBundleSchema } from "./render-bundle"; +export type { SerializedPresentationDefinitionV1 } from "./definition"; +export type { SerializedRenderBundleV1 } from "./render-bundle"; diff --git a/packages/contracts/src/presentation/json-schema.ts b/packages/contracts/src/presentation/json-schema.ts new file mode 100644 index 0000000..e646f52 --- /dev/null +++ b/packages/contracts/src/presentation/json-schema.ts @@ -0,0 +1,38 @@ +import * as z from "zod"; + +import { presentationDefinitionSchema } from "./definition"; +import { renderBundleSchema } from "./render-bundle"; + +const draft202012 = "https://json-schema.org/draft/2020-12/schema"; + +const generateJsonSchema = ( + schema: z.ZodType, + id: string, + title: string, +): Record => { + const generated = z.toJSONSchema(schema, { + target: "draft-2020-12", + unrepresentable: "throw", + reused: "ref", + override: ({ jsonSchema }) => { + if (!Array.isArray(jsonSchema.prefixItems)) return; + jsonSchema.items = false; + jsonSchema.minItems = jsonSchema.prefixItems.length; + jsonSchema.maxItems = jsonSchema.prefixItems.length; + }, + }) as Record; + const { $schema: _schema, id: _id, title: _title, ...shape } = generated; + return { $schema: draft202012, $id: id, title, ...shape }; +}; + +export const presentationDefinitionJsonSchema = generateJsonSchema( + presentationDefinitionSchema, + "https://contracts.unframe.dev/presentation/presentation-definition.v1.schema.json", + "SerializedPresentationDefinitionV1", +); + +export const renderBundleJsonSchema = generateJsonSchema( + renderBundleSchema, + "https://contracts.unframe.dev/presentation/render-bundle.v1.schema.json", + "SerializedRenderBundleV1", +); diff --git a/packages/contracts/src/presentation/render-bundle.ts b/packages/contracts/src/presentation/render-bundle.ts new file mode 100644 index 0000000..4f163b5 --- /dev/null +++ b/packages/contracts/src/presentation/render-bundle.ts @@ -0,0 +1,76 @@ +import * as z from "zod"; + +import { boundsSchema, idSchema, semanticTreeSchema, vector2Schema } from "./common"; + +const bindingSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("empty") }), + z.strictObject({ kind: z.literal("artifacts"), artifactIds: z.array(idSchema).min(1) }), +]); + +const textureSchema = z.strictObject({ + assetId: idSchema, + mediaType: z.literal("image/png"), + pixelSize: vector2Schema, + checksum: idSchema, + colorSpace: z.literal("srgb"), + alphaMode: z.enum(["opaque", "straight", "premultiplied"]), +}); + +const bakedArtifactSchema = z.strictObject({ + id: idSchema, + kind: z.literal("baked-web"), + states: z.record( + z.string(), + z.strictObject({ stateId: idSchema, textures: z.array(textureSchema).min(1) }), + ), +}); + +const renderSurfaceSchema = z.strictObject({ + id: idSchema, + semanticSurfaceId: idSchema, + logicalBounds: boundsSchema, + layer: z.int().nonnegative(), + artifacts: z.record(z.string(), bakedArtifactSchema), + stateBindings: z.record(z.string(), bindingSchema), +}); + +const hitRegionSchema = z.strictObject({ + interactionId: idSchema, + semanticNodeId: idSchema, + bounds: boundsSchema, + coordinateSpace: z.literal("normalized"), + event: idSchema, + priority: z.number(), +}); + +const compiledSurfaceSchema = z.strictObject({ + semanticSurfaceId: idSchema, + logicalSize: vector2Schema, + physicalSizeMeters: vector2Schema, + renderSurfaceIds: z.array(idSchema).min(1), + renderSurfaces: z.record(z.string(), renderSurfaceSchema), + semanticsByState: z.record(z.string(), semanticTreeSchema), + interactionsByState: z.record(z.string(), z.array(hitRegionSchema)), +}); + +export const renderBundleSchema = z.strictObject({ + schemaVersion: z.literal(1), + bundleId: idSchema, + sourceHash: idSchema, + definitionHash: idSchema, + compiler: z.strictObject({ + name: idSchema, + version: idSchema, + environmentHash: idSchema, + }), + buildContext: z.strictObject({ + locale: idSchema, + timezone: idSchema, + colorScheme: z.enum(["light", "dark"]), + themeId: idSchema, + themeHash: idSchema, + }), + surfaces: z.record(z.string(), compiledSurfaceSchema), +}); + +export type SerializedRenderBundleV1 = z.infer; diff --git a/packages/contracts/src/presentation/v2/asset-set.ts b/packages/contracts/src/presentation/v2/asset-set.ts new file mode 100644 index 0000000..be4225d --- /dev/null +++ b/packages/contracts/src/presentation/v2/asset-set.ts @@ -0,0 +1,24 @@ +import * as z from "zod"; + +import { contentHashV2Schema, idV2Schema, safeUIntV2Schema } from "./common"; + +export const assetDescriptorV2Schema = z.strictObject({ + checksum: contentHashV2Schema, + mediaType: z.enum([ + "image/png", + "image/jpeg", + "font/ttf", + "font/otf", + "video/mp4", + "model/gltf-binary", + ]), + encodedSizeBytes: safeUIntV2Schema, +}); + +export const assetSetManifestV2Schema = z.strictObject({ + schemaVersion: z.literal(2), + assets: z.record(idV2Schema, assetDescriptorV2Schema), +}); + +export type AssetDescriptorV2 = z.infer; +export type AssetSetManifestV2 = z.infer; diff --git a/packages/contracts/src/presentation/v2/capability.ts b/packages/contracts/src/presentation/v2/capability.ts new file mode 100644 index 0000000..c398c31 --- /dev/null +++ b/packages/contracts/src/presentation/v2/capability.ts @@ -0,0 +1,87 @@ +import * as z from "zod"; + +import { idV2Schema, positiveSafeUIntV2Schema } from "./common"; + +const rendererCapability = (features: T) => + z.strictObject({ + supported: z.boolean(), + contractVersion: z.literal(1), + features: z.array(features), + }); +const budget = (shape: T) => + z.strictObject({ tierId: idV2Schema, ...shape }); + +export const capabilityProfileV2Schema = z.strictObject({ + schemaVersion: z.literal(2), + capabilityProfileId: idV2Schema, + contractVersions: z.strictObject({ + delivery: z.literal(2), + runtime: z.literal(2), + progression: z.literal(1), + projection: z.literal(1), + }), + renderers: z.strictObject({ + bakedWeb: rendererCapability(z.enum(["alpha-opaque", "alpha-straight", "png", "srgb"])), + nativeUi: rendererCapability(z.enum(["clip", "ellipsis", "explicitFontFallback"])), + video: rendererCapability(z.enum(["alpha", "audio", "h264", "vp9", "av1"])), + }), + model: z.strictObject({ + supported: z.boolean(), + contractVersion: z.literal(1), + formats: z.tuple([z.literal("model/gltf-binary")]), + features: z.array( + z.enum([ + "alphaBlend", + "alphaMask", + "animation", + "morphTargets", + "pbrMetallicRoughness", + "skin", + "unlit", + ]), + ), + }), + limits: z.strictObject({ + texture: budget({ + maxTextureWidth: positiveSafeUIntV2Schema, + maxTextureHeight: positiveSafeUIntV2Schema, + maxTexturePixels: positiveSafeUIntV2Schema, + maxTextureBindings: positiveSafeUIntV2Schema, + maxGpuBytes: positiveSafeUIntV2Schema, + maxSerialLoadCpuBytes: positiveSafeUIntV2Schema, + maxEncodedCacheBytes: positiveSafeUIntV2Schema, + encodedCacheReserveBytes: positiveSafeUIntV2Schema, + }), + nativeUi: budget({ + maxNodesPerArtifact: positiveSafeUIntV2Schema, + maxTreeDepth: positiveSafeUIntV2Schema, + maxTextNodesPerArtifact: positiveSafeUIntV2Schema, + maxCodePointsPerText: positiveSafeUIntV2Schema, + maxGlyphs: positiveSafeUIntV2Schema, + maxFontAssets: positiveSafeUIntV2Schema, + }), + video: budget({ + maxWidth: positiveSafeUIntV2Schema, + maxHeight: positiveSafeUIntV2Schema, + maxPixels: positiveSafeUIntV2Schema, + maxConcurrentDecoders: positiveSafeUIntV2Schema, + maxEncodedBytes: positiveSafeUIntV2Schema, + maxDecodedFrameBytes: positiveSafeUIntV2Schema, + }), + model: budget({ + maxAssets: positiveSafeUIntV2Schema, + maxInstances: positiveSafeUIntV2Schema, + maxEncodedBytes: positiveSafeUIntV2Schema, + maxNodes: positiveSafeUIntV2Schema, + maxPrimitives: positiveSafeUIntV2Schema, + maxVertices: positiveSafeUIntV2Schema, + maxTriangles: positiveSafeUIntV2Schema, + maxBonesPerSkin: positiveSafeUIntV2Schema, + maxMorphTargetsPerPrimitive: positiveSafeUIntV2Schema, + maxAnimationClipsPerAsset: positiveSafeUIntV2Schema, + }), + }), + localOverlaySupported: z.boolean(), +}); + +export type CapabilityProfileV2 = z.infer; diff --git a/packages/contracts/src/presentation/v2/common.ts b/packages/contracts/src/presentation/v2/common.ts new file mode 100644 index 0000000..1e74e77 --- /dev/null +++ b/packages/contracts/src/presentation/v2/common.ts @@ -0,0 +1,88 @@ +import * as z from "zod"; + +export const idV2Schema = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/); +export const contentHashV2Schema = z.string().regex(/^sha256:[0-9a-f]{64}$/); +export const finiteNumberV2Schema = z.number().finite(); +export const nonNegativeFiniteNumberV2Schema = finiteNumberV2Schema.min(0); +export const positiveFiniteNumberV2Schema = finiteNumberV2Schema.positive(); +export const unitIntervalV2Schema = finiteNumberV2Schema.min(0).max(1); +export const safeUIntV2Schema = z.int().min(0).max(Number.MAX_SAFE_INTEGER); +export const positiveSafeUIntV2Schema = z.int().min(1).max(Number.MAX_SAFE_INTEGER); +export const uint32V2Schema = z.int().min(0).max(4_294_967_295); + +export const vector2V2Schema = z.tuple([finiteNumberV2Schema, finiteNumberV2Schema]); +export const positiveVector2V2Schema = z.tuple([ + positiveFiniteNumberV2Schema, + positiveFiniteNumberV2Schema, +]); +export const vector3V2Schema = z.tuple([ + finiteNumberV2Schema, + finiteNumberV2Schema, + finiteNumberV2Schema, +]); +export const positiveVector3V2Schema = z.tuple([ + positiveFiniteNumberV2Schema, + positiveFiniteNumberV2Schema, + positiveFiniteNumberV2Schema, +]); +export const quaternionV2Schema = z.tuple([ + finiteNumberV2Schema, + finiteNumberV2Schema, + finiteNumberV2Schema, + finiteNumberV2Schema, +]); +export const transformV2Schema = z.strictObject({ + position: vector3V2Schema, + rotation: quaternionV2Schema, + scale: positiveVector3V2Schema, +}); +export const boundsV2Schema = z.strictObject({ + x: finiteNumberV2Schema, + y: finiteNumberV2Schema, + width: positiveFiniteNumberV2Schema, + height: positiveFiniteNumberV2Schema, +}); +export const srgbaColorV2Schema = z.strictObject({ + red: unitIntervalV2Schema, + green: unitIntervalV2Schema, + blue: unitIntervalV2Schema, + alpha: unitIntervalV2Schema, +}); +export const srgbColorV2Schema = z.strictObject({ + red: unitIntervalV2Schema, + green: unitIntervalV2Schema, + blue: unitIntervalV2Schema, +}); + +export const resourceOwnerV2Schema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("presentation") }), + z.strictObject({ kind: z.literal("group"), groupId: idV2Schema }), +]); +export const projectionAudienceV2Schema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("all") }), + z.strictObject({ kind: z.literal("role"), role: z.enum(["presenter", "viewer"]) }), +]); +export const spatialParentV2Schema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("stage") }), + z.strictObject({ kind: z.literal("node"), nodeId: idV2Schema }), + z.strictObject({ + kind: z.literal("anchor"), + target: z.enum(["head", "leftHand", "rightHand", "body"]), + owner: z.strictObject({ kind: z.literal("presenter") }), + followPosition: z.boolean(), + followRotation: z.boolean(), + }), +]); + +export const easingV2Schema = z.enum(["linear", "cubicIn", "cubicOut", "cubicInOut"]); +export const scalarV2Schema = z.union([z.null(), z.boolean(), finiteNumberV2Schema, z.string()]); +export const scalarTypeV2Schema = z.enum(["null", "boolean", "number", "string"]); +export const actionValueV2Schema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("literal"), value: scalarV2Schema }), + z.strictObject({ kind: z.literal("eventPayload"), field: idV2Schema }), + z.strictObject({ kind: z.literal("variable"), variableId: idV2Schema }), +]); + +export type ResourceOwnerV2 = z.infer; +export type ProjectionAudienceV2 = z.infer; +export type SpatialParentV2 = z.infer; diff --git a/packages/contracts/src/presentation/v2/definition.ts b/packages/contracts/src/presentation/v2/definition.ts new file mode 100644 index 0000000..240620f --- /dev/null +++ b/packages/contracts/src/presentation/v2/definition.ts @@ -0,0 +1,650 @@ +import * as z from "zod"; + +import { + actionValueV2Schema, + easingV2Schema, + finiteNumberV2Schema, + idV2Schema, + nonNegativeFiniteNumberV2Schema, + positiveFiniteNumberV2Schema, + positiveSafeUIntV2Schema, + positiveVector2V2Schema, + positiveVector3V2Schema, + projectionAudienceV2Schema, + quaternionV2Schema, + resourceOwnerV2Schema, + safeUIntV2Schema, + scalarTypeV2Schema, + scalarV2Schema, + spatialParentV2Schema, + srgbColorV2Schema, + srgbaColorV2Schema, + transformV2Schema, + uint32V2Schema, + unitIntervalV2Schema, + vector3V2Schema, +} from "./common"; +import { semanticTreeDefinitionV2Schema, surfaceSemanticOverrideV2Schema } from "./semantics"; + +const edgeInsetsSchema = z.strictObject({ + top: nonNegativeFiniteNumberV2Schema, + right: nonNegativeFiniteNumberV2Schema, + bottom: nonNegativeFiniteNumberV2Schema, + left: nonNegativeFiniteNumberV2Schema, +}); +const absolutePlacementSchema = z.strictObject({ + kind: z.literal("absolute"), + x: finiteNumberV2Schema, + y: finiteNumberV2Schema, + width: positiveFiniteNumberV2Schema, + height: positiveFiniteNumberV2Schema, +}); +const stackPlacementSchema = z.strictObject({ + kind: z.literal("stack"), + grow: nonNegativeFiniteNumberV2Schema, + width: positiveFiniteNumberV2Schema, + height: positiveFiniteNumberV2Schema, + alignSelf: z.enum(["auto", "start", "center", "end", "stretch"]), + margin: edgeInsetsSchema, +}); +const gridPlacementSchema = z.strictObject({ + kind: z.literal("grid"), + column: positiveSafeUIntV2Schema, + row: positiveSafeUIntV2Schema, + columnSpan: positiveSafeUIntV2Schema, + rowSpan: positiveSafeUIntV2Schema, + width: positiveFiniteNumberV2Schema, + height: positiveFiniteNumberV2Schema, + alignSelf: z.enum(["start", "center", "end", "stretch"]), + justifySelf: z.enum(["start", "center", "end", "stretch"]), + margin: edgeInsetsSchema, +}); +const placementSchema = z.discriminatedUnion("kind", [ + absolutePlacementSchema, + stackPlacementSchema, + gridPlacementSchema, +]); +const absoluteLayoutSchema = z.strictObject({ kind: z.literal("absolute") }); +const stackLayoutSchema = z.strictObject({ + kind: z.literal("stack"), + direction: z.enum(["horizontal", "vertical"]), + gap: nonNegativeFiniteNumberV2Schema, + padding: edgeInsetsSchema, + alignItems: z.enum(["start", "center", "end", "stretch"]), + justifyContent: z.enum(["start", "center", "end", "spaceBetween"]), +}); +const gridTrackSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("fixed"), size: positiveFiniteNumberV2Schema }), + z.strictObject({ kind: z.literal("fraction"), fraction: positiveFiniteNumberV2Schema }), +]); +const gridLayoutSchema = z.strictObject({ + kind: z.literal("grid"), + columns: z.array(gridTrackSchema).min(1), + rows: z.array(gridTrackSchema).min(1), + columnGap: nonNegativeFiniteNumberV2Schema, + rowGap: nonNegativeFiniteNumberV2Schema, + padding: edgeInsetsSchema, +}); +const frameLayoutSchema = z.discriminatedUnion("kind", [ + absoluteLayoutSchema, + stackLayoutSchema, + gridLayoutSchema, +]); +const borderSchema = z.strictObject({ + color: srgbaColorV2Schema, + width: nonNegativeFiniteNumberV2Schema, + radius: nonNegativeFiniteNumberV2Schema, +}); +const commonContent = { + id: idV2Schema, + parentId: idV2Schema.nullable(), + order: uint32V2Schema, + semanticNodeId: idV2Schema.optional(), + visible: z.boolean(), + opacity: unitIntervalV2Schema, +}; +const placedContent = { ...commonContent, placement: placementSchema }; +const frameContentSchema = z.strictObject({ + ...placedContent, + kind: z.literal("frame"), + children: z.array(idV2Schema), + layout: frameLayoutSchema, + backgroundColor: srgbaColorV2Schema, + border: borderSchema, + clip: z.boolean(), +}); +const textStyleSchema = z.strictObject({ + fontAssetId: idV2Schema, + fallbackFontAssetIds: z.array(idV2Schema), + fontSize: positiveFiniteNumberV2Schema, + lineHeight: positiveFiniteNumberV2Schema, + color: srgbaColorV2Schema, + weight: z.enum(["regular", "bold"]), + align: z.enum(["start", "center", "end"]), + overflow: z.enum(["clip", "ellipsis"]), +}); +const textContentSchema = z.strictObject({ + ...placedContent, + kind: z.literal("text"), + value: z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("literal"), value: z.string() }), + z.strictObject({ + kind: z.literal("variableString"), + variableId: idV2Schema, + expectedType: z.literal("string"), + format: z.strictObject({ + kind: z.literal("string"), + allowedCodePointRanges: z + .array(z.tuple([uint32V2Schema.max(1_114_111), uint32V2Schema.max(1_114_111)])) + .min(1), + }), + }), + z.strictObject({ + kind: z.literal("variableBoolean"), + variableId: idV2Schema, + expectedType: z.literal("boolean"), + format: z.strictObject({ + kind: z.literal("boolean"), + trueLabel: z.string(), + falseLabel: z.string(), + }), + }), + z.strictObject({ + kind: z.literal("variableNumber"), + variableId: idV2Schema, + expectedType: z.literal("number"), + format: z.strictObject({ + kind: z.literal("number"), + fractionDigits: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]), + }), + }), + z.strictObject({ + kind: z.literal("stepTimerRemaining"), + groupId: idV2Schema, + stepId: idV2Schema, + cueId: idV2Schema, + durationMilliseconds: positiveSafeUIntV2Schema, + whenStepInactive: z.enum(["empty", "zero"]), + format: z.enum(["mm:ss", "hh:mm:ss"]), + }), + ]), + maxCodePoints: positiveSafeUIntV2Schema, + style: textStyleSchema, +}); +const imageStyleSchema = z.strictObject({ + fit: z.enum(["contain", "cover", "stretch"]), + tint: srgbaColorV2Schema, + border: borderSchema, +}); +const imageContentSchema = z.strictObject({ + ...placedContent, + kind: z.literal("image"), + assetId: idV2Schema, + style: imageStyleSchema, +}); +const shapeGeometrySchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("rectangle"), + width: positiveFiniteNumberV2Schema, + height: positiveFiniteNumberV2Schema, + radius: nonNegativeFiniteNumberV2Schema, + }), + z.strictObject({ + kind: z.literal("ellipse"), + width: positiveFiniteNumberV2Schema, + height: positiveFiniteNumberV2Schema, + }), + z.strictObject({ + kind: z.literal("line"), + endX: finiteNumberV2Schema, + endY: finiteNumberV2Schema, + }), +]); +const shapeStyleSchema = z.strictObject({ + fill: srgbaColorV2Schema, + stroke: srgbaColorV2Schema, + strokeWidth: nonNegativeFiniteNumberV2Schema, +}); +const shapeContentSchema = z.strictObject({ + ...placedContent, + kind: z.literal("shape"), + geometry: shapeGeometrySchema, + style: shapeStyleSchema, +}); +const videoStyleSchema = z.strictObject({ + fit: z.enum(["contain", "cover", "stretch"]), + tint: srgbaColorV2Schema, + border: borderSchema, +}); +const videoContentSchema = z.strictObject({ + ...placedContent, + kind: z.literal("video"), + assetId: idV2Schema, + loop: z.boolean(), + style: videoStyleSchema, +}); +const surfaceContentNodeSchema = z.discriminatedUnion("kind", [ + frameContentSchema, + textContentSchema, + imageContentSchema, + shapeContentSchema, + videoContentSchema, +]); + +const commonOverride = { + visible: z.boolean().optional(), + opacity: unitIntervalV2Schema.optional(), + placement: placementSchema.optional(), +}; +const contentOverrideSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + ...commonOverride, + kind: z.literal("frame"), + layout: frameLayoutSchema.optional(), + backgroundColor: srgbaColorV2Schema.optional(), + border: borderSchema.optional(), + clip: z.boolean().optional(), + }), + z.strictObject({ + ...commonOverride, + kind: z.literal("text"), + value: textContentSchema.shape.value.optional(), + style: textStyleSchema.optional(), + }), + z.strictObject({ + ...commonOverride, + kind: z.literal("image"), + assetId: idV2Schema.optional(), + style: imageStyleSchema.optional(), + }), + z.strictObject({ + ...commonOverride, + kind: z.literal("shape"), + geometry: shapeGeometrySchema.optional(), + style: shapeStyleSchema.optional(), + }), + z.strictObject({ + ...commonOverride, + kind: z.literal("video"), + style: videoStyleSchema.optional(), + }), +]); + +const triggerActorSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("presenter") }), + z.strictObject({ + kind: z.literal("system"), + source: z.enum(["tracking", "timer", "timeline", "media", "runtime"]).optional(), + }), +]); +const trackedSubjectSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("participant"), + owner: z.strictObject({ kind: z.literal("presenter") }), + }), + z.strictObject({ + kind: z.literal("anchor"), + owner: z.strictObject({ kind: z.literal("presenter") }), + target: z.enum(["head", "leftHand", "rightHand", "body"]), + }), +]); +const triggerSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("logicalInput"), + action: idV2Schema, + actor: triggerActorSchema, + }), + z.strictObject({ + kind: z.literal("semanticEvent"), + event: idV2Schema, + actor: triggerActorSchema, + }), + z.strictObject({ + kind: z.literal("surfaceInteraction"), + actor: triggerActorSchema, + surfaceId: idV2Schema, + interactionId: idV2Schema, + }), + z.strictObject({ + kind: z.literal("zoneEdge"), + actor: z.strictObject({ kind: z.literal("system"), source: z.literal("tracking") }), + subject: trackedSubjectSchema, + zoneId: idV2Schema, + edge: z.enum(["enter", "exit"]), + dwellMilliseconds: safeUIntV2Schema.optional(), + hysteresisMeters: nonNegativeFiniteNumberV2Schema.optional(), + }), + z.strictObject({ + kind: z.literal("motion"), + actor: z.strictObject({ kind: z.literal("system"), source: z.literal("tracking") }), + subject: trackedSubjectSchema, + minimumDistanceMeters: positiveFiniteNumberV2Schema, + windowMilliseconds: positiveSafeUIntV2Schema, + }), + z.strictObject({ kind: z.literal("timer"), afterMilliseconds: positiveSafeUIntV2Schema }), + z.strictObject({ kind: z.literal("timelineCompleted"), timelineId: idV2Schema }), + z.strictObject({ kind: z.literal("mediaCompleted"), surfaceId: idV2Schema }), + z.strictObject({ kind: z.literal("modelClipCompleted"), nodeId: idV2Schema, clipId: idV2Schema }), +]); +const valueReferenceSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("variable"), variableId: idV2Schema }), + z.strictObject({ kind: z.literal("eventPayload"), field: idV2Schema }), + z.strictObject({ kind: z.literal("surfaceState"), surfaceId: idV2Schema }), + z.strictObject({ + kind: z.literal("nodeField"), + nodeId: idV2Schema, + field: z.enum(["active", "visible", "opacity"]), + }), +]); +export type GuardV2 = + | { kind: "all"; guards: GuardV2[] } + | { kind: "any"; guards: GuardV2[] } + | { kind: "not"; guard: GuardV2 } + | { + kind: "compare"; + left: z.infer; + operator: "eq" | "neq" | "gt" | "gte" | "lt" | "lte"; + right: z.infer; + }; +const guardSchema: z.ZodType = z.lazy(() => + z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("all"), guards: z.array(guardSchema).min(1) }), + z.strictObject({ kind: z.literal("any"), guards: z.array(guardSchema).min(1) }), + z.strictObject({ kind: z.literal("not"), guard: guardSchema }), + z.strictObject({ + kind: z.literal("compare"), + left: valueReferenceSchema, + operator: z.enum(["eq", "neq", "gt", "gte", "lt", "lte"]), + right: scalarV2Schema, + }), + ]), +); +const booleanActionValueSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("literal"), value: z.boolean() }), + z.strictObject({ kind: z.literal("eventPayload"), field: idV2Schema }), + z.strictObject({ kind: z.literal("variable"), variableId: idV2Schema }), +]); +const numberActionValueSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("literal"), value: finiteNumberV2Schema }), + z.strictObject({ kind: z.literal("eventPayload"), field: idV2Schema }), + z.strictObject({ kind: z.literal("variable"), variableId: idV2Schema }), +]); +const nodePatchSchema = z.strictObject({ + active: booleanActionValueSchema.optional(), + visible: booleanActionValueSchema.optional(), + opacity: numberActionValueSchema.optional(), + transform: transformV2Schema.optional(), +}); +const actionSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("surface.setState"), + surfaceId: idV2Schema, + stateId: idV2Schema, + transition: z + .discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("cut") }), + z.strictObject({ + kind: z.literal("crossfade"), + durationMilliseconds: positiveSafeUIntV2Schema, + easing: easingV2Schema, + completion: z.literal("blocking"), + }), + ]) + .optional(), + }), + z.strictObject({ kind: z.literal("node.patch"), nodeId: idV2Schema, patch: nodePatchSchema }), + z.strictObject({ + kind: z.literal("timeline.play"), + timelineId: idV2Schema, + completion: z.enum(["blocking", "nonBlocking"]), + conflict: z.literal("reject"), + }), + z.strictObject({ kind: z.literal("timeline.stop"), timelineId: idV2Schema }), + z.strictObject({ + kind: z.literal("variable.set"), + variableId: idV2Schema, + value: actionValueV2Schema, + }), + z.strictObject({ kind: z.literal("media.play"), surfaceId: idV2Schema }), + z.strictObject({ kind: z.literal("media.pause"), surfaceId: idV2Schema }), + z.strictObject({ + kind: z.literal("media.seek"), + surfaceId: idV2Schema, + positionSeconds: numberActionValueSchema, + }), + z.strictObject({ + kind: z.literal("modelClip.play"), + nodeId: idV2Schema, + clipId: idV2Schema, + speed: positiveFiniteNumberV2Schema, + loop: z.boolean(), + completion: z.enum(["blocking", "nonBlocking"]), + transition: z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("immediate") }), + z.strictObject({ + kind: z.literal("crossfade"), + durationMilliseconds: positiveSafeUIntV2Schema, + easing: easingV2Schema, + }), + ]), + conflict: z.literal("reject"), + }), + z.strictObject({ kind: z.literal("modelClip.pause"), nodeId: idV2Schema }), + z.strictObject({ kind: z.literal("modelClip.resume"), nodeId: idV2Schema }), + z.strictObject({ kind: z.literal("modelClip.stop"), nodeId: idV2Schema }), +]); + +const spatialBase = { + id: idV2Schema, + name: z.string().min(1).optional(), + owner: resourceOwnerV2Schema, + audience: projectionAudienceV2Schema, + parent: spatialParentV2Schema, + order: uint32V2Schema, + transform: transformV2Schema, + active: z.boolean(), + visible: z.boolean(), + opacity: unitIntervalV2Schema, +}; +const spatialNodeSchema = z.discriminatedUnion("kind", [ + z.strictObject({ ...spatialBase, kind: z.literal("container") }), + z.strictObject({ ...spatialBase, kind: z.literal("model"), assetId: idV2Schema }), + z.strictObject({ ...spatialBase, kind: z.literal("surface"), surfaceId: idV2Schema }), + z.strictObject({ + ...spatialBase, + kind: z.literal("shape"), + geometry: z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("box"), size: positiveVector3V2Schema }), + z.strictObject({ kind: z.literal("sphere"), radius: positiveFiniteNumberV2Schema }), + ]), + material: z.strictObject({ + shaderModel: z.literal("unlit"), + color: srgbaColorV2Schema, + doubleSided: z.boolean(), + castsShadows: z.literal(false), + receivesShadows: z.literal(false), + }), + }), + z.strictObject({ + ...spatialBase, + kind: z.literal("light"), + light: z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("directional"), + color: srgbColorV2Schema, + intensityLux: nonNegativeFiniteNumberV2Schema, + castsShadows: z.boolean(), + }), + z.strictObject({ + kind: z.literal("point"), + color: srgbColorV2Schema, + intensityCandela: nonNegativeFiniteNumberV2Schema, + rangeMeters: positiveFiniteNumberV2Schema, + castsShadows: z.boolean(), + }), + z.strictObject({ + kind: z.literal("spot"), + color: srgbColorV2Schema, + intensityCandela: nonNegativeFiniteNumberV2Schema, + rangeMeters: positiveFiniteNumberV2Schema, + outerAngleDegrees: positiveFiniteNumberV2Schema.max(180), + innerAngleDegrees: nonNegativeFiniteNumberV2Schema.max(180), + castsShadows: z.boolean(), + }), + ]), + }), +]); +const renderIntentSchema = z.strictObject({ + updateModel: z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("static") }), + z.strictObject({ kind: z.literal("finite-state"), stateIds: z.array(idV2Schema).min(1) }), + z.strictObject({ + kind: z.literal("continuous-native-text"), + maximumUpdateRateHz: positiveFiniteNumberV2Schema, + }), + ]), + interaction: z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("none") }), + z.strictObject({ kind: z.literal("regions"), events: z.array(idV2Schema).min(1) }), + ]), + internalAnimation: z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("none") }), + z.strictObject({ + kind: z.literal("precomputed-video"), + durationMilliseconds: positiveSafeUIntV2Schema, + }), + ]), + rendererPreference: z.enum(["auto", "baked-web", "native-ui", "video"]), + fallbackPolicy: z.enum(["reject", "degrade"]), +}); +const semanticSurfaceSchema = z.strictObject({ + id: idV2Schema, + hostNodeId: idV2Schema, + physicalSizeMeters: positiveVector2V2Schema, + logicalSize: positiveVector2V2Schema, + fit: z.enum(["contain", "cover", "stretch"]), + rootFrameId: idV2Schema, + contentNodes: z.record(idV2Schema, surfaceContentNodeSchema), + baseSemanticTree: semanticTreeDefinitionV2Schema, + interactions: z.record( + idV2Schema, + z.strictObject({ + id: idV2Schema, + kind: z.literal("click"), + event: idV2Schema, + hitPriority: uint32V2Schema, + }), + ), + initialStateId: idV2Schema, + states: z.record( + idV2Schema, + z.strictObject({ + id: idV2Schema, + contentOverrides: z.record(idV2Schema, contentOverrideSchema), + semanticOverrides: z.array(surfaceSemanticOverrideV2Schema), + enabledInteractionIds: z.array(idV2Schema), + }), + ), + renderIntent: renderIntentSchema, +}); +const timelineValueSchema = z.union([finiteNumberV2Schema, vector3V2Schema, quaternionV2Schema]); +const timelineSchema = z.strictObject({ + id: idV2Schema, + owner: resourceOwnerV2Schema, + durationMilliseconds: positiveSafeUIntV2Schema, + tracks: z + .array( + z.strictObject({ + target: z.strictObject({ + nodeId: idV2Schema, + property: z.enum([ + "opacity", + "transform.position", + "transform.rotation", + "transform.scale", + ]), + }), + keyframes: z + .array( + z.strictObject({ + timeMilliseconds: safeUIntV2Schema, + value: timelineValueSchema, + easingToNext: easingV2Schema.optional(), + }), + ) + .min(2), + }), + ) + .min(1), +}); +const variableSchema = z.strictObject({ + id: idV2Schema, + owner: resourceOwnerV2Schema, + type: scalarTypeV2Schema, + initialValue: scalarV2Schema, +}); +const cueSchema = z.strictObject({ + id: idV2Schema, + priority: safeUIntV2Schema, + order: uint32V2Schema, + trigger: triggerSchema, + fixedPayload: z.record(idV2Schema, scalarV2Schema).optional(), + guard: guardSchema.optional(), + firePolicy: z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("oncePerStepEntry") }), + z.strictObject({ + kind: z.literal("repeatable"), + cooldownMilliseconds: safeUIntV2Schema, + }), + ]), + actions: z.array(actionSchema), + next: z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("stay") }), + z.strictObject({ kind: z.literal("step"), stepId: idV2Schema }), + z.strictObject({ kind: z.literal("group"), groupId: idV2Schema }), + z.strictObject({ kind: z.literal("end") }), + ]), +}); + +export const presentationDefinitionV2Schema = z.strictObject({ + schemaVersion: z.literal(2), + presentationId: idV2Schema, + metadata: z.strictObject({ title: z.string().min(1) }), + stage: z.strictObject({ + coordinateSystem: z.strictObject({ + unit: z.literal("meter"), + handedness: z.literal("right"), + upAxis: z.literal("+Y"), + forwardAxis: z.literal("-Z"), + }), + size: positiveVector3V2Schema, + zones: z.record( + idV2Schema, + z.strictObject({ + id: idV2Schema, + owner: resourceOwnerV2Schema, + center: vector3V2Schema, + size: positiveVector3V2Schema, + }), + ), + }), + scene: z.strictObject({ + nodes: z.record(idV2Schema, spatialNodeSchema), + surfaces: z.record(idV2Schema, semanticSurfaceSchema), + }), + flow: z.strictObject({ + initialGroupId: idV2Schema, + groups: z.record( + idV2Schema, + z.strictObject({ + id: idV2Schema, + initialStepId: idV2Schema, + steps: z.record(idV2Schema, z.strictObject({ id: idV2Schema, cues: z.array(cueSchema) })), + }), + ), + variables: z.record(idV2Schema, variableSchema), + timelines: z.record(idV2Schema, timelineSchema), + }), +}); + +export type PresentationDefinitionV2 = z.infer; diff --git a/packages/contracts/src/presentation/v2/index.ts b/packages/contracts/src/presentation/v2/index.ts new file mode 100644 index 0000000..3846464 --- /dev/null +++ b/packages/contracts/src/presentation/v2/index.ts @@ -0,0 +1,7 @@ +export * from "./asset-set"; +export * from "./capability"; +export * from "./common"; +export * from "./definition"; +export * from "./publication"; +export * from "./render-bundle"; +export * from "./semantics"; diff --git a/packages/contracts/src/presentation/v2/publication.ts b/packages/contracts/src/presentation/v2/publication.ts new file mode 100644 index 0000000..23e265d --- /dev/null +++ b/packages/contracts/src/presentation/v2/publication.ts @@ -0,0 +1,50 @@ +import * as z from "zod"; + +import { + contentHashV2Schema, + idV2Schema, + positiveSafeUIntV2Schema, + safeUIntV2Schema, +} from "./common"; + +export const contractVersionsV2Schema = z.strictObject({ + definition: z.literal(2), + renderBundle: z.literal(2), + assetSet: z.literal(2), + delivery: z.literal(2), + runtime: z.literal(2), + progression: z.literal(1), + projection: z.literal(1), +}); +export const publicationFenceV2Schema = z.strictObject({ + presentationId: idV2Schema, + publicationEpoch: positiveSafeUIntV2Schema, + publicationManifestHash: contentHashV2Schema, +}); + +export const buildManifestV2Schema = z.strictObject({ + schemaVersion: z.literal(2), + buildId: idV2Schema, + presentationId: idV2Schema, + sourceDraftRevision: safeUIntV2Schema, + definitionHash: contentHashV2Schema, + renderBundleHash: contentHashV2Schema, + assetSetHash: contentHashV2Schema, + contractVersions: contractVersionsV2Schema, +}); + +export const publishedPresentationV2Schema = z.strictObject({ + schemaVersion: z.literal(2), + presentationId: idV2Schema, + publicationEpoch: positiveSafeUIntV2Schema, + publicationManifestHash: contentHashV2Schema, + sourceDraftRevision: safeUIntV2Schema, + buildId: idV2Schema, + definitionHash: contentHashV2Schema, + renderBundleHash: contentHashV2Schema, + assetSetHash: contentHashV2Schema, + contractVersions: contractVersionsV2Schema, +}); + +export type BuildManifestV2 = z.infer; +export type PublishedPresentationV2 = z.infer; diff --git a/packages/contracts/src/presentation/v2/render-bundle.ts b/packages/contracts/src/presentation/v2/render-bundle.ts new file mode 100644 index 0000000..5df65db --- /dev/null +++ b/packages/contracts/src/presentation/v2/render-bundle.ts @@ -0,0 +1,262 @@ +import * as z from "zod"; + +import { + boundsV2Schema, + contentHashV2Schema, + finiteNumberV2Schema, + idV2Schema, + nonNegativeFiniteNumberV2Schema, + positiveFiniteNumberV2Schema, + positiveSafeUIntV2Schema, + positiveVector2V2Schema, + safeUIntV2Schema, + srgbaColorV2Schema, + uint32V2Schema, +} from "./common"; +import { completedSemanticTreeV2Schema } from "./semantics"; + +const requiredBakedFeatureSchema = z.enum(["alpha-opaque", "alpha-straight", "png", "srgb"]); +const requiredNativeFeatureSchema = z.enum(["clip", "ellipsis", "explicitFontFallback"]); +const requiredVideoFeatureSchema = z.enum(["alpha", "audio", "h264", "vp9", "av1"]); +const textureSchema = z.strictObject({ + assetId: idV2Schema, + mediaType: z.literal("image/png"), + pixelSize: z.tuple([positiveSafeUIntV2Schema, positiveSafeUIntV2Schema]), + checksum: contentHashV2Schema, + encodedSizeBytes: safeUIntV2Schema, + colorSpace: z.literal("srgb"), + alphaMode: z.enum(["opaque", "straight"]), + mipCount: z.literal(1), + gpuBytes: safeUIntV2Schema, +}); +const bakedArtifactSchema = z.strictObject({ + id: idV2Schema, + kind: z.literal("baked-web"), + contractVersion: z.literal(1), + requiredFeatures: z.array(requiredBakedFeatureSchema), + states: z.record(idV2Schema, z.strictObject({ stateId: idV2Schema, texture: textureSchema })), +}); +const codePointRangeSchema = z.tuple([ + uint32V2Schema.max(1_114_111), + uint32V2Schema.max(1_114_111), +]); +const fontFaceSchema = z.strictObject({ + assetId: idV2Schema, + supportedCodePointRanges: z.array(codePointRangeSchema).min(1), +}); +const fontSchema = z.strictObject({ primary: fontFaceSchema, fallbacks: z.array(fontFaceSchema) }); +const nativeTextValueSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("literal"), value: z.string() }), + z.strictObject({ + kind: z.literal("variableString"), + variableId: idV2Schema, + expectedType: z.literal("string"), + format: z.strictObject({ + kind: z.literal("string"), + allowedCodePointRanges: z.array(codePointRangeSchema).min(1), + }), + }), + z.strictObject({ + kind: z.literal("variableBoolean"), + variableId: idV2Schema, + expectedType: z.literal("boolean"), + format: z.strictObject({ + kind: z.literal("boolean"), + trueLabel: z.string(), + falseLabel: z.string(), + }), + }), + z.strictObject({ + kind: z.literal("variableNumber"), + variableId: idV2Schema, + expectedType: z.literal("number"), + format: z.strictObject({ + kind: z.literal("number"), + fractionDigits: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]), + }), + }), + z.strictObject({ + kind: z.literal("stepTimerRemaining"), + groupId: idV2Schema, + stepId: idV2Schema, + cueId: idV2Schema, + durationMilliseconds: positiveSafeUIntV2Schema, + whenStepInactive: z.enum(["empty", "zero"]), + format: z.enum(["mm:ss", "hh:mm:ss"]), + }), +]); +const nativeRectSchema = z.strictObject({ + x: finiteNumberV2Schema, + y: finiteNumberV2Schema, + width: nonNegativeFiniteNumberV2Schema, + height: nonNegativeFiniteNumberV2Schema, + coordinateSpace: z.literal("renderSurfaceLogical"), +}); +const nativeNodeSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("group"), + id: idV2Schema, + bounds: nativeRectSchema, + clip: z.boolean(), + children: z.array(idV2Schema), + }), + z.strictObject({ + kind: z.literal("text"), + id: idV2Schema, + bounds: nativeRectSchema, + semanticNodeId: idV2Schema, + value: nativeTextValueSchema, + font: fontSchema, + color: srgbaColorV2Schema, + fontSize: positiveFiniteNumberV2Schema, + lineHeight: positiveFiniteNumberV2Schema, + align: z.enum(["start", "center", "end"]), + overflow: z.enum(["clip", "ellipsis"]), + maxCodePoints: positiveSafeUIntV2Schema, + }), +]); +const nativeArtifactSchema = z.strictObject({ + id: idV2Schema, + kind: z.literal("native-ui"), + contractVersion: z.literal(1), + requiredFeatures: z.array(requiredNativeFeatureSchema), + rootNodeId: idV2Schema, + nodes: z.record(idV2Schema, nativeNodeSchema), +}); +const videoArtifactSchema = z.strictObject({ + id: idV2Schema, + kind: z.literal("video"), + contractVersion: z.literal(1), + requiredFeatures: z.array(requiredVideoFeatureSchema), + assetId: idV2Schema, + checksum: contentHashV2Schema, + encodedSizeBytes: safeUIntV2Schema, + mediaType: z.literal("video/mp4"), + codec: z.enum(["h264", "vp9", "av1"]), + durationMilliseconds: positiveSafeUIntV2Schema, + loop: z.boolean(), + alpha: z.boolean(), + audio: z.boolean(), + pixelSize: z.tuple([positiveSafeUIntV2Schema, positiveSafeUIntV2Schema]), +}); +const artifactSchema = z.discriminatedUnion("kind", [ + bakedArtifactSchema, + nativeArtifactSchema, + videoArtifactSchema, +]); +const bindingSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("empty") }), + z.strictObject({ kind: z.literal("artifacts"), artifactIds: z.array(idV2Schema).min(1) }), +]); +const regionSchema = z.strictObject({ + interactionId: idV2Schema, + semanticNodeId: idV2Schema, + bounds: z.strictObject({ + x: finiteNumberV2Schema.min(0).max(1), + y: finiteNumberV2Schema.min(0).max(1), + width: positiveFiniteNumberV2Schema.max(1), + height: positiveFiniteNumberV2Schema.max(1), + }), + coordinateSpace: z.literal("normalized"), + priority: uint32V2Schema, +}); +const renderSurfaceSchema = z.strictObject({ + id: idV2Schema, + semanticSurfaceId: idV2Schema, + logicalBounds: boundsV2Schema, + layer: uint32V2Schema, + artifacts: z.record(idV2Schema, artifactSchema), + stateBindings: z.record(idV2Schema, bindingSchema), +}); +const compiledSurfaceSchema = z.strictObject({ + semanticSurfaceId: idV2Schema, + logicalSize: positiveVector2V2Schema, + physicalSizeMeters: positiveVector2V2Schema, + renderSurfaceIds: z.array(idV2Schema).min(1), + renderSurfaces: z.record(idV2Schema, renderSurfaceSchema), + semanticsByState: z.record(idV2Schema, completedSemanticTreeV2Schema), + interactionsByState: z.record(idV2Schema, z.array(regionSchema)), +}); +const compiledModelSchema = z.strictObject({ + assetId: idV2Schema, + mediaType: z.literal("model/gltf-binary"), + checksum: contentHashV2Schema, + encodedSizeBytes: safeUIntV2Schema, + nodeCount: safeUIntV2Schema, + primitiveCount: safeUIntV2Schema, + vertexCount: safeUIntV2Schema, + triangleCount: safeUIntV2Schema, + maxBonesPerSkin: safeUIntV2Schema, + maxMorphTargetsPerPrimitive: safeUIntV2Schema, + animationClipCount: safeUIntV2Schema, + requiredFeatures: z.array( + z.enum([ + "alphaBlend", + "alphaMask", + "animation", + "morphTargets", + "pbrMetallicRoughness", + "skin", + "unlit", + ]), + ), + rootMotion: z.literal("removed"), + materials: z.record( + idV2Schema, + z.strictObject({ + sourceMaterialIndex: safeUIntV2Schema, + shaderModel: z.enum(["pbrMetallicRoughness", "unlit"]), + alphaMode: z.enum(["opaque", "mask", "blend"]), + alphaCutoff: z.number().min(0).max(1).optional(), + doubleSided: z.boolean(), + }), + ), + clips: z.record( + idV2Schema, + z.strictObject({ + sourceAnimationIndex: uint32V2Schema, + durationMilliseconds: positiveSafeUIntV2Schema, + }), + ), +}); + +export const renderBundleV2Schema = z.strictObject({ + schemaVersion: z.literal(2), + bundleId: idV2Schema, + sourceHash: contentHashV2Schema, + definitionHash: contentHashV2Schema, + compiler: z.strictObject({ + name: idV2Schema, + version: idV2Schema, + environmentHash: contentHashV2Schema, + }), + buildContext: z.strictObject({ + locale: idV2Schema, + timezone: idV2Schema, + colorScheme: z.enum(["light", "dark"]), + themeId: idV2Schema, + themeHash: contentHashV2Schema, + textureBuildPolicy: z.strictObject({ + policyVersion: z.literal(1), + resolutionPolicyVersion: z.literal(1), + policyHash: contentHashV2Schema, + longEdgePixels: positiveSafeUIntV2Schema, + maxStatesPerRenderSurface: positiveSafeUIntV2Schema, + maxRenderSurfacesPerSemanticSurface: positiveSafeUIntV2Schema, + maxRenderSurfacesPerBundle: positiveSafeUIntV2Schema, + maxTextureBindings: positiveSafeUIntV2Schema, + maxTextureWidth: positiveSafeUIntV2Schema, + maxTextureHeight: positiveSafeUIntV2Schema, + maxTexturePixels: positiveSafeUIntV2Schema, + maxRenderedPixels: positiveSafeUIntV2Schema, + maxSurfaceCaptureBytes: positiveSafeUIntV2Schema, + maxBuildOutputBytes: positiveSafeUIntV2Schema, + maxBuildAccountedPeakBytes: positiveSafeUIntV2Schema, + rendererConcurrency: z.literal(1), + }), + }), + surfaces: z.record(idV2Schema, compiledSurfaceSchema), + models: z.record(idV2Schema, compiledModelSchema), +}); + +export type RenderBundleV2 = z.infer; diff --git a/packages/contracts/src/presentation/v2/semantics.ts b/packages/contracts/src/presentation/v2/semantics.ts new file mode 100644 index 0000000..a5eb89c --- /dev/null +++ b/packages/contracts/src/presentation/v2/semantics.ts @@ -0,0 +1,101 @@ +import * as z from "zod"; + +import { idV2Schema, uint32V2Schema } from "./common"; + +const base = { id: idV2Schema, parentId: idV2Schema.nullable(), order: uint32V2Schema }; +const language = { language: z.string().min(1).optional() }; +const text = { text: z.string().min(1), ...language }; + +export const semanticNodeDefinitionV2Schema = z.discriminatedUnion("role", [ + z.strictObject({ + ...base, + role: z.literal("heading"), + level: z.union([ + z.literal(1), + z.literal(2), + z.literal(3), + z.literal(4), + z.literal(5), + z.literal(6), + ]), + ...text, + }), + z.strictObject({ ...base, role: z.literal("paragraph"), ...text }), + z.strictObject({ ...base, role: z.literal("image"), alt: z.string().min(1), ...language }), + z.strictObject({ ...base, role: z.literal("button"), interactionId: idV2Schema, ...text }), + z.strictObject({ ...base, role: z.literal("list"), ordered: z.boolean() }), + z.strictObject({ ...base, role: z.literal("listItem"), ...text }), + z.strictObject({ + ...base, + role: z.literal("table"), + label: z.string().min(1).optional(), + ...language, + }), + z.strictObject({ ...base, role: z.literal("row") }), + z.strictObject({ ...base, role: z.literal("cell"), ...text }), + z.strictObject({ ...base, role: z.literal("columnHeader"), ...text }), + z.strictObject({ ...base, role: z.literal("rowHeader"), ...text }), +]); + +export const semanticTreeDefinitionV2Schema = z.strictObject({ + rootNodeIds: z.array(idV2Schema), + nodes: z.record(idV2Schema, semanticNodeDefinitionV2Schema), +}); + +export const completedSemanticNodeV2Schema = z.discriminatedUnion("role", [ + z.strictObject({ + ...base, + role: z.literal("heading"), + level: z.union([ + z.literal(1), + z.literal(2), + z.literal(3), + z.literal(4), + z.literal(5), + z.literal(6), + ]), + ...text, + }), + z.strictObject({ ...base, role: z.literal("paragraph"), ...text }), + z.strictObject({ ...base, role: z.literal("image"), alt: z.string().min(1), ...language }), + z.strictObject({ + ...base, + role: z.literal("button"), + interactionId: idV2Schema, + stateEnabled: z.boolean(), + ...text, + }), + z.strictObject({ ...base, role: z.literal("list"), ordered: z.boolean() }), + z.strictObject({ ...base, role: z.literal("listItem"), ...text }), + z.strictObject({ + ...base, + role: z.literal("table"), + label: z.string().min(1).optional(), + ...language, + }), + z.strictObject({ ...base, role: z.literal("row") }), + z.strictObject({ ...base, role: z.literal("cell"), ...text }), + z.strictObject({ ...base, role: z.literal("columnHeader"), ...text }), + z.strictObject({ ...base, role: z.literal("rowHeader"), ...text }), +]); +export const completedSemanticTreeV2Schema = z.strictObject({ + rootNodeIds: z.array(idV2Schema), + nodes: z.record(idV2Schema, completedSemanticNodeV2Schema), +}); + +export const surfaceSemanticOverrideV2Schema = z.strictObject({ + nodes: z.record( + idV2Schema, + z.strictObject({ + included: z.boolean().optional(), + text: z.string().min(1).optional(), + language: z.string().min(1).nullable().optional(), + alt: z.string().min(1).optional(), + label: z.string().min(1).nullable().optional(), + }), + ), +}); + +export type SemanticNodeDefinitionV2 = z.infer; +export type SemanticTreeDefinitionV2 = z.infer; +export type CompletedSemanticTreeV2 = z.infer; diff --git a/packages/contracts/test/presentation-v2.contract.test.ts b/packages/contracts/test/presentation-v2.contract.test.ts new file mode 100644 index 0000000..7516b4e --- /dev/null +++ b/packages/contracts/test/presentation-v2.contract.test.ts @@ -0,0 +1,261 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { test } from "node:test"; +import Ajv2020 from "ajv/dist/2020.js"; +import * as z from "zod"; + +import { + assetSetManifestV2Schema, + buildManifestV2Schema, + capabilityProfileV2Schema, + presentationDefinitionV2Schema, + publishedPresentationV2Schema, + renderBundleV2Schema, +} from "../src/presentation/v2/index"; + +const root = resolve(import.meta.dirname, "../presentation/v2"); +const schemas: ReadonlyArray = [ + ["presentation-definition", presentationDefinitionV2Schema], + ["render-bundle", renderBundleV2Schema], + ["asset-set-manifest", assetSetManifestV2Schema], + ["build-manifest", buildManifestV2Schema], + ["published-presentation", publishedPresentationV2Schema], + ["capability-profile", capabilityProfileV2Schema], +]; +const ajv = new Ajv2020({ allErrors: true, strict: true, allowUnionTypes: true }); +const fixture = async (name: string) => + JSON.parse(await readFile(resolve(root, "fixtures", `${name}.json`), "utf8")); + +for (const [name, schema] of schemas) { + const jsonSchema = JSON.parse(await readFile(resolve(root, `${name}.schema.json`), "utf8")); + const validate = ajv.compile(jsonSchema); + const sample = await fixture(name); + test(`${name}: Zod and portable JSON Schema accept the complete fixture`, () => { + const result = schema.safeParse(sample); + assert.equal(result.success, true, result.success ? undefined : result.error.message); + assert.equal(validate(sample), true, ajv.errorsText(validate.errors)); + }); + test(`${name}: both schemas reject wrong major and unknown transport fields`, () => { + for (const invalid of [ + { ...sample, schemaVersion: 1 }, + { ...sample, sessionId: "session" }, + ]) { + assert.equal(schema.safeParse(invalid).success, false); + assert.equal(validate(invalid), false); + } + }); +} + +const definition = await fixture("presentation-definition"); +const validateDefinition = ajv.getSchema( + "https://contracts.unframe.dev/presentation/presentation-definition.v2.schema.json", +)!; +const bundle = await fixture("render-bundle"); +const validateBundle = ajv.getSchema( + "https://contracts.unframe.dev/presentation/render-bundle.v2.schema.json", +)!; + +function rejectsDefinition(change: (value: typeof definition) => void): void { + const invalid = structuredClone(definition); + change(invalid); + assert.equal(presentationDefinitionV2Schema.safeParse(invalid).success, false); + assert.equal(validateDefinition(invalid), false); +} + +test("scope excludes WebView, standalone audio and animation layers", () => { + for (const kind of ["embedded-web", "audio"]) { + rejectsDefinition((value) => { + value.scene.nodes.model = { ...value.scene.nodes.model, kind }; + }); + } + rejectsDefinition((value) => { + value.flow.groups.intro.steps.start.cues[0].actions[0].layers = []; + }); + rejectsDefinition((value) => { + value.scene.surfaces.baked.renderIntent.rendererPreference = "embedded-web"; + }); +}); + +test("state patches cannot replace topology or video identity", () => { + for (const property of ["parentId", "children", "order"]) { + rejectsDefinition((value) => { + value.scene.surfaces.baked.states.default.contentOverrides.root = { + kind: "frame", + [property]: property === "children" ? [] : 0, + }; + }); + } + rejectsDefinition((value) => { + value.scene.surfaces.video.states.default.contentOverrides.video = { + kind: "video", + assetId: "another-video", + }; + }); +}); + +test("clip controls require positive speed and typed transition", () => { + for (const speed of [0, -1]) { + rejectsDefinition((value) => { + value.flow.groups.intro.steps.start.cues[0].actions[0].speed = speed; + }); + } + rejectsDefinition((value) => { + value.flow.groups.intro.steps.start.cues[0].actions[0].transition = { + kind: "crossfade", + durationMilliseconds: 0, + easing: "linear", + }; + }); +}); + +test("nested guard rejects arbitrary payloads", () => { + rejectsDefinition((value) => { + value.flow.groups.intro.steps.start.cues[0].guard = { + kind: "all", + guards: [{ kind: "not", guard: { kind: "script", body: "true" } }], + }; + }); +}); + +test("asset set rejects self hash, external paths and unsafe byte counts", async () => { + const manifest = await fixture("asset-set-manifest"); + for (const extra of [ + { assetSetHash: `sha256:${"a".repeat(64)}` }, + { url: "https://example.com" }, + ]) { + assert.equal(assetSetManifestV2Schema.safeParse({ ...manifest, ...extra }).success, false); + } + for (const bytes of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1]) { + const invalid = structuredClone(manifest); + invalid.assets.image.encodedSizeBytes = bytes; + assert.equal(assetSetManifestV2Schema.safeParse(invalid).success, false); + } +}); + +test("render artifacts reject runtime code and malformed texture tuples", () => { + for (const patch of [{ script: "run()" }, { kind: "embedded-web" }]) { + const invalid = structuredClone(bundle); + Object.assign( + invalid.surfaces.baked.renderSurfaces["render-baked"].artifacts["artifact-baked"], + patch, + ); + assert.equal(renderBundleV2Schema.safeParse(invalid).success, false); + assert.equal(validateBundle(invalid), false); + } + const invalid = structuredClone(bundle); + invalid.surfaces.baked.renderSurfaces["render-baked"].artifacts[ + "artifact-baked" + ].states.default.texture.pixelSize.push(1); + assert.equal(renderBundleV2Schema.safeParse(invalid).success, false); + assert.equal(validateBundle(invalid), false); +}); + +test("all four resource budgets are mandatory", async () => { + const profile = await fixture("capability-profile"); + for (const renderer of ["texture", "nativeUi", "video", "model"]) { + const invalid = structuredClone(profile); + delete invalid.limits[renderer]; + assert.equal(capabilityProfileV2Schema.safeParse(invalid).success, false); + } +}); + +for (const [file, message] of [ + ["held-model-blend", "unframe.realtime.v2.ModelClipRuntimeState"], + ["paused-model-crossfade", "unframe.realtime.v2.ModelClipRunSnapshot"], + ["paused-crossfade-held-source", "unframe.realtime.v2.ModelClipRunSnapshot"], + ["null-scalar", "unframe.presentation.v2.ScalarValue"], + ["clipless-model-node", "unframe.presentation.v2.ProjectedNodeDefinition"], +]) { + test(`${file}: Protobuf preserves the complete portable state`, async () => { + const { execFileSync } = await import("node:child_process"); + const input = await readFile(resolve(root, "fixtures/wire", `${file}.textproto`), "utf8"); + const descriptor = resolve(root, "contract.pb"); + const encode = (text: string) => + execFileSync("protoc", [`--descriptor_set_in=${descriptor}`, `--encode=${message}`], { + input: text, + stdio: ["pipe", "pipe", "pipe"], + }); + const encoded = encode(input); + const decoded = execFileSync( + "protoc", + [`--descriptor_set_in=${descriptor}`, `--decode=${message}`], + { input: encoded, encoding: "utf8" }, + ); + assert.deepEqual(encode(decoded), encoded); + assert.ok(encoded.length > 0); + assert.throws(() => encode(`${input}\nunknown_state: true\n`)); + }); +} + +test("the fixture publication closes over the exact immutable artifacts", async () => { + const { createHash } = await import("node:crypto"); + const { default: canonicalize } = await import("canonicalize"); + const hash = (value: unknown) => { + const canonical = canonicalize(value); + assert.notEqual(canonical, undefined); + return `sha256:${createHash("sha256").update(canonical!).digest("hex")}`; + }; + const assets = assetSetManifestV2Schema.parse(await fixture("asset-set-manifest")); + const build = buildManifestV2Schema.parse(await fixture("build-manifest")); + const publication = publishedPresentationV2Schema.parse(await fixture("published-presentation")); + assert.equal(build.definitionHash, hash(definition)); + assert.equal(bundle.definitionHash, hash(definition)); + assert.equal(build.renderBundleHash, hash(bundle)); + assert.equal(build.assetSetHash, hash(assets)); + const { publicationEpoch, publicationManifestHash, ...publishedBuild } = publication; + assert.deepEqual(publishedBuild, build); + assert.equal(publicationManifestHash, hash({ ...publishedBuild, publicationEpoch })); + for (const surface of Object.values(renderBundleV2Schema.parse(bundle).surfaces)) { + for (const part of Object.values(surface.renderSurfaces)) { + for (const artifact of Object.values(part.artifacts)) { + const descriptors = + artifact.kind === "baked-web" + ? Object.values(artifact.states).map((state) => state.texture) + : artifact.kind === "video" + ? [artifact] + : []; + for (const descriptor of descriptors) { + assert.deepEqual(assets.assets[descriptor.assetId], { + checksum: descriptor.checksum, + mediaType: descriptor.mediaType, + encodedSizeBytes: descriptor.encodedSizeBytes, + }); + } + } + } + } +}); + +test("typed state overrides and recursive scalar guards remain representable", () => { + const valid = structuredClone(definition); + valid.scene.surfaces.baked.states.default.contentOverrides = { + text: { kind: "text", value: { kind: "literal", value: "Updated title" } }, + root: { kind: "frame", opacity: 0.5 }, + }; + valid.flow.groups.intro.steps.start.cues[0].guard = { + kind: "all", + guards: [ + { + kind: "not", + guard: { + kind: "compare", + left: { kind: "variable", variableId: "counter" }, + operator: "lt", + right: 0, + }, + }, + ], + }; + assert.equal(presentationDefinitionV2Schema.safeParse(valid).success, true); + assert.equal(validateDefinition(valid), true, ajv.errorsText(validateDefinition.errors)); +}); + +test("asset catalog excludes executable Web content and standalone audio", async () => { + const assets = await fixture("asset-set-manifest"); + for (const mediaType of ["audio/mpeg", "audio/wav", "text/html", "application/javascript"]) { + const invalid = structuredClone(assets); + invalid.assets.image.mediaType = mediaType; + assert.equal(assetSetManifestV2Schema.safeParse(invalid).success, false); + } +}); diff --git a/packages/contracts/test/presentation.contract.test.ts b/packages/contracts/test/presentation.contract.test.ts new file mode 100644 index 0000000..5adb840 --- /dev/null +++ b/packages/contracts/test/presentation.contract.test.ts @@ -0,0 +1,222 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import Ajv2020 from "ajv/dist/2020.js"; + +import { + presentationDefinitionJsonSchema, + presentationDefinitionSchema, + renderBundleJsonSchema, + renderBundleSchema, +} from "../src/presentation/index"; + +const root = resolve(import.meta.dirname, ".."); +const fixture = async (name: string) => + JSON.parse(await readFile(resolve(root, "presentation/fixtures", name), "utf8")); + +const ajv = new Ajv2020({ + allErrors: true, + strict: true, + allowUnionTypes: true, +}); +const validateDefinition = ajv.compile(presentationDefinitionJsonSchema); +const validateBundle = ajv.compile(renderBundleJsonSchema); + +const minimalDefinition = await fixture("minimal.presentation-definition.v1.json"); +const minimalBundle = await fixture("minimal.render-bundle.v1.json"); + +assert.equal( + validateDefinition(minimalDefinition), + true, + ajv.errorsText(validateDefinition.errors), +); +assert.equal(validateBundle(minimalBundle), true, ajv.errorsText(validateBundle.errors)); +assert.equal(presentationDefinitionSchema.safeParse(minimalDefinition).success, true); +assert.equal(renderBundleSchema.safeParse(minimalBundle).success, true); + +const invalidSchemaVersion = structuredClone(minimalDefinition); +invalidSchemaVersion.schemaVersion = 2; +assert.equal(validateDefinition(invalidSchemaVersion), false, "schemaVersion must be fixed"); +assert.equal(presentationDefinitionSchema.safeParse(invalidSchemaVersion).success, false); + +const invalidMetadata = structuredClone(minimalDefinition); +invalidMetadata.metadata.extra = true; +assert.equal(validateDefinition(invalidMetadata), false, "metadata must reject unknown properties"); +assert.equal(presentationDefinitionSchema.safeParse(invalidMetadata).success, false); + +const invalidTopLevel = structuredClone(minimalDefinition); +invalidTopLevel.extra = true; +assert.equal( + validateDefinition(invalidTopLevel), + false, + "the definition must reject unknown top-level properties", +); +assert.equal(presentationDefinitionSchema.safeParse(invalidTopLevel).success, false); + +const invalidCoordinateSystem = structuredClone(minimalDefinition); +invalidCoordinateSystem.stage.coordinateSystem.forwardAxis = "+Z"; +assert.equal( + validateDefinition(invalidCoordinateSystem), + false, + "coordinate system must be canonical", +); +assert.equal(presentationDefinitionSchema.safeParse(invalidCoordinateSystem).success, false); + +const invalidAudience = structuredClone(minimalDefinition); +invalidAudience.scene.nodes["surface-node-title"].audience = "all"; +assert.equal( + validateDefinition(invalidAudience), + false, + "audience must use the discriminated record", +); +assert.equal(presentationDefinitionSchema.safeParse(invalidAudience).success, false); + +const invalidTransform = structuredClone(minimalDefinition); +invalidTransform.scene.nodes["surface-node-title"].transform.scale = [1, 1]; +assert.equal( + validateDefinition(invalidTransform), + false, + "transform must have a positive Vector3 scale", +); +assert.equal(presentationDefinitionSchema.safeParse(invalidTransform).success, false); + +const invalidRecordValue = structuredClone(minimalDefinition); +invalidRecordValue.scene.surfaces["surface-title"].states["state-default"] = {}; +assert.equal( + validateDefinition(invalidRecordValue), + false, + "record values must use their strict definitions", +); +assert.equal(presentationDefinitionSchema.safeParse(invalidRecordValue).success, false); + +const missingTextPlacement = structuredClone(minimalDefinition); +delete missingTextPlacement.scene.surfaces["surface-title"].contentNodes["text-title"].placement; +assert.equal( + validateDefinition(missingTextPlacement), + false, + "text nodes require absolute placement within their parent frame", +); +assert.equal(presentationDefinitionSchema.safeParse(missingTextPlacement).success, false); + +const invalidTextPlacement = structuredClone(minimalDefinition); +invalidTextPlacement.scene.surfaces["surface-title"].contentNodes["text-title"].placement.width = 0; +assert.equal( + validateDefinition(invalidTextPlacement), + false, + "text placement dimensions must be positive", +); +assert.equal(presentationDefinitionSchema.safeParse(invalidTextPlacement).success, false); + +const invalidRendererPreference = structuredClone(minimalDefinition); +invalidRendererPreference.scene.surfaces["surface-title"].renderIntent.rendererPreference = + "canvas"; +assert.equal( + validateDefinition(invalidRendererPreference), + false, + "rendererPreference must reject unknown values", +); +assert.equal(presentationDefinitionSchema.safeParse(invalidRendererPreference).success, false); + +const unsupportedCue = structuredClone(minimalDefinition); +unsupportedCue.flow.groups["group-intro"].steps["step-intro"].cues.push({ + id: "cue", +}); +assert.equal( + validateDefinition(unsupportedCue), + false, + "cues are intentionally unsupported in the first milestone", +); +assert.equal(presentationDefinitionSchema.safeParse(unsupportedCue).success, false); + +const invalidDefinition = structuredClone(minimalDefinition); +invalidDefinition.scene.surfaces["surface-title"].logicalSize = [0, 1080]; +assert.equal(validateDefinition(invalidDefinition), false, "logicalSize must be positive"); +assert.equal(presentationDefinitionSchema.safeParse(invalidDefinition).success, false); + +const invalidOpacity = structuredClone(minimalDefinition); +invalidOpacity.scene.nodes["surface-node-title"].opacity = 1.01; +assert.equal(validateDefinition(invalidOpacity), false, "opacity must be between zero and one"); +assert.equal(presentationDefinitionSchema.safeParse(invalidOpacity).success, false); + +const duplicateEnabledInteractionIds = structuredClone(minimalDefinition); +duplicateEnabledInteractionIds.scene.surfaces["surface-title"].states[ + "state-default" +].enabledInteractionIds = ["interaction-title", "interaction-title"]; +assert.equal( + validateDefinition(duplicateEnabledInteractionIds), + false, + "enabled interaction IDs must be unique", +); +assert.equal(presentationDefinitionSchema.safeParse(duplicateEnabledInteractionIds).success, false); + +const definitionWithoutNodes = structuredClone(minimalDefinition); +definitionWithoutNodes.scene.nodes = {}; +assert.equal(validateDefinition(definitionWithoutNodes), false, "a scene must contain a node"); +assert.equal(presentationDefinitionSchema.safeParse(definitionWithoutNodes).success, false); + +const definitionWithoutSurfaces = structuredClone(minimalDefinition); +definitionWithoutSurfaces.scene.surfaces = {}; +assert.equal( + validateDefinition(definitionWithoutSurfaces), + false, + "a scene must contain a surface", +); +assert.equal(presentationDefinitionSchema.safeParse(definitionWithoutSurfaces).success, false); + +const invalidBundle = structuredClone(minimalBundle); +invalidBundle.surfaces["surface-title"].renderSurfaces["render-surface-title"].artifacts[ + "artifact-title-default" +].states["state-default"].textures[0].pixelSize = [0, 1152]; +assert.equal(validateBundle(invalidBundle), false, "texture pixelSize must be positive"); +assert.equal(renderBundleSchema.safeParse(invalidBundle).success, false); + +const invalidRenderSurfaceLayer = structuredClone(minimalBundle); +invalidRenderSurfaceLayer.surfaces["surface-title"].renderSurfaces["render-surface-title"].layer = + -1; +assert.equal( + validateBundle(invalidRenderSurfaceLayer), + false, + "render surface layer must be a non-negative integer", +); +assert.equal(renderBundleSchema.safeParse(invalidRenderSurfaceLayer).success, false); + +const fractionalRenderSurfaceLayer = structuredClone(minimalBundle); +fractionalRenderSurfaceLayer.surfaces["surface-title"].renderSurfaces[ + "render-surface-title" +].layer = 0.5; +assert.equal( + validateBundle(fractionalRenderSurfaceLayer), + false, + "render surface layer must be an integer", +); +assert.equal(renderBundleSchema.safeParse(fractionalRenderSurfaceLayer).success, false); + +const invalidBundleRecord = structuredClone(minimalBundle); +invalidBundleRecord.surfaces["surface-title"].renderSurfaces["render-surface-title"].stateBindings[ + "state-default" +] = { kind: "artifacts" }; +assert.equal(validateBundle(invalidBundleRecord), false, "artifact bindings require artifact IDs"); +assert.equal(renderBundleSchema.safeParse(invalidBundleRecord).success, false); + +const legacyControlPlaneDefinition = { + id: "legacy-presentation", + metadata: { title: "Legacy" }, + stage: {}, + groups: [], + elements: [], +}; +assert.equal( + validateDefinition(legacyControlPlaneDefinition), + false, + "the target schema must reject the existing Control Plane representation", +); +assert.equal(presentationDefinitionSchema.safeParse(legacyControlPlaneDefinition).success, false); + +const generatedDefinition = JSON.parse( + await readFile(resolve(root, "presentation/presentation-definition.schema.json"), "utf8"), +); +const generatedBundle = JSON.parse( + await readFile(resolve(root, "presentation/render-bundle.schema.json"), "utf8"), +); +assert.deepEqual(generatedDefinition, presentationDefinitionJsonSchema); +assert.deepEqual(generatedBundle, renderBundleJsonSchema); diff --git a/packages/contracts/test/presentation.type-test.ts b/packages/contracts/test/presentation.type-test.ts new file mode 100644 index 0000000..1cc697b --- /dev/null +++ b/packages/contracts/test/presentation.type-test.ts @@ -0,0 +1,16 @@ +import type { + SerializedPresentationDefinitionV1, + SerializedRenderBundleV1, +} from "../src/presentation/index"; + +export const tupleConformance = { + stageSize: [4, 3, 2], + logicalSize: [1920, 1080], + rotation: [0, 0, 0, 1], + texturePixelSize: [2048, 1152], +} satisfies { + stageSize: SerializedPresentationDefinitionV1["stage"]["size"]; + logicalSize: SerializedPresentationDefinitionV1["scene"]["surfaces"][string]["logicalSize"]; + rotation: SerializedPresentationDefinitionV1["scene"]["nodes"][string]["transform"]["rotation"]; + texturePixelSize: SerializedRenderBundleV1["surfaces"][string]["renderSurfaces"][string]["artifacts"][string]["states"][string]["textures"][number]["pixelSize"]; +}; diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json new file mode 100644 index 0000000..e64d7f0 --- /dev/null +++ b/packages/contracts/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../config/tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "@cloudflare/workers-types"] + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/presentation-assets/ARCHITECTURE.md b/packages/presentation-assets/ARCHITECTURE.md new file mode 100644 index 0000000..f42f57f --- /dev/null +++ b/packages/presentation-assets/ARCHITECTURE.md @@ -0,0 +1,104 @@ +# Presentation Assets Architecture + +- **Status**: Initial memory-only PNG encoder implemented +- **Scope**: Compiler build 中の deterministic asset transformation +- **Related**: + - [Presentation Architecture](../../docs/presentation/ARCHITECTURE.md) + - [Presentation Implementation Design](../../docs/presentation/DESIGN.md) + - [Presentation Core Architecture](../presentation-core/ARCHITECTURE.md) + - [ADR-0012](../../docs/decisions/0012-texture-budget-residency-contract.md) + +## 1. Role + +`PNG_ENCODER_IDENTITY`は現在のmemory-only encoderの凍結された公開provenance identity +である。cacheとdelivery bindingはこのpackageの責務外である。 + +`presentation-assets` は、入力 Asset と renderer capture を content-addressed な build artifact へ変換する library boundary である。OS tool、codec、font processor などの environment-dependent implementation を semantic core から隔離し、変換条件と provenance を明示する。 + +Asset の upload、ownership、delivery URL、Unity runtime cache はこの package の責務ではない。 + +## 2. Owned pipeline + +次は変換adapterのownership候補である。Baked Web v1のformat、resolution、byte式、aggregate budgetは [ADR-0012](../../docs/decisions/0012-texture-budget-residency-contract.md) でAcceptedだが、現行実装はmemory-only PNG encodeだけである。 + +- image / renderer capture の resize +- texture encode、mipmap、checksum +- font resolution と必要な場合の subset +- video / model transformer を追加する adapter boundary +- content-addressed binary output +- media type、dimensions、size、checksum、encoder provenance +- temporary workspace と output cleanup の library boundary + +```text +declared input + transform request + toolchain provenance + ↓ + deterministic transform + ↓ + binary + descriptor + diagnostics +``` + +## 3. Input and output boundary + +入力は content、declared media type、変換 request、明示された toolchain / environment configuration とする。Source path や process current directory は identity に使用しない。 + +出力 descriptor は RenderBundle が参照できる portable metadata とし、Signed URL、R2 object key、Unity cache path を含めない。 + +## 4. Invariants + +- binary identity は content と変換条件から決まり、作業 directory に依存しない。 +- checksum、media type、size、encoder provenance を出力に含める。 +- temporary output は成功・失敗・cancel のいずれでも cleanup できる。 +- source Asset と derived artifact の対応を追跡できる。 +- renderer capture の layout / Hit Region geometry は renderer が所有し、この package は capture 後の binary 変換だけを所有する。 +- Semantic Tree の意味を image、DOM、font output から抽出しない。 + +## 5. Non-responsibilities + +- Component / Surface semantics +- Browser layout と capture +- renderer selection と plugin orchestration +- Control Plane の Asset ownership、R2 upload、Signed URL +- Unity runtime download、preload、eviction +- presentation publication policy + +## 6. Dependency rules + +`presentation-assets` は artifact descriptor と diagnostic の型に限って `presentation-core` へ依存できる。Renderer API、Compiler、concrete renderer、Control Plane へ依存しない。 + +入力 request、limit、pixel size、color space、alpha mode は Zod 4 schema で検証する。schema に渡す前に request を一度 snapshot し、hostile property access は stable diagnostic へ変換する。PNG plan の byte arithmetic、absolute / caller budget、RGBA length、opaque alpha byte列の走査は format 固有の algorithmic invariant なので手書きで保持する。 + +外部 executable / codec を adapter 内に閉じ込め、public model や Core に process-specific 型を露出しない。 + +## 7. Validation strategy + +- same-input / same-output hash の determinism test +- image dimensions、alpha、color space、mipmap の golden fixture +- font subset と glyph coverage fixture +- invalid media、tool failure、cancel、cleanup の test +- descriptor と binary checksum の consistency test +- toolchain provenance 変更時の cache invalidation test + +## 8. Current implementation + +最初の milestone は memory-only の `encodeRgbaToPng` を公開する。入力は source ID、sRGB RGBA8 bytes、pixel size、alpha mode と caller budget であり、filesystem path、process environment、renderer固有型を含まない。 + +PNG byte列は 8-bit RGBA / non-interlaceのIHDR、sRGB rendering intent 0、各scanlineのfilter 0、`78 01` zlib headerと最大65,535 byteのstored DEFLATE block、Adler-32、CRC32、IENDに固定する。timestampや可変metadataを持たず、native codecやOS toolへ依存しない。同じ入力は同じbyte列になるが、圧縮率は最適化しない。 + +Encoderは検証時に最終byte数を計画し、入力RGBAとは別の単一output bufferへPNGを直接書き込む。scanline全体、DEFLATE block列、chunk列の中間copyは作らず、最大時の主要working setをcaller-owned inputとencoder-owned outputに限定する。 + +PNGがunassociated alphaを表すため、`opaque`は全alpha channelが255であることを検証し、`straight`をそのまま保持する。`premultiplied`は丸めを伴う変換規則が未定義なのでstable diagnosticで拒否する。 + +Callerはwidth、height、pixel数、input byte数、output byte数の上限を明示する。さらにpackage trust boundaryとして4,096 x 4,096、16,777,216 pixels、64 MiB input、65 MiB outputの絶対上限を超えられない。これはGPUやDeliveryの品質budgetではなく、未trusted inputによる過大allocationを防ぐ初期上限である。 + +最終PNG bytesのchecksumをlowercase hexの`sha256:`とし、first milestoneのderived `assetId`にも同じ値を使う。source ID、derived ID、Compiler cache keyは別概念であり、encoder version / fingerprintはprovenanceとして返す。現行RenderBundleに独立provenance fieldがないため、Compilerが将来cache/environment hashへ結合する。 + +返却された`bytes`はcallerが所有し、別のencode結果や入力bufferとは共有しない。callerはchecksum検証後のbytesを変更できるが、descriptor/checksumを永続化または後段へ渡した後は変更してはならない。mutable/untrustedなbytesを受け取る永続化・Delivery境界はdescriptorのchecksumを再検証する。 + +resize、mipmap、temporary workspace、font / video / model変換、cache、Surface State binding、RenderBundle組み立て、upload / Deliveryは実装しない。 + +## 9. Deferred decisions + +- ADR-0012でAcceptedになったtexture metadata / aggregate budget / resolution policyの実装 +- font resolver と subset toolchain +- video / model adapter の初期範囲 +- cross-platform reproducibility と container / Nix boundary diff --git a/packages/presentation-assets/package.json b/packages/presentation-assets/package.json new file mode 100644 index 0000000..bc63475 --- /dev/null +++ b/packages/presentation-assets/package.json @@ -0,0 +1,26 @@ +{ + "name": "@unframe/presentation-assets", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "lint": "vp check src test", + "test": "vp test run", + "check": "pnpm typecheck && pnpm lint && pnpm test" + }, + "dependencies": { + "@noble/hashes": "2.3.0", + "@unframe/presentation-core": "workspace:*", + "zod": "4.4.3" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^7.0.0", + "vite-plus": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/presentation-assets/src/index.ts b/packages/presentation-assets/src/index.ts new file mode 100644 index 0000000..c30af56 --- /dev/null +++ b/packages/presentation-assets/src/index.ts @@ -0,0 +1,10 @@ +export { encodeRgbaToPng } from "./png/encode-rgba-to-png.js"; + +export { PNG_ABSOLUTE_LIMITS, PNG_ENCODER_IDENTITY } from "./png/constants.js"; + +export type { + EncodedTextureArtifact, + EncodeLimits, + EncodeRequest, + RgbaInput, +} from "./public-types.js"; diff --git a/packages/presentation-assets/src/png/constants.ts b/packages/presentation-assets/src/png/constants.ts new file mode 100644 index 0000000..515b28d --- /dev/null +++ b/packages/presentation-assets/src/png/constants.ts @@ -0,0 +1,19 @@ +import type { EncodeLimits } from "../public-types.js"; + +export const INTERNAL_PNG_HARD_CAPS = Object.freeze({ + maxWidth: 4_096, + maxHeight: 4_096, + maxPixels: 16_777_216, + maxInputBytes: 64 * 1_024 * 1_024, + maxOutputBytes: 65 * 1_024 * 1_024, +}); + +export const PNG_ABSOLUTE_LIMITS: Readonly = Object.freeze({ + ...INTERNAL_PNG_HARD_CAPS, +}); + +export const PNG_ENCODER_IDENTITY = Object.freeze({ + encoderId: "unframe-memory-png" as const, + version: "1" as const, + fingerprint: "png-rgba8-srgb-filter0-store-v1" as const, +}); diff --git a/packages/presentation-assets/src/png/encode-rgba-to-png.ts b/packages/presentation-assets/src/png/encode-rgba-to-png.ts new file mode 100644 index 0000000..ca4d25a --- /dev/null +++ b/packages/presentation-assets/src/png/encode-rgba-to-png.ts @@ -0,0 +1,418 @@ +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex } from "@noble/hashes/utils.js"; +import type { Diagnostic, ValidationResult } from "@unframe/presentation-core"; +import { z } from "zod"; + +import { INTERNAL_PNG_HARD_CAPS, PNG_ENCODER_IDENTITY } from "./constants.js"; +import type { EncodedTextureArtifact, EncodeLimits, EncodeRequest } from "../public-types.js"; + +type ValidatedRequest = { + sourceId: string; + rgba: Uint8Array; + width: number; + height: number; + alphaMode: "opaque" | "straight"; + outputBytes: number; +}; + +type PngPlan = { + rowBytes: number; + scanlineBytes: number; + blockCount: number; + zlibBytes: number; + outputBytes: number; +}; + +const PNG_SIGNATURE = Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10); +const SRGB_INTENT = Uint8Array.of(0); +const EMPTY_BYTES = new Uint8Array(); +const ADLER_MODULUS = 65_521; +const STORED_BLOCK_SIZE = 65_535; + +const crcTable = Uint32Array.from({ length: 256 }, (_, value) => { + let current = value; + for (let bit = 0; bit < 8; bit++) + current = current & 1 ? 0xedb88320 ^ (current >>> 1) : current >>> 1; + return current >>> 0; +}); + +const diagnostic = ( + code: string, + path: readonly (string | number)[], + message: string, +): Diagnostic => ({ + code, + path, + message, +}); + +const invalid = ( + code: string, + path: readonly (string | number)[], + message: string, +): ValidationResult => ({ + valid: false, + diagnostics: [diagnostic(code, path, message)], +}); + +const typedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype); +const typedArrayByteLength = Object.getOwnPropertyDescriptor( + typedArrayPrototype, + "byteLength", +)?.get; +const typedArrayTag = Object.getOwnPropertyDescriptor(typedArrayPrototype, Symbol.toStringTag)?.get; +const copyUint8Array = (value: unknown): Uint8Array | undefined => { + try { + if (!ArrayBuffer.isView(value) || !typedArrayByteLength || !typedArrayTag) return undefined; + if (typedArrayTag.call(value) !== "Uint8Array") return undefined; + const byteLength = typedArrayByteLength.call(value); + if (!Number.isSafeInteger(byteLength) || byteLength < 0) return undefined; + const copy = new Uint8Array(byteLength); + Uint8Array.prototype.set.call(copy, value as Uint8Array); + return copy; + } catch { + return undefined; + } +}; + +const positiveSafeIntegerSchema = z.number().int().safe().positive(); +const encodeLimitsSchema = z.strictObject({ + maxWidth: positiveSafeIntegerSchema, + maxHeight: positiveSafeIntegerSchema, + maxPixels: positiveSafeIntegerSchema, + maxInputBytes: positiveSafeIntegerSchema, + maxOutputBytes: positiveSafeIntegerSchema, +}); +const encodeRequestSchema = z.strictObject({ + sourceId: z.string().trim().min(1), + rgba: z.instanceof(Uint8Array), + pixelSize: z.tuple([positiveSafeIntegerSchema, positiveSafeIntegerSchema]), + colorSpace: z.literal("srgb"), + alphaMode: z.enum(["opaque", "straight", "premultiplied"]), + limits: encodeLimitsSchema, +}); + +type ParsedEncodeRequest = z.infer; + +const createPngPlan = (width: number, height: number): PngPlan => { + const rowBytes = width * 4; + const scanlineBytes = height * (rowBytes + 1); + const blockCount = Math.ceil(scanlineBytes / STORED_BLOCK_SIZE); + const zlibBytes = 2 + blockCount * 5 + scanlineBytes + 4; + return { + rowBytes, + scanlineBytes, + blockCount, + zlibBytes, + outputBytes: 70 + zlibBytes, + }; +}; + +const snapshotRecord = (value: unknown): Record | undefined => { + try { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return undefined; + if (Object.getOwnPropertySymbols(value).length !== 0) return undefined; + const descriptors: Record = Object.getOwnPropertyDescriptors(value); + if ( + Object.values(descriptors).some( + (descriptor) => + descriptor.get !== undefined || descriptor.set !== undefined || !descriptor.enumerable, + ) + ) + return undefined; + return Object.fromEntries( + Object.entries(descriptors).map(([key, descriptor]) => [key, descriptor.value]), + ); + } catch { + return undefined; + } +}; + +const snapshotDenseArray = (value: unknown): readonly unknown[] | undefined => { + try { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) return undefined; + const descriptors: Record = Object.getOwnPropertyDescriptors(value); + const length = descriptors["length"]?.value; + if (!Number.isSafeInteger(length) || length < 0) return undefined; + const keys = Array.from({ length }, (_, index) => String(index)); + if ( + Object.getOwnPropertySymbols(value).length !== 0 || + Object.keys(descriptors).length !== length + 1 || + keys.some((key) => { + const descriptor = descriptors[key]; + return !descriptor || descriptor.get !== undefined || descriptor.set !== undefined; + }) + ) + return undefined; + return keys.map((key) => descriptors[key]!.value); + } catch { + return undefined; + } +}; + +const snapshotRequest = (value: unknown): unknown => { + const request = snapshotRecord(value); + if (!request) return undefined; + return { + ...request, + rgba: copyUint8Array(request.rgba), + pixelSize: snapshotDenseArray(request.pixelSize), + limits: snapshotRecord(request.limits), + }; +}; + +const validationIssue = (parsed: z.ZodSafeParseError): Diagnostic => { + if (parsed.error.issues.length > 1) + return diagnostic("invalid-encode-request", [], "Encode request must be an object."); + const issue = parsed.error.issues[0]; + const path = (issue?.path ?? []).filter( + (segment): segment is string | number => + typeof segment === "string" || typeof segment === "number", + ); + if (path[0] === "sourceId") + return diagnostic("invalid-source-id", path, "Source ID must be a non-empty string."); + if (path[0] === "rgba") + return diagnostic("invalid-rgba", path, "RGBA input must be a Uint8Array."); + if (path[0] === "pixelSize") + return diagnostic( + "invalid-pixel-size", + ["pixelSize"], + "Pixel size must contain two positive safe integers.", + ); + if (path[0] === "colorSpace") + return diagnostic("unsupported-color-space", path, "Only the sRGB color space is supported."); + if (path[0] === "alphaMode") + return diagnostic( + "invalid-alpha-mode", + path, + "Alpha mode must be opaque, straight, or premultiplied.", + ); + if (path[0] === "limits") + return diagnostic( + "invalid-encode-limits", + path, + "Encode limits must be positive safe integers.", + ); + return diagnostic("invalid-encode-request", path, "Encode request must be an object."); +}; + +const validateLimits = ( + limits: z.infer, +): ValidationResult => { + for (const [key, limit] of Object.entries(limits) as [keyof EncodeLimits, number][]) { + if (limit > INTERNAL_PNG_HARD_CAPS[key]) + return invalid( + "encode-limit-above-hard-cap", + ["limits", key], + "Caller limits cannot exceed the package hard cap.", + ); + } + return { valid: true, value: limits, diagnostics: [] }; +}; + +const validateRequest = (value: unknown): ValidationResult => { + const parsed = encodeRequestSchema.safeParse(snapshotRequest(value)); + if (!parsed.success) return { valid: false, diagnostics: [validationIssue(parsed)] }; + const { sourceId, rgba, pixelSize, alphaMode } = parsed.data as ParsedEncodeRequest; + if (alphaMode === "premultiplied") + return invalid( + "unsupported-alpha-mode", + ["alphaMode"], + "Premultiplied alpha conversion is not defined by this encoder version.", + ); + + const limits = validateLimits(parsed.data.limits); + if (!limits.valid) return limits; + + const [width, height] = pixelSize; + const pixels = width * height; + const inputBytes = pixels * 4; + if ( + width > INTERNAL_PNG_HARD_CAPS.maxWidth || + height > INTERNAL_PNG_HARD_CAPS.maxHeight || + pixels > INTERNAL_PNG_HARD_CAPS.maxPixels || + inputBytes > INTERNAL_PNG_HARD_CAPS.maxInputBytes + ) + return invalid( + "png-hard-cap-exceeded", + ["pixelSize"], + "PNG dimensions or input bytes exceed the package hard cap.", + ); + if (rgba.length !== inputBytes) + return invalid( + "rgba-length-mismatch", + ["rgba"], + "RGBA byte length must equal width multiplied by height multiplied by four.", + ); + + const plan = createPngPlan(width, height); + if (plan.outputBytes > INTERNAL_PNG_HARD_CAPS.maxOutputBytes) + return invalid( + "png-hard-cap-exceeded", + ["limits", "maxOutputBytes"], + "Predicted PNG bytes exceed the package hard cap.", + ); + if ( + width > limits.value.maxWidth || + height > limits.value.maxHeight || + pixels > limits.value.maxPixels || + inputBytes > limits.value.maxInputBytes || + plan.outputBytes > limits.value.maxOutputBytes + ) + return invalid( + "encode-limit-exceeded", + ["limits"], + "Input or predicted output exceeds the caller-provided encode limits.", + ); + + if (alphaMode === "opaque") { + for (let offset = 3; offset < rgba.length; offset += 4) { + if (rgba[offset] !== 255) + return invalid( + "opaque-alpha-mismatch", + ["rgba", offset], + "Opaque RGBA input must use alpha 255 for every pixel.", + ); + } + } + + return { + valid: true, + value: { + sourceId, + rgba, + width, + height, + alphaMode, + outputBytes: plan.outputBytes, + }, + diagnostics: [], + }; +}; + +const writeU32 = (bytes: Uint8Array, offset: number, value: number) => { + new DataView(bytes.buffer, bytes.byteOffset + offset, 4).setUint32(0, value >>> 0); +}; + +const writeU16Le = (bytes: Uint8Array, offset: number, value: number) => { + bytes[offset] = value & 0xff; + bytes[offset + 1] = value >>> 8; +}; + +const writeAscii = (bytes: Uint8Array, offset: number, value: string) => { + for (let index = 0; index < value.length; index++) + bytes[offset + index] = value.charCodeAt(index); +}; + +const crc32 = (bytes: Uint8Array) => { + let checksum = 0xffffffff; + for (const byte of bytes) checksum = crcTable[(checksum ^ byte) & 0xff]! ^ (checksum >>> 8); + return (checksum ^ 0xffffffff) >>> 0; +}; + +const writeChunk = (output: Uint8Array, offset: number, type: string, data: Uint8Array) => { + writeU32(output, offset, data.length); + writeAscii(output, offset + 4, type); + output.set(data, offset + 8); + writeU32( + output, + offset + 8 + data.length, + crc32(output.subarray(offset + 4, offset + 8 + data.length)), + ); + return offset + 12 + data.length; +}; + +const writeIdat = (output: Uint8Array, offset: number, rgba: Uint8Array, plan: PngPlan) => { + writeU32(output, offset, plan.zlibBytes); + writeAscii(output, offset + 4, "IDAT"); + let cursor = offset + 8; + output[cursor++] = 0x78; + output[cursor++] = 0x01; + + let scanlineOffset = 0; + let rowPosition = 0; + let rgbaOffset = 0; + let adlerA = 1; + let adlerB = 0; + for (let blockIndex = 0; blockIndex < plan.blockCount; blockIndex++) { + const blockLength = Math.min(STORED_BLOCK_SIZE, plan.scanlineBytes - scanlineOffset); + output[cursor++] = blockIndex === plan.blockCount - 1 ? 1 : 0; + writeU16Le(output, cursor, blockLength); + cursor += 2; + writeU16Le(output, cursor, ~blockLength & 0xffff); + cursor += 2; + + for (let index = 0; index < blockLength; index++, scanlineOffset++) { + const byte = rowPosition === 0 ? 0 : rgba[rgbaOffset++]!; + output[cursor++] = byte; + adlerA = (adlerA + byte) % ADLER_MODULUS; + adlerB = (adlerB + adlerA) % ADLER_MODULUS; + rowPosition = rowPosition === plan.rowBytes ? 0 : rowPosition + 1; + } + } + + writeU32(output, cursor, (adlerB << 16) | adlerA); + cursor += 4; + writeU32(output, cursor, crc32(output.subarray(offset + 4, cursor))); + return cursor + 4; +}; + +const encodeValidated = (request: ValidatedRequest): ValidationResult => { + const plan = createPngPlan(request.width, request.height); + if (plan.outputBytes !== request.outputBytes) + return invalid("png-encode-failed", [], "Validated PNG plan changed before encoding."); + + const output = new Uint8Array(plan.outputBytes); + output.set(PNG_SIGNATURE); + const ihdr = new Uint8Array(13); + writeU32(ihdr, 0, request.width); + writeU32(ihdr, 4, request.height); + ihdr.set([8, 6, 0, 0, 0], 8); + + let cursor = writeChunk(output, PNG_SIGNATURE.length, "IHDR", ihdr); + cursor = writeChunk(output, cursor, "sRGB", SRGB_INTENT); + cursor = writeIdat(output, cursor, request.rgba, plan); + cursor = writeChunk(output, cursor, "IEND", EMPTY_BYTES); + if (cursor !== output.length) + return invalid("png-encode-failed", [], "PNG encoder produced an unexpected byte length."); + + const checksum = `sha256:${bytesToHex(sha256(output))}`; + return { + valid: true, + value: { + descriptor: { + assetId: checksum, + mediaType: "image/png", + pixelSize: [request.width, request.height], + checksum, + colorSpace: "srgb", + alphaMode: request.alphaMode, + }, + bytes: output, + byteLength: output.length, + sourceId: request.sourceId, + provenance: { + ...PNG_ENCODER_IDENTITY, + }, + }, + diagnostics: [], + }; +}; + +export function encodeRgbaToPng(input: EncodeRequest): ValidationResult; +export function encodeRgbaToPng(input: unknown): ValidationResult; +export function encodeRgbaToPng(input: unknown): ValidationResult { + try { + const validation = validateRequest(input); + if (!validation.valid) return validation; + try { + return encodeValidated(validation.value); + } catch { + return invalid("png-encode-failed", [], "PNG encoding failed."); + } + } catch { + return invalid("invalid-encode-request", [], "Encode request could not be inspected safely."); + } +} diff --git a/packages/presentation-assets/src/public-types.ts b/packages/presentation-assets/src/public-types.ts new file mode 100644 index 0000000..f539f8c --- /dev/null +++ b/packages/presentation-assets/src/public-types.ts @@ -0,0 +1,32 @@ +import type { TextureArtifact } from "@unframe/presentation-core"; + +export type RgbaInput = Uint8Array; + +export type EncodeLimits = { + readonly maxWidth: number; + readonly maxHeight: number; + readonly maxPixels: number; + readonly maxInputBytes: number; + readonly maxOutputBytes: number; +}; + +export type EncodeRequest = { + readonly sourceId: string; + readonly rgba: RgbaInput; + readonly pixelSize: readonly [number, number]; + readonly colorSpace: "srgb"; + readonly alphaMode: "opaque" | "straight" | "premultiplied"; + readonly limits: EncodeLimits; +}; + +export type EncodedTextureArtifact = { + readonly descriptor: TextureArtifact; + readonly bytes: Uint8Array; + readonly byteLength: number; + readonly sourceId: string; + readonly provenance: { + readonly encoderId: "unframe-memory-png"; + readonly version: "1"; + readonly fingerprint: "png-rgba8-srgb-filter0-store-v1"; + }; +}; diff --git a/packages/presentation-assets/test/png.test.ts b/packages/presentation-assets/test/png.test.ts new file mode 100644 index 0000000..d495d4d --- /dev/null +++ b/packages/presentation-assets/test/png.test.ts @@ -0,0 +1,373 @@ +import { runInNewContext } from "node:vm"; +import { inflateSync } from "node:zlib"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + PNG_ABSOLUTE_LIMITS, + PNG_ENCODER_IDENTITY, + encodeRgbaToPng, + type EncodeRequest, + type EncodedTextureArtifact, +} from "../src/index.js"; + +const request = { + sourceId: "capture-title-default", + rgba: new Uint8Array([255, 0, 0, 255]), + pixelSize: [1, 1], + colorSpace: "srgb", + alphaMode: "opaque", + limits: { + maxWidth: 1, + maxHeight: 1, + maxPixels: 1, + maxInputBytes: 4, + maxOutputBytes: 1024, + }, +} as const satisfies EncodeRequest; + +const readU32 = (bytes: Uint8Array, offset: number) => + new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getUint32(0); + +const crcTable = Uint32Array.from({ length: 256 }, (_, value) => { + let current = value; + for (let bit = 0; bit < 8; bit++) + current = current & 1 ? 0xedb88320 ^ (current >>> 1) : current >>> 1; + return current >>> 0; +}); + +const crc32 = (bytes: Uint8Array) => { + let checksum = 0xffffffff; + for (const byte of bytes) checksum = crcTable[(checksum ^ byte) & 0xff]! ^ (checksum >>> 8); + return (checksum ^ 0xffffffff) >>> 0; +}; + +const parseChunks = (bytes: Uint8Array) => { + const chunks: { type: string; data: Uint8Array; checksum: number }[] = []; + for (let offset = 8; offset < bytes.length;) { + const length = readU32(bytes, offset); + const typeBytes = bytes.subarray(offset + 4, offset + 8); + const data = bytes.subarray(offset + 8, offset + 8 + length); + const checksum = readU32(bytes, offset + 8 + length); + chunks.push({ type: new TextDecoder().decode(typeBytes), data, checksum }); + expect(checksum).toBe(crc32(new Uint8Array([...typeBytes, ...data]))); + offset += 12 + length; + } + return chunks; +}; + +const diagnosticCodes = (value: unknown) => { + const result = encodeRgbaToPng(value); + expect(result.valid).toBe(false); + return result.valid ? [] : result.diagnostics.map(({ code }) => code); +}; + +const parseStoredBlocks = (idat: Uint8Array) => { + expect([...idat.subarray(0, 2)]).toEqual([0x78, 0x01]); + const blocks: { final: boolean; length: number }[] = []; + let offset = 2; + while (offset < idat.length - 4) { + const header = idat[offset++]!; + expect(header & 0b110).toBe(0); + const length = idat[offset]! | (idat[offset + 1]! << 8); + const inverseLength = idat[offset + 2]! | (idat[offset + 3]! << 8); + expect((length ^ inverseLength) & 0xffff).toBe(0xffff); + offset += 4 + length; + blocks.push({ final: (header & 1) === 1, length }); + if ((header & 1) === 1) break; + } + expect(offset).toBe(idat.length - 4); + expect(blocks.slice(0, -1).every(({ final }) => !final)).toBe(true); + expect(blocks.at(-1)?.final).toBe(true); + return blocks; +}; + +describe("deterministic PNG encoding", () => { + it("freezes the public encoder identity and uses it for each result", () => { + expect(Object.isFrozen(PNG_ENCODER_IDENTITY)).toBe(true); + expect(Reflect.set(PNG_ENCODER_IDENTITY, "version", "changed")).toBe(false); + const result = encodeRgbaToPng(request); + expect(result.valid && result.value.provenance).toEqual(PNG_ENCODER_IDENTITY); + }); + it("emits a stable content-addressed texture and provenance", () => { + const first = encodeRgbaToPng(request); + const second = encodeRgbaToPng(request); + expect(first.valid).toBe(true); + expect(second).toEqual(first); + if (!first.valid) return; + + expect(first.value.descriptor).toEqual({ + assetId: first.value.descriptor.checksum, + mediaType: "image/png", + pixelSize: [1, 1], + checksum: first.value.descriptor.checksum, + colorSpace: "srgb", + alphaMode: "opaque", + }); + expect(first.value.descriptor.checksum).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(first.value.descriptor.checksum).toBe( + "sha256:9708f867a1a68bb0c8f81e9d92374e50a63f78f5164e5222c27875fcf343c0cf", + ); + expect(Buffer.from(first.value.bytes).toString("hex")).toBe( + "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489000000017352474200aece1ce900000010494441547801010500faff00ff0000ff050001fffa5c88d10000000049454e44ae426082", + ); + expect(first.value.byteLength).toBe(first.value.bytes.length); + expect(first.value.sourceId).toBe(request.sourceId); + expect(first.value.provenance).toEqual({ + encoderId: "unframe-memory-png", + version: "1", + fingerprint: "png-rgba8-srgb-filter0-store-v1", + }); + }); + + it("emits valid fixed PNG chunks, CRCs, and filter-none RGBA scanlines", () => { + const result = encodeRgbaToPng(request); + expect(result.valid).toBe(true); + if (!result.valid) return; + + expect([...result.value.bytes.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]); + const chunks = parseChunks(result.value.bytes); + expect(chunks.map(({ type }) => type)).toEqual(["IHDR", "sRGB", "IDAT", "IEND"]); + expect([...chunks[0]!.data]).toEqual([0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]); + expect([...chunks[1]!.data]).toEqual([0]); + expect([...inflateSync(chunks[2]!.data)]).toEqual([0, 255, 0, 0, 255]); + expect(chunks[3]!.data).toHaveLength(0); + }); + + it("preserves pixel order across scanline boundaries", () => { + const rgba = Uint8Array.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16); + const result = encodeRgbaToPng({ + ...request, + rgba, + pixelSize: [2, 2], + alphaMode: "straight", + limits: { + maxWidth: 2, + maxHeight: 2, + maxPixels: 4, + maxInputBytes: rgba.length, + maxOutputBytes: 1024, + }, + }); + expect(result.valid).toBe(true); + if (!result.valid) return; + + const idat = parseChunks(result.value.bytes).find(({ type }) => type === "IDAT")!.data; + expect([...inflateSync(idat)]).toEqual([ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 0, 9, 10, 11, 12, 13, 14, 15, 16, + ]); + }); + + it("uses the standard CRC-32 polynomial", () => { + expect(crc32(new TextEncoder().encode("123456789"))).toBe(0xcbf43926); + }); + + it("splits large scanline data into valid deterministic stored blocks", () => { + const width = 256; + const height = 64; + const rgba = new Uint8Array(width * height * 4).fill(255); + const largeRequest = { + ...request, + rgba, + pixelSize: [width, height], + limits: { + maxWidth: width, + maxHeight: height, + maxPixels: width * height, + maxInputBytes: rgba.length, + maxOutputBytes: 70_000, + }, + } as const satisfies EncodeRequest; + const result = encodeRgbaToPng(largeRequest); + const repeated = encodeRgbaToPng(largeRequest); + expect(result.valid).toBe(true); + expect(repeated).toEqual(result); + if (!result.valid) return; + + const idat = parseChunks(result.value.bytes).find(({ type }) => type === "IDAT")!.data; + expect(parseStoredBlocks(idat)).toEqual([ + { final: false, length: 65_535 }, + { final: true, length: 65 }, + ]); + expect(inflateSync(idat)).toHaveLength(height * (width * 4 + 1)); + }); + + it("uses exactly one final stored block below the 65,535-byte boundary", () => { + const width = 256; + const height = 63; + const rgba = new Uint8Array(width * height * 4).fill(255); + const result = encodeRgbaToPng({ + ...request, + rgba, + pixelSize: [width, height], + limits: { + maxWidth: width, + maxHeight: height, + maxPixels: width * height, + maxInputBytes: rgba.length, + maxOutputBytes: 70_000, + }, + }); + expect(result.valid).toBe(true); + if (!result.valid) return; + + const idat = parseChunks(result.value.bytes).find(({ type }) => type === "IDAT")!.data; + expect(parseStoredBlocks(idat)).toEqual([{ final: true, length: 64_575 }]); + }); + + it("preserves straight alpha and rejects ambiguous or false alpha metadata", () => { + const straight = encodeRgbaToPng({ + ...request, + rgba: new Uint8Array([10, 20, 30, 40]), + alphaMode: "straight", + }); + expect(straight.valid).toBe(true); + if (straight.valid) { + const idat = parseChunks(straight.value.bytes).find(({ type }) => type === "IDAT")!; + expect([...inflateSync(idat.data)]).toEqual([0, 10, 20, 30, 40]); + expect(straight.value.descriptor.alphaMode).toBe("straight"); + } + + expect(diagnosticCodes({ ...request, alphaMode: "premultiplied" })).toContain( + "unsupported-alpha-mode", + ); + expect(diagnosticCodes({ ...request, rgba: new Uint8Array([0, 0, 0, 0]) })).toContain( + "opaque-alpha-mismatch", + ); + }); + + it("changes content identity when encoded pixels change without mutating input", () => { + const original = new Uint8Array([255, 0, 0, 255]); + const red = encodeRgbaToPng({ ...request, rgba: original }); + const blue = encodeRgbaToPng({ + ...request, + rgba: new Uint8Array([0, 0, 255, 255]), + }); + expect(red.valid && blue.valid).toBe(true); + if (!red.valid || !blue.valid) return; + expect(red.value.descriptor.assetId).not.toBe(blue.value.descriptor.assetId); + expect(original).toEqual(new Uint8Array([255, 0, 0, 255])); + }); + + it("returns caller-owned bytes without sharing output across encode calls", () => { + const original = new Uint8Array(request.rgba); + const first = encodeRgbaToPng({ ...request, rgba: original }); + expect(first.valid).toBe(true); + if (!first.valid) return; + + first.value.bytes[0] = 0; + const repeated = encodeRgbaToPng({ ...request, rgba: original }); + expect(repeated.valid).toBe(true); + if (!repeated.valid) return; + expect(repeated.value.bytes[0]).toBe(137); + expect(original).toEqual(request.rgba); + }); + + it("accepts Uint8Array input restored from another JavaScript realm", () => { + const rgba = runInNewContext("new Uint8Array([255, 0, 0, 255])") as Uint8Array; + expect(rgba).not.toBeInstanceOf(Uint8Array); + expect(encodeRgbaToPng({ ...request, rgba }).valid).toBe(true); + }); +}); + +describe("PNG trust boundary", () => { + it("rejects malformed source, bytes, geometry, color, and alpha fields", () => { + expect(diagnosticCodes(null as never)).toContain("invalid-encode-request"); + expect(diagnosticCodes({ ...request, sourceId: "" })).toContain("invalid-source-id"); + expect(diagnosticCodes({ ...request, rgba: [255, 0, 0, 255] as never })).toContain( + "invalid-rgba", + ); + expect(diagnosticCodes({ ...request, pixelSize: [0, 1] })).toContain("invalid-pixel-size"); + expect(diagnosticCodes({ ...request, pixelSize: [1.5, 1] })).toContain("invalid-pixel-size"); + expect(diagnosticCodes({ ...request, rgba: new Uint8Array(3) })).toContain( + "rgba-length-mismatch", + ); + expect(diagnosticCodes({ ...request, colorSpace: "display-p3" as never })).toContain( + "unsupported-color-space", + ); + expect(diagnosticCodes({ ...request, alphaMode: "unknown" as never })).toContain( + "invalid-alpha-mode", + ); + }); + + it("requires valid caller limits no larger than package hard caps", () => { + expect(Object.isFrozen(PNG_ABSOLUTE_LIMITS)).toBe(true); + expect( + Reflect.set( + PNG_ABSOLUTE_LIMITS as Record, + "maxWidth", + PNG_ABSOLUTE_LIMITS.maxWidth + 1, + ), + ).toBe(false); + expect(diagnosticCodes({ ...request, limits: { ...request.limits, maxWidth: 0 } })).toContain( + "invalid-encode-limits", + ); + expect( + diagnosticCodes({ + ...request, + limits: { ...request.limits, maxWidth: PNG_ABSOLUTE_LIMITS.maxWidth + 1 }, + }), + ).toContain("encode-limit-above-hard-cap"); + }); + + it("enforces caller input and predicted-output budgets", () => { + expect( + diagnosticCodes({ ...request, limits: { ...request.limits, maxInputBytes: 3 } }), + ).toContain("encode-limit-exceeded"); + expect( + diagnosticCodes({ ...request, limits: { ...request.limits, maxOutputBytes: 85 } }), + ).toContain("encode-limit-exceeded"); + expect( + diagnosticCodes({ + ...request, + pixelSize: [PNG_ABSOLUTE_LIMITS.maxWidth + 1, 1], + rgba: new Uint8Array(4), + limits: { + ...request.limits, + maxWidth: PNG_ABSOLUTE_LIMITS.maxWidth, + maxPixels: PNG_ABSOLUTE_LIMITS.maxPixels, + maxInputBytes: PNG_ABSOLUTE_LIMITS.maxInputBytes, + maxOutputBytes: PNG_ABSOLUTE_LIMITS.maxOutputBytes, + }, + }), + ).toContain("png-hard-cap-exceeded"); + }); + + it("converts hostile property access into stable diagnostics", () => { + const throwing = new Proxy( + {}, + { + get() { + throw new Error("hostile getter"); + }, + }, + ); + const revocable = Proxy.revocable({}, {}); + revocable.revoke(); + + expect(() => encodeRgbaToPng(throwing)).not.toThrow(); + expect(diagnosticCodes(throwing)).toContain("invalid-encode-request"); + expect(() => encodeRgbaToPng(revocable.proxy)).not.toThrow(); + expect(diagnosticCodes(revocable.proxy)).toContain("invalid-encode-request"); + }); + + it("rejects accessor-backed request properties without executing them", () => { + let sourceReads = 0; + const getterRequest = { ...request } as Record; + Object.defineProperty(getterRequest, "sourceId", { + get() { + sourceReads++; + return request.sourceId; + }, + }); + + expect(encodeRgbaToPng(getterRequest).valid).toBe(false); + expect(sourceReads).toBe(0); + }); +}); + +const typeContractChecks = (artifact: EncodedTextureArtifact) => { + // @ts-expect-error RenderBundle descriptor aliases are read-only + artifact.descriptor.assetId = "changed"; + artifact.bytes[0] = 0; +}; + +expectTypeOf(typeContractChecks).toBeFunction(); diff --git a/packages/presentation-assets/tsconfig.json b/packages/presentation-assets/tsconfig.json new file mode 100644 index 0000000..8b84660 --- /dev/null +++ b/packages/presentation-assets/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../config/tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "noPropertyAccessFromIndexSignature": false, + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/presentation-authoring/ARCHITECTURE.md b/packages/presentation-authoring/ARCHITECTURE.md new file mode 100644 index 0000000..a6b2c44 --- /dev/null +++ b/packages/presentation-authoring/ARCHITECTURE.md @@ -0,0 +1,133 @@ +# Presentation Authoring Architecture + +- **Status**: Initial declaration API implemented; compiler integration deferred +- **Public package name**: `@unframe/presentation` +- **Scope**: 利用者向け Authoring SDK、制限付き DSL、semantic authoring operation +- **Related**: + - [Presentation Architecture](../../docs/presentation/ARCHITECTURE.md) + - [Presentation Implementation Design](../../docs/presentation/DESIGN.md) + - [Presentation Core Architecture](../presentation-core/ARCHITECTURE.md) + +## 1. Role + +`presentation-authoring` は、Presentation Orchestrator、Theme Declaration、Component Manifest、Structured Component Structure を記述する公開 SDK を所有する。Authoring Source を実行して Presentation を作る runtime ではなく、Compiler が静的に認識できる declaration signature と authoring operation の契約である。 + +同じ operation を Code authoring と Web Editor の Semantic Command が共有し、Lossless Syntax Tree / Source Map を介した意味論的 round-trip の基礎にする。 + +## 2. Public surfaces + +- `definePresentation` +- `defineTheme` +- Component Manifest builder +- Props、Slots、Parts、Variants、States、Actions、Outputs の builder +- Spatial / Surface / Layout primitive の authoring declaration +- Theme、Token、Named Style、Asset reference +- Stable ID、source metadata、Component Instance operation +- Structured / Opaque authoring mode +- override、Detach、semantic command が共有する operation vocabulary + +公開 API は Compiler が AST 上で symbol と signature を識別できる形に保つ。任意の runtime side effect や import-time registration を必要としない。 + +## 3. Source boundaries + +```text +presentation.unframe.tsx # composition root +theme.unframe.ts # Theme declaration +*.manifest.ts # Component public contract +*.structure.tsx # Structured Component internal declaration +*.web.tsx / *.css # Opaque renderer source, authoring SDKの所有外 +``` + +Manifest は Component の公開意味を持つ。Structure は Structured Component の内部宣言を持つ。Opaque renderer の React / CSS 実装はこの package に含めず、Manifest がその entry と binding key を宣言する。 + +## 4. Static DSL contract + +Presentation Orchestrator、Theme、Manifest、Structure は静的解析可能な制限付き DSL とする。 + +- 許可された declaration と expression を明示する。 +- declaration は AST から Declaration Graph へ lower できなければならない。 +- Stable ID と source correlation を失う暗黙生成を避ける。 +- dynamic ID、loop / `map` による topology 生成、dynamic import、任意関数呼び出しを許可しない。 +- filesystem、network、process environment、clock、random、DOM、React state に依存しない。 +- arbitrary JS を必要とする処理は Opaque renderer source に閉じ込める。 +- Preview source は build input や contract の正本にしない。 + +具体的な許可構文の判定、parse、typecheck、symbol resolution は Compiler が所有する。 + +M1 の declaration source は、各 file が対応する public definition builder の直接呼出しを default export する。 + +```ts +import { definePresentation } from "@unframe/presentation"; + +export default definePresentation({ + // JSON-like declaration +}); +``` + +Compiler が認識するのは、locked `@unframe/presentation` package の root export として TypeChecker で provenance を検証できる named value import だけである。named import alias は元の public export を保持する場合に限り許可する。引数は JSON-like literal と、同じ規則で認識された builder の直接呼出しに限定する。 + +JSX、任意関数、loop / branch、dynamic import、property access、spread、template expression、local variable / function を経由した builder alias は M1 では lower せず、stable diagnostic で拒否する。Source module と builder function を実行せず、AST から Declaration Graph へ lower する。JSX-first authoring、local const 参照、より広い static expression は後続の明示的な contract 判断まで追加しない。 + +## 5. Invariants + +- `presentation.unframe.tsx` は Component の配置と接続を行う composition root であり、Component 内部を展開した正本にしない。 +- Component Action は canonical Action batch、Output は明示された canonical event source / Trigger へ compile-time に完全に lower できる。 +- Structured / Opaque mode は Component version ごとに一つに固定する。 +- GUI が編集できる範囲は Manifest と Structure が宣言した semantic boundary を超えない。 +- renderer の DOM、React tree、capture result から authoring semantics を逆推論しない。 +- Detach は authoring operation であり、Delivery artifact から source を復元しない。 + +## 6. Non-responsibilities + +- Authoring Source の parse、typecheck、AST lowering +- Declaration Graph normalization と semantic validation +- Opaque renderer bundle / execution +- renderer artifact generation +- project filesystem、lockfile、cache +- Web Editor UI と history state +- Control Plane persistence と publish +- Runtime progression evaluation + +## 7. Dependency rules + +`presentation-authoring` は `presentation-core` と Zod 4 にだけ依存する。Compiler、Web Editor、Component package がこの package を利用する。Compiler や Web Editor への逆依存は禁止する。 + +## 8. Validation strategy + +- declaration の型テスト +- AST fixture に対する recognized signature の contract test +- valid / invalid Manifest、Structure、Theme fixture +- Semantic Command と code patch の semantic equivalence fixture +- Structured / Opaque boundary と Action / Output lowering の fixture +- package import が filesystem、Browser、network side effect を起こさないことのテスト + +公開 builder / definition の入力は、descriptor ベースの null-prototype plain-data snapshot で accessor、cycle、sparse array、symbol key、非 JSON 値を先に拒否してから Zod 4 schema に渡す。snapshot 後は caller-owned object を再読せず、配列長も own data descriptor から取得して inherited getter と Proxy の `get` trap を実行しない。Zod は string、number、tuple、record、enum、discriminated union と declaration の構造を検証する。参照の存在、一意性、tree、owner 継承のように複数 declaration を横断する意味論だけは Compiler / Core の責務として残す。 + +definition ごとの pure type guard は builder と同じ local declaration validation を共有する。Compiler は post-lowering value の検査にこの guard を利用できるが、builder implementation や Authoring Source は実行しない。 + +## 9. Deferred decisions + +- M1 より広い Static DSL、JSX-first authoring、local const / expression evaluation +- Lossless Syntax Tree / source patching library +- `unframe.lock` と Component package distribution の形式 +- public API の正確な naming と versioning + +## 10. Current implementation + +現在は最初の reference Authoring Project に必要な次の宣言 API を提供する。 + +- `definePresentation`、`defineTheme`、`defineComponentManifest`、`defineComponentStructure` +- Props、Slots、Parts、Variants、States、Actions、Outputs の builder +- Token、Named Style、Asset reference +- Stage、Flow、resource owner / audience、Component Instance と package lock +- Spatial、Semantic Surface、absolute layout の Frame / Text +- Structured Component の Part / Slot mapping と Opaque Component の semantic binding +- topology を変更しない semantic override と Structured Component の Detach vocabulary + +Topology を持つ宣言は explicit ID を必須とする。source metadata は Compiler が AST から付与するため入力では任意とし、source correlation と diagnostic に共有できる型を提供する。API は finite な JSON plain data だけを受け取り、import 時登録、暗黙 ID、入力 mutation、function 値を持たない。 + +最初の実装は static / non-interactive / baked-web Surface、absolute layout、flat Scalar payload に限定する。API 境界では空 ID、非 finite な数値、不正な source range、JSON で表現できない値を拒否する。参照の存在、一意性、tree、owner 継承、Manifest と Structure の整合性は declaration を横断するため、ここでは検証せず Compiler / Core の semantic validation に残す。 + +parse、AST lowering、reference resolution、normalization、renderer、filesystem は実装せず、それぞれ Compiler、Core、concrete renderer の境界に残す。 + +現時点の reference Authoring Project は package test 内の inline fixture であり、Stage、Surface Component、Semantic Tree、Flow を current contract へ lower できる入力として固定している。公開用の `examples/presentation/` source は Compiler / CLI と同じ品質ゲートで実行できる段階に追加する。 diff --git a/packages/presentation-authoring/package.json b/packages/presentation-authoring/package.json new file mode 100644 index 0000000..7b3b5f0 --- /dev/null +++ b/packages/presentation-authoring/package.json @@ -0,0 +1,24 @@ +{ + "name": "@unframe/presentation", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "lint": "vp check src test", + "test": "vp test run", + "check": "pnpm typecheck && pnpm lint && pnpm test" + }, + "dependencies": { + "@unframe/presentation-core": "workspace:*", + "zod": "4.4.3" + }, + "devDependencies": { + "typescript": "^7.0.0", + "vite-plus": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/presentation-authoring/src/api/definitions.ts b/packages/presentation-authoring/src/api/definitions.ts new file mode 100644 index 0000000..b3982f6 --- /dev/null +++ b/packages/presentation-authoring/src/api/definitions.ts @@ -0,0 +1,594 @@ +import { z } from "zod"; +import { isDeclaration, snapshotDeclaration } from "../internal/declaration-validation.js"; +import type { + SourceMetadata, + StableDeclaration, + ResourceOwner, + StringPropDeclaration, + NumberPropDeclaration, + BooleanPropDeclaration, + SlotDeclaration, + PartDeclaration, + VariantDeclaration, + StateDeclaration, + ActionPrecondition, + ActionEffect, + ActionDeclaration, + OutputProducer, + OutputDeclaration, + TokenReference, + NamedStyleReference, + AssetReference, + SpatialDeclaration, + AbsoluteLayoutDeclaration, + SemanticOverrideDeclaration, + FrameDeclaration, + TextDeclaration, + SurfaceDeclaration, + ContentNodeDeclaration, + ComponentInstanceDeclaration, + DetachDeclaration, + ThemeDeclaration, + ComponentManifest, + ComponentStructure, + ComponentActionInvocation, + ComponentOutputReference, + CueDeclaration, + FlowDeclaration, + PresentationDeclaration, +} from "../domain/declarations.js"; + +type WithoutKind = Omit; +type WithoutStableKind = Omit; +type Exact = T & Record, never>; + +const invalid = (message: string): never => { + throw new TypeError(message); +}; + +const idSchema = z.string().min(1); +const finiteNumberSchema = z.number().finite(); +const nonNegativeIntegerSchema = z.number().int().safe().nonnegative(); +const positiveSafeIntegerSchema = z.number().int().safe().positive(); +const jsonValueSchema: z.ZodType = z.lazy(() => + z.union([ + z.null(), + z.boolean(), + finiteNumberSchema, + z.string(), + z.array(jsonValueSchema), + z.record(z.string(), jsonValueSchema), + ]), +); +const stringPropSchema = z.object({ + required: z.boolean().optional(), + default: z.string().optional(), +}); +const numberPropSchema = z.object({ + required: z.boolean().optional(), + default: finiteNumberSchema.optional(), +}); +const booleanPropSchema = z.object({ + required: z.boolean().optional(), + default: z.boolean().optional(), +}); +const slotSchema = z.object({ + accepts: z.array(idSchema), + cardinality: z.enum(["one", "many"]), + required: z.boolean().optional(), +}); +const partSchema = z.object({ overridable: z.array(z.enum(["content", "placement", "style"])) }); +const variantSchema = z.object({ values: z.array(idSchema), default: idSchema.optional() }); +const stateSchema = z.object({ initial: z.boolean().optional() }); + +const assertSchema = (schema: z.ZodType, value: unknown, message: string): void => { + if (!schema.safeParse(value).success) invalid(message); +}; + +const assertId: (value: unknown, label?: string) => asserts value is string = ( + value, + label = "id", +) => { + if (!idSchema.safeParse(value).success) invalid(`${label} must be a non-empty id.`); +}; +const assertFinite = (values: readonly number[], label: string, positive = false): void => { + const schema = z.array(positive ? finiteNumberSchema.positive() : finiteNumberSchema); + if (!schema.safeParse(values).success) + invalid(`${label} must contain ${positive ? "positive " : ""}finite numbers.`); +}; +const assertVector = ( + values: readonly number[], + length: number, + label: string, + positive = false, +): void => { + if (!z.array(finiteNumberSchema).length(length).safeParse(values).success) + invalid(`${label} must contain exactly ${length} numbers.`); + assertFinite(values, label, positive); +}; +const assertSource = (source: SourceMetadata | undefined): void => { + if (source === undefined) return; + assertId(source.file, "source.file"); + const rangeSchema = z + .tuple([nonNegativeIntegerSchema, nonNegativeIntegerSchema]) + .refine(([start, end]) => start <= end); + if (source.range !== undefined && !rangeSchema.safeParse(source.range).success) + invalid("source.range must be ordered non-negative integer offsets."); +}; + +const assertJsonSafe = (value: T): T => { + const snapshot = snapshotDeclaration(value); + if (!jsonValueSchema.safeParse(snapshot).success) + invalid("Declarations must contain finite JSON numbers."); + return snapshot as T; +}; + +const defineStable = (value: T): T => { + const declaration = assertJsonSafe(value); + assertId(declaration.id); + assertSource(declaration.source); + return value; +}; +const build = (value: T): T => { + assertJsonSafe(value); + return value; +}; + +const assertStableNested = (value: StableDeclaration, label: string): void => { + assertId(value.id, label); + assertSource(value.source); +}; +const assertRecordKeys = (value: Readonly>, label: string): void => { + if (!z.record(idSchema, z.unknown()).safeParse(value).success) + invalid(`${label} must be a non-empty id.`); +}; +const assertOwner = (owner: ResourceOwner): void => { + const result = z + .discriminatedUnion("kind", [ + z.object({ kind: z.literal("presentation") }), + z.object({ kind: z.literal("group"), groupId: idSchema }), + ]) + .safeParse(owner); + if (!result.success) invalid("owner.groupId must be a non-empty id."); +}; +const assertLayout = (layout: AbsoluteLayoutDeclaration): void => { + const result = z + .object({ + kind: z.literal("absolute"), + x: finiteNumberSchema, + y: finiteNumberSchema, + width: finiteNumberSchema.positive(), + height: finiteNumberSchema.positive(), + }) + .safeParse(layout); + if (!result.success) { + const dimensions = result.error.issues.some( + ({ path }) => path[0] === "width" || path[0] === "height", + ); + invalid( + dimensions + ? "layout size must contain positive finite numbers." + : "layout position must contain finite numbers.", + ); + } +}; +const assertSpatialFields = (value: SpatialDeclaration): void => { + assertStableNested(value, "spatial node id"); + assertOwner(value.owner); + if (value.parent.kind === "node") assertId(value.parent.nodeId, "spatial parent nodeId"); + assertVector(value.transform.position, 3, "transform.position"); + assertVector(value.transform.rotation, 4, "transform.rotation"); + assertVector(value.transform.scale, 3, "transform.scale", true); + if (!nonNegativeIntegerSchema.safeParse(value.order).success) + invalid("Spatial order must be a non-negative integer."); + if (!finiteNumberSchema.min(0).max(1).safeParse(value.opacity).success) + invalid("Spatial opacity must be between 0 and 1."); +}; +const assertComponentInstanceIds = (value: ComponentInstanceDeclaration): void => { + assertStableNested(value, "component instance id"); + assertId(value.componentId, "componentId"); + assertId(value.spatialNodeId, "spatialNodeId"); + assertOwner(value.owner); + assertId(value.packageLock.packageVersion, "packageLock.packageVersion"); + assertId(value.packageLock.packageIntegrity, "packageLock.packageIntegrity"); + assertId(value.packageLock.manifestHash, "packageLock.manifestHash"); + if (value.packageLock.structureHash !== undefined) + assertId(value.packageLock.structureHash, "packageLock.structureHash"); + assertRecordKeys(value.slots, "slot binding id"); + for (const targetIds of Object.values(value.slots)) + for (const targetId of targetIds) assertId(targetId, "slot binding targetId"); + assertRecordKeys(value.variants, "variant id"); + for (const variantValue of Object.values(value.variants)) assertId(variantValue, "variant value"); + for (const partOverride of value.partOverrides) assertId(partOverride.partId, "part override id"); + assertRecordKeys(value.props, "prop binding id"); +}; +const assertContentIds = (node: ContentNodeDeclaration): void => { + assertStableNested(node, "content node id"); + assertLayout(node.layout); + if (node.kind === "frame") for (const child of node.children) assertContentIds(child); +}; +const assertSurfaceIds = ( + value: Pick< + SurfaceDeclaration, + | "id" + | "source" + | "physicalSizeMeters" + | "logicalSize" + | "root" + | "baseSemanticTree" + | "initialStateId" + | "states" + | "interactions" + >, +): void => { + assertStableNested(value, "surface id"); + assertContentIds(value.root); + assertVector(value.physicalSizeMeters, 2, "physicalSizeMeters", true); + assertVector(value.logicalSize, 2, "logicalSize", true); + assertSurfaceSemanticIds(value); +}; +const assertSurfaceSemanticIds = ( + value: Pick< + SurfaceDeclaration, + "baseSemanticTree" | "initialStateId" | "states" | "interactions" + >, +): void => { + if (Object.keys(value.interactions).length !== 0) + invalid("The initial non-interactive Surface milestone requires empty interactions."); + assertId(value.initialStateId, "initialStateId"); + for (const rootNodeId of value.baseSemanticTree.rootNodeIds) + assertId(rootNodeId, "semantic root id"); + assertRecordKeys(value.baseSemanticTree.nodes, "semantic node record key"); + for (const node of Object.values(value.baseSemanticTree.nodes)) { + assertStableNested(node, "semantic node id"); + if (node.parentId !== null) assertId(node.parentId, "semantic parentId"); + if (node.interactionId !== undefined) + invalid("The initial non-interactive Surface milestone forbids semantic interactionId."); + } + assertRecordKeys(value.states, "surface state record key"); + for (const stateValue of Object.values(value.states)) { + assertStableNested(stateValue, "surface state id"); + if (stateValue.enabledInteractionIds.length !== 0) + invalid("The initial non-interactive Surface milestone cannot enable interactions."); + for (const interactionId of stateValue.enabledInteractionIds) + assertId(interactionId, "enabledInteractionId"); + for (const stateOverride of stateValue.semanticOverrides) { + assertStableNested(stateOverride, "semantic override id"); + assertId(stateOverride.targetId, "semantic override targetId"); + } + } +}; +const assertFlowIds = (flow: FlowDeclaration): void => { + assertId(flow.initialGroupId, "flow.initialGroupId"); + assertRecordKeys(flow.groups, "flow group record key"); + for (const group of Object.values(flow.groups)) { + assertStableNested(group, "flow group id"); + assertId(group.initialStepId, "flow group initialStepId"); + assertRecordKeys(group.steps, "flow step record key"); + for (const step of Object.values(group.steps)) { + assertStableNested(step, "flow step id"); + for (const cueValue of step.cues) { + assertStableNested(cueValue, "cue id"); + if (cueValue.trigger.kind === "event") assertId(cueValue.trigger.event, "cue event"); + else { + assertId(cueValue.trigger.componentInstanceId, "cue output componentInstanceId"); + assertId(cueValue.trigger.outputId, "cue outputId"); + } + for (const invocation of cueValue.actions) { + assertId(invocation.componentInstanceId, "cue action componentInstanceId"); + assertId(invocation.actionId, "cue actionId"); + assertRecordKeys(invocation.arguments, "cue action argument id"); + } + if (cueValue.toStepId !== undefined) assertId(cueValue.toStepId, "cue toStepId"); + if (cueValue.toGroupId !== undefined) assertId(cueValue.toGroupId, "cue toGroupId"); + } + } + } + assertRecordKeys(flow.variables, "flow variable record key"); + for (const variable of Object.values(flow.variables)) { + assertStableNested(variable, "flow variable id"); + assertOwner(variable.owner); + } +}; + +const assertPresentationDeclaration = (value: unknown): void => { + const declaration = assertJsonSafe(value) as PresentationDeclaration; + assertVector(declaration.stage.size, 3, "stage.size", true); + assertFlowIds(declaration.flow); + for (const node of declaration.scene.spatial) { + assertSpatialFields(node); + } + for (const instance of declaration.scene.components) { + assertComponentInstanceIds(instance); + } + for (const reference of declaration.assets) assertId(reference.assetId, "assetId"); + if (declaration.theme !== undefined) assertId(declaration.theme.themeId, "themeId"); + for (const operation of declaration.operations) { + assertStableNested(operation, "operation id"); + assertId(operation.instanceId, "operation instanceId"); + assertId(operation.provenance.componentId, "operation provenance componentId"); + } + assertStableNested(declaration, "id"); +}; +const assertThemeDeclaration = (value: unknown): void => { + const declaration = assertJsonSafe(value) as ThemeDeclaration; + assertRecordKeys(declaration.tokens, "theme token id"); + assertRecordKeys(declaration.namedStyles, "named style id"); + assertStableNested(declaration, "id"); +}; +const assertComponentManifest = (value: unknown): void => { + const declaration = assertJsonSafe(value) as ComponentManifest; + assertId(declaration.componentId, "componentId"); + assertSource(declaration.source); + if (!positiveSafeIntegerSchema.safeParse(declaration.version).success) + invalid("Component version must be a positive integer."); + for (const [label, members] of Object.entries({ + prop: declaration.props, + slot: declaration.slots, + part: declaration.parts, + variant: declaration.variants, + state: declaration.states, + action: declaration.actions, + output: declaration.outputs, + })) + assertRecordKeys(members, `${label} id`); + for (const actionValue of Object.values(declaration.actions)) { + assertRecordKeys(actionValue.inputs, "action input id"); + if (!z.array(z.unknown()).min(1).safeParse(actionValue.effects).success) + invalid("Component actions must declare at least one effect."); + for (const precondition of actionValue.preconditions) { + assertId(precondition.surfaceId, "action precondition surfaceId"); + assertId(precondition.stateId, "action precondition stateId"); + } + for (const effect of actionValue.effects) { + if (effect.kind === "setSurfaceState") { + assertId(effect.surfaceId, "action effect surfaceId"); + assertId(effect.stateId, "action effect stateId"); + } else assertId(effect.timelineId, "action effect timelineId"); + } + } + for (const slotValue of Object.values(declaration.slots)) + for (const accepted of slotValue.accepts) assertId(accepted, "slot accepted kind"); + for (const variantValue of Object.values(declaration.variants)) { + for (const option of variantValue.values) assertId(option, "variant value"); + if (variantValue.default !== undefined) assertId(variantValue.default, "variant default"); + } + for (const outputValue of Object.values(declaration.outputs)) { + assertRecordKeys(outputValue.payload, "output payload id"); + if (outputValue.producer.kind === "surfaceInteraction") + assertId(outputValue.producer.interactionId, "output producer interactionId"); + else if (outputValue.producer.kind === "timelineCompleted") + assertId(outputValue.producer.timelineId, "output producer timelineId"); + else if (outputValue.producer.kind === "mediaCompleted") + assertId(outputValue.producer.surfaceId, "output producer surfaceId"); + else if ( + !finiteNumberSchema.nonnegative().safeParse(outputValue.producer.afterMilliseconds).success + ) + invalid("output producer afterMilliseconds must be a non-negative finite number."); + } + if (!("semantics" in declaration)) { + for (const rendererId of declaration.renderers) assertId(rendererId, "renderer id"); + } else { + assertRecordKeys(declaration.renderers, "renderer id"); + for (const renderer of Object.values(declaration.renderers)) { + assertId(renderer.entry, "renderer entry"); + for (const bindingKey of renderer.bindingKeys) assertId(bindingKey, "renderer bindingKey"); + } + for (const target of declaration.semantics.targets) { + assertId(target.id, "opaque semantic target id"); + if (target.bindingKey !== undefined) assertId(target.bindingKey, "opaque bindingKey"); + } + for (const semanticSurface of declaration.semantics.surfaces) { + assertId(semanticSurface.id, "opaque surface id"); + assertId(semanticSurface.bindingKey, "opaque surface bindingKey"); + assertSurfaceSemanticIds(semanticSurface); + } + } +}; +const assertComponentStructure = (value: unknown): void => { + const declaration = assertJsonSafe(value) as ComponentStructure; + assertId(declaration.componentId, "componentId"); + assertRecordKeys(declaration.partBindings, "part binding id"); + for (const targetId of Object.values(declaration.partBindings)) + assertId(targetId, "part binding targetId"); + assertRecordKeys(declaration.slotPlacements, "slot placement id"); + for (const targetId of Object.values(declaration.slotPlacements)) + assertId(targetId, "slot placement targetId"); + for (const timeline of declaration.timelines) assertStableNested(timeline, "timeline id"); + if (declaration.root.kind === "surface") assertSurfaceIds(declaration.root); + else assertContentIds(declaration.root); + assertStableNested(declaration, "id"); +}; + +export const isPresentationDeclaration = (value: unknown): value is PresentationDeclaration => + isDeclaration(value, assertPresentationDeclaration); +export const isThemeDeclaration = (value: unknown): value is ThemeDeclaration => + isDeclaration(value, assertThemeDeclaration); +export const isComponentManifest = (value: unknown): value is ComponentManifest => + isDeclaration(value, assertComponentManifest); +export const isComponentStructure = (value: unknown): value is ComponentStructure => + isDeclaration(value, assertComponentStructure); + +export const definePresentation = (value: T): T => { + assertPresentationDeclaration(value); + return value; +}; +export const defineTheme = (value: T): T => { + assertThemeDeclaration(value); + return value; +}; +export const defineComponentManifest = (value: T): T => { + assertComponentManifest(value); + return value; +}; +export const defineComponentStructure = (value: T): T => { + assertComponentStructure(value); + return value; +}; + +export const stringProp = >(value: T) => { + const declaration = assertJsonSafe(value); + assertSchema(stringPropSchema, declaration, "Invalid string prop declaration."); + return build({ ...declaration, kind: "string" as const }); +}; +export const numberProp = >(value: T) => { + const declaration = assertJsonSafe(value); + assertSchema(numberPropSchema, declaration, "Invalid number prop declaration."); + return build({ ...declaration, kind: "number" as const }); +}; +export const booleanProp = >(value: T) => { + const declaration = assertJsonSafe(value); + assertSchema(booleanPropSchema, declaration, "Invalid boolean prop declaration."); + return build({ ...declaration, kind: "boolean" as const }); +}; +export const slot = >(value: T) => { + const declaration = assertJsonSafe(value); + assertSchema(slotSchema, declaration, "Invalid slot declaration."); + return build({ ...declaration, kind: "slot" as const }); +}; +export const part = >(value: T) => { + const declaration = assertJsonSafe(value); + assertSchema(partSchema, declaration, "Invalid part declaration."); + return build({ ...declaration, kind: "part" as const }); +}; +export const variant = >(value: T) => { + const declaration = assertJsonSafe(value); + assertSchema(variantSchema, declaration, "Invalid variant declaration."); + return build({ ...declaration, kind: "variant" as const }); +}; +export function state(): StateDeclaration; +export function state>( + value: T, +): T & { kind: "state" }; +export function state(value: WithoutKind = {}): StateDeclaration { + const declaration = assertJsonSafe(value); + assertSchema(stateSchema, declaration, "Invalid state declaration."); + return build({ ...declaration, kind: "state" }); +} +export const action = >(value: T) => { + const declaration = assertJsonSafe(value); + if (!z.array(z.unknown()).min(1).safeParse(declaration.effects).success) + invalid("Component actions must declare at least one effect."); + return build({ ...declaration, kind: "action" as const }); +}; +export const output = >(value: T) => { + const declaration = assertJsonSafe(value); + return build({ ...declaration, kind: "output" as const }); +}; + +export const surfaceState = (surfaceId: string, stateId: string): ActionPrecondition => { + assertId(surfaceId, "surfaceId"); + assertId(stateId, "stateId"); + return { kind: "surfaceState", surfaceId, stateId }; +}; +export const setSurfaceState = (surfaceId: string, stateId: string): ActionEffect => { + assertId(surfaceId, "surfaceId"); + assertId(stateId, "stateId"); + return { kind: "setSurfaceState", surfaceId, stateId }; +}; +export const playTimeline = ( + timelineId: string, + options: { completion: "blocking" | "nonBlocking" }, +): ActionEffect => { + assertId(timelineId, "timelineId"); + const declaration = assertJsonSafe(options); + if (!z.object({ completion: z.enum(["blocking", "nonBlocking"]) }).safeParse(declaration).success) + invalid("completion must be blocking or nonBlocking."); + return build({ kind: "playTimeline", timelineId, ...declaration }); +}; +export const surfaceInteraction = (interactionId: string): OutputProducer => { + assertId(interactionId, "interactionId"); + return { kind: "surfaceInteraction", interactionId }; +}; +export const timelineCompleted = (timelineId: string): OutputProducer => { + assertId(timelineId, "timelineId"); + return { kind: "timelineCompleted", timelineId }; +}; +export const mediaCompleted = (surfaceId: string): OutputProducer => { + assertId(surfaceId, "surfaceId"); + return { kind: "mediaCompleted", surfaceId }; +}; +export const after = (afterMilliseconds: number): OutputProducer => { + if (!finiteNumberSchema.nonnegative().safeParse(afterMilliseconds).success) + invalid("afterMilliseconds must be a non-negative finite number."); + return { kind: "timer", afterMilliseconds }; +}; +export const invokeComponentAction = >( + value: T, +) => { + const declaration = assertJsonSafe(value); + assertId(declaration.componentInstanceId, "componentInstanceId"); + assertId(declaration.actionId, "actionId"); + return build({ ...declaration, kind: "component.action" as const }); +}; +export const componentOutput = >( + value: T, +) => { + const declaration = assertJsonSafe(value); + assertId(declaration.componentInstanceId, "componentInstanceId"); + assertId(declaration.outputId, "outputId"); + return build({ ...declaration, kind: "component.output" as const }); +}; +export const cue = (value: T): T => defineStable(value); + +export const tokenRef = >(value: T) => { + const declaration = assertJsonSafe(value); + assertId(declaration.tokenId, "tokenId"); + return build({ ...declaration, kind: "token-ref" as const }); +}; +export const namedStyleRef = >(value: T) => { + const declaration = assertJsonSafe(value); + assertId(declaration.styleId, "styleId"); + return build({ ...declaration, kind: "named-style-ref" as const }); +}; +export const assetRef = >(value: T) => { + const declaration = assertJsonSafe(value); + assertId(declaration.assetId, "assetId"); + return build({ ...declaration, kind: "asset-ref" as const }); +}; + +export const spatial = >(value: T) => { + const snapshot = assertJsonSafe(value); + const declaration = { ...snapshot, kind: "spatial" as const }; + assertSpatialFields(declaration); + return defineStable(declaration); +}; +export const frame = >(value: T) => { + const declaration = assertJsonSafe(value); + assertLayout(declaration.layout); + return defineStable({ ...declaration, kind: "frame" as const }); +}; +export const text = >(value: T) => { + const declaration = assertJsonSafe(value); + assertLayout(declaration.layout); + return defineStable({ ...declaration, kind: "text" as const }); +}; +export const surface = >(value: T) => { + const snapshot = assertJsonSafe(value); + const declaration = { ...snapshot, kind: "surface" as const }; + assertSurfaceIds(declaration); + return defineStable(declaration); +}; +export const semanticOverride = >( + value: Exact>, +) => { + const declaration = assertJsonSafe(value); + assertId(declaration.targetId, "targetId"); + return defineStable({ ...declaration, kind: "semantic-override" as const }); +}; +export const componentInstance = >( + value: T, +) => { + const snapshot = assertJsonSafe(value); + assertId(snapshot.componentId, "componentId"); + assertId(snapshot.spatialNodeId, "spatialNodeId"); + const declaration = { ...snapshot, kind: "component-instance" as const }; + assertComponentInstanceIds(declaration); + return defineStable(declaration); +}; +export const detach = >(value: T) => { + const declaration = assertJsonSafe(value); + assertId(declaration.instanceId, "instanceId"); + assertId(declaration.provenance.componentId, "provenance.componentId"); + return defineStable({ ...declaration, kind: "detach" as const }); +}; diff --git a/packages/presentation-authoring/src/domain/declarations.ts b/packages/presentation-authoring/src/domain/declarations.ts new file mode 100644 index 0000000..b623417 --- /dev/null +++ b/packages/presentation-authoring/src/domain/declarations.ts @@ -0,0 +1,321 @@ +import type { Diagnostic } from "@unframe/presentation-core"; + +export type Json = + | null + | boolean + | number + | string + | readonly Json[] + | { readonly [key: string]: Json }; +export type Scalar = null | boolean | number | string; +export type ScalarType = "null" | "boolean" | "number" | "string"; + +export type SourceMetadata = { + file: string; + range?: readonly [start: number, end: number]; +}; +export type StableDeclaration = { id: string; source?: SourceMetadata }; +export type AuthoringDiagnostic = Diagnostic & { + severity?: "error" | "warning"; + source?: SourceMetadata; +}; + +export type ResourceOwner = { kind: "presentation" } | { kind: "group"; groupId: string }; +export type ProjectionAudience = { kind: "all" } | { kind: "role"; role: "presenter" | "viewer" }; + +export type StringPropDeclaration = { kind: "string"; required?: boolean; default?: string }; +export type NumberPropDeclaration = { kind: "number"; required?: boolean; default?: number }; +export type BooleanPropDeclaration = { kind: "boolean"; required?: boolean; default?: boolean }; +export type PropDeclaration = + | StringPropDeclaration + | NumberPropDeclaration + | BooleanPropDeclaration; + +export type SlotDeclaration = { + kind: "slot"; + accepts: readonly string[]; + cardinality: "one" | "many"; + required?: boolean; +}; +export type PartDeclaration = { + kind: "part"; + overridable: readonly ("content" | "placement" | "style")[]; +}; +export type VariantDeclaration = { + kind: "variant"; + values: readonly string[]; + default?: string; +}; +export type StateDeclaration = { kind: "state"; initial?: boolean }; + +export type ActionValue = + | { kind: "literal"; value: Scalar } + | { kind: "eventPayload"; field: string } + | { kind: "variable"; variableId: string }; +export type ActionPrecondition = { + kind: "surfaceState"; + surfaceId: string; + stateId: string; +}; +export type ActionEffect = + | { kind: "setSurfaceState"; surfaceId: string; stateId: string } + | { + kind: "playTimeline"; + timelineId: string; + completion: "blocking" | "nonBlocking"; + }; +export type ActionDeclaration = { + kind: "action"; + inputs: Readonly>; + preconditions: readonly ActionPrecondition[]; + effects: readonly ActionEffect[]; +}; + +export type OutputPayloadField = + | { type: "null"; value: null } + | { type: "boolean"; value: boolean } + | { type: "number"; value: number } + | { type: "string"; value: string }; +export type OutputProducer = + | { kind: "surfaceInteraction"; interactionId: string } + | { kind: "timelineCompleted"; timelineId: string } + | { kind: "mediaCompleted"; surfaceId: string } + | { kind: "timer"; afterMilliseconds: number }; +export type OutputDeclaration = { + kind: "output"; + payload: Readonly>; + producer: OutputProducer; +}; + +export type TokenReference = { kind: "token-ref"; tokenId: string }; +export type NamedStyleReference = { kind: "named-style-ref"; styleId: string }; +export type AssetReference = { kind: "asset-ref"; assetId: string }; + +export type TransformDeclaration = { + position: readonly [number, number, number]; + rotation: readonly [number, number, number, number]; + scale: readonly [number, number, number]; +}; +export type StageDeclaration = { + coordinateSystem: { + unit: "meter"; + handedness: "right"; + upAxis: "+Y"; + forwardAxis: "-Z"; + }; + size: readonly [number, number, number]; +}; + +export type SpatialDeclaration = StableDeclaration & { + kind: "spatial"; + name: string; + owner: ResourceOwner; + audience: ProjectionAudience; + parent: { kind: "stage" } | { kind: "node"; nodeId: string }; + order: number; + transform: TransformDeclaration; + active: boolean; + visible: boolean; + opacity: number; +}; +export type AbsoluteLayoutDeclaration = { + kind: "absolute"; + x: number; + y: number; + width: number; + height: number; +}; + +export type SemanticNodeDeclaration = StableDeclaration & { + parentId: string | null; + order: number; + role: "heading" | "paragraph" | "image" | "button" | "table" | "list" | "listItem"; + text?: string; + language?: string; + alt?: string; + interactionId?: never; +}; +export type InteractionDeclaration = StableDeclaration & { + kind: "click"; + event: string; +}; +export type SemanticOverrideDeclaration = StableDeclaration & { + kind: "semantic-override"; + targetId: string; + included?: boolean; + text?: string | null; + language?: string | null; + alt?: string | null; +}; +export type SurfaceStateDeclaration = StableDeclaration & { + semanticOverrides: readonly SemanticOverrideDeclaration[]; + enabledInteractionIds: readonly string[]; +}; + +export type FrameDeclaration = StableDeclaration & { + kind: "frame"; + layout: AbsoluteLayoutDeclaration; + children: readonly ContentNodeDeclaration[]; + style?: NamedStyleReference; +}; +export type TextDeclaration = StableDeclaration & { + kind: "text"; + value: string; + layout: AbsoluteLayoutDeclaration; + style?: NamedStyleReference; +}; +export type SurfaceDeclaration = StableDeclaration & { + kind: "surface"; + physicalSizeMeters: readonly [number, number]; + logicalSize: readonly [number, number]; + fit: "contain" | "cover" | "stretch"; + root: FrameDeclaration; + baseSemanticTree: { + rootNodeIds: readonly string[]; + nodes: Readonly>; + }; + interactions: Readonly>; + initialStateId: string; + states: Readonly>; + renderIntent: { + updateModel: "static"; + interaction: "none"; + internalAnimation: "none"; + rendererPreference: "baked-web"; + fallbackPolicy: "reject"; + }; +}; +export type ContentNodeDeclaration = FrameDeclaration | TextDeclaration; +export type StructureRootDeclaration = SurfaceDeclaration | FrameDeclaration; + +export type PartOverrideDeclaration = { + partId: string; + content?: Json; + placement?: AbsoluteLayoutDeclaration; + style?: Readonly>; +}; +export type ComponentPackageLock = { + packageVersion: string; + packageIntegrity: string; + manifestHash: string; + structureHash?: string; +}; +export type ComponentInstanceDeclaration = StableDeclaration & { + kind: "component-instance"; + componentId: string; + version: number; + packageLock: ComponentPackageLock; + owner: ResourceOwner; + spatialNodeId: string; + props: Readonly>; + slots: Readonly>; + variants: Readonly>; + partOverrides: readonly PartOverrideDeclaration[]; +}; +export type DetachDeclaration = StableDeclaration & { + kind: "detach"; + mode: "structured"; + instanceId: string; + provenance: { componentId: string; version: number }; +}; + +export type ThemeDeclaration = StableDeclaration & { + tokens: Readonly>; + namedStyles: Readonly>>>; +}; + +export type ComponentManifestMembers = { + props: Readonly>; + slots: Readonly>; + parts: Readonly>; + variants: Readonly>; + states: Readonly>; + actions: Readonly>; + outputs: Readonly>; +}; +export type OpaqueSemanticTarget = { + id: string; + kind: "node" | "timeline" | "variable" | "media"; + bindingKey?: string; +}; +export type OpaqueSurfaceSemanticAdapter = { + id: string; + bindingKey: string; + baseSemanticTree: SurfaceDeclaration["baseSemanticTree"]; + interactions: SurfaceDeclaration["interactions"]; + initialStateId: string; + states: SurfaceDeclaration["states"]; +}; +export type OpaqueSemantics = { + targets: readonly OpaqueSemanticTarget[]; + surfaces: readonly OpaqueSurfaceSemanticAdapter[]; +}; +export type ComponentManifest = ComponentManifestMembers & { + componentId: string; + version: number; + source?: SourceMetadata; +} & ( + | { + authoring: { mode: "structured"; structure: string }; + renderers: readonly string[]; + } + | { + authoring: { mode: "opaque" }; + renderers: Readonly>; + semantics: OpaqueSemantics; + } + ); + +export type ComponentStructure = StableDeclaration & { + componentId: string; + root: StructureRootDeclaration; + partBindings: Readonly>; + slotPlacements: Readonly>; + timelines: readonly StableDeclaration[]; +}; + +export type ComponentActionInvocation = { + kind: "component.action"; + componentInstanceId: string; + actionId: string; + arguments: Readonly>; +}; +export type ComponentOutputReference = { + kind: "component.output"; + componentInstanceId: string; + outputId: string; +}; +export type CueTrigger = { kind: "event"; event: string } | ComponentOutputReference; +export type CueDeclaration = StableDeclaration & { + trigger: CueTrigger; + actions: readonly ComponentActionInvocation[]; + toStepId?: string; + toGroupId?: string; +}; +export type FlowStepDeclaration = StableDeclaration & { cues: readonly CueDeclaration[] }; +export type FlowGroupDeclaration = StableDeclaration & { + initialStepId: string; + steps: Readonly>; +}; +export type VariableDeclaration = + | (StableDeclaration & { owner: ResourceOwner; type: "null"; initialValue: null }) + | (StableDeclaration & { owner: ResourceOwner; type: "boolean"; initialValue: boolean }) + | (StableDeclaration & { owner: ResourceOwner; type: "number"; initialValue: number }) + | (StableDeclaration & { owner: ResourceOwner; type: "string"; initialValue: string }); +export type FlowDeclaration = { + initialGroupId: string; + groups: Readonly>; + variables: Readonly>; +}; +export type PresentationDeclaration = StableDeclaration & { + metadata: { title: string }; + stage: StageDeclaration; + scene: { + spatial: readonly SpatialDeclaration[]; + components: readonly ComponentInstanceDeclaration[]; + }; + theme?: { themeId: string }; + assets: readonly AssetReference[]; + flow: FlowDeclaration; + operations: readonly DetachDeclaration[]; +}; diff --git a/packages/presentation-authoring/src/index.ts b/packages/presentation-authoring/src/index.ts new file mode 100644 index 0000000..f079d07 --- /dev/null +++ b/packages/presentation-authoring/src/index.ts @@ -0,0 +1,2 @@ +export * from "./domain/declarations.js"; +export * from "./api/definitions.js"; diff --git a/packages/presentation-authoring/src/internal/declaration-validation.ts b/packages/presentation-authoring/src/internal/declaration-validation.ts new file mode 100644 index 0000000..65fc407 --- /dev/null +++ b/packages/presentation-authoring/src/internal/declaration-validation.ts @@ -0,0 +1,85 @@ +const invalid = (message: string): never => { + throw new TypeError(message); +}; + +/** + * Copies only descriptor-backed JSON data. This prevents declaration validation + * from invoking user supplied getters while isolating declarations from inherited data. + */ +export const snapshotDeclaration = (value: unknown, ancestors = new WeakSet()): unknown => { + if (value === null || typeof value === "string" || typeof value === "boolean") return value; + if (typeof value === "number") return value; + if (typeof value !== "object") invalid("Declarations must contain JSON-safe values."); + + const object = value as object; + if (ancestors.has(object)) invalid("Declarations must not contain cycles."); + ancestors.add(object); + try { + if (Array.isArray(value)) { + const keys = Reflect.ownKeys(value); + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length"); + if (lengthDescriptor === undefined || !("value" in lengthDescriptor)) + invalid("Declarations must contain data properties."); + const length = (lengthDescriptor as PropertyDescriptor & { value: unknown }).value; + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffff_ffff) + invalid("Declarations must contain valid array lengths."); + if ( + Object.keys(value).length !== length || + keys.some( + (key) => + key !== "length" && + (typeof key !== "string" || !/^(0|[1-9]\d*)$/.test(key) || Number(key) >= length), + ) + ) + invalid("Declarations must not contain sparse arrays or custom array properties."); + const snapshot: unknown[] = []; + for (let index = 0; index < length; index++) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (descriptor === undefined || !("value" in descriptor)) + invalid("Declarations must contain data properties."); + snapshot.push( + snapshotDeclaration( + (descriptor as PropertyDescriptor & { value: unknown }).value, + ancestors, + ), + ); + } + return snapshot; + } + + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) + invalid("Declarations must be plain data."); + const snapshot = Object.create(null) as Record; + for (const key of Reflect.ownKeys(object)) { + if (typeof key !== "string") invalid("Declarations must use string object keys."); + const descriptor = Object.getOwnPropertyDescriptor(object, key); + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) + invalid("Declarations must contain enumerable data properties only."); + Object.defineProperty(snapshot, key, { + value: snapshotDeclaration( + (descriptor as PropertyDescriptor & { value: unknown }).value, + ancestors, + ), + enumerable: true, + configurable: true, + writable: true, + }); + } + return snapshot; + } finally { + ancestors.delete(object); + } +}; + +export const isDeclaration = ( + value: unknown, + assertDeclaration: (value: T) => void, +): value is T => { + try { + assertDeclaration(value as T); + return true; + } catch { + return false; + } +}; diff --git a/packages/presentation-authoring/test/api.test.ts b/packages/presentation-authoring/test/api.test.ts new file mode 100644 index 0000000..a1a9406 --- /dev/null +++ b/packages/presentation-authoring/test/api.test.ts @@ -0,0 +1,634 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + action, + after, + assetRef, + booleanProp, + componentOutput, + componentInstance, + cue, + defineComponentManifest, + defineComponentStructure, + definePresentation, + defineTheme, + detach, + frame, + invokeComponentAction, + isComponentManifest, + isComponentStructure, + isPresentationDeclaration, + isThemeDeclaration, + namedStyleRef, + numberProp, + output, + part, + playTimeline, + semanticOverride, + setSurfaceState, + slot, + spatial, + state, + stringProp, + surface, + surfaceState, + text, + timelineCompleted, + tokenRef, + variant, +} from "../src/index.js"; + +const absolute = { kind: "absolute" as const, x: 0, y: 0, width: 1920, height: 1080 }; +const title = text({ id: "text-title", value: "Hello", layout: absolute }); +const root = frame({ id: "frame-root", layout: absolute, children: [title] }); +const defaultState = { + id: "state-default", + semanticOverrides: [], + enabledInteractionIds: [], +} as const; +const titleSurface = surface({ + id: "surface-title", + physicalSizeMeters: [1.6, 0.9], + logicalSize: [1920, 1080], + fit: "contain", + root, + baseSemanticTree: { + rootNodeIds: ["semantic-title"], + nodes: { + "semantic-title": { + id: "semantic-title", + parentId: null, + order: 0, + role: "heading", + text: "Hello", + }, + }, + }, + interactions: {}, + initialStateId: defaultState.id, + states: { [defaultState.id]: defaultState }, + renderIntent: { + updateModel: "static", + interaction: "none", + internalAnimation: "none", + rendererPreference: "baked-web", + fallbackPolicy: "reject", + }, +}); +const surfaceNode = spatial({ + id: "surface-node-title", + name: "Title surface", + owner: { kind: "presentation" }, + audience: { kind: "all" }, + parent: { kind: "stage" }, + order: 0, + transform: { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] }, + active: true, + visible: true, + opacity: 1, +}); + +const surfaceManifest = defineComponentManifest({ + componentId: "@unframe/components/Surface", + version: 1, + authoring: { mode: "structured", structure: "./Surface.structure.ts" }, + props: { + width: numberProp({ required: true }), + height: numberProp({ required: true }), + }, + slots: { content: slot({ accepts: ["frame", "text"], cardinality: "many" }) }, + parts: { root: part({ overridable: ["placement", "style"] }) }, + variants: { fit: variant({ values: ["contain", "cover", "stretch"], default: "contain" }) }, + states: { hidden: state(), shown: state({ initial: true }) }, + actions: { + show: action({ + inputs: {}, + preconditions: [surfaceState("root", "hidden")], + effects: [ + setSurfaceState("root", "shown"), + playTimeline("reveal", { completion: "blocking" }), + ], + }), + }, + outputs: { + completed: output({ + payload: { reason: { type: "string", value: "timeline" } }, + producer: timelineCompleted("reveal"), + }), + }, + renderers: ["baked-web"], +}); +const surfaceStructure = defineComponentStructure({ + id: "surface-structure", + componentId: surfaceManifest.componentId, + root: titleSurface, + partBindings: { root: titleSurface.id }, + slotPlacements: { content: root.id }, + timelines: [{ id: "reveal" }], +}); +const titleInstance = componentInstance({ + id: "title-component", + componentId: surfaceManifest.componentId, + version: 1, + packageLock: { + packageVersion: "1.0.0", + packageIntegrity: "sha256-package", + manifestHash: "sha256-manifest", + structureHash: "sha256-structure", + }, + owner: { kind: "presentation" }, + spatialNodeId: surfaceNode.id, + props: { width: 1920, height: 1080, logo: assetRef({ assetId: "logo" }) }, + slots: { content: [title.id] }, + variants: { fit: "contain" }, + partOverrides: [{ partId: "root", style: { opacity: 1 } }], +}); + +const referencePresentation = { + id: "presentation-intro", + source: { file: "presentation.unframe.tsx", range: [0, 42] as const }, + metadata: { title: "Intro" }, + stage: { + coordinateSystem: { + unit: "meter" as const, + handedness: "right" as const, + upAxis: "+Y" as const, + forwardAxis: "-Z" as const, + }, + size: [4, 3, 2] as const, + }, + scene: { spatial: [surfaceNode], components: [titleInstance] }, + assets: [assetRef({ assetId: "logo" })], + flow: { + initialGroupId: "group-intro", + groups: { + "group-intro": { + id: "group-intro", + initialStepId: "step-intro", + steps: { "step-intro": { id: "step-intro", cues: [] } }, + }, + }, + variables: { + "optional-subtitle": { + id: "optional-subtitle", + owner: { kind: "presentation" as const }, + type: "null" as const, + initialValue: null, + }, + }, + }, + operations: [], +} as const; + +describe("reference authoring project", () => { + it("carries every source field needed by the canonical first-milestone fixture", () => { + const presentation = definePresentation(referencePresentation); + + expect(presentation).toBe(referencePresentation); + expect(presentation.stage.size).toEqual([4, 3, 2]); + expect(presentation.scene.spatial[0]?.id).toBe("surface-node-title"); + expect(presentation.scene.components[0]?.componentId).toBe("@unframe/components/Surface"); + expect(presentation.flow.groups["group-intro"]?.steps["step-intro"]?.cues).toEqual([]); + expect(surfaceStructure.root.baseSemanticTree.nodes["semantic-title"]?.role).toBe("heading"); + expect(surfaceStructure.root.initialStateId).toBe("state-default"); + expectTypeOf(presentation.id).toEqualTypeOf<"presentation-intro">(); + expect(JSON.parse(JSON.stringify({ presentation, surfaceManifest, surfaceStructure }))).toEqual( + { + presentation, + surfaceManifest, + surfaceStructure, + }, + ); + }); + + it("keeps source correlation optional and does not mutate identity declarations", () => { + const presentation = definePresentation(referencePresentation); + expect(presentation.source.file).toBe("presentation.unframe.tsx"); + expect(presentation).toBe(referencePresentation); + }); +}); + +describe("component contract", () => { + it("represents structured Manifest actions, outputs, Parts, Slots, and local semantics", () => { + expect(surfaceManifest.actions.show.effects).toEqual([ + { kind: "setSurfaceState", surfaceId: "root", stateId: "shown" }, + { kind: "playTimeline", timelineId: "reveal", completion: "blocking" }, + ]); + expect(surfaceManifest.outputs.completed.producer).toEqual({ + kind: "timelineCompleted", + timelineId: "reveal", + }); + expect(surfaceStructure.partBindings.root).toBe("surface-title"); + expect(surfaceStructure.timelines[0]?.id).toBe("reveal"); + }); + + it("represents Opaque renderer entries only through declared binding keys and semantics", () => { + const manifest = defineComponentManifest({ + componentId: "@example/opaque-chart", + version: 1, + authoring: { mode: "opaque" }, + props: { interactive: booleanProp({ default: false }) }, + slots: {}, + parts: {}, + variants: {}, + states: {}, + actions: {}, + outputs: { refresh: output({ payload: {}, producer: after(1000) }) }, + renderers: { + "baked-web": { entry: "./Chart.web.tsx", bindingKeys: ["chart-root"] }, + }, + semantics: { + targets: [], + surfaces: [ + { + id: "root", + bindingKey: "chart-root", + baseSemanticTree: titleSurface.baseSemanticTree, + interactions: titleSurface.interactions, + initialStateId: titleSurface.initialStateId, + states: titleSurface.states, + }, + ], + }, + }); + + expect(manifest.authoring.mode).toBe("opaque"); + expect(manifest.semantics.surfaces[0]?.bindingKey).toBe("chart-root"); + }); + + it("carries owner, package lock, variants, slots, and bounded Part overrides on instances", () => { + expect(titleInstance.owner).toEqual({ kind: "presentation" }); + expect(titleInstance.packageLock.structureHash).toBe("sha256-structure"); + expect(titleInstance.partOverrides).toEqual([{ partId: "root", style: { opacity: 1 } }]); + }); + + it("limits semantic overrides and detach to explicit structured operations", () => { + const changed = semanticOverride({ + id: "rename-title", + targetId: "semantic-title", + text: "Welcome", + included: true, + }); + const detached = detach({ + id: "detach-title", + mode: "structured", + instanceId: titleInstance.id, + provenance: { componentId: surfaceManifest.componentId, version: 1 }, + }); + + expect(changed).toEqual({ + kind: "semantic-override", + id: "rename-title", + targetId: "semantic-title", + text: "Welcome", + included: true, + }); + expect(detached.mode).toBe("structured"); + }); + + it("connects Component Outputs and typed Action invocations to Flow cues", () => { + const completed = componentOutput({ + componentInstanceId: titleInstance.id, + outputId: "completed", + }); + const show = invokeComponentAction({ + componentInstanceId: titleInstance.id, + actionId: "show", + arguments: {}, + }); + const transition = cue({ + id: "show-after-complete", + trigger: completed, + actions: [show], + toStepId: "step-shown", + }); + + expect(transition.trigger.kind).toBe("component.output"); + expect(transition.actions[0]?.kind).toBe("component.action"); + }); + + it("uses null semantic override fields to remove inherited values", () => { + expect( + semanticOverride({ + id: "remove-alt", + targetId: "semantic-title", + alt: null, + language: null, + }), + ).toMatchObject({ alt: null, language: null }); + }); +}); + +describe("theme and reference vocabulary", () => { + it("creates typed Theme, Prop, State, and reference declarations", () => { + const theme = defineTheme({ + id: "default-theme", + tokens: { accent: "#ff00ff", spacing: 8 }, + namedStyles: { heading: { color: "#ff00ff", fontSize: 64 } }, + }); + + expect(theme.tokens.accent).toBe("#ff00ff"); + expect(stringProp({ required: true })).toEqual({ kind: "string", required: true }); + expect(tokenRef({ tokenId: "accent" })).toEqual({ kind: "token-ref", tokenId: "accent" }); + expect(namedStyleRef({ styleId: "heading" })).toEqual({ + kind: "named-style-ref", + styleId: "heading", + }); + }); + + it("does not mutate builder inputs or retain registry state", () => { + const input = { id: "copy", value: "Copy", layout: absolute } as const; + const first = text(input); + const second = text(input); + + expect(input).toEqual({ id: "copy", value: "Copy", layout: absolute }); + expect(first).not.toBe(input); + expect(second).not.toBe(first); + expect(second).toEqual(first); + }); + + it("preserves unresolved cross-declaration references for Compiler validation", () => { + const unresolved = defineComponentStructure({ + id: "unresolved-structure", + componentId: "@example/missing-manifest", + root, + partBindings: { missingPart: "missing-node" }, + slotPlacements: { missingSlot: "missing-node" }, + timelines: [], + }); + + expect(unresolved.partBindings.missingPart).toBe("missing-node"); + expect(tokenRef({ tokenId: "missing-token" }).tokenId).toBe("missing-token"); + }); +}); + +describe("local declaration boundary", () => { + it("exposes non-mutating declaration guards with builder-equivalent acceptance", () => { + const theme = { id: "default-theme", tokens: { accent: "#ff00ff" }, namedStyles: {} }; + const before = JSON.stringify({ + referencePresentation, + surfaceManifest, + surfaceStructure, + theme, + }); + + expect(isPresentationDeclaration(referencePresentation)).toBe(true); + expect(isComponentManifest(surfaceManifest)).toBe(true); + expect(isComponentStructure(surfaceStructure)).toBe(true); + expect(isThemeDeclaration(theme)).toBe(true); + expect(() => definePresentation(referencePresentation)).not.toThrow(); + expect(() => defineComponentManifest(surfaceManifest)).not.toThrow(); + expect(() => defineComponentStructure(surfaceStructure)).not.toThrow(); + expect(() => defineTheme(theme)).not.toThrow(); + expect(isThemeDeclaration({})).toBe(false); + expect(() => defineTheme({} as never)).toThrow(TypeError); + expect( + JSON.stringify({ referencePresentation, surfaceManifest, surfaceStructure, theme }), + ).toBe(before); + }); + + it("returns false without evaluating malformed declaration accessors", () => { + let reads = 0; + const accessor = Object.defineProperty({ id: "theme", tokens: {}, namedStyles: {} }, "tokens", { + enumerable: true, + get() { + reads++; + return {}; + }, + }); + const cyclic: Record = { id: "theme", tokens: {}, namedStyles: {} }; + cyclic.self = cyclic; + + expect(isThemeDeclaration(accessor)).toBe(false); + expect(reads).toBe(0); + expect(isThemeDeclaration(cyclic)).toBe(false); + expect( + isThemeDeclaration({ + id: "theme", + tokens: { invalid: Number.POSITIVE_INFINITY }, + namedStyles: {}, + }), + ).toBe(false); + }); + + it("accepts descriptor-backed Proxy declarations without reading caller values", () => { + let reads = 0; + const proxy = (value: T): T => + new Proxy(value, { + get() { + reads++; + throw new Error("must not read caller data"); + }, + }); + + expect(() => stringProp(proxy({ required: true }))).not.toThrow(); + expect(() => assetRef(proxy({ assetId: "logo" }))).not.toThrow(); + expect(() => semanticOverride(proxy({ id: "override", targetId: "target" }))).not.toThrow(); + expect(() => + cue(proxy({ id: "cue", trigger: { kind: "event", event: "ready" }, actions: [] })), + ).not.toThrow(); + expect(isThemeDeclaration(proxy({ id: "theme", tokens: {}, namedStyles: {} }))).toBe(true); + expect(reads).toBe(0); + }); + + it("does not read length through a nested Array Proxy", () => { + let reads = 0; + const accepts = new Proxy(["frame"], { + get() { + reads++; + throw new Error("must not read array length"); + }, + }); + + expect(() => slot({ accepts: accepts as never, cardinality: "many" })).not.toThrow(); + expect(reads).toBe(0); + }); + + it("does not inherit Object.prototype accessors during validation", () => { + let reads = 0; + Object.defineProperty(Object.prototype, "required", { + configurable: true, + get() { + reads++; + throw new Error("must not inherit caller data"); + }, + }); + + try { + expect(() => stringProp({})).not.toThrow(); + expect(isThemeDeclaration({ id: "theme", tokens: {}, namedStyles: {} })).toBe(true); + expect(reads).toBe(0); + } finally { + delete (Object.prototype as { required?: unknown }).required; + } + }); + + it("accepts normalized null-prototype declarations", () => { + const theme = Object.assign(Object.create(null), { + id: "theme", + tokens: Object.assign(Object.create(null), { accent: "#ff00ff" }), + namedStyles: Object.create(null), + }); + + expect(isThemeDeclaration(theme)).toBe(true); + }); + + it.each([ + ["function", () => undefined], + ["undefined", undefined], + ["bigint", 1n], + ["symbol", Symbol("value")], + ["NaN", Number.NaN], + ["Infinity", Number.POSITIVE_INFINITY], + ["Date", new Date(0)], + ])("rejects non-JSON %s values", (_label, invalid) => { + expect(() => + defineTheme({ id: "theme", tokens: { invalid }, namedStyles: {} } as never), + ).toThrow(TypeError); + }); + + it("rejects empty references, invalid source ranges, and invalid local geometry", () => { + expect(() => assetRef({ assetId: "" })).toThrow(/assetId/); + expect(() => + defineTheme({ + id: "theme", + source: { file: "theme.ts", range: [2, 1] }, + tokens: {}, + namedStyles: {}, + }), + ).toThrow(/source.range/); + expect(() => text({ id: "bad", value: "bad", layout: { ...absolute, width: 0 } })).toThrow( + /layout size/, + ); + expect(() => + spatial({ + ...surfaceNode, + id: "bad-scale", + transform: { ...surfaceNode.transform, scale: [1, 0, 1] }, + }), + ).toThrow(/transform.scale/); + expect(() => action({ inputs: {}, preconditions: [], effects: [] })).toThrow( + /at least one effect/, + ); + expect(() => + spatial({ + ...surfaceNode, + id: "bad-position", + transform: { ...surfaceNode.transform, position: [0, 0] as never }, + }), + ).toThrow(/exactly 3/); + }); + + it("rejects nested empty ids and interactions in the non-interactive milestone", () => { + expect(() => + defineComponentStructure({ + id: "bad-structure", + componentId: surfaceManifest.componentId, + root, + partBindings: {}, + slotPlacements: {}, + timelines: [{ id: "" }], + }), + ).toThrow(/timeline id/); + expect(() => + surface({ + ...titleSurface, + id: "interactive-surface", + interactions: { + click: { id: "click", kind: "click", event: "clicked" }, + }, + } as never), + ).toThrow(/non-interactive Surface/); + expect(() => + surface({ + ...titleSurface, + id: "semantic-interaction-surface", + baseSemanticTree: { + ...titleSurface.baseSemanticTree, + nodes: { + "semantic-title": { + ...titleSurface.baseSemanticTree.nodes["semantic-title"], + interactionId: "undeclared-click", + }, + }, + }, + } as never), + ).toThrow(/semantic interactionId/); + }); + + it("rejects cycles, sparse arrays, and accessor properties", () => { + const cyclic: Record = {}; + cyclic.self = cyclic; + expect(() => defineTheme({ id: "theme", tokens: cyclic, namedStyles: {} } as never)).toThrow( + /cycles/, + ); + + const sparse: unknown[] = []; + sparse.length = 2; + expect(() => + defineTheme({ id: "theme", tokens: { sparse }, namedStyles: {} } as never), + ).toThrow(/sparse arrays/); + + const accessor = Object.defineProperty({}, "value", { enumerable: true, get: () => "hidden" }); + expect(() => defineTheme({ id: "theme", tokens: accessor, namedStyles: {} } as never)).toThrow( + /data properties/, + ); + }); + + it("rejects builder accessors before execution and invalid action completion enums", () => { + let reads = 0; + const reference = {} as { assetId: string }; + Object.defineProperty(reference, "assetId", { + enumerable: true, + get() { + reads++; + return "logo"; + }, + }); + + expect(() => assetRef(reference)).toThrow(/data properties/); + expect(reads).toBe(0); + expect(() => playTimeline("reveal", { completion: "eventually" } as never)).toThrow( + /completion/, + ); + expect(() => stringProp({ default: 1 } as never)).toThrow(/string prop/); + expect(() => slot({ accepts: ["frame"], cardinality: "optional" } as never)).toThrow( + /slot declaration/, + ); + }); +}); + +const typeContractChecks = () => { + // @ts-expect-error component manifests use componentId as their stable public identity + defineComponentManifest({ id: "legacy" }); + // @ts-expect-error string prop defaults must be strings + stringProp({ default: 1 }); + // @ts-expect-error slot cardinality is explicit and closed + slot({ accepts: [], cardinality: "optional" }); + // @ts-expect-error semantic overrides cannot alter topology or roles + semanticOverride({ id: "bad", targetId: "node", parentId: "other" }); + // @ts-expect-error a Surface is a Structure root and cannot be nested inside Frame content + frame({ id: "bad-frame", layout: absolute, children: [titleSurface] }); + surface({ + ...titleSurface, + // @ts-expect-error the initial milestone cannot declare Surface interactions + interactions: { click: { id: "click", kind: "click", event: "clicked" } }, + }); + // @ts-expect-error output payload types and fixed scalar values must agree + output({ payload: { count: { type: "number", value: "one" } }, producer: after(1) }); + // @ts-expect-error opaque manifests must declare semantic bindings + defineComponentManifest({ + componentId: "@example/opaque", + version: 1, + authoring: { mode: "opaque" }, + props: {}, + slots: {}, + parts: {}, + variants: {}, + states: {}, + actions: {}, + outputs: {}, + renderers: {}, + }); +}; + +expectTypeOf(typeContractChecks).toBeFunction(); diff --git a/packages/presentation-authoring/tsconfig.json b/packages/presentation-authoring/tsconfig.json new file mode 100644 index 0000000..a2c27e0 --- /dev/null +++ b/packages/presentation-authoring/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../config/tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "noPropertyAccessFromIndexSignature": false + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/presentation-cli/ARCHITECTURE.md b/packages/presentation-cli/ARCHITECTURE.md new file mode 100644 index 0000000..5ff2b07 --- /dev/null +++ b/packages/presentation-cli/ARCHITECTURE.md @@ -0,0 +1,134 @@ +# Presentation CLI Architecture + +- **Status**: M1 filesystem-backed headless application boundary +- **Scope**: Authoring Project を Compiler / Web Renderer に接続し、Bun 上の OpenTUI command selector と headless API を提供する +- **Related**: + - [Presentation Implementation Design](../../docs/presentation/DESIGN.md) + - [Presentation Compiler Architecture](../presentation-compiler/ARCHITECTURE.md) + - [Web Renderer Architecture](../presentation-renderer-web/ARCHITECTURE.md) + - [ADR-0013: Local Compiler の project filesystem contract](../../docs/decisions/0013-local-compiler-project-filesystem-contract.md) + +## 1. Role + +`presentation-cli` は headless な `runPresentationCli` と、interactive な `runPresentationTui` を別 entrypoint +として公開する。CLI は Authoring semantic rule や renderer implementation を所有しないが、ADR-0013 に従い explicit +project root、data-only config / lock、Fixed Browser lifecycle、managed output の原子的公開を組み立てる。headless +application API はテスト用に Fixed Browser session factory、AbortSignal、固定 build context を注入できる。production default +は packaged Fixed Browser と固定 toolchain context を使用し、project reader や output directory は注入しない。 + +```text +runPresentationCli +├─ parse explicit absolute project directory +├─ M1 filesystem host: explicit root + config / lock, materialize virtual project +├─ check: presentation-compiler.checkAuthoringProjectAssembly(Browserなし) +└─ build: exclusive project build lock + compiler + presentation-renderer-web + └─ managed staging + atomic dist symlink replacement +``` + +実装は package の public entrypoint だけを import する。Compiler / Renderer の内部 module を deep import +せず、input と adapter の accessor・mutation 防御は各 public boundary に委譲する。 + +```text +src/ +├─ index.ts # headless export only +├─ application/ +│ ├─ types.ts # headless host / result contract +│ └─ run-presentation-cli.ts # check / build orchestration +├─ filesystem/build-lock.ts # process-local build exclusion lease +├─ process/ +│ ├─ main.ts # Bun executable entrypoint +│ └─ run-presentation-process.ts # SIGINT/SIGTERM owner +└─ tui/ + ├─ model.ts # renderer-independent state and effects + ├─ view.tsx # OpenTUI Solid view + ├─ run.tsx # Bun / Zig core / keymap lifecycle + └─ main.tsx # executable entrypoint +``` + +root export は native module を import しない。OpenTUI の Zig core を必要とする利用者だけが `./tui` subpath +または `pnpm tui` を使う。これにより Node / Vitest 上の headless check と build は native TUI lifecycle から +独立する。 + +## 2. Command contract + +現行 public API の `args` は descriptor-safe snapshot の後に検査する dense な string array である。M1 +process command は absolute project directory を受け、その realpath を root として同じ directory の`unframe.config.ts`、 +`unframe.lock`を読む。上方探索は行わない。public input、command grammar、build context、host callback も descriptor-safe +snapshot と explicit validation で boundary validation する。host の callback +invocation と Compiler / Renderer の cross-boundary semantic diagnostics は、この構造検査の後に扱う。 + +```text +check [--format text|json] +build [--format text|json] +``` + +`check` は discovery、config、lock、Source frontend と assembly を検証するだけで、Browser adapter / Renderer を読まず起動しない。 +`build` だけが Fixed Browser adapter と build context から baked-web renderer を作り、Compiler の公開 build API を呼ぶ。 +project root の検証後、Browser を起動する前に `.unframe-build.lock` を `O_CREAT|O_EXCL|O_NOFOLLOW` で取得する。 +同一 project の concurrent build は I/O diagnostic で終了し、output を公開しない。lock は保持した inode が path 上で同一の +ときだけ finally で削除する。crash 後の stale lock は fail-closed とし、稼働中 build がないことを確認した operator だけが除去する。 +非協調 process が lock path を unlink する攻撃は ADR-0013 の threat model 外である。`check` は lock を取得しない。 + +Exit code は `0` が成功、`1` が `syntax` / `type` / `semantic` / `renderer`、`2` が `usage`、`3` が `io`、signal cancel の +`130` が `cancel` である。成功時の JSON output は `ok: true`、失敗時は diagnostic array を持つ。diagnostic JSON は +`"usage" | "syntax" | "type" | "semantic" | "renderer" | "io" | "cancel"` の `family` field を必ず持ち、text diagnostics は +`path: family/code: message` の一行形式である。family と順序は ADR-0013 に従う。 + +## 3. Artifact boundary + +build の成功時だけ、CLI は次の deterministic な全ファイルを辞書順 path で一度に `publishAtomicArtifacts` へ渡す。 + +- `definition.json` +- `render-bundle.json` +- `assets/.png` + +filesystem host は root 固定の `dist` に対し complete artifact set を `.unframe/generations/` に閉じ、成功時だけ validated +relative target の managed `dist` symlink を atomic replacement する。失敗または cancel では previous `dist` を維持し、今回の +staging を公開しない。manifest と Delivery artifact は出力しない。Compiler / Renderer の domain diagnostics は exit code `1`、 +discovery / read / write I/O は exit code `3` とする。 + +## 4. Interactive TUI boundary + +interactive shell は Bun を runtime とし、OpenTUI の Solid renderer を使用する。pnpm は引き続き dependency と +lockfile の管理を担当し、Bun を package manager として使用しない。TUI が現在所有するのは `check` / `build` +command の選択、keyboard navigation、quit lifecycle までであり、選択後の filesystem host や Browser process +はまだ接続しない。 + +- `@opentui/core`: Zig native renderer と terminal lifecycle +- `@opentui/solid` + `solid-js`: declarative view +- `@opentui/keymap`: navigation / selection / quit key binding +- `web-tree-sitter`: `@opentui/core` が要求する peer dependency + +`@opentui/react`、`@opentui/ssh`、dynamic runtime plugin loading は採用しない。TUI state transition は +`model.ts` の pure reducer と explicit effect に閉じ、Bun/OpenTUI を使わず Vitest で検証できる。描画 integration +は Bun と OpenTUI test renderer で検証する。 + +## 5. Dependency rules + +headless application は `presentation-compiler` と `presentation-renderer-web` にだけ依存する。TUI adapter だけが +OpenTUI stack に依存する。`presentation-components` は fixture の devDependency であり、CLI runtime の project +discovery / component registry ではない。依存 version は pnpm lockfile で固定し、repository toolchain は Bun と +Node.js の両方を提供する。 + +## 6. Process and acceptance boundary + +`pnpm presentation -- check|build ` は Bun process entry である。この entrypoint だけが単一の +`AbortController` と `SIGINT` / `SIGTERM` listener を所有し、同じ signal を application API に渡す。listener は +常に解除し、`process.exit()` は呼ばず `process.exitCode` と stdout/stderr の stable result を使う。 + +`nix run .#presentation` の check mode は通常の package check の後、provision 済み repository-local Fixed Browser +で reference project の check と temp copy への build を2回行う。`definition.json`、`render-bundle.json`、PNG asset set +の relative path と SHA-256 manifest が完全一致することを検証する。fix mode と通常 package check は Browser を起動しない。 + +## 7. Deferred + +以下は current implementation に含めない。 + +- TUI command selection と M1 process command の接続 +- remote package registry、plugin discovery、distribution update +- `init`、`dev`、`test`、`preview`、`publish` command +- watch / incremental cache、remote publish adapter、credential integration +- Windows / case-insensitive filesystem support + +これらを追加する場合も、Compiler rule、Renderer implementation、durable publication state の所有権はこの +package に移さない。 diff --git a/packages/presentation-cli/bunfig.toml b/packages/presentation-cli/bunfig.toml new file mode 100644 index 0000000..7693482 --- /dev/null +++ b/packages/presentation-cli/bunfig.toml @@ -0,0 +1 @@ +preload = ["@opentui/solid/preload"] diff --git a/packages/presentation-cli/package.json b/packages/presentation-cli/package.json new file mode 100644 index 0000000..8fe4bd6 --- /dev/null +++ b/packages/presentation-cli/package.json @@ -0,0 +1,36 @@ +{ + "name": "@unframe/presentation-cli", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./tui": "./src/tui/run.tsx" + }, + "scripts": { + "tui": "bun src/tui/main.tsx", + "presentation": "bun src/process/main.ts", + "typecheck": "tsc --noEmit -p tsconfig.json", + "lint": "vp check src test test-bun", + "test": "vp test run", + "test:tui": "bun test ./test-bun/tui-render.test.bun.tsx", + "check": "pnpm typecheck && pnpm lint && pnpm test && pnpm test:tui" + }, + "dependencies": { + "@opentui/core": "0.4.5", + "@opentui/keymap": "0.4.5", + "@opentui/solid": "0.4.5", + "@unframe/presentation-compiler": "workspace:*", + "@unframe/presentation-core": "workspace:*", + "@unframe/presentation-renderer-web": "workspace:*", + "solid-js": "1.9.12", + "typescript": "5.9.3", + "web-tree-sitter": "0.25.10", + "zod": "4.4.3" + }, + "devDependencies": { + "@unframe/presentation-components": "workspace:*", + "vite-plus": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/presentation-cli/src/application/run-presentation-cli.ts b/packages/presentation-cli/src/application/run-presentation-cli.ts new file mode 100644 index 0000000..4b13fc3 --- /dev/null +++ b/packages/presentation-cli/src/application/run-presentation-cli.ts @@ -0,0 +1,452 @@ +import { + checkAuthoringProjectAssembly, + compileAuthoringProject, + type AuthoringProjectPipelineResult, + type CompiledDeclarationProject, +} from "@unframe/presentation-compiler"; +import { hashCanonicalJsonPayload } from "@unframe/presentation-core"; +import { + createBakedWebRenderer, + createWebRendererConfigHash, + openPlaywrightFixedBrowser, + type FixedBrowserSession, +} from "@unframe/presentation-renderer-web"; + +import { publishAtomicArtifacts } from "../filesystem/atomic-output.js"; +import { acquireBuildLock, type BuildLock } from "../filesystem/build-lock.js"; +import { discoverPresentationProjectFiles } from "../filesystem/discover-project.js"; +import { loadUnframeLock } from "../filesystem/load-lock.js"; +import type { + PresentationCliDiagnostic, + PresentationCliExitCode, + PresentationCliHost, + PresentationCliResult, +} from "./types.js"; + +type Command = Readonly<{ command: "check" | "build"; directory: string; format: "text" | "json" }>; +class BrowserProvisionFailure extends Error {} +class BrowserCleanupFailure extends Error {} +const encoder = new TextEncoder(); +const fixedContext = Object.freeze({ + compiler: Object.freeze({ + name: "unframe", + version: "1", + baseEnvironmentHash: hashCanonicalJsonPayload({ + browser: "playwright-chromium-fixed", + fontProfile: "Noto Sans CJK JP", + locale: "ja-JP", + timezone: "Asia/Tokyo", + toolchain: "unframe-presentation-m1", + }), + }), + locale: "ja-JP" as const, + timezone: "Asia/Tokyo" as const, + colorScheme: "light" as const, + pixelTarget: [1920, 1080] as const, + webRendererConfig: Object.freeze({ + documentBackground: [0, 0, 0, 255] as const, + fontFamily: "Noto Sans CJK JP", + }), +}); +const limits = Object.freeze({ + maxWidth: 4096, + maxHeight: 4096, + maxPixels: 16_777_216, + maxInputBytes: 64 * 1024 * 1024, + maxOutputBytes: 65 * 1024 * 1024, +}); +const usage = + "Usage: presentation-cli check [--format text|json]\n presentation-cli build [--format text|json]"; +const rendererDiagnosticCodes = new Set([ + "unsupported-structured-tree", + "invalid-render-scale", + "text-outside-render-surface", + "invalid-render-geometry", + "renderer-not-invoked", + "invalid-browser-environment", + "invalid-renderer-config", + "renderer-config-hash-mismatch", + "browser-environment-context-mismatch", + "unsupported-state-visual-variation", + "renderer-invalid-input", + "browser-capture-failed", + "invalid-browser-capture", +]); + +const safeRecord = (value: unknown): Record | undefined => { + try { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype || + Object.getOwnPropertySymbols(value).length + ) + return undefined; + const descriptors = Object.getOwnPropertyDescriptors(value); + if (Object.values(descriptors).some((d) => d.get || d.set || !d.enumerable)) return undefined; + return Object.fromEntries(Object.entries(descriptors).map(([k, d]) => [k, d.value])); + } catch { + return undefined; + } +}; +const safeStrings = (value: unknown): readonly string[] | undefined => { + try { + if (!Array.isArray(value) || Object.getOwnPropertySymbols(value).length) return undefined; + const d = Object.getOwnPropertyDescriptors(value); + const n = Object.getOwnPropertyDescriptor(value, "length")?.value; + if (!Number.isSafeInteger(n) || n < 0 || Object.keys(d).length !== n + 1) return undefined; + const values = Array.from({ length: n }, (_, i) => d[String(i)]); + return values.every((x) => x && !x.get && !x.set && typeof x.value === "string") + ? values.map((x) => x!.value as string) + : undefined; + } catch { + return undefined; + } +}; +const diagnostic = ( + family: PresentationCliDiagnostic["family"], + code: string, + message: string, + path: readonly (string | number)[] = [], +): PresentationCliDiagnostic => ({ family, code, message, path }); +const pathText = (path: readonly (string | number)[]) => + path.length ? `$/` + path.map(String).join("/") : "$"; +const ordered = (items: readonly PresentationCliDiagnostic[]) => + [...items].sort((a, b) => { + const left = `${pathText(a.path)}\0${a.family}\0${a.code}\0${a.message}`; + const right = `${pathText(b.path)}\0${b.family}\0${b.code}\0${b.message}`; + return left < right ? -1 : left > right ? 1 : 0; + }); +const output = ( + exitCode: PresentationCliExitCode, + command: Command["command"] | undefined, + format: Command["format"], + diagnostics: readonly PresentationCliDiagnostic[] = [], +): PresentationCliResult => { + const list = ordered(diagnostics); + if (exitCode === 0) + return { + exitCode, + stdout: + format === "json" + ? `${JSON.stringify({ ok: true, command, diagnostics: [] })}\n` + : `${command}: ok\n`, + stderr: "", + }; + const stderr = + format === "json" + ? `${JSON.stringify({ ok: false, command: command ?? null, diagnostics: list })}\n` + : list.map((d) => `${pathText(d.path)}: ${d.family}/${d.code}: ${d.message}`).join("\n") + + "\n"; + return { exitCode, stdout: "", stderr }; +}; +const parse = ( + raw: unknown, +): + | { ok: true; value: Command } + | { + ok: false; + command?: Command["command"]; + format: Command["format"]; + diagnostics: readonly PresentationCliDiagnostic[]; + } => { + const args = safeStrings(raw); + if (!args) + return { + ok: false, + format: "text", + diagnostics: [ + diagnostic("usage", "cli-invalid-arguments", "Arguments must be a dense string array."), + ], + }; + const command = args[0] === "check" || args[0] === "build" ? args[0] : undefined; + const at = args.indexOf("--format"); + const format = at >= 0 && args[at + 1] === "json" ? "json" : "text"; + const positional = at < 0 ? args : args.filter((_, i) => i !== at && i !== at + 1); + if ( + !command || + (at >= 0 && (at !== args.length - 2 || !["text", "json"].includes(args[at + 1] ?? ""))) || + positional.length !== 2 || + !positional[1]?.startsWith("/") + ) + return command + ? { + ok: false, + command, + format, + diagnostics: [diagnostic("usage", "cli-invalid-usage", usage)], + } + : { ok: false, format, diagnostics: [diagnostic("usage", "cli-invalid-usage", usage)] }; + return { ok: true, value: { command, directory: positional[1], format } }; +}; +const compilerDiagnostics = ( + result: Exclude, { valid: true }>, +): readonly PresentationCliDiagnostic[] => + result.diagnostics.map((item) => { + if (result.phase === "source") { + const source = item as { + code: string; + message: string; + fileName: string; + typescriptCode?: number; + }; + return diagnostic( + source.code === "compiler-source-syntax-error" || + source.code === "compiler-source-kind-unsupported" || + source.code.startsWith("compiler-static-") + ? "syntax" + : source.code === "compiler-source-type-error" || + source.code.startsWith("compiler-module-") || + source.code === "compiler-project-entry-invariant-invalid" + ? "type" + : "semantic", + source.code, + source.message, + source.fileName ? [source.fileName] : [], + ); + } + const domain = item as { code: string; message: string; path: readonly (string | number)[] }; + const rendererCode = + domain.code.startsWith("compiler-renderer-") || rendererDiagnosticCodes.has(domain.code); + return diagnostic( + result.phase === "compile" && rendererCode ? "renderer" : "semantic", + domain.code, + domain.message, + domain.path, + ); + }); +const artifacts = (compiled: CompiledDeclarationProject) => + Object.freeze({ + definition: encoder.encode(compiled.definitionJson), + renderBundle: encoder.encode(compiled.renderBundleJson), + assets: Object.entries(compiled.assets) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([assetId, bytes]) => Object.freeze({ assetId, bytes: new Uint8Array(bytes) })), + }); +const closeSession = async (session: FixedBrowserSession) => { + try { + const close = session.close; + if (typeof close !== "function") throw new Error("invalid close"); + await Promise.resolve(Reflect.apply(close, session, [])); + } catch { + throw new BrowserCleanupFailure(); + } +}; + +export const runPresentationCli = async (input: unknown): Promise => { + const record = safeRecord(input); + if (!record) + return output(2, undefined, "text", [ + diagnostic("usage", "cli-invalid-input", "CLI input is invalid."), + ]); + const parsed = parse(record.args); + if (!parsed.ok) return output(2, parsed.command, parsed.format, parsed.diagnostics); + const { command, directory, format } = parsed.value; + const hostRecord = record.host === undefined ? {} : safeRecord(record.host); + if (!hostRecord) + return output(2, command, format, [ + diagnostic("usage", "cli-invalid-host", "CLI host is invalid."), + ]); + const host = hostRecord as PresentationCliHost; + if (host.signal?.aborted) + return output(130, command, format, [ + diagnostic("cancel", "cli-cancelled", "Build was cancelled."), + ]); + const discovered = await discoverPresentationProjectFiles(directory); + if (!discovered.ok) + return output(discovered.code === "cli-config-invalid" ? 1 : 3, command, format, [ + diagnostic( + discovered.code === "cli-config-invalid" ? "syntax" : "io", + discovered.code, + discovered.message, + [directory], + ), + ]); + const lock = loadUnframeLock(discovered.lockBytes); + if (!lock.ok) + return output(1, command, format, [ + diagnostic(lock.diagnostic.family, lock.diagnostic.code, lock.diagnostic.message, [ + "unframe.lock", + ]), + ]); + const source = Object.freeze({ + projectRoot: discovered.projectDirectory, + entryFile: discovered.entryFile, + files: discovered.files, + ...lock.value.virtualSource, + }); + if (command === "check") { + const checked = checkAuthoringProjectAssembly(source, lock.value.assemblyCarrier); + return checked.valid + ? output(0, command, format) + : output(1, command, format, compilerDiagnostics(checked)); + } + const acquired = await acquireBuildLock(discovered.projectDirectory); + if (!acquired.ok) + return output(3, command, format, [ + diagnostic( + "io", + acquired.code, + "Another build owns this project or its build lock is unsafe.", + ), + ]); + const buildLock: BuildLock = acquired.value; + const finishBuild = async (result: PresentationCliResult): Promise => { + try { + await buildLock.release(); + } catch { + return output( + result.exitCode === 130 ? 130 : 3, + command, + format, + result.exitCode === 130 + ? [ + diagnostic("cancel", "cli-cancelled", "Build was cancelled."), + diagnostic( + "io", + "cli-build-lock-release-failed", + "Build lock could not be released.", + ), + ] + : [ + diagnostic( + "io", + "cli-build-lock-release-failed", + "Build lock could not be released.", + ), + ], + ); + } + return result; + }; + if (host.signal?.aborted) + return finishBuild( + output(130, command, format, [diagnostic("cancel", "cli-cancelled", "Build was cancelled.")]), + ); + let cleanupFailed = false; + const buildResult = await (async (): Promise => { + let session: FixedBrowserSession | undefined; + try { + const opener = + host.openFixedBrowser ?? + ((options: Readonly<{ signal?: AbortSignal }>) => openPlaywrightFixedBrowser(options)); + try { + session = await opener(host.signal ? { signal: host.signal } : {}); + } catch { + if (host.signal?.aborted) + return output(130, command, format, [ + diagnostic("cancel", "cli-cancelled", "Build was cancelled."), + ]); + throw new BrowserProvisionFailure(); + } + if (host.signal?.aborted) + return output(130, command, format, [ + diagnostic("cancel", "cli-cancelled", "Build was cancelled."), + ]); + const context = host.buildContext ?? fixedContext; + const adapter = Object.freeze({ + identity: session.identity, + environment: session.environment, + capture: (request: Parameters[0]) => + Reflect.apply(session!.capture, session, [ + request, + ...(host.signal ? [{ signal: host.signal }] : []), + ]), + }); + const compiled = await compileAuthoringProject(source, lock.value.assemblyCarrier, { + compiler: context.compiler, + locale: context.locale, + timezone: context.timezone, + colorScheme: context.colorScheme, + pixelTarget: context.pixelTarget, + rendererConfigHash: createWebRendererConfigHash(context.webRendererConfig), + renderers: [createBakedWebRenderer({ adapter, config: context.webRendererConfig })], + encodeLimits: limits, + }); + if (!compiled.valid) + return output( + host.signal?.aborted ? 130 : 1, + command, + format, + host.signal?.aborted + ? [diagnostic("cancel", "cli-cancelled", "Build was cancelled.")] + : compilerDiagnostics(compiled), + ); + const closed = session; + session = undefined; + await closeSession(closed); + if (host.signal?.aborted) + return output(130, command, format, [ + diagnostic("cancel", "cli-cancelled", "Build was cancelled."), + ]); + const published = await publishAtomicArtifacts({ + projectDirectory: discovered.projectDirectory, + artifacts: artifacts(compiled.value), + ...(host.signal ? { signal: host.signal } : {}), + }); + if (!published.ok) + return output(published.family === "cancel" ? 130 : 3, command, format, [ + diagnostic( + published.family, + published.code, + published.family === "cancel" + ? "Build was cancelled." + : "Build artifacts could not be published.", + ), + ]); + return output(0, command, format); + } catch (error) { + const cancel = + host.signal?.aborted || (error instanceof Error && error.name === "AbortError"); + const browserFailure = + error instanceof BrowserProvisionFailure || error instanceof BrowserCleanupFailure; + return output(cancel ? 130 : browserFailure ? 1 : 3, command, format, [ + diagnostic( + cancel ? "cancel" : browserFailure ? "renderer" : "io", + cancel + ? "cli-cancelled" + : error instanceof BrowserCleanupFailure + ? "cli-browser-cleanup-failed" + : error instanceof BrowserProvisionFailure + ? "cli-browser-provision-failed" + : "cli-build-io", + cancel + ? "Build was cancelled." + : browserFailure + ? "Fixed Browser could not be completed." + : "Build could not be completed.", + ), + ]); + } finally { + if (session) + await closeSession(session).catch(() => { + cleanupFailed = true; + }); + } + })(); + const completedResult = cleanupFailed + ? output( + buildResult.exitCode === 130 ? 130 : 1, + command, + format, + buildResult.exitCode === 130 + ? [ + diagnostic("cancel", "cli-cancelled", "Build was cancelled."), + diagnostic( + "renderer", + "cli-browser-cleanup-failed", + "Fixed Browser could not be completed.", + ), + ] + : [ + diagnostic( + "renderer", + "cli-browser-cleanup-failed", + "Fixed Browser could not be completed.", + ), + ], + ) + : buildResult; + return finishBuild(completedResult); +}; diff --git a/packages/presentation-cli/src/application/types.ts b/packages/presentation-cli/src/application/types.ts new file mode 100644 index 0000000..255af12 --- /dev/null +++ b/packages/presentation-cli/src/application/types.ts @@ -0,0 +1,46 @@ +import type { FixedBrowserSession, WebRendererConfig } from "@unframe/presentation-renderer-web"; + +export type PresentationCliExitCode = 0 | 1 | 2 | 3 | 130; +export type PresentationDiagnosticFamily = + | "usage" + | "syntax" + | "type" + | "semantic" + | "renderer" + | "io" + | "cancel"; + +export type PresentationCliDiagnostic = Readonly<{ + family: PresentationDiagnosticFamily; + code: string; + message: string; + path: readonly (string | number)[]; +}>; + +export type PresentationCliResult = Readonly<{ + exitCode: PresentationCliExitCode; + stdout: string; + stderr: string; +}>; + +export type PresentationCliBuildContext = Readonly<{ + compiler: Readonly<{ name: string; version: string; baseEnvironmentHash: string }>; + locale: "ja-JP"; + timezone: "Asia/Tokyo"; + colorScheme: "light"; + pixelTarget: readonly [width: number, height: number]; + webRendererConfig: WebRendererConfig; +}>; + +export type PresentationCliHost = Readonly<{ + /** Test seam. Production opens the packaged Fixed Browser. */ + openFixedBrowser?: (input: Readonly<{ signal?: AbortSignal }>) => Promise; + /** Process owners pass their single cancellation signal through this boundary. */ + signal?: AbortSignal; + buildContext?: PresentationCliBuildContext; +}>; + +export type RunPresentationCliInput = Readonly<{ + args: readonly string[]; + host?: PresentationCliHost; +}>; diff --git a/packages/presentation-cli/src/filesystem/atomic-output.ts b/packages/presentation-cli/src/filesystem/atomic-output.ts new file mode 100644 index 0000000..d8423c1 --- /dev/null +++ b/packages/presentation-cli/src/filesystem/atomic-output.ts @@ -0,0 +1,437 @@ +import { randomBytes } from "node:crypto"; +import { constants, type Stats } from "node:fs"; +import { + type FileHandle, + lstat, + mkdir, + open, + readlink, + rename, + rm, + symlink, + unlink, +} from "node:fs/promises"; +import { isAbsolute, join, parse } from "node:path"; + +const GENERATION_ID = /^[0-9a-f]{32}$/; +const MANAGED_DIST_TARGET = /^\.unframe\/generations\/([0-9a-f]{32})$/; + +export type AtomicOutputArtifacts = { + readonly definition: Uint8Array; + readonly renderBundle: Uint8Array; + readonly assets: readonly { readonly assetId: string; readonly bytes: Uint8Array }[]; +}; + +export type AtomicOutputResult = + | { readonly ok: true; readonly generationId: string } + | { + readonly ok: false; + readonly family: "cancel" | "io"; + readonly code: "cli-output-cancel" | "cli-output-io"; + }; + +export type PublishAtomicArtifactsInput = { + readonly projectDirectory: string; + readonly artifacts: AtomicOutputArtifacts; + readonly generationId?: () => string; + readonly signal?: AbortSignal; + readonly testing?: { + readonly onPhase?: ( + phase: + | "staging-created" + | "artifact-written" + | "before-generation-rename" + | "before-dist-replace", + ) => void | Promise; + readonly lstat?: RawLstat; + }; +}; + +type FileArtifact = { readonly bytes: Uint8Array; readonly path: string }; +type DirectoryIdentity = { + readonly dev: number; + readonly handle: FileHandle; + readonly ino: number; + readonly path: string; +}; +type LinkIdentity = { readonly dev: number; readonly ino: number; readonly path: string }; +type ManagedDist = LinkIdentity & { readonly target: string }; +type AbsentDist = { readonly absent: true; readonly path: string }; +type RawLstat = (path: string) => Promise; + +class PublicationFailure extends Error { + override readonly name = "PublicationFailure"; +} + +class PublicationCancelled extends Error { + override readonly name = "PublicationCancelled"; +} + +const fail = (): never => { + throw new PublicationFailure(); +}; + +const cancel = (): never => { + throw new PublicationCancelled(); +}; + +const optionalLstat = async (path: string, readLstat: RawLstat = lstat) => { + try { + return await readLstat(path); + } catch (error) { + if (typeof error === "object" && error && "code" in error && error.code === "ENOENT") + return undefined; + return fail(); + } +}; + +const abortGetter = Object.getOwnPropertyDescriptor(AbortSignal.prototype, "aborted")?.get; + +const cancelled = (signal: AbortSignal | undefined) => { + if (!signal) return false; + const getter = abortGetter ?? fail(); + try { + return getter.call(signal) === true; + } catch { + fail(); + } +}; + +const verifyDirectory = async (path: string): Promise => { + const before = await lstat(path).catch(() => undefined); + if (!before || before.isSymbolicLink() || !before.isDirectory()) return false; + const handle = await open( + path, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ).catch(() => undefined); + if (!handle) return false; + let valid = false; + try { + const opened = await handle.stat(); + const after = await lstat(path).catch(() => undefined); + valid = Boolean( + opened.isDirectory() && + after && + !after.isSymbolicLink() && + after.isDirectory() && + before.dev === opened.dev && + before.ino === opened.ino && + before.dev === after.dev && + before.ino === after.ino, + ); + } catch { + valid = false; + } + try { + await handle.close(); + } catch { + return false; + } + return valid; +}; + +const verifyDirectoryPath = async (path: string): Promise => { + if (!isAbsolute(path)) return false; + const root = parse(path).root; + let current = root; + if (!(await verifyDirectory(current))) return false; + for (const component of path.slice(root.length).split("/")) { + if (!component) continue; + current = join(current, component); + if (!(await verifyDirectory(current))) return false; + } + return true; +}; + +const captureDirectory = async (path: string): Promise => { + if (!(await verifyDirectoryPath(path))) fail(); + const handle = await open( + path, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ).catch(() => fail()); + try { + const opened = await handle.stat(); + const stat = await lstat(path).catch(() => undefined); + if ( + !stat || + stat.isSymbolicLink() || + !stat.isDirectory() || + !opened.isDirectory() || + stat.dev !== opened.dev || + stat.ino !== opened.ino + ) + fail(); + // Keeping this descriptor open prevents an unlinked staging inode from + // being recycled with the same dev/ino pair before the next verification. + return { path, handle, dev: opened.dev, ino: opened.ino }; + } catch { + await handle.close().catch(() => undefined); + return fail(); + } +}; + +const sameDirectory = async (directory: DirectoryIdentity): Promise => { + const [opened, stat] = await Promise.all([ + directory.handle.stat().catch(() => undefined), + lstat(directory.path).catch(() => undefined), + ]); + return Boolean( + opened && + stat && + !stat.isSymbolicLink() && + stat.isDirectory() && + opened.isDirectory() && + opened.dev === directory.dev && + opened.ino === directory.ino && + stat.dev === directory.dev && + stat.ino === directory.ino, + ); +}; + +const requireSameDirectory = async (directory: DirectoryIdentity) => { + if (!(await sameDirectory(directory))) fail(); +}; + +const ensureDirectory = async ( + parent: DirectoryIdentity, + name: string, +): Promise => { + await requireSameDirectory(parent); + const directory = join(parent.path, name); + const exists = await lstat(directory).catch(() => undefined); + if (exists) { + if (exists.isSymbolicLink() || !exists.isDirectory()) fail(); + } else await mkdir(directory, { mode: 0o700 }); + return captureDirectory(directory); +}; + +const createExclusiveDirectory = async ( + parent: DirectoryIdentity, + name: string, +): Promise => { + await requireSameDirectory(parent); + const directory = join(parent.path, name); + await mkdir(directory, { mode: 0o700 }).catch(() => fail()); + return captureDirectory(directory); +}; + +const writeExclusiveFile = async ( + directory: DirectoryIdentity, + relativePath: string, + bytes: Uint8Array, +) => { + await requireSameDirectory(directory); + const path = join(directory.path, relativePath); + const handle = await open( + path, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ).catch(() => fail()); + let opened: { readonly dev: number; readonly ino: number; readonly size: number } | undefined; + try { + const stat = await handle.stat(); + if (!stat.isFile() || stat.isSymbolicLink()) fail(); + opened = { dev: stat.dev, ino: stat.ino, size: stat.size }; + await handle.writeFile(bytes); + await handle.sync(); + const synced = await handle.stat(); + if ( + !synced.isFile() || + synced.isSymbolicLink() || + synced.dev !== opened.dev || + synced.ino !== opened.ino || + synced.size !== bytes.byteLength + ) + fail(); + } catch { + await handle.close().catch(() => undefined); + fail(); + } + try { + await handle.close(); + } catch { + fail(); + } + const stat = await lstat(path).catch(() => undefined); + if ( + !opened || + !stat || + stat.isSymbolicLink() || + !stat.isFile() || + stat.dev !== opened.dev || + stat.ino !== opened.ino || + stat.size !== bytes.byteLength || + !(await sameDirectory(directory)) + ) + fail(); +}; + +const snapshotArtifacts = (artifacts: AtomicOutputArtifacts): readonly FileArtifact[] => { + const output: FileArtifact[] = [ + { path: "definition.json", bytes: artifacts.definition.slice() }, + { path: "render-bundle.json", bytes: artifacts.renderBundle.slice() }, + ]; + const paths = new Set(output.map((artifact) => artifact.path)); + for (const asset of artifacts.assets) { + if (!asset.assetId) fail(); + let encodedAssetId: string | undefined; + try { + encodedAssetId = encodeURIComponent(asset.assetId); + } catch { + fail(); + } + if (!encodedAssetId) fail(); + const path = `assets/${encodedAssetId}.png`; + if (!paths.add(path)) fail(); + output.push({ path, bytes: asset.bytes.slice() }); + } + return output.sort((left, right) => + left.path < right.path ? -1 : left.path > right.path ? 1 : 0, + ); +}; + +const existingDistIsManaged = async ( + root: string, + generations: DirectoryIdentity, + readLstat: RawLstat = lstat, +): Promise => { + const dist = join(root, "dist"); + const stat = await optionalLstat(dist, readLstat); + if (!stat) return { path: dist, absent: true }; + if (!stat.isSymbolicLink()) return false; + const target = await readlink(dist).catch(() => undefined); + const match = target?.match(MANAGED_DIST_TARGET); + if (!match) return false; + if (!(await sameDirectory(generations))) return false; + if (!(await verifyDirectoryPath(join(generations.path, match[1]!)))) return false; + return { path: dist, dev: stat.dev, ino: stat.ino, target: target ?? fail() }; +}; + +const unchangedDist = async ( + expected: ManagedDist | AbsentDist, + readLstat: RawLstat = lstat, +): Promise => { + const path = expected.path; + if ("absent" in expected) return !(await optionalLstat(path, readLstat)); + const stat = await lstat(path).catch(() => undefined); + if (!stat || !stat.isSymbolicLink() || stat.dev !== expected.dev || stat.ino !== expected.ino) + return false; + return (await readlink(path).catch(() => undefined)) === expected.target; +}; + +const cleanupStaging = async (staging: DirectoryIdentity | undefined) => { + if (!staging || !(await sameDirectory(staging))) return; + await rm(staging.path, { force: true, recursive: true }).catch(() => undefined); +}; + +const cleanupTemporaryLink = async (link: LinkIdentity | undefined) => { + if (!link) return; + const stat = await lstat(link.path).catch(() => undefined); + if (stat?.isSymbolicLink() && stat.dev === link.dev && stat.ino === link.ino) + await unlink(link.path).catch(() => undefined); +}; + +const closeDirectories = async (...directories: (DirectoryIdentity | undefined)[]) => { + await Promise.all( + directories.map((directory) => directory?.handle.close().catch(() => undefined)), + ); +}; + +export const publishAtomicArtifacts = async ({ + projectDirectory, + artifacts, + generationId = () => randomBytes(16).toString("hex"), + signal, + testing, +}: PublishAtomicArtifactsInput): Promise => { + let staging: DirectoryIdentity | undefined; + let temporaryLink: LinkIdentity | undefined; + let project: DirectoryIdentity | undefined; + let unframe: DirectoryIdentity | undefined; + let generations: DirectoryIdentity | undefined; + let assets: DirectoryIdentity | undefined; + try { + if (cancelled(signal)) return { ok: false, family: "cancel", code: "cli-output-cancel" }; + project = await captureDirectory(projectDirectory); + const id = generationId(); + if (!GENERATION_ID.test(id)) fail(); + const files = snapshotArtifacts(artifacts); + unframe = await ensureDirectory(project, ".unframe"); + generations = await ensureDirectory(unframe, "generations"); + const readLstat = testing?.lstat ?? lstat; + const discoveredDist = await existingDistIsManaged(project.path, generations, readLstat); + if (discoveredDist === false) return fail(); + const previousDist: ManagedDist | AbsentDist = discoveredDist; + if (cancelled(signal)) cancel(); + + staging = await createExclusiveDirectory(generations, `.staging-${id}`); + await testing?.onPhase?.("staging-created"); + await requireSameDirectory(staging); + assets = await ensureDirectory(staging, "assets"); + for (const file of files) { + if (cancelled(signal)) cancel(); + await requireSameDirectory(staging); + await writeExclusiveFile( + file.path.startsWith("assets/") ? assets : staging, + file.path.replace(/^assets\//, ""), + file.bytes, + ); + await testing?.onPhase?.("artifact-written"); + await requireSameDirectory(staging); + } + if (cancelled(signal)) cancel(); + await requireSameDirectory(staging); + + const generation = join(generations.path, id); + if (await lstat(generation).catch(() => undefined)) fail(); + await testing?.onPhase?.("before-generation-rename"); + if (cancelled(signal)) cancel(); + await requireSameDirectory(staging); + await requireSameDirectory(generations); + if (cancelled(signal)) cancel(); + await rename(staging.path, generation).catch(() => fail()); + if ( + !(await verifyDirectoryPath(generation)) || + !(await sameDirectory(generations)) || + !(await unchangedDist(previousDist, readLstat)) + ) + fail(); + + const temporaryPath = join(project.path, `.dist-${id}`); + if (await lstat(temporaryPath).catch(() => undefined)) fail(); + await symlink(`.unframe/generations/${id}`, temporaryPath).catch(() => fail()); + const temporaryStat = await lstat(temporaryPath).catch(() => undefined); + if (!temporaryStat || !temporaryStat.isSymbolicLink()) return fail(); + const createdTemporaryLink: LinkIdentity = { + path: temporaryPath, + dev: temporaryStat.dev, + ino: temporaryStat.ino, + }; + temporaryLink = createdTemporaryLink; + if (cancelled(signal)) cancel(); + await testing?.onPhase?.("before-dist-replace"); + if (cancelled(signal)) cancel(); + await requireSameDirectory(project); + await requireSameDirectory(generations); + if (!(await unchangedDist(previousDist, readLstat))) fail(); + const currentTemporary = await lstat(createdTemporaryLink.path).catch(() => undefined); + if ( + !currentTemporary?.isSymbolicLink() || + currentTemporary.dev !== createdTemporaryLink.dev || + currentTemporary.ino !== createdTemporaryLink.ino + ) + fail(); + if (cancelled(signal)) cancel(); + await rename(createdTemporaryLink.path, join(project.path, "dist")).catch(() => fail()); + temporaryLink = undefined; + return { ok: true, generationId: id }; + } catch (error) { + await cleanupStaging(staging); + await cleanupTemporaryLink(temporaryLink); + if (error instanceof PublicationCancelled) + return { ok: false, family: "cancel", code: "cli-output-cancel" }; + return { ok: false, family: "io", code: "cli-output-io" }; + } finally { + await closeDirectories(assets, staging, generations, unframe, project); + } +}; diff --git a/packages/presentation-cli/src/filesystem/build-lock.ts b/packages/presentation-cli/src/filesystem/build-lock.ts new file mode 100644 index 0000000..54bd97e --- /dev/null +++ b/packages/presentation-cli/src/filesystem/build-lock.ts @@ -0,0 +1,117 @@ +import { constants, type Stats } from "node:fs"; +import { lstat, open, unlink, type FileHandle } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; + +const LOCK_FILE_NAME = ".unframe-build.lock"; + +type Identity = Readonly<{ dev: number; ino: number }>; + +export type BuildLockFileSystem = Readonly<{ + open: (path: string, flags: number, mode: number) => Promise; + lstat: (path: string) => Promise; + unlink: (path: string) => Promise; +}>; + +export type BuildLock = Readonly<{ + release: () => Promise; +}>; + +export type BuildLockResult = + | Readonly<{ ok: true; value: BuildLock }> + | Readonly<{ ok: false; code: "cli-build-lock-unavailable" | "cli-build-lock-io" }>; + +const sameIdentity = (left: Identity, right: Identity) => + left.dev === right.dev && left.ino === right.ino; + +const defaultFileSystem: BuildLockFileSystem = Object.freeze({ open, lstat, unlink }); + +const isErrorCode = (error: unknown, code: string) => + typeof error === "object" && error !== null && "code" in error && error.code === code; + +const optionalLstat = async (fileSystem: BuildLockFileSystem, path: string) => { + try { + return await fileSystem.lstat(path); + } catch (error) { + if (isErrorCode(error, "ENOENT")) return undefined; + throw error; + } +}; + +/** + * Acquires a non-recovering build lease. A lock left by a crashed process is deliberately + * fail-closed: only an operator may inspect and remove it after confirming that no build lives. + */ +export const acquireBuildLock = async ( + projectDirectory: string, + fileSystem: BuildLockFileSystem = defaultFileSystem, +): Promise => { + if (!isAbsolute(projectDirectory)) return { ok: false, code: "cli-build-lock-io" }; + const path = join(projectDirectory, LOCK_FILE_NAME); + let handle; + try { + handle = await fileSystem.open( + path, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ); + } catch (error) { + return { + ok: false, + code: isErrorCode(error, "EEXIST") ? "cli-build-lock-unavailable" : "cli-build-lock-io", + }; + } + + const opened = await handle.stat().catch(() => undefined); + const linked = await optionalLstat(fileSystem, path).catch(() => undefined); + if ( + !opened || + !linked || + !opened.isFile() || + !linked.isFile() || + linked.isSymbolicLink() || + !sameIdentity(opened, linked) + ) { + await handle.close().catch(() => undefined); + if (opened) { + const current = await optionalLstat(fileSystem, path).catch(() => undefined); + if (current?.isFile() && !current.isSymbolicLink() && sameIdentity(opened, current)) + await fileSystem.unlink(path).catch(() => undefined); + } + return { ok: false, code: "cli-build-lock-io" }; + } + const identity: Identity = { dev: opened.dev, ino: opened.ino }; + let released = false; + let closed = false; + const release = async () => { + if (released) return; + if (!closed) { + try { + await handle.close(); + closed = true; + } catch { + throw new Error("Build lock release failed."); + } + } + let current; + try { + current = await optionalLstat(fileSystem, path); + } catch { + throw new Error("Build lock release failed."); + } + if (!current) { + released = true; + return; + } + if (!current.isFile() || current.isSymbolicLink() || !sameIdentity(identity, current)) + throw new Error("Build lock release failed."); + try { + await fileSystem.unlink(path); + } catch { + throw new Error("Build lock release failed."); + } + released = true; + }; + return { ok: true, value: Object.freeze({ release }) }; +}; + +export const buildLockFileName = LOCK_FILE_NAME; diff --git a/packages/presentation-cli/src/filesystem/discover-project.ts b/packages/presentation-cli/src/filesystem/discover-project.ts new file mode 100644 index 0000000..efe2cb5 --- /dev/null +++ b/packages/presentation-cli/src/filesystem/discover-project.ts @@ -0,0 +1,125 @@ +import { join, relative } from "node:path"; +import { loadProjectConfig } from "./load-config.js"; +import { + projectDirectory, + readDirectoryNames, + readRegularFile, + rootRelativePosix, +} from "./path-policy.js"; + +type ProjectFailureCode = + | "cli-project-discovery-invalid-directory" + | "cli-project-discovery-missing-files" + | "cli-config-invalid" + | "cli-project-discovery-invalid-entry-file" + | "cli-project-discovery-source-scan-failed"; + +export type ProjectSourceFile = Readonly<{ fileName: string; sourceText: string }>; + +export type DiscoveredProjectFiles = + | { + readonly ok: true; + readonly projectDirectory: string; + readonly entryFile: string; + readonly lockBytes: Uint8Array; + readonly files: readonly ProjectSourceFile[]; + } + | { readonly ok: false; readonly code: ProjectFailureCode; readonly message: string }; + +const failure = (code: ProjectFailureCode, message: string): DiscoveredProjectFiles => ({ + ok: false, + code, + message, +}); + +const sourceDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + +const ignoredDirectoryNames = new Set([".git", ".unframe", "dist", "node_modules"]); + +const sourceFileName = (fileName: string) => + fileName.endsWith(".ts") || fileName.endsWith(".tsx") || fileName.endsWith(".d.ts"); + +const compareCodeUnits = (left: string, right: string) => + left < right ? -1 : left > right ? 1 : 0; + +const sourceFailure = () => + failure( + "cli-project-discovery-source-scan-failed", + "Project source scan must contain only stable regular UTF-8 TypeScript files.", + ); + +const scanAuthoringSources = async ( + root: string, +): Promise => { + const files: ProjectSourceFile[] = []; + + const scan = async (directory: string): Promise => { + const names = await readDirectoryNames(directory); + if (!names) return false; + for (const name of [...names].sort(compareCodeUnits)) { + const path = join(directory, name); + const relativeName = relative(root, path).split("\\").join("/"); + if (!relativeName || relativeName.startsWith("../")) return false; + if (ignoredDirectoryNames.has(name)) continue; + + const nestedNames = await readDirectoryNames(path); + if (nestedNames) { + if (!(await scan(path))) return false; + continue; + } + + const bytes = await readRegularFile(path); + if (!bytes) return false; + if (!sourceFileName(name) || relativeName === "unframe.config.ts") continue; + try { + files.push({ fileName: relativeName, sourceText: sourceDecoder.decode(bytes) }); + } catch { + return false; + } + } + return true; + }; + + return (await scan(root)) + ? files.sort((left, right) => compareCodeUnits(left.fileName, right.fileName)) + : undefined; +}; + +/** Config parsing and raw file discovery only; lock validation belongs to the next boundary. */ +export const discoverPresentationProjectFiles = async ( + directory: string, +): Promise => { + const root = await projectDirectory(directory); + if (!root) + return failure( + "cli-project-discovery-invalid-directory", + "Project directory must be an absolute non-symbolic-link directory.", + ); + const config = await readRegularFile(join(root, "unframe.config.ts")); + const lock = await readRegularFile(join(root, "unframe.lock")); + if (!config || !lock) + return failure( + "cli-project-discovery-missing-files", + "Project root must contain regular unframe.config.ts and unframe.lock files.", + ); + const entryFile = loadProjectConfig(config); + if (!entryFile) + return failure( + "cli-config-invalid", + "Project config must be a data-only default export with entryFile.", + ); + const entryPath = rootRelativePosix(root, entryFile); + if (!entryPath || !(await readRegularFile(entryPath))) + return failure( + "cli-project-discovery-invalid-entry-file", + "Project entryFile must name a regular non-symbolic-link file within the project root.", + ); + const files = await scanAuthoringSources(root); + if (!files) return sourceFailure(); + if (!files.some((file) => file.fileName === entryFile)) + return failure( + "cli-project-discovery-invalid-entry-file", + "Project entryFile must name a regular non-symbolic-link file within the project root.", + ); + return { ok: true, projectDirectory: root, entryFile, lockBytes: lock.slice(), files }; +}; diff --git a/packages/presentation-cli/src/filesystem/load-config.ts b/packages/presentation-cli/src/filesystem/load-config.ts new file mode 100644 index 0000000..85c2d32 --- /dev/null +++ b/packages/presentation-cli/src/filesystem/load-config.ts @@ -0,0 +1,60 @@ +import { + ScriptTarget, + createSourceFile, + isExportAssignment, + isIdentifier, + isObjectLiteralExpression, + isPropertyAssignment, + isStringLiteral, +} from "typescript"; + +const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + +const hasLoneSurrogate = (source: string) => { + for (let index = 0; index < source.length; index += 1) { + const code = source.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + if ( + index + 1 >= source.length || + source.charCodeAt(index + 1) < 0xdc00 || + source.charCodeAt(index + 1) > 0xdfff + ) + return true; + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) return true; + } + return false; +}; + +export const loadProjectConfig = (bytes: Uint8Array): string | undefined => { + let source: string; + try { + source = decoder.decode(bytes); + } catch { + return undefined; + } + if (source.charCodeAt(0) === 0xfeff || hasLoneSurrogate(source)) return undefined; + const file = createSourceFile("unframe.config.ts", source, ScriptTarget.ES2022, true); + const diagnostics = (file as unknown as { readonly parseDiagnostics: readonly unknown[] }) + .parseDiagnostics; + if (diagnostics.length !== 0 || file.statements.length !== 1) return undefined; + const statement = file.statements[0]!; + if ( + !isExportAssignment(statement) || + statement.isExportEquals || + !isObjectLiteralExpression(statement.expression) + ) + return undefined; + const properties = statement.expression.properties; + if (properties.length !== 1) return undefined; + const property = properties[0]!; + if ( + !isPropertyAssignment(property) || + !isIdentifier(property.name) || + property.name.text !== "entryFile" + ) + return undefined; + if (!isStringLiteral(property.initializer) || hasLoneSurrogate(property.initializer.text)) + return undefined; + return property.initializer.text; +}; diff --git a/packages/presentation-cli/src/filesystem/load-lock.ts b/packages/presentation-cli/src/filesystem/load-lock.ts new file mode 100644 index 0000000..2ebe1f6 --- /dev/null +++ b/packages/presentation-cli/src/filesystem/load-lock.ts @@ -0,0 +1,390 @@ +import { hashCanonicalJsonPayload } from "@unframe/presentation-core"; +import type { DeclarationProjectAssemblyCarrier } from "@unframe/presentation-compiler"; + +import { parseStrictJson, type StrictJsonRecord, type StrictJsonValue } from "./strict-json.js"; + +type ContentHash = `sha256:${string}`; +type PackageIdentity = Readonly<{ + packageName: string; + packageVersion: string; + packageIntegrity: ContentHash; +}>; +type LockedPackage = PackageIdentity & + Readonly<{ + files: readonly Readonly<{ fileName: string; sourceText: string }>[]; + exports: readonly Readonly<{ subpath: string; targetFile: string }>[]; + dependencies: readonly PackageIdentity[]; + }>; + +export type LoadedUnframeLock = Readonly<{ + virtualSource: Readonly<{ + packageDependencies: readonly PackageIdentity[]; + packages: readonly LockedPackage[]; + }>; + assemblyCarrier: DeclarationProjectAssemblyCarrier; + lockHash: ContentHash; +}>; + +export type LockDiagnostic = Readonly<{ + family: "syntax" | "semantic"; + code: string; + message: string; +}>; + +export type LoadUnframeLockResult = + | Readonly<{ ok: true; value: LoadedUnframeLock }> + | Readonly<{ ok: false; diagnostic: LockDiagnostic }>; + +const contentHashPattern = /^sha256:[0-9a-f]{64}$/u; +const compare = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0); +const isRecord = (value: StrictJsonValue): value is StrictJsonRecord => + typeof value === "object" && value !== null && !Array.isArray(value); +const exactKeys = (value: StrictJsonValue, keys: readonly string[]): value is StrictJsonRecord => + isRecord(value) && + Object.keys(value).length === keys.length && + Object.keys(value).every((key) => keys.includes(key)); +const contentHash = (value: StrictJsonValue | undefined): value is ContentHash => + typeof value === "string" && contentHashPattern.test(value); +const nonemptyString = (value: StrictJsonValue | undefined): value is string => + typeof value === "string" && value.length > 0; +const positiveInteger = (value: StrictJsonValue | undefined): value is number => + typeof value === "number" && Number.isSafeInteger(value) && value > 0; +const path = (value: StrictJsonValue | undefined) => + nonemptyString(value) && + !value.startsWith("/") && + !value.includes("\\") && + !value.includes("\0") && + value.split("/").every((segment) => segment !== "" && segment !== "." && segment !== ".."); +const identityKey = (value: PackageIdentity) => + `${value.packageName}\0${value.packageVersion}\0${value.packageIntegrity}`; +const identityCompare = (left: PackageIdentity, right: PackageIdentity) => + compare(left.packageName, right.packageName) || + compare(left.packageVersion, right.packageVersion) || + compare(left.packageIntegrity, right.packageIntegrity); +const failure = (code: string, message: string): LoadUnframeLockResult => ({ + ok: false, + diagnostic: { family: "semantic", code, message }, +}); + +// The Compiler owns package-name and version grammar; this boundary only fixes the serialized lock shape. +const parseIdentity = (value: StrictJsonValue): PackageIdentity | undefined => { + if (!exactKeys(value, ["packageName", "packageVersion", "packageIntegrity"])) return undefined; + const record = value; + if ( + !nonemptyString(record.packageName) || + !nonemptyString(record.packageVersion) || + !contentHash(record.packageIntegrity) + ) + return undefined; + return { + packageName: record.packageName, + packageVersion: record.packageVersion, + packageIntegrity: record.packageIntegrity, + }; +}; + +const unique = (items: readonly T[], key: (item: T) => string) => { + const seen = new Set(); + return items.every((item) => !seen.has(key(item)) && (seen.add(key(item)), true)); +}; + +type PackageParseResult = + | { readonly ok: true; readonly value: LockedPackage } + | { readonly ok: false; readonly code: string }; + +const parsePackage = (value: StrictJsonValue): PackageParseResult => { + if ( + !exactKeys(value, [ + "packageName", + "packageVersion", + "packageIntegrity", + "files", + "exports", + "dependencies", + ]) + ) + return { ok: false, code: "cli-lock-package-shape-invalid" }; + const record = value; + const identity = parseIdentity({ + packageName: record.packageName!, + packageVersion: record.packageVersion!, + packageIntegrity: record.packageIntegrity!, + }); + if ( + !identity || + !Array.isArray(record.files) || + !Array.isArray(record.exports) || + !Array.isArray(record.dependencies) + ) + return { + ok: false, + code: !contentHash(record.packageIntegrity) + ? "cli-lock-content-hash-invalid" + : "cli-lock-package-shape-invalid", + }; + const files = record.files.map((file) => { + if ( + !exactKeys(file, ["fileName", "sourceText"]) || + !path(file.fileName) || + typeof file.sourceText !== "string" + ) + return undefined; + return { fileName: file.fileName, sourceText: file.sourceText }; + }); + const exports = record.exports.map((entry) => { + if ( + !exactKeys(entry, ["subpath", "targetFile"]) || + !nonemptyString(entry.subpath) || + !path(entry.targetFile) || + (entry.subpath !== "." && !(entry.subpath.startsWith("./") && path(entry.subpath.slice(2)))) + ) + return undefined; + return { subpath: entry.subpath, targetFile: entry.targetFile }; + }); + const dependencies = record.dependencies.map(parseIdentity); + if (files.some((file) => !file)) + return { ok: false, code: "cli-lock-package-file-shape-invalid" }; + if (exports.some((entry) => !entry)) + return { ok: false, code: "cli-lock-package-export-shape-invalid" }; + if (dependencies.some((item) => !item)) + return { ok: false, code: "cli-lock-package-dependency-shape-invalid" }; + const normalizedFiles = files as { fileName: string; sourceText: string }[]; + const normalizedExports = exports as { subpath: string; targetFile: string }[]; + const normalizedDependencies = dependencies as PackageIdentity[]; + if ( + !unique(normalizedFiles, (item) => item.fileName) || + !unique(normalizedExports, (item) => item.subpath) || + !unique(normalizedDependencies, identityKey) + ) + return { + ok: false, + code: !unique(normalizedFiles, (item) => item.fileName) + ? "cli-lock-duplicate-package-file" + : !unique(normalizedExports, (item) => item.subpath) + ? "cli-lock-duplicate-package-export" + : "cli-lock-duplicate-package-dependency", + }; + const normalized = { + ...identity, + files: [...normalizedFiles].sort((left, right) => compare(left.fileName, right.fileName)), + exports: [...normalizedExports].sort((left, right) => compare(left.subpath, right.subpath)), + dependencies: [...normalizedDependencies].sort(identityCompare), + }; + const expectedIntegrity = hashCanonicalJsonPayload({ + packageName: normalized.packageName, + packageVersion: normalized.packageVersion, + files: normalized.files, + exports: normalized.exports, + dependencies: normalized.dependencies, + }) as ContentHash; + return expectedIntegrity === normalized.packageIntegrity + ? { ok: true, value: normalized } + : { ok: false, code: "cli-lock-package-integrity-mismatch" }; +}; + +export const loadUnframeLock = (bytes: Uint8Array): LoadUnframeLockResult => { + const parsed = parseStrictJson(bytes); + if (!parsed.ok) + return { + ok: false, + diagnostic: { + family: "syntax", + code: parsed.code, + message: "unframe.lock must be strict UTF-8 JSON.", + }, + }; + if ( + !exactKeys(parsed.value, [ + "schemaVersion", + "packageDependencies", + "packages", + "themeHashes", + "componentLocks", + "assets", + ]) + ) + return failure( + "cli-lock-shape-invalid", + "unframe.lock must match the v1 serialized shape exactly.", + ); + const root = parsed.value; + if (root.schemaVersion !== 1) + return failure("cli-lock-schema-version-invalid", "unframe.lock schemaVersion must be 1."); + if ( + !Array.isArray(root.packageDependencies) || + !Array.isArray(root.packages) || + !Array.isArray(root.themeHashes) || + !Array.isArray(root.componentLocks) || + root.assets === undefined || + !isRecord(root.assets) + ) + return failure( + "cli-lock-shape-invalid", + "unframe.lock must match the v1 serialized shape exactly.", + ); + const packageDependencies = root.packageDependencies.map(parseIdentity); + if (packageDependencies.some((item) => !item)) + return failure( + "cli-lock-content-hash-invalid", + "Package identities must contain valid content hashes.", + ); + const packages = root.packages.map(parsePackage); + const invalidPackage = packages.find((item) => !item.ok); + if (invalidPackage && !invalidPackage.ok) + return failure(invalidPackage.code, "Locked package does not match the v1 contract."); + const dependencies = packageDependencies as PackageIdentity[]; + const lockedPackages = packages.map( + (item) => (item as Extract).value, + ); + if (!unique(dependencies, identityKey) || !unique(lockedPackages, identityKey)) + return failure( + "cli-lock-duplicate-package-identity", + "Lock package identities must be unique.", + ); + if (!unique(lockedPackages, (item) => item.packageName)) + return failure( + "cli-lock-duplicate-package-name", + "Only one locked package may use a package name.", + ); + const packageKeys = new Set(lockedPackages.map(identityKey)); + if ( + [...dependencies, ...lockedPackages.flatMap((item) => item.dependencies)].some( + (item) => !packageKeys.has(identityKey(item)), + ) + ) + return failure( + "cli-lock-package-reference-missing", + "Package references must match an existing locked package identity.", + ); + if ( + lockedPackages.some((item) => + item.exports.some((entry) => !item.files.some((file) => file.fileName === entry.targetFile)), + ) + ) + return failure( + "cli-lock-package-export-target-missing", + "Package export targets must name a locked package file.", + ); + + const themeHashes = root.themeHashes.map((value) => { + if ( + !exactKeys(value, ["themeId", "hash"]) || + !nonemptyString(value.themeId) || + !contentHash(value.hash) + ) + return undefined; + return { themeId: value.themeId, hash: value.hash }; + }); + if (themeHashes.some((item) => !item)) + return failure("cli-lock-content-hash-invalid", "Theme hashes must be valid content hashes."); + const themes = themeHashes as { themeId: string; hash: ContentHash }[]; + if (!unique(themes, (item) => item.themeId)) + return failure("cli-lock-duplicate-theme-id", "Theme ids must be unique."); + + const componentLocks = root.componentLocks.map((value) => { + const lockValue = isRecord(value) ? value.lock : undefined; + if ( + !exactKeys(value, ["componentId", "version", "lock"]) || + !nonemptyString(value.componentId) || + !positiveInteger(value.version) || + lockValue === undefined || + !exactKeys(lockValue, ["packageVersion", "packageIntegrity", "manifestHash", "structureHash"]) + ) + return undefined; + const lock = lockValue; + if ( + !nonemptyString(lock.packageVersion) || + !contentHash(lock.packageIntegrity) || + !contentHash(lock.manifestHash) || + !contentHash(lock.structureHash) + ) + return undefined; + return { + componentId: value.componentId, + version: value.version, + lock: { + packageVersion: lock.packageVersion, + packageIntegrity: lock.packageIntegrity, + manifestHash: lock.manifestHash, + structureHash: lock.structureHash, + }, + }; + }); + if (componentLocks.some((item) => !item)) + return failure( + "cli-lock-content-hash-invalid", + "Component locks must contain valid content hashes.", + ); + const components = componentLocks as NonNullable<(typeof componentLocks)[number]>[]; + if (!unique(components, (item) => `${item.componentId}\0${item.version}`)) + return failure("cli-lock-duplicate-component-lock", "Component locks must be unique."); + + const assetEntries = Object.entries(root.assets).map(([key, value]) => { + if ( + !nonemptyString(key) || + !exactKeys(value, ["id", "mediaType", "checksum"]) || + !nonemptyString(value.id) || + !nonemptyString(value.mediaType) || + !contentHash(value.checksum) + ) + return undefined; + return [key, { id: value.id, mediaType: value.mediaType, checksum: value.checksum }] as const; + }); + if (assetEntries.some((entry) => !entry)) + return failure("cli-lock-content-hash-invalid", "Assets must contain valid content hashes."); + const assets = Object.fromEntries( + [ + ...(assetEntries as readonly (readonly [ + string, + { id: string; mediaType: string; checksum: ContentHash }, + ])[]), + ].sort(([left], [right]) => compare(left, right)), + ); + if (!unique(Object.values(assets), (asset) => asset.id)) + return failure("cli-lock-duplicate-asset-id", "Asset ids must be unique."); + + const freezeIdentity = (item: PackageIdentity) => Object.freeze({ ...item }); + const virtualSource = Object.freeze({ + packageDependencies: Object.freeze([...dependencies].sort(identityCompare).map(freezeIdentity)), + packages: Object.freeze( + [...lockedPackages].sort(identityCompare).map((item) => + Object.freeze({ + ...freezeIdentity(item), + files: Object.freeze(item.files.map((file) => Object.freeze({ ...file }))), + exports: Object.freeze(item.exports.map((entry) => Object.freeze({ ...entry }))), + dependencies: Object.freeze(item.dependencies.map(freezeIdentity)), + }), + ), + ), + }); + const assemblyCarrier = Object.freeze({ + themeHashes: Object.freeze( + [...themes] + .sort((left, right) => compare(left.themeId, right.themeId)) + .map((item) => Object.freeze({ ...item })), + ), + componentLocks: Object.freeze( + [...components] + .sort( + (left, right) => + compare(left.componentId, right.componentId) || left.version - right.version, + ) + .map((item) => Object.freeze({ ...item, lock: Object.freeze({ ...item.lock }) })), + ), + assets: Object.freeze( + Object.fromEntries( + Object.entries(assets).map(([key, asset]) => [key, Object.freeze({ ...asset })]), + ), + ), + }) as DeclarationProjectAssemblyCarrier; + const normalizedLock = { + schemaVersion: 1, + ...virtualSource, + themeHashes: assemblyCarrier.themeHashes, + componentLocks: assemblyCarrier.componentLocks, + assets, + }; + const lockHash = hashCanonicalJsonPayload(normalizedLock) as ContentHash; + const value = Object.freeze({ virtualSource, assemblyCarrier, lockHash }); + return { ok: true, value }; +}; diff --git a/packages/presentation-cli/src/filesystem/path-policy.ts b/packages/presentation-cli/src/filesystem/path-policy.ts new file mode 100644 index 0000000..e1ccdb7 --- /dev/null +++ b/packages/presentation-cli/src/filesystem/path-policy.ts @@ -0,0 +1,117 @@ +import { constants } from "node:fs"; +import { lstat, open, readdir, realpath } from "node:fs/promises"; +import { isAbsolute, join, parse, relative } from "node:path"; + +type Identity = { readonly dev: number; readonly ino: number }; +type CheckedPath = { readonly identity: Identity; readonly resolved: string }; + +const sameIdentity = (left: Identity, right: Identity) => + left.dev === right.dev && left.ino === right.ino; + +const noSymlinkComponents = async (path: string): Promise => { + if (!isAbsolute(path)) return false; + const parsed = parse(path); + let current = parsed.root; + for (const component of path.slice(parsed.root.length).split("/")) { + if (!component) continue; + current = join(current, component); + const stat = await lstat(current).catch(() => undefined); + if (!stat || stat.isSymbolicLink()) return false; + } + return true; +}; + +const checkedPath = async (path: string, directory: boolean): Promise => { + if (!(await noSymlinkComponents(path))) return undefined; + const stat = await lstat(path).catch(() => undefined); + if (!stat || stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile())) + return undefined; + const resolved = await realpath(path).catch(() => undefined); + return resolved ? { identity: { dev: stat.dev, ino: stat.ino }, resolved } : undefined; +}; + +const stablePath = async (path: string, before: CheckedPath, directory: boolean) => { + const after = await checkedPath(path, directory); + return ( + after && after.resolved === before.resolved && sameIdentity(after.identity, before.identity) + ); +}; + +export const readRegularFile = async (path: string): Promise => { + const before = await checkedPath(path, false); + if (!before) return undefined; + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW).catch(() => undefined); + if (!handle) return undefined; + let bytes: Uint8Array | undefined; + try { + const stat = await handle.stat(); + if (stat.isFile() && sameIdentity(before.identity, { dev: stat.dev, ino: stat.ino })) + bytes = await handle.readFile(); + } catch {} + const stable = bytes && (await stablePath(path, before, false)); + try { + await handle.close(); + } catch { + return undefined; + } + return stable && bytes ? bytes.slice() : undefined; +}; + +export const projectDirectory = async (path: string) => { + const before = await checkedPath(path, true); + if (!before) return undefined; + const handle = await open( + path, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ).catch(() => undefined); + if (!handle) return undefined; + let stable = false; + try { + const stat = await handle.stat(); + stable = Boolean( + stat.isDirectory() && + sameIdentity(before.identity, { dev: stat.dev, ino: stat.ino }) && + (await stablePath(path, before, true)), + ); + } catch {} + try { + await handle.close(); + } catch { + return undefined; + } + return stable ? before.resolved : undefined; +}; + +/** Returns a stable snapshot of direct names; callers must validate every discovered entry. */ +export const readDirectoryNames = async (path: string): Promise => { + const before = await checkedPath(path, true); + if (!before) return undefined; + const handle = await open( + path, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ).catch(() => undefined); + if (!handle) return undefined; + let names: readonly string[] | undefined; + try { + const stat = await handle.stat(); + if (stat.isDirectory() && sameIdentity(before.identity, { dev: stat.dev, ino: stat.ino })) + names = await readdir(path); + } catch {} + const stable = names && (await stablePath(path, before, true)); + try { + await handle.close(); + } catch { + return undefined; + } + return stable && names ? [...names] : undefined; +}; + +export const rootRelativePosix = (root: string, value: string) => { + if (!value || value.includes("\\") || value.startsWith("/") || value.includes("\0")) + return undefined; + const components = value.split("/"); + if (components.some((component) => !component || component === "." || component === "..")) + return undefined; + const path = join(root, ...components); + return relative(root, path).startsWith("..") ? undefined : path; +}; diff --git a/packages/presentation-cli/src/filesystem/strict-json.ts b/packages/presentation-cli/src/filesystem/strict-json.ts new file mode 100644 index 0000000..aaa7502 --- /dev/null +++ b/packages/presentation-cli/src/filesystem/strict-json.ts @@ -0,0 +1,238 @@ +export type StrictJsonPrimitive = boolean | null | number | string; +export type StrictJsonValue = StrictJsonPrimitive | StrictJsonRecord | readonly StrictJsonValue[]; +export interface StrictJsonRecord { + readonly [key: string]: StrictJsonValue; +} + +type StrictJsonFailureCode = + | "cli-lock-json-bom" + | "cli-lock-json-duplicate-key" + | "cli-lock-json-invalid-utf8" + | "cli-lock-json-lone-surrogate" + | "cli-lock-json-number" + | "cli-lock-json-resource-limit" + | "cli-lock-json-syntax"; + +export type StrictJsonParseResult = + | { readonly ok: true; readonly value: StrictJsonValue } + | { readonly ok: false; readonly code: StrictJsonFailureCode }; + +const MAX_INPUT_BYTES = 64 * 1_024 * 1_024; +const MAX_NESTING_DEPTH = 64; + +class ParseFailure extends Error { + override readonly name = "ParseFailure"; + + constructor(readonly code: StrictJsonFailureCode) { + super(code); + } +} + +const fail = (code: StrictJsonFailureCode): never => { + throw new ParseFailure(code); +}; + +const isWhitespace = (character: string) => + character === " " || character === "\n" || character === "\r" || character === "\t"; + +const isDigit = (character: string | undefined) => + character !== undefined && character >= "0" && character <= "9"; + +const isHexDigit = (character: string | undefined) => + character !== undefined && + ((character >= "0" && character <= "9") || + (character >= "a" && character <= "f") || + (character >= "A" && character <= "F")); + +class StrictJsonParser { + #index = 0; + + constructor(readonly source: string) {} + + parse(): StrictJsonValue { + this.#skipWhitespace(); + const value = this.#value(0); + this.#skipWhitespace(); + if (this.#index !== this.source.length) fail("cli-lock-json-syntax"); + return value; + } + + #value(depth: number): StrictJsonValue { + const character = this.source[this.#index]; + if (character === "{") return this.#object(depth + 1); + if (character === "[") return this.#array(depth + 1); + if (character === '"') return this.#string(); + if (character === "-" || isDigit(character)) return this.#number(); + if (this.source.startsWith("true", this.#index)) { + this.#index += 4; + return true; + } + if (this.source.startsWith("false", this.#index)) { + this.#index += 5; + return false; + } + if (this.source.startsWith("null", this.#index)) { + this.#index += 4; + return null; + } + return fail("cli-lock-json-syntax"); + } + + #object(depth: number): StrictJsonRecord { + if (depth > MAX_NESTING_DEPTH) fail("cli-lock-json-resource-limit"); + this.#index += 1; + this.#skipWhitespace(); + const record: Record = Object.create(null) as Record< + string, + StrictJsonValue + >; + if (this.source[this.#index] === "}") { + this.#index += 1; + return record; + } + while (true) { + if (this.source[this.#index] !== '"') fail("cli-lock-json-syntax"); + const key = this.#string(); + if (Object.hasOwn(record, key)) fail("cli-lock-json-duplicate-key"); + this.#skipWhitespace(); + if (this.source[this.#index] !== ":") fail("cli-lock-json-syntax"); + this.#index += 1; + this.#skipWhitespace(); + record[key] = this.#value(depth); + this.#skipWhitespace(); + const separator = this.source[this.#index]; + if (separator === "}") { + this.#index += 1; + return record; + } + if (separator !== ",") fail("cli-lock-json-syntax"); + this.#index += 1; + this.#skipWhitespace(); + } + } + + #array(depth: number): readonly StrictJsonValue[] { + if (depth > MAX_NESTING_DEPTH) fail("cli-lock-json-resource-limit"); + this.#index += 1; + this.#skipWhitespace(); + const values: StrictJsonValue[] = []; + if (this.source[this.#index] === "]") { + this.#index += 1; + return values; + } + while (true) { + values.push(this.#value(depth)); + this.#skipWhitespace(); + const separator = this.source[this.#index]; + if (separator === "]") { + this.#index += 1; + return values; + } + if (separator !== ",") fail("cli-lock-json-syntax"); + this.#index += 1; + this.#skipWhitespace(); + } + } + + #string(): string { + this.#index += 1; + let value = ""; + while (this.#index < this.source.length) { + const character = this.source[this.#index++]!; + if (character === '"') return value; + if (character === "\\") { + value += this.#escape(); + continue; + } + const code = character.charCodeAt(0); + if (code < 0x20) fail("cli-lock-json-syntax"); + if (code >= 0xd800 && code <= 0xdbff) { + const next = this.source[this.#index]; + if (next === undefined || next.charCodeAt(0) < 0xdc00 || next.charCodeAt(0) > 0xdfff) + fail("cli-lock-json-lone-surrogate"); + value += character + next; + this.#index += 1; + continue; + } + if (code >= 0xdc00 && code <= 0xdfff) fail("cli-lock-json-lone-surrogate"); + value += character; + } + return fail("cli-lock-json-syntax"); + } + + #escape(): string { + const escape = this.source[this.#index++]; + if (escape === '"' || escape === "\\" || escape === "/") return escape; + if (escape === "b") return "\b"; + if (escape === "f") return "\f"; + if (escape === "n") return "\n"; + if (escape === "r") return "\r"; + if (escape === "t") return "\t"; + if (escape !== "u") return fail("cli-lock-json-syntax"); + + const codeUnit = this.#unicodeEscape(); + if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) fail("cli-lock-json-lone-surrogate"); + if (codeUnit < 0xd800 || codeUnit > 0xdbff) return String.fromCharCode(codeUnit); + if (this.source[this.#index] !== "\\" || this.source[this.#index + 1] !== "u") + fail("cli-lock-json-lone-surrogate"); + this.#index += 2; + const lowSurrogate = this.#unicodeEscape(); + if (lowSurrogate < 0xdc00 || lowSurrogate > 0xdfff) fail("cli-lock-json-lone-surrogate"); + return String.fromCharCode(codeUnit, lowSurrogate); + } + + #unicodeEscape(): number { + const start = this.#index; + for (let offset = 0; offset < 4; offset += 1) { + if (!isHexDigit(this.source[this.#index + offset])) fail("cli-lock-json-syntax"); + } + this.#index += 4; + return Number.parseInt(this.source.slice(start, this.#index), 16); + } + + #number(): number { + const start = this.#index; + if (this.source[this.#index] === "-") this.#index += 1; + if (this.source[this.#index] === "0") this.#index += 1; + else { + if (!isDigit(this.source[this.#index])) fail("cli-lock-json-syntax"); + while (isDigit(this.source[this.#index])) this.#index += 1; + } + if (this.source[this.#index] === ".") { + this.#index += 1; + if (!isDigit(this.source[this.#index])) fail("cli-lock-json-syntax"); + while (isDigit(this.source[this.#index])) this.#index += 1; + } + if (this.source[this.#index] === "e" || this.source[this.#index] === "E") { + this.#index += 1; + if (this.source[this.#index] === "+" || this.source[this.#index] === "-") this.#index += 1; + if (!isDigit(this.source[this.#index])) fail("cli-lock-json-syntax"); + while (isDigit(this.source[this.#index])) this.#index += 1; + } + const value = Number(this.source.slice(start, this.#index)); + if (!Number.isFinite(value)) fail("cli-lock-json-number"); + return value; + } + + #skipWhitespace(): void { + while (isWhitespace(this.source[this.#index] ?? "")) this.#index += 1; + } +} + +export const parseStrictJson = (input: Uint8Array): StrictJsonParseResult => { + if (input.byteLength > MAX_INPUT_BYTES) + return { ok: false, code: "cli-lock-json-resource-limit" }; + let source: string; + try { + source = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(input); + } catch { + return { ok: false, code: "cli-lock-json-invalid-utf8" }; + } + if (source.charCodeAt(0) === 0xfeff) return { ok: false, code: "cli-lock-json-bom" }; + try { + return { ok: true, value: new StrictJsonParser(source).parse() }; + } catch (error) { + if (error instanceof ParseFailure) return { ok: false, code: error.code }; + return { ok: false, code: "cli-lock-json-syntax" }; + } +}; diff --git a/packages/presentation-cli/src/index.ts b/packages/presentation-cli/src/index.ts new file mode 100644 index 0000000..50807f0 --- /dev/null +++ b/packages/presentation-cli/src/index.ts @@ -0,0 +1,9 @@ +export { runPresentationCli } from "./application/run-presentation-cli.js"; +export { runPresentationProcess } from "./process/run-presentation-process.js"; +export type { + PresentationCliBuildContext, + PresentationCliExitCode, + PresentationCliHost, + PresentationCliResult, + RunPresentationCliInput, +} from "./application/types.js"; diff --git a/packages/presentation-cli/src/process/main.ts b/packages/presentation-cli/src/process/main.ts new file mode 100644 index 0000000..ead770f --- /dev/null +++ b/packages/presentation-cli/src/process/main.ts @@ -0,0 +1,5 @@ +import { runPresentationProcess } from "./run-presentation-process.js"; + +void runPresentationProcess({ process }).catch(() => { + process.exitCode = 3; +}); diff --git a/packages/presentation-cli/src/process/run-presentation-process.ts b/packages/presentation-cli/src/process/run-presentation-process.ts new file mode 100644 index 0000000..f8f2334 --- /dev/null +++ b/packages/presentation-cli/src/process/run-presentation-process.ts @@ -0,0 +1,64 @@ +import { runPresentationCli } from "../application/run-presentation-cli.js"; +import type { PresentationCliResult } from "../application/types.js"; + +type SignalName = "SIGINT" | "SIGTERM"; + +export type PresentationProcess = { + argv: readonly string[]; + stderr: Readonly<{ write: (text: string) => unknown }>; + stdout: Readonly<{ write: (text: string) => unknown }>; + on: (signal: SignalName, listener: () => void) => unknown; + off: (signal: SignalName, listener: () => void) => unknown; + exitCode: number | string | null | undefined; +}; + +export type RunPresentationProcessInput = Readonly<{ + process: PresentationProcess; + run?: ( + input: Readonly<{ args: readonly string[]; host: Readonly<{ signal: AbortSignal }> }>, + ) => PresentationCliResult | Promise; +}>; + +const ioFailure = (): PresentationCliResult => ({ + exitCode: 3, + stdout: "", + stderr: "$: io/cli-process-io: Presentation process could not be completed.\n", +}); + +/** The executable owner of SIGINT/SIGTERM. Application code only receives its signal. */ +export const runPresentationProcess = async ({ + process, + run = runPresentationCli, +}: RunPresentationProcessInput): Promise => { + const controller = new AbortController(); + const abort = () => controller.abort(); + process.on("SIGINT", abort); + process.on("SIGTERM", abort); + let result: PresentationCliResult; + try { + result = await Promise.resolve( + run({ args: process.argv.slice(2), host: { signal: controller.signal } }), + ); + if (controller.signal.aborted && result.exitCode !== 130) + result = { + exitCode: 130, + stdout: "", + stderr: "$: cancel/cli-cancelled: Build was cancelled.\n", + }; + } catch { + result = controller.signal.aborted + ? { exitCode: 130, stdout: "", stderr: "$: cancel/cli-cancelled: Build was cancelled.\n" } + : ioFailure(); + } finally { + process.off("SIGINT", abort); + process.off("SIGTERM", abort); + } + try { + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + } catch { + result = ioFailure(); + } + process.exitCode = result.exitCode; + return result; +}; diff --git a/packages/presentation-cli/src/tui/main.tsx b/packages/presentation-cli/src/tui/main.tsx new file mode 100644 index 0000000..3b1d61f --- /dev/null +++ b/packages/presentation-cli/src/tui/main.tsx @@ -0,0 +1,3 @@ +import { runPresentationTui } from "./run.js"; + +await runPresentationTui(); diff --git a/packages/presentation-cli/src/tui/model.ts b/packages/presentation-cli/src/tui/model.ts new file mode 100644 index 0000000..caf3631 --- /dev/null +++ b/packages/presentation-cli/src/tui/model.ts @@ -0,0 +1,70 @@ +export type PresentationTuiCommandId = "build" | "check"; + +export type PresentationTuiCommand = Readonly<{ + id: PresentationTuiCommandId; + label: string; + description: string; +}>; + +export const presentationTuiCommands: readonly PresentationTuiCommand[] = Object.freeze([ + Object.freeze({ + id: "check", + label: "Check presentation", + description: "Validate an authoring project without rendering artifacts.", + }), + Object.freeze({ + id: "build", + label: "Build presentation", + description: "Compile and render a complete artifact set.", + }), +]); + +export type PresentationTuiEffect = + | Readonly<{ type: "command-selected"; command: PresentationTuiCommandId }> + | Readonly<{ type: "quit" }>; + +export type PresentationTuiState = Readonly<{ + selectedIndex: number; + effect?: PresentationTuiEffect; +}>; + +export type PresentationTuiAction = Readonly<{ + type: "effect-handled" | "next" | "previous" | "quit" | "select"; +}>; + +export const initialPresentationTuiState: PresentationTuiState = Object.freeze({ + selectedIndex: 0, +}); + +export const reducePresentationTuiState = ( + state: PresentationTuiState, + action: PresentationTuiAction, +): PresentationTuiState => { + switch (action.type) { + case "next": + return Object.freeze({ + selectedIndex: (state.selectedIndex + 1) % presentationTuiCommands.length, + }); + case "previous": + return Object.freeze({ + selectedIndex: + (state.selectedIndex + presentationTuiCommands.length - 1) % + presentationTuiCommands.length, + }); + case "select": + return Object.freeze({ + selectedIndex: state.selectedIndex, + effect: Object.freeze({ + type: "command-selected", + command: presentationTuiCommands[state.selectedIndex]?.id ?? "check", + }), + }); + case "quit": + return Object.freeze({ + selectedIndex: state.selectedIndex, + effect: Object.freeze({ type: "quit" }), + }); + case "effect-handled": + return Object.freeze({ selectedIndex: state.selectedIndex }); + } +}; diff --git a/packages/presentation-cli/src/tui/run.tsx b/packages/presentation-cli/src/tui/run.tsx new file mode 100644 index 0000000..2c7f51c --- /dev/null +++ b/packages/presentation-cli/src/tui/run.tsx @@ -0,0 +1,71 @@ +import { createCliRenderer, type CliRenderer } from "@opentui/core"; +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"; +import { KeymapProvider, useBindings } from "@opentui/keymap/solid"; +import { render } from "@opentui/solid"; +import { createEffect, createSignal } from "solid-js"; + +import { + initialPresentationTuiState, + reducePresentationTuiState, + type PresentationTuiAction, + type PresentationTuiCommandId, +} from "./model.js"; +import { PresentationTuiView } from "./view.js"; + +export type RunPresentationTuiOptions = Readonly<{ + onCommandSelected?: (command: PresentationTuiCommandId) => void; +}>; + +type PresentationTuiAppProps = RunPresentationTuiOptions & + Readonly<{ + renderer: CliRenderer; + }>; + +const PresentationTuiApp = (props: PresentationTuiAppProps) => { + const [state, setState] = createSignal(initialPresentationTuiState); + const dispatch = (action: PresentationTuiAction) => { + setState((current) => reducePresentationTuiState(current, action)); + }; + + useBindings(() => ({ + bindings: [ + { key: "up", cmd: () => dispatch({ type: "previous" }) }, + { key: "k", cmd: () => dispatch({ type: "previous" }) }, + { key: "down", cmd: () => dispatch({ type: "next" }) }, + { key: "j", cmd: () => dispatch({ type: "next" }) }, + { key: "enter", cmd: () => dispatch({ type: "select" }) }, + { key: "q", cmd: () => dispatch({ type: "quit" }) }, + { key: "escape", cmd: () => dispatch({ type: "quit" }) }, + { key: "ctrl+c", cmd: () => dispatch({ type: "quit" }) }, + ], + })); + + createEffect(() => { + const effect = state().effect; + if (!effect) return; + if (effect.type === "command-selected") props.onCommandSelected?.(effect.command); + if (effect.type === "quit") props.renderer.destroy(); + dispatch({ type: "effect-handled" }); + }); + + return ; +}; + +export const runPresentationTui = async (options: RunPresentationTuiOptions = {}) => { + if (!("Bun" in globalThis)) throw new Error("Presentation TUI requires the Bun runtime."); + + const renderer = await createCliRenderer({ + exitOnCtrlC: false, + screenMode: "alternate-screen", + }); + const keymap = createDefaultOpenTuiKeymap(renderer); + + await render( + () => ( + + + + ), + renderer, + ); +}; diff --git a/packages/presentation-cli/src/tui/view.tsx b/packages/presentation-cli/src/tui/view.tsx new file mode 100644 index 0000000..07cbdef --- /dev/null +++ b/packages/presentation-cli/src/tui/view.tsx @@ -0,0 +1,30 @@ +import { For } from "solid-js"; + +import { presentationTuiCommands, type PresentationTuiState } from "./model.js"; + +export type PresentationTuiViewProps = Readonly<{ + state: PresentationTuiState; +}>; + +export const PresentationTuiView = (props: PresentationTuiViewProps) => ( + + Unframe Presentation + Choose an authoring workflow. + + + {(command, index) => { + const selected = () => index() === props.state.selectedIndex; + return ( + + + {`${selected() ? ">" : " "} ${command.label}`} + + {` ${command.description}`} + + ); + }} + + + ↑/↓ navigate • Enter select • q quit + +); diff --git a/packages/presentation-cli/test-bun/tui-render.test.bun.tsx b/packages/presentation-cli/test-bun/tui-render.test.bun.tsx new file mode 100644 index 0000000..293fd9d --- /dev/null +++ b/packages/presentation-cli/test-bun/tui-render.test.bun.tsx @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test"; +import { testRender } from "@opentui/solid"; + +import { initialPresentationTuiState } from "../src/tui/model.js"; +import { PresentationTuiView } from "../src/tui/view.js"; + +test("renders the command palette and keyboard help", async () => { + const setup = await testRender( + () => , + { height: 14, width: 72 }, + ); + + await setup.renderOnce(); + const frame = setup.captureCharFrame(); + + expect(frame).toContain("Unframe Presentation"); + expect(frame).toContain("> Check presentation"); + expect(frame).toContain("Build presentation"); + expect(frame).toContain("↑/↓ navigate"); + + setup.renderer.destroy(); +}); diff --git a/packages/presentation-cli/test/atomic-output.test.ts b/packages/presentation-cli/test/atomic-output.test.ts new file mode 100644 index 0000000..85766d2 --- /dev/null +++ b/packages/presentation-cli/test/atomic-output.test.ts @@ -0,0 +1,416 @@ +import { + lstat, + mkdir, + mkdtemp, + readFile, + readlink, + rm, + symlink, + unlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { publishAtomicArtifacts } from "../src/filesystem/atomic-output.js"; + +const directories: string[] = []; +const encoder = new TextEncoder(); + +const project = async () => { + const directory = await mkdtemp(join(tmpdir(), "unframe-atomic-output-")); + directories.push(directory); + return directory; +}; + +const artifacts = (suffix = "one") => ({ + definition: encoder.encode(`definition-${suffix}`), + renderBundle: encoder.encode(`bundle-${suffix}`), + assets: [ + { assetId: "z asset", bytes: encoder.encode(`z-${suffix}`) }, + { assetId: "a/asset", bytes: encoder.encode(`a-${suffix}`) }, + ], +}); + +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })), + ); +}); + +describe("atomic artifact publication", () => { + it("writes a complete first generation and atomically replaces it on a second build", async () => { + const directory = await project(); + await expect( + publishAtomicArtifacts({ + projectDirectory: directory, + artifacts: artifacts(), + generationId: () => "a".repeat(32), + }), + ).resolves.toMatchObject({ ok: true }); + expect(await readlink(join(directory, "dist"))).toBe( + ".unframe/generations/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + + await expect( + publishAtomicArtifacts({ + projectDirectory: directory, + artifacts: artifacts("two"), + generationId: () => "b".repeat(32), + }), + ).resolves.toMatchObject({ ok: true }); + expect(await readlink(join(directory, "dist"))).toBe( + ".unframe/generations/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ); + await expect(readFile(join(directory, "dist", "definition.json"), "utf8")).resolves.toBe( + "definition-two", + ); + }); + + it("uses deterministic file bytes and encoded asset paths", async () => { + const directory = await project(); + await publishAtomicArtifacts({ + projectDirectory: directory, + artifacts: artifacts(), + generationId: () => "c".repeat(32), + }); + await publishAtomicArtifacts({ + projectDirectory: directory, + artifacts: artifacts(), + generationId: () => "d".repeat(32), + }); + const first = join(directory, ".unframe/generations", "c".repeat(32)); + const second = join(directory, ".unframe/generations", "d".repeat(32)); + await expect(readFile(join(first, "assets", "a%2Fasset.png"))).resolves.toEqual( + await readFile(join(second, "assets", "a%2Fasset.png")), + ); + await expect(readFile(join(first, "definition.json"))).resolves.toEqual( + await readFile(join(second, "definition.json")), + ); + }); + + it("refuses unmanaged output and leaves it untouched", async () => { + const directory = await project(); + await writeFile(join(directory, "dist"), "user output"); + await expect( + publishAtomicArtifacts({ + projectDirectory: directory, + artifacts: artifacts(), + generationId: () => "e".repeat(32), + }), + ).resolves.toMatchObject({ ok: false, family: "io" }); + await expect(readFile(join(directory, "dist"), "utf8")).resolves.toBe("user output"); + }); + + it.each([ + ["absolute", "/tmp/outside"], + ["external", "../outside"], + ["malformed", ".unframe/generations/not-an-id"], + ["dangling", ".unframe/generations/0123456789abcdef0123456789abcdef"], + ])("refuses %s dist symlinks", async (_kind, target) => { + const directory = await project(); + await mkdir(join(directory, ".unframe/generations"), { recursive: true }); + await symlink(target, join(directory, "dist")); + await expect( + publishAtomicArtifacts({ + projectDirectory: directory, + artifacts: artifacts(), + generationId: () => "0".repeat(32), + }), + ).resolves.toMatchObject({ ok: false, family: "io" }); + await expect(readlink(join(directory, "dist"))).resolves.toBe(target); + }); + + it("refuses a dist directory and nested .unframe symlink", async () => { + const directory = await project(); + await mkdir(join(directory, "dist")); + await expect( + publishAtomicArtifacts({ + projectDirectory: directory, + artifacts: artifacts(), + generationId: () => "9".repeat(32), + }), + ).resolves.toMatchObject({ ok: false, family: "io" }); + + const second = await project(); + const external = await project(); + await symlink(external, join(second, ".unframe")); + await expect( + publishAtomicArtifacts({ + projectDirectory: second, + artifacts: artifacts(), + generationId: () => "8".repeat(32), + }), + ).resolves.toMatchObject({ ok: false, family: "io" }); + }); + + it("rejects malformed generation IDs and duplicate encoded asset paths without publishing partial output", async () => { + const directory = await project(); + await expect( + publishAtomicArtifacts({ + projectDirectory: directory, + artifacts: artifacts(), + generationId: () => "not-an-id", + }), + ).resolves.toMatchObject({ ok: false, family: "io" }); + await expect(lstat(join(directory, "dist"))).rejects.toThrow(); + + await expect( + publishAtomicArtifacts({ + projectDirectory: directory, + generationId: () => "f".repeat(32), + artifacts: { + ...artifacts(), + assets: [ + { assetId: "x", bytes: encoder.encode("a") }, + { assetId: "x", bytes: encoder.encode("b") }, + ], + }, + }), + ).resolves.toMatchObject({ ok: false, family: "io" }); + await expect(lstat(join(directory, "dist"))).rejects.toThrow(); + }); + + it("cancels before the first phase without publishing output", async () => { + const directory = await project(); + const controller = new AbortController(); + controller.abort(); + await expect( + publishAtomicArtifacts({ + projectDirectory: directory, + artifacts: artifacts(), + generationId: () => "1".repeat(32), + signal: controller.signal, + }), + ).resolves.toEqual({ ok: false, family: "cancel", code: "cli-output-cancel" }); + await expect(lstat(join(directory, "dist"))).rejects.toThrow(); + }); + + it("cleans only this staging directory and preserves the previous dist on a mid-phase cancel", async () => { + const directory = await project(); + await publishAtomicArtifacts({ + projectDirectory: directory, + artifacts: artifacts("previous"), + generationId: () => "2".repeat(32), + }); + const controller = new AbortController(); + await expect( + publishAtomicArtifacts({ + projectDirectory: directory, + artifacts: artifacts("cancelled"), + generationId: () => "3".repeat(32), + signal: controller.signal, + testing: { + onPhase: (phase) => { + if (phase === "staging-created") controller.abort(); + }, + }, + }), + ).resolves.toEqual({ ok: false, family: "cancel", code: "cli-output-cancel" }); + await expect(readlink(join(directory, "dist"))).resolves.toBe( + ".unframe/generations/22222222222222222222222222222222", + ); + await expect( + lstat(join(directory, ".unframe/generations/.staging-33333333333333333333333333333333")), + ).rejects.toThrow(); + }); + + it("rejects an asset ID with a lone surrogate without publishing output", async () => { + const directory = await project(); + await expect( + publishAtomicArtifacts({ + projectDirectory: directory, + generationId: () => "4".repeat(32), + artifacts: { + ...artifacts(), + assets: [{ assetId: "\ud800", bytes: encoder.encode("asset") }], + }, + }), + ).resolves.toMatchObject({ ok: false, family: "io" }); + await expect(lstat(join(directory, "dist"))).rejects.toThrow(); + }); + + it("does not publish partial output when a write path becomes unsafe", async () => { + const directory = await project(); + const external = await project(); + let changed = false; + await expect( + publishAtomicArtifacts({ + projectDirectory: directory, + generationId: () => "5".repeat(32), + artifacts: artifacts(), + testing: { + onPhase: async (phase) => { + if (phase !== "artifact-written" || changed) return; + changed = true; + const assets = join( + directory, + ".unframe/generations/.staging-55555555555555555555555555555555/assets", + ); + await rm(assets, { force: true, recursive: true }); + await symlink(external, assets); + }, + }, + }), + ).resolves.toMatchObject({ ok: false, family: "io" }); + await expect(lstat(join(directory, "dist"))).rejects.toThrow(); + await expect( + lstat(join(directory, ".unframe/generations/.staging-55555555555555555555555555555555")), + ).rejects.toThrow(); + }); + + it("does not remove a staging path that is replaced after it is created", async () => { + const directory = await project(); + const id = "6".repeat(32); + const staging = join(directory, ".unframe/generations", `.staging-${id}`); + await expect( + publishAtomicArtifacts({ + projectDirectory: directory, + artifacts: artifacts(), + generationId: () => id, + testing: { + onPhase: async (phase) => { + if (phase !== "staging-created") return; + await rm(staging, { force: true, recursive: true }); + await mkdir(staging, { mode: 0o700 }); + await writeFile(join(staging, "replacement.txt"), "do not remove"); + }, + }, + }), + ).resolves.toMatchObject({ ok: false, family: "io" }); + await expect(lstat(join(directory, "dist"))).rejects.toThrow(); + await expect(readFile(join(staging, "replacement.txt"), "utf8")).resolves.toBe("do not remove"); + }); + + it("does not overwrite an unmanaged dist inserted after the managed-output check", async () => { + const directory = await project(); + await publishAtomicArtifacts({ + projectDirectory: directory, + artifacts: artifacts("previous"), + generationId: () => "7".repeat(32), + }); + const external = await project(); + await writeFile(join(external, "sentinel.txt"), "user output"); + await expect( + publishAtomicArtifacts({ + projectDirectory: directory, + artifacts: artifacts("next"), + generationId: () => "8".repeat(32), + testing: { + onPhase: async (phase) => { + if (phase !== "before-dist-replace") return; + await unlink(join(directory, "dist")); + await symlink("../external-output", join(directory, "dist")); + }, + }, + }), + ).resolves.toMatchObject({ ok: false, family: "io" }); + await expect(readlink(join(directory, "dist"))).resolves.toBe("../external-output"); + await expect(readFile(join(external, "sentinel.txt"), "utf8")).resolves.toBe("user output"); + }); + + it("does not create a first dist when output appears after the absent-dist check", async () => { + const directory = await project(); + await expect( + publishAtomicArtifacts({ + projectDirectory: directory, + artifacts: artifacts(), + generationId: () => "9".repeat(32), + testing: { + onPhase: async (phase) => { + if (phase !== "before-dist-replace") return; + await symlink("user-managed-output", join(directory, "dist")); + }, + }, + }), + ).resolves.toMatchObject({ ok: false, family: "io" }); + await expect(readlink(join(directory, "dist"))).resolves.toBe("user-managed-output"); + }); + + it("cancels before generation rename and preserves the previous dist", async () => { + const directory = await project(); + await publishAtomicArtifacts({ + projectDirectory: directory, + artifacts: artifacts("previous"), + generationId: () => "a".repeat(32), + }); + const controller = new AbortController(); + await expect( + publishAtomicArtifacts({ + projectDirectory: directory, + artifacts: artifacts("cancelled"), + generationId: () => "b".repeat(32), + signal: controller.signal, + testing: { + onPhase: (phase) => { + if (phase === "before-generation-rename") controller.abort(); + }, + }, + }), + ).resolves.toEqual({ ok: false, family: "cancel", code: "cli-output-cancel" }); + await expect(readlink(join(directory, "dist"))).resolves.toBe( + ".unframe/generations/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + await expect(lstat(join(directory, ".unframe/generations", "b".repeat(32)))).rejects.toThrow(); + }); + + it("cancels before dist replacement without publishing a first dist", async () => { + const directory = await project(); + const controller = new AbortController(); + await expect( + publishAtomicArtifacts({ + projectDirectory: directory, + artifacts: artifacts(), + generationId: () => "c".repeat(32), + signal: controller.signal, + testing: { + onPhase: (phase) => { + if (phase === "before-dist-replace") controller.abort(); + }, + }, + }), + ).resolves.toEqual({ ok: false, family: "cancel", code: "cli-output-cancel" }); + await expect(lstat(join(directory, "dist"))).rejects.toThrow(); + }); + + it("reports an initial dist metadata access failure as I/O instead of missing output", async () => { + const directory = await project(); + await expect( + publishAtomicArtifacts({ + projectDirectory: directory, + artifacts: artifacts(), + generationId: () => "d".repeat(32), + testing: { + lstat: async (path) => { + if (path === join(directory, "dist")) { + throw Object.assign(new Error("permission denied"), { code: "EACCES" }); + } + return lstat(path); + }, + }, + }), + ).resolves.toEqual({ ok: false, family: "io", code: "cli-output-io" }); + }); + + it("reports an absent-dist recheck metadata failure as I/O without publishing dist", async () => { + const directory = await project(); + let distLookups = 0; + await expect( + publishAtomicArtifacts({ + projectDirectory: directory, + artifacts: artifacts(), + generationId: () => "e".repeat(32), + testing: { + lstat: async (path) => { + if (path === join(directory, "dist") && ++distLookups === 2) { + throw Object.assign(new Error("I/O error"), { code: "EIO" }); + } + return lstat(path); + }, + }, + }), + ).resolves.toEqual({ ok: false, family: "io", code: "cli-output-io" }); + await expect(lstat(join(directory, "dist"))).rejects.toThrow(); + }); +}); diff --git a/packages/presentation-cli/test/build-lock.test.ts b/packages/presentation-cli/test/build-lock.test.ts new file mode 100644 index 0000000..a3551e0 --- /dev/null +++ b/packages/presentation-cli/test/build-lock.test.ts @@ -0,0 +1,97 @@ +import { lstat, mkdtemp, open, rm, unlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { acquireBuildLock, buildLockFileName } from "../src/filesystem/build-lock.js"; + +const directories: string[] = []; +const project = async () => { + const directory = await mkdtemp(join(tmpdir(), "unframe-build-lock-")); + directories.push(directory); + return directory; +}; + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); +}); + +describe("build lock", () => { + it("exclusively retains and releases its own regular-file inode", async () => { + const directory = await project(); + const first = await acquireBuildLock(directory); + expect(first.ok).toBe(true); + if (!first.ok) return; + expect((await lstat(join(directory, buildLockFileName))).isFile()).toBe(true); + const concurrent = await acquireBuildLock(directory); + expect(concurrent).toEqual({ ok: false, code: "cli-build-lock-unavailable" }); + await first.value.release(); + await expect(lstat(join(directory, buildLockFileName))).rejects.toThrow(); + }); + + it("fails closed when a stale lock exists", async () => { + const directory = await project(); + await writeFile(join(directory, buildLockFileName), "stale"); + await expect(acquireBuildLock(directory)).resolves.toEqual({ + ok: false, + code: "cli-build-lock-unavailable", + }); + }); + + it("removes its own lock when post-open validation fails", async () => { + const directory = await project(); + const path = join(directory, buildLockFileName); + let lstatCalls = 0; + const acquired = await acquireBuildLock(directory, { + open, + unlink, + lstat: async (candidate) => { + lstatCalls += 1; + if (lstatCalls === 1) throw Object.assign(new Error("I/O failure"), { code: "EIO" }); + return lstat(candidate); + }, + }); + + expect(acquired).toEqual({ ok: false, code: "cli-build-lock-io" }); + await expect(lstat(path)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("surfaces release I/O and permits retrying cleanup", async () => { + const directory = await project(); + const path = join(directory, buildLockFileName); + let releaseLstatFailed = false; + let lstatCalls = 0; + const acquired = await acquireBuildLock(directory, { + open, + unlink, + lstat: async (candidate) => { + lstatCalls += 1; + if (lstatCalls === 2 && !releaseLstatFailed) { + releaseLstatFailed = true; + throw Object.assign(new Error("I/O failure"), { code: "EIO" }); + } + return lstat(candidate); + }, + }); + expect(acquired.ok).toBe(true); + if (!acquired.ok) return; + + await expect(acquired.value.release()).rejects.toThrow("Build lock release failed"); + await expect(lstat(path)).resolves.toBeDefined(); + await expect(acquired.value.release()).resolves.toBeUndefined(); + await expect(lstat(path)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("does not remove a lock whose inode replaced the acquired lock", async () => { + const directory = await project(); + const path = join(directory, buildLockFileName); + const acquired = await acquireBuildLock(directory); + expect(acquired.ok).toBe(true); + if (!acquired.ok) return; + await unlink(path); + await writeFile(path, "replacement"); + + await expect(acquired.value.release()).rejects.toThrow("Build lock release failed"); + await expect(lstat(path)).resolves.toBeDefined(); + }); +}); diff --git a/packages/presentation-cli/test/cli.test.ts b/packages/presentation-cli/test/cli.test.ts new file mode 100644 index 0000000..64d44f7 --- /dev/null +++ b/packages/presentation-cli/test/cli.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; + +import { runPresentationCli } from "../src/index.js"; + +describe("runPresentationCli", () => { + it("accepts only the M1 project-root command grammar", async () => { + const result = await runPresentationCli({ args: ["build", "/project", "/output"] }); + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("usage/cli-invalid-usage"); + expect(result.stderr).toContain(""); + }); + + it("emits stable JSON usage diagnostics", async () => { + const result = await runPresentationCli({ args: ["wat", "/project", "--format", "json"] }); + expect(result.exitCode).toBe(2); + expect(JSON.parse(result.stderr)).toEqual({ + ok: false, + command: null, + diagnostics: [ + { family: "usage", code: "cli-invalid-usage", message: expect.any(String), path: [] }, + ], + }); + }); + + it("does not inspect the Browser seam for check", async () => { + let browserRead = false; + const result = await runPresentationCli({ + args: ["check", "/missing-project"], + host: { + get openFixedBrowser() { + browserRead = true; + throw new Error("must not open browser"); + }, + }, + }); + expect(result.exitCode).toBe(2); + expect(browserRead).toBe(false); + }); + + it("classifies a missing project root as I/O", async () => { + const result = await runPresentationCli({ + args: ["check", "/definitely-not-an-unframe-project"], + }); + expect(result.exitCode).toBe(3); + expect(result.stderr).toContain("io/cli-project-discovery-invalid-directory"); + }); + + it("stops before filesystem discovery when its process signal is cancelled", async () => { + const controller = new AbortController(); + controller.abort(); + const result = await runPresentationCli({ + args: ["build", "/missing-project"], + host: { signal: controller.signal }, + }); + expect(result.exitCode).toBe(130); + expect(result.stderr).toContain("cancel/cli-cancelled"); + }); + + it("rejects hostile public inputs without invoking getters", async () => { + let reads = 0; + const input = Object.create(null, { + args: { + enumerable: true, + get() { + reads += 1; + throw new Error("unsafe"); + }, + }, + }); + const result = await runPresentationCli(input); + expect(reads).toBe(0); + expect(result.exitCode).toBe(2); + }); +}); diff --git a/packages/presentation-cli/test/filesystem-project.test.ts b/packages/presentation-cli/test/filesystem-project.test.ts new file mode 100644 index 0000000..97a2305 --- /dev/null +++ b/packages/presentation-cli/test/filesystem-project.test.ts @@ -0,0 +1,324 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { mkdtemp, mkdir, rm, symlink, unlink, writeFile } from "node:fs/promises"; +import { promisify } from "node:util"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { discoverPresentationProjectFiles } from "../src/filesystem/discover-project.js"; + +const execFile = promisify(execFileCallback); + +const directories: string[] = []; +const links: string[] = []; + +const project = async () => { + const directory = await mkdtemp(join(tmpdir(), "unframe-project-")); + directories.push(directory); + await writeFile(join(directory, "unframe.lock"), "{}", "utf8"); + await writeFile(join(directory, "presentation.unframe.tsx"), "export default {}", "utf8"); + await writeFile( + join(directory, "unframe.config.ts"), + 'export default { entryFile: "presentation.unframe.tsx" }', + "utf8", + ); + return directory; +}; + +afterEach(async () => { + await Promise.all(links.splice(0).map((link) => unlink(link).catch(() => undefined))); + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })), + ); +}); + +describe("filesystem project discovery", () => { + it("絶対rootだけからdata-only configを読み、entryをroot内regular fileへ限定する", async () => { + const directory = await project(); + await expect(discoverPresentationProjectFiles(directory)).resolves.toMatchObject({ + ok: true, + entryFile: "presentation.unframe.tsx", + }); + }); + + it("project-owned TypeScript sourceをUTF-16 code-unit順のUTF-8 snapshotとして返す", async () => { + const directory = await project(); + await mkdir(join(directory, "nested")); + await writeFile(join(directory, "z.ts"), "export const z = 1;", "utf8"); + await writeFile(join(directory, "nested", "a.d.ts"), "declare const a: string;", "utf8"); + await writeFile(join(directory, "nested", "b.tsx"), "export const b =
;", "utf8"); + await writeFile(join(directory, "ignored.js"), "throw new Error();", "utf8"); + const result = await discoverPresentationProjectFiles(directory); + expect(result).toMatchObject({ ok: true }); + if (result.ok) + expect(result.files).toEqual([ + { fileName: "nested/a.d.ts", sourceText: "declare const a: string;" }, + { fileName: "nested/b.tsx", sourceText: "export const b =
;" }, + { fileName: "presentation.unframe.tsx", sourceText: "export default {}" }, + { fileName: "z.ts", sourceText: "export const z = 1;" }, + ]); + }); + + it("excluded directoryをsource入力から除外し、dist生成後も同じprojectを再checkできる", async () => { + const directory = await project(); + for (const name of [".git", ".unframe", "dist", "node_modules"]) { + await mkdir(join(directory, name)); + await writeFile(join(directory, name, "ignored.ts"), "invalid source", "utf8"); + } + const first = await discoverPresentationProjectFiles(directory); + const second = await discoverPresentationProjectFiles(directory); + expect(first).toMatchObject({ ok: true }); + expect(second).toMatchObject({ ok: true }); + if (first.ok && second.ok) { + expect(first.files).toEqual(second.files); + expect(first.files).toEqual([ + { fileName: "presentation.unframe.tsx", sourceText: "export default {}" }, + ]); + } + }); + + it("scan対象のsymlinkとinvalid UTF-8をstable I/O failureとして拒否する", async () => { + const directory = await project(); + await writeFile(join(directory, "outside.ts"), "export {};", "utf8"); + await symlink(join(directory, "outside.ts"), join(directory, "linked.ts")); + await expect(discoverPresentationProjectFiles(directory)).resolves.toMatchObject({ + ok: false, + code: "cli-project-discovery-source-scan-failed", + }); + await unlink(join(directory, "linked.ts")); + await writeFile(join(directory, "invalid.ts"), Buffer.from([0xed, 0xa0, 0x80])); + await expect(discoverPresentationProjectFiles(directory)).resolves.toMatchObject({ + ok: false, + code: "cli-project-discovery-source-scan-failed", + }); + }); + + it("entryがsnapshot内のTypeScript sourceでなければ拒否する", async () => { + const directory = await project(); + await writeFile(join(directory, "entry.txt"), "not TypeScript", "utf8"); + await writeFile( + join(directory, "unframe.config.ts"), + 'export default { entryFile: "entry.txt" }', + "utf8", + ); + await expect(discoverPresentationProjectFiles(directory)).resolves.toMatchObject({ + ok: false, + code: "cli-project-discovery-invalid-entry-file", + }); + }); + + it.each([ + ["unframe.config.ts", "cli-project-discovery-missing-files"], + ["unframe.lock", "cli-project-discovery-missing-files"], + ["presentation.unframe.tsx", "cli-project-discovery-invalid-entry-file"], + ["nested/source.ts", "cli-project-discovery-source-scan-failed"], + ])("FIFOをopenせずstable failureにする: %s", async (fileName, code) => { + const directory = await project(); + const path = join(directory, fileName); + await mkdir(dirname(path), { recursive: true }); + await unlink(path).catch(() => undefined); + await execFile("mkfifo", [path]); + await expect(discoverPresentationProjectFiles(directory)).resolves.toMatchObject({ + ok: false, + code, + }); + }); + + it("末尾slashを持つabsolute rootをrealpath済みrootとして受理する", async () => { + const directory = await project(); + await expect(discoverPresentationProjectFiles(`${directory}/`)).resolves.toMatchObject({ + ok: true, + projectDirectory: directory, + }); + }); + + it("lock validation前のraw bytesをdescriptor snapshot用copyとして返す", async () => { + const directory = await project(); + await writeFile(join(directory, "unframe.lock"), "raw-lock", "utf8"); + const result = await discoverPresentationProjectFiles(directory); + expect(result).toMatchObject({ ok: true }); + if (result.ok) { + expect(new TextDecoder().decode(result.lockBytes)).toBe("raw-lock"); + result.lockBytes[0] = 0; + } + const second = await discoverPresentationProjectFiles(directory); + expect(second).toMatchObject({ ok: true }); + if (second.ok) expect(new TextDecoder().decode(second.lockBytes)).toBe("raw-lock"); + }); + + it.each([ + 'import x from "x"; export default { entryFile: "presentation.unframe.tsx" }', + "export default { entryFile: dynamic }", + 'export default { entryFile: "a", entryFile: "b" }', + 'export default { ["entryFile"]: "a" }', + 'export default { "entryFile": "presentation.unframe.tsx" }', + 'export = { entryFile: "presentation.unframe.tsx" }', + 'export default { ...{ entryFile: "presentation.unframe.tsx" } }', + 'export default { get entryFile() { return "presentation.unframe.tsx" } }', + 'export default { entryFile() { return "presentation.unframe.tsx" } }', + 'export default { entryFile: "presentation.unframe.tsx", other: "x" }', + 'export default ({ entryFile: "presentation.unframe.tsx" })', + ])("configを実行せず拒否する: %s", async (config) => { + const directory = await project(); + await writeFile(join(directory, "unframe.config.ts"), config, "utf8"); + await expect(discoverPresentationProjectFiles(directory)).resolves.toMatchObject({ + ok: false, + code: "cli-config-invalid", + }); + }); + + it("symlinkとroot外entryをfail closedする", async () => { + const directory = await project(); + await symlink(join(directory, "presentation.unframe.tsx"), join(directory, "linked.tsx")); + await writeFile( + join(directory, "unframe.config.ts"), + 'export default { entryFile: "linked.tsx" }', + "utf8", + ); + await expect(discoverPresentationProjectFiles(directory)).resolves.toMatchObject({ + ok: false, + code: "cli-project-discovery-invalid-entry-file", + }); + }); + + it.each(["../outside.tsx", "nested\\entry.tsx", "/absolute.tsx", "nested/../entry.tsx"])( + "root-relative POSIX entryだけを許可する: %s", + async (entryFile) => { + const directory = await project(); + await writeFile( + join(directory, "unframe.config.ts"), + `export default { entryFile: ${JSON.stringify(entryFile)} }`, + "utf8", + ); + await expect(discoverPresentationProjectFiles(directory)).resolves.toMatchObject({ + ok: false, + code: "cli-project-discovery-invalid-entry-file", + }); + }, + ); + + it("BOMとinvalid UTF-8を拒否する", async () => { + const directory = await project(); + await writeFile( + join(directory, "unframe.config.ts"), + '\ufeffexport default { entryFile: "presentation.unframe.tsx" }', + "utf8", + ); + await expect(discoverPresentationProjectFiles(directory)).resolves.toMatchObject({ + ok: false, + code: "cli-config-invalid", + }); + await writeFile(join(directory, "unframe.config.ts"), Buffer.from([0xed, 0xa0, 0x80])); + await expect(discoverPresentationProjectFiles(directory)).resolves.toMatchObject({ + ok: false, + code: "cli-config-invalid", + }); + }); + + it("TypeScript escapeで作られたlone surrogateを拒否する", async () => { + const directory = await project(); + await writeFile( + join(directory, "unframe.config.ts"), + 'export default { entryFile: "\\ud800.unframe.tsx" }', + "utf8", + ); + await expect(discoverPresentationProjectFiles(directory)).resolves.toMatchObject({ + ok: false, + code: "cli-config-invalid", + }); + }); + + it("正当な U+FFFD を拒否しない", async () => { + const directory = await project(); + await writeFile(join(directory, "presentation�.unframe.tsx"), "export default {}", "utf8"); + await writeFile( + join(directory, "unframe.config.ts"), + 'export default { entryFile: "presentation�.unframe.tsx" }', + "utf8", + ); + await expect(discoverPresentationProjectFiles(directory)).resolves.toMatchObject({ + ok: true, + entryFile: "presentation�.unframe.tsx", + }); + }); + + it("non-BMP文字とfoo..barを含む正当なentry filenameを許可する", async () => { + const directory = await project(); + const entryFile = "slides/😀foo..bar.unframe.tsx"; + await mkdir(join(directory, "slides")); + await writeFile(join(directory, entryFile), "export default {}", "utf8"); + await writeFile( + join(directory, "unframe.config.ts"), + `export default { entryFile: ${JSON.stringify(entryFile)} }`, + "utf8", + ); + await expect(discoverPresentationProjectFiles(directory)).resolves.toMatchObject({ + ok: true, + entryFile, + }); + }); + + it("entryのnested parent symlinkを拒否する", async () => { + const directory = await project(); + const assets = join(directory, "assets"); + await mkdir(assets); + await writeFile(join(assets, "slide.tsx"), "export default {}", "utf8"); + await symlink(assets, join(directory, "linked-assets")); + await writeFile( + join(directory, "unframe.config.ts"), + 'export default { entryFile: "linked-assets/slide.tsx" }', + "utf8", + ); + await expect(discoverPresentationProjectFiles(directory)).resolves.toMatchObject({ + ok: false, + code: "cli-project-discovery-invalid-entry-file", + }); + }); + + it("explicit absolute root 以外・symlink root/config/lockを拒否する", async () => { + const directory = await project(); + await expect(discoverPresentationProjectFiles("relative-project")).resolves.toMatchObject({ + ok: false, + code: "cli-project-discovery-invalid-directory", + }); + const linkedRoot = `${directory}-link`; + await symlink(directory, linkedRoot); + links.push(linkedRoot); + await expect(discoverPresentationProjectFiles(linkedRoot)).resolves.toMatchObject({ + ok: false, + code: "cli-project-discovery-invalid-directory", + }); + const linkedParent = `${directory}-tmp-link`; + await symlink(tmpdir(), linkedParent); + links.push(linkedParent); + await expect( + discoverPresentationProjectFiles(join(linkedParent, basename(directory))), + ).resolves.toMatchObject({ + ok: false, + code: "cli-project-discovery-invalid-directory", + }); + + const configTarget = join(directory, "config-target.ts"); + await writeFile( + configTarget, + 'export default { entryFile: "presentation.unframe.tsx" }', + "utf8", + ); + await unlink(join(directory, "unframe.config.ts")); + await symlink(configTarget, join(directory, "unframe.config.ts")); + await expect(discoverPresentationProjectFiles(directory)).resolves.toMatchObject({ + ok: false, + code: "cli-project-discovery-missing-files", + }); + }); + + it("failure diagnosticにhost pathやerror textを含めない", async () => { + const directory = await mkdtemp(join(tmpdir(), "unframe-project-")); + directories.push(directory); + await expect(discoverPresentationProjectFiles(directory)).resolves.toEqual({ + ok: false, + code: "cli-project-discovery-missing-files", + message: "Project root must contain regular unframe.config.ts and unframe.lock files.", + }); + }); +}); diff --git a/packages/presentation-cli/test/load-lock.test.ts b/packages/presentation-cli/test/load-lock.test.ts new file mode 100644 index 0000000..e8e23bc --- /dev/null +++ b/packages/presentation-cli/test/load-lock.test.ts @@ -0,0 +1,153 @@ +import { hashCanonicalJsonPayload } from "@unframe/presentation-core"; +import { describe, expect, it } from "vitest"; + +import { loadUnframeLock } from "../src/filesystem/load-lock.js"; + +const bytes = (value: unknown) => new TextEncoder().encode(JSON.stringify(value)); +const hash = (value: unknown) => hashCanonicalJsonPayload(value); +const identity = (packageName: string, packageVersion = "1.0.0", packageIntegrity?: string) => ({ + packageName, + packageVersion, + packageIntegrity: packageIntegrity ?? `sha256:${"a".repeat(64)}`, +}); + +const lockedPackage = (overrides: Partial> = {}) => { + const base = { + ...identity("example-package"), + files: [{ fileName: "index.ts", sourceText: "export const example = true;" }], + exports: [{ subpath: ".", targetFile: "index.ts" }], + dependencies: [], + }; + const payload = { ...base, ...overrides }; + const { packageIntegrity: _ignored, ...integrityPayload } = payload; + return { ...payload, packageIntegrity: hash(integrityPayload) }; +}; + +const validLock = () => { + const pkg = lockedPackage(); + return { + schemaVersion: 1, + packageDependencies: [ + { ...identity(pkg.packageName, pkg.packageVersion, pkg.packageIntegrity) }, + ], + packages: [pkg], + themeHashes: [{ themeId: "default", hash: `sha256:${"b".repeat(64)}` }], + componentLocks: [ + { + componentId: "example-component", + version: 1, + lock: { + packageVersion: "1.0.0", + packageIntegrity: `sha256:${"c".repeat(64)}`, + manifestHash: `sha256:${"d".repeat(64)}`, + structureHash: `sha256:${"e".repeat(64)}`, + }, + }, + ], + assets: { + "asset-b": { id: "asset-b", mediaType: "image/png", checksum: `sha256:${"f".repeat(64)}` }, + "asset-a": { id: "asset-a", mediaType: "image/png", checksum: `sha256:${"0".repeat(64)}` }, + } as Record, + }; +}; + +describe("unframe.lock v1 boundary", () => { + it("normalizes lock arrays and materializes typed compiler carriers", () => { + const lock = validLock(); + lock.assets = Object.fromEntries(Object.entries(lock.assets).reverse()); + + const result = loadUnframeLock(bytes(lock)); + + expect(result).toMatchObject({ ok: true }); + if (!result.ok) return; + expect(result.value.virtualSource.packageDependencies).toEqual(lock.packageDependencies); + expect(result.value.virtualSource.packages[0]?.files.map((item) => item.fileName)).toEqual([ + "index.ts", + ]); + expect(Object.keys(result.value.assemblyCarrier.assets)).toEqual(["asset-a", "asset-b"]); + expect(result.value.lockHash).toMatch(/^sha256:[0-9a-f]{64}$/u); + expect(Object.isFrozen(result.value.virtualSource.packages[0]?.files[0])).toBe(true); + expect(Object.isFrozen(result.value.assemblyCarrier.assets["asset-a"])).toBe(true); + }); + + it.each([ + [{ ...validLock(), extra: true }, "semantic", "cli-lock-shape-invalid"], + [{ ...validLock(), schemaVersion: 2 }, "semantic", "cli-lock-schema-version-invalid"], + [ + { + ...validLock(), + packages: [{ ...validLock().packages[0], packageIntegrity: "sha256:UPPER" }], + }, + "semantic", + "cli-lock-content-hash-invalid", + ], + [ + { + ...validLock(), + packages: [{ ...validLock().packages[0], packageIntegrity: `sha256:${"1".repeat(64)}` }], + }, + "semantic", + "cli-lock-package-integrity-mismatch", + ], + ] as const)("rejects invalid semantic lock data", (value, family, code) => { + expect(loadUnframeLock(bytes(value))).toMatchObject({ + ok: false, + diagnostic: { family, code }, + }); + }); + + it("rejects nested unknown fields before materializing the package", () => { + const lock = validLock(); + const source = lock.packages[0]!; + const pkg = lockedPackage({ files: [{ ...source.files[0]!, extra: true }] }); + lock.packages = [pkg]; + lock.packageDependencies = [ + identity(pkg.packageName, pkg.packageVersion, pkg.packageIntegrity), + ]; + expect(loadUnframeLock(bytes(lock))).toMatchObject({ + ok: false, + diagnostic: { code: "cli-lock-package-file-shape-invalid" }, + }); + }); + + it.each([ + ['{"schemaVersion":1,"schemaVersion":1}', "cli-lock-json-duplicate-key"], + ["{", "cli-lock-json-syntax"], + ])("preserves strict JSON failure as syntax diagnostic", (source, code) => { + expect(loadUnframeLock(new TextEncoder().encode(source))).toMatchObject({ + ok: false, + diagnostic: { family: "syntax", code }, + }); + }); + + it("rejects duplicate keys, unresolved exact identities, and missing export targets", () => { + const duplicate = validLock(); + duplicate.themeHashes = [duplicate.themeHashes[0]!, { ...duplicate.themeHashes[0]! }]; + expect(loadUnframeLock(bytes(duplicate))).toMatchObject({ + ok: false, + diagnostic: { code: "cli-lock-duplicate-theme-id" }, + }); + + const unresolved = validLock(); + unresolved.packageDependencies = [identity("missing")]; + expect(loadUnframeLock(bytes(unresolved))).toMatchObject({ + ok: false, + diagnostic: { code: "cli-lock-package-reference-missing" }, + }); + + const missingExport = validLock(); + missingExport.packages[0]!.exports = [{ subpath: ".", targetFile: "missing.ts" }]; + missingExport.packages[0] = lockedPackage(missingExport.packages[0]); + missingExport.packageDependencies = [ + identity( + missingExport.packages[0]!.packageName, + missingExport.packages[0]!.packageVersion, + missingExport.packages[0]!.packageIntegrity, + ), + ]; + expect(loadUnframeLock(bytes(missingExport))).toMatchObject({ + ok: false, + diagnostic: { code: "cli-lock-package-export-target-missing" }, + }); + }); +}); diff --git a/packages/presentation-cli/test/process.test.ts b/packages/presentation-cli/test/process.test.ts new file mode 100644 index 0000000..fe3cda9 --- /dev/null +++ b/packages/presentation-cli/test/process.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { runPresentationProcess } from "../src/index.js"; + +const fakeProcess = (argv: readonly string[] = ["bun", "presentation", "check", "/project"]) => { + const listeners = new Map void)[]>(); + const stdout: string[] = []; + const stderr: string[] = []; + const process = { + argv, + stdout: { write: (text: string) => stdout.push(text) }, + stderr: { write: (text: string) => stderr.push(text) }, + on: (signal: string, listener: () => void) => { + listeners.set(signal, [...(listeners.get(signal) ?? []), listener]); + }, + off: (signal: string, listener: () => void) => { + listeners.set( + signal, + (listeners.get(signal) ?? []).filter((item) => item !== listener), + ); + }, + emit: (signal: string) => listeners.get(signal)?.forEach((listener) => listener()), + listenerCount: (signal: string) => listeners.get(signal)?.length ?? 0, + exitCode: undefined as number | undefined, + }; + return { process, stdout, stderr }; +}; + +describe("presentation process entry", () => { + it("owns listeners once, forwards its signal, writes output, and cleans up", async () => { + const host = fakeProcess(); + let signal: AbortSignal | undefined; + const result = await runPresentationProcess({ + process: host.process, + run: async (input) => { + signal = input.host.signal; + expect(host.process.listenerCount("SIGINT")).toBe(1); + expect(host.process.listenerCount("SIGTERM")).toBe(1); + return { exitCode: 0, stdout: "check: ok\n", stderr: "" }; + }, + }); + expect(result.exitCode).toBe(0); + expect(signal?.aborted).toBe(false); + expect(host.stdout).toEqual(["check: ok\n"]); + expect(host.stderr).toEqual([]); + expect(host.process.exitCode).toBe(0); + expect(host.process.listenerCount("SIGINT")).toBe(0); + expect(host.process.listenerCount("SIGTERM")).toBe(0); + }); + + it("turns SIGINT into a shared cancellation and exit 130", async () => { + const host = fakeProcess(); + const result = await runPresentationProcess({ + process: host.process, + run: async ({ host: inputHost }) => { + host.process.emit("SIGINT"); + expect(inputHost.signal.aborted).toBe(true); + return { exitCode: 0, stdout: "", stderr: "" }; + }, + }); + expect(result.exitCode).toBe(130); + expect(host.process.exitCode).toBe(130); + expect(host.stderr.join("")).toContain("cancel/cli-cancelled"); + expect(host.process.listenerCount("SIGINT")).toBe(0); + expect(host.process.listenerCount("SIGTERM")).toBe(0); + }); + + it("converts synchronous and asynchronous runner failures to stable I/O", async () => { + for (const run of [ + () => { + throw new Error("sync"); + }, + async () => Promise.reject(new Error("async")), + ]) { + const host = fakeProcess(); + const result = await runPresentationProcess({ process: host.process, run }); + expect(result).toMatchObject({ exitCode: 3, stdout: "" }); + expect(host.stderr.join("")).toContain("io/cli-process-io"); + expect(host.process.listenerCount("SIGINT")).toBe(0); + expect(host.process.listenerCount("SIGTERM")).toBe(0); + } + }); +}); diff --git a/packages/presentation-cli/test/reference-project.test.ts b/packages/presentation-cli/test/reference-project.test.ts new file mode 100644 index 0000000..8ffa7c6 --- /dev/null +++ b/packages/presentation-cli/test/reference-project.test.ts @@ -0,0 +1,340 @@ +import { + cp, + lstat, + mkdtemp, + readFile, + readdir, + readlink, + rm, + unlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import type { FixedBrowserSession } from "@unframe/presentation-renderer-web"; + +import { runPresentationCli } from "../src/index.js"; + +const referenceDirectory = join( + dirname(fileURLToPath(import.meta.url)), + "../../../examples/presentation", +); +const temporaryDirectories: string[] = []; + +const projectCopy = async () => { + const directory = await mkdtemp(join(tmpdir(), "unframe-reference-project-")); + temporaryDirectories.push(directory); + await cp(referenceDirectory, directory, { recursive: true }); + return directory; +}; + +const buildContext = { + compiler: { + name: "unframe", + version: "1", + baseEnvironmentHash: "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + }, + locale: "ja-JP" as const, + timezone: "Asia/Tokyo" as const, + colorScheme: "light" as const, + pixelTarget: [2, 2] as const, + webRendererConfig: { + documentBackground: [0, 0, 0, 255] as const, + fontFamily: "Noto Sans CJK JP", + }, +}; + +const fakeBrowser = ( + configuration: { + readonly abortDuringCapture?: AbortController; + readonly failClose?: boolean; + readonly failCapture?: boolean; + readonly beforeClose?: () => Promise; + } = {}, +) => { + const observed = { close: 0, capture: 0, signals: [] as (AbortSignal | undefined)[] }; + const session: FixedBrowserSession = { + identity: { id: "reference-fake-browser", implementationHash: "sha256:fake-browser" }, + environment: { + browser: { id: "reference-fake-browser", version: "1", fontFingerprint: "sha256:fonts" }, + locale: "ja-JP", + timezone: "Asia/Tokyo", + colorSpace: "srgb", + deviceScaleFactor: 1, + network: "deny", + filesystem: "deny", + clock: "fixed", + random: "fixed", + }, + capture: async (request, options) => { + observed.capture += 1; + observed.signals.push(options?.signal); + if (options?.signal?.aborted) throw new DOMException("cancelled", "AbortError"); + if (configuration.abortDuringCapture) { + configuration.abortDuringCapture.abort(); + throw new DOMException("cancelled", "AbortError"); + } + if (configuration.failCapture) throw new Error("capture failed"); + const [width, height] = request.pixelTarget; + return { + rgba: Uint8Array.from({ length: width * height * 4 }, (_, index) => + index % 4 === 3 ? 255 : 0, + ), + pixelSize: [width, height], + colorSpace: "srgb", + alphaMode: "opaque", + }; + }, + close: async () => { + observed.close += 1; + await configuration.beforeClose?.(); + if (configuration.failClose) throw new Error("close failed"); + }, + }; + return { observed, session }; +}; + +const diagnostics = (result: Awaited>) => + JSON.parse(result.stderr).diagnostics as readonly { + family: string; + code: string; + path: readonly (string | number)[]; + }[]; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("reference Authoring Project", () => { + it("checks without reading or launching a Browser", async () => { + let browserReads = 0; + const result = await runPresentationCli({ + args: ["check", referenceDirectory, "--format", "json"], + host: { + openFixedBrowser: async () => { + browserReads += 1; + throw new Error("check must not read the Browser factory"); + }, + }, + }); + + expect(result.exitCode).toBe(0); + expect(browserReads).toBe(0); + }); + + it("builds deterministic Definition, RenderBundle, and PNG assets into managed dist", async () => { + const directory = await projectCopy(); + const first = fakeBrowser(); + const second = fakeBrowser(); + const controller = new AbortController(); + const build = (browser: ReturnType) => + runPresentationCli({ + args: ["build", directory, "--format", "json"], + host: { + openFixedBrowser: async () => browser.session, + buildContext, + signal: controller.signal, + }, + }); + + const firstResult = await build(first); + expect(firstResult.exitCode).toBe(0); + const firstDefinition = await readFile(join(directory, "dist", "definition.json")); + const firstBundle = await readFile(join(directory, "dist", "render-bundle.json")); + const firstTarget = await readlink(join(directory, "dist")); + expect(firstTarget).toMatch(/^\.unframe\/generations\/[0-9a-f]{32}$/u); + const assetNames = await readdir(join(directory, "dist", "assets")); + expect(assetNames).toHaveLength(1); + expect(assetNames[0]).toMatch(/\.png$/u); + const assetName = assetNames[0]!; + const firstPng = await readFile(join(directory, "dist", "assets", assetName)); + expect(first.observed).toMatchObject({ capture: 1, close: 1 }); + expect(first.observed.signals).toEqual([controller.signal]); + + expect((await build(second)).exitCode).toBe(0); + expect(await readFile(join(directory, "dist", "definition.json"))).toEqual(firstDefinition); + expect(await readFile(join(directory, "dist", "render-bundle.json"))).toEqual(firstBundle); + expect(await readdir(join(directory, "dist", "assets"))).toEqual([assetName]); + expect(await readFile(join(directory, "dist", "assets", assetName))).toEqual(firstPng); + expect(second.observed).toMatchObject({ capture: 1, close: 1 }); + expect(second.observed.signals).toEqual([controller.signal]); + }); + + it.each([ + [ + "syntax", + async (directory: string) => + writeFile(join(directory, "presentation.unframe.tsx"), "export default ("), + { + family: "syntax", + code: "compiler-source-syntax-error", + path: ["presentation.unframe.tsx"], + }, + ], + [ + "type", + async (directory: string) => + writeFile(join(directory, "presentation.unframe.tsx"), 'import "missing";'), + { + family: "type", + code: "compiler-module-package-unsupported", + path: ["presentation.unframe.tsx"], + }, + ], + [ + "semantic", + async (directory: string) => + writeFile( + join(directory, "unframe.lock"), + (await readFile(join(directory, "unframe.lock"), "utf8")).replace("ad2ce030", "bd2ce030"), + ), + { + family: "semantic", + code: "compiler-theme-hash-mismatch", + path: ["themeHashes", 0], + }, + ], + ] as const)("reports stable %s diagnostics", async (_name, mutate, expected) => { + const directory = await projectCopy(); + await mutate(directory); + const first = await runPresentationCli({ args: ["check", directory, "--format", "json"] }); + const second = await runPresentationCli({ args: ["check", directory, "--format", "json"] }); + expect(first.exitCode).toBe(1); + expect(second.stderr).toBe(first.stderr); + expect(diagnostics(first)).toHaveLength(1); + expect(diagnostics(first)[0]).toMatchObject(expected); + }); + + it("cancels a capture without publishing a partial output", async () => { + const directory = await projectCopy(); + const controller = new AbortController(); + const browser = fakeBrowser({ abortDuringCapture: controller }); + const result = await runPresentationCli({ + args: ["build", directory, "--format", "json"], + host: { + openFixedBrowser: async () => browser.session, + buildContext, + signal: controller.signal, + }, + }); + + expect(result.exitCode).toBe(130); + expect(diagnostics(result)).toEqual([ + { family: "cancel", code: "cli-cancelled", message: expect.any(String), path: [] }, + ]); + expect(browser.observed).toMatchObject({ capture: 1, close: 1, signals: [controller.signal] }); + await expect(lstat(join(directory, "dist"))).rejects.toThrow(); + await expect(lstat(join(directory, ".unframe"))).rejects.toThrow(); + }); + + it("does not start a Browser when cancellation arrives during project I/O", async () => { + const directory = await projectCopy(); + const controller = new AbortController(); + let browserOpens = 0; + queueMicrotask(() => controller.abort()); + + const result = await runPresentationCli({ + args: ["build", directory, "--format", "json"], + host: { + signal: controller.signal, + openFixedBrowser: async () => { + browserOpens += 1; + return fakeBrowser().session; + }, + }, + }); + + expect(result.exitCode).toBe(130); + expect(browserOpens).toBe(0); + await expect(lstat(join(directory, ".unframe-build.lock"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("reports Browser cleanup failure after a compile failure", async () => { + const directory = await projectCopy(); + const browser = fakeBrowser({ failCapture: true, failClose: true }); + const result = await runPresentationCli({ + args: ["build", directory, "--format", "json"], + host: { openFixedBrowser: async () => browser.session, buildContext }, + }); + + expect(result.exitCode).toBe(1); + expect(diagnostics(result)).toEqual([ + { + family: "renderer", + code: "cli-browser-cleanup-failed", + message: expect.any(String), + path: [], + }, + ]); + expect(browser.observed.close).toBe(1); + }); + + it("keeps the previous managed dist unchanged for renderer and I/O failures", async () => { + const directory = await projectCopy(); + const initial = fakeBrowser(); + expect( + ( + await runPresentationCli({ + args: ["build", directory], + host: { openFixedBrowser: async () => initial.session, buildContext }, + }) + ).exitCode, + ).toBe(0); + const previousTarget = await readlink(join(directory, "dist")); + + const failing = fakeBrowser({ failCapture: true }); + const renderer = await runPresentationCli({ + args: ["build", directory, "--format", "json"], + host: { openFixedBrowser: async () => failing.session, buildContext }, + }); + expect(renderer.exitCode).toBe(1); + expect(diagnostics(renderer).map((item) => item.family)).toContain("renderer"); + expect(await readlink(join(directory, "dist"))).toBe(previousTarget); + expect(failing.observed.close).toBe(1); + + await rm(join(directory, "dist")); + await writeFile(join(directory, "dist"), "unmanaged output"); + const io = await runPresentationCli({ + args: ["build", directory, "--format", "json"], + host: { openFixedBrowser: async () => fakeBrowser().session, buildContext }, + }); + expect(io.exitCode).toBe(3); + expect(diagnostics(io)[0]?.family).toBe("io"); + await expect(readFile(join(directory, "dist"), "utf8")).resolves.toBe("unmanaged output"); + }); + + it("reports a stable I/O diagnostic when its build lock cannot be released", async () => { + const directory = await projectCopy(); + const lockPath = join(directory, ".unframe-build.lock"); + const browser = fakeBrowser({ + beforeClose: async () => { + await unlink(lockPath); + await writeFile(lockPath, "replacement"); + }, + }); + + const result = await runPresentationCli({ + args: ["build", directory, "--format", "json"], + host: { openFixedBrowser: async () => browser.session, buildContext }, + }); + + expect(result.exitCode).toBe(3); + expect(diagnostics(result)).toEqual([ + { + family: "io", + code: "cli-build-lock-release-failed", + message: expect.any(String), + path: [], + }, + ]); + await expect(readFile(lockPath, "utf8")).resolves.toBe("replacement"); + }); +}); diff --git a/packages/presentation-cli/test/strict-json.test.ts b/packages/presentation-cli/test/strict-json.test.ts new file mode 100644 index 0000000..7b11cb1 --- /dev/null +++ b/packages/presentation-cli/test/strict-json.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { parseStrictJson } from "../src/filesystem/strict-json.js"; + +const bytes = (source: string) => new TextEncoder().encode(source); + +describe("strict JSON lock boundary", () => { + it("decodes valid JSON into null-prototype records without changing its input", () => { + const input = bytes('{"__proto__":{"safe":true},"items":[1,false,null]}'); + const before = [...input]; + + const result = parseStrictJson(input); + + expect(result).toMatchObject({ ok: true }); + if (!result.ok) return; + expect(Object.getPrototypeOf(result.value)).toBe(null); + const record = result.value as Record; + expect(Object.hasOwn(record, "__proto__")).toBe(true); + expect(record["__proto__"]).toEqual({ safe: true }); + expect(record.items).toEqual([1, false, null]); + expect([...input]).toEqual(before); + }); + + it.each([ + [new Uint8Array([0xed, 0xa0, 0x80]), "cli-lock-json-invalid-utf8"], + [bytes("\ufeff{}"), "cli-lock-json-bom"], + [bytes('{"a":1,}'), "cli-lock-json-syntax"], + [bytes('{"a":1,// comment\n"b":2}'), "cli-lock-json-syntax"], + [bytes('{"n":NaN}'), "cli-lock-json-syntax"], + [bytes('{"n":Infinity}'), "cli-lock-json-syntax"], + [bytes('{"a":1,"\\u0061":2}'), "cli-lock-json-duplicate-key"], + [bytes('{"value":"\\ud800"}'), "cli-lock-json-lone-surrogate"], + [bytes('{"value":1e999}'), "cli-lock-json-number"], + ] as const)("rejects unsafe input with stable code %s", (input, code) => { + expect(parseStrictJson(input)).toEqual({ ok: false, code }); + }); + + it("accepts an escaped surrogate pair", () => { + expect(parseStrictJson(bytes('{"value":"\\ud83d\\ude00"}'))).toMatchObject({ ok: true }); + }); + + it("accepts actual UTF-8 non-BMP characters in keys and values", () => { + expect(parseStrictJson(bytes('{"😀":"😀"}'))).toEqual({ + ok: true, + value: Object.assign(Object.create(null), { "😀": "😀" }), + }); + }); + + it("rejects resource-limit input without exposing host errors", () => { + const input = bytes(`[${"[".repeat(100)}${"0"}${"]".repeat(100)}]`); + expect(parseStrictJson(input)).toEqual({ ok: false, code: "cli-lock-json-resource-limit" }); + }); +}); diff --git a/packages/presentation-cli/test/tui-model.test.ts b/packages/presentation-cli/test/tui-model.test.ts new file mode 100644 index 0000000..1d2d503 --- /dev/null +++ b/packages/presentation-cli/test/tui-model.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { + initialPresentationTuiState, + presentationTuiCommands, + reducePresentationTuiState, +} from "../src/tui/model.js"; + +describe("presentation TUI model", () => { + it("wraps command selection without depending on a terminal renderer", () => { + expect(presentationTuiCommands.map((command) => command.id)).toEqual(["check", "build"]); + + const previous = reducePresentationTuiState(initialPresentationTuiState, { type: "previous" }); + expect(previous.selectedIndex).toBe(1); + expect(reducePresentationTuiState(previous, { type: "next" })).toEqual( + initialPresentationTuiState, + ); + }); + + it("marks selection and quit as explicit effects", () => { + const selected = reducePresentationTuiState(initialPresentationTuiState, { type: "select" }); + expect(selected.effect).toEqual({ type: "command-selected", command: "check" }); + + const cleared = reducePresentationTuiState(selected, { type: "effect-handled" }); + expect(cleared.effect).toBeUndefined(); + expect(reducePresentationTuiState(cleared, { type: "quit" }).effect).toEqual({ type: "quit" }); + }); +}); diff --git a/packages/presentation-cli/tsconfig.json b/packages/presentation-cli/tsconfig.json new file mode 100644 index 0000000..ed7f17e --- /dev/null +++ b/packages/presentation-cli/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../config/tsconfig.base.json", + "compilerOptions": { + "jsx": "preserve", + "jsxImportSource": "@opentui/solid", + "lib": ["ES2022", "DOM"], + "noEmit": true, + "noPropertyAccessFromIndexSignature": false + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts"] +} diff --git a/packages/presentation-compiler/ARCHITECTURE.md b/packages/presentation-compiler/ARCHITECTURE.md new file mode 100644 index 0000000..62acd35 --- /dev/null +++ b/packages/presentation-compiler/ARCHITECTURE.md @@ -0,0 +1,149 @@ +# Presentation Compiler Architecture + +- **Status**: Initial implementation +- **Scope**: Authoring Project から canonical PresentationDefinition と RenderBundle を生成する library +- **Related**: + - [Presentation Architecture](../../docs/presentation/ARCHITECTURE.md) + - [Presentation Implementation Design](../../docs/presentation/DESIGN.md) + - [Presentation Authoring Architecture](../presentation-authoring/ARCHITECTURE.md) + - [Renderer API Architecture](../presentation-renderer-api/ARCHITECTURE.md) + - [ADR-0011](../../docs/decisions/0011-surface-partition-contract.md) + - [ADR-0012](../../docs/decisions/0012-texture-budget-residency-contract.md) + +## 1. Role + +`presentation-compiler` は programmatic Local Compiler pipeline を所有する。現在はTypeScript Compiler APIによるTS/TSXの構文解析境界と、post-lowering の plain-data `PresentationDeclaration` を検査してStatic Structured Surface subsetをcanonical `PresentationDefinition` JSONへlowerする境界を持つ。 + +CLI command parsing、concrete renderer implementation、publish は所有しない。Compiler は orchestration library であり、concrete renderer は host から plugin として注入する。 + +## 2. Pipeline + +```text +Authoring Source / lock / config + ↓ parse +Lossless Syntax Tree + Source Map + ↓ import / symbol resolution / typecheck +validated AST + ↓ context-specific static lowering +Declaration Graph + ↓ normalize / validate +Semantic Authoring IR + ↓ component / theme / layout / surface resolution +PresentationDefinition semantic model + ↓ renderer selection and plugin orchestration +RenderBundle candidates + Asset Set + ↓ canonicalize +presentation.definition.json + RenderBundle +``` + +Orchestrator、Theme、Manifest、Structure は実行せず、検証済み AST から lower する。通常の TS / React / CSS として bundle / execute できるのは Opaque renderer source に限る。 + +## 3. Internal boundaries + +```text +src/ +├─ project/ # explicit project inputs and lock verification +├─ syntax/ # parse, lossless tree, source map +├─ resolution/ # module, symbol, typecheck +├─ lowering/ # AST to Declaration Graph +├─ normalization/ # Declaration Graph to Semantic Authoring IR +├─ expansion/ # components, slots, variants, themes +├─ surfaces/ # Semantic to Render Surface lowering +├─ rendering/ # selection and plugin orchestration +├─ cache/ # content-addressed build cache +├─ diagnostics/ # stable compiler diagnostics +└─ api/ # compile / check programmatic entrypoints +``` + +これは ownership の提案であり、pass の完全な分割や実装順を固定しない。 + +## 4. Current implementation + +`checkDeclarationProject(unknown)` は accessor を実行しない descriptor-safe plain-data clone の後、Zod 4 で project envelope を検査し、Theme、Component manifest/structure/lock、Spatial instance、Asset reference を解決する。cross-reference、duplicate、initial subset の制約だけは semantic invariant として個別に検査する。実装済み subset は Structured `Surface → Frame → direct Text`、静的・非対話・baked-web のみである。結果には Core canonical JSON、source hash、definition hash を含む。 + +`compileDeclarationProject(unknown, options)` は同じ subset を一つの全 Surface RenderSurface に展開し、全 State の完成 Semantic Tree を Core で materialize する。注入された `baked-web` Renderer の raw RGBA capture を `presentation-assets` で決定論的な PNG に encode し、Core で検証済みの canonical RenderBundle と asset bytes を返す。Renderer / encoder / malformed input の失敗は diagnostics として返す。 + +Renderer registry は `baked-web` ID がちょうど一つに解決されることを要求する。Bundle identity と renderer build context は source / Definition、Compiler identity、明示 build context、Renderer fingerprint、PNG encoder identity を入力に含める。Host は `baseEnvironmentHash` として Compiler host の基礎環境を渡し、Compiler は Renderer / encoder identity を結合した `environmentHash` を RenderBundle に固定する。 + +Source frontend は、明示的な logical project root、root-relative TS / TSX / declaration file、locked virtual package を descriptor-safe に snapshot する。TypeScript Compiler API は virtual source だけを読み、project 内 relative import、package 内 relative import、direct locked dependency、exact package export を解決する。実 filesystem、`node_modules`、`ts.sys` へ fallback しない。 + +typecheck は strict ES2022、`noLib` で実行し、project root から到達しない package の ambient declaration を semantic program へ混入させない。一方、lock graph 全体の module specifier は preflight し、不正な dependency / export を owner-aware source diagnostic として拒否する。named value import は TypeChecker alias と package identity / export / declaration owner を照合し、plain-data symbol provenance を生成できる。 + +個別 declaration file については、M1 Static DSL の import、root builder、nested builder、JSON-like expression を fail closed で検証し、source origin 付きの plain-data Declaration Graph へ lower できる。builder signature は現行 public Authoring API の arity と基本 argument shape に固定し、Source module と builder implementation は実行しない。 + +単一 Declaration Graph は、builder call を実行せず null-prototype の plain declaration value へ normalize できる。normalizer は予約 field の衝突と不正 Graph を fail closed で拒否し、正規化後の JSON path と value / property key / generated field の source origin を sidecar source map に保持する。 + +project-owned declaration file は、entry、`*.unframe.ts`、`*.manifest.ts`、`*.structure.tsx` の role と root builder を照合し、project-relative filename 順で lower / normalize できる。補助 `.d.ts` と package-owned source は collection から除外し、未対応 suffix や role mismatch は全 file 分を canonical diagnostic として返す。 + +正規化済み collection は、Presentation 1件、Theme ID、Component `(componentId, version)` を検証し、Structured Manifest が所有する root-contained な `authoring.structure` entry から Structure を決定論的に対応付ける。複数versionが同じ Structure entryを共有することは許可し、Structure の `componentId` は参照元 Manifest と一致させる。pairing は source map 付き canonical diagnostic を全件集約し、失敗時に partial catalog を返さない。 + +`checkAuthoringProject(unknown)` は virtual Source frontend の公開 pure boundary として parse、typecheck、lower、normalize、collect、pair を接続し、成功時は TypeScript の `Program` / `TypeChecker` を含まない plain declaration catalog、失敗時は source range 付き diagnostic を返す。builder implementation、filesystem、Browser は実行しない。 + +`assembleDeclarationProject(unknown)` は paired catalog と、Theme ID ごとの hash、Component `(componentId, version)` ごとの完全 package lock、Asset carrier を明示的に受け取る pure boundary である。catalog の source-map wrapper を出力に持ち込まず、carrier の欠落・余分・重複・identity mismatch を fail closed で拒否する。Theme、Manifest、Structure はそれぞれの declaration semantic payload を Core の canonical JSON SHA-256 で再計算し、lock hash mismatch を stable diagnostic として拒否する。declaration node の `source` metadata は hash から除くが、Theme token / named-style 等の任意 JSON にある同名のデータは保持する。入力順に依存せず canonical envelope を組み立て、`checkDeclarationProject` で再検証する。package integrity と asset checksum は計算も推測もしない。 + +post-lowering declaration の検査は Authoring package の pure type guard を利用し、definition builder を呼び出さない。Compiler の plain-data clone は `Object.prototype` と null-prototype の record を受理し、descriptor だけから null-prototype clone を作る。custom prototype、accessor、cycle、sparse array、symbol key、非 JSON 値は Zod や semantic validation に渡す前に拒否し、caller-owned getter や Proxy の `get` trap を実行しない。 + +M1 Static DSL は、各 declaration file の default export を、provenance 検証済みの `@unframe/presentation` root named value import に対する直接 builder call へ限定する。builder 引数は JSON-like literal と認識済み builder call だけを許可し、JSX、任意関数、control flow、dynamic import、property access、spread、template expression、local alias を stable diagnostic で拒否する。named import alias は元の package export provenance を保持する。Compiler は Authoring Source や builder implementation を実行しない。 + +## 5. Public API + +- `checkDeclarationProject`: post-lowering declaration の限定 subset を検査し Definition を返す。Renderer は実行しない +- `checkAuthoringProject`: virtual Authoring source を実行せず検査し、source origin 付き declaration catalog を返す +- `assembleDeclarationProject`: checked catalog と明示 carrier から、source-map を含まない canonical `CompilerDeclarationProject` を返す +- `checkAuthoringProjectAssembly`: virtual source と catalog を上書きできない carrier を接続し、phase 付き失敗または Checked Definition を返す +- `compileAuthoringProject`: 同じ source-to-assembly 経路を Renderer / PNG compilation へ接続する +- `compileDeclarationProject`: 明示された build context、Renderer plugin、encoder limits から Definition、RenderBundle、PNG asset bytes を返す +- diagnostics: stable code、severity、semantic path、source range +- build metadata: source、lock、config、Compiler、renderer environment の hash / provenance + +Programmatic API は command line、stdout、process exit、global current directory に依存しない。 + +M1 filesystem hostとprocess entryは`presentation-cli`が所有する。CLIはreference Authoring Projectのconfig / lockをvirtual inputへ変換し、この公開APIへ渡す。`nix run .#presentation`は同じ入力をFixed Browserで2回buildし、Definition、RenderBundle、PNG asset setの全bytesが一致することを検証する。 + +## 6. Invariants + +以下は target pipeline 全体の invariant である。現在の初期 subset は Component Action / Output、Interaction、Timeline、Opaque renderer を lower せず、入力で明示的に拒否する。 + +- static lowering の入力は Source、locked package、Theme、Asset metadata、Compiler configuration に限定する。 +- 同じ明示入力と toolchain version から同じ Declaration Graph と canonical PresentationDefinition を生成する。 +- Orchestrator、Theme、Manifest、Structure を transpile、bundle、実行せず、Authoring JS を build artifact として生成しない。 +- Component Action / Output を canonical Action / Trigger へ完全に lower する。 +- SemanticSurface と RenderSurface の mapping、resource lifetime、projection audience、State artifact closure を検証する。 +- Surface transition、Semantic Tree / Hit Region、Opaque renderer binding が全 reachable State で完全かつ整合することを検証する。 +- PresentationDefinition と RenderBundle の source / definition hash を一致させる。 +- Browser execution と OS codec は adapter boundary の外へ漏らさない。 + +## 7. Non-responsibilities + +- user-facing command parsing と dev server UX +- Control Plane publish、Asset upload、durable Publication state +- concrete renderer と codec implementation +- Web Editor UI +- authoritative Runtime progression evaluation + +## 8. Dependency rules + +現在の package は `presentation-core`、`presentation-authoring`、`presentation-renderer-api`、`presentation-assets`、固定versionの`typescript`に依存する。構文解析はclassic TypeScript Compiler APIを直接使用し、`ts-morph`のようなwrapperを介さない。TypeScript 7の`unstable/sync` APIはvirtual filesystemと`tsgo` processを伴うproject解析向けであるため、このpureな単一source構文解析境界には採用しない。Concrete component / renderer の実装には依存せず、Renderer は plugin として host から注入する。 + +Compiler は CLI、Web Editor、Control Plane、Realtime、Unity に依存しない。 + +## 9. Validation strategy + +- Explore → Red → Green で compiler pass ごとの fixture を追加する +- AST lowering と forbidden syntax の fixture +- Declaration Graph normalization の golden test +- Semantic Authoring IR / PresentationDefinition の valid / invalid fixture +- canonical JSON と cache key の determinism test +- renderer plugin fake を使った orchestration test +- package lock / source / config / toolchain drift test +- reference Authoring Project の end-to-end build + +## 10. Deferred decisions + +- M1 より広い Static DSL、named entry export、TSX / JSX lowering +- plugin discovery と version negotiation +- ADR-0011でAcceptedになったSurface partition / author isolate overrideのM3〜M4実装 +- ADR-0012でAcceptedになったresolution / count / aggregate build budgetのM3〜M4実装 +- cache layout と remote cache policy +- M1後のBrowser pooling / multi-project isolate topology +- release間のdiagnostic compatibility policy diff --git a/packages/presentation-compiler/package.json b/packages/presentation-compiler/package.json new file mode 100644 index 0000000..c8a2c49 --- /dev/null +++ b/packages/presentation-compiler/package.json @@ -0,0 +1,29 @@ +{ + "name": "@unframe/presentation-compiler", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "lint": "vp check src test", + "test": "vp test run", + "check": "pnpm typecheck && pnpm lint && pnpm test" + }, + "dependencies": { + "@noble/hashes": "2.3.0", + "@unframe/presentation": "workspace:*", + "@unframe/presentation-assets": "workspace:*", + "@unframe/presentation-core": "workspace:*", + "@unframe/presentation-renderer-api": "workspace:*", + "typescript": "6.0.3", + "zod": "4.4.3" + }, + "devDependencies": { + "@unframe/presentation-components": "workspace:*", + "vite-plus": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/presentation-compiler/src/api/assemble-declaration-project.ts b/packages/presentation-compiler/src/api/assemble-declaration-project.ts new file mode 100644 index 0000000..5f99c2d --- /dev/null +++ b/packages/presentation-compiler/src/api/assemble-declaration-project.ts @@ -0,0 +1,469 @@ +import { z } from "zod"; +import type { + ComponentManifest, + ComponentStructure, + ThemeDeclaration, +} from "@unframe/presentation"; +import { + isComponentManifest, + isComponentStructure, + isThemeDeclaration, +} from "@unframe/presentation"; +import type { Diagnostic, ValidationResult } from "@unframe/presentation-core"; +import { sortDiagnostics, diagnostic } from "../diagnostics/diagnostics.js"; +import { safePlainClone } from "../validation/safe-plain-clone.js"; +import { resolveAuthoringStructurePath } from "../project/pair-authoring-declarations.js"; +import { checkDeclarationProject } from "./check-declaration-project.js"; +import { + hashComponentManifestDeclaration, + hashComponentStructureDeclaration, + hashThemeDeclaration, +} from "../semantic/declaration-hashes.js"; +import type { CheckedDeclarationProject, CompilerDeclarationProject } from "./types.js"; + +const nonEmptyStringSchema = z.string().min(1); +const sourceOriginSchema = z + .object({ + fileName: nonEmptyStringSchema, + start: z.int().nonnegative(), + end: z.int().nonnegative(), + line: z.int().positive(), + column: z.int().positive(), + }) + .strict() + .refine((value) => value.end >= value.start); +const sourceMapEntrySchema = z + .object({ + path: z.array(z.union([z.string(), z.int().nonnegative()])), + origin: sourceOriginSchema, + keyOrigin: sourceOriginSchema.optional(), + }) + .strict(); +const catalogValueSchema = ( + role: "presentation" | "theme" | "component-manifest" | "component-structure", + rootBuilder: + | "definePresentation" + | "defineTheme" + | "defineComponentManifest" + | "defineComponentStructure", +) => + z + .object({ + role: z.literal(role), + fileName: nonEmptyStringSchema, + rootBuilder: z.literal(rootBuilder), + value: z.unknown(), + sourceMap: z.array(sourceMapEntrySchema), + }) + .strict(); +const presentationCatalogSchema = catalogValueSchema("presentation", "definePresentation"); +const themeCatalogSchema = catalogValueSchema("theme", "defineTheme"); +const manifestCatalogSchema = catalogValueSchema("component-manifest", "defineComponentManifest"); +const structureCatalogSchema = catalogValueSchema( + "component-structure", + "defineComponentStructure", +); +const componentCatalogSchema = z + .object({ manifest: manifestCatalogSchema, structure: structureCatalogSchema }) + .strict(); +const componentLockSchema = z + .object({ + componentId: nonEmptyStringSchema, + version: z.int().positive(), + lock: z + .object({ + packageVersion: nonEmptyStringSchema, + packageIntegrity: nonEmptyStringSchema, + manifestHash: nonEmptyStringSchema, + structureHash: nonEmptyStringSchema, + }) + .strict(), + }) + .strict(); +const assetCarrierSchema = z + .object({ + id: nonEmptyStringSchema, + mediaType: nonEmptyStringSchema, + checksum: nonEmptyStringSchema, + }) + .strict(); +const assemblyInputSchema = z + .object({ + catalog: z + .object({ + presentation: presentationCatalogSchema, + themes: z.array(themeCatalogSchema), + components: z.array(componentCatalogSchema), + }) + .strict(), + themeHashes: z.array( + z.object({ themeId: nonEmptyStringSchema, hash: nonEmptyStringSchema }).strict(), + ), + componentLocks: z.array(componentLockSchema), + assets: z.record(z.string(), assetCarrierSchema), + }) + .strict(); + +type AssemblyInput = z.output; +type ThemeEntry = { readonly id: string; readonly declaration: ThemeDeclaration }; +type ComponentEntry = { + readonly componentId: string; + readonly version: number; + readonly manifest: ComponentManifest; + readonly structure: ComponentStructure; +}; + +const assemblyEnvelopeDiagnostics = (issues: readonly z.core.$ZodIssue[]): Diagnostic[] => { + const diagnostics = issues.map((issue) => { + const [section, entry] = issue.path; + if (section === "themeHashes") + return diagnostic( + "compiler-invalid-theme-hash-entry", + ["themeHashes", typeof entry === "number" ? entry : 0], + "Theme hash entries require a non-empty theme ID and hash.", + ); + if (section === "componentLocks") + return diagnostic( + "compiler-invalid-component-lock-entry", + ["componentLocks", typeof entry === "number" ? entry : 0], + "Component lock entries require an identity and complete non-empty lock.", + ); + if (section === "assets") + return diagnostic( + "compiler-invalid-asset", + ["assets", typeof entry === "string" ? entry : ""], + "Asset carrier entries must use string keys and plain JSON values.", + ); + if (section === "catalog") { + const [catalogSection, catalogEntry] = issue.path.slice(1); + if (catalogSection === "presentation") + return diagnostic( + "compiler-invalid-catalog-presentation", + ["catalog", "presentation"], + "Presentation catalog wrapper must be complete and provenance-safe.", + ); + if (catalogSection === "themes") + return diagnostic( + "compiler-invalid-catalog-theme-entry", + ["catalog", "themes", typeof catalogEntry === "number" ? catalogEntry : 0], + "Theme catalog wrappers must be complete and provenance-safe.", + ); + if (catalogSection === "components") + return diagnostic( + "compiler-invalid-catalog-component-entry", + ["catalog", "components", typeof catalogEntry === "number" ? catalogEntry : 0], + "Component catalog wrappers must be complete and provenance-safe.", + ); + } + return diagnostic("compiler-invalid-input", [], "Assembly input is malformed."); + }); + return sortDiagnostics([ + ...new Map( + diagnostics.map((item) => [`${item.code}\u0000${item.path.join("/")}`, item]), + ).values(), + ]); +}; + +const keyForComponent = (componentId: string, version: number) => `${componentId}\u0000${version}`; +const byString = (left: T, right: T) => + left.id < right.id ? -1 : left.id > right.id ? 1 : 0; +const byComponent = (left: ComponentEntry, right: ComponentEntry) => + left.componentId < right.componentId + ? -1 + : left.componentId > right.componentId + ? 1 + : left.version - right.version; +const canonicalRecord = (value: Readonly>): Readonly> => + Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, value[key]!]), + ) as Readonly>; + +const record = (value: unknown): Record | undefined => + value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; + +const collectThemes = (input: AssemblyInput, diagnostics: Diagnostic[]): ThemeEntry[] => + input.catalog.themes.map((candidate, index) => { + const id = record(candidate.value)?.id; + if (typeof id !== "string" || id.length === 0) { + diagnostics.push( + diagnostic( + "compiler-theme-hash-identity-mismatch", + ["catalog", "themes", index], + "Theme declarations must expose a non-empty ID for hash pairing.", + ), + ); + return { id: `\u0000${index}`, declaration: candidate.value as ThemeDeclaration }; + } + return { id, declaration: candidate.value as ThemeDeclaration }; + }); + +const collectComponents = (input: AssemblyInput, diagnostics: Diagnostic[]): ComponentEntry[] => + input.catalog.components.map((candidate, index) => { + const manifest = record(candidate.manifest.value); + const structure = record(candidate.structure.value); + const componentId = manifest?.componentId; + const rawVersion = manifest?.version; + const version = + typeof rawVersion === "number" && Number.isInteger(rawVersion) && rawVersion > 0 + ? rawVersion + : undefined; + if (typeof componentId !== "string" || componentId.length === 0 || version === undefined) { + diagnostics.push( + diagnostic( + "compiler-component-lock-identity-mismatch", + ["catalog", "components", index], + "Component manifests must expose a component ID and positive version for lock pairing.", + ), + ); + return { + componentId: `\u0000${index}`, + version: 0, + manifest: candidate.manifest.value as ComponentManifest, + structure: candidate.structure.value as ComponentStructure, + }; + } + if (structure?.componentId !== componentId) + diagnostics.push( + diagnostic( + "compiler-component-lock-identity-mismatch", + ["catalog", "components", index, "structure"], + "Component structure must use the manifest component ID.", + ), + ); + const authoring = record(manifest?.authoring); + if (authoring?.mode === "opaque") + diagnostics.push( + diagnostic( + "compiler-opaque-component-unsupported", + ["catalog", "components", index, "manifest", "authoring", "mode"], + "Opaque component authoring is not supported by this milestone.", + ), + ); + if (authoring?.mode === "structured") { + const expectedStructurePath = + typeof authoring.structure === "string" + ? resolveAuthoringStructurePath(candidate.manifest.fileName, authoring.structure) + : undefined; + if (expectedStructurePath !== candidate.structure.fileName) + diagnostics.push( + diagnostic( + "compiler-component-structure-path-mismatch", + ["catalog", "components", index, "structure", "fileName"], + "Component structure file must exactly match the manifest authoring.structure path.", + ), + ); + } + return { + componentId, + version, + manifest: candidate.manifest.value as ComponentManifest, + structure: candidate.structure.value as ComponentStructure, + }; + }); + +const duplicateDiagnostics = ( + values: readonly T[], + key: (value: T) => string, + path: readonly (string | number)[], + code: string, + message: string, + diagnostics: Diagnostic[], +) => { + const seen = new Set(); + values.forEach((value, index) => { + const identity = key(value); + if (seen.has(identity)) diagnostics.push(diagnostic(code, [...path, index], message)); + seen.add(identity); + }); +}; + +/** Assembles source-free compiler declarations from a checked Authoring catalog and explicit carriers. */ +export const assembleDeclarationProjectValidated = ( + input: unknown, +): ValidationResult<{ + project: CompilerDeclarationProject; + checked: CheckedDeclarationProject; +}> => { + const snapshot = safePlainClone(input); + if (!snapshot.valid) return snapshot; + const parsed = assemblyInputSchema.safeParse(snapshot.value); + if (!parsed.success) + return { valid: false, diagnostics: assemblyEnvelopeDiagnostics(parsed.error.issues) }; + + const value = parsed.data; + const diagnostics: Diagnostic[] = []; + const themes = collectThemes(value, diagnostics); + const components = collectComponents(value, diagnostics); + duplicateDiagnostics( + themes, + (item) => item.id, + ["catalog", "themes"], + "compiler-theme-hash-duplicate", + "Theme declarations must resolve exactly once.", + diagnostics, + ); + duplicateDiagnostics( + value.themeHashes, + (item) => item.themeId, + ["themeHashes"], + "compiler-theme-hash-duplicate", + "Theme hash entries must resolve exactly once.", + diagnostics, + ); + duplicateDiagnostics( + components, + (item) => keyForComponent(item.componentId, item.version), + ["catalog", "components"], + "compiler-component-lock-duplicate", + "Component declarations must resolve exactly once.", + diagnostics, + ); + duplicateDiagnostics( + value.componentLocks, + (item) => keyForComponent(item.componentId, item.version), + ["componentLocks"], + "compiler-component-lock-duplicate", + "Component lock entries must resolve exactly once.", + diagnostics, + ); + + const themeHashes = new Map( + value.themeHashes.map((item, index) => [item.themeId, { item, index }]), + ); + const catalogThemeIds = new Set(themes.map((item) => item.id)); + themes.forEach((theme, index) => { + if (!themeHashes.has(theme.id)) + diagnostics.push( + diagnostic( + "compiler-theme-hash-missing", + ["catalog", "themes", index], + "Every Theme declaration requires exactly one hash entry.", + ), + ); + }); + value.themeHashes.forEach((entry, index) => { + if (!catalogThemeIds.has(entry.themeId)) + diagnostics.push( + diagnostic( + "compiler-theme-hash-extra", + ["themeHashes", index], + "Theme hash entries must reference a catalog Theme declaration.", + ), + ); + }); + + themes.forEach((theme) => { + const entry = themeHashes.get(theme.id); + if ( + entry !== undefined && + isThemeDeclaration(theme.declaration) && + entry.item.hash !== hashThemeDeclaration(theme.declaration) + ) + diagnostics.push( + diagnostic( + "compiler-theme-hash-mismatch", + ["themeHashes", entry.index], + "Theme hash must match the declaration semantic payload.", + ), + ); + }); + + const locks = new Map( + value.componentLocks.map((item, index) => [ + keyForComponent(item.componentId, item.version), + { item, index }, + ]), + ); + const catalogComponentKeys = new Set( + components.map((item) => keyForComponent(item.componentId, item.version)), + ); + components.forEach((component, index) => { + const exactKey = keyForComponent(component.componentId, component.version); + if (!locks.has(exactKey)) { + const hasSameComponent = value.componentLocks.some( + (lock) => lock.componentId === component.componentId, + ); + diagnostics.push( + diagnostic( + hasSameComponent + ? "compiler-component-lock-identity-mismatch" + : "compiler-component-lock-missing", + ["catalog", "components", index], + hasSameComponent + ? "Component lock identity must match the manifest component ID and version." + : "Every Component declaration requires exactly one lock entry.", + ), + ); + } + }); + value.componentLocks.forEach((entry, index) => { + if (!catalogComponentKeys.has(keyForComponent(entry.componentId, entry.version))) + diagnostics.push( + diagnostic( + "compiler-component-lock-extra", + ["componentLocks", index], + "Component lock entries must reference a catalog Component declaration.", + ), + ); + }); + components.forEach((component) => { + const entry = locks.get(keyForComponent(component.componentId, component.version)); + if (entry === undefined) return; + const { lock } = entry.item; + if ( + isComponentManifest(component.manifest) && + lock.manifestHash !== hashComponentManifestDeclaration(component.manifest) + ) + diagnostics.push( + diagnostic( + "compiler-component-manifest-hash-mismatch", + ["componentLocks", entry.index, "lock", "manifestHash"], + "Component manifest hash must match the declaration semantic payload.", + ), + ); + if ( + isComponentStructure(component.structure) && + lock.structureHash !== hashComponentStructureDeclaration(component.structure) + ) + diagnostics.push( + diagnostic( + "compiler-component-structure-hash-mismatch", + ["componentLocks", entry.index, "lock", "structureHash"], + "Component structure hash must match the declaration semantic payload.", + ), + ); + }); + if (diagnostics.length > 0) return { valid: false, diagnostics: sortDiagnostics(diagnostics) }; + + const project: CompilerDeclarationProject = { + presentation: value.catalog.presentation.value as CompilerDeclarationProject["presentation"], + themes: themes.sort(byString).map((theme) => ({ + declaration: theme.declaration as CompilerDeclarationProject["themes"][number]["declaration"], + hash: themeHashes.get(theme.id)!.item.hash, + })), + components: components.sort(byComponent).map((component) => ({ + manifest: component.manifest as CompilerDeclarationProject["components"][number]["manifest"], + structure: + component.structure as CompilerDeclarationProject["components"][number]["structure"], + lock: locks.get(keyForComponent(component.componentId, component.version))!.item.lock, + })), + assets: canonicalRecord( + value.assets as CompilerDeclarationProject["assets"], + ) as CompilerDeclarationProject["assets"], + }; + const checked = checkDeclarationProject(project); + return checked.valid + ? { valid: true as const, value: { project, checked: checked.value }, diagnostics: [] as const } + : checked; +}; + +export const assembleDeclarationProject = ( + input: unknown, +): ValidationResult => { + const result = assembleDeclarationProjectValidated(input); + return result.valid ? { valid: true, value: result.value.project, diagnostics: [] } : result; +}; diff --git a/packages/presentation-compiler/src/api/authoring-project-pipeline.ts b/packages/presentation-compiler/src/api/authoring-project-pipeline.ts new file mode 100644 index 0000000..d0c8973 --- /dev/null +++ b/packages/presentation-compiler/src/api/authoring-project-pipeline.ts @@ -0,0 +1,48 @@ +import { checkAuthoringProject } from "./check-authoring-project.js"; +import { assembleDeclarationProjectValidated } from "./assemble-declaration-project.js"; +import { safePlainClone } from "../validation/safe-plain-clone.js"; +import type { + AuthoringProjectPipelineResult, + CheckedDeclarationProject, + CompilerDeclarationProject, +} from "./types.js"; + +export const assembleAuthoringProject = ( + source: unknown, + carrier: unknown, +): AuthoringProjectPipelineResult<{ + project: CompilerDeclarationProject; + checked: CheckedDeclarationProject; +}> => { + const catalog = checkAuthoringProject(source); + if (!catalog.valid) return { valid: false, phase: "source", diagnostics: catalog.diagnostics }; + const snapshot = safePlainClone(carrier); + if (!snapshot.valid) + return { valid: false, phase: "assembly", diagnostics: snapshot.diagnostics }; + if ( + snapshot.value === null || + typeof snapshot.value !== "object" || + Array.isArray(snapshot.value) || + Object.keys(snapshot.value).some( + (key) => !["themeHashes", "componentLocks", "assets"].includes(key), + ) + ) + return { + valid: false, + phase: "assembly", + diagnostics: [ + { + code: "compiler-invalid-input", + path: [], + message: "Assembly carrier must contain only explicit carrier fields.", + }, + ], + }; + const assembled = assembleDeclarationProjectValidated({ + ...(snapshot.value as object), + catalog: catalog.value, + }); + return assembled.valid + ? { valid: true, value: assembled.value, diagnostics: [] } + : { valid: false, phase: "assembly", diagnostics: assembled.diagnostics }; +}; diff --git a/packages/presentation-compiler/src/api/check-authoring-project-assembly.ts b/packages/presentation-compiler/src/api/check-authoring-project-assembly.ts new file mode 100644 index 0000000..c781d7f --- /dev/null +++ b/packages/presentation-compiler/src/api/check-authoring-project-assembly.ts @@ -0,0 +1,12 @@ +import type { CheckedDeclarationProject } from "./types.js"; +import { assembleAuthoringProject } from "./authoring-project-pipeline.js"; +import type { AuthoringProjectPipelineResult } from "./types.js"; + +export const checkAuthoringProjectAssembly = ( + source: unknown, + carrier: unknown, +): AuthoringProjectPipelineResult => { + const assembled = assembleAuthoringProject(source, carrier); + if (!assembled.valid) return assembled; + return { valid: true, value: assembled.value.checked, diagnostics: [] }; +}; diff --git a/packages/presentation-compiler/src/api/check-authoring-project.ts b/packages/presentation-compiler/src/api/check-authoring-project.ts new file mode 100644 index 0000000..1948b99 --- /dev/null +++ b/packages/presentation-compiler/src/api/check-authoring-project.ts @@ -0,0 +1,62 @@ +import { collectAuthoringDeclarations } from "../project/collect-authoring-declarations.js"; +import { + pairAuthoringDeclarations, + type PairedAuthoringDeclarationCatalog, +} from "../project/pair-authoring-declarations.js"; +import { parseAuthoringProject } from "../project/parse-authoring-project.js"; +import { analyzeAuthoringProject } from "../resolution/typecheck-authoring-project.js"; + +export type AuthoringProjectDiagnostic = { + readonly code: string; + readonly fileName: string; + readonly message: string; + readonly start: number; + readonly end: number; + readonly line: number; + readonly column: number; + readonly typescriptCode?: number; +}; + +export type CheckAuthoringProjectResult = + | { + readonly valid: true; + readonly value: PairedAuthoringDeclarationCatalog; + readonly diagnostics: []; + } + | { readonly valid: false; readonly diagnostics: readonly AuthoringProjectDiagnostic[] }; + +const invalidInput = (): CheckAuthoringProjectResult => ({ + valid: false, + diagnostics: [ + { + code: "compiler-invalid-input", + fileName: "", + message: "Project input cannot be inspected safely.", + start: 0, + end: 0, + line: 1, + column: 1, + }, + ], +}); + +/** Checks only virtual Authoring source and returns its plain declaration catalog. */ +export const checkAuthoringProject = (input: unknown): CheckAuthoringProjectResult => { + try { + const parsed = parseAuthoringProject(input); + if (!parsed.ok) return { valid: false, diagnostics: parsed.diagnostics }; + + const analyzed = analyzeAuthoringProject(parsed.value); + if (!analyzed.ok) return { valid: false, diagnostics: analyzed.diagnostics }; + + const collected = collectAuthoringDeclarations(analyzed); + if (!collected.ok) return { valid: false, diagnostics: collected.diagnostics }; + + const paired = pairAuthoringDeclarations(collected); + return paired.ok + ? { valid: true, value: paired.catalog, diagnostics: [] } + : { valid: false, diagnostics: paired.diagnostics }; + } catch { + return invalidInput(); + } +}; diff --git a/packages/presentation-compiler/src/api/check-declaration-project.ts b/packages/presentation-compiler/src/api/check-declaration-project.ts new file mode 100644 index 0000000..ba16df1 --- /dev/null +++ b/packages/presentation-compiler/src/api/check-declaration-project.ts @@ -0,0 +1,606 @@ +import { + isComponentManifest, + isComponentStructure, + isPresentationDeclaration, + isThemeDeclaration, + type PresentationDeclaration, +} from "@unframe/presentation"; +import { + canonicalizePresentationDefinition, + hashPresentationDefinition, + validatePresentationDefinition, + type Diagnostic, + type PresentationDefinition, + type ValidationResult, +} from "@unframe/presentation-core"; +import { + declarationProjectEnvelopeSchema, + declarationProjectFieldKeysSchema, +} from "../validation/project-schemas.js"; +import { safePlainClone } from "../validation/safe-plain-clone.js"; +import { diagnostic, sortDiagnostics } from "../diagnostics/diagnostics.js"; +import { + emptyRecord, + hashJson, + isRecord, + nonEmptyString, + renderIntent, + resourceId, + sameLock, + hasValidInitialPresentationShape, + projectEnvelopeDiagnostics, +} from "../lowering/support.js"; +import type { CheckedDeclarationProject, CompilerDeclarationProject } from "./types.js"; + +type UnknownRecord = Record; +const checkDeclarationProjectUnchecked = ( + input: unknown, +): ValidationResult => { + const cloned = safePlainClone(input); + if (!cloned.valid) return cloned; + if ( + !declarationProjectFieldKeysSchema.safeParse(Object.keys(cloned.value as UnknownRecord)).success + ) + return { + valid: false, + diagnostics: [ + diagnostic( + "compiler-invalid-project-field", + [], + "Project contains an unknown top-level field.", + ), + ], + }; + const parsedProject = declarationProjectEnvelopeSchema.safeParse(cloned.value); + if (!parsedProject.success) + return { + valid: false, + diagnostics: sortDiagnostics([...projectEnvelopeDiagnostics(parsedProject.error.issues)]), + }; + + // Declaration API owns the detailed Authoring contracts; this schema owns the public envelope. + const project = parsedProject.data as unknown as CompilerDeclarationProject; + const diagnostics: Diagnostic[] = []; + const rawPresentation = project.presentation; + const presentation = rawPresentation as PresentationDeclaration; + const themes = project.themes as CompilerDeclarationProject["themes"]; + const components = project.components as CompilerDeclarationProject["components"]; + const assets = project.assets as CompilerDeclarationProject["assets"]; + const validateDeclaration = (path: readonly (string | number)[], valid: boolean) => { + if (!valid) { + diagnostics.push( + diagnostic( + "compiler-invalid-declaration", + path, + "Declaration failed Authoring SDK validation.", + ), + ); + return false; + } + return true; + }; + const presentationValid = validateDeclaration( + ["presentation"], + isPresentationDeclaration(presentation), + ); + if (presentationValid && !hasValidInitialPresentationShape(presentation)) + diagnostics.push( + diagnostic( + "compiler-invalid-presentation-shape", + ["presentation"], + "Presentation fields do not match the initial declaration contract.", + ), + ); + for (const [index, candidate] of themes.entries()) { + validateDeclaration( + ["themes", index, "declaration"], + isThemeDeclaration(candidate.declaration), + ); + } + for (const [index, candidate] of components.entries()) { + const manifestValid = validateDeclaration( + ["components", index, "manifest"], + isComponentManifest(candidate.manifest), + ); + const structure = candidate.structure; + const structureValid = validateDeclaration( + ["components", index, "structure"], + isComponentStructure(structure), + ); + if ( + structureValid && + structure.root.kind === "surface" && + Object.values(structure.root.states).some((state) => state.enabledInteractionIds.length !== 0) + ) + diagnostics.push( + diagnostic( + "compiler-enabled-interactions-unsupported", + ["components", index, "structure"], + "The initial subset does not support enabled interactions.", + ), + ); + if (!manifestValid || !structureValid) continue; + } + if (diagnostics.length) return { valid: false, diagnostics: sortDiagnostics(diagnostics) }; + if (!presentation.theme) + diagnostics.push( + diagnostic( + "compiler-theme-required", + ["presentation", "theme"], + "A theme selection is required.", + ), + ); + const theme = themes.filter( + (candidate) => candidate.declaration.id === presentation.theme?.themeId, + ); + if (theme.length !== 1) + diagnostics.push( + diagnostic( + "compiler-theme-not-found", + ["themes"], + "Selected theme must resolve exactly once.", + ), + ); + + const nodes: PresentationDefinition["scene"]["nodes"] = {}; + const surfaces: PresentationDefinition["scene"]["surfaces"] = {}; + const uniqueIds = ( + values: readonly { id: string }[], + path: readonly (string | number)[], + code: string, + ) => { + const seen = new Set(); + for (const value of values) { + if (seen.has(value.id)) + diagnostics.push(diagnostic(code, path, "Declaration IDs must be unique before lowering.")); + seen.add(value.id); + } + }; + uniqueIds( + presentation.scene?.spatial ?? [], + ["presentation", "scene", "spatial"], + "compiler-duplicate-spatial-id", + ); + uniqueIds( + presentation.scene?.components ?? [], + ["presentation", "scene", "components"], + "compiler-duplicate-component-instance-id", + ); + uniqueIds( + presentation.assets.map((reference) => ({ id: reference.assetId })), + ["presentation", "assets"], + "compiler-duplicate-asset-reference", + ); + if (presentation.operations.length) + diagnostics.push( + diagnostic( + "compiler-operations-unsupported", + ["presentation", "operations"], + "Operations are not supported.", + ), + ); + const spatialById = new Map((presentation.scene?.spatial ?? []).map((node) => [node.id, node])); + const mappedSpatialIds = new Set(); + const used = new Set(); + for (const [index, instance] of (presentation.scene?.components ?? []).entries()) { + const path = ["presentation", "scene", "components", index] as const; + const entries = components.filter( + (candidate) => + candidate.manifest.componentId === instance.componentId && + candidate.manifest.version === instance.version, + ); + if (entries.length !== 1) { + diagnostics.push( + diagnostic( + "compiler-component-not-found", + path, + "Component manifest must resolve exactly once.", + ), + ); + continue; + } + const entry = entries[0]!; + if (entry.manifest.authoring.mode !== "structured") + diagnostics.push( + diagnostic( + "compiler-opaque-component-unsupported", + path, + "Only structured components are supported.", + ), + ); + if ( + entry.structure.componentId !== entry.manifest.componentId || + !sameLock(instance.packageLock, entry.lock) + ) + diagnostics.push( + diagnostic( + "compiler-component-lock-mismatch", + path, + "Component structure and exact package lock must match.", + ), + ); + if (!emptyRecord(instance.props)) + diagnostics.push( + diagnostic( + "compiler-nonempty-props-unsupported", + [...path, "props"], + "Props are not supported.", + ), + ); + if ( + !emptyRecord(instance.slots) || + !emptyRecord(instance.variants) || + instance.partOverrides.length !== 0 + ) + diagnostics.push( + diagnostic( + "compiler-component-feature-unsupported", + path, + "Slots, variants, and overrides are not supported.", + ), + ); + const spatial = spatialById.get(instance.spatialNodeId); + if (!spatial || spatial.kind !== "spatial") { + diagnostics.push( + diagnostic( + "compiler-spatial-not-found", + [...path, "spatialNodeId"], + "Component must reference one Spatial node.", + ), + ); + continue; + } + if (JSON.stringify(spatial.owner) !== JSON.stringify(instance.owner)) + diagnostics.push( + diagnostic("compiler-owner-mismatch", path, "Component and Spatial owner must match."), + ); + if (spatial.parent.kind === "node") + diagnostics.push( + diagnostic( + "compiler-spatial-node-parent-unsupported", + [...path, "spatialNodeId"], + "The initial subset supports only stage Spatial parents.", + ), + ); + if (mappedSpatialIds.has(spatial.id)) + diagnostics.push( + diagnostic( + "compiler-spatial-component-mismatch", + path, + "Each Spatial node may host only one component.", + ), + ); + mappedSpatialIds.add(spatial.id); + const root = entry.structure.root; + if ( + root.kind !== "surface" || + root.root.kind !== "frame" || + root.root.children.some((child) => child.kind !== "text") + ) + diagnostics.push( + diagnostic( + "compiler-structure-unsupported", + path, + "Only Surface to Frame to direct Text is supported.", + ), + ); + if ( + root.kind === "surface" && + Object.values(root.states).some((state) => state.enabledInteractionIds.length !== 0) + ) + diagnostics.push( + diagnostic( + "compiler-enabled-interactions-unsupported", + path, + "The initial subset does not support enabled interactions.", + ), + ); + if (root.kind !== "surface") continue; + const localIds = [root.id, root.root.id, ...root.root.children.map((child) => child.id)]; + uniqueIds( + localIds.map((id) => ({ id })), + [...path, "structure"], + "compiler-duplicate-content-id", + ); + const semanticEntries = Object.entries(root.baseSemanticTree.nodes); + uniqueIds( + semanticEntries.map(([, semantic]) => ({ id: semantic.id })), + [...path, "structure", "baseSemanticTree", "nodes"], + "compiler-duplicate-semantic-node-id", + ); + for (const [semanticId, semantic] of semanticEntries) + if (semanticId !== semantic.id) + diagnostics.push( + diagnostic( + "compiler-record-key-id-mismatch", + [...path, "structure", "baseSemanticTree", "nodes", semanticId], + "Semantic Tree record keys must match semantic node IDs.", + ), + ); + const stateEntries = Object.entries(root.states); + uniqueIds( + stateEntries.map(([, state]) => ({ id: state.id })), + [...path, "structure", "states"], + "compiler-duplicate-state-id", + ); + for (const [stateId, state] of stateEntries) + if (stateId !== state.id) + diagnostics.push( + diagnostic( + "compiler-record-key-id-mismatch", + [...path, "structure", "states", stateId], + "State record keys must match state IDs.", + ), + ); + if ( + Object.keys(root.interactions).length || + root.renderIntent.updateModel !== "static" || + root.renderIntent.interaction !== "none" || + root.renderIntent.internalAnimation !== "none" || + root.renderIntent.rendererPreference !== "baked-web" || + root.renderIntent.fallbackPolicy !== "reject" + ) + diagnostics.push( + diagnostic( + "compiler-surface-feature-unsupported", + path, + "Only static, non-interactive baked-web surfaces are supported.", + ), + ); + if ( + Object.keys(entry.manifest.actions).length || + Object.keys(entry.manifest.outputs).length || + Object.keys(entry.manifest.props).length || + Object.keys(entry.manifest.slots).length || + Object.keys(entry.manifest.variants).length || + Object.keys(entry.manifest.parts).length + ) + diagnostics.push( + diagnostic( + "compiler-manifest-feature-unsupported", + path, + "Manifest exposes unsupported features.", + ), + ); + if ( + Object.keys(entry.manifest.states).sort().join("\u0000") !== + Object.keys(root.states).sort().join("\u0000") + ) + diagnostics.push( + diagnostic( + "compiler-state-set-mismatch", + path, + "Manifest and Surface state sets must match.", + ), + ); + if (entry.structure.timelines.length) + diagnostics.push( + diagnostic( + "compiler-operations-unsupported", + path, + "Timelines and operations are not supported.", + ), + ); + for (const style of [root.root.style, ...root.root.children.map((child) => child.style)]) { + if ( + style && + (!theme[0] || + !(style.styleId in theme[0].declaration.namedStyles) || + !emptyRecord(theme[0].declaration.namedStyles[style.styleId]!)) + ) + diagnostics.push( + diagnostic( + "compiler-named-style-unsupported", + path, + "Named styles must resolve to empty records.", + ), + ); + } + const surfaceId = resourceId(instance.id, root.id); + const nodeId = resourceId(instance.id, spatial.id); + if (surfaceId === nodeId || used.has(surfaceId) || used.has(nodeId)) + diagnostics.push( + diagnostic("compiler-resource-id-collision", path, "Lowered resource IDs must be unique."), + ); + used.add(surfaceId); + used.add(nodeId); + const contentNodes: PresentationDefinition["scene"]["surfaces"][string]["contentNodes"] = {}; + const frameId = resourceId(instance.id, root.root.id); + const childIds: string[] = []; + for (const [childOrder, child] of root.root.children.entries()) { + if (child.kind !== "text") continue; + const id = resourceId(instance.id, child.id); + childIds.push(id); + contentNodes[id] = { + id, + kind: "text", + parentId: frameId, + order: childOrder, + placement: { ...child.layout }, + text: child.value, + }; + } + contentNodes[frameId] = { + id: frameId, + kind: "frame", + parentId: null, + order: 0, + layout: { kind: "absolute" }, + children: childIds, + }; + const semanticNodes: PresentationDefinition["scene"]["surfaces"][string]["baseSemanticTree"]["nodes"] = + {}; + for (const semantic of Object.values(root.baseSemanticTree.nodes)) { + const id = resourceId(instance.id, semantic.id); + semanticNodes[id] = { + ...semantic, + id, + parentId: semantic.parentId === null ? null : resourceId(instance.id, semantic.parentId), + }; + } + const states: PresentationDefinition["scene"]["surfaces"][string]["states"] = {}; + for (const state of Object.values(root.states)) + states[resourceId(instance.id, state.id)] = { + id: resourceId(instance.id, state.id), + semanticOverrides: state.semanticOverrides.map((override) => ({ + nodes: { + [resourceId(instance.id, override.targetId)]: { + ...(override.included === undefined ? {} : { included: override.included }), + ...(override.text === undefined ? {} : { text: override.text }), + ...(override.language === undefined ? {} : { language: override.language }), + ...(override.alt === undefined ? {} : { alt: override.alt }), + }, + }, + })), + enabledInteractionIds: [], + }; + nodes[nodeId] = { + id: nodeId, + kind: "surface", + name: spatial.name, + owner: spatial.owner, + audience: spatial.audience, + parent: + spatial.parent.kind === "node" + ? { kind: "node", nodeId: resourceId(instance.id, spatial.parent.nodeId) } + : spatial.parent, + transform: { + position: [...spatial.transform.position], + rotation: [...spatial.transform.rotation], + scale: [...spatial.transform.scale], + }, + order: spatial.order, + active: spatial.active, + visible: spatial.visible, + opacity: spatial.opacity, + surfaceId, + }; + surfaces[surfaceId] = { + id: surfaceId, + hostNodeId: nodeId, + physicalSizeMeters: [...root.physicalSizeMeters], + logicalSize: [...root.logicalSize], + fit: root.fit, + rootFrameId: frameId, + contentNodes, + baseSemanticTree: { + rootNodeIds: root.baseSemanticTree.rootNodeIds.map((id) => resourceId(instance.id, id)), + nodes: semanticNodes, + }, + interactions: {}, + initialStateId: resourceId(instance.id, root.initialStateId), + states, + renderIntent: renderIntent(), + }; + } + for (const spatial of presentation.scene?.spatial ?? []) + if (!mappedSpatialIds.has(spatial.id)) + diagnostics.push( + diagnostic( + "compiler-spatial-component-mismatch", + ["presentation", "scene", "spatial", spatial.id], + "Every Spatial node must host one Surface component.", + ), + ); + if (presentation.assets.some((asset) => !Object.hasOwn(assets, asset.assetId))) + diagnostics.push( + diagnostic( + "compiler-asset-not-found", + ["presentation", "assets"], + "Asset references must resolve.", + ), + ); + const referencedAssetIds = new Set(presentation.assets.map((asset) => asset.assetId)); + for (const [assetId, asset] of Object.entries(assets)) { + if ( + !isRecord(asset) || + Object.keys(asset).some((key) => !["id", "mediaType", "checksum"].includes(key)) || + asset.id !== assetId || + !nonEmptyString(asset.mediaType) || + !nonEmptyString(asset.checksum) + ) + diagnostics.push( + diagnostic( + "compiler-invalid-asset", + ["assets", assetId], + "Asset descriptors must match their key and portable contract shape.", + ), + ); + if (!referencedAssetIds.has(assetId)) + diagnostics.push( + diagnostic( + "compiler-asset-unreferenced", + ["assets", assetId], + "Asset carrier entries must be referenced by the presentation.", + ), + ); + } + if ( + Object.values(presentation.flow.groups).some((group) => + Object.values(group.steps).some((step) => step.cues.length), + ) + ) + diagnostics.push( + diagnostic("compiler-cues-unsupported", ["presentation", "flow"], "Cues are not supported."), + ); + if (diagnostics.length) return { valid: false, diagnostics: sortDiagnostics(diagnostics) }; + + const groups: PresentationDefinition["flow"]["groups"] = {}; + for (const [groupId, group] of Object.entries(presentation.flow.groups)) { + groups[groupId] = { id: group.id, initialStepId: group.initialStepId, steps: {} }; + for (const [stepId, step] of Object.entries(group.steps)) + groups[groupId].steps[stepId] = { id: step.id, cues: [] }; + } + const variables: PresentationDefinition["flow"]["variables"] = {}; + for (const [variableId, variable] of Object.entries(presentation.flow.variables)) + variables[variableId] = { + id: variable.id, + owner: variable.owner, + type: variable.type, + initialValue: variable.initialValue, + }; + const definition: PresentationDefinition = { + schemaVersion: 1, + presentationId: presentation.id, + metadata: presentation.metadata, + stage: { ...presentation.stage, size: [...presentation.stage.size], zones: {} }, + assets, + scene: { nodes, surfaces }, + flow: { initialGroupId: presentation.flow.initialGroupId, groups, variables }, + }; + const validated = validatePresentationDefinition(definition); + if (!validated.valid) + return { valid: false, diagnostics: sortDiagnostics(validated.diagnostics) }; + const canonical = canonicalizePresentationDefinition(validated.value); + const definitionHash = hashPresentationDefinition(validated.value); + if (!canonical.valid || !definitionHash.valid) + return { + valid: false, + diagnostics: !canonical.valid ? canonical.diagnostics : definitionHash.diagnostics, + }; + return { + valid: true, + value: { + definition: validated.value, + definitionJson: canonical.value, + sourceHash: hashJson(JSON.stringify(cloned.value)), + definitionHash: definitionHash.value, + }, + diagnostics: [], + }; +}; + +export const checkDeclarationProject = ( + input: unknown, +): ValidationResult => { + try { + return checkDeclarationProjectUnchecked(input); + } catch { + return { + valid: false, + diagnostics: [ + diagnostic("compiler-invalid-input", [], "Project input could not be inspected safely."), + ], + }; + } +}; diff --git a/packages/presentation-compiler/src/api/compile-authoring-project.ts b/packages/presentation-compiler/src/api/compile-authoring-project.ts new file mode 100644 index 0000000..41c4694 --- /dev/null +++ b/packages/presentation-compiler/src/api/compile-authoring-project.ts @@ -0,0 +1,31 @@ +import { compileCheckedDeclarationProject } from "./compile-declaration-project.js"; +import { assembleAuthoringProject } from "./authoring-project-pipeline.js"; +import { diagnostic } from "../diagnostics/diagnostics.js"; +import type { AuthoringProjectPipelineResult, CompiledDeclarationProject } from "./types.js"; + +export const compileAuthoringProject = async ( + source: unknown, + carrier: unknown, + options: unknown, +): Promise> => { + const assembled = assembleAuthoringProject(source, carrier); + if (!assembled.valid) return assembled; + try { + const compiled = await compileCheckedDeclarationProject( + assembled.value.project, + assembled.value.checked, + options, + ); + return compiled.valid + ? { valid: true, value: compiled.value, diagnostics: [] } + : { valid: false, phase: "compile", diagnostics: compiled.diagnostics }; + } catch { + return { + valid: false, + phase: "compile", + diagnostics: [ + diagnostic("compiler-invalid-input", [], "Compiler input could not be inspected safely."), + ], + }; + } +}; diff --git a/packages/presentation-compiler/src/api/compile-declaration-project.ts b/packages/presentation-compiler/src/api/compile-declaration-project.ts new file mode 100644 index 0000000..43b48a7 --- /dev/null +++ b/packages/presentation-compiler/src/api/compile-declaration-project.ts @@ -0,0 +1,326 @@ +import { PNG_ENCODER_IDENTITY, encodeRgbaToPng } from "@unframe/presentation-assets"; +import { + canonicalizeRenderBundle, + hashRenderBundle, + materializeCompletedSemanticTree, + validatePresentationArtifacts, + type Diagnostic, + type RenderBundle, + type ValidationResult, +} from "@unframe/presentation-core"; +import { + createRendererFingerprint, + executeRendererPlugin, + validateRendererPlugin, +} from "@unframe/presentation-renderer-api"; +import { + compilerBuildOptionsSchema, + declarationProjectEnvelopeSchema, +} from "../validation/project-schemas.js"; +import { safePlainClone } from "../validation/safe-plain-clone.js"; +import { safeBuildOptionsSnapshot } from "../validation/safe-build-options.js"; +import { compareStrings, diagnostic, sortDiagnostics } from "../diagnostics/diagnostics.js"; +import { hashJson } from "../lowering/support.js"; +import { checkDeclarationProject } from "./check-declaration-project.js"; +import type { + CompilerBuildOptions, + CompilerDeclarationProject, + CompiledDeclarationProject, + CheckedDeclarationProject, +} from "./types.js"; + +const invalidCompileOptions = (message: string): ValidationResult => ({ + valid: false, + diagnostics: [diagnostic("compiler-invalid-options", ["options"], message)], +}); + +const compileUnchecked = async ( + input: unknown, + options: unknown, + checkedOverride?: CheckedDeclarationProject, +): Promise> => { + const checked = checkedOverride + ? { valid: true as const, value: checkedOverride, diagnostics: [] as const } + : checkDeclarationProject(input); + if (!checked.valid) return checked; + const optionsSnapshot = safeBuildOptionsSnapshot(options); + if (!optionsSnapshot.valid) return optionsSnapshot; + const parsedOptions = compilerBuildOptionsSchema.safeParse(optionsSnapshot.value); + if (!parsedOptions.success) + return invalidCompileOptions("Build options must be a complete explicit configuration."); + const validatedOptions = parsedOptions.data as unknown as CompilerBuildOptions; + + // The Definition does not retain Theme metadata; resolve it from the checked project instead. + const sourceProject = safePlainClone(input); + if (!sourceProject.valid) + return { + valid: false, + diagnostics: [ + diagnostic("compiler-invalid-input", [], "Project input could not be resolved safely."), + ], + }; + const parsedSourceProject = declarationProjectEnvelopeSchema.safeParse(sourceProject.value); + if (!parsedSourceProject.success) + return { + valid: false, + diagnostics: [ + diagnostic("compiler-invalid-input", [], "Project input could not be resolved safely."), + ], + }; + const resolvedSourceProject = parsedSourceProject.data as unknown as CompilerDeclarationProject; + const selectedThemeId = resolvedSourceProject.presentation.theme?.themeId; + const selectedTheme = resolvedSourceProject.themes.find( + (candidate) => candidate.declaration.id === selectedThemeId, + ); + if (!selectedTheme) + return { + valid: false, + diagnostics: [ + diagnostic( + "compiler-theme-not-found", + ["themes"], + "Selected theme must resolve exactly once.", + ), + ], + }; + + const rendererDiagnostics: Diagnostic[] = []; + for (const [index, candidate] of validatedOptions.renderers.entries()) + for (const item of validateRendererPlugin(candidate)) + rendererDiagnostics.push({ + ...item, + path: ["options", "renderers", index, ...item.path], + }); + if (rendererDiagnostics.length > 0) + return { valid: false, diagnostics: sortDiagnostics(rendererDiagnostics) }; + const matchingRenderers = validatedOptions.renderers.filter( + (candidate) => candidate.identity.id === "baked-web", + ); + if (matchingRenderers.length !== 1) + return { + valid: false, + diagnostics: [ + diagnostic( + matchingRenderers.length === 0 + ? "compiler-renderer-not-found" + : "compiler-renderer-ambiguous", + ["options", "renderers"], + "baked-web renderer must resolve exactly once.", + ), + ], + }; + const renderer = matchingRenderers[0]!; + const rendererFingerprint = createRendererFingerprint( + renderer.identity, + validatedOptions.rendererConfigHash, + ); + const environmentHash = hashJson( + JSON.stringify({ + baseEnvironmentHash: validatedOptions.compiler.baseEnvironmentHash, + compilerName: validatedOptions.compiler.name, + compilerVersion: validatedOptions.compiler.version, + pngEncoder: PNG_ENCODER_IDENTITY, + rendererFingerprint, + }), + ); + const assets: Record = {}; + const surfaces: RenderBundle["surfaces"] = {}; + const definition = checked.value.definition; + + for (const [surfaceId, surface] of Object.entries(definition.scene.surfaces).sort(([a], [b]) => + compareStrings(a, b), + )) { + const semanticsByState: Record< + string, + RenderBundle["surfaces"][string]["semanticsByState"][string] + > = {}; + const states: Record = {}; + for (const stateId of Object.keys(surface.states).sort()) { + const materialized = materializeCompletedSemanticTree(surface, stateId); + if (!materialized.valid) return materialized; + semanticsByState[stateId] = + materialized.value as unknown as RenderBundle["surfaces"][string]["semanticsByState"][string]; + states[stateId] = { kind: "capture" }; + } + const renderSurfaceId = `${surfaceId}:render`; + const inputHash = hashJson( + JSON.stringify({ definitionHash: checked.value.definitionHash, surfaceId, semanticsByState }), + ); + const buildContextHash = hashJson( + JSON.stringify({ + colorScheme: validatedOptions.colorScheme, + inputHash, + locale: validatedOptions.locale, + pixelTarget: validatedOptions.pixelTarget, + rendererConfigHash: validatedOptions.rendererConfigHash, + themeHash: selectedTheme.hash, + themeId: selectedThemeId, + timezone: validatedOptions.timezone, + }), + ); + const rendered = await executeRendererPlugin(renderer, { + surface, + sourceIntent: surface.renderIntent, + resolvedIntent: { + updateModel: surface.renderIntent.updateModel, + interaction: surface.renderIntent.interaction, + internalAnimation: surface.renderIntent.internalAnimation, + selectedRendererId: "baked-web", + fallbackPolicy: surface.renderIntent.fallbackPolicy, + }, + semanticsByState, + plan: { + id: renderSurfaceId, + semanticSurfaceId: surfaceId, + logicalBounds: { + x: 0, + y: 0, + width: surface.logicalSize[0], + height: surface.logicalSize[1], + }, + layer: 0, + contentNodeIds: Object.keys(surface.contentNodes).sort(), + states, + }, + entry: { kind: "structured" }, + context: { + locale: validatedOptions.locale, + timezone: validatedOptions.timezone, + colorScheme: validatedOptions.colorScheme, + themeId: selectedThemeId as string, + themeHash: selectedTheme.hash, + inputHash, + buildContextHash, + environmentHash, + rendererConfigHash: validatedOptions.rendererConfigHash, + rendererFingerprint, + pixelTarget: validatedOptions.pixelTarget, + }, + }); + if (!rendered.valid) return rendered; + const artifactId = `${renderSurfaceId}:artifact`; + const artifactStates: Record = {}; + for (const capture of [...rendered.value.captures].sort((a, b) => + compareStrings(a.stateId, b.stateId), + )) { + const encoded = encodeRgbaToPng({ + sourceId: `${surfaceId}:${capture.id}`, + rgba: capture.rgba, + pixelSize: capture.pixelSize, + colorSpace: capture.colorSpace, + alphaMode: capture.alphaMode, + limits: validatedOptions.encodeLimits, + }); + if (!encoded.valid) return encoded; + assets[encoded.value.descriptor.assetId] = encoded.value.bytes; + artifactStates[capture.stateId] = { + stateId: capture.stateId, + textures: [encoded.value.descriptor], + }; + } + const stateBindings: Record = {}; + for (const stateId of Object.keys(surface.states).sort()) + stateBindings[stateId] = { kind: "artifacts", artifactIds: [artifactId] }; + surfaces[surfaceId] = { + semanticSurfaceId: surfaceId, + logicalSize: [...surface.logicalSize], + physicalSizeMeters: [...surface.physicalSizeMeters], + renderSurfaceIds: [renderSurfaceId], + renderSurfaces: { + [renderSurfaceId]: { + id: renderSurfaceId, + semanticSurfaceId: surfaceId, + logicalBounds: { + x: 0, + y: 0, + width: surface.logicalSize[0], + height: surface.logicalSize[1], + }, + layer: 0, + artifacts: { + [artifactId]: { id: artifactId, kind: "baked-web", states: artifactStates }, + }, + stateBindings, + }, + }, + semanticsByState, + interactionsByState: rendered.value.hitRegionsByState, + } as unknown as RenderBundle["surfaces"][string]; + } + const bundle: RenderBundle = { + schemaVersion: 1, + bundleId: hashJson( + JSON.stringify({ + sourceHash: checked.value.sourceHash, + definitionHash: checked.value.definitionHash, + environmentHash, + buildContext: { + locale: validatedOptions.locale, + timezone: validatedOptions.timezone, + colorScheme: validatedOptions.colorScheme, + themeId: selectedThemeId, + themeHash: selectedTheme.hash, + pixelTarget: validatedOptions.pixelTarget, + rendererConfigHash: validatedOptions.rendererConfigHash, + }, + }), + ), + sourceHash: checked.value.sourceHash, + definitionHash: checked.value.definitionHash, + compiler: { + name: validatedOptions.compiler.name, + version: validatedOptions.compiler.version, + environmentHash, + }, + buildContext: { + locale: validatedOptions.locale, + timezone: validatedOptions.timezone, + colorScheme: validatedOptions.colorScheme, + themeId: selectedThemeId as string, + themeHash: selectedTheme.hash, + }, + surfaces, + }; + const validated = validatePresentationArtifacts(definition, bundle); + if (!validated.valid) return validated; + const canonical = canonicalizeRenderBundle(validated.value.renderBundle); + const hash = hashRenderBundle(validated.value.renderBundle); + if (!canonical.valid || !hash.valid) + return { + valid: false, + diagnostics: !canonical.valid ? canonical.diagnostics : hash.diagnostics, + }; + return { + valid: true, + value: { + ...checked.value, + renderBundle: validated.value.renderBundle, + renderBundleJson: canonical.value, + renderBundleHash: hash.value, + assets, + }, + diagnostics: [], + }; +}; + +export const compileCheckedDeclarationProject = ( + project: CompilerDeclarationProject, + checked: CheckedDeclarationProject, + options: unknown, +) => compileUnchecked(project, options, checked); + +export const compileDeclarationProject = async ( + input: unknown, + options: unknown, +): Promise> => { + try { + return await compileUnchecked(input, options); + } catch { + return { + valid: false, + diagnostics: [ + diagnostic("compiler-invalid-input", [], "Compiler input could not be inspected safely."), + ], + }; + } +}; diff --git a/packages/presentation-compiler/src/api/types.ts b/packages/presentation-compiler/src/api/types.ts new file mode 100644 index 0000000..30ab4fb --- /dev/null +++ b/packages/presentation-compiler/src/api/types.ts @@ -0,0 +1,77 @@ +import type { + ComponentManifest, + ComponentPackageLock, + ComponentStructure, + PresentationDeclaration, + ThemeDeclaration, +} from "@unframe/presentation"; +import type { EncodeLimits } from "@unframe/presentation-assets"; +import type { PresentationDefinition, RenderBundle } from "@unframe/presentation-core"; +import type { Diagnostic } from "@unframe/presentation-core"; +import type { RendererPlugin } from "@unframe/presentation-renderer-api"; +import type { PairedAuthoringDeclarationCatalog } from "../project/pair-authoring-declarations.js"; + +export type CompilerDeclarationProject = { + presentation: PresentationDeclaration; + themes: readonly { declaration: ThemeDeclaration; hash: string }[]; + components: readonly { + manifest: ComponentManifest; + structure: ComponentStructure; + lock: Required; + }[]; + assets: Readonly>; +}; +export type DeclarationProjectThemeHash = { + readonly themeId: string; + readonly hash: string; +}; +export type DeclarationProjectComponentLock = { + readonly componentId: string; + readonly version: number; + readonly lock: Required; +}; +export type DeclarationProjectAssemblyInput = { + readonly catalog: PairedAuthoringDeclarationCatalog; + readonly themeHashes: readonly DeclarationProjectThemeHash[]; + readonly componentLocks: readonly DeclarationProjectComponentLock[]; + readonly assets: Readonly>; +}; +export type DeclarationProjectAssemblyCarrier = Omit; +export type AuthoringProjectPipelineResult = + | { readonly valid: true; readonly value: T; readonly diagnostics: [] } + | { + readonly valid: false; + readonly phase: "source"; + readonly diagnostics: readonly import("./check-authoring-project.js").AuthoringProjectDiagnostic[]; + } + | { + readonly valid: false; + readonly phase: "assembly" | "compile"; + readonly diagnostics: readonly Diagnostic[]; + }; +export type CheckedDeclarationProject = { + definition: PresentationDefinition; + definitionJson: string; + sourceHash: string; + definitionHash: string; +}; +export type CompilerBuildOptions = { + readonly compiler: { + readonly name: string; + readonly version: string; + readonly baseEnvironmentHash: string; + }; + readonly locale: string; + readonly timezone: string; + readonly colorScheme: "light" | "dark"; + readonly pixelTarget: readonly [width: number, height: number]; + readonly rendererConfigHash: string; + readonly renderers: readonly RendererPlugin[]; + readonly encodeLimits: EncodeLimits; +}; +export type CompiledDeclarationProject = CheckedDeclarationProject & { + readonly renderBundle: RenderBundle; + readonly renderBundleJson: string; + readonly renderBundleHash: string; + readonly assets: Readonly>; +}; diff --git a/packages/presentation-compiler/src/diagnostics/diagnostics.ts b/packages/presentation-compiler/src/diagnostics/diagnostics.ts new file mode 100644 index 0000000..b998b49 --- /dev/null +++ b/packages/presentation-compiler/src/diagnostics/diagnostics.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; +import type { Diagnostic } from "@unframe/presentation-core"; + +export const diagnostic = ( + code: string, + path: readonly (string | number)[], + message: string, +): Diagnostic => ({ + code, + path, + message, +}); + +export const sortDiagnostics = (items: Diagnostic[]) => + items.sort((left, right) => { + const a = `${left.path.join("/")}\u0000${left.code}`; + const b = `${right.path.join("/")}\u0000${right.code}`; + return a < b ? -1 : a > b ? 1 : 0; + }); + +export const compareStrings = (left: string, right: string) => + left < right ? -1 : left > right ? 1 : 0; + +export const safelyIsDiagnostic = (value: unknown): value is Diagnostic => { + try { + return z + .strictObject({ + code: z.string(), + path: z.array(z.union([z.string(), z.number()])), + message: z.string(), + relatedPath: z.array(z.union([z.string(), z.number()])).optional(), + }) + .safeParse(value).success; + } catch { + return false; + } +}; diff --git a/packages/presentation-compiler/src/index.ts b/packages/presentation-compiler/src/index.ts new file mode 100644 index 0000000..f1f5969 --- /dev/null +++ b/packages/presentation-compiler/src/index.ts @@ -0,0 +1,30 @@ +export { checkDeclarationProject } from "./api/check-declaration-project.js"; +export { checkAuthoringProject } from "./api/check-authoring-project.js"; +export { checkAuthoringProjectAssembly } from "./api/check-authoring-project-assembly.js"; +export { compileAuthoringProject } from "./api/compile-authoring-project.js"; +export { assembleDeclarationProject } from "./api/assemble-declaration-project.js"; +export { + hashComponentManifestDeclaration, + hashComponentStructureDeclaration, + hashThemeDeclaration, +} from "./semantic/declaration-hashes.js"; +export { compileDeclarationProject } from "./api/compile-declaration-project.js"; +export type { + CheckedDeclarationProject, + CompiledDeclarationProject, + CompilerBuildOptions, + CompilerDeclarationProject, + DeclarationProjectAssemblyInput, + DeclarationProjectComponentLock, + DeclarationProjectThemeHash, + DeclarationProjectAssemblyCarrier, + AuthoringProjectPipelineResult, +} from "./api/types.js"; +export type { + AuthoringProjectDiagnostic, + CheckAuthoringProjectResult, +} from "./api/check-authoring-project.js"; +export type { + PairedAuthoringDeclarationCatalog, + PairedComponentDeclaration, +} from "./project/pair-authoring-declarations.js"; diff --git a/packages/presentation-compiler/src/lowering/lower-authoring-declaration.ts b/packages/presentation-compiler/src/lowering/lower-authoring-declaration.ts new file mode 100644 index 0000000..a3c46f1 --- /dev/null +++ b/packages/presentation-compiler/src/lowering/lower-authoring-declaration.ts @@ -0,0 +1,503 @@ +import * as ts from "typescript"; + +import { + collectPackageValueProvenance, + type PackageValueProvenance, +} from "../resolution/symbol-provenance.js"; +import type { AnalyzedAuthoringProject } from "../resolution/typecheck-authoring-project.js"; + +export type DeclarationSourceOrigin = { + readonly fileName: string; + readonly start: number; + readonly end: number; + readonly line: number; + readonly column: number; +}; + +export type DeclarationGraphValue = + | { + readonly kind: "literal"; + readonly origin: DeclarationSourceOrigin; + readonly value: null | string | number | boolean; + } + | { + readonly kind: "array"; + readonly origin: DeclarationSourceOrigin; + readonly values: readonly DeclarationGraphValue[]; + } + | { + readonly kind: "object"; + readonly origin: DeclarationSourceOrigin; + readonly properties: readonly { + readonly key: string; + readonly origin: DeclarationSourceOrigin; + readonly value: DeclarationGraphValue; + }[]; + } + | { + readonly kind: "builder-call"; + readonly builder: string; + readonly origin: DeclarationSourceOrigin; + readonly arguments: readonly DeclarationGraphValue[]; + }; + +export type DeclarationGraph = { + readonly fileName: string; + readonly root: Extract; +}; + +export type StaticDeclarationDiagnostic = { + readonly code: string; + readonly fileName: string; + readonly message: string; + readonly start: number; + readonly end: number; + readonly line: number; + readonly column: number; +}; + +export type LoweredAuthoringDeclaration = + | { readonly ok: true; readonly graph: DeclarationGraph; readonly diagnostics: [] } + | { readonly ok: false; readonly diagnostics: readonly StaticDeclarationDiagnostic[] }; + +const rootBuilders = new Set([ + "definePresentation", + "defineTheme", + "defineComponentManifest", + "defineComponentStructure", +]); + +const nestedBuilders = new Set([ + "stringProp", + "numberProp", + "booleanProp", + "slot", + "part", + "variant", + "state", + "action", + "output", + "surfaceState", + "setSurfaceState", + "playTimeline", + "surfaceInteraction", + "timelineCompleted", + "mediaCompleted", + "after", + "invokeComponentAction", + "componentOutput", + "cue", + "tokenRef", + "namedStyleRef", + "assetRef", + "spatial", + "frame", + "text", + "surface", + "semanticOverride", + "componentInstance", + "detach", +]); + +type BuilderArgumentKind = "object" | "string" | "number"; +type BuilderSignature = readonly (readonly BuilderArgumentKind[])[]; + +const rootSignature: BuilderSignature = [["object"]]; +const nestedSignatures = new Map([ + ["state", [[], ["object"]]], + ["surfaceState", [["string", "string"]]], + ["setSurfaceState", [["string", "string"]]], + ["playTimeline", [["string", "object"]]], + ["surfaceInteraction", [["string"]]], + ["timelineCompleted", [["string"]]], + ["mediaCompleted", [["string"]]], + ["after", [["number"]]], + ...[...nestedBuilders] + .filter( + (builder) => + ![ + "state", + "surfaceState", + "setSurfaceState", + "playTimeline", + "surfaceInteraction", + "timelineCompleted", + "mediaCompleted", + "after", + ].includes(builder), + ) + .map((builder) => [builder, [["object"]]] as const), +]); + +const compareDiagnostics = ( + left: StaticDeclarationDiagnostic, + right: StaticDeclarationDiagnostic, +) => + (left.fileName < right.fileName ? -1 : left.fileName > right.fileName ? 1 : 0) || + left.start - right.start || + left.end - right.end || + (left.code < right.code ? -1 : left.code > right.code ? 1 : 0) || + (left.message < right.message ? -1 : left.message > right.message ? 1 : 0); + +const displayDiagnostic = ( + sourceFile: ts.SourceFile, + node: ts.Node, + code: string, + message: string, + displayFileName: string, +): StaticDeclarationDiagnostic => { + const start = node.getStart(sourceFile); + const position = sourceFile.getLineAndCharacterOfPosition(start); + return { + code, + fileName: displayFileName, + message, + start, + end: node.getEnd(), + line: position.line + 1, + column: position.character + 1, + }; +}; + +const isPresentationBuilder = (provenance: PackageValueProvenance | undefined) => + provenance?.packageName === "@unframe/presentation" && provenance.subpath === "."; + +const argumentMatches = (argument: ts.Expression, kind: BuilderArgumentKind) => + (kind === "object" && ts.isObjectLiteralExpression(argument)) || + (kind === "string" && ts.isStringLiteral(argument)) || + (kind === "number" && + (ts.isNumericLiteral(argument) || + (ts.isPrefixUnaryExpression(argument) && + argument.operator === ts.SyntaxKind.MinusToken && + ts.isNumericLiteral(argument.operand)))); + +export const lowerAuthoringDeclarationFile = ( + analyzed: Extract, + sourceFile = analyzed.value.entrySourceFile, +): LoweredAuthoringDeclaration => { + const displayFileName = analyzed.value.context.displayFileName(sourceFile); + const provenanceByRange = new Map( + collectPackageValueProvenance(analyzed) + .filter((item) => item.fileName === displayFileName) + .map((item) => [`${item.start}:${item.end}`, item]), + ); + const provenanceByBinding = new Map(); + for (const statement of sourceFile.statements) { + if ( + !ts.isImportDeclaration(statement) || + !statement.importClause?.namedBindings || + !ts.isNamedImports(statement.importClause.namedBindings) + ) + continue; + for (const element of statement.importClause.namedBindings.elements) { + const provenance = provenanceByRange.get( + `${element.name.getStart(sourceFile)}:${element.name.getEnd()}`, + ); + const binding = analyzed.value.checker.getSymbolAtLocation(element.name); + if (provenance && binding) provenanceByBinding.set(binding, provenance); + } + } + const diagnostics: StaticDeclarationDiagnostic[] = []; + const originFor = (node: ts.Node): DeclarationSourceOrigin => ({ + fileName: displayFileName, + start: node.getStart(sourceFile), + end: node.getEnd(), + line: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1, + column: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).character + 1, + }); + const report = (node: ts.Node, code: string, message: string) => + diagnostics.push(displayDiagnostic(sourceFile, node, code, message, displayFileName)); + const provenanceFor = (node: ts.Identifier) => { + const binding = analyzed.value.checker.getSymbolAtLocation(node); + return binding ? provenanceByBinding.get(binding) : undefined; + }; + const verifiedBuilder = (node: ts.Expression, allowed: ReadonlySet) => { + if (!ts.isIdentifier(node)) return undefined; + const provenance = provenanceFor(node); + return isPresentationBuilder(provenance) && provenance && allowed.has(provenance.exportName) + ? provenance.exportName + : undefined; + }; + const validateArguments = (call: ts.CallExpression, signatures: BuilderSignature) => { + const signature = signatures.find((candidate) => candidate.length === call.arguments.length); + if (!signature) { + report( + call, + "compiler-static-builder-arguments-invalid", + "Builder arguments do not match the static declaration signature.", + ); + return false; + } + let valid = true; + for (const [index, kind] of signature.entries()) { + const argument = call.arguments[index]!; + if (ts.isSpreadElement(argument) || !argumentMatches(argument, kind)) { + report( + argument, + "compiler-static-builder-arguments-invalid", + "Builder arguments do not match the static declaration signature.", + ); + valid = false; + } + } + return valid; + }; + + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement)) continue; + if ( + !ts.isStringLiteralLike(statement.moduleSpecifier) || + statement.moduleSpecifier.text !== "@unframe/presentation" || + statement.attributes || + !statement.importClause || + statement.importClause.isTypeOnly || + statement.importClause.name || + !statement.importClause.namedBindings || + !ts.isNamedImports(statement.importClause.namedBindings) + ) { + report( + statement, + "compiler-static-import-invalid", + "Declaration files may import verified named builders from @unframe/presentation only.", + ); + continue; + } + for (const element of statement.importClause.namedBindings.elements) { + const provenance = provenanceByRange.get( + `${element.name.getStart(sourceFile)}:${element.name.getEnd()}`, + ); + if ( + element.isTypeOnly || + !isPresentationBuilder(provenance) || + !provenance || + (!rootBuilders.has(provenance.exportName) && !nestedBuilders.has(provenance.exportName)) + ) + report( + element, + "compiler-static-import-invalid", + "Declaration files may import verified named builders from @unframe/presentation only.", + ); + } + } + if (diagnostics.length) return { ok: false, diagnostics: diagnostics.sort(compareDiagnostics) }; + + let defaultExport: ts.ExportAssignment | undefined; + for (const statement of sourceFile.statements) { + if (ts.isImportDeclaration(statement)) continue; + if (ts.isExportAssignment(statement) && !statement.isExportEquals && !defaultExport) { + defaultExport = statement; + continue; + } + report( + statement, + "compiler-static-top-level-unsupported", + "Declaration files may contain imports and one default export only.", + ); + } + if (!defaultExport) + report( + sourceFile, + "compiler-static-root-invalid", + "Declaration files must default-export a recognized root builder call.", + ); + if (diagnostics.length) return { ok: false, diagnostics: diagnostics.sort(compareDiagnostics) }; + + const lowerValue = (node: ts.Expression): DeclarationGraphValue | undefined => { + if (ts.isStringLiteral(node)) + return { kind: "literal", origin: originFor(node), value: node.text }; + if (ts.isNumericLiteral(node)) { + const value = Number(node.text); + if (Number.isFinite(value)) return { kind: "literal", origin: originFor(node), value }; + report(node, "compiler-static-number-invalid", "Numbers must be finite."); + return undefined; + } + if (node.kind === ts.SyntaxKind.TrueKeyword) + return { kind: "literal", origin: originFor(node), value: true }; + if (node.kind === ts.SyntaxKind.FalseKeyword) + return { kind: "literal", origin: originFor(node), value: false }; + if (node.kind === ts.SyntaxKind.NullKeyword) + return { kind: "literal", origin: originFor(node), value: null }; + if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.MinusToken) { + if (!ts.isNumericLiteral(node.operand)) { + report( + node, + "compiler-static-expression-unsupported", + "Only numeric literals may be negated.", + ); + return undefined; + } + const value = -Number(node.operand.text); + if (Number.isFinite(value)) + return { + kind: "literal", + origin: originFor(node), + value: Object.is(value, -0) ? 0 : value, + }; + report(node, "compiler-static-number-invalid", "Numbers must be finite."); + return undefined; + } + if (ts.isArrayLiteralExpression(node)) { + const values: DeclarationGraphValue[] = []; + for (const element of node.elements) { + if (ts.isOmittedExpression(element)) { + report(element, "compiler-static-array-hole", "Arrays must be dense."); + continue; + } + if (ts.isSpreadElement(element)) { + report( + element, + "compiler-static-expression-unsupported", + "Spread expressions are not allowed.", + ); + continue; + } + const value = lowerValue(element); + if (value !== undefined) values.push(value); + } + return diagnostics.length ? undefined : { kind: "array", origin: originFor(node), values }; + } + if (ts.isObjectLiteralExpression(node)) { + const properties: Extract["properties"][number][] = + []; + const keys = new Set(); + for (const property of node.properties) { + if (!ts.isPropertyAssignment(property) || ts.isComputedPropertyName(property.name)) { + report( + property, + "compiler-static-object-property-invalid", + "Objects require explicit non-computed property assignments.", + ); + continue; + } + const key = + ts.isIdentifier(property.name) || + ts.isStringLiteral(property.name) || + ts.isNumericLiteral(property.name) + ? property.name.text + : undefined; + if (key === undefined || key === "__proto__") { + report( + property.name, + "compiler-static-object-property-invalid", + "Object property names are not allowed.", + ); + continue; + } + if (keys.has(key)) { + report( + property.name, + "compiler-static-object-key-duplicate", + "Object property names must be unique.", + ); + continue; + } + keys.add(key); + const value = lowerValue(property.initializer); + if (value !== undefined) properties.push({ key, origin: originFor(property.name), value }); + } + return diagnostics.length + ? undefined + : { kind: "object", origin: originFor(node), properties }; + } + if (ts.isCallExpression(node)) { + if (node.questionDotToken) { + report( + node, + "compiler-static-builder-invalid", + "Calls must target a verified nested declaration builder.", + ); + return undefined; + } + const builder = verifiedBuilder(node.expression, nestedBuilders); + if (!builder) { + report( + node.expression, + "compiler-static-builder-invalid", + "Calls must target a verified nested declaration builder.", + ); + return undefined; + } + const signature = nestedSignatures.get(builder)!; + if (!validateArguments(node, signature)) return undefined; + const arguments_: DeclarationGraphValue[] = []; + for (const argument of node.arguments) { + if (ts.isSpreadElement(argument)) { + report( + argument, + "compiler-static-expression-unsupported", + "Spread expressions are not allowed.", + ); + continue; + } + const value = lowerValue(argument); + if (value !== undefined) arguments_.push(value); + } + return diagnostics.length + ? undefined + : { kind: "builder-call", builder, origin: originFor(node), arguments: arguments_ }; + } + report( + node, + "compiler-static-expression-unsupported", + "Expression is not supported by the static declaration DSL.", + ); + return undefined; + }; + + const rootExpression = defaultExport!.expression; + if (!ts.isCallExpression(rootExpression)) { + report( + rootExpression, + "compiler-static-root-invalid", + "Default export must be a direct root builder call.", + ); + } else { + if (rootExpression.questionDotToken) { + report( + rootExpression, + "compiler-static-root-invalid", + "Default export must be a direct root builder call.", + ); + return { ok: false, diagnostics: diagnostics.sort(compareDiagnostics) }; + } + const builder = verifiedBuilder(rootExpression.expression, rootBuilders); + if (!builder) + report( + rootExpression.expression, + "compiler-static-root-invalid", + "Default export must call a verified root builder.", + ); + else { + if (!validateArguments(rootExpression, rootSignature)) + return { ok: false, diagnostics: diagnostics.sort(compareDiagnostics) }; + const arguments_: DeclarationGraphValue[] = []; + for (const argument of rootExpression.arguments) { + if (ts.isSpreadElement(argument)) + report( + argument, + "compiler-static-expression-unsupported", + "Spread expressions are not allowed.", + ); + else { + const value = lowerValue(argument); + if (value !== undefined) arguments_.push(value); + } + } + if (!diagnostics.length) + return { + ok: true, + graph: { + fileName: displayFileName, + root: { + kind: "builder-call", + builder, + origin: originFor(rootExpression), + arguments: arguments_, + }, + }, + diagnostics: [], + }; + } + } + return { ok: false, diagnostics: diagnostics.sort(compareDiagnostics) }; +}; diff --git a/packages/presentation-compiler/src/lowering/support.ts b/packages/presentation-compiler/src/lowering/support.ts new file mode 100644 index 0000000..eeb772a --- /dev/null +++ b/packages/presentation-compiler/src/lowering/support.ts @@ -0,0 +1,72 @@ +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex } from "@noble/hashes/utils.js"; +import { z } from "zod"; +import type { ComponentPackageLock } from "@unframe/presentation"; +import type { PresentationDeclaration } from "@unframe/presentation"; +import type { Diagnostic } from "@unframe/presentation-core"; +import { diagnostic } from "../diagnostics/diagnostics.js"; +import { + initialPresentationShapeSchema, + nonEmptyStringSchema, +} from "../validation/project-schemas.js"; + +export const hashJson = (json: string) => + `sha256:${bytesToHex(sha256(new TextEncoder().encode(json)))}`; +export const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +export const emptyRecord = (value: unknown) => z.strictObject({}).safeParse(value).success; +export const nonEmptyString = (value: unknown): value is string => + nonEmptyStringSchema.safeParse(value).success; +export const resourceId = (instanceId: string, localId: string) => + `${encodeURIComponent(instanceId)}:${encodeURIComponent(localId)}`; +export const sameLock = (left: ComponentPackageLock, right: ComponentPackageLock) => + left.packageVersion === right.packageVersion && + left.packageIntegrity === right.packageIntegrity && + left.manifestHash === right.manifestHash && + left.structureHash === right.structureHash; + +export const renderIntent = () => ({ + updateModel: { kind: "static" as const }, + interaction: { kind: "none" as const }, + internalAnimation: { kind: "none" as const }, + rendererPreference: "baked-web" as const, + fallbackPolicy: "reject" as const, +}); + +export const hasValidInitialPresentationShape = (presentation: PresentationDeclaration) => + initialPresentationShapeSchema.safeParse(presentation).success; + +export const projectEnvelopeDiagnostics = ( + issues: readonly z.core.$ZodIssue[], +): readonly Diagnostic[] => { + const mapped = issues.map((issue) => { + const [section, index] = issue.path; + if (section === "components") + return diagnostic( + "compiler-invalid-component-entry", + ["components", typeof index === "number" ? index : 0], + "Component entries require declarations and a complete non-empty lock.", + ); + if (section === "themes") + return diagnostic( + "compiler-invalid-theme-entry", + ["themes", typeof index === "number" ? index : 0], + "Theme entries require a declaration and non-empty hash.", + ); + if (issue.code === "unrecognized_keys" && issue.path.length === 0) + return diagnostic( + "compiler-invalid-project-field", + [], + "Project contains an unknown top-level field.", + ); + return diagnostic( + "compiler-invalid-input", + issue.path.map((segment) => (typeof segment === "number" ? segment : String(segment))), + "Project fields are malformed.", + ); + }); + return [ + ...new Map(mapped.map((item) => [`${item.code}\0${item.path.join("/")}`, item])).values(), + ]; +}; diff --git a/packages/presentation-compiler/src/normalization/normalize-declaration-graph.ts b/packages/presentation-compiler/src/normalization/normalize-declaration-graph.ts new file mode 100644 index 0000000..9f6f9a4 --- /dev/null +++ b/packages/presentation-compiler/src/normalization/normalize-declaration-graph.ts @@ -0,0 +1,385 @@ +import type { + DeclarationGraph, + DeclarationSourceOrigin, +} from "../lowering/lower-authoring-declaration.js"; + +export type NormalizedDeclarationValue = + | null + | boolean + | number + | string + | readonly NormalizedDeclarationValue[] + | { readonly [key: string]: NormalizedDeclarationValue }; +type Json = NormalizedDeclarationValue; +type PathSegment = string | number; + +export type DeclarationSourceMapEntry = { + readonly path: readonly PathSegment[]; + readonly origin: DeclarationSourceOrigin; + readonly keyOrigin?: DeclarationSourceOrigin; +}; + +export type NormalizationDiagnostic = { + readonly code: string; + readonly fileName: string; + readonly message: string; + readonly start: number; + readonly end: number; + readonly line: number; + readonly column: number; +}; + +export type NormalizedDeclarationGraph = + | { + readonly ok: true; + readonly rootBuilder: string; + readonly rootOrigin: DeclarationSourceOrigin; + readonly value: Json; + readonly sourceMap: readonly DeclarationSourceMapEntry[]; + readonly diagnostics: []; + } + | { readonly ok: false; readonly diagnostics: readonly NormalizationDiagnostic[] }; + +const diagnosticCode = "compiler-normalization-invalid-graph"; +const rootBuilders = new Set([ + "definePresentation", + "defineTheme", + "defineComponentManifest", + "defineComponentStructure", +]); +const objectBuilders = new Map([ + ["stringProp", "string"], + ["numberProp", "number"], + ["booleanProp", "boolean"], + ["slot", "slot"], + ["part", "part"], + ["variant", "variant"], + ["state", "state"], + ["action", "action"], + ["output", "output"], + ["invokeComponentAction", "component.action"], + ["componentOutput", "component.output"], + ["tokenRef", "token-ref"], + ["namedStyleRef", "named-style-ref"], + ["assetRef", "asset-ref"], + ["spatial", "spatial"], + ["frame", "frame"], + ["text", "text"], + ["surface", "surface"], + ["semanticOverride", "semantic-override"], + ["componentInstance", "component-instance"], + ["detach", "detach"], +]); +const fallbackOrigin: DeclarationSourceOrigin = { + fileName: "", + start: 0, + end: 0, + line: 1, + column: 1, +}; + +const compareDiagnostics = (left: NormalizationDiagnostic, right: NormalizationDiagnostic) => + (left.fileName < right.fileName ? -1 : left.fileName > right.fileName ? 1 : 0) || + left.start - right.start || + left.end - right.end || + (left.code < right.code ? -1 : left.code > right.code ? 1 : 0) || + (left.message < right.message ? -1 : left.message > right.message ? 1 : 0); + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isOrigin = (value: unknown): value is DeclarationSourceOrigin => + isRecord(value) && + typeof value.fileName === "string" && + typeof value.start === "number" && + typeof value.end === "number" && + typeof value.line === "number" && + typeof value.column === "number" && + Number.isFinite(value.start) && + Number.isFinite(value.end) && + Number.isFinite(value.line) && + Number.isFinite(value.column); + +const isNode = (value: unknown): value is Record => + isRecord(value) && isOrigin(value.origin); +type ObjectNode = Record & { readonly properties: readonly unknown[] }; +type BuilderNode = Record & { + readonly builder: string; + readonly arguments: readonly unknown[]; +}; + +const isObjectNode = (value: unknown): value is ObjectNode => + isNode(value) && value.kind === "object" && Array.isArray(value.properties); +const isBuilderNode = (value: unknown): value is BuilderNode => + isNode(value) && + value.kind === "builder-call" && + typeof value.builder === "string" && + Array.isArray(value.arguments); +const isLiteralNode = (value: unknown): value is Record => + isNode(value) && value.kind === "literal"; +const isStringLiteral = (value: unknown) => isLiteralNode(value) && typeof value.value === "string"; +const isFiniteNumberLiteral = (value: unknown) => + isLiteralNode(value) && typeof value.value === "number" && Number.isFinite(value.value); +const createObject = (): Record => Object.create(null) as Record; + +export const normalizeDeclarationGraph = (graph: DeclarationGraph): NormalizedDeclarationGraph => { + const diagnostics: NormalizationDiagnostic[] = []; + const entries = new Map(); + const fail = (origin: DeclarationSourceOrigin, message: string) => { + diagnostics.push({ code: diagnosticCode, ...origin, message }); + }; + const addSource = ( + path: readonly PathSegment[], + origin: DeclarationSourceOrigin, + keyOrigin?: DeclarationSourceOrigin, + ) => { + const key = JSON.stringify(path); + if (entries.has(key)) fail(origin, "Source map paths must be unique."); + else + entries.set( + key, + keyOrigin ? { path: [...path], origin, keyOrigin } : { path: [...path], origin }, + ); + }; + const requireArguments = (node: Record, valid: boolean) => { + if (valid) return true; + fail(node.origin as DeclarationSourceOrigin, "Builder arguments are invalid."); + return false; + }; + + const copyObject = ( + node: unknown, + path: readonly PathSegment[], + materialize: ( + node: unknown, + path: readonly PathSegment[], + keyOrigin?: DeclarationSourceOrigin, + ) => Json | undefined, + target = createObject(), + rejectedKeys: ReadonlySet = new Set(), + ): Record | undefined => { + if (!isObjectNode(node)) { + fail( + isNode(node) ? (node.origin as DeclarationSourceOrigin) : fallbackOrigin, + "Object value is invalid.", + ); + return undefined; + } + const keys = new Set(); + for (const property of node.properties) { + if (!isRecord(property) || typeof property.key !== "string" || !isOrigin(property.origin)) { + fail(node.origin as DeclarationSourceOrigin, "Object property is invalid."); + continue; + } + if (property.key === "__proto__" || keys.has(property.key)) { + fail(property.origin, "Object keys must be unique and safe."); + continue; + } + keys.add(property.key); + if (rejectedKeys.has(property.key)) { + fail(property.origin, "Builder fields conflict with input."); + continue; + } + const value = materialize(property.value, [...path, property.key], property.origin); + if (value !== undefined) + Object.defineProperty(target, property.key, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + } + return diagnostics.length === 0 ? target : undefined; + }; + + const materialize = ( + node: unknown, + path: readonly PathSegment[], + keyOrigin?: DeclarationSourceOrigin, + ): Json | undefined => { + if (!isNode(node) || typeof node.kind !== "string") { + fail(keyOrigin ?? fallbackOrigin, "Declaration graph value is invalid."); + return undefined; + } + const origin = node.origin as DeclarationSourceOrigin; + addSource(path, origin, keyOrigin); + if (node.kind === "literal") { + if (node.value !== null && !["string", "boolean", "number"].includes(typeof node.value)) { + fail(origin, "Literal values must be JSON primitives."); + return undefined; + } + if (typeof node.value === "number" && !Number.isFinite(node.value)) { + fail(origin, "Numbers must be finite."); + return undefined; + } + return node.value as Json; + } + if (node.kind === "array") { + if (!Array.isArray(node.values)) { + fail(origin, "Array values are invalid."); + return undefined; + } + const result: Json[] = []; + for (const [index, child] of node.values.entries()) { + const value = materialize(child, [...path, index]); + if (value !== undefined) result.push(value); + } + return diagnostics.length === 0 ? result : undefined; + } + if (node.kind === "object") return copyObject(node, path, materialize); + if (!isBuilderNode(node)) { + fail(origin, "Declaration graph value kind is unknown."); + return undefined; + } + const generated = ( + target: Record, + key: string, + value: Json, + fieldOrigin: DeclarationSourceOrigin, + ) => { + Object.defineProperty(target, key, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + addSource([...path, key], fieldOrigin); + }; + const arguments_ = node.arguments; + const objectKind = objectBuilders.get(node.builder as string); + if (objectKind) { + const state = node.builder === "state"; + if ( + !requireArguments( + node, + state + ? arguments_.length <= 1 && (arguments_.length === 0 || isObjectNode(arguments_[0])) + : arguments_.length === 1 && isObjectNode(arguments_[0]), + ) + ) + return undefined; + const result = + arguments_.length === 0 + ? createObject() + : copyObject(arguments_[0], path, materialize, createObject(), new Set(["kind"])); + if (!result) return undefined; + generated(result, "kind", objectKind, origin); + return result; + } + if (node.builder === "cue") { + if (!requireArguments(node, arguments_.length === 1 && isObjectNode(arguments_[0]))) + return undefined; + return copyObject(arguments_[0], path, materialize); + } + if (node.builder === "surfaceState" || node.builder === "setSurfaceState") { + if ( + !requireArguments( + node, + arguments_.length === 2 && + isStringLiteral(arguments_[0]) && + isStringLiteral(arguments_[1]), + ) + ) + return undefined; + const result = createObject(); + generated(result, "kind", node.builder as string, origin); + const surface = materialize(arguments_[0], [...path, "surfaceId"]); + const state = materialize(arguments_[1], [...path, "stateId"]); + if (surface === undefined || state === undefined) return undefined; + Object.defineProperty(result, "surfaceId", { + value: surface, + enumerable: true, + writable: true, + configurable: true, + }); + Object.defineProperty(result, "stateId", { + value: state, + enumerable: true, + writable: true, + configurable: true, + }); + return diagnostics.length === 0 ? result : undefined; + } + if (node.builder === "playTimeline") { + if ( + !requireArguments( + node, + arguments_.length === 2 && isStringLiteral(arguments_[0]) && isObjectNode(arguments_[1]), + ) + ) + return undefined; + const result = createObject(); + generated(result, "kind", "playTimeline", origin); + const timeline = materialize(arguments_[0], [...path, "timelineId"]); + if (timeline === undefined) return undefined; + Object.defineProperty(result, "timelineId", { + value: timeline, + enumerable: true, + writable: true, + configurable: true, + }); + return copyObject(arguments_[1], path, materialize, result, new Set(["kind", "timelineId"])); + } + const stringField = new Map([ + ["surfaceInteraction", "interactionId"], + ["timelineCompleted", "timelineId"], + ["mediaCompleted", "surfaceId"], + ]).get(node.builder as string); + if (stringField) { + if (!requireArguments(node, arguments_.length === 1 && isStringLiteral(arguments_[0]))) + return undefined; + const result = createObject(); + generated(result, "kind", node.builder as string, origin); + const value = materialize(arguments_[0], [...path, stringField]); + if (value === undefined) return undefined; + Object.defineProperty(result, stringField, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + return diagnostics.length === 0 ? result : undefined; + } + if (node.builder === "after") { + if (!requireArguments(node, arguments_.length === 1 && isFiniteNumberLiteral(arguments_[0]))) + return undefined; + const result = createObject(); + generated(result, "kind", "timer", origin); + const value = materialize(arguments_[0], [...path, "afterMilliseconds"]); + if (value === undefined) return undefined; + Object.defineProperty(result, "afterMilliseconds", { + value, + enumerable: true, + writable: true, + configurable: true, + }); + return diagnostics.length === 0 ? result : undefined; + } + fail(origin, "Unknown declaration builder."); + return undefined; + }; + + const root = isRecord(graph as unknown) + ? (graph as unknown as Record).root + : undefined; + if (!isBuilderNode(root) || !rootBuilders.has(root.builder as string)) { + fail( + isNode(root) ? (root.origin as DeclarationSourceOrigin) : fallbackOrigin, + "Root builder is invalid.", + ); + return { ok: false, diagnostics: diagnostics.sort(compareDiagnostics) }; + } + if (!requireArguments(root, root.arguments.length === 1 && isObjectNode(root.arguments[0]))) + return { ok: false, diagnostics: diagnostics.sort(compareDiagnostics) }; + const value = materialize(root.arguments[0], []); + if (diagnostics.length !== 0 || value === undefined) + return { ok: false, diagnostics: diagnostics.sort(compareDiagnostics) }; + return { + ok: true, + rootBuilder: root.builder as string, + rootOrigin: root.origin as DeclarationSourceOrigin, + value, + sourceMap: [...entries.values()], + diagnostics: [], + }; +}; diff --git a/packages/presentation-compiler/src/project/collect-authoring-declarations.ts b/packages/presentation-compiler/src/project/collect-authoring-declarations.ts new file mode 100644 index 0000000..178a95a --- /dev/null +++ b/packages/presentation-compiler/src/project/collect-authoring-declarations.ts @@ -0,0 +1,149 @@ +import type { DeclarationSourceOrigin } from "../lowering/lower-authoring-declaration.js"; +import { + normalizeDeclarationGraph, + type DeclarationSourceMapEntry, + type NormalizedDeclarationValue, +} from "../normalization/normalize-declaration-graph.js"; +import type { AnalyzedAuthoringProject } from "../resolution/typecheck-authoring-project.js"; +import { lowerAuthoringDeclarationFile } from "../lowering/lower-authoring-declaration.js"; + +export type AuthoringDeclarationRole = + | "presentation" + | "theme" + | "component-manifest" + | "component-structure"; +export type AuthoringDeclarationRootBuilder = + | "definePresentation" + | "defineTheme" + | "defineComponentManifest" + | "defineComponentStructure"; + +export type DeclarationCollectionDiagnostic = { + readonly code: string; + readonly fileName: string; + readonly message: string; + readonly start: number; + readonly end: number; + readonly line: number; + readonly column: number; +}; + +export type CollectedAuthoringDeclaration = { + readonly role: AuthoringDeclarationRole; + readonly fileName: string; + readonly rootBuilder: AuthoringDeclarationRootBuilder; + readonly value: NormalizedDeclarationValue; + readonly sourceMap: readonly DeclarationSourceMapEntry[]; +}; + +export type CollectedAuthoringDeclarations = + | { + readonly ok: true; + readonly declarations: readonly CollectedAuthoringDeclaration[]; + readonly diagnostics: readonly []; + } + | { readonly ok: false; readonly diagnostics: readonly DeclarationCollectionDiagnostic[] }; + +export type CollectedAuthoringDeclarationsSuccess = Extract< + CollectedAuthoringDeclarations, + { readonly ok: true } +>; + +const roleFor = (fileName: string, entryFileName: string) => { + if (fileName === entryFileName) + return { role: "presentation", rootBuilder: "definePresentation" } as const; + if (fileName.endsWith(".unframe.ts")) + return { role: "theme", rootBuilder: "defineTheme" } as const; + if (fileName.endsWith(".manifest.ts")) + return { role: "component-manifest", rootBuilder: "defineComponentManifest" } as const; + if (fileName.endsWith(".structure.tsx")) + return { role: "component-structure", rootBuilder: "defineComponentStructure" } as const; + return undefined; +}; + +const compareDiagnostics = ( + left: DeclarationCollectionDiagnostic, + right: DeclarationCollectionDiagnostic, +) => + (left.fileName < right.fileName ? -1 : left.fileName > right.fileName ? 1 : 0) || + left.start - right.start || + left.end - right.end || + (left.code < right.code ? -1 : left.code > right.code ? 1 : 0) || + (left.message < right.message ? -1 : left.message > right.message ? 1 : 0); + +const diagnosticAt = ( + origin: DeclarationSourceOrigin, + code: string, + message: string, +): DeclarationCollectionDiagnostic => ({ code, message, ...origin }); + +export const collectAuthoringDeclarations = ( + analyzed: Extract, +): CollectedAuthoringDeclarations => { + const { context, entrySourceFile } = analyzed.value; + const entryFileName = context.displayFileName(entrySourceFile); + const diagnostics: DeclarationCollectionDiagnostic[] = []; + if (entryFileName.endsWith(".d.ts")) + diagnostics.push({ + code: "compiler-declaration-entry-file-unsupported", + fileName: entryFileName, + message: "The declaration entry file must not use the .d.ts suffix.", + start: 0, + end: 0, + line: 1, + column: 1, + }); + const files = [...context.sourceFiles.values()] + .filter((sourceFile) => context.ownerFor(sourceFile)?.kind === "project") + .map((sourceFile) => ({ sourceFile, fileName: context.displayFileName(sourceFile) })) + .sort((left, right) => + left.fileName < right.fileName ? -1 : left.fileName > right.fileName ? 1 : 0, + ); + const declarations: CollectedAuthoringDeclaration[] = []; + for (const { sourceFile, fileName } of files) { + if (fileName.endsWith(".d.ts")) continue; + const role = roleFor(fileName, entryFileName); + if (!role) { + diagnostics.push({ + code: "compiler-declaration-file-role-unsupported", + fileName, + message: "Project declaration files must use a recognized declaration suffix.", + start: 0, + end: 0, + line: 1, + column: 1, + }); + continue; + } + const lowered = lowerAuthoringDeclarationFile(analyzed, sourceFile); + if (!lowered.ok) { + diagnostics.push(...lowered.diagnostics); + continue; + } + if (lowered.graph.root.builder !== role.rootBuilder) { + diagnostics.push( + diagnosticAt( + lowered.graph.root.origin, + "compiler-declaration-root-mismatch", + "Declaration file root builder does not match its file role.", + ), + ); + continue; + } + const normalized = normalizeDeclarationGraph(lowered.graph); + if (!normalized.ok) { + diagnostics.push(...normalized.diagnostics); + continue; + } + declarations.push({ + role: role.role, + fileName, + rootBuilder: role.rootBuilder, + value: normalized.value, + sourceMap: normalized.sourceMap, + }); + } + if (diagnostics.length !== 0) + return { ok: false, diagnostics: diagnostics.sort(compareDiagnostics) }; + return { ok: true, declarations, diagnostics: [] }; +}; diff --git a/packages/presentation-compiler/src/project/pair-authoring-declarations.ts b/packages/presentation-compiler/src/project/pair-authoring-declarations.ts new file mode 100644 index 0000000..79a659e --- /dev/null +++ b/packages/presentation-compiler/src/project/pair-authoring-declarations.ts @@ -0,0 +1,368 @@ +import { + isComponentManifest, + isComponentStructure, + isPresentationDeclaration, + isThemeDeclaration, + type ComponentManifest, + type ComponentStructure, + type PresentationDeclaration, + type ThemeDeclaration, +} from "@unframe/presentation"; +import type { DeclarationSourceOrigin } from "../lowering/lower-authoring-declaration.js"; +import type { + CollectedAuthoringDeclaration, + CollectedAuthoringDeclarationsSuccess, + DeclarationCollectionDiagnostic, +} from "./collect-authoring-declarations.js"; + +type TypedDeclaration = CollectedAuthoringDeclaration & { readonly value: T }; + +export type PairedComponentDeclaration = { + readonly manifest: TypedDeclaration; + readonly structure: TypedDeclaration; +}; + +export type PairedAuthoringDeclarationCatalog = { + readonly presentation: TypedDeclaration; + readonly themes: readonly TypedDeclaration[]; + readonly components: readonly PairedComponentDeclaration[]; +}; + +export type PairAuthoringDeclarationsResult = + | { + readonly ok: true; + readonly catalog: PairedAuthoringDeclarationCatalog; + readonly diagnostics: []; + } + | { readonly ok: false; readonly diagnostics: readonly DeclarationCollectionDiagnostic[] }; + +const fallbackOrigin: DeclarationSourceOrigin = { + fileName: "", + start: 0, + end: 0, + line: 1, + column: 1, +}; + +const compareDiagnostics = ( + left: DeclarationCollectionDiagnostic, + right: DeclarationCollectionDiagnostic, +) => + (left.fileName < right.fileName ? -1 : left.fileName > right.fileName ? 1 : 0) || + left.start - right.start || + left.end - right.end || + (left.code < right.code ? -1 : left.code > right.code ? 1 : 0) || + (left.message < right.message ? -1 : left.message > right.message ? 1 : 0); + +const samePath = (left: readonly (string | number)[], right: readonly (string | number)[]) => + left.length === right.length && left.every((segment, index) => segment === right[index]); + +const originAt = (declaration: CollectedAuthoringDeclaration, path: readonly (string | number)[]) => + declaration.sourceMap.find((entry) => samePath(entry.path, path))?.origin ?? + declaration.sourceMap.find((entry) => entry.path.length === 0)?.origin ?? { + ...fallbackOrigin, + fileName: declaration.fileName, + }; + +const diagnosticAt = ( + declaration: CollectedAuthoringDeclaration, + path: readonly (string | number)[], + code: string, + message: string, +): DeclarationCollectionDiagnostic => ({ code, message, ...originAt(declaration, path) }); + +export const resolveAuthoringStructurePath = ( + manifestFileName: string, + value: string, +): string | undefined => { + const relative = value.startsWith("./") ? value.slice(2) : value; + if ( + relative.length === 0 || + relative.endsWith("/") || + relative.includes("\\") || + relative.includes("\0") || + relative.startsWith("/") + ) + return undefined; + const directory = manifestFileName.split("/").slice(0, -1); + const segments = [...directory]; + let hasNormalSegment = false; + for (const segment of relative.split("/")) { + if (segment === "" || segment === ".") return undefined; + if (segment === "..") { + if (hasNormalSegment || segments.length === 0) return undefined; + segments.pop(); + continue; + } + hasNormalSegment = true; + segments.push(segment); + } + return hasNormalSegment ? segments.join("/") : undefined; +}; + +const ownDataValue = (value: unknown, key: string): unknown => { + if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined; + try { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && descriptor.enumerable && "value" in descriptor + ? descriptor.value + : undefined; + } catch { + return undefined; + } +}; + +const potentialStructurePath = (declaration: CollectedAuthoringDeclaration): string | undefined => { + const authoring = ownDataValue(declaration.value, "authoring"); + const structure = ownDataValue(authoring, "structure"); + return typeof structure === "string" + ? resolveAuthoringStructurePath(declaration.fileName, structure) + : undefined; +}; + +const rootOrigin = (declaration: CollectedAuthoringDeclaration) => originAt(declaration, []); +const compareDeclarations = ( + left: CollectedAuthoringDeclaration, + right: CollectedAuthoringDeclaration, +) => { + const leftOrigin = rootOrigin(left); + const rightOrigin = rootOrigin(right); + return ( + (left.fileName < right.fileName ? -1 : left.fileName > right.fileName ? 1 : 0) || + leftOrigin.start - rightOrigin.start || + leftOrigin.end - rightOrigin.end || + (left.role < right.role ? -1 : left.role > right.role ? 1 : 0) || + (left.rootBuilder < right.rootBuilder ? -1 : left.rootBuilder > right.rootBuilder ? 1 : 0) + ); +}; + +const compareComponentDeclarations = ( + left: PairedComponentDeclaration, + right: PairedComponentDeclaration, +) => + (left.manifest.value.componentId < right.manifest.value.componentId + ? -1 + : left.manifest.value.componentId > right.manifest.value.componentId + ? 1 + : 0) || + left.manifest.value.version - right.manifest.value.version || + compareDeclarations(left.manifest, right.manifest); + +/** Pairs already-normalized static declarations without executing Authoring builders. */ +export const pairAuthoringDeclarations = ( + input: CollectedAuthoringDeclarationsSuccess, +): PairAuthoringDeclarationsResult => { + const declarations = [...input.declarations].sort(compareDeclarations); + const diagnostics: DeclarationCollectionDiagnostic[] = []; + const presentations: TypedDeclaration[] = []; + const themes: TypedDeclaration[] = []; + const manifests: TypedDeclaration[] = []; + const structures: TypedDeclaration[] = []; + const rawStructureFiles = new Set( + declarations + .filter((declaration) => declaration.role === "component-structure") + .map((declaration) => declaration.fileName), + ); + const presentationCount = declarations.filter( + (declaration) => declaration.role === "presentation", + ).length; + const potentiallyReferencedStructureFiles = new Set(); + + for (const declaration of declarations) { + switch (declaration.role) { + case "presentation": + if (isPresentationDeclaration(declaration.value)) + presentations.push(declaration as TypedDeclaration); + else + diagnostics.push( + diagnosticAt( + declaration, + [], + "compiler-invalid-declaration", + "Presentation declaration failed Authoring SDK validation.", + ), + ); + break; + case "theme": + if (isThemeDeclaration(declaration.value)) + themes.push(declaration as TypedDeclaration); + else + diagnostics.push( + diagnosticAt( + declaration, + [], + "compiler-invalid-declaration", + "Theme declaration failed Authoring SDK validation.", + ), + ); + break; + case "component-manifest": + if (isComponentManifest(declaration.value)) + manifests.push(declaration as TypedDeclaration); + else { + const structurePath = potentialStructurePath(declaration); + if (structurePath) potentiallyReferencedStructureFiles.add(structurePath); + diagnostics.push( + diagnosticAt( + declaration, + [], + "compiler-invalid-declaration", + "Component manifest declaration failed Authoring SDK validation.", + ), + ); + } + break; + case "component-structure": + if (isComponentStructure(declaration.value)) + structures.push(declaration as TypedDeclaration); + else + diagnostics.push( + diagnosticAt( + declaration, + [], + "compiler-invalid-declaration", + "Component structure declaration failed Authoring SDK validation.", + ), + ); + break; + } + } + + if (presentationCount !== 1) { + const declaration = + declarations.find((item) => item.role === "presentation") ?? declarations[0]; + if (declaration) + diagnostics.push( + diagnosticAt( + declaration, + [], + "compiler-presentation-declaration-count-invalid", + "Exactly one presentation declaration is required.", + ), + ); + else + diagnostics.push({ + code: "compiler-presentation-declaration-count-invalid", + message: "Exactly one presentation declaration is required.", + ...fallbackOrigin, + }); + } + + const themesById = new Map>(); + for (const theme of themes) { + const existing = themesById.get(theme.value.id); + if (existing) + diagnostics.push( + diagnosticAt( + theme, + ["id"], + "compiler-theme-duplicate", + `Theme id '${theme.value.id}' is declared more than once.`, + ), + ); + else themesById.set(theme.value.id, theme); + } + + const manifestsByIdentity = new Map>(); + for (const manifest of manifests) { + const identity = `${manifest.value.componentId}\0${manifest.value.version}`; + const existing = manifestsByIdentity.get(identity); + if (existing) { + diagnostics.push( + diagnosticAt( + manifest, + ["componentId"], + "compiler-component-manifest-duplicate", + `Component id '${manifest.value.componentId}' is declared more than once.`, + ), + ); + continue; + } + manifestsByIdentity.set(identity, manifest); + const authoring = manifest.value.authoring; + if (authoring.mode === "opaque") { + diagnostics.push( + diagnosticAt( + manifest, + ["authoring", "mode"], + "compiler-opaque-component-unsupported", + "Opaque component authoring is not supported by this milestone.", + ), + ); + continue; + } + } + + const structuresByFile = new Map(structures.map((structure) => [structure.fileName, structure])); + const referencedStructures = new Set>(); + const components: PairedComponentDeclaration[] = []; + for (const manifest of manifestsByIdentity.values()) { + const authoring = manifest.value.authoring; + if (authoring.mode !== "structured") continue; + const structurePath = resolveAuthoringStructurePath(manifest.fileName, authoring.structure); + if (!structurePath) { + diagnostics.push( + diagnosticAt( + manifest, + ["authoring", "structure"], + "compiler-component-structure-entry-invalid", + "Structured component authoring.structure must be a root-contained POSIX relative file path.", + ), + ); + continue; + } + const structure = structuresByFile.get(structurePath); + if (!structure && !rawStructureFiles.has(structurePath)) { + diagnostics.push( + diagnosticAt( + manifest, + ["authoring", "structure"], + "compiler-component-structure-not-found", + `No collected component structure exists at '${structurePath}'.`, + ), + ); + continue; + } + if (!structure) continue; + referencedStructures.add(structure); + if (structure.value.componentId !== manifest.value.componentId) { + diagnostics.push( + diagnosticAt( + structure, + ["componentId"], + "compiler-component-identity-mismatch", + `Structure component id '${structure.value.componentId}' does not match manifest component id '${manifest.value.componentId}'.`, + ), + ); + continue; + } + components.push({ manifest, structure }); + } + for (const structure of structures) { + if ( + referencedStructures.has(structure) || + potentiallyReferencedStructureFiles.has(structure.fileName) + ) + continue; + diagnostics.push( + diagnosticAt( + structure, + [], + "compiler-component-structure-unreferenced", + `Component structure '${structure.fileName}' is not referenced by a structured component manifest.`, + ), + ); + } + + if (diagnostics.length !== 0) + return { ok: false, diagnostics: diagnostics.sort(compareDiagnostics) }; + return { + ok: true, + catalog: { + presentation: presentations[0]!, + themes: [...themesById.values()].sort(compareDeclarations), + components: components.sort(compareComponentDeclarations), + }, + diagnostics: [], + }; +}; diff --git a/packages/presentation-compiler/src/project/parse-authoring-project.ts b/packages/presentation-compiler/src/project/parse-authoring-project.ts new file mode 100644 index 0000000..54af919 --- /dev/null +++ b/packages/presentation-compiler/src/project/parse-authoring-project.ts @@ -0,0 +1,246 @@ +import type * as ts from "typescript"; +import { z } from "zod"; + +import { + parseLockedPackages, + type LockedPackageIdentity, + type ParsedLockedPackage, +} from "./parse-locked-packages.js"; +import { safePlainClone } from "../validation/safe-plain-clone.js"; +import { parseAuthoringSource } from "../syntax/parse-authoring-source.js"; + +type ProjectSourceDiagnostic = { + readonly code: string; + readonly fileName: string; + readonly message: string; + readonly start: number; + readonly end: number; + readonly line: number; + readonly column: number; + readonly typescriptCode?: number; +}; + +export type ParsedAuthoringProjectValue = { + readonly projectRoot: string; + readonly entryFile: string; + readonly files: Readonly>; + readonly packageDependencies: readonly LockedPackageIdentity[]; + readonly packages: readonly ParsedLockedPackage[]; +}; + +export type ParsedAuthoringProject = + | { + readonly ok: true; + readonly value: ParsedAuthoringProjectValue; + readonly diagnostics: []; + } + | { readonly ok: false; readonly diagnostics: readonly ProjectSourceDiagnostic[] }; + +const inputSchema = z + .object({ + projectRoot: z.string(), + entryFile: z.string(), + files: z.array(z.object({ fileName: z.string(), sourceText: z.string() }).strict()), + packageDependencies: z.array(z.unknown()), + packages: z.array(z.unknown()), + }) + .strict(); + +const hasExactOwnKeys = (value: unknown, expected: readonly string[]) => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.keys(value).length === expected.length && + Object.keys(value).every((key) => expected.includes(key)); + +const hasProjectEnvelopeShape = (value: unknown) => { + if ( + !hasExactOwnKeys(value, [ + "projectRoot", + "entryFile", + "files", + "packageDependencies", + "packages", + ]) + ) + return false; + const { files, packageDependencies, packages } = value as Record; + return ( + Array.isArray(files) && + files.every((file) => hasExactOwnKeys(file, ["fileName", "sourceText"])) && + Array.isArray(packageDependencies) && + Array.isArray(packages) + ); +}; + +const isLogicalAbsolutePosixPath = (value: string) => + value.startsWith("/") && + !value.includes("\\") && + !value.includes("\0") && + (value === "/" || !value.endsWith("/")) && + value + .split("/") + .slice(1) + .every((segment) => segment !== "" && segment !== "." && segment !== ".."); + +const isRootRelativePath = (value: string) => + value.length > 0 && + !value.startsWith("/") && + !value.includes("\\") && + !value.includes("\0") && + value.split("/").every((segment) => segment !== "" && segment !== "." && segment !== ".."); + +const sourceKindSupported = (fileName: string) => + fileName.endsWith(".ts") || fileName.endsWith(".tsx") || fileName.endsWith(".d.ts"); + +const compareDiagnostics = (left: ProjectSourceDiagnostic, right: ProjectSourceDiagnostic) => + (left.fileName < right.fileName ? -1 : left.fileName > right.fileName ? 1 : 0) || + left.start - right.start || + left.end - right.end || + (left.code < right.code ? -1 : left.code > right.code ? 1 : 0) || + (left.typescriptCode ?? 0) - (right.typescriptCode ?? 0) || + (left.message < right.message ? -1 : left.message > right.message ? 1 : 0); + +const projectDiagnostic = ( + code: ProjectSourceDiagnostic["code"], + fileName: string, + message: string, +): ProjectSourceDiagnostic => ({ code, fileName, message, start: 0, end: 0, line: 1, column: 1 }); + +export const parseAuthoringProject = (input: unknown): ParsedAuthoringProject => { + const snapshot = safePlainClone(input); + if (!snapshot.valid) + return { + ok: false, + diagnostics: [ + projectDiagnostic( + "compiler-invalid-input", + "", + "Project input cannot be inspected safely.", + ), + ], + }; + if (!hasProjectEnvelopeShape(snapshot.value)) + return { + ok: false, + diagnostics: [ + projectDiagnostic( + "compiler-invalid-input", + "", + "Project input has an invalid virtual filesystem shape.", + ), + ], + }; + const parsed = inputSchema.safeParse(snapshot.value); + if (!parsed.success) + return { + ok: false, + diagnostics: [ + projectDiagnostic( + "compiler-invalid-input", + "", + "Project input has an invalid virtual filesystem shape.", + ), + ], + }; + + const { projectRoot, entryFile, files } = parsed.data; + const lockedPackages = parseLockedPackages(snapshot.value as Record); + if (!lockedPackages.valid) + return { ok: false, diagnostics: [...lockedPackages.diagnostics].sort(compareDiagnostics) }; + const diagnostics: ProjectSourceDiagnostic[] = []; + if (!isLogicalAbsolutePosixPath(projectRoot)) + diagnostics.push( + projectDiagnostic( + "compiler-project-root-invalid", + "", + "Project root must be a logical absolute POSIX path.", + ), + ); + if (!isRootRelativePath(entryFile)) + diagnostics.push( + projectDiagnostic( + "compiler-project-path-invalid", + entryFile, + "Entry file must be relative to the project root.", + ), + ); + + const seen = new Set(); + for (const file of files) { + if (!isRootRelativePath(file.fileName)) + diagnostics.push( + projectDiagnostic( + "compiler-project-path-invalid", + file.fileName, + "File name must be relative to the project root.", + ), + ); + else if (seen.has(file.fileName)) + diagnostics.push( + projectDiagnostic( + "compiler-project-file-duplicate", + file.fileName, + "Virtual project file names must be unique.", + ), + ); + else seen.add(file.fileName); + if (!sourceKindSupported(file.fileName)) + diagnostics.push({ + ...projectDiagnostic( + "compiler-source-kind-unsupported", + file.fileName, + "Authoring source must use a .ts, .tsx, or .d.ts file name.", + ), + }); + } + if (isRootRelativePath(entryFile) && !seen.has(entryFile)) + diagnostics.push( + projectDiagnostic( + "compiler-project-entry-not-found", + entryFile, + "Entry file must be present in the virtual project.", + ), + ); + if (diagnostics.length > 0) + return { ok: false, diagnostics: diagnostics.sort(compareDiagnostics) }; + + const parsedFiles: Record = {}; + for (const file of [...files].sort((left, right) => + left.fileName < right.fileName ? -1 : left.fileName > right.fileName ? 1 : 0, + )) { + const result = parseAuthoringSource({ + fileName: `${projectRoot === "/" ? "" : projectRoot}/${file.fileName}`, + sourceText: file.sourceText, + }); + if (result.ok) { + parsedFiles[file.fileName] = result.value; + continue; + } + diagnostics.push( + ...result.diagnostics.map((item): ProjectSourceDiagnostic => ({ + code: item.code, + fileName: file.fileName, + message: item.message, + start: item.start, + end: item.start + item.length, + line: item.line, + column: item.column, + ...(item.typescriptCode === undefined ? {} : { typescriptCode: item.typescriptCode }), + })), + ); + } + return diagnostics.length > 0 + ? { ok: false, diagnostics: diagnostics.sort(compareDiagnostics) } + : { + ok: true, + value: { + projectRoot, + entryFile, + files: parsedFiles, + packageDependencies: lockedPackages.packageDependencies, + packages: lockedPackages.packages, + }, + diagnostics: [], + }; +}; diff --git a/packages/presentation-compiler/src/project/parse-locked-packages.ts b/packages/presentation-compiler/src/project/parse-locked-packages.ts new file mode 100644 index 0000000..7cd56d0 --- /dev/null +++ b/packages/presentation-compiler/src/project/parse-locked-packages.ts @@ -0,0 +1,337 @@ +import type * as ts from "typescript"; + +import { parseAuthoringSource } from "../syntax/parse-authoring-source.js"; + +export type LockedPackageIdentity = { + readonly packageName: string; + readonly packageVersion: string; + readonly packageIntegrity: string; +}; + +export type ParsedLockedPackage = LockedPackageIdentity & { + readonly files: Readonly>; + readonly exports: readonly { readonly subpath: string; readonly targetFile: string }[]; + readonly dependencies: readonly LockedPackageIdentity[]; +}; + +export type LockedPackageDiagnostic = { + readonly code: string; + readonly fileName: string; + readonly message: string; + readonly start: number; + readonly end: number; + readonly line: number; + readonly column: number; + readonly typescriptCode?: number; +}; + +export type ParsedLockedPackages = + | { + readonly valid: true; + readonly packageDependencies: readonly LockedPackageIdentity[]; + readonly packages: readonly ParsedLockedPackage[]; + readonly diagnostics: []; + } + | { readonly valid: false; readonly diagnostics: readonly LockedPackageDiagnostic[] }; + +type UnknownRecord = Record; +type PackageInput = LockedPackageIdentity & { + files: { fileName: string; sourceText: string }[]; + exports: { subpath: string; targetFile: string }[]; + dependencies: LockedPackageIdentity[]; +}; + +const compareStrings = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0); + +const compareDiagnostics = (left: LockedPackageDiagnostic, right: LockedPackageDiagnostic) => + compareStrings(left.fileName, right.fileName) || + left.start - right.start || + left.end - right.end || + compareStrings(left.code, right.code) || + (left.typescriptCode ?? 0) - (right.typescriptCode ?? 0) || + compareStrings(left.message, right.message); + +const diagnostic = (code: string, message: string, fileName = ""): LockedPackageDiagnostic => ({ + code, + fileName, + message, + start: 0, + end: 0, + line: 1, + column: 1, +}); + +const hasExactOwnKeys = (value: unknown, expected: readonly string[]) => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.keys(value).length === expected.length && + Object.keys(value).every((key) => expected.includes(key)); + +const isIdentity = (value: unknown): value is LockedPackageIdentity => + hasExactOwnKeys(value, ["packageName", "packageVersion", "packageIntegrity"]) && + typeof (value as UnknownRecord).packageName === "string" && + typeof (value as UnknownRecord).packageVersion === "string" && + typeof (value as UnknownRecord).packageIntegrity === "string"; + +const isPackageInput = (value: unknown): value is PackageInput => { + if ( + !hasExactOwnKeys(value, [ + "packageName", + "packageVersion", + "packageIntegrity", + "files", + "exports", + "dependencies", + ]) + ) + return false; + const input = value as UnknownRecord; + return ( + typeof input.packageName === "string" && + typeof input.packageVersion === "string" && + typeof input.packageIntegrity === "string" && + Array.isArray(input.files) && + input.files.every( + (file) => + hasExactOwnKeys(file, ["fileName", "sourceText"]) && + typeof (file as UnknownRecord).fileName === "string" && + typeof (file as UnknownRecord).sourceText === "string", + ) && + Array.isArray(input.exports) && + input.exports.every( + (entry) => + hasExactOwnKeys(entry, ["subpath", "targetFile"]) && + typeof (entry as UnknownRecord).subpath === "string" && + typeof (entry as UnknownRecord).targetFile === "string", + ) && + Array.isArray(input.dependencies) && + input.dependencies.every(isIdentity) + ); +}; + +const packageNamePattern = + /^(?:@[-a-z0-9][a-z0-9._-]*\/[-a-z0-9._][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)$/; +const sourceExtensions = [".ts", ".tsx", ".d.ts"] as const; + +const isRootRelativePath = (value: string) => + value.length > 0 && + !value.startsWith("/") && + !value.includes("\\") && + !value.includes("\0") && + value.split("/").every((segment) => segment !== "" && segment !== "." && segment !== ".."); + +const sameIdentity = (left: LockedPackageIdentity, right: LockedPackageIdentity) => + left.packageName === right.packageName && + left.packageVersion === right.packageVersion && + left.packageIntegrity === right.packageIntegrity; + +const compareIdentity = (left: LockedPackageIdentity, right: LockedPackageIdentity) => + compareStrings(left.packageName, right.packageName) || + compareStrings(left.packageVersion, right.packageVersion) || + compareStrings(left.packageIntegrity, right.packageIntegrity); + +const hasDuplicateIdentity = (dependencies: readonly LockedPackageIdentity[]) => + dependencies.some((dependency, index) => + dependencies.slice(0, index).some((previous) => sameIdentity(previous, dependency)), + ); + +const packageDisplayFileName = (item: LockedPackageIdentity, fileName: string) => + `${item.packageName}@${item.packageVersion}/${fileName}`; + +const encodeVirtualPathSegment = (value: string) => { + let encoded = "p"; + for (let index = 0; index < value.length; index++) + encoded += value.charCodeAt(index).toString(16).toUpperCase().padStart(4, "0"); + return encoded; +}; + +const packageVirtualFileName = (item: LockedPackageIdentity, fileName: string) => + `unframe-package://${encodeVirtualPathSegment(item.packageName)}/${encodeVirtualPathSegment(item.packageVersion)}/${encodeVirtualPathSegment(item.packageIntegrity)}/${fileName}`; + +export const parseLockedPackages = (value: UnknownRecord): ParsedLockedPackages => { + const dependencies = value.packageDependencies; + const packages = value.packages; + if ( + !Array.isArray(dependencies) || + !dependencies.every(isIdentity) || + !Array.isArray(packages) || + !packages.every(isPackageInput) + ) + return { + valid: false, + diagnostics: [ + diagnostic("compiler-invalid-input", "Project input has an invalid package shape."), + ], + }; + + const diagnostics: LockedPackageDiagnostic[] = []; + const identities = [ + ...dependencies, + ...packages.map(({ packageName, packageVersion, packageIntegrity }): LockedPackageIdentity => ({ + packageName, + packageVersion, + packageIntegrity, + })), + ...packages.flatMap((item) => item.dependencies), + ]; + for (const identity of identities) + if ( + identity.packageName.length > 214 || + !packageNamePattern.test(identity.packageName) || + identity.packageVersion.length === 0 || + identity.packageIntegrity.length === 0 + ) + diagnostics.push( + diagnostic( + "compiler-package-identity-invalid", + "Package name, version, and integrity must be valid nonempty locked identities.", + identity.packageName, + ), + ); + + const packageByName = new Map(); + if (hasDuplicateIdentity(dependencies)) + diagnostics.push( + diagnostic( + "compiler-package-dependency-duplicate", + "Project package dependencies must not repeat the same locked identity.", + ), + ); + for (const item of packages) { + if (packageByName.has(item.packageName)) + diagnostics.push( + diagnostic( + "compiler-package-duplicate", + "Only one locked package version may be present for a package name.", + item.packageName, + ), + ); + else packageByName.set(item.packageName, item); + if (hasDuplicateIdentity(item.dependencies)) + diagnostics.push( + diagnostic( + "compiler-package-dependency-duplicate", + "Package dependencies must not repeat the same locked identity.", + item.packageName, + ), + ); + const files = new Set(); + for (const file of item.files) { + if (!isRootRelativePath(file.fileName)) + diagnostics.push( + diagnostic( + "compiler-package-file-path-invalid", + "Package file names must be relative POSIX paths.", + item.packageName, + ), + ); + else if (files.has(file.fileName)) + diagnostics.push( + diagnostic( + "compiler-package-file-duplicate", + "Package file names must be unique.", + item.packageName, + ), + ); + else files.add(file.fileName); + if (!sourceExtensions.some((extension) => file.fileName.endsWith(extension))) + diagnostics.push( + diagnostic( + "compiler-source-kind-unsupported", + "Package sources must use a .ts, .tsx, or .d.ts file name.", + item.packageName, + ), + ); + } + const exports = new Set(); + for (const entry of item.exports) { + const validSubpath = + entry.subpath === "." || + (entry.subpath.startsWith("./") && isRootRelativePath(entry.subpath.slice(2))); + if (!validSubpath) + diagnostics.push( + diagnostic( + "compiler-package-export-subpath-invalid", + "Package export subpaths must be '.' or root-relative './' paths.", + item.packageName, + ), + ); + if (exports.has(entry.subpath)) + diagnostics.push( + diagnostic( + "compiler-package-export-duplicate", + "Package export subpaths must be unique.", + item.packageName, + ), + ); + else exports.add(entry.subpath); + if (!files.has(entry.targetFile)) + diagnostics.push( + diagnostic( + "compiler-package-export-target-missing", + "Package export targets must name a package source file.", + item.packageName, + ), + ); + } + } + const allDependencies = [...dependencies, ...packages.flatMap((item) => item.dependencies)]; + for (const dependency of allDependencies) { + const candidate = packageByName.get(dependency.packageName); + if (candidate === undefined || !sameIdentity(candidate, dependency)) + diagnostics.push( + diagnostic( + "compiler-package-dependency-mismatch", + "Package dependencies must match one locked package identity exactly.", + dependency.packageName, + ), + ); + } + if (diagnostics.length > 0) + return { valid: false, diagnostics: diagnostics.sort(compareDiagnostics) }; + + const parsedPackages: ParsedLockedPackage[] = []; + for (const item of [...packages].sort(compareIdentity)) { + const files: Record = {}; + for (const file of [...item.files].sort((left, right) => + compareStrings(left.fileName, right.fileName), + )) { + const displayFileName = packageDisplayFileName(item, file.fileName); + const parsed = parseAuthoringSource({ + fileName: packageVirtualFileName(item, file.fileName), + sourceText: file.sourceText, + }); + if (!parsed.ok) { + diagnostics.push( + ...parsed.diagnostics.map((item): LockedPackageDiagnostic => ({ + code: item.code, + fileName: displayFileName, + message: item.message, + start: item.start, + end: item.start + item.length, + line: item.line, + column: item.column, + ...(item.typescriptCode === undefined ? {} : { typescriptCode: item.typescriptCode }), + })), + ); + } else files[file.fileName] = parsed.value; + } + parsedPackages.push({ + packageName: item.packageName, + packageVersion: item.packageVersion, + packageIntegrity: item.packageIntegrity, + files, + exports: [...item.exports].sort((left, right) => compareStrings(left.subpath, right.subpath)), + dependencies: [...item.dependencies].sort(compareIdentity), + }); + } + return diagnostics.length > 0 + ? { valid: false, diagnostics: diagnostics.sort(compareDiagnostics) } + : { + valid: true, + packageDependencies: [...dependencies].sort(compareIdentity), + packages: parsedPackages, + diagnostics: [], + }; +}; diff --git a/packages/presentation-compiler/src/resolution/symbol-provenance.ts b/packages/presentation-compiler/src/resolution/symbol-provenance.ts new file mode 100644 index 0000000..bc6cfc3 --- /dev/null +++ b/packages/presentation-compiler/src/resolution/symbol-provenance.ts @@ -0,0 +1,103 @@ +import * as ts from "typescript"; + +import type { AnalyzedAuthoringProject } from "./typecheck-authoring-project.js"; + +export type PackageValueProvenance = { + readonly packageName: string; + readonly packageVersion: string; + readonly packageIntegrity: string; + readonly subpath: string; + readonly exportName: string; + readonly targetFile: string; + readonly declarationFile: string; + readonly fileName: string; + readonly start: number; + readonly end: number; + readonly line: number; + readonly column: number; +}; + +const compare = (left: PackageValueProvenance, right: PackageValueProvenance) => + (left.fileName < right.fileName ? -1 : left.fileName > right.fileName ? 1 : 0) || + left.start - right.start || + (left.exportName < right.exportName ? -1 : left.exportName > right.exportName ? 1 : 0); + +export const collectPackageValueProvenance = ( + analyzed: Extract, +) => { + const result: PackageValueProvenance[] = []; + for (const sourceFile of analyzed.value.context.sourceFiles.values()) { + const visit = (node: ts.Node): void => { + if ( + ts.isImportDeclaration(node) && + !node.importClause?.isTypeOnly && + node.importClause?.namedBindings && + ts.isNamedImports(node.importClause.namedBindings) && + ts.isStringLiteralLike(node.moduleSpecifier) + ) { + const resolved = analyzed.value.context.resolve( + sourceFile.fileName, + node.moduleSpecifier.text, + ); + if (resolved.kind === "resolved" && resolved.packageExport) { + const packageExport = resolved.packageExport; + const moduleSymbol = analyzed.value.checker.getSymbolAtLocation(node.moduleSpecifier); + for (const element of node.importClause.namedBindings.elements) { + if (element.isTypeOnly) continue; + const local = analyzed.value.checker.getSymbolAtLocation(element.name); + if (!local || !(local.flags & ts.SymbolFlags.Alias)) continue; + const actual = analyzed.value.checker.getAliasedSymbol(local); + const exportName = (element.propertyName ?? element.name).text; + const exported = + moduleSymbol && + analyzed.value.checker + .getExportsOfModule(moduleSymbol) + .find((item) => item.name === exportName); + if (!exported) continue; + const exportedActual = + exported.flags & ts.SymbolFlags.Alias + ? analyzed.value.checker.getAliasedSymbol(exported) + : exported; + if (exportedActual !== actual) continue; + if (!(actual.flags & ts.SymbolFlags.Value) || !actual.declarations?.length) continue; + const declarationSources = actual.declarations.map((declaration) => + declaration.getSourceFile(), + ); + if ( + !declarationSources.every((declarationSource) => { + const owner = analyzed.value.context.ownerFor(declarationSource); + return ( + owner?.kind === "package" && + owner.package.packageName === packageExport.packageName && + owner.package.packageVersion === packageExport.packageVersion && + owner.package.packageIntegrity === packageExport.packageIntegrity + ); + }) + ) + continue; + const declarationSource = [...declarationSources].sort((left, right) => { + const a = analyzed.value.context.relativeFileName(left)!; + const b = analyzed.value.context.relativeFileName(right)!; + return a < b ? -1 : a > b ? 1 : 0; + })[0]!; + const start = element.name.getStart(sourceFile); + const position = sourceFile.getLineAndCharacterOfPosition(start); + result.push({ + ...packageExport, + exportName, + declarationFile: analyzed.value.context.relativeFileName(declarationSource)!, + fileName: analyzed.value.context.displayFileName(sourceFile), + start, + end: element.name.getEnd(), + line: position.line + 1, + column: position.character + 1, + }); + } + } + } + ts.forEachChild(node, visit); + }; + ts.forEachChild(sourceFile, visit); + } + return result.sort(compare); +}; diff --git a/packages/presentation-compiler/src/resolution/typecheck-authoring-project.ts b/packages/presentation-compiler/src/resolution/typecheck-authoring-project.ts new file mode 100644 index 0000000..894c983 --- /dev/null +++ b/packages/presentation-compiler/src/resolution/typecheck-authoring-project.ts @@ -0,0 +1,120 @@ +import * as ts from "typescript"; + +import type { ParsedAuthoringProjectValue } from "../project/parse-authoring-project.js"; +import { virtualCompilerHostFor } from "./virtual-compiler-host.js"; +import { moduleSpecifiersFor, VirtualModuleContext } from "./virtual-module-context.js"; + +type AuthoringProjectDiagnostic = { + readonly code: string; + readonly fileName: string; + readonly message: string; + readonly start: number; + readonly end: number; + readonly line: number; + readonly column: number; + readonly typescriptCode?: number; +}; + +export type TypecheckedAuthoringProject = + | { readonly ok: true; readonly diagnostics: [] } + | { readonly ok: false; readonly diagnostics: readonly AuthoringProjectDiagnostic[] }; + +export type AnalyzedAuthoringProject = + | { readonly ok: false; readonly diagnostics: readonly AuthoringProjectDiagnostic[] } + | { + readonly ok: true; + readonly value: { + readonly program: ts.Program; + readonly checker: ts.TypeChecker; + readonly context: VirtualModuleContext; + readonly entrySourceFile: ts.SourceFile; + }; + readonly diagnostics: []; + }; + +const compareDiagnostics = (left: AuthoringProjectDiagnostic, right: AuthoringProjectDiagnostic) => + (left.fileName < right.fileName ? -1 : left.fileName > right.fileName ? 1 : 0) || + left.start - right.start || + left.end - right.end || + (left.code < right.code ? -1 : left.code > right.code ? 1 : 0) || + (left.typescriptCode ?? 0) - (right.typescriptCode ?? 0) || + (left.message < right.message ? -1 : left.message > right.message ? 1 : 0); + +const rangeFor = (sourceFile: ts.SourceFile, start: number, end: number) => { + const position = sourceFile.getLineAndCharacterOfPosition(start); + return { start, end, line: position.line + 1, column: position.character + 1 }; +}; + +export const analyzeAuthoringProject = ( + project: ParsedAuthoringProjectValue, +): AnalyzedAuthoringProject => { + const context = new VirtualModuleContext(project); + const diagnostics: AuthoringProjectDiagnostic[] = []; + for (const sourceFile of context.sourceFiles.values()) + for (const specifier of moduleSpecifiersFor(sourceFile)) { + const resolved = context.resolve(sourceFile.fileName, specifier.text); + if (resolved.kind === "resolved") continue; + const start = specifier.getStart(sourceFile) + 1; + diagnostics.push({ + code: resolved.code, + fileName: context.displayFileName(sourceFile), + message: resolved.message, + ...rangeFor(sourceFile, start, specifier.getEnd() - 1), + }); + } + if (diagnostics.length) return { ok: false, diagnostics: diagnostics.sort(compareDiagnostics) }; + + const program = ts.createProgram({ + rootNames: context.projectRootFiles, + options: { + jsx: ts.JsxEmit.Preserve, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + noEmit: true, + noLib: true, + strict: true, + target: ts.ScriptTarget.ES2022, + }, + host: virtualCompilerHostFor(context), + }); + for (const item of program.getSemanticDiagnostics()) { + if (!item.file) continue; + const start = item.start ?? 0; + diagnostics.push({ + code: "compiler-source-type-error", + fileName: context.displayFileName(item.file), + message: ts.flattenDiagnosticMessageText(item.messageText, "\n"), + ...rangeFor(item.file, start, start + (item.length ?? 0)), + typescriptCode: item.code, + }); + } + if (diagnostics.length) return { ok: false, diagnostics: diagnostics.sort(compareDiagnostics) }; + const entrySourceFile = project.files[project.entryFile]; + if (!entrySourceFile) + return { + ok: false, + diagnostics: [ + { + code: "compiler-project-entry-invariant-invalid", + fileName: "", + message: "Parsed project entry source is unavailable.", + start: 0, + end: 0, + line: 1, + column: 1, + }, + ], + }; + return { + ok: true, + value: { program, checker: program.getTypeChecker(), context, entrySourceFile }, + diagnostics: [], + }; +}; + +export const typecheckAuthoringProject = ( + project: ParsedAuthoringProjectValue, +): TypecheckedAuthoringProject => { + const analyzed = analyzeAuthoringProject(project); + return analyzed.ok ? { ok: true, diagnostics: [] } : analyzed; +}; diff --git a/packages/presentation-compiler/src/resolution/virtual-compiler-host.ts b/packages/presentation-compiler/src/resolution/virtual-compiler-host.ts new file mode 100644 index 0000000..ee528f8 --- /dev/null +++ b/packages/presentation-compiler/src/resolution/virtual-compiler-host.ts @@ -0,0 +1,23 @@ +import type * as ts from "typescript"; + +import { extensionFor, VirtualModuleContext } from "./virtual-module-context.js"; + +export const virtualCompilerHostFor = (context: VirtualModuleContext): ts.CompilerHost => ({ + fileExists: (fileName) => context.sourceFiles.has(fileName), + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: () => "", + getDefaultLibFileName: () => "", + getDirectories: () => [], + getNewLine: () => "\n", + getSourceFile: (fileName) => context.sourceFiles.get(fileName), + readFile: (fileName) => context.sourceFiles.get(fileName)?.text, + resolveModuleNames: (moduleNames, containingFile) => + moduleNames.map((specifier) => { + const resolved = context.resolve(containingFile, specifier); + return resolved.kind === "resolved" + ? { resolvedFileName: resolved.fileName, extension: extensionFor(resolved.fileName) } + : undefined; + }), + useCaseSensitiveFileNames: () => true, + writeFile: () => undefined, +}); diff --git a/packages/presentation-compiler/src/resolution/virtual-module-context.ts b/packages/presentation-compiler/src/resolution/virtual-module-context.ts new file mode 100644 index 0000000..87f3e49 --- /dev/null +++ b/packages/presentation-compiler/src/resolution/virtual-module-context.ts @@ -0,0 +1,244 @@ +import * as ts from "typescript"; + +import type { ParsedAuthoringProjectValue } from "../project/parse-authoring-project.js"; +import type { ParsedLockedPackage } from "../project/parse-locked-packages.js"; + +export type ModuleFailureCode = + | "compiler-module-root-escape" + | "compiler-module-unresolved" + | "compiler-module-package-unsupported" + | "compiler-module-deep-import-forbidden"; + +export type ModuleResolution = + | { + readonly kind: "resolved"; + readonly fileName: string; + readonly packageExport?: { + readonly packageName: string; + readonly packageVersion: string; + readonly packageIntegrity: string; + readonly subpath: string; + readonly targetFile: string; + }; + } + | { readonly kind: "failed"; readonly code: ModuleFailureCode; readonly message: string }; + +export type SourceOwner = + | { + readonly kind: "project"; + readonly files: Readonly>; + readonly display: (fileName: string) => string; + } + | { + readonly kind: "package"; + readonly package: ParsedLockedPackage; + readonly files: Readonly>; + readonly display: (fileName: string) => string; + }; + +const sourceExtensions = [".ts", ".tsx", ".d.ts"] as const; + +const sameIdentity = ( + left: { packageName: string; packageVersion: string; packageIntegrity: string }, + right: { packageName: string; packageVersion: string; packageIntegrity: string }, +) => + left.packageName === right.packageName && + left.packageVersion === right.packageVersion && + left.packageIntegrity === right.packageIntegrity; + +const moduleCandidates = (path: string) => { + if (sourceExtensions.some((extension) => path.endsWith(extension))) return [path]; + if (path.endsWith(".js")) { + const withoutJs = path.slice(0, -3); + return sourceExtensions.map((extension) => `${withoutJs}${extension}`); + } + return [ + path, + ...sourceExtensions.map((extension) => `${path}${extension}`), + ...sourceExtensions.map((extension) => `${path}/index${extension}`), + ]; +}; + +const isRelativeSpecifier = (specifier: string) => + specifier === "." || + specifier === ".." || + specifier.startsWith("./") || + specifier.startsWith("../"); + +const relativeModulePath = (containingFile: string, specifier: string): string | undefined => { + const segments = containingFile.split("/").slice(0, -1); + for (const segment of specifier.split("/")) { + if (segment === "" || segment === ".") continue; + if (segment === "..") { + if (segments.length === 0) return undefined; + segments.pop(); + } else segments.push(segment); + } + return segments.join("/"); +}; + +const parseBareSpecifier = (specifier: string) => { + if (specifier.startsWith("@")) { + const first = specifier.indexOf("/"); + const second = first === -1 ? -1 : specifier.indexOf("/", first + 1); + return first === -1 + ? { packageName: specifier, subpath: "." } + : { + packageName: second === -1 ? specifier : specifier.slice(0, second), + subpath: second === -1 ? "." : `.${specifier.slice(second)}`, + }; + } + const slash = specifier.indexOf("/"); + return slash === -1 + ? { packageName: specifier, subpath: "." } + : { packageName: specifier.slice(0, slash), subpath: `.${specifier.slice(slash)}` }; +}; + +export class VirtualModuleContext { + readonly sourceFiles = new Map(); + readonly projectRootFiles: string[] = []; + readonly #owners = new Map(); + readonly #relativeNames = new Map(); + readonly #packages = new Map(); + + constructor(private readonly project: ParsedAuthoringProjectValue) { + const projectOwner: SourceOwner = { + kind: "project", + files: project.files, + display: (fileName) => fileName, + }; + for (const [fileName, sourceFile] of Object.entries(project.files)) { + this.sourceFiles.set(sourceFile.fileName, sourceFile); + this.projectRootFiles.push(sourceFile.fileName); + this.#owners.set(sourceFile.fileName, projectOwner); + this.#relativeNames.set(sourceFile.fileName, fileName); + } + for (const pkg of project.packages) { + this.#packages.set(pkg.packageName, pkg); + const owner: SourceOwner = { + kind: "package", + package: pkg, + files: pkg.files, + display: (fileName) => `${pkg.packageName}@${pkg.packageVersion}/${fileName}`, + }; + for (const [fileName, sourceFile] of Object.entries(pkg.files)) { + this.sourceFiles.set(sourceFile.fileName, sourceFile); + this.#owners.set(sourceFile.fileName, owner); + this.#relativeNames.set(sourceFile.fileName, fileName); + } + } + } + + displayFileName(sourceFile: ts.SourceFile) { + const owner = this.#owners.get(sourceFile.fileName); + const relative = this.#relativeNames.get(sourceFile.fileName); + return owner === undefined || relative === undefined ? "" : owner.display(relative); + } + + ownerFor(sourceFile: ts.SourceFile) { + return this.#owners.get(sourceFile.fileName); + } + + relativeFileName(sourceFile: ts.SourceFile) { + return this.#relativeNames.get(sourceFile.fileName); + } + + resolve(containingFile: string, specifier: string): ModuleResolution { + const owner = this.#owners.get(containingFile); + const relativeFileName = this.#relativeNames.get(containingFile); + if (owner === undefined || relativeFileName === undefined) + return { + kind: "failed", + code: "compiler-module-unresolved", + message: "Virtual source owner is unavailable.", + }; + if (specifier.startsWith("/")) + return { + kind: "failed", + code: "compiler-module-root-escape", + message: "Relative import must remain inside its virtual owner.", + }; + if (isRelativeSpecifier(specifier)) { + const path = relativeModulePath(relativeFileName, specifier); + if (path === undefined) + return { + kind: "failed", + code: "compiler-module-root-escape", + message: "Relative import must remain inside its virtual owner.", + }; + const resolved = moduleCandidates(path).find( + (candidate) => owner.files[candidate] !== undefined, + ); + return resolved === undefined + ? { + kind: "failed", + code: "compiler-module-unresolved", + message: "Relative import must resolve inside its virtual owner.", + } + : { kind: "resolved", fileName: owner.files[resolved]!.fileName }; + } + const { packageName, subpath } = parseBareSpecifier(specifier); + const pkg = this.#packages.get(packageName); + const dependencies = + owner.kind === "project" ? this.project.packageDependencies : owner.package.dependencies; + if (pkg === undefined || !dependencies.some((dependency) => sameIdentity(dependency, pkg))) + return { + kind: "failed", + code: "compiler-module-package-unsupported", + message: "Bare import must name a direct locked dependency.", + }; + const exported = pkg.exports.find((entry) => entry.subpath === subpath); + if (exported === undefined) + return { + kind: "failed", + code: "compiler-module-deep-import-forbidden", + message: "Bare imports must resolve through an exact locked package export.", + }; + return { + kind: "resolved", + fileName: pkg.files[exported.targetFile]!.fileName, + packageExport: { + packageName: pkg.packageName, + packageVersion: pkg.packageVersion, + packageIntegrity: pkg.packageIntegrity, + subpath, + targetFile: exported.targetFile, + }, + }; + } +} + +export const moduleSpecifiersFor = (sourceFile: ts.SourceFile) => { + const specifiers: ts.StringLiteralLike[] = []; + const visit = (node: ts.Node): void => { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier && + ts.isStringLiteralLike(node.moduleSpecifier) + ) + specifiers.push(node.moduleSpecifier); + else if ( + ts.isImportTypeNode(node) && + ts.isLiteralTypeNode(node.argument) && + ts.isStringLiteralLike(node.argument.literal) + ) + specifiers.push(node.argument.literal); + else if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments[0] && + ts.isStringLiteralLike(node.arguments[0]) + ) + specifiers.push(node.arguments[0]); + ts.forEachChild(node, visit); + }; + ts.forEachChild(sourceFile, visit); + return specifiers; +}; + +export const extensionFor = (fileName: string) => + fileName.endsWith(".d.ts") + ? ts.Extension.Dts + : fileName.endsWith(".tsx") + ? ts.Extension.Tsx + : ts.Extension.Ts; diff --git a/packages/presentation-compiler/src/semantic/declaration-hashes.ts b/packages/presentation-compiler/src/semantic/declaration-hashes.ts new file mode 100644 index 0000000..52566cb --- /dev/null +++ b/packages/presentation-compiler/src/semantic/declaration-hashes.ts @@ -0,0 +1,92 @@ +import type { + ComponentManifest, + ComponentStructure, + FrameDeclaration, + Json, + OpaqueSurfaceSemanticAdapter, + SemanticNodeDeclaration, + SurfaceDeclaration, + SurfaceStateDeclaration, + ThemeDeclaration, +} from "@unframe/presentation"; +import { hashCanonicalJsonPayload } from "@unframe/presentation-core"; + +type JsonRecord = Readonly>; + +const withoutSource = (value: T): Omit => { + const { source: _source, ...semantic } = value; + return semantic; +}; + +const semanticNode = (value: SemanticNodeDeclaration) => withoutSource(value); + +const surfaceState = (value: SurfaceStateDeclaration) => ({ + ...withoutSource(value), + semanticOverrides: value.semanticOverrides.map((override) => withoutSource(override)), +}); + +const opaqueSurface = (value: OpaqueSurfaceSemanticAdapter): JsonRecord => ({ + ...withoutSource(value), + baseSemanticTree: { + ...value.baseSemanticTree, + nodes: Object.fromEntries( + Object.entries(value.baseSemanticTree.nodes).map(([id, node]) => [id, semanticNode(node)]), + ), + }, + states: Object.fromEntries( + Object.entries(value.states).map(([id, state]) => [id, surfaceState(state)]), + ), +}); + +const structureRoot = (value: SurfaceDeclaration | FrameDeclaration): JsonRecord => { + const semantic = withoutSource(value); + if (value.kind === "surface") + return { + ...semantic, + root: structureRoot(value.root), + baseSemanticTree: { + ...value.baseSemanticTree, + nodes: Object.fromEntries( + Object.entries(value.baseSemanticTree.nodes).map(([id, node]) => [ + id, + semanticNode(node), + ]), + ), + }, + states: Object.fromEntries( + Object.entries(value.states).map(([id, state]) => [id, surfaceState(state)]), + ), + }; + return { + ...semantic, + children: value.children.map((child) => + child.kind === "frame" ? structureRoot(child) : withoutSource(child), + ), + }; +}; + +/** Hashes a Theme declaration while retaining arbitrary token and named-style JSON verbatim. */ +export const hashThemeDeclaration = (declaration: ThemeDeclaration): string => + hashCanonicalJsonPayload(withoutSource(declaration)); + +/** Hashes a Component Manifest after excluding only declaration source metadata. */ +export const hashComponentManifestDeclaration = (declaration: ComponentManifest): string => { + if (declaration.authoring.mode !== "opaque") + return hashCanonicalJsonPayload(withoutSource(declaration)); + const opaque = declaration as Extract; + return hashCanonicalJsonPayload({ + ...withoutSource(opaque), + semantics: { + ...opaque.semantics, + surfaces: opaque.semantics.surfaces.map(opaqueSurface), + }, + }); +}; + +/** Hashes a Component Structure after excluding source metadata from declaration nodes. */ +export const hashComponentStructureDeclaration = (declaration: ComponentStructure): string => + hashCanonicalJsonPayload({ + ...withoutSource(declaration), + root: structureRoot(declaration.root), + timelines: declaration.timelines.map((timeline) => withoutSource(timeline)), + }); diff --git a/packages/presentation-compiler/src/syntax/parse-authoring-source.ts b/packages/presentation-compiler/src/syntax/parse-authoring-source.ts new file mode 100644 index 0000000..8deea54 --- /dev/null +++ b/packages/presentation-compiler/src/syntax/parse-authoring-source.ts @@ -0,0 +1,163 @@ +import * as ts from "typescript"; +import { z } from "zod"; + +import { safePlainClone } from "../validation/safe-plain-clone.js"; + +export type AuthoringSourceInput = { + readonly fileName: string; + readonly sourceText: string; +}; + +export type AuthoringSourceDiagnostic = { + readonly code: + | "compiler-invalid-input" + | "compiler-source-kind-unsupported" + | "compiler-source-syntax-error"; + readonly fileName: string; + readonly message: string; + readonly start: number; + readonly length: number; + readonly line: number; + readonly column: number; + readonly typescriptCode?: number; +}; + +export type ParsedAuthoringSource = + | { readonly ok: true; readonly value: ts.SourceFile; readonly diagnostics: [] } + | { readonly ok: false; readonly diagnostics: AuthoringSourceDiagnostic[] }; + +const scriptKindFor = (fileName: string) => { + if (fileName.endsWith(".tsx")) return ts.ScriptKind.TSX; + if (fileName.endsWith(".ts") || fileName.endsWith(".d.ts")) return ts.ScriptKind.TS; + return undefined; +}; + +const compareDiagnostics = (left: AuthoringSourceDiagnostic, right: AuthoringSourceDiagnostic) => + left.start - right.start || + (left.typescriptCode ?? 0) - (right.typescriptCode ?? 0) || + (left.message < right.message ? -1 : left.message > right.message ? 1 : 0); + +const compilerHostFor = (sourceFile: ts.SourceFile, sourceText: string): ts.CompilerHost => ({ + fileExists: (fileName) => fileName === sourceFile.fileName, + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: () => "", + getDefaultLibFileName: () => "lib.d.ts", + getDirectories: () => [], + getNewLine: () => "\n", + getSourceFile: (fileName) => (fileName === sourceFile.fileName ? sourceFile : undefined), + readFile: (fileName) => (fileName === sourceFile.fileName ? sourceText : undefined), + useCaseSensitiveFileNames: () => true, + writeFile: () => undefined, +}); + +const authoringSourceInputSchema = z + .object({ fileName: z.string(), sourceText: z.string() }) + .strict(); + +const hasAuthoringSourceKeys = (value: unknown) => + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.keys(value).length === 2 && + Object.keys(value).every((key) => key === "fileName" || key === "sourceText"); + +export const parseAuthoringSource = (input: unknown): ParsedAuthoringSource => { + const snapshot = safePlainClone(input); + if (!snapshot.valid) + return { + ok: false, + diagnostics: [ + { + code: "compiler-invalid-input", + fileName: "", + message: "Authoring source input cannot be inspected safely.", + start: 0, + length: 0, + line: 1, + column: 1, + }, + ], + }; + if (!hasAuthoringSourceKeys(snapshot.value)) + return { + ok: false, + diagnostics: [ + { + code: "compiler-source-kind-unsupported", + fileName: "", + message: "Authoring source input must contain a file name and source text.", + start: 0, + length: 0, + line: 1, + column: 1, + }, + ], + }; + const parsedInput = authoringSourceInputSchema.safeParse(snapshot.value); + if (!parsedInput.success) + return { + ok: false, + diagnostics: [ + { + code: "compiler-source-kind-unsupported", + fileName: "", + message: "Authoring source input must contain a file name and source text.", + start: 0, + length: 0, + line: 1, + column: 1, + }, + ], + }; + const { fileName, sourceText } = parsedInput.data; + const scriptKind = scriptKindFor(fileName); + if (scriptKind === undefined) + return { + ok: false, + diagnostics: [ + { + code: "compiler-source-kind-unsupported", + fileName, + message: "Authoring source must use a .ts, .tsx, or .d.ts file name.", + start: 0, + length: 0, + line: 1, + column: 1, + }, + ], + }; + + const sourceFile = ts.createSourceFile( + fileName, + sourceText, + ts.ScriptTarget.Latest, + true, + scriptKind, + ); + const program = ts.createProgram({ + rootNames: [fileName], + options: { jsx: ts.JsxEmit.Preserve, noLib: true, noResolve: true }, + host: compilerHostFor(sourceFile, sourceText), + }); + const diagnostics = program + .getSyntacticDiagnostics(sourceFile) + .map((item): AuthoringSourceDiagnostic => { + const start = item.start ?? 0; + const position = sourceFile.getLineAndCharacterOfPosition(start); + return { + code: "compiler-source-syntax-error", + fileName, + message: ts.flattenDiagnosticMessageText(item.messageText, "\n"), + start, + length: item.length ?? 0, + line: position.line + 1, + column: position.character + 1, + typescriptCode: item.code, + }; + }) + .sort(compareDiagnostics); + + return diagnostics.length === 0 + ? { ok: true, value: sourceFile, diagnostics: [] } + : { ok: false, diagnostics }; +}; diff --git a/packages/presentation-compiler/src/validation/project-schemas.ts b/packages/presentation-compiler/src/validation/project-schemas.ts new file mode 100644 index 0000000..614d547 --- /dev/null +++ b/packages/presentation-compiler/src/validation/project-schemas.ts @@ -0,0 +1,93 @@ +import { z } from "zod"; + +export const nonEmptyStringSchema = z.string().min(1); +export const plainRecordSchema = z.record(z.string(), z.unknown()); +export const declarationProjectEnvelopeSchema = z + .object({ + presentation: plainRecordSchema, + themes: z.array( + z.object({ declaration: plainRecordSchema, hash: nonEmptyStringSchema }).strict(), + ), + components: z.array( + z + .object({ + manifest: plainRecordSchema, + structure: plainRecordSchema, + lock: z + .object({ + packageVersion: nonEmptyStringSchema, + packageIntegrity: nonEmptyStringSchema, + manifestHash: nonEmptyStringSchema, + structureHash: nonEmptyStringSchema, + }) + .strict(), + }) + .strict(), + ), + assets: plainRecordSchema, + }) + .strict(); +export const declarationProjectFieldKeysSchema = z.array( + z.enum(["presentation", "themes", "components", "assets"]), +); +export const compilerBuildOptionsSchema = z + .object({ + compiler: z + .object({ + name: nonEmptyStringSchema, + version: nonEmptyStringSchema, + baseEnvironmentHash: nonEmptyStringSchema, + }) + .strict(), + locale: nonEmptyStringSchema, + timezone: nonEmptyStringSchema, + colorScheme: z.enum(["light", "dark"]), + pixelTarget: z.tuple([z.int().positive(), z.int().positive()]), + rendererConfigHash: nonEmptyStringSchema, + renderers: z.array(z.unknown()), + encodeLimits: z.object({}).passthrough(), + }) + .strict(); +export const diagnosticSchema = z.strictObject({ + code: z.string(), + path: z.array(z.union([z.string(), z.number()])), + message: z.string(), + relatedPath: z.array(z.union([z.string(), z.number()])).optional(), +}); +export const initialOwnerSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("presentation") }), + z.strictObject({ kind: z.literal("group"), groupId: nonEmptyStringSchema }), +]); +export const initialAudienceSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("all") }), + z.strictObject({ kind: z.literal("role"), role: z.enum(["presenter", "viewer"]) }), +]); +export const initialParentSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("stage") }), + z.strictObject({ kind: z.literal("node"), nodeId: nonEmptyStringSchema }), +]); +export const initialPresentationShapeSchema = z.object({ + metadata: z.object({ title: nonEmptyStringSchema }), + stage: z.object({ + coordinateSystem: z.strictObject({ + unit: z.literal("meter"), + handedness: z.literal("right"), + upAxis: z.literal("+Y"), + forwardAxis: z.literal("-Z"), + }), + }), + scene: z.object({ + spatial: z.array( + z.object({ + kind: z.literal("spatial"), + name: z.string(), + owner: initialOwnerSchema, + audience: initialAudienceSchema, + parent: initialParentSchema, + active: z.boolean(), + visible: z.boolean(), + }), + ), + }), + assets: z.array(z.strictObject({ kind: z.literal("asset-ref"), assetId: nonEmptyStringSchema })), +}); diff --git a/packages/presentation-compiler/src/validation/safe-build-options.ts b/packages/presentation-compiler/src/validation/safe-build-options.ts new file mode 100644 index 0000000..bf32ed7 --- /dev/null +++ b/packages/presentation-compiler/src/validation/safe-build-options.ts @@ -0,0 +1,64 @@ +import type { ValidationResult } from "@unframe/presentation-core"; +import { diagnostic } from "../diagnostics/diagnostics.js"; +import { safePlainClone } from "./safe-plain-clone.js"; + +const invalidOptions = (message: string): ValidationResult => ({ + valid: false, + diagnostics: [diagnostic("compiler-invalid-options", ["options"], message)], +}); + +const snapshotRenderers = (value: unknown): ValidationResult => { + if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) + return invalidOptions("Build renderers must be a plain dense array."); + try { + if ( + Object.getOwnPropertySymbols(value).length !== 0 || + Object.keys(value).length !== value.length || + Object.keys(value).some((key) => !/^(0|[1-9][0-9]*)$/.test(key)) + ) + return invalidOptions("Build renderers must be a plain dense array."); + const renderers: unknown[] = []; + for (let index = 0; index < value.length; index++) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || descriptor.get !== undefined || descriptor.set !== undefined) + return invalidOptions("Build renderer entries must not use accessors."); + renderers.push(descriptor.value); + } + return { valid: true, value: renderers, diagnostics: [] }; + } catch { + return invalidOptions("Build options could not be inspected safely."); + } +}; + +/** Snapshots data configuration without cloning caller-owned callable renderer plugins. */ +export const safeBuildOptionsSnapshot = (input: unknown): ValidationResult => { + try { + if ( + typeof input !== "object" || + input === null || + Array.isArray(input) || + Object.getPrototypeOf(input) !== Object.prototype + ) + return invalidOptions("Build options must be a complete explicit configuration."); + if (Object.getOwnPropertySymbols(input).length !== 0) + return invalidOptions("Build options must be a complete explicit configuration."); + const snapshot: Record = {}; + for (const key of Object.keys(input).sort()) { + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if (!descriptor || descriptor.get !== undefined || descriptor.set !== undefined) + return invalidOptions("Build options must not contain accessors."); + if (key === "renderers") { + const renderers = snapshotRenderers(descriptor.value); + if (!renderers.valid) return renderers; + snapshot[key] = renderers.value; + continue; + } + const cloned = safePlainClone(descriptor.value); + if (!cloned.valid) return invalidOptions("Build options must contain plain JSON data."); + snapshot[key] = cloned.value; + } + return { valid: true, value: snapshot, diagnostics: [] }; + } catch { + return invalidOptions("Build options could not be inspected safely."); + } +}; diff --git a/packages/presentation-compiler/src/validation/safe-plain-clone.ts b/packages/presentation-compiler/src/validation/safe-plain-clone.ts new file mode 100644 index 0000000..c1c9f36 --- /dev/null +++ b/packages/presentation-compiler/src/validation/safe-plain-clone.ts @@ -0,0 +1,105 @@ +import type { ValidationResult } from "@unframe/presentation-core"; +import { diagnostic, safelyIsDiagnostic } from "../diagnostics/diagnostics.js"; + +type UnknownRecord = Record; + +export const safePlainClone = (input: unknown): ValidationResult => { + const ancestors = new WeakSet(); + const walk = (value: unknown, path: readonly (string | number)[]): unknown => { + if (value === null || typeof value === "string" || typeof value === "boolean") return value; + if (typeof value === "number") { + if (Number.isFinite(value)) return value; + throw diagnostic("compiler-invalid-input", path, "Input must contain finite JSON numbers."); + } + if (typeof value !== "object") + throw diagnostic("compiler-invalid-input", path, "Input must contain plain JSON data."); + if (ancestors.has(value)) + throw diagnostic("compiler-invalid-input", path, "Input must not contain cycles."); + const prototype = Object.getPrototypeOf(value); + if ( + (Array.isArray(value) && prototype !== Array.prototype) || + (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) + ) + throw diagnostic("compiler-invalid-input", path, "Input objects must use a plain prototype."); + ancestors.add(value); + try { + if (Object.getOwnPropertySymbols(value).length !== 0) + throw diagnostic( + "compiler-invalid-input", + path, + "Input must not contain symbol properties.", + ); + if (Array.isArray(value)) { + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length"); + if ( + !lengthDescriptor || + lengthDescriptor.get !== undefined || + lengthDescriptor.set !== undefined + ) + throw diagnostic("compiler-invalid-input", path, "Input must not contain accessors."); + const length = lengthDescriptor.value; + if (!Number.isSafeInteger(length) || length < 0 || length > 0xffff_ffff) + throw diagnostic( + "compiler-invalid-input", + path, + "Input must contain valid array lengths.", + ); + if ( + Object.keys(value).length !== length || + Object.keys(value).some((key) => !/^(0|[1-9][0-9]*)$/.test(key)) || + [...Array(length).keys()].some((index) => !Object.hasOwn(value, index)) + ) + throw diagnostic("compiler-invalid-input", path, "Input must not contain sparse arrays."); + const cloned: unknown[] = []; + for (let index = 0; index < length; index++) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor || descriptor.get !== undefined || descriptor.set !== undefined) + throw diagnostic( + "compiler-invalid-input", + [...path, index], + "Input must not contain accessors.", + ); + cloned.push(walk(descriptor.value, [...path, index])); + } + return cloned; + } + if ( + Object.getOwnPropertyNames(value).some( + (key) => !Object.getOwnPropertyDescriptor(value, key)?.enumerable, + ) + ) + throw diagnostic( + "compiler-invalid-input", + path, + "Input must not contain non-enumerable properties.", + ); + const cloned = Object.create(null) as UnknownRecord; + for (const key of Object.keys(value).sort()) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || descriptor.get !== undefined || descriptor.set !== undefined) + throw diagnostic( + "compiler-invalid-input", + [...path, key], + "Input must not contain accessors.", + ); + Object.defineProperty(cloned, key, { + value: walk(descriptor.value, [...path, key]), + enumerable: true, + configurable: true, + writable: true, + }); + } + return cloned; + } finally { + ancestors.delete(value); + } + }; + try { + return { valid: true, value: walk(input, []), diagnostics: [] }; + } catch (error) { + const item = safelyIsDiagnostic(error) + ? error + : diagnostic("compiler-invalid-input", [], "Input cannot be inspected safely."); + return { valid: false, diagnostics: [item] }; + } +}; diff --git a/packages/presentation-compiler/test/assemble-declaration-project.test.ts b/packages/presentation-compiler/test/assemble-declaration-project.test.ts new file mode 100644 index 0000000..b4375a9 --- /dev/null +++ b/packages/presentation-compiler/test/assemble-declaration-project.test.ts @@ -0,0 +1,731 @@ +import { describe, expect, it } from "vitest"; +import { standardComponents } from "@unframe/presentation-components"; +import type { PresentationDeclaration } from "@unframe/presentation"; +import { + assembleDeclarationProject, + checkAuthoringProject, + hashComponentManifestDeclaration, + hashComponentStructureDeclaration, + hashThemeDeclaration, + type DeclarationProjectAssemblyInput, + type PairedAuthoringDeclarationCatalog, +} from "../src/index.js"; + +const presentation = (): PresentationDeclaration => ({ + id: "presentation", + metadata: { title: "Reference" }, + stage: { + coordinateSystem: { unit: "meter", handedness: "right", upAxis: "+Y", forwardAxis: "-Z" }, + size: [4, 3, 4], + }, + theme: { themeId: standardComponents.theme.id }, + scene: { + spatial: [ + { + id: "spatial", + kind: "spatial", + name: "Surface", + owner: { kind: "presentation" }, + audience: { kind: "all" }, + parent: { kind: "stage" }, + order: 0, + transform: { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] }, + active: true, + visible: true, + opacity: 1, + }, + ], + components: [ + { + id: "instance", + kind: "component-instance", + componentId: standardComponents.surface.manifest.componentId, + version: standardComponents.surface.manifest.version, + owner: { kind: "presentation" }, + spatialNodeId: "spatial", + packageLock: { + packageVersion: "1", + packageIntegrity: "integrity", + manifestHash: hashComponentManifestDeclaration(standardComponents.surface.manifest), + structureHash: hashComponentStructureDeclaration(standardComponents.surface.structure), + }, + props: {}, + slots: {}, + variants: {}, + partOverrides: [], + }, + ], + }, + assets: [], + flow: { + initialGroupId: "group", + groups: { + group: { id: "group", initialStepId: "step", steps: { step: { id: "step", cues: [] } } }, + }, + variables: {}, + }, + operations: [], +}); + +const additionalTheme = { ...standardComponents.theme, id: "theme-z" }; +const additionalManifest = { + ...standardComponents.surface.manifest, + componentId: "surface-z", + version: 2, + authoring: { mode: "structured" as const, structure: "./surface-z.structure.ts" }, +}; +const additionalStructure = { + ...standardComponents.surface.structure, + componentId: "surface-z", +}; + +const origin = (fileName: string) => ({ fileName, start: 0, end: 0, line: 1, column: 1 }); +const wrapper = ( + role: "presentation" | "theme" | "component-manifest" | "component-structure", + fileName: string, + rootBuilder: + | "definePresentation" + | "defineTheme" + | "defineComponentManifest" + | "defineComponentStructure", + value: T, +) => ({ role, fileName, rootBuilder, value, sourceMap: [{ path: [], origin: origin(fileName) }] }); + +const catalog = (includeAdditional = false): PairedAuthoringDeclarationCatalog => + ({ + presentation: wrapper("presentation", "presentation.ts", "definePresentation", presentation()), + themes: [ + wrapper("theme", "theme.ts", "defineTheme", standardComponents.theme), + ...(includeAdditional + ? [wrapper("theme", "theme-z.ts", "defineTheme", additionalTheme)] + : []), + ], + components: [ + { + manifest: wrapper( + "component-manifest", + "standard-surface.manifest.ts", + "defineComponentManifest", + standardComponents.surface.manifest, + ), + structure: wrapper( + "component-structure", + "standard-surface.structure.ts", + "defineComponentStructure", + standardComponents.surface.structure, + ), + }, + ...(includeAdditional + ? [ + { + manifest: wrapper( + "component-manifest", + "surface-z.manifest.ts", + "defineComponentManifest", + additionalManifest, + ), + structure: wrapper( + "component-structure", + "surface-z.structure.ts", + "defineComponentStructure", + additionalStructure, + ), + }, + ] + : []), + ], + }) as unknown as PairedAuthoringDeclarationCatalog; + +const input = (includeAdditional = false): DeclarationProjectAssemblyInput => ({ + catalog: catalog(includeAdditional), + themeHashes: [ + { themeId: standardComponents.theme.id, hash: hashThemeDeclaration(standardComponents.theme) }, + ], + componentLocks: [ + { + componentId: standardComponents.surface.manifest.componentId, + version: standardComponents.surface.manifest.version, + lock: { + packageVersion: "1", + packageIntegrity: "integrity", + manifestHash: hashComponentManifestDeclaration(standardComponents.surface.manifest), + structureHash: hashComponentStructureDeclaration(standardComponents.surface.structure), + }, + }, + ], + ...(includeAdditional + ? { + themeHashes: [ + { + themeId: standardComponents.theme.id, + hash: hashThemeDeclaration(standardComponents.theme), + }, + { themeId: "theme-z", hash: hashThemeDeclaration(additionalTheme) }, + ], + componentLocks: [ + { + componentId: standardComponents.surface.manifest.componentId, + version: standardComponents.surface.manifest.version, + lock: { + packageVersion: "1", + packageIntegrity: "integrity", + manifestHash: hashComponentManifestDeclaration(standardComponents.surface.manifest), + structureHash: hashComponentStructureDeclaration( + standardComponents.surface.structure, + ), + }, + }, + { + componentId: "surface-z", + version: 2, + lock: { + packageVersion: "2", + packageIntegrity: "integrity-z", + manifestHash: hashComponentManifestDeclaration(additionalManifest), + structureHash: hashComponentStructureDeclaration(additionalStructure), + }, + }, + ], + } + : {}), + assets: {}, +}); + +const codes = (value: unknown) => { + const result = assembleDeclarationProject(value); + return result.valid ? [] : result.diagnostics.map((item) => item.code); +}; + +describe("assembleDeclarationProject", () => { + it("assembles a plain canonical declaration envelope without source-map wrappers", () => { + const result = assembleDeclarationProject(input()); + expect(result.valid).toBe(true); + if (!result.valid) return; + expect(result.value).toEqual({ + presentation: presentation(), + themes: [ + { + declaration: standardComponents.theme, + hash: hashThemeDeclaration(standardComponents.theme), + }, + ], + components: [ + { + manifest: standardComponents.surface.manifest, + structure: standardComponents.surface.structure, + lock: { + packageVersion: "1", + packageIntegrity: "integrity", + manifestHash: hashComponentManifestDeclaration(standardComponents.surface.manifest), + structureHash: hashComponentStructureDeclaration(standardComponents.surface.structure), + }, + }, + ], + assets: {}, + }); + expect(JSON.stringify(result.value)).not.toContain("sourceMap"); + }); + + it("is canonical when catalog and carrier order differ", () => { + const first = input(true); + const second = { + ...input(true), + catalog: { + ...catalog(true), + themes: [...catalog(true).themes].reverse(), + components: [...catalog(true).components].reverse(), + }, + themeHashes: [...input(true).themeHashes].reverse(), + componentLocks: [...input(true).componentLocks].reverse(), + }; + expect(assembleDeclarationProject(second)).toEqual(assembleDeclarationProject(first)); + }); + + it("hashes declaration semantics without admitting declaration locations as content", () => { + const themeWithDifferentLocation = { + ...standardComponents.theme, + source: { file: "another-theme.ts", range: [4, 9] as const }, + }; + expect(hashThemeDeclaration(themeWithDifferentLocation)).toBe( + hashThemeDeclaration(standardComponents.theme), + ); + expect( + hashThemeDeclaration({ + ...standardComponents.theme, + tokens: { source: "token-content" }, + }), + ).not.toBe(hashThemeDeclaration(standardComponents.theme)); + + const manifestWithDifferentLocation = { + ...standardComponents.surface.manifest, + source: { file: "another-manifest.ts" }, + }; + expect(hashComponentManifestDeclaration(manifestWithDifferentLocation)).toBe( + hashComponentManifestDeclaration(standardComponents.surface.manifest), + ); + + const structureWithDifferentLocations = { + ...standardComponents.surface.structure, + source: { file: "another-structure.ts" }, + root: { + ...standardComponents.surface.structure.root, + source: { file: "another-root.ts" }, + }, + }; + expect(hashComponentStructureDeclaration(structureWithDifferentLocations)).toBe( + hashComponentStructureDeclaration(standardComponents.surface.structure), + ); + }); + + it("rejects declaration hashes that do not match the paired lock", () => { + const themeMismatch = { + ...input(), + themeHashes: [{ ...input().themeHashes[0]!, hash: "sha256:0" }], + }; + const manifestMismatch = { + ...input(), + componentLocks: input().componentLocks.map((entry) => ({ + ...entry, + lock: { ...entry.lock, manifestHash: "sha256:0" }, + })), + }; + const structureMismatch = { + ...input(), + componentLocks: input().componentLocks.map((entry) => ({ + ...entry, + lock: { ...entry.lock, structureHash: "sha256:0" }, + })), + }; + expect(codes(themeMismatch)).toContain("compiler-theme-hash-mismatch"); + expect(codes(manifestMismatch)).toContain("compiler-component-manifest-hash-mismatch"); + expect(codes(structureMismatch)).toContain("compiler-component-structure-hash-mismatch"); + }); + + it("reports hash mismatches at the matching carrier entries after carrier reordering", () => { + const source = input(true); + const reordered = { + ...source, + themeHashes: source.themeHashes + .map((entry) => + entry.themeId === "theme-z" ? { ...entry, hash: "sha256:theme-mismatch" } : entry, + ) + .reverse(), + componentLocks: source.componentLocks + .map((entry) => + entry.componentId === "surface-z" + ? { ...entry, lock: { ...entry.lock, structureHash: "sha256:structure-mismatch" } } + : entry, + ) + .reverse(), + }; + const result = assembleDeclarationProject(reordered); + expect(result.valid).toBe(false); + if (!result.valid) + expect(result.diagnostics.map(({ code, path }) => ({ code, path }))).toEqual([ + { + code: "compiler-component-structure-hash-mismatch", + path: ["componentLocks", 0, "lock", "structureHash"], + }, + { code: "compiler-theme-hash-mismatch", path: ["themeHashes", 0] }, + ]); + }); + + it("excludes declaration locations inside opaque semantic surfaces from manifest hashes", () => { + const opaque = { + ...standardComponents.surface.manifest, + authoring: { mode: "opaque" as const }, + renderers: { "baked-web": { entry: "renderer.ts", bindingKeys: [] } }, + semantics: { + targets: [], + surfaces: [ + { + id: "surface", + bindingKey: "surface", + source: { file: "first-surface.ts" }, + baseSemanticTree: { + rootNodeIds: ["node"], + nodes: { + node: { + id: "node", + parentId: null, + order: 0, + role: "paragraph" as const, + source: { file: "first-node.ts" }, + }, + }, + }, + interactions: {}, + initialStateId: "state", + states: { + state: { + id: "state", + source: { file: "first-state.ts" }, + semanticOverrides: [ + { + id: "override", + kind: "semantic-override" as const, + targetId: "node", + source: { file: "first-override.ts" }, + }, + ], + enabledInteractionIds: [], + }, + }, + }, + ], + }, + } as const; + const relocated = { + ...opaque, + semantics: { + ...opaque.semantics, + surfaces: [ + { + ...opaque.semantics.surfaces[0], + source: { file: "second-surface.ts" }, + baseSemanticTree: { + ...opaque.semantics.surfaces[0].baseSemanticTree, + nodes: { + node: { + ...opaque.semantics.surfaces[0].baseSemanticTree.nodes.node, + source: { file: "second-node.ts" }, + }, + }, + }, + states: { + state: { + ...opaque.semantics.surfaces[0].states.state, + source: { file: "second-state.ts" }, + semanticOverrides: [ + { + ...opaque.semantics.surfaces[0].states.state.semanticOverrides[0], + source: { file: "second-override.ts" }, + }, + ], + }, + }, + }, + ], + }, + } as const; + expect(hashComponentManifestDeclaration(relocated)).toBe( + hashComponentManifestDeclaration(opaque), + ); + }); + + it("orders asset carrier keys canonically", () => { + const withAssetReferences = () => { + const result = catalog() as unknown as { + presentation: { value: PresentationDeclaration }; + }; + result.presentation.value = { + ...result.presentation.value, + assets: [ + { kind: "asset-ref", assetId: "asset-a" }, + { kind: "asset-ref", assetId: "asset-b" }, + ], + }; + return result; + }; + const first = { + ...input(), + catalog: withAssetReferences(), + assets: { + "asset-a": { id: "asset-a", mediaType: "image/png", checksum: "checksum-a" }, + "asset-b": { id: "asset-b", mediaType: "image/png", checksum: "checksum-b" }, + }, + }; + const second = { + ...input(), + catalog: withAssetReferences(), + assets: { + "asset-b": { id: "asset-b", mediaType: "image/png", checksum: "checksum-b" }, + "asset-a": { id: "asset-a", mediaType: "image/png", checksum: "checksum-a" }, + }, + }; + const firstResult = assembleDeclarationProject(first); + const secondResult = assembleDeclarationProject(second); + expect(firstResult.valid).toBe(true); + expect(secondResult.valid).toBe(true); + if (!firstResult.valid || !secondResult.valid) return; + expect(JSON.stringify(secondResult.value)).toBe(JSON.stringify(firstResult.value)); + }); + + it("fails closed for missing, extra, duplicate, and mismatched carriers", () => { + const missing = { ...input(), themeHashes: [] }; + const extra = { + ...input(), + themeHashes: [...input().themeHashes, { themeId: "extra", hash: "extra" }], + }; + const duplicate = { + ...input(), + componentLocks: [...input().componentLocks, { ...input().componentLocks[0]! }], + }; + const mismatch = { + ...input(), + componentLocks: input().componentLocks.map((entry) => ({ ...entry, version: 2 })), + }; + expect(codes(missing)).toContain("compiler-theme-hash-missing"); + expect(codes(extra)).toContain("compiler-theme-hash-extra"); + expect(codes(duplicate)).toContain("compiler-component-lock-duplicate"); + expect(codes(mismatch)).toContain("compiler-component-lock-identity-mismatch"); + + const themeDuplicate = { + ...input(), + themeHashes: [...input().themeHashes, { ...input().themeHashes[0]! }], + }; + const componentMissing = { ...input(), componentLocks: [] }; + const componentExtra = { + ...input(), + componentLocks: [ + ...input().componentLocks, + { + componentId: "extra", + version: 1, + lock: { + packageVersion: "1", + packageIntegrity: "extra", + manifestHash: "extra", + structureHash: "extra", + }, + }, + ], + }; + expect(codes(themeDuplicate)).toContain("compiler-theme-hash-duplicate"); + expect(codes(componentMissing)).toContain("compiler-component-lock-missing"); + expect(codes(componentExtra)).toContain("compiler-component-lock-extra"); + }); + + it("does not execute hostile accessors or proxies", () => { + let reads = 0; + const hostile = input(); + Object.defineProperty(hostile, "themeHashes", { + enumerable: true, + get() { + reads += 1; + throw new Error("must not run"); + }, + }); + expect(codes(hostile)).toContain("compiler-invalid-input"); + expect(reads).toBe(0); + expect( + codes( + new Proxy(input(), { + ownKeys: () => { + throw new Error("must not run"); + }, + }), + ), + ).toContain("compiler-invalid-input"); + }); + + it("rejects a forged Structure wrapper that does not match authoring.structure", () => { + const forgedCatalog = catalog() as unknown as { + components: [{ structure: { fileName: string } }]; + }; + forgedCatalog.components[0].structure.fileName = "forged.structure.ts"; + const result = assembleDeclarationProject({ ...input(), catalog: forgedCatalog }); + expect(result).toMatchObject({ + valid: false, + diagnostics: [ + { + code: "compiler-component-structure-path-mismatch", + path: ["catalog", "components", 0, "structure", "fileName"], + }, + ], + }); + }); + + it("rejects an unused opaque Component forged into the catalog", () => { + const forgedCatalog = catalog() as unknown as { + components: [{ manifest: { value: Record } }]; + }; + forgedCatalog.components[0].manifest.value = { + ...forgedCatalog.components[0].manifest.value, + authoring: { mode: "opaque" }, + renderers: {}, + semantics: { targets: [], surfaces: [] }, + }; + expect(codes({ ...input(), catalog: forgedCatalog })).toContain( + "compiler-opaque-component-unsupported", + ); + }); + + it("requires a complete component lock before checking the resulting project", () => { + const value = input(); + delete (value.componentLocks[0]!.lock as { structureHash?: string }).structureHash; + expect(codes(value)).toContain("compiler-invalid-component-lock-entry"); + }); + + it("returns every malformed carrier diagnostic in canonical order", () => { + const baseCatalog = catalog() as unknown as Record; + const malformed = { + ...input(), + catalog: { + ...baseCatalog, + presentation: { + ...(baseCatalog.presentation as Record), + sourceMap: [ + { + path: [], + origin: { fileName: "presentation.ts", start: -1, end: 0, line: 1, column: 1 }, + }, + ], + }, + themes: [ + { + ...(baseCatalog.themes as Record[])[0]!, + rootBuilder: "definePresentation", + }, + ], + }, + themeHashes: [{ themeId: "", hash: "" }], + componentLocks: [ + { + componentId: "", + version: 0, + lock: { packageVersion: "", packageIntegrity: "", manifestHash: "", structureHash: "" }, + }, + ], + assets: { broken: { id: "", mediaType: "", checksum: "" } }, + }; + const result = assembleDeclarationProject(malformed); + expect(result.valid).toBe(false); + if (result.valid) return; + expect(result.diagnostics.map(({ code, path }) => ({ code, path }))).toEqual([ + { code: "compiler-invalid-asset", path: ["assets", "broken"] }, + { code: "compiler-invalid-catalog-presentation", path: ["catalog", "presentation"] }, + { code: "compiler-invalid-catalog-theme-entry", path: ["catalog", "themes", 0] }, + { code: "compiler-invalid-component-lock-entry", path: ["componentLocks", 0] }, + { code: "compiler-invalid-theme-hash-entry", path: ["themeHashes", 0] }, + ]); + }); + + it("reuses declaration-project diagnostics for unresolved asset references", () => { + const unresolved = input(); + const projectCatalog = catalog() as unknown as { + presentation: { value: PresentationDeclaration }; + }; + projectCatalog.presentation.value = { + ...projectCatalog.presentation.value, + assets: [{ kind: "asset-ref", assetId: "missing" }], + }; + expect(codes({ ...unresolved, catalog: projectCatalog })).toContain("compiler-asset-not-found"); + }); + + it("accepts a checked virtual-source catalog and removes its source-map wrappers", () => { + const builders = [ + "definePresentation", + "defineTheme", + "defineComponentManifest", + "defineComponentStructure", + ] + .map((name) => `export const ${name} = (...args: unknown[]) => { throw 0; };`) + .join("\n"); + const source = { + projectRoot: "/virtual/presentation", + entryFile: "entry.ts", + files: [ + { + fileName: "entry.ts", + sourceText: `import { definePresentation } from "@unframe/presentation"; +export default definePresentation({ + id: "presentation", metadata: { title: "Presentation" }, + stage: { coordinateSystem: { unit: "meter", handedness: "right", upAxis: "+Y", forwardAxis: "-Z" }, size: [1, 1, 1] }, + theme: { themeId: "theme" }, + scene: { + spatial: [{ id: "spatial", kind: "spatial", name: "Surface", owner: { kind: "presentation" }, audience: { kind: "all" }, parent: { kind: "stage" }, order: 0, transform: { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] }, active: true, visible: true, opacity: 1 }], + components: [{ id: "instance", kind: "component-instance", componentId: "surface", version: 1, owner: { kind: "presentation" }, spatialNodeId: "spatial", packageLock: { packageVersion: "1", packageIntegrity: "integrity", manifestHash: "manifest", structureHash: "structure" }, props: {}, slots: {}, variants: {}, partOverrides: [] }] + }, assets: [], + flow: { initialGroupId: "group", groups: { group: { id: "group", initialStepId: "step", steps: { step: { id: "step", cues: [] } } } }, variables: {} }, operations: [] +});`, + }, + { + fileName: "theme.unframe.ts", + sourceText: `import { defineTheme } from "@unframe/presentation"; +export default defineTheme({ id: "theme", tokens: {}, namedStyles: {} });`, + }, + { + fileName: "surface.manifest.ts", + sourceText: `import { defineComponentManifest } from "@unframe/presentation"; +export default defineComponentManifest({ componentId: "surface", version: 1, authoring: { mode: "structured", structure: "./surface.structure.tsx" }, props: {}, slots: {}, parts: {}, variants: {}, states: { default: { kind: "state", initial: true } }, actions: {}, outputs: {}, renderers: ["baked-web"] });`, + }, + { + fileName: "surface.structure.tsx", + sourceText: `import { defineComponentStructure } from "@unframe/presentation"; +export default defineComponentStructure({ + id: "surface-structure", componentId: "surface", + root: { + id: "surface-root", kind: "surface", physicalSizeMeters: [1, 1], logicalSize: [1, 1], fit: "contain", + root: { id: "frame-root", kind: "frame", layout: { kind: "absolute", x: 0, y: 0, width: 1, height: 1 }, children: [{ id: "text", kind: "text", value: "", layout: { kind: "absolute", x: 0, y: 0, width: 1, height: 1 } }] }, + baseSemanticTree: { rootNodeIds: ["semantic-text"], nodes: { "semantic-text": { id: "semantic-text", parentId: null, order: 0, role: "paragraph", text: "" } } }, + interactions: {}, initialStateId: "default", states: { default: { id: "default", semanticOverrides: [], enabledInteractionIds: [] } }, + renderIntent: { updateModel: "static", interaction: "none", internalAnimation: "none", rendererPreference: "baked-web", fallbackPolicy: "reject" } + }, partBindings: {}, slotPlacements: {}, timelines: [] +});`, + }, + ], + packageDependencies: [ + { + packageName: "@unframe/presentation", + packageVersion: "1", + packageIntegrity: "integrity", + }, + ], + packages: [ + { + packageName: "@unframe/presentation", + packageVersion: "1", + packageIntegrity: "integrity", + files: [{ fileName: "index.ts", sourceText: builders }], + exports: [{ subpath: ".", targetFile: "index.ts" }], + dependencies: [], + }, + ], + }; + const checked = checkAuthoringProject(source); + expect(checked.valid).toBe(true); + if (!checked.valid) return; + const component = checked.value.components[0]!; + const manifestHash = hashComponentManifestDeclaration(component.manifest.value); + const structureHash = hashComponentStructureDeclaration(component.structure.value); + const checkedCatalog = checked.value as unknown as { + presentation: { value: PresentationDeclaration }; + }; + checkedCatalog.presentation.value = { + ...checkedCatalog.presentation.value, + scene: { + ...checkedCatalog.presentation.value.scene, + components: checkedCatalog.presentation.value.scene.components.map((instance) => ({ + ...instance, + packageLock: { + ...instance.packageLock, + manifestHash, + structureHash, + }, + })), + }, + }; + const result = assembleDeclarationProject({ + catalog: checkedCatalog, + themeHashes: [ + { themeId: "theme", hash: hashThemeDeclaration(checked.value.themes[0]!.value) }, + ], + componentLocks: [ + { + componentId: "surface", + version: 1, + lock: { + packageVersion: "1", + packageIntegrity: "integrity", + manifestHash, + structureHash, + }, + }, + ], + assets: {}, + }); + expect(result.valid).toBe(true); + if (result.valid) expect(JSON.stringify(result.value)).not.toContain("sourceMap"); + }); +}); diff --git a/packages/presentation-compiler/test/authoring-project-pipeline.test.ts b/packages/presentation-compiler/test/authoring-project-pipeline.test.ts new file mode 100644 index 0000000..8267557 --- /dev/null +++ b/packages/presentation-compiler/test/authoring-project-pipeline.test.ts @@ -0,0 +1,408 @@ +import { describe, expect, it } from "vitest"; +import { PNG_ABSOLUTE_LIMITS } from "@unframe/presentation-assets"; +import { + createRendererFingerprint, + evaluateFirstMilestoneSupport, + type RendererPlugin, +} from "@unframe/presentation-renderer-api"; +import { + checkAuthoringProjectAssembly, + checkAuthoringProject, + compileAuthoringProject, + hashComponentManifestDeclaration, + hashComponentStructureDeclaration, + hashThemeDeclaration, + type DeclarationProjectAssemblyCarrier, +} from "../src/index.js"; + +const builders = [ + "definePresentation", + "defineTheme", + "defineComponentManifest", + "defineComponentStructure", +] + .map((name) => `export const ${name} = (...args: unknown[]) => { throw 0; };`) + .join("\n"); + +const entrySource = `import { definePresentation } from "@unframe/presentation"; +export default definePresentation({ + id: "presentation", metadata: { title: "Pipeline" }, + stage: { coordinateSystem: { unit: "meter", handedness: "right", upAxis: "+Y", forwardAxis: "-Z" }, size: [1, 1, 1] }, + theme: { themeId: "theme" }, + scene: { + spatial: [{ id: "spatial", kind: "spatial", name: "Surface", owner: { kind: "presentation" }, audience: { kind: "all" }, parent: { kind: "stage" }, order: 0, transform: { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] }, active: true, visible: true, opacity: 1 }], + components: [{ id: "instance", kind: "component-instance", componentId: "surface", version: 1, owner: { kind: "presentation" }, spatialNodeId: "spatial", packageLock: { packageVersion: "1", packageIntegrity: "integrity", manifestHash: "__MANIFEST_HASH__", structureHash: "__STRUCTURE_HASH__" }, props: {}, slots: {}, variants: {}, partOverrides: [] }] + }, assets: [], + flow: { initialGroupId: "group", groups: { group: { id: "group", initialStepId: "step", steps: { step: { id: "step", cues: [] } } } }, variables: {} }, operations: [] +});`; + +const themeSource = `import { defineTheme } from "@unframe/presentation"; +export default defineTheme({ id: "theme", tokens: {}, namedStyles: {} });`; + +const extraThemeSource = `import { defineTheme } from "@unframe/presentation"; +export default defineTheme({ id: "theme-z", tokens: {}, namedStyles: {} });`; + +const manifestSource = `import { defineComponentManifest } from "@unframe/presentation"; +export default defineComponentManifest({ componentId: "surface", version: 1, authoring: { mode: "structured", structure: "./surface.structure.tsx" }, props: {}, slots: {}, parts: {}, variants: {}, states: { default: { kind: "state", initial: true } }, actions: {}, outputs: {}, renderers: ["baked-web"] });`; + +const extraManifestSource = `import { defineComponentManifest } from "@unframe/presentation"; +export default defineComponentManifest({ componentId: "surface-z", version: 2, authoring: { mode: "structured", structure: "./surface-z.structure.tsx" }, props: {}, slots: {}, parts: {}, variants: {}, states: { default: { kind: "state", initial: true } }, actions: {}, outputs: {}, renderers: ["baked-web"] });`; + +const structureSource = ( + componentId: string, + structureId: string, +) => `import { defineComponentStructure } from "@unframe/presentation"; +export default defineComponentStructure({ + id: "${structureId}", componentId: "${componentId}", + root: { + id: "${structureId}-root", kind: "surface", physicalSizeMeters: [1, 1], logicalSize: [1, 1], fit: "contain", + root: { id: "${structureId}-frame", kind: "frame", layout: { kind: "absolute", x: 0, y: 0, width: 1, height: 1 }, children: [{ id: "text", kind: "text", value: "", layout: { kind: "absolute", x: 0, y: 0, width: 1, height: 1 } }] }, + baseSemanticTree: { rootNodeIds: ["semantic-text"], nodes: { "semantic-text": { id: "semantic-text", parentId: null, order: 0, role: "paragraph", text: "" } } }, + interactions: {}, initialStateId: "default", states: { default: { id: "default", semanticOverrides: [], enabledInteractionIds: [] } }, + renderIntent: { updateModel: "static", interaction: "none", internalAnimation: "none", rendererPreference: "baked-web", fallbackPolicy: "reject" } + }, partBindings: {}, slotPlacements: {}, timelines: [] +});`; + +type VirtualFile = { readonly fileName: string; readonly sourceText: string }; + +const baseProject = (files: readonly VirtualFile[]) => ({ + projectRoot: "/virtual/pipeline", + entryFile: "entry.ts", + files, + packageDependencies: [ + { packageName: "@unframe/presentation", packageVersion: "1", packageIntegrity: "integrity" }, + ], + packages: [ + { + packageName: "@unframe/presentation", + packageVersion: "1", + packageIntegrity: "integrity", + files: [{ fileName: "index.ts", sourceText: builders }], + exports: [{ subpath: ".", targetFile: "index.ts" }], + dependencies: [], + }, + ], +}); + +const sourceFiles = (): readonly VirtualFile[] => [ + { fileName: "entry.ts", sourceText: entrySource }, + { fileName: "theme.unframe.ts", sourceText: themeSource }, + { fileName: "theme-z.unframe.ts", sourceText: extraThemeSource }, + { fileName: "surface.manifest.ts", sourceText: manifestSource }, + { fileName: "surface.structure.tsx", sourceText: structureSource("surface", "surface") }, + { fileName: "surface-z.manifest.ts", sourceText: extraManifestSource }, + { + fileName: "surface-z.structure.tsx", + sourceText: structureSource("surface-z", "surface-z"), + }, +]; + +const virtualProject = (files?: readonly VirtualFile[]) => { + if (files !== undefined) return baseProject(files); + const unbound = baseProject(sourceFiles()); + const catalog = checkAuthoringProject(unbound); + if (!catalog.valid) throw new Error("Test authoring project must parse."); + const component = catalog.value.components.find( + (item) => item.manifest.value.componentId === "surface", + )!; + const manifestHash = hashComponentManifestDeclaration(component.manifest.value); + const structureHash = hashComponentStructureDeclaration(component.structure.value); + return baseProject( + sourceFiles().map((file) => + file.fileName === "entry.ts" + ? { + ...file, + sourceText: file.sourceText + .replace("__MANIFEST_HASH__", manifestHash) + .replace("__STRUCTURE_HASH__", structureHash), + } + : file, + ), + ); +}; + +const carrier = (): DeclarationProjectAssemblyCarrier => { + const catalog = checkAuthoringProject(virtualProject()); + if (!catalog.valid) throw new Error("Test authoring project must parse."); + return { + themeHashes: catalog.value.themes.map((theme) => ({ + themeId: theme.value.id, + hash: hashThemeDeclaration(theme.value), + })), + componentLocks: catalog.value.components.map((component) => ({ + componentId: component.manifest.value.componentId, + version: component.manifest.value.version, + lock: { + packageVersion: component.manifest.value.componentId === "surface" ? "1" : "2", + packageIntegrity: + component.manifest.value.componentId === "surface" ? "integrity" : "integrity-z", + manifestHash: hashComponentManifestDeclaration(component.manifest.value), + structureHash: hashComponentStructureDeclaration(component.structure.value), + }, + })), + assets: {}, + }; +}; + +const options = (renderer: RendererPlugin) => ({ + compiler: { name: "unframe", version: "1", baseEnvironmentHash: "environment" }, + locale: "ja-JP", + timezone: "Asia/Tokyo", + colorScheme: "dark" as const, + pixelTarget: [2, 2] as const, + rendererConfigHash: "renderer-config", + renderers: [renderer], + encodeLimits: PNG_ABSOLUTE_LIMITS, +}); + +const diagnosticCodes = (result: { + readonly valid: false; + readonly diagnostics: readonly { code: string }[]; +}) => result.diagnostics.map(({ code }) => code); + +const makeRenderer = (calls?: { count: number }): RendererPlugin => { + const identity = { + id: "baked-web", + version: "1", + contractVersion: "1", + implementationHash: "renderer-implementation", + } as const; + const capabilities = { + inputKinds: ["structured"] as const, + updateModels: ["static"] as const, + interactions: ["none"] as const, + internalAnimations: ["none"] as const, + rendererPreferences: ["baked-web"] as const, + fallbackPolicies: ["reject"] as const, + deterministic: true as const, + }; + return { + identity, + capabilities, + support: evaluateFirstMilestoneSupport, + build: (input) => { + if (calls) calls.count += 1; + const [width, height] = input.context.pixelTarget; + return { + ok: true as const, + renderSurface: { + id: input.plan.id, + semanticSurfaceId: input.plan.semanticSurfaceId, + logicalBounds: input.plan.logicalBounds, + layer: input.plan.layer, + }, + captures: Object.keys(input.plan.states).map((stateId) => ({ + id: `${stateId}:capture`, + stateId, + rgba: Uint8Array.from({ length: width * height * 4 }, (_, index) => + index % 4 === 3 ? 255 : 0, + ), + pixelSize: [width, height] as [number, number], + colorSpace: "srgb" as const, + alphaMode: "opaque" as const, + })), + hitRegionsByState: Object.fromEntries( + Object.keys(input.plan.states).map((stateId) => [stateId, []]), + ), + provenance: { + ...identity, + inputHash: input.context.inputHash, + buildContextHash: input.context.buildContextHash, + environmentHash: input.context.environmentHash, + rendererConfigHash: input.context.rendererConfigHash, + rendererFingerprint: createRendererFingerprint( + identity, + input.context.rendererConfigHash, + ), + }, + diagnostics: [], + }; + }, + }; +}; + +describe("Authoring source to compiler pipeline", () => { + it("check success returns a checked Definition without invoking a renderer", () => { + const result = checkAuthoringProjectAssembly(virtualProject(), carrier()); + + expect(result.valid).toBe(true); + if (!result.valid) return; + expect(result.value.definition.presentationId).toBe("presentation"); + expect(Object.keys(result.value.definition.scene.surfaces)).toEqual(["instance:surface-root"]); + }); + + it("compile success invokes the callable renderer and emits PNG bytes", async () => { + const calls = { count: 0 }; + const result = await compileAuthoringProject( + virtualProject(), + carrier(), + options(makeRenderer(calls)), + ); + + expect(result.valid).toBe(true); + expect(calls.count).toBeGreaterThan(0); + if (!result.valid) return; + expect(Object.keys(result.value.assets)).toHaveLength(1); + const bytes = Object.values(result.value.assets)[0]!; + expect(Array.from(bytes.slice(0, 8))).toEqual([137, 80, 78, 71, 13, 10, 26, 10]); + }); + + it("preserves source syntax diagnostics and exact range at the source phase", () => { + const files = [...virtualProject().files]; + files[0] = { fileName: "entry.ts", sourceText: "const value = ;" }; + + const result = checkAuthoringProjectAssembly(virtualProject(files), carrier()); + + expect(result).toEqual({ + valid: false, + phase: "source", + diagnostics: [ + { + code: "compiler-source-syntax-error", + fileName: "entry.ts", + message: "Expression expected.", + start: 14, + end: 15, + line: 1, + column: 15, + typescriptCode: 1109, + }, + ], + }); + }); + + it("reports an absent explicit carrier at the assembly phase", () => { + const missingThemeHash = { ...carrier(), themeHashes: [] }; + + const result = checkAuthoringProjectAssembly(virtualProject(), missingThemeHash); + + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.phase).toBe("assembly"); + expect(diagnosticCodes(result)).toContain("compiler-theme-hash-missing"); + } + }); + + it("maps renderer failures to the compile phase", async () => { + const renderer = makeRenderer(); + const failingRenderer: RendererPlugin = { + ...renderer, + build: () => ({ + ok: false, + diagnostics: [{ code: "test-renderer-failure", path: [], message: "render failed" }], + }), + }; + + const result = await compileAuthoringProject( + virtualProject(), + carrier(), + options(failingRenderer), + ); + + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.phase).toBe("compile"); + expect(diagnosticCodes(result)).toContain("test-renderer-failure"); + } + }); + + it("maps a hostile renderer identity exception to a stable compile diagnostic", async () => { + const renderer = makeRenderer(); + const hostileIdentity = new Proxy(renderer.identity, { + get: () => { + throw new Error("renderer identity getter must not escape"); + }, + }); + + const result = await compileAuthoringProject( + virtualProject(), + carrier(), + options({ ...renderer, identity: hostileIdentity }), + ); + + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.phase).toBe("compile"); + expect(diagnosticCodes(result)).toContain("compiler-invalid-input"); + } + }); + + it("fails closed for hostile source, carrier, and options without executing accessors", async () => { + const source = new Proxy(virtualProject(), { + ownKeys: () => { + throw new Error("source ownKeys must not execute"); + }, + }); + expect(() => checkAuthoringProjectAssembly(source, carrier())).not.toThrow(); + const sourceResult = checkAuthoringProjectAssembly(source, carrier()); + expect(sourceResult.valid).toBe(false); + if (!sourceResult.valid) expect(sourceResult.phase).toBe("source"); + + const hostileCarrier = new Proxy(carrier(), { + ownKeys: () => { + throw new Error("carrier ownKeys must not execute"); + }, + }); + expect(() => checkAuthoringProjectAssembly(virtualProject(), hostileCarrier)).not.toThrow(); + const carrierResult = checkAuthoringProjectAssembly(virtualProject(), hostileCarrier); + expect(carrierResult.valid).toBe(false); + if (!carrierResult.valid) { + expect(carrierResult.phase).toBe("assembly"); + expect(diagnosticCodes(carrierResult)).toContain("compiler-invalid-input"); + } + + const rendererState = { count: 0 }; + const hostileOptions = options(makeRenderer(rendererState)); + Object.defineProperty(hostileOptions, "compiler", { + enumerable: true, + get: () => { + throw new Error("options compiler getter must not execute"); + }, + }); + expect( + await compileAuthoringProject(virtualProject(), carrier(), hostileOptions), + ).toMatchObject({ valid: false, phase: "compile" }); + const result = await compileAuthoringProject(virtualProject(), carrier(), hostileOptions); + expect(result.valid).toBe(false); + if (!result.valid) expect(diagnosticCodes(result)).toContain("compiler-invalid-options"); + expect(rendererState.count).toBe(0); + }); + + it("cannot use a catalog field to override the explicit carrier", () => { + const sourceResult = checkAuthoringProjectAssembly(virtualProject(), carrier()); + expect(sourceResult.valid).toBe(true); + + const forgedCarrier = { + ...carrier(), + catalog: { themes: [{ id: "forged", hash: "forged" }] }, + }; + const result = checkAuthoringProjectAssembly(virtualProject(), forgedCarrier); + + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.phase).toBe("assembly"); + expect(diagnosticCodes(result)).toContain("compiler-invalid-input"); + } + }); + + it("is deterministic when source files and carrier arrays are reversed", async () => { + const first = await compileAuthoringProject( + virtualProject(), + carrier(), + options(makeRenderer()), + ); + const baselineCarrier = carrier(); + const reversedCarrier: DeclarationProjectAssemblyCarrier = { + ...baselineCarrier, + themeHashes: [...baselineCarrier.themeHashes].reverse(), + componentLocks: [...baselineCarrier.componentLocks].reverse(), + }; + const second = await compileAuthoringProject( + virtualProject([...virtualProject().files].reverse()), + reversedCarrier, + options(makeRenderer()), + ); + + expect(second).toEqual(first); + }); +}); diff --git a/packages/presentation-compiler/test/check-authoring-project.test.ts b/packages/presentation-compiler/test/check-authoring-project.test.ts new file mode 100644 index 0000000..500a8cf --- /dev/null +++ b/packages/presentation-compiler/test/check-authoring-project.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; + +import { + checkAuthoringProject, + type PairedAuthoringDeclarationCatalog, + type PairedComponentDeclaration, +} from "../src/index.js"; + +const builders = [ + "definePresentation", + "defineTheme", + "defineComponentManifest", + "defineComponentStructure", +] + .map((name) => `export const ${name} = (...args: unknown[]) => { throw 0; };`) + .join("\n"); + +const presentationSource = ` +import { definePresentation } from "@unframe/presentation"; +export default definePresentation({ + id: "presentation", + metadata: { title: "Presentation" }, + stage: { coordinateSystem: { unit: "meter", handedness: "right", upAxis: "+Y", forwardAxis: "-Z" }, size: [1, 1, 1] }, + scene: { spatial: [], components: [] }, + assets: [], + flow: { initialGroupId: "group", groups: { group: { id: "group", initialStepId: "step", steps: { step: { id: "step", cues: [] } } } }, variables: {} }, + operations: [], +});`; + +const project = (files = [{ fileName: "entry.ts", sourceText: presentationSource }]) => ({ + projectRoot: "/virtual/presentation", + entryFile: "entry.ts", + files, + packageDependencies: [ + { packageName: "@unframe/presentation", packageVersion: "1", packageIntegrity: "integrity" }, + ], + packages: [ + { + packageName: "@unframe/presentation", + packageVersion: "1", + packageIntegrity: "integrity", + files: [{ fileName: "index.ts", sourceText: builders }], + exports: [{ subpath: ".", targetFile: "index.ts" }], + dependencies: [], + }, + ], +}); + +describe("checkAuthoringProject", () => { + it("connects the virtual source frontend without executing builder implementations", () => { + const result = checkAuthoringProject(project()); + expect(result).toMatchObject({ + valid: true, + value: { presentation: { fileName: "entry.ts", value: { id: "presentation" } } }, + }); + if (!result.valid) return; + const catalog: PairedAuthoringDeclarationCatalog = result.value; + const component: PairedComponentDeclaration | undefined = catalog.components[0]; + expect(component).toBeUndefined(); + expect(JSON.parse(JSON.stringify(result.value))).toEqual(result.value); + }); + + it("preserves parse diagnostics including source ranges and TypeScript codes", () => { + const result = checkAuthoringProject( + project([{ fileName: "entry.ts", sourceText: "const value = ;" }]), + ); + expect(result).toEqual({ + valid: false, + diagnostics: [ + { + code: "compiler-source-syntax-error", + fileName: "entry.ts", + message: "Expression expected.", + start: 14, + end: 15, + line: 1, + column: 15, + typescriptCode: 1109, + }, + ], + }); + }); + + it("fails closed for hostile project input", () => { + const hostile = new Proxy(project(), { + ownKeys() { + throw new Error("must not run"); + }, + }); + expect(() => checkAuthoringProject(hostile)).not.toThrow(); + expect(checkAuthoringProject(hostile)).toMatchObject({ valid: false }); + }); + + it("is deterministic when virtual project file order is reversed", () => { + const files = [ + { + fileName: "theme.unframe.ts", + sourceText: + 'import { defineTheme } from "@unframe/presentation"; export default defineTheme({ id: "theme", tokens: {}, namedStyles: {} });', + }, + { fileName: "entry.ts", sourceText: presentationSource }, + ]; + expect(checkAuthoringProject(project(files))).toEqual( + checkAuthoringProject(project([...files].reverse())), + ); + }); +}); diff --git a/packages/presentation-compiler/test/check-declaration-project.test.ts b/packages/presentation-compiler/test/check-declaration-project.test.ts new file mode 100644 index 0000000..7fbeba5 --- /dev/null +++ b/packages/presentation-compiler/test/check-declaration-project.test.ts @@ -0,0 +1,872 @@ +import { describe, expect, it } from "vitest"; +import { standardComponents } from "@unframe/presentation-components"; +import type { PresentationDeclaration } from "@unframe/presentation"; +import { + canonicalizePresentationDefinition, + validatePresentationDefinition, +} from "@unframe/presentation-core"; +import { compileDeclarationProject, checkDeclarationProject } from "../src/index.js"; +import { safePlainClone } from "../src/validation/safe-plain-clone.js"; +import { + createRendererFingerprint, + evaluateFirstMilestoneSupport, + type RendererPlugin, +} from "@unframe/presentation-renderer-api"; +import { PNG_ABSOLUTE_LIMITS } from "@unframe/presentation-assets"; + +const presentation = (): PresentationDeclaration => ({ + id: "presentation", + metadata: { title: "Reference" }, + stage: { + coordinateSystem: { unit: "meter", handedness: "right", upAxis: "+Y", forwardAxis: "-Z" }, + size: [4, 3, 4], + }, + theme: { themeId: standardComponents.theme.id }, + scene: { + spatial: [ + { + id: "spatial", + kind: "spatial", + name: "Surface", + owner: { kind: "presentation" }, + audience: { kind: "all" }, + parent: { kind: "stage" }, + order: 0, + transform: { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] }, + active: true, + visible: true, + opacity: 1, + }, + ], + components: [ + { + id: "instance", + kind: "component-instance", + componentId: standardComponents.surface.manifest.componentId, + version: 1, + owner: { kind: "presentation" }, + spatialNodeId: "spatial", + packageLock: { + packageVersion: "1", + packageIntegrity: "integrity", + manifestHash: "manifest", + structureHash: "structure", + }, + props: {}, + slots: {}, + variants: {}, + partOverrides: [], + }, + ], + }, + assets: [], + flow: { + initialGroupId: "group", + groups: { + group: { id: "group", initialStepId: "step", steps: { step: { id: "step", cues: [] } } }, + }, + variables: {}, + }, + operations: [], +}); + +const project = () => ({ + presentation: presentation(), + themes: [{ declaration: standardComponents.theme, hash: "theme" }], + components: [ + { + manifest: standardComponents.surface.manifest, + structure: standardComponents.surface.structure, + lock: { + packageVersion: "1", + packageIntegrity: "integrity", + manifestHash: "manifest", + structureHash: "structure", + }, + }, + ], + assets: {}, +}); + +const nullPrototype = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(nullPrototype); + if (value === null || typeof value !== "object") return value; + return Object.assign( + Object.create(null), + Object.fromEntries(Object.entries(value).map(([key, child]) => [key, nullPrototype(child)])), + ); +}; + +const codes = (value: unknown) => { + const result = checkDeclarationProject(value); + return result.valid ? [] : result.diagnostics.map((item) => item.code); +}; + +describe("checkDeclarationProject", () => { + it("rejects accessor-backed project data without executing the accessor", () => { + let reads = 0; + const input = { + presentation: presentation(), + themes: [], + components: [], + assets: {}, + }; + Object.defineProperty(input, "themes", { + enumerable: true, + get() { + reads += 1; + throw new Error("must not execute"); + }, + }); + expect(codes(input)).toContain("compiler-invalid-input"); + expect(reads).toBe(0); + }); + + it("keeps malformed public envelopes and sparse arrays on the diagnostic boundary", () => { + const sparse: string[] = []; + sparse.length = 2; + sparse[1] = "hole"; + for (const input of [ + { presentation: {}, themes: [], components: [], assets: {} }, + { presentation: {}, themes: {}, components: [], assets: {} }, + { presentation: {}, themes: [], components: [], assets: {}, extra: sparse }, + ]) { + expect(() => checkDeclarationProject(input)).not.toThrow(); + expect(codes(input)).not.toEqual([]); + } + }); + + it("lowers the reference structured Surface to a Core-valid canonical Definition", () => { + const result = checkDeclarationProject(project()); + expect(result.valid ? [] : result.diagnostics).toEqual([]); + if (!result.valid) return; + expect(validatePresentationDefinition(result.value.definition).valid).toBe(true); + expect(result.value.definition.scene.surfaces["instance:surface-root"]?.rootFrameId).toBe( + "instance:frame-root", + ); + const canonical = canonicalizePresentationDefinition(result.value.definition); + expect(canonical).toMatchObject({ valid: true }); + if (canonical.valid) expect(result.value.definitionJson).toBe(canonical.value); + expect(result.value.sourceHash).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(result.value.definitionHash).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(result.value.sourceHash).toBe( + "sha256:f062c195c245585f457c5c9ebc420f663375f15e4379a14ceab5f58e7dd047ac", + ); + expect(result.value.definitionHash).toBe( + "sha256:498e988f6967fb310feb236b2627ff724d1b0875eaf6506fa56a4d073a962403", + ); + }); + + it("accepts recursively null-prototype declaration data", () => { + const normalized = nullPrototype(project()); + const result = checkDeclarationProject(normalized); + + expect(result.valid ? [] : result.diagnostics).toEqual([]); + }); + + it("does not read length through declaration Array Proxies", () => { + let reads = 0; + const value = project(); + value.themes = new Proxy(value.themes, { + get() { + reads++; + throw new Error("must not read array length"); + }, + }); + + const result = checkDeclarationProject(value); + expect(result.valid ? [] : result.diagnostics).toEqual([]); + expect(reads).toBe(0); + }); + + it("does not inherit Object.prototype accessors into cloned declaration data", () => { + let reads = 0; + Object.defineProperty(Object.prototype, "presentation", { + configurable: true, + get() { + reads++; + throw new Error("must not inherit caller data"); + }, + }); + + try { + const result = safePlainClone(project()); + expect(result.valid).toBe(true); + if (!result.valid) return; + expect(reads).toBe(0); + expect(Object.getPrototypeOf(result.value)).toBeNull(); + } finally { + delete (Object.prototype as { presentation?: unknown }).presentation; + } + }); + + it("is independent of plain-object insertion order", () => { + const firstProject = project(); + firstProject.presentation.assets = [ + { kind: "asset-ref", assetId: "asset-a" }, + { kind: "asset-ref", assetId: "asset-b" }, + ]; + const assetA = { id: "asset-a", mediaType: "image/png", checksum: "sha256:a" }; + const assetB = { id: "asset-b", mediaType: "image/png", checksum: "sha256:b" }; + firstProject.assets = { "asset-a": assetA, "asset-b": assetB }; + const first = checkDeclarationProject(firstProject); + const secondProject = project(); + secondProject.presentation.assets = [...firstProject.presentation.assets]; + secondProject.assets = { + "asset-b": assetB, + "asset-a": assetA, + }; + const second = checkDeclarationProject(secondProject); + expect(first).toMatchObject({ valid: true }); + expect(second).toMatchObject({ valid: true }); + if (!first.valid || !second.valid) return; + expect(second.value).toEqual(first.value); + }); + + it("does not mutate input and rejects duplicate catalogs, empty locks, interactions, and node parents", () => { + const value = project(); + const before = structuredClone(value); + checkDeclarationProject(value); + expect(value).toEqual(before); + + const duplicateTheme = project(); + expect( + codes({ ...duplicateTheme, themes: [...duplicateTheme.themes, duplicateTheme.themes[0]!] }), + ).toContain("compiler-theme-not-found"); + const emptyLock = project(); + expect( + codes({ + ...emptyLock, + components: [ + { + ...emptyLock.components[0]!, + lock: { ...emptyLock.components[0]!.lock, manifestHash: "" }, + }, + ], + }), + ).toContain("compiler-invalid-component-entry"); + const duplicateComponent = project(); + expect( + codes({ + ...duplicateComponent, + components: [...duplicateComponent.components, duplicateComponent.components[0]!], + }), + ).toContain("compiler-component-not-found"); + const mismatchedLock = project(); + mismatchedLock.presentation.scene.components[0]!.packageLock.manifestHash = "different"; + expect(codes(mismatchedLock)).toContain("compiler-component-lock-mismatch"); + const interactions = project(); + expect( + codes({ + ...interactions, + components: [ + { + ...interactions.components[0]!, + structure: { + ...interactions.components[0]!.structure, + root: { + ...interactions.components[0]!.structure.root, + states: { + default: { + ...interactions.components[0]!.structure.root.states.default!, + enabledInteractionIds: ["tap"], + }, + }, + }, + } as never, + }, + ], + }), + ).toContain("compiler-invalid-declaration"); + const parent = project(); + expect( + codes({ + ...parent, + presentation: { + ...parent.presentation, + scene: { + ...parent.presentation.scene, + spatial: [ + { + ...parent.presentation.scene.spatial[0]!, + parent: { kind: "node", nodeId: "spatial" }, + }, + ], + }, + }, + }), + ).toContain("compiler-spatial-node-parent-unsupported"); + }); + + it("rejects resolution and subset mismatches without silently dropping them", () => { + const missingTheme = project(); + missingTheme.presentation.theme = { themeId: "missing" }; + expect(codes(missingTheme)).toContain("compiler-theme-not-found"); + + const opaque = project(); + opaque.components[0]!.manifest = { + ...opaque.components[0]!.manifest, + authoring: { mode: "opaque" }, + renderers: {}, + semantics: { targets: [], surfaces: [] }, + } as never; + expect(codes(opaque)).toContain("compiler-opaque-component-unsupported"); + + const props = project(); + props.presentation.scene.components[0]!.props = { title: "not supported" }; + expect(codes(props)).toContain("compiler-nonempty-props-unsupported"); + + const owner = project(); + owner.presentation.scene.components[0]!.owner = { kind: "group", groupId: "group" }; + expect(codes(owner)).toContain("compiler-owner-mismatch"); + }); + + it("rejects component, structure, lock, style, and Spatial resolution mismatches", () => { + const missingComponent = project(); + expect( + codes({ + ...missingComponent, + presentation: { + ...missingComponent.presentation, + scene: { + ...missingComponent.presentation.scene, + components: [ + { ...missingComponent.presentation.scene.components[0]!, componentId: "missing" }, + ], + }, + }, + }), + ).toContain("compiler-component-not-found"); + + const mismatch = project(); + expect( + codes({ + ...mismatch, + components: [ + { + ...mismatch.components[0]!, + structure: { ...mismatch.components[0]!.structure, componentId: "wrong" } as never, + }, + ], + }), + ).toContain("compiler-component-lock-mismatch"); + + const missingSpatial = project(); + expect( + codes({ + ...missingSpatial, + presentation: { + ...missingSpatial.presentation, + scene: { + ...missingSpatial.presentation.scene, + components: [ + { ...missingSpatial.presentation.scene.components[0]!, spatialNodeId: "missing" }, + ], + }, + }, + }), + ).toContain("compiler-spatial-not-found"); + + const style = project(); + expect( + codes({ + ...style, + components: [ + { + ...style.components[0]!, + structure: { + ...style.components[0]!.structure, + root: { + ...style.components[0]!.structure.root, + root: { + ...standardComponents.surface.structure.root.root, + style: { kind: "named-style-ref", styleId: "missing" }, + }, + }, + } as never, + }, + ], + }), + ).toContain("compiler-named-style-unsupported"); + }); + + it("rejects nested structures and nonempty actions, outputs, cues, and operations", () => { + const nested = project(); + expect( + codes({ + ...nested, + components: [ + { + ...nested.components[0]!, + structure: { + ...nested.components[0]!.structure, + root: { + ...standardComponents.surface.structure.root, + root: { + ...standardComponents.surface.structure.root.root, + children: [ + { + id: "nested", + kind: "frame", + layout: { kind: "absolute", x: 0, y: 0, width: 1, height: 1 }, + children: [], + }, + ], + }, + }, + } as never, + }, + ], + }), + ).toContain("compiler-structure-unsupported"); + + const features = project(); + expect( + codes({ + ...features, + components: [ + { + ...features.components[0]!, + manifest: { + ...features.components[0]!.manifest, + actions: { + click: { + kind: "action", + inputs: {}, + preconditions: [], + effects: [ + { kind: "playTimeline", timelineId: "timeline", completion: "blocking" }, + ], + }, + }, + outputs: { + done: { + kind: "output", + payload: {}, + producer: { kind: "timer", afterMilliseconds: 1 }, + }, + }, + }, + }, + ], + presentation: { + ...features.presentation, + flow: { + ...features.presentation.flow, + groups: { + group: { + id: "group", + initialStepId: "step", + steps: { + step: { + id: "step", + cues: [{ id: "cue", trigger: { kind: "event", event: "tap" }, actions: [] }], + }, + }, + }, + }, + }, + operations: [ + { + id: "detach", + kind: "detach", + mode: "structured", + instanceId: "instance", + provenance: { componentId: "x", version: 1 }, + }, + ], + }, + }), + ).toEqual( + expect.arrayContaining([ + "compiler-manifest-feature-unsupported", + "compiler-cues-unsupported", + "compiler-operations-unsupported", + ]), + ); + }); + + it("uses escaped instance-local identifiers and rejects hostile boundary values", () => { + const escaped = project(); + escaped.presentation.scene.components[0]!.id = "instance/a"; + const result = checkDeclarationProject(escaped); + expect(result.valid ? [] : result.diagnostics).toEqual([]); + if (result.valid) + expect(result.value.definition.scene.surfaces).toHaveProperty("instance%2Fa:surface-root"); + + const collision = project(); + collision.presentation.scene.spatial[0]!.id = "surface-root"; + collision.presentation.scene.components[0]!.spatialNodeId = "surface-root"; + expect(codes(collision)).toContain("compiler-resource-id-collision"); + + const inheritedAsset = project(); + inheritedAsset.presentation.assets = [{ kind: "asset-ref", assetId: "toString" }]; + expect(codes(inheritedAsset)).toContain("compiler-asset-not-found"); + + const unreferencedAsset = project(); + unreferencedAsset.assets = { + unused: { id: "unused", mediaType: "image/png", checksum: "checksum" }, + }; + expect(codes(unreferencedAsset)).toContain("compiler-asset-unreferenced"); + + const malformedStructure = project(); + (malformedStructure.components[0] as unknown as { structure: unknown }).structure = {}; + const malformedStructureResult = checkDeclarationProject(malformedStructure); + expect(malformedStructureResult).toMatchObject({ + valid: false, + diagnostics: [ + { + code: "compiler-invalid-declaration", + path: ["components", 0, "structure"], + }, + ], + }); + + const cyclic: Record = {}; + cyclic.self = cyclic; + expect(() => checkDeclarationProject(cyclic)).not.toThrow(); + expect(codes(cyclic)).toContain("compiler-invalid-input"); + + expect(codes({ presentation: undefined })).toContain("compiler-invalid-input"); + expect(codes({ callback: () => undefined })).toContain("compiler-invalid-input"); + const throwingGetter: Record = {}; + Object.defineProperty(throwingGetter, "presentation", { + enumerable: true, + get: () => { + throw new Error("hostile"); + }, + }); + expect(() => checkDeclarationProject(throwingGetter)).not.toThrow(); + expect(codes(throwingGetter)).toContain("compiler-invalid-input"); + + const symbol = project() as Record; + symbol[Symbol("hidden")] = true; + expect(codes(symbol)).toContain("compiler-invalid-input"); + const hidden = project(); + Object.defineProperty(hidden, "hidden", { value: true }); + expect(codes(hidden)).toContain("compiler-invalid-input"); + const arrayWithExtra = project(); + Object.assign(arrayWithExtra.themes, { extra: true }); + expect(codes(arrayWithExtra)).toContain("compiler-invalid-input"); + const proto = JSON.parse(JSON.stringify(project())) as Record; + Object.defineProperty(proto, "__proto__", { value: { retained: true }, enumerable: true }); + expect(codes(proto)).toContain("compiler-invalid-project-field"); + + const customArray = project(); + let customMapCalled = false; + Object.setPrototypeOf(customArray.themes, { + map: () => { + customMapCalled = true; + return []; + }, + }); + expect(codes(customArray)).toContain("compiler-invalid-input"); + expect(customMapCalled).toBe(false); + + class CustomData {} + const customObject = project(); + customObject.assets = new CustomData() as never; + expect(codes(customObject)).toContain("compiler-invalid-input"); + }); + + it("rejects duplicate lowering identifiers and operations without component instances", () => { + const duplicateSpatial = project(); + duplicateSpatial.presentation.scene.spatial = [ + ...duplicateSpatial.presentation.scene.spatial, + { ...duplicateSpatial.presentation.scene.spatial[0]! }, + ] as never; + expect(codes(duplicateSpatial)).toContain("compiler-duplicate-spatial-id"); + const duplicateContent = project(); + duplicateContent.components[0]!.structure = { + ...duplicateContent.components[0]!.structure, + root: { + ...duplicateContent.components[0]!.structure.root, + root: { + ...duplicateContent.components[0]!.structure.root.root, + children: [ + ...duplicateContent.components[0]!.structure.root.root.children, + { ...duplicateContent.components[0]!.structure.root.root.children[0]! }, + ], + }, + }, + } as never; + expect(codes(duplicateContent)).toContain("compiler-duplicate-content-id"); + const operations = project(); + operations.presentation.scene.components = []; + operations.presentation.scene.spatial = []; + operations.presentation.operations = [ + { + id: "op", + kind: "detach", + mode: "structured", + instanceId: "instance", + provenance: { componentId: "component", version: 1 }, + }, + ]; + expect(codes(operations)).toContain("compiler-operations-unsupported"); + }); + + it("rejects malformed presentation fields, assets, and duplicate asset references", () => { + const metadata = project(); + (metadata.presentation.metadata as unknown as { title: unknown }).title = 1; + expect(codes(metadata)).toContain("compiler-invalid-presentation-shape"); + + const coordinateSystem = project(); + ( + coordinateSystem.presentation.stage.coordinateSystem as unknown as { handedness: string } + ).handedness = "left"; + expect(codes(coordinateSystem)).toContain("compiler-invalid-presentation-shape"); + + const audience = project(); + (audience.presentation.scene.spatial[0] as unknown as { audience: unknown }).audience = { + kind: "role", + role: "operator", + }; + expect(codes(audience)).toContain("compiler-invalid-presentation-shape"); + + const malformedAsset = project() as ReturnType & { + assets: Record; + }; + malformedAsset.assets["asset"] = { id: "asset", mediaType: 123, checksum: null }; + expect(codes(malformedAsset)).toContain("compiler-invalid-asset"); + + const duplicateReference = project(); + duplicateReference.presentation.assets = [ + { kind: "asset-ref", assetId: "asset" }, + { kind: "asset-ref", assetId: "asset" }, + ]; + (duplicateReference as typeof duplicateReference & { assets: Record }).assets = + { + asset: { id: "asset", mediaType: "image/png", checksum: "sha256:asset" }, + }; + expect(codes(duplicateReference)).toContain("compiler-duplicate-asset-reference"); + }); +}); + +describe("compileDeclarationProject", () => { + const renderer: RendererPlugin = { + identity: { + id: "baked-web", + version: "1", + contractVersion: "1", + implementationHash: "sha256:renderer", + }, + capabilities: { + inputKinds: ["structured"], + updateModels: ["static"], + interactions: ["none"], + internalAnimations: ["none"], + rendererPreferences: ["baked-web"], + fallbackPolicies: ["reject"], + deterministic: true, + }, + support: evaluateFirstMilestoneSupport, + build: (input) => ({ + ok: true, + renderSurface: { + id: input.plan.id, + semanticSurfaceId: input.plan.semanticSurfaceId, + logicalBounds: input.plan.logicalBounds, + layer: input.plan.layer, + }, + captures: Object.keys(input.plan.states).map((stateId) => ({ + id: `${stateId}:capture`, + stateId, + rgba: Uint8Array.from( + { length: input.context.pixelTarget[0] * input.context.pixelTarget[1] * 4 }, + (_, index) => (index % 4 === 3 ? 255 : 0), + ), + pixelSize: input.context.pixelTarget, + colorSpace: "srgb", + alphaMode: "opaque", + })), + hitRegionsByState: Object.fromEntries(Object.keys(input.plan.states).map((id) => [id, []])), + provenance: { + ...renderer.identity, + inputHash: input.context.inputHash, + buildContextHash: input.context.buildContextHash, + environmentHash: input.context.environmentHash, + rendererConfigHash: input.context.rendererConfigHash, + rendererFingerprint: createRendererFingerprint( + renderer.identity, + input.context.rendererConfigHash, + ), + }, + diagnostics: [], + }), + }; + const options = () => ({ + compiler: { name: "unframe", version: "1", baseEnvironmentHash: "sha256:environment" }, + locale: "ja-JP", + timezone: "Asia/Tokyo", + colorScheme: "dark" as const, + pixelTarget: [2, 2] as const, + rendererConfigHash: "sha256:config", + renderers: [renderer], + encodeLimits: PNG_ABSOLUTE_LIMITS, + }); + + it("renders every Surface state into a canonical valid RenderBundle without changing check", async () => { + const before = checkDeclarationProject(project()); + const result = await compileDeclarationProject(project(), options()); + expect(result.valid ? [] : result.diagnostics).toEqual([]); + if (!result.valid) return; + expect(result.value.renderBundleHash).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(result.value.renderBundleJson).toBeTruthy(); + expect(Object.keys(result.value.assets)).toHaveLength(1); + const surface = result.value.renderBundle.surfaces["instance:surface-root"]!; + const renderSurface = surface.renderSurfaces["instance:surface-root:render"]!; + expect(renderSurface.stateBindings).toEqual({ + "instance:default": { + kind: "artifacts", + artifactIds: ["instance:surface-root:render:artifact"], + }, + }); + expect(checkDeclarationProject(project())).toEqual(before); + expect(await compileDeclarationProject(project(), options())).toEqual(result); + }); + + it("rejects accessor-backed build options without invoking the accessor", async () => { + let accessed = false; + const hostile = options() as Record; + Object.defineProperty(hostile, "compiler", { + enumerable: true, + get: () => { + accessed = true; + throw new Error("hostile"); + }, + }); + + const result = await compileDeclarationProject(project(), hostile); + + expect(accessed).toBe(false); + expect(result.valid).toBe(false); + if (!result.valid) + expect(result.diagnostics.map((item) => item.code)).toContain("compiler-invalid-options"); + }); + + it("binds bundle identity to compiler and build context", async () => { + const baseline = await compileDeclarationProject(project(), options()); + const differentCompiler = await compileDeclarationProject(project(), { + ...options(), + compiler: { ...options().compiler, version: "2" }, + }); + const differentLocale = await compileDeclarationProject(project(), { + ...options(), + locale: "en-US", + }); + expect(baseline.valid && differentCompiler.valid && differentLocale.valid).toBe(true); + if (!baseline.valid || !differentCompiler.valid || !differentLocale.valid) return; + expect(differentCompiler.value.renderBundle.bundleId).not.toBe( + baseline.value.renderBundle.bundleId, + ); + expect(differentLocale.value.renderBundle.bundleId).not.toBe( + baseline.value.renderBundle.bundleId, + ); + }); + + it("renders surfaces in locale-independent UTF-16 lexical order", async () => { + const value = project(); + const originalSpatial = value.presentation.scene.spatial[0]!; + const originalInstance = value.presentation.scene.components[0]!; + value.presentation.scene.spatial = [ + { ...originalSpatial, id: "spatial-a", order: 1 }, + { ...originalSpatial, id: "spatial-Z", order: 0 }, + ]; + value.presentation.scene.components = [ + { ...originalInstance, id: "a", spatialNodeId: "spatial-a" }, + { ...originalInstance, id: "Z", spatialNodeId: "spatial-Z" }, + ]; + const calls: string[] = []; + const orderedRenderer: RendererPlugin = { + ...renderer, + build: (input) => { + calls.push(input.surface.id); + return renderer.build(input); + }, + }; + + const result = await compileDeclarationProject(value, { + ...options(), + renderers: [orderedRenderer], + }); + + expect(result.valid ? [] : result.diagnostics).toEqual([]); + expect(calls).toEqual(["Z:surface-root", "a:surface-root"]); + }); + + it("keeps renderer and encoder failures on the diagnostic boundary", async () => { + const noRenderer = await compileDeclarationProject(project(), { ...options(), renderers: [] }); + expect(noRenderer.valid && noRenderer.value).toBeFalsy(); + if (!noRenderer.valid) + expect(noRenderer.diagnostics.map((item) => item.code)).toContain( + "compiler-renderer-not-found", + ); + const duplicateRenderer = await compileDeclarationProject(project(), { + ...options(), + renderers: [renderer, { ...renderer }], + }); + expect(duplicateRenderer.valid).toBe(false); + if (!duplicateRenderer.valid) + expect(duplicateRenderer.diagnostics.map((item) => item.code)).toContain( + "compiler-renderer-ambiguous", + ); + const invalidRenderer = await compileDeclarationProject(project(), { + ...options(), + renderers: [{ ...renderer, build: undefined } as never], + }); + expect(invalidRenderer.valid).toBe(false); + if (!invalidRenderer.valid) + expect(invalidRenderer.diagnostics.map((item) => item.code)).toContain( + "invalid-renderer-plugin", + ); + const throwing = { + ...renderer, + build: () => { + throw new Error("nope"); + }, + }; + const failure = await compileDeclarationProject(project(), { + ...options(), + renderers: [throwing], + }); + expect(failure.valid).toBe(false); + if (!failure.valid) + expect(failure.diagnostics.map((item) => item.code)).toContain("renderer-threw"); + const mutating = { + ...renderer, + build: (input: Parameters[0]) => { + (input.plan.states as Record).mutated = { kind: "capture" }; + return renderer.build(input); + }, + }; + const mutation = await compileDeclarationProject(project(), { + ...options(), + renderers: [mutating], + }); + expect(mutation.valid).toBe(false); + if (!mutation.valid) + expect(mutation.diagnostics.map((item) => item.code)).toContain("renderer-mutated-input"); + const encoding = await compileDeclarationProject(project(), { + ...options(), + encodeLimits: { ...PNG_ABSOLUTE_LIMITS, maxWidth: 1 }, + }); + expect(encoding.valid).toBe(false); + if (!encoding.valid) + expect(encoding.diagnostics.map((item) => item.code)).toContain("encode-limit-exceeded"); + }); + + it("does not invoke renderers from the check-only API", () => { + let calls = 0; + const unused = { + ...renderer, + build: (input: Parameters[0]) => { + calls++; + return renderer.build(input); + }, + }; + expect(checkDeclarationProject(project()).valid).toBe(true); + expect(calls).toBe(0); + expect(unused).toBeDefined(); + }); +}); diff --git a/packages/presentation-compiler/test/collect-authoring-declarations.test.ts b/packages/presentation-compiler/test/collect-authoring-declarations.test.ts new file mode 100644 index 0000000..d990118 --- /dev/null +++ b/packages/presentation-compiler/test/collect-authoring-declarations.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from "vitest"; + +import { collectAuthoringDeclarations } from "../src/project/collect-authoring-declarations.js"; +import { parseAuthoringProject } from "../src/project/parse-authoring-project.js"; +import { analyzeAuthoringProject } from "../src/resolution/typecheck-authoring-project.js"; + +const builders = [ + "definePresentation", + "defineTheme", + "defineComponentManifest", + "defineComponentStructure", +] + .map((name) => `export const ${name} = (...args: unknown[]) => { throw 0; };`) + .join("\n"); + +const analyze = ( + files: readonly { readonly fileName: string; readonly sourceText: string }[], + entryFile = "entry.ts", +) => { + const parsed = parseAuthoringProject({ + projectRoot: "/virtual/presentation", + entryFile, + files, + packageDependencies: [ + { packageName: "@unframe/presentation", packageVersion: "1", packageIntegrity: "integrity" }, + ], + packages: [ + { + packageName: "@unframe/presentation", + packageVersion: "1", + packageIntegrity: "integrity", + files: [{ fileName: "index.ts", sourceText: builders }], + exports: [{ subpath: ".", targetFile: "index.ts" }], + dependencies: [], + }, + ], + }); + if (!parsed.ok) throw new Error(JSON.stringify(parsed.diagnostics)); + const result = analyzeAuthoringProject(parsed.value); + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + return result; +}; + +const source = (builder: string, value = "{}") => + `import { ${builder} } from "@unframe/presentation"; export default ${builder}(${value});`; + +describe("collectAuthoringDeclarations", () => { + it("rejects a declaration entry file without treating it as a skipped ambient file", () => { + const result = collectAuthoringDeclarations( + analyze( + [{ fileName: "entry.d.ts", sourceText: "declare const value: {}; export default value;" }], + "entry.d.ts", + ), + ); + expect(result).toEqual({ + ok: false, + diagnostics: [ + { + code: "compiler-declaration-entry-file-unsupported", + fileName: "entry.d.ts", + message: "The declaration entry file must not use the .d.ts suffix.", + start: 0, + end: 0, + line: 1, + column: 1, + }, + ], + }); + }); + + it("canonically aggregates entry ambient and independent project failures", () => { + const result = collectAuthoringDeclarations( + analyze( + [ + { fileName: "entry.d.ts", sourceText: "declare const value: {}; export default value;" }, + { fileName: "a.ts", sourceText: "export {};" }, + { + fileName: "z.unframe.ts", + sourceText: + 'import { defineTheme } from "@unframe/presentation"; export default defineTheme();', + }, + ], + "entry.d.ts", + ), + ); + expect(result).toMatchObject({ ok: false }); + if (result.ok) return; + expect(result.diagnostics.map(({ fileName, code }) => ({ fileName, code }))).toEqual([ + { fileName: "a.ts", code: "compiler-declaration-file-role-unsupported" }, + { fileName: "entry.d.ts", code: "compiler-declaration-entry-file-unsupported" }, + { fileName: "z.unframe.ts", code: "compiler-static-builder-arguments-invalid" }, + ]); + }); + + it("collects all four roles deterministically without executing builders", () => { + const files = [ + { fileName: "entry.ts", sourceText: source("definePresentation", '{ id: "presentation" }') }, + { fileName: "theme.unframe.ts", sourceText: source("defineTheme", '{ id: "theme" }') }, + { + fileName: "button.manifest.ts", + sourceText: source("defineComponentManifest", '{ id: "manifest" }'), + }, + { + fileName: "button.structure.tsx", + sourceText: source("defineComponentStructure", '{ id: "structure" }'), + }, + ]; + const first = collectAuthoringDeclarations(analyze(files)); + const second = collectAuthoringDeclarations(analyze([...files].reverse())); + expect(first).toEqual(second); + expect(first).toMatchObject({ + ok: true, + declarations: [ + { + role: "component-manifest", + fileName: "button.manifest.ts", + rootBuilder: "defineComponentManifest", + value: { id: "manifest" }, + }, + { + role: "component-structure", + fileName: "button.structure.tsx", + rootBuilder: "defineComponentStructure", + value: { id: "structure" }, + }, + { + role: "presentation", + fileName: "entry.ts", + rootBuilder: "definePresentation", + value: { id: "presentation" }, + }, + { + role: "theme", + fileName: "theme.unframe.ts", + rootBuilder: "defineTheme", + value: { id: "theme" }, + }, + ], + }); + }); + + it("skips ambient declarations and package-owned files while rejecting unsupported project suffixes", () => { + const result = collectAuthoringDeclarations( + analyze([ + { fileName: "entry.ts", sourceText: source("definePresentation") }, + { fileName: "ambient.d.ts", sourceText: "declare const ignored: string;" }, + { fileName: "helper.ts", sourceText: "export {};" }, + ]), + ); + expect(result).toEqual({ + ok: false, + diagnostics: [ + { + code: "compiler-declaration-file-role-unsupported", + fileName: "helper.ts", + message: "Project declaration files must use a recognized declaration suffix.", + start: 0, + end: 0, + line: 1, + column: 1, + }, + ], + }); + }); + + it("reports a role mismatch at the root call origin", () => { + const sourceText = source("defineTheme"); + const result = collectAuthoringDeclarations(analyze([{ fileName: "entry.ts", sourceText }])); + const start = sourceText.lastIndexOf("defineTheme({})"); + expect(result).toEqual({ + ok: false, + diagnostics: [ + { + code: "compiler-declaration-root-mismatch", + fileName: "entry.ts", + message: "Declaration file root builder does not match its file role.", + start, + end: start + "defineTheme({})".length, + line: 1, + column: start + 1, + }, + ], + }); + }); + + it("canonically aggregates independent declaration failures", () => { + const result = collectAuthoringDeclarations( + analyze([ + { fileName: "entry.ts", sourceText: source("defineTheme") }, + { + fileName: "z.unframe.ts", + sourceText: + 'import { defineTheme } from "@unframe/presentation"; export default defineTheme();', + }, + { fileName: "a.ts", sourceText: "export {};" }, + ]), + ); + expect(result).toMatchObject({ ok: false }); + if (result.ok) return; + expect(result.diagnostics.map(({ fileName, code }) => ({ fileName, code }))).toEqual([ + { fileName: "a.ts", code: "compiler-declaration-file-role-unsupported" }, + { fileName: "entry.ts", code: "compiler-declaration-root-mismatch" }, + { fileName: "z.unframe.ts", code: "compiler-static-builder-arguments-invalid" }, + ]); + }); +}); diff --git a/packages/presentation-compiler/test/lower-authoring-declaration.test.ts b/packages/presentation-compiler/test/lower-authoring-declaration.test.ts new file mode 100644 index 0000000..3331e20 --- /dev/null +++ b/packages/presentation-compiler/test/lower-authoring-declaration.test.ts @@ -0,0 +1,507 @@ +import { describe, expect, it } from "vitest"; + +import { lowerAuthoringDeclarationFile } from "../src/lowering/lower-authoring-declaration.js"; +import { parseAuthoringProject } from "../src/project/parse-authoring-project.js"; +import { analyzeAuthoringProject } from "../src/resolution/typecheck-authoring-project.js"; + +const builderNames = [ + "definePresentation", + "defineTheme", + "defineComponentManifest", + "defineComponentStructure", + "stringProp", + "numberProp", + "booleanProp", + "slot", + "part", + "variant", + "state", + "action", + "output", + "surfaceState", + "setSurfaceState", + "playTimeline", + "surfaceInteraction", + "timelineCompleted", + "mediaCompleted", + "after", + "invokeComponentAction", + "componentOutput", + "cue", + "tokenRef", + "namedStyleRef", + "assetRef", + "spatial", + "frame", + "text", + "surface", + "semanticOverride", + "componentInstance", + "detach", +]; + +const builderModule = builderNames + .map((name) => `export const ${name} = (...args: unknown[]) => { throw 0; };`) + .concat( + "export const fakeBuilder = (...args: unknown[]) => { throw 0; };", + "export default (...args: unknown[]) => { throw 0; };", + ) + .join("\n"); + +const analyze = ( + sourceText: string, + { + files = [], + packages = [], + }: { + readonly files?: readonly { readonly fileName: string; readonly sourceText: string }[]; + readonly packages?: readonly { + readonly packageName: string; + readonly packageVersion: string; + readonly packageIntegrity: string; + readonly files: readonly { readonly fileName: string; readonly sourceText: string }[]; + readonly exports: readonly { readonly subpath: string; readonly targetFile: string }[]; + readonly dependencies: readonly unknown[]; + }[]; + } = {}, +) => { + const presentationPackage = { + packageName: "@unframe/presentation", + packageVersion: "1", + packageIntegrity: "integrity", + files: [{ fileName: "index.ts", sourceText: builderModule }], + exports: [{ subpath: ".", targetFile: "index.ts" }], + dependencies: [], + }; + const lockedPackages = [presentationPackage, ...packages]; + const parsed = parseAuthoringProject({ + projectRoot: "/virtual/presentation", + entryFile: "presentation.ts", + files: [{ fileName: "presentation.ts", sourceText }, ...files], + packageDependencies: lockedPackages.map( + ({ packageName, packageVersion, packageIntegrity }) => ({ + packageName, + packageVersion, + packageIntegrity, + }), + ), + packages: lockedPackages, + }); + if (!parsed.ok) throw new Error(JSON.stringify(parsed.diagnostics)); + const result = analyzeAuthoringProject(parsed.value); + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + return result; +}; + +const lower = (sourceText: string, options?: Parameters[1]) => + lowerAuthoringDeclarationFile(analyze(sourceText, options)); + +describe("lowerAuthoringDeclarationFile", () => { + it("lowers a direct provenanced root call without executing throwing builders", () => { + const sourceText = [ + 'import { definePresentation as presentation, stringProp as string, after } from "@unframe/presentation";', + 'export default presentation({ emoji: "😀", title: string({ default: "Hello" }), timer: after(100), number: -0, flags: [true, false, null] });', + ].join("\n"); + const result = lower(sourceText); + const rootStart = sourceText.indexOf("presentation({"); + + expect(result).toEqual({ + ok: true, + graph: { + fileName: "presentation.ts", + root: { + kind: "builder-call", + builder: "definePresentation", + origin: { + fileName: "presentation.ts", + start: rootStart, + end: sourceText.length - 1, + line: 2, + column: rootStart - sourceText.lastIndexOf("\n"), + }, + arguments: [ + { + kind: "object", + origin: expect.any(Object), + properties: expect.arrayContaining([ + { + key: "number", + origin: expect.any(Object), + value: expect.objectContaining({ kind: "literal", value: 0 }), + }, + { + key: "title", + origin: expect.any(Object), + value: expect.objectContaining({ + kind: "builder-call", + builder: "stringProp", + arguments: expect.any(Array), + }), + }, + { + key: "timer", + origin: expect.any(Object), + value: expect.objectContaining({ + kind: "builder-call", + builder: "after", + arguments: [expect.objectContaining({ kind: "literal", value: 100 })], + }), + }, + ]), + }, + ], + }, + }, + diagnostics: [], + }); + }); + + it("preserves UTF-16 offsets and line columns after multiline emoji text", () => { + const sourceText = [ + 'import { definePresentation, stringProp } from "@unframe/presentation";', + "export default definePresentation({", + ' emoji: "😀",', + " value: stringProp({", + ' label: "ok",', + " }),", + "});", + ].join("\n"); + const result = lower(sourceText); + const position = (text: string) => { + const start = sourceText.indexOf(text); + const lineStart = sourceText.lastIndexOf("\n", start); + return { + fileName: "presentation.ts", + start, + end: start + text.length, + line: sourceText.slice(0, start).split("\n").length, + column: start - lineStart, + }; + }; + if (!result.ok) throw new Error("expected lowered declaration"); + const rootObject = result.graph.root.arguments[0]; + if (rootObject?.kind !== "object") throw new Error("expected root object"); + const emoji = rootObject.properties[0]; + const value = rootObject.properties[1]; + expect(emoji).toMatchObject({ key: "emoji", origin: position("emoji") }); + expect(value).toMatchObject({ key: "value", origin: position("value") }); + if (value?.value.kind !== "builder-call") throw new Error("expected nested builder"); + expect(value.value.origin).toEqual(position('stringProp({\n label: "ok",\n })')); + const nestedObject = value.value.arguments[0]; + if (nestedObject?.kind !== "object") throw new Error("expected nested object"); + const label = nestedObject.properties[0]; + expect(label?.origin).toEqual(position("label")); + expect(label?.value).toMatchObject({ origin: position('"ok"') }); + }); + + it("rejects unprovenanced root forms and root builders nested as values", () => { + for (const sourceText of [ + "const definePresentation = (...args: unknown[]) => args; export default definePresentation({});", + 'import * as presentation from "@unframe/presentation"; export default presentation.definePresentation({});', + 'import presentation from "@unframe/presentation"; export default presentation({});', + 'import type { definePresentation } from "@unframe/presentation"; const local = (...args: unknown[]) => args; export default local({});', + 'import { definePresentation } from "@unframe/presentation"; const local = definePresentation; export default local({});', + 'import { fakeBuilder } from "@unframe/presentation"; export default fakeBuilder({});', + 'import { definePresentation } from "@unframe/presentation"; export default definePresentation({ nested: definePresentation({}) });', + ]) { + expect(lower(sourceText)).toMatchObject({ + ok: false, + diagnostics: [{ code: expect.stringMatching(/^compiler-static-/) }], + }); + } + }); + + it("preserves ordered multi-argument nested builder calls", () => { + const result = lower( + 'import { definePresentation, setSurfaceState } from "@unframe/presentation"; export default definePresentation({ effect: setSurfaceState("surface", "state") });', + ); + + expect(result).toMatchObject({ + ok: true, + graph: { + root: { + arguments: [ + { + kind: "object", + properties: [ + { + key: "effect", + value: { + kind: "builder-call", + builder: "setSurfaceState", + arguments: [ + { kind: "literal", value: "surface" }, + { kind: "literal", value: "state" }, + ], + }, + }, + ], + }, + ], + }, + }, + }); + }); + + it("reports the exact stable range for root and nested builder signatures", () => { + const rootSource = + 'import { definePresentation } from "@unframe/presentation"; export default definePresentation();'; + const root = lower(rootSource); + expect(root).toEqual({ + ok: false, + diagnostics: [ + { + code: "compiler-static-builder-arguments-invalid", + fileName: "presentation.ts", + message: "Builder arguments do not match the static declaration signature.", + start: rootSource.indexOf("definePresentation()"), + end: rootSource.length - 1, + line: 1, + column: rootSource.indexOf("definePresentation()") + 1, + }, + ], + }); + const nestedSource = + 'import { definePresentation, after } from "@unframe/presentation"; export default definePresentation({ timer: after("bad") });'; + const nested = lower(nestedSource); + const badStart = nestedSource.indexOf('"bad"'); + expect(nested).toMatchObject({ + ok: false, + diagnostics: [ + { + code: "compiler-static-builder-arguments-invalid", + fileName: "presentation.ts", + start: badStart, + end: badStart + '"bad"'.length, + line: 1, + column: badStart + 1, + }, + ], + }); + }); + + it("accepts every builder signature class", () => { + for (const sourceText of [ + 'import { defineTheme } from "@unframe/presentation"; export default defineTheme({});', + 'import { defineComponentManifest } from "@unframe/presentation"; export default defineComponentManifest({});', + 'import { defineComponentStructure } from "@unframe/presentation"; export default defineComponentStructure({});', + 'import { definePresentation, state } from "@unframe/presentation"; export default definePresentation({ value: state() });', + 'import { definePresentation, stringProp } from "@unframe/presentation"; export default definePresentation({ value: stringProp({}) });', + 'import { definePresentation, surfaceState } from "@unframe/presentation"; export default definePresentation({ value: surfaceState("a", "b") });', + 'import { definePresentation, playTimeline } from "@unframe/presentation"; export default definePresentation({ value: playTimeline("a", {}) });', + 'import { definePresentation, after } from "@unframe/presentation"; export default definePresentation({ value: after(-0) });', + ]) + expect(lower(sourceText)).toMatchObject({ ok: true }); + }); + + it("rejects optional root and nested calls at their call ranges", () => { + const rootSource = + 'import { definePresentation } from "@unframe/presentation"; export default definePresentation?.({});'; + const root = lower(rootSource); + const rootStart = rootSource.indexOf("definePresentation?."); + expect(root).toMatchObject({ + ok: false, + diagnostics: [ + { + code: "compiler-static-root-invalid", + start: rootStart, + end: rootSource.length - 1, + }, + ], + }); + const nestedSource = + 'import { definePresentation, after } from "@unframe/presentation"; export default definePresentation({ value: after?.(1) });'; + const nested = lower(nestedSource); + const nestedStart = nestedSource.indexOf("after?."); + expect(nested).toMatchObject({ + ok: false, + diagnostics: [ + { + code: "compiler-static-builder-invalid", + start: nestedStart, + end: nestedStart + "after?.(1)".length, + }, + ], + }); + }); + + it("rejects side-effect, namespace, default, type-only, and non-builder imports", () => { + for (const sourceText of [ + 'import "@unframe/presentation"; import { definePresentation } from "@unframe/presentation"; export default definePresentation({});', + 'import * as builders from "@unframe/presentation"; import { definePresentation } from "@unframe/presentation"; export default definePresentation({});', + 'import defaultBuilder, { definePresentation } from "@unframe/presentation"; export default definePresentation({});', + 'import type { stringProp } from "@unframe/presentation"; import { definePresentation } from "@unframe/presentation"; export default definePresentation({});', + 'import { fakeBuilder, definePresentation } from "@unframe/presentation"; export default definePresentation({});', + ]) { + expect(lower(sourceText)).toMatchObject({ + ok: false, + diagnostics: [{ code: "compiler-static-import-invalid", fileName: "presentation.ts" }], + }); + } + }); + + it("reports exact stable diagnostics for unsupported static expression forms", () => { + const prefix = + 'import { definePresentation } from "@unframe/presentation"; export default definePresentation('; + const cases = [ + { + value: "{ x: ({ a: 1 }).a }", + code: "compiler-static-expression-unsupported", + range: "({ a: 1 }).a", + }, + { + value: "{ __proto__: 1 }", + code: "compiler-static-object-property-invalid", + range: "__proto__", + }, + { value: "{ values: [1,,2] }", code: "compiler-static-array-hole", range: "" }, + { + value: "{ value: {} as unknown }", + code: "compiler-static-expression-unsupported", + range: "{} as unknown", + }, + { + value: "{ value: {} satisfies unknown }", + code: "compiler-static-expression-unsupported", + range: "{} satisfies unknown", + }, + ] as const; + for (const fixture of cases) { + const sourceText = `${prefix}${fixture.value});`; + const result = lower(sourceText); + expect(result.ok).toBe(false); + if (result.ok) continue; + const diagnostic = result.diagnostics[0]!; + const start = fixture.range + ? sourceText.indexOf(fixture.range) + : sourceText.indexOf(",,") + 1; + expect(diagnostic).toEqual({ + code: fixture.code, + fileName: "presentation.ts", + message: diagnostic.message, + start, + end: start + fixture.range.length, + line: 1, + column: start + 1, + }); + } + }); + + it("uses UTF-16 offsets and canonical ordering for multiple lowerable diagnostics", () => { + const sourceText = + 'import { definePresentation } from "@unframe/presentation"; export default definePresentation({ emoji: "😀", __proto__: 1, x: ({ a: 1 }).a, values: [1,,2] });'; + const result = lower(sourceText); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.diagnostics.map(({ code, start }) => ({ code, start }))).toEqual([ + { code: "compiler-static-object-property-invalid", start: sourceText.indexOf("__proto__") }, + { code: "compiler-static-expression-unsupported", start: sourceText.indexOf("({ a: 1 }).a") }, + { code: "compiler-static-array-hole", start: sourceText.indexOf(",,") + 1 }, + ]); + expect(result.diagnostics[1]).toMatchObject({ + start: sourceText.indexOf("({ a: 1 }).a"), + end: sourceText.indexOf("({ a: 1 }).a") + "({ a: 1 }).a".length, + line: 1, + column: sourceText.indexOf("({ a: 1 }).a") + 1, + }); + }); + + it("rejects local aliases at the top-level statement range", () => { + const sourceText = + 'import { definePresentation } from "@unframe/presentation"; const local = definePresentation; export default local({});'; + const result = lower(sourceText); + expect(result).toMatchObject({ + ok: false, + diagnostics: [ + { + code: "compiler-static-top-level-unsupported", + fileName: "presentation.ts", + start: sourceText.indexOf("const local"), + end: sourceText.indexOf("; export") + 1, + line: 1, + column: sourceText.indexOf("const local") + 1, + }, + ], + }); + }); + + it("rejects the second duplicate object key at its exact range", () => { + const sourceText = [ + 'import { definePresentation } from "@unframe/presentation";', + "export default definePresentation({ a: 1, // @ts-ignore", + "a: 2 });", + ].join("\n"); + const result = lower(sourceText); + const start = sourceText.lastIndexOf("a:"); + expect(result).toMatchObject({ + ok: false, + diagnostics: [ + { + code: "compiler-static-object-key-duplicate", + fileName: "presentation.ts", + start, + end: start + 1, + line: 3, + column: 1, + }, + ], + }); + }); + + it("rejects typechecked local and foreign package imports at their import ranges", () => { + const localSource = [ + 'import { definePresentation } from "@unframe/presentation";', + 'import { localValue } from "./local";', + "export default definePresentation({ value: localValue });", + ].join("\n"); + const local = lower(localSource, { + files: [{ fileName: "local.ts", sourceText: "export const localValue = 1;" }], + }); + const localStart = localSource.indexOf('import { localValue } from "./local";'); + expect(local).toMatchObject({ + ok: false, + diagnostics: [ + { + code: "compiler-static-import-invalid", + fileName: "presentation.ts", + start: localStart, + end: localStart + 'import { localValue } from "./local";'.length, + line: 2, + column: 1, + }, + ], + }); + + const foreignSource = [ + 'import { definePresentation } from "@unframe/presentation";', + 'import { foreign } from "foreign";', + "export default definePresentation({ value: foreign });", + ].join("\n"); + const foreign = lower(foreignSource, { + packages: [ + { + packageName: "foreign", + packageVersion: "1", + packageIntegrity: "foreign-integrity", + files: [{ fileName: "index.ts", sourceText: "export const foreign = 1;" }], + exports: [{ subpath: ".", targetFile: "index.ts" }], + dependencies: [], + }, + ], + }); + const foreignStart = foreignSource.indexOf('import { foreign } from "foreign";'); + expect(foreign).toMatchObject({ + ok: false, + diagnostics: [ + { + code: "compiler-static-import-invalid", + fileName: "presentation.ts", + start: foreignStart, + end: foreignStart + 'import { foreign } from "foreign";'.length, + line: 2, + column: 1, + }, + ], + }); + }); +}); diff --git a/packages/presentation-compiler/test/normalize-declaration-graph.test.ts b/packages/presentation-compiler/test/normalize-declaration-graph.test.ts new file mode 100644 index 0000000..55b6991 --- /dev/null +++ b/packages/presentation-compiler/test/normalize-declaration-graph.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, it } from "vitest"; +import { normalizeDeclarationGraph } from "../src/normalization/normalize-declaration-graph.js"; +import type { + DeclarationGraph, + DeclarationGraphValue, + DeclarationSourceOrigin, +} from "../src/lowering/lower-authoring-declaration.js"; + +const origin = (start = 0): DeclarationSourceOrigin => ({ + fileName: "presentation.ts", + start, + end: start + 1, + line: 1, + column: start + 1, +}); +const literal = (value: null | boolean | number | string) => ({ + kind: "literal" as const, + origin: origin(), + value, +}); +const object = ( + properties: readonly { + readonly key: string; + readonly origin: DeclarationSourceOrigin; + readonly value: DeclarationGraphValue; + }[], +): Extract => ({ + kind: "object", + origin: origin(), + properties, +}); +const call = ( + builder: string, + arguments_: readonly DeclarationGraphValue[], + start = 0, +): Extract => ({ + kind: "builder-call", + builder, + origin: origin(start), + arguments: arguments_, +}); +const graph = (value: DeclarationGraphValue): DeclarationGraph => ({ + fileName: "presentation.ts", + root: call("definePresentation", [value]), +}); + +describe("normalizeDeclarationGraph", () => { + it("materializes nested builders without executing them and maps generated fields", () => { + const result = normalizeDeclarationGraph( + graph( + object([ + { + key: "effect", + origin: origin(2), + value: call("setSurfaceState", [literal("surface"), literal("state")], 3), + }, + ]), + ), + ); + expect(result).toMatchObject({ + ok: true, + rootBuilder: "definePresentation", + value: { effect: { kind: "setSurfaceState", surfaceId: "surface", stateId: "state" } }, + }); + if (result.ok) expect(Object.getPrototypeOf(result.value)).toBe(null); + }); + + it("fails closed for reserved kind conflicts", () => { + const result = normalizeDeclarationGraph( + graph( + object([ + { + key: "value", + origin: origin(4), + value: call("stringProp", [ + object([{ key: "kind", origin: origin(8), value: literal("spoof") }]), + ]), + }, + ]), + ), + ); + expect(result).toEqual({ + ok: false, + diagnostics: [ + { + code: "compiler-normalization-invalid-graph", + fileName: "presentation.ts", + message: "Builder fields conflict with input.", + start: 8, + end: 9, + line: 1, + column: 9, + }, + ], + }); + }); + + it("keeps unique paths with child and property-key origins", () => { + const valueOrigin = origin(20); + const keyOrigin = origin(10); + const callOrigin = origin(30); + const result = normalizeDeclarationGraph( + graph( + object([ + { + key: "nested", + origin: keyOrigin, + value: call("after", [{ kind: "literal", origin: valueOrigin, value: 3 }], 30), + }, + ]), + ), + ); + expect(result).toMatchObject({ ok: true }); + if (!result.ok) return; + expect(result.sourceMap).toEqual([ + { path: [], origin: expect.any(Object) }, + { path: ["nested"], origin: callOrigin, keyOrigin }, + { path: ["nested", "kind"], origin: callOrigin }, + { path: ["nested", "afterMilliseconds"], origin: valueOrigin }, + ]); + expect(new Set(result.sourceMap.map((entry) => JSON.stringify(entry.path))).size).toBe( + result.sourceMap.length, + ); + }); + + it("materializes every object builder and special builder without executing source definitions", () => { + const expectedKinds = [ + ["stringProp", "string"], + ["numberProp", "number"], + ["booleanProp", "boolean"], + ["slot", "slot"], + ["part", "part"], + ["variant", "variant"], + ["state", "state"], + ["action", "action"], + ["output", "output"], + ["invokeComponentAction", "component.action"], + ["componentOutput", "component.output"], + ["tokenRef", "token-ref"], + ["namedStyleRef", "named-style-ref"], + ["assetRef", "asset-ref"], + ["spatial", "spatial"], + ["frame", "frame"], + ["text", "text"], + ["surface", "surface"], + ["semanticOverride", "semantic-override"], + ["componentInstance", "component-instance"], + ["detach", "detach"], + ] as const; + for (const [builder, kind] of expectedKinds) { + const args = builder === "state" ? [] : [object([])]; + const result = normalizeDeclarationGraph( + graph(object([{ key: builder, origin: origin(), value: call(builder, args) }])), + ); + expect(result).toMatchObject({ ok: true, value: { [builder]: { kind } } }); + } + for (const rootBuilder of [ + "definePresentation", + "defineTheme", + "defineComponentManifest", + "defineComponentStructure", + ]) { + const result = normalizeDeclarationGraph({ + fileName: "presentation.ts", + root: call(rootBuilder, [ + object([{ key: "identity", origin: origin(), value: literal(rootBuilder) }]), + ]), + }); + expect(result).toEqual( + expect.objectContaining({ ok: true, rootBuilder, value: { identity: rootBuilder } }), + ); + } + const specialBuilders = [ + [call("state", [object([])]), { kind: "state" }], + [call("cue", [object([{ key: "id", origin: origin(), value: literal("x") }])]), { id: "x" }], + [ + call("surfaceState", [literal("s"), literal("st")]), + { kind: "surfaceState", surfaceId: "s", stateId: "st" }, + ], + [ + call("setSurfaceState", [literal("s"), literal("st")]), + { kind: "setSurfaceState", surfaceId: "s", stateId: "st" }, + ], + [ + call("playTimeline", [ + literal("t"), + object([{ key: "completion", origin: origin(), value: literal("blocking") }]), + ]), + { kind: "playTimeline", timelineId: "t", completion: "blocking" }, + ], + [ + call("surfaceInteraction", [literal("i")]), + { kind: "surfaceInteraction", interactionId: "i" }, + ], + [call("timelineCompleted", [literal("t")]), { kind: "timelineCompleted", timelineId: "t" }], + [call("mediaCompleted", [literal("s")]), { kind: "mediaCompleted", surfaceId: "s" }], + [call("after", [literal(1)]), { kind: "timer", afterMilliseconds: 1 }], + ] as const; + for (const [value, expected] of specialBuilders) { + const result = normalizeDeclarationGraph( + graph(object([{ key: "value", origin: origin(), value }])), + ); + expect(result).toEqual(expect.objectContaining({ ok: true, value: { value: expected } })); + } + }); + + it("rejects reserved fields at each exact offending property origin", () => { + const kind = origin(7); + const timeline = origin(11); + const result = normalizeDeclarationGraph( + graph( + object([ + { + key: "x", + origin: origin(), + value: call("playTimeline", [ + literal("t"), + object([ + { key: "timelineId", origin: timeline, value: literal("bad") }, + { key: "kind", origin: kind, value: literal("bad") }, + ]), + ]), + }, + ]), + ), + ); + expect(result).toMatchObject({ ok: false }); + if (result.ok) return; + expect(result.diagnostics.map(({ start }) => start)).toEqual([7, 11]); + }); + + it("rejects incorrect special shapes and malformed graphs without throwing", () => { + const bad = [ + call("surfaceState", [literal("s")]), + call("setSurfaceState", [literal("s"), object([])]), + call("playTimeline", [literal("t"), literal("bad")]), + call("surfaceInteraction", [literal(1)]), + call("timelineCompleted", [object([])]), + call("mediaCompleted", []), + call("after", [literal(Number.POSITIVE_INFINITY)]), + call("cue", [literal("bad")]), + ]; + for (const value of bad) { + const result = normalizeDeclarationGraph( + graph(object([{ key: "x", origin: origin(), value }])), + ); + expect(result).toMatchObject({ ok: false }); + } + const invalid = [ + { fileName: "presentation.ts", root: call("definePresentation", []) }, + { + fileName: "presentation.ts", + root: { + kind: "builder-call", + builder: "definePresentation", + origin: origin(), + arguments: [{ kind: "unknown", origin: origin() }], + }, + }, + graph( + object([ + { + key: "x", + origin: origin(), + value: { + kind: "literal", + origin: origin(), + value: Number.NaN, + } as DeclarationGraphValue, + }, + ]), + ), + graph( + object([ + { key: "__proto__", origin: origin(), value: literal(1) }, + { key: "__proto__", origin: origin(2), value: literal(2) }, + ]), + ), + ]; + for (const fixture of invalid) + expect(() => normalizeDeclarationGraph(fixture as unknown as DeclarationGraph)).not.toThrow(); + for (const fixture of invalid) + expect(normalizeDeclarationGraph(fixture as unknown as DeclarationGraph)).toMatchObject({ + ok: false, + }); + }); + + it("uses null-prototype objects recursively and canonical diagnostic ordering", () => { + const result = normalizeDeclarationGraph( + graph( + object([ + { + key: "z", + origin: origin(30), + value: { + kind: "literal", + origin: origin(30), + value: Number.NaN, + } as DeclarationGraphValue, + }, + { key: "x", origin: origin(10), value: call("after", [], 10) }, + { + key: "p", + origin: origin(20), + value: call("stringProp", [ + object([{ key: "kind", origin: origin(20), value: literal("x") }]), + ]), + }, + ]), + ), + ); + expect(result).toMatchObject({ ok: false }); + if (!result.ok) expect(result.diagnostics.map(({ start }) => start)).toEqual([10, 20, 30]); + const good = normalizeDeclarationGraph( + graph( + object([ + { + key: "nested", + origin: origin(), + value: object([{ key: "child", origin: origin(), value: object([]) }]), + }, + ]), + ), + ); + if (!good.ok) throw new Error("expected valid graph"); + const value = good.value as { + readonly nested: { readonly child: Record }; + }; + expect(Object.getPrototypeOf(value)).toBe(null); + expect(Object.getPrototypeOf(value.nested)).toBe(null); + expect(Object.getPrototypeOf(value.nested.child)).toBe(null); + }); +}); diff --git a/packages/presentation-compiler/test/pair-authoring-declarations.test.ts b/packages/presentation-compiler/test/pair-authoring-declarations.test.ts new file mode 100644 index 0000000..8625f7b --- /dev/null +++ b/packages/presentation-compiler/test/pair-authoring-declarations.test.ts @@ -0,0 +1,499 @@ +import { describe, expect, it } from "vitest"; + +import { + defineComponentManifest, + defineComponentStructure, + definePresentation, + defineTheme, + frame, +} from "@unframe/presentation"; +import type { CollectedAuthoringDeclaration } from "../src/project/collect-authoring-declarations.js"; +import { + pairAuthoringDeclarations, + resolveAuthoringStructurePath, +} from "../src/project/pair-authoring-declarations.js"; + +const origin = (fileName: string, start = 0) => ({ + fileName, + start, + end: start + 1, + line: 1, + column: start + 1, +}); + +const entry = ( + role: CollectedAuthoringDeclaration["role"], + fileName: string, + value: unknown, + sourceMap: CollectedAuthoringDeclaration["sourceMap"] = [{ path: [], origin: origin(fileName) }], +): CollectedAuthoringDeclaration => ({ + role, + fileName, + rootBuilder: + role === "presentation" + ? "definePresentation" + : role === "theme" + ? "defineTheme" + : role === "component-manifest" + ? "defineComponentManifest" + : "defineComponentStructure", + value: value as CollectedAuthoringDeclaration["value"], + sourceMap, +}); + +const presentation = () => + definePresentation({ + id: "presentation", + metadata: { title: "Presentation" }, + stage: { + coordinateSystem: { unit: "meter", handedness: "right", upAxis: "+Y", forwardAxis: "-Z" }, + size: [1, 1, 1], + }, + scene: { spatial: [], components: [] }, + assets: [], + flow: { + initialGroupId: "group", + groups: { + group: { id: "group", initialStepId: "step", steps: { step: { id: "step", cues: [] } } }, + }, + variables: {}, + }, + operations: [], + }); + +const manifest = (componentId: string, structure = "./Button.structure.tsx", version = 1) => + defineComponentManifest({ + componentId, + version, + authoring: { mode: "structured", structure }, + props: {}, + slots: {}, + parts: {}, + variants: {}, + states: {}, + actions: {}, + outputs: {}, + renderers: [], + }); + +const structure = (componentId: string) => + defineComponentStructure({ + id: `${componentId}-structure`, + componentId, + root: frame({ + id: `${componentId}-root`, + layout: { kind: "absolute", x: 0, y: 0, width: 1, height: 1 }, + children: [], + }), + partBindings: {}, + slotPlacements: {}, + timelines: [], + }); + +const collected = (declarations: readonly CollectedAuthoringDeclaration[]) => ({ + ok: true as const, + declarations, + diagnostics: [] as const, +}); + +const nullPrototype = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(nullPrototype); + if (value !== null && typeof value === "object") { + const copy = Object.create(null) as Record; + for (const [key, child] of Object.entries(value)) copy[key] = nullPrototype(child); + return copy; + } + return value; +}; + +describe("resolveAuthoringStructurePath", () => { + it("resolves a root-contained POSIX relative structure path", () => { + expect( + resolveAuthoringStructurePath("components/Button.manifest.ts", "./Button.structure.tsx"), + ).toBe("components/Button.structure.tsx"); + expect( + resolveAuthoringStructurePath("components/Button.manifest.ts", "../Shared.structure.tsx"), + ).toBe("Shared.structure.tsx"); + expect( + resolveAuthoringStructurePath("Button.manifest.ts", "../escape.structure.tsx"), + ).toBeUndefined(); + }); +}); + +describe("pairAuthoringDeclarations", () => { + it("pairs structured manifest and structure deterministically while retaining collected entries", () => { + const declarations = [ + entry("component-structure", "components/Button.structure.tsx", structure("button")), + entry("theme", "z.unframe.ts", defineTheme({ id: "z", tokens: {}, namedStyles: {} })), + entry("presentation", "entry.ts", presentation()), + entry("component-manifest", "components/Button.manifest.ts", manifest("button")), + entry("theme", "a.unframe.ts", defineTheme({ id: "a", tokens: {}, namedStyles: {} })), + ]; + const first = pairAuthoringDeclarations(collected(declarations)); + const second = pairAuthoringDeclarations(collected([...declarations].reverse())); + expect(first).toEqual(second); + expect(first).toMatchObject({ + ok: true, + catalog: { + presentation: { fileName: "entry.ts", value: { id: "presentation" } }, + themes: [{ value: { id: "a" } }, { value: { id: "z" } }], + components: [ + { + manifest: { value: { componentId: "button" } }, + structure: { value: { componentId: "button" } }, + }, + ], + }, + }); + }); + + it("accepts normalized null-prototype declaration values", () => { + const result = pairAuthoringDeclarations( + collected([ + entry("presentation", "entry.ts", nullPrototype(presentation())), + entry("component-manifest", "Button.manifest.ts", nullPrototype(manifest("button"))), + entry("component-structure", "Button.structure.tsx", nullPrototype(structure("button"))), + ]), + ); + expect(result).toMatchObject({ + ok: true, + catalog: { components: [{ manifest: { value: { componentId: "button" } } }] }, + }); + }); + + it("reports invalid role declarations at their source map origin", () => { + const result = pairAuthoringDeclarations( + collected([ + entry("presentation", "entry.ts", {}, [{ path: [], origin: origin("entry.ts", 9) }]), + ]), + ); + expect(result).toEqual({ + ok: false, + diagnostics: [ + { + code: "compiler-invalid-declaration", + fileName: "entry.ts", + message: "Presentation declaration failed Authoring SDK validation.", + start: 9, + end: 10, + line: 1, + column: 10, + }, + ], + }); + }); + + it("requires exactly one presentation declaration", () => { + const result = pairAuthoringDeclarations(collected([])); + expect(result).toEqual({ + ok: false, + diagnostics: [ + { + code: "compiler-presentation-declaration-count-invalid", + fileName: "", + message: "Exactly one presentation declaration is required.", + start: 0, + end: 0, + line: 1, + column: 1, + }, + ], + }); + }); + + it("rejects duplicate theme and component identities using property origins", () => { + const result = pairAuthoringDeclarations( + collected([ + entry("presentation", "entry.ts", presentation()), + entry("theme", "a.unframe.ts", defineTheme({ id: "theme", tokens: {}, namedStyles: {} })), + entry("theme", "b.unframe.ts", defineTheme({ id: "theme", tokens: {}, namedStyles: {} }), [ + { path: [], origin: origin("b.unframe.ts") }, + { path: ["id"], origin: origin("b.unframe.ts", 7) }, + ]), + entry( + "component-manifest", + "one.manifest.ts", + manifest("button", "./Button.structure.tsx"), + ), + entry( + "component-manifest", + "two.manifest.ts", + manifest("button", "./Button.structure.tsx"), + [ + { path: [], origin: origin("two.manifest.ts") }, + { path: ["componentId"], origin: origin("two.manifest.ts", 4) }, + ], + ), + entry("component-structure", "Button.structure.tsx", structure("button")), + ]), + ); + expect(result).toMatchObject({ ok: false }); + if (result.ok) return; + expect(result.diagnostics.map((item) => item.code)).toEqual([ + "compiler-theme-duplicate", + "compiler-component-manifest-duplicate", + ]); + expect(result.diagnostics[0]?.start).toBe(7); + expect(result.diagnostics[1]?.start).toBe(4); + }); + + it("rejects invalid, absent, duplicate, mismatched, and unreferenced structures together", () => { + const result = pairAuthoringDeclarations( + collected([ + entry("presentation", "entry.ts", presentation()), + entry("component-manifest", "bad.manifest.ts", manifest("bad", "../escape.structure.tsx")), + entry( + "component-manifest", + "missing.manifest.ts", + manifest("missing", "./missing.structure.tsx"), + ), + entry("component-manifest", "one.manifest.ts", manifest("one", "./shared.structure.tsx")), + entry("component-manifest", "two.manifest.ts", manifest("two", "./shared.structure.tsx")), + entry("component-structure", "shared.structure.tsx", structure("wrong")), + entry("component-structure", "unused.structure.tsx", structure("unused")), + ]), + ); + expect(result).toMatchObject({ ok: false }); + if (result.ok) return; + expect(result.diagnostics.map((item) => item.code)).toEqual([ + "compiler-component-structure-entry-invalid", + "compiler-component-structure-not-found", + "compiler-component-identity-mismatch", + "compiler-component-identity-mismatch", + "compiler-component-structure-unreferenced", + ]); + }); + + it("allows same component id at different versions to share one structure", () => { + const result = pairAuthoringDeclarations( + collected([ + entry("presentation", "entry.ts", presentation()), + entry( + "component-manifest", + "v2.manifest.ts", + manifest("button", "./shared.structure.tsx", 2), + ), + entry("component-structure", "shared.structure.tsx", structure("button")), + entry( + "component-manifest", + "v1.manifest.ts", + manifest("button", "./shared.structure.tsx", 1), + ), + ]), + ); + expect(result).toMatchObject({ + ok: true, + catalog: { + components: [ + { manifest: { value: { componentId: "button", version: 1 } } }, + { manifest: { value: { componentId: "button", version: 2 } } }, + ], + }, + }); + }); + + it("allows same component id at different versions to use separate structures", () => { + const result = pairAuthoringDeclarations( + collected([ + entry("presentation", "entry.ts", presentation()), + entry("component-manifest", "v1.manifest.ts", manifest("button", "./v1.structure.tsx", 1)), + entry("component-manifest", "v2.manifest.ts", manifest("button", "./v2.structure.tsx", 2)), + entry("component-structure", "v1.structure.tsx", structure("button")), + entry("component-structure", "v2.structure.tsx", structure("button")), + ]), + ); + expect(result).toMatchObject({ ok: true, catalog: { components: [{}, {}] } }); + }); + + it("rejects duplicate manifest identity but not a different version", () => { + const result = pairAuthoringDeclarations( + collected([ + entry("presentation", "entry.ts", presentation()), + entry( + "component-manifest", + "one.manifest.ts", + manifest("button", "./shared.structure.tsx", 1), + ), + entry( + "component-manifest", + "two.manifest.ts", + manifest("button", "./shared.structure.tsx", 1), + ), + entry( + "component-manifest", + "three.manifest.ts", + manifest("button", "./shared.structure.tsx", 2), + ), + entry("component-structure", "shared.structure.tsx", structure("button")), + ]), + ); + expect(result).toMatchObject({ ok: false }); + if (result.ok) return; + expect(result.diagnostics.map((item) => item.code)).toEqual([ + "compiler-component-manifest-duplicate", + ]); + }); + + it.each([ + ".", + "./", + "Button.structure.tsx/", + "dir/./Button.structure.tsx", + "dir/../Button.structure.tsx", + ])("rejects non-canonical structured entry %s", (structurePath) => { + const result = pairAuthoringDeclarations( + collected([ + entry("presentation", "entry.ts", presentation()), + entry("component-manifest", "Button.manifest.ts", manifest("button", structurePath)), + ]), + ); + expect(result).toMatchObject({ ok: false }); + if (result.ok) return; + expect(result.diagnostics.map((item) => item.code)).toContain( + "compiler-component-structure-entry-invalid", + ); + }); + + it("does not suppress an independent unreferenced structure for an invalid manifest without a path", () => { + const result = pairAuthoringDeclarations( + collected([ + entry("presentation", "entry.ts", presentation()), + entry("component-manifest", "invalid.manifest.ts", {}), + entry("component-manifest", "Button.manifest.ts", manifest("button")), + entry("component-structure", "Button.structure.tsx", {}), + entry("component-structure", "unused.structure.tsx", structure("unused")), + ]), + ); + expect(result).toMatchObject({ ok: false }); + if (result.ok) return; + expect(result.diagnostics.map((item) => item.code)).toEqual([ + "compiler-invalid-declaration", + "compiler-invalid-declaration", + "compiler-component-structure-unreferenced", + ]); + }); + + it("suppresses only the structure named by an invalid manifest's descriptor-backed path", () => { + const result = pairAuthoringDeclarations( + collected([ + entry("presentation", "entry.ts", presentation()), + entry("component-manifest", "Button.manifest.ts", { + authoring: { mode: "structured", structure: "./Button.structure.tsx" }, + }), + entry("component-structure", "Button.structure.tsx", structure("button")), + entry("component-structure", "unused.structure.tsx", structure("unused")), + ]), + ); + expect(result).toMatchObject({ ok: false }); + if (result.ok) return; + expect(result.diagnostics.map((item) => item.code)).toEqual([ + "compiler-invalid-declaration", + "compiler-component-structure-unreferenced", + ]); + }); + + it("reads an invalid manifest candidate without invoking its Proxy get trap", () => { + let reads = 0; + const candidate = new Proxy( + { authoring: { mode: "structured", structure: "./Button.structure.tsx" } }, + { + get() { + reads += 1; + throw new Error("get must not run"); + }, + }, + ); + const result = pairAuthoringDeclarations( + collected([ + entry("presentation", "entry.ts", presentation()), + entry("component-manifest", "Button.manifest.ts", candidate), + entry("component-structure", "Button.structure.tsx", structure("button")), + ]), + ); + expect(reads).toBe(0); + expect(result).toMatchObject({ ok: false }); + if (result.ok) return; + expect(result.diagnostics.map((item) => item.code)).toEqual(["compiler-invalid-declaration"]); + }); + + it("treats a throwing Proxy descriptor trap as an invalid manifest without a candidate path", () => { + let reads = 0; + const candidate = new Proxy( + {}, + { + get() { + reads += 1; + throw new Error("get must not run"); + }, + getOwnPropertyDescriptor() { + throw new Error("descriptor failure"); + }, + }, + ); + expect(() => + pairAuthoringDeclarations( + collected([ + entry("presentation", "entry.ts", presentation()), + entry("component-manifest", "Button.manifest.ts", candidate), + entry("component-structure", "unused.structure.tsx", structure("unused")), + ]), + ), + ).not.toThrow(); + expect(reads).toBe(0); + const result = pairAuthoringDeclarations( + collected([ + entry("presentation", "entry.ts", presentation()), + entry("component-manifest", "Button.manifest.ts", candidate), + entry("component-structure", "unused.structure.tsx", structure("unused")), + ]), + ); + expect(result).toMatchObject({ ok: false }); + if (result.ok) return; + expect(result.diagnostics.map((item) => item.code)).toEqual([ + "compiler-invalid-declaration", + "compiler-component-structure-unreferenced", + ]); + }); + + it("keeps diagnostics identical when input declarations are reversed", () => { + const declarations = [ + entry("component-manifest", "b.manifest.ts", manifest("b", "./missing.structure.tsx")), + entry("presentation", "entry.ts", presentation()), + entry("theme", "a.unframe.ts", defineTheme({ id: "theme", tokens: {}, namedStyles: {} })), + entry("theme", "z.unframe.ts", defineTheme({ id: "theme", tokens: {}, namedStyles: {} })), + ]; + expect(pairAuthoringDeclarations(collected(declarations))).toEqual( + pairAuthoringDeclarations(collected([...declarations].reverse())), + ); + }); + + it("rejects opaque manifests without attempting to pair their structures", () => { + const opaque = defineComponentManifest({ + componentId: "opaque", + version: 1, + authoring: { mode: "opaque" }, + props: {}, + slots: {}, + parts: {}, + variants: {}, + states: {}, + actions: {}, + outputs: {}, + renderers: {}, + semantics: { targets: [], surfaces: [] }, + }); + const result = pairAuthoringDeclarations( + collected([ + entry("presentation", "entry.ts", presentation()), + entry("component-manifest", "opaque.manifest.ts", opaque), + entry("component-structure", "opaque.structure.tsx", structure("opaque")), + ]), + ); + expect(result).toMatchObject({ ok: false }); + if (result.ok) return; + expect(result.diagnostics.map((item) => item.code)).toEqual([ + "compiler-opaque-component-unsupported", + "compiler-component-structure-unreferenced", + ]); + }); +}); diff --git a/packages/presentation-compiler/test/parse-authoring-project.test.ts b/packages/presentation-compiler/test/parse-authoring-project.test.ts new file mode 100644 index 0000000..4790192 --- /dev/null +++ b/packages/presentation-compiler/test/parse-authoring-project.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; + +import { parseAuthoringProject } from "../src/project/parse-authoring-project.js"; + +const project = () => ({ + projectRoot: "/virtual/presentation", + entryFile: "presentation.unframe.tsx", + packageDependencies: [], + packages: [], + files: [ + { + fileName: "presentation.unframe.tsx", + sourceText: 'import type { Theme } from "./theme";\nexport default
;', + }, + { fileName: "theme.d.ts", sourceText: "export interface Theme { readonly name: string; }" }, + { fileName: "components/frame.ts", sourceText: "export const frame = {};" }, + ], +}); + +describe("parseAuthoringProject", () => { + it("parses a root-relative virtual TS/TSX/declaration project without executing it", () => { + const result = parseAuthoringProject(project()); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.projectRoot).toBe("/virtual/presentation"); + expect(result.value.entryFile).toBe("presentation.unframe.tsx"); + expect(Object.keys(result.value.files)).toEqual([ + "components/frame.ts", + "presentation.unframe.tsx", + "theme.d.ts", + ]); + expect(result.value.files["presentation.unframe.tsx"]?.fileName).toBe( + "/virtual/presentation/presentation.unframe.tsx", + ); + }); + + it("reports syntax failures with root-relative UTF-16 ranges in canonical order", () => { + const input = project(); + input.files = [ + { fileName: "z.ts", sourceText: "const z = ;" }, + { fileName: "a.ts", sourceText: 'const a = "😀" + ;' }, + ...input.files, + ]; + + expect(parseAuthoringProject(input)).toEqual({ + ok: false, + diagnostics: [ + { + code: "compiler-source-syntax-error", + fileName: "a.ts", + message: "Expression expected.", + start: 17, + end: 18, + line: 1, + column: 18, + typescriptCode: 1109, + }, + { + code: "compiler-source-syntax-error", + fileName: "z.ts", + message: "Expression expected.", + start: 10, + end: 11, + line: 1, + column: 11, + typescriptCode: 1109, + }, + ], + }); + }); + + it("fails closed for invalid roots, paths, duplicate files, missing entries, and unsupported files", () => { + const cases = [ + [{ ...project(), projectRoot: "virtual/presentation" }, "compiler-project-root-invalid"], + [{ ...project(), entryFile: "../presentation.unframe.tsx" }, "compiler-project-path-invalid"], + [ + { ...project(), files: [...project().files, project().files[0]!] }, + "compiler-project-file-duplicate", + ], + [{ ...project(), entryFile: "missing.tsx" }, "compiler-project-entry-not-found"], + [ + { + ...project(), + files: [{ fileName: "presentation.js", sourceText: "export default {};" }], + }, + "compiler-source-kind-unsupported", + ], + ] as const; + + for (const [input, code] of cases) { + const result = parseAuthoringProject(input); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.diagnostics.map((item) => item.code)).toContain(code); + } + }); + + it("rejects accessor and proxy-backed input without running authoring code", () => { + let accessorReads = 0; + const accessorInput = project() as Record; + Object.defineProperty(accessorInput, "files", { + enumerable: true, + get() { + accessorReads += 1; + throw new Error("must not run"); + }, + }); + const proxyInput = new Proxy(project(), { + ownKeys() { + throw new Error("must not run"); + }, + }); + + for (const input of [accessorInput, proxyInput]) { + const result = parseAuthoringProject(input); + expect(result).toEqual({ + ok: false, + diagnostics: [ + { + code: "compiler-invalid-input", + fileName: "", + message: "Project input cannot be inspected safely.", + start: 0, + end: 0, + line: 1, + column: 1, + }, + ], + }); + } + expect(accessorReads).toBe(0); + }); + + it("rejects own prototype-shaped unknown fields in the project and file envelope", () => { + const rootUnknown = project() as Record; + Object.defineProperty(rootUnknown, "__proto__", { value: {}, enumerable: true }); + const fileUnknown = project(); + Object.defineProperty(fileUnknown.files[0]!, "constructor", { value: {}, enumerable: true }); + + for (const input of [rootUnknown, fileUnknown]) { + const result = parseAuthoringProject(input); + expect(result).toEqual({ + ok: false, + diagnostics: [ + { + code: "compiler-invalid-input", + fileName: "", + message: "Project input has an invalid virtual filesystem shape.", + start: 0, + end: 0, + line: 1, + column: 1, + }, + ], + }); + } + }); +}); diff --git a/packages/presentation-compiler/test/parse-authoring-source.test.ts b/packages/presentation-compiler/test/parse-authoring-source.test.ts new file mode 100644 index 0000000..b7b61fc --- /dev/null +++ b/packages/presentation-compiler/test/parse-authoring-source.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import * as ts from "typescript"; + +import { parseAuthoringSource } from "../src/syntax/parse-authoring-source.js"; + +describe("parseAuthoringSource", () => { + it("parses TSX with parent links without evaluating authoring code", () => { + const result = parseAuthoringSource({ + fileName: "presentation.unframe.tsx", + sourceText: + 'import { definePresentation } from "@unframe/presentation";\nexport default
;', + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.diagnostics).toEqual([]); + expect(result.value.statements).toHaveLength(2); + expect(result.value.statements[0]?.parent).toBe(result.value); + const exportStatement = result.value.statements[1]; + expect(exportStatement !== undefined && ts.isExportAssignment(exportStatement)).toBe(true); + if (exportStatement !== undefined && ts.isExportAssignment(exportStatement)) + expect(ts.isJsxSelfClosingElement(exportStatement.expression)).toBe(true); + }); + + it("returns stable source ranges for TypeScript syntax errors", () => { + const result = parseAuthoringSource({ + fileName: "presentation.unframe.ts", + sourceText: "export const presentation = { title: ; };", + }); + + expect(result).toEqual({ + ok: false, + diagnostics: [ + { + code: "compiler-source-syntax-error", + fileName: "presentation.unframe.ts", + message: "Expression expected.", + start: 37, + length: 1, + line: 1, + column: 38, + typescriptCode: 1109, + }, + ], + }); + }); + + it("rejects source kinds outside the TS and TSX authoring contract", () => { + expect( + parseAuthoringSource({ + fileName: "presentation.unframe.js", + sourceText: "export default {};", + }), + ).toEqual({ + ok: false, + diagnostics: [ + { + code: "compiler-source-kind-unsupported", + fileName: "presentation.unframe.js", + message: "Authoring source must use a .ts, .tsx, or .d.ts file name.", + start: 0, + length: 0, + line: 1, + column: 1, + }, + ], + }); + }); + + it("rejects accessor and proxy source input without executing traps", () => { + let reads = 0; + const accessorInput = { fileName: "presentation.ts", sourceText: "export {};" }; + Object.defineProperty(accessorInput, "sourceText", { + enumerable: true, + get() { + reads += 1; + throw new Error("must not run"); + }, + }); + const proxyInput = new Proxy( + { fileName: "presentation.ts", sourceText: "export {};" }, + { + ownKeys() { + throw new Error("must not run"); + }, + }, + ); + + for (const input of [accessorInput, proxyInput]) + expect(parseAuthoringSource(input)).toEqual({ + ok: false, + diagnostics: [ + { + code: "compiler-invalid-input", + fileName: "", + message: "Authoring source input cannot be inspected safely.", + start: 0, + length: 0, + line: 1, + column: 1, + }, + ], + }); + expect(reads).toBe(0); + }); + + it("rejects own prototype-shaped unknown source fields", () => { + const input = { fileName: "presentation.ts", sourceText: "export {};" } as Record< + string, + unknown + >; + Object.defineProperty(input, "__proto__", { value: {}, enumerable: true }); + + expect(parseAuthoringSource(input)).toEqual({ + ok: false, + diagnostics: [ + { + code: "compiler-source-kind-unsupported", + fileName: "", + message: "Authoring source input must contain a file name and source text.", + start: 0, + length: 0, + line: 1, + column: 1, + }, + ], + }); + }); +}); diff --git a/packages/presentation-compiler/test/parse-locked-packages.test.ts b/packages/presentation-compiler/test/parse-locked-packages.test.ts new file mode 100644 index 0000000..5b4c316 --- /dev/null +++ b/packages/presentation-compiler/test/parse-locked-packages.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it } from "vitest"; + +import { parseAuthoringProject } from "../src/project/parse-authoring-project.js"; + +const packageEntry = () => ({ + packageName: "@unframe/theme", + packageVersion: "1.0.0", + packageIntegrity: "opaque-integrity", + files: [{ fileName: "index.d.ts", sourceText: "export declare const theme: string;" }], + exports: [{ subpath: ".", targetFile: "index.d.ts" }], + dependencies: [] as { + packageName: string; + packageVersion: string; + packageIntegrity: string; + }[], +}); + +const input = () => ({ + projectRoot: "/virtual/presentation", + entryFile: "presentation.unframe.ts", + files: [{ fileName: "presentation.unframe.ts", sourceText: "export {};" }], + packageDependencies: [ + { + packageName: "@unframe/theme", + packageVersion: "1.0.0", + packageIntegrity: "opaque-integrity", + }, + ], + packages: [packageEntry()], +}); + +describe("parseAuthoringProject locked packages", () => { + it("snapshots and parses locked package TS/TSX/declaration sources with provenance", () => { + const result = parseAuthoringProject(input()); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.packageDependencies).toEqual(input().packageDependencies); + expect(result.value.packages).toHaveLength(1); + expect(result.value.packages[0]).toMatchObject({ + packageName: "@unframe/theme", + packageVersion: "1.0.0", + packageIntegrity: "opaque-integrity", + exports: [{ subpath: ".", targetFile: "index.d.ts" }], + dependencies: [], + }); + expect(result.value.packages[0]?.files["index.d.ts"]?.fileName).toBe( + "unframe-package://p00400075006E006600720061006D0065002F007400680065006D0065/p0031002E0030002E0030/p006F00700061007100750065002D0069006E0074006500670072006900740079/index.d.ts", + ); + }); + + it("fails closed for hostile package shapes and invalid package metadata", () => { + const accessor = input(); + Object.defineProperty(accessor.packages[0]!, "files", { + enumerable: true, + get() { + throw new Error("must not run"); + }, + }); + const prototypeField = input(); + Object.defineProperty(prototypeField.packages[0]!, "__proto__", { + value: {}, + enumerable: true, + }); + const proxy = input(); + proxy.packages = [ + new Proxy(proxy.packages[0]!, { + ownKeys() { + throw new Error("must not run"); + }, + }), + ]; + const invalidName = input(); + invalidName.packages[0]!.packageName = ".not-a-package"; + const emptyIntegrity = input(); + emptyIntegrity.packages[0]!.packageIntegrity = ""; + + for (const [value, expected] of [ + [accessor, ["compiler-invalid-input"]], + [prototypeField, ["compiler-invalid-input"]], + [proxy, ["compiler-invalid-input"]], + [invalidName, ["compiler-package-identity-invalid"]], + [emptyIntegrity, ["compiler-package-identity-invalid"]], + ] as const) { + const result = parseAuthoringProject(value); + expect(result.ok).toBe(false); + if (!result.ok) + for (const code of expected) + expect(result.diagnostics.map((item) => item.code)).toContain(code); + } + }); + + it("rejects duplicate identities, paths, exports, invalid export targets, and mismatched dependencies", () => { + const duplicatePackage = input(); + duplicatePackage.packages.push({ ...packageEntry(), packageVersion: "2.0.0" }); + const duplicateFile = input(); + duplicateFile.packages[0]!.files.push({ ...duplicateFile.packages[0]!.files[0]! }); + const duplicateExport = input(); + duplicateExport.packages[0]!.exports.push({ ...duplicateExport.packages[0]!.exports[0]! }); + const invalidExport = input(); + invalidExport.packages[0]!.exports = [{ subpath: "not-relative", targetFile: "missing.ts" }]; + const mismatch = input(); + mismatch.packageDependencies[0]!.packageIntegrity = "different"; + + for (const [value, expected] of [ + [duplicatePackage, ["compiler-package-duplicate"]], + [duplicateFile, ["compiler-package-file-duplicate"]], + [duplicateExport, ["compiler-package-export-duplicate"]], + [ + invalidExport, + ["compiler-package-export-subpath-invalid", "compiler-package-export-target-missing"], + ], + [mismatch, ["compiler-package-dependency-mismatch"]], + ] as const) { + const result = parseAuthoringProject(value); + expect(result.ok).toBe(false); + if (!result.ok) + for (const code of expected) + expect(result.diagnostics.map((item) => item.code)).toContain(code); + } + }); + + it("orders package syntax diagnostics independently of input order", () => { + const value = input(); + value.packages = [ + { + ...packageEntry(), + packageName: "z-package", + files: [{ fileName: "index.ts", sourceText: "export const z = ;" }], + exports: [{ subpath: ".", targetFile: "index.ts" }], + }, + { + ...packageEntry(), + packageName: "a-package", + files: [{ fileName: "index.ts", sourceText: "export const a = ;" }], + exports: [{ subpath: ".", targetFile: "index.ts" }], + }, + ]; + value.packageDependencies = value.packages.map( + ({ packageName, packageVersion, packageIntegrity }) => ({ + packageName, + packageVersion, + packageIntegrity, + }), + ); + + const result = parseAuthoringProject(value); + + expect(result).toMatchObject({ + ok: false, + diagnostics: [ + { fileName: "a-package@1.0.0/index.ts", code: "compiler-source-syntax-error" }, + { fileName: "z-package@1.0.0/index.ts", code: "compiler-source-syntax-error" }, + ], + }); + }); + + it("compares opaque identities without delimiter collisions", () => { + const value = input(); + value.packages = [ + { + ...packageEntry(), + packageVersion: "one\0two", + packageIntegrity: "three", + }, + ]; + value.packageDependencies = [ + { packageName: "@unframe/theme", packageVersion: "one", packageIntegrity: "two\0three" }, + ]; + + const result = parseAuthoringProject(value); + + expect(result).toMatchObject({ + ok: false, + diagnostics: [{ code: "compiler-package-dependency-mismatch" }], + }); + }); + + it("rejects duplicate dependency identities within each owner but permits separate owners", () => { + const projectDuplicate = input(); + projectDuplicate.packageDependencies.push({ ...projectDuplicate.packageDependencies[0]! }); + const packageDuplicate = input(); + packageDuplicate.packages[0]!.dependencies = [ + { ...packageDuplicate.packageDependencies[0]! }, + { ...packageDuplicate.packageDependencies[0]! }, + ]; + const separateOwners = input(); + separateOwners.packages.push({ + ...packageEntry(), + packageName: "other-package", + dependencies: [{ ...separateOwners.packageDependencies[0]! }], + }); + separateOwners.packageDependencies.push({ + packageName: "other-package", + packageVersion: "1.0.0", + packageIntegrity: "opaque-integrity", + }); + + for (const value of [projectDuplicate, packageDuplicate]) { + const result = parseAuthoringProject(value); + expect(result).toMatchObject({ + ok: false, + diagnostics: [{ code: "compiler-package-dependency-duplicate" }], + }); + } + expect(parseAuthoringProject(separateOwners).ok).toBe(true); + }); + + it("keeps raw opaque versions in diagnostic display while parsing through a safe virtual namespace", () => { + const value = input(); + value.packages[0]!.packageVersion = "1/../evil"; + value.packageDependencies[0]!.packageVersion = "1/../evil"; + value.packages[0]!.files = [{ fileName: "index.ts", sourceText: "export const value = ;" }]; + value.packages[0]!.exports = [{ subpath: ".", targetFile: "index.ts" }]; + + expect(parseAuthoringProject(value)).toMatchObject({ + ok: false, + diagnostics: [ + { + code: "compiler-source-syntax-error", + fileName: "@unframe/theme@1/../evil/index.ts", + }, + ], + }); + + const valid = input(); + valid.packages[0]!.packageVersion = "1/../evil"; + valid.packageDependencies[0]!.packageVersion = "1/../evil"; + const parsed = parseAuthoringProject(valid); + expect(parsed.ok).toBe(true); + if (parsed.ok) + expect(parsed.value.packages[0]?.files["index.d.ts"]?.fileName).toBe( + "unframe-package://p00400075006E006600720061006D0065002F007400680065006D0065/p0031002F002E002E002F006500760069006C/p006F00700061007100750065002D0069006E0074006500670072006900740079/index.d.ts", + ); + }); + + it("escapes dot-only opaque version and integrity segments in virtual source paths", () => { + const cases = [ + { + packageVersion: ".", + packageIntegrity: "opaque-integrity", + expected: "p002E/p006F00700061007100750065002D0069006E0074006500670072006900740079", + }, + { packageVersion: "1", packageIntegrity: "..", expected: "p0031/p002E002E" }, + ] as const; + + for (const { packageVersion, packageIntegrity, expected } of cases) { + const value = input(); + value.packages[0]!.packageVersion = packageVersion; + value.packages[0]!.packageIntegrity = packageIntegrity; + value.packageDependencies[0]!.packageVersion = packageVersion; + value.packageDependencies[0]!.packageIntegrity = packageIntegrity; + + const result = parseAuthoringProject(value); + expect(result.ok).toBe(true); + if (result.ok) + expect(result.value.packages[0]?.files["index.d.ts"]?.fileName).toBe( + `unframe-package://p00400075006E006600720061006D0065002F007400680065006D0065/${expected}/index.d.ts`, + ); + } + }); + + it("accepts lone-surrogate opaque identities without throwing while preserving a safe namespace", () => { + const value = input(); + value.packages[0]!.packageVersion = "\ud800"; + value.packageDependencies[0]!.packageVersion = "\ud800"; + + expect(() => parseAuthoringProject(value)).not.toThrow(); + const result = parseAuthoringProject(value); + expect(result.ok).toBe(true); + if (result.ok) + expect(result.value.packages[0]?.files["index.d.ts"]?.fileName).toContain("/pD800/"); + }); + + it("enforces current npm package-name length and scoped leading-character rules", () => { + const withName = (packageName: string) => { + const value = input(); + value.packages[0]!.packageName = packageName; + value.packageDependencies[0]!.packageName = packageName; + return value; + }; + + for (const packageName of [ + "a".repeat(214), + "@scope/_foo", + "@scope/.foo", + "@scope/-foo", + "foo-bar", + ]) + expect(parseAuthoringProject(withName(packageName)).ok).toBe(true); + for (const packageName of [ + "a".repeat(215), + "_foo", + ".foo", + "-foo", + "excited!", + "a~b", + "a*b", + "a'b", + "a(b)", + "@scope/excited!", + "@scope/a~b", + "@scope/a*b", + "@scope/a'b", + "@scope/a(b)", + ]) { + const result = parseAuthoringProject(withName(packageName)); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.diagnostics.map((item) => item.code)).toContain( + "compiler-package-identity-invalid", + ); + } + }); +}); diff --git a/packages/presentation-compiler/test/symbol-provenance.test.ts b/packages/presentation-compiler/test/symbol-provenance.test.ts new file mode 100644 index 0000000..25f8454 --- /dev/null +++ b/packages/presentation-compiler/test/symbol-provenance.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from "vitest"; + +import { parseAuthoringProject } from "../src/project/parse-authoring-project.js"; +import { collectPackageValueProvenance } from "../src/resolution/symbol-provenance.js"; +import { analyzeAuthoringProject } from "../src/resolution/typecheck-authoring-project.js"; + +type PackageInput = { + readonly packageName: string; + readonly packageVersion?: string; + readonly packageIntegrity?: string; + readonly files: readonly { readonly fileName: string; readonly sourceText: string }[]; + readonly exports: readonly { readonly subpath: string; readonly targetFile: string }[]; + readonly dependencies?: readonly { + readonly packageName: string; + readonly packageVersion: string; + readonly packageIntegrity: string; + }[]; +}; + +const lockedPackage = ({ + packageName, + packageVersion = "1", + packageIntegrity = "integrity", + files, + exports, + dependencies = [], +}: PackageInput) => ({ + packageName, + packageVersion, + packageIntegrity, + files, + exports, + dependencies, +}); + +const project = ({ + sourceText, + files = [], + packages = [], +}: { + readonly sourceText: string; + readonly files?: readonly { readonly fileName: string; readonly sourceText: string }[]; + readonly packages?: readonly ReturnType[]; +}) => { + const parsed = parseAuthoringProject({ + projectRoot: "/virtual/presentation", + entryFile: "presentation.ts", + files: [{ fileName: "presentation.ts", sourceText }, ...files], + packageDependencies: packages.map(({ packageName, packageVersion, packageIntegrity }) => ({ + packageName, + packageVersion, + packageIntegrity, + })), + packages, + }); + if (!parsed.ok) throw new Error(JSON.stringify(parsed.diagnostics)); + return parsed.value; +}; + +const analyze = (input: Parameters[0]) => analyzeAuthoringProject(project(input)); + +const provenance = (input: Parameters[0]) => { + const result = analyze(input); + expect(result).toMatchObject({ ok: true }); + if (!result.ok) throw new Error(JSON.stringify(result.diagnostics)); + return collectPackageValueProvenance(result); +}; + +const pkg = (sourceText = "export const value = 1;") => + lockedPackage({ + packageName: "pkg", + files: [{ fileName: "index.ts", sourceText }], + exports: [{ subpath: ".", targetFile: "index.ts" }], + }); + +describe("collectPackageValueProvenance", () => { + it("keeps every provenance field and UTF-16 range for a named alias", () => { + const sourceText = 'import { value as alias } from "pkg";\nexport { alias };'; + const start = sourceText.indexOf("alias"); + + expect(provenance({ sourceText, packages: [pkg()] })).toEqual([ + { + packageName: "pkg", + packageVersion: "1", + packageIntegrity: "integrity", + subpath: ".", + exportName: "value", + targetFile: "index.ts", + declarationFile: "index.ts", + fileName: "presentation.ts", + start, + end: start + "alias".length, + line: 1, + column: start + 1, + }, + ]); + }); + + it("recognizes same-package index re-exports while retaining target and declaration files", () => { + const sourceText = 'import { value } from "pkg"; export { value };'; + const result = provenance({ + sourceText, + packages: [ + lockedPackage({ + packageName: "pkg", + files: [ + { fileName: "index.ts", sourceText: 'export { value } from "./definitions";' }, + { fileName: "definitions.ts", sourceText: "export const value = 1;" }, + ], + exports: [{ subpath: ".", targetFile: "index.ts" }], + }), + ], + }); + + expect(result).toMatchObject([ + { targetFile: "index.ts", declarationFile: "definitions.ts", exportName: "value" }, + ]); + }); + + it("omits local values with the same name", () => { + expect(provenance({ sourceText: "const value = 1; export { value };" })).toEqual([]); + }); + + it("omits syntactic import type even when the export is a value", () => { + expect( + provenance({ + sourceText: 'import type { value } from "pkg"; export type { value };', + packages: [pkg()], + }), + ).toEqual([]); + }); + + it("omits normal imports of semantic type-only exports", () => { + expect( + provenance({ + sourceText: 'import { Shape } from "pkg"; export type { Shape };', + packages: [pkg("export interface Shape {}")], + }), + ).toEqual([]); + }); + + it("omits namespace and default imports", () => { + expect( + provenance({ + sourceText: + 'import * as namespace from "pkg"; export const viaNamespace = namespace.value;', + packages: [pkg()], + }), + ).toEqual([]); + expect( + provenance({ + sourceText: 'import value from "pkg"; export { value };', + packages: [pkg("export default 1;")], + }), + ).toEqual([]); + }); + + it("omits values imported through a project-local wrapper re-export", () => { + expect( + provenance({ + sourceText: 'import { value } from "./wrapper"; export { value };', + files: [{ fileName: "wrapper.ts", sourceText: 'export { value } from "pkg";' }], + packages: [pkg()], + }), + ).toEqual([]); + }); + + it("omits a package re-export that resolves to a different package owner", () => { + const dependency = lockedPackage({ + packageName: "dependency", + files: [{ fileName: "index.ts", sourceText: "export const value = 1;" }], + exports: [{ subpath: ".", targetFile: "index.ts" }], + }); + const owner = lockedPackage({ + packageName: "owner", + files: [{ fileName: "index.ts", sourceText: 'export { value } from "dependency";' }], + exports: [{ subpath: ".", targetFile: "index.ts" }], + dependencies: [ + { packageName: "dependency", packageVersion: "1", packageIntegrity: "integrity" }, + ], + }); + + expect( + provenance({ + sourceText: 'import { value } from "owner"; export { value };', + packages: [owner, dependency], + }), + ).toEqual([]); + }); + + it("recognizes direct dependency imports inside package sources with raw package display names", () => { + const builder = lockedPackage({ + packageName: "builder", + packageVersion: "2", + packageIntegrity: "builder-integrity", + files: [{ fileName: "index.ts", sourceText: "export const define = () => 1;" }], + exports: [{ subpath: ".", targetFile: "index.ts" }], + }); + const owner = lockedPackage({ + packageName: "owner", + files: [ + { + fileName: "index.ts", + sourceText: + 'import { define as builderDefine } from "builder"; export const value = builderDefine();', + }, + ], + exports: [{ subpath: ".", targetFile: "index.ts" }], + dependencies: [ + { packageName: "builder", packageVersion: "2", packageIntegrity: "builder-integrity" }, + ], + }); + + expect(provenance({ sourceText: 'import "owner";', packages: [owner, builder] })).toMatchObject( + [ + { + packageName: "builder", + packageVersion: "2", + packageIntegrity: "builder-integrity", + exportName: "define", + fileName: "owner@1/index.ts", + }, + ], + ); + }); + + it("is canonical when package and file inputs are reversed", () => { + const alpha = lockedPackage({ + packageName: "alpha", + files: [{ fileName: "index.ts", sourceText: "export const alpha = 1;" }], + exports: [{ subpath: ".", targetFile: "index.ts" }], + }); + const beta = lockedPackage({ + packageName: "beta", + files: [{ fileName: "index.ts", sourceText: "export const beta = 1;" }], + exports: [{ subpath: ".", targetFile: "index.ts" }], + }); + const sourceText = "export {};"; + const projectFiles = [ + { fileName: "alpha-use.ts", sourceText: 'import { alpha } from "alpha"; export { alpha };' }, + { fileName: "beta-use.ts", sourceText: 'import { beta } from "beta"; export { beta };' }, + ]; + + const forward = provenance({ sourceText, files: projectFiles, packages: [alpha, beta] }); + const reversed = provenance({ + sourceText, + files: [...projectFiles].reverse(), + packages: [beta, alpha], + }); + + expect(reversed).toEqual(forward); + expect(forward.map((item) => item.exportName)).toEqual(["alpha", "beta"]); + }); + + it("reports a stable nonempty diagnostic for an unavailable parsed entry source", () => { + const parsed = project({ sourceText: "export const value = 1;" }); + const result = analyzeAuthoringProject({ ...parsed, entryFile: "missing.ts" }); + + expect(result).toEqual({ + ok: false, + diagnostics: [ + { + code: "compiler-project-entry-invariant-invalid", + fileName: "", + message: "Parsed project entry source is unavailable.", + start: 0, + end: 0, + line: 1, + column: 1, + }, + ], + }); + }); +}); diff --git a/packages/presentation-compiler/test/typecheck-authoring-project.test.ts b/packages/presentation-compiler/test/typecheck-authoring-project.test.ts new file mode 100644 index 0000000..9afe6e4 --- /dev/null +++ b/packages/presentation-compiler/test/typecheck-authoring-project.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; + +import { parseAuthoringProject } from "../src/project/parse-authoring-project.js"; +import { typecheckAuthoringProject } from "../src/resolution/typecheck-authoring-project.js"; + +const project = (files: readonly { fileName: string; sourceText: string }[]) => { + const parsed = parseAuthoringProject({ + projectRoot: "/virtual/presentation", + entryFile: "presentation.unframe.ts", + packageDependencies: [], + packages: [], + files, + }); + if (!parsed.ok) throw new Error(JSON.stringify(parsed.diagnostics)); + return parsed.value; +}; + +describe("typecheckAuthoringProject", () => { + it("resolves relative extensionless, .js, and index imports from virtual files only", () => { + const result = typecheckAuthoringProject( + project([ + { + fileName: "presentation.unframe.ts", + sourceText: + 'import { extensionless } from "./extensionless";\nimport { js } from "./js-target.js";\nimport { indexed } from "./directory";\nexport const value = extensionless + js + indexed;', + }, + { fileName: "extensionless.ts", sourceText: "export const extensionless: number = 1;" }, + { fileName: "js-target.ts", sourceText: "export const js: number = 2;" }, + { fileName: "directory/index.d.ts", sourceText: "export declare const indexed: number;" }, + ]), + ); + + expect(result).toEqual({ ok: true, diagnostics: [] }); + }); + + it("reports semantic types with root-relative UTF-16 locations", () => { + const result = typecheckAuthoringProject( + project([ + { + fileName: "presentation.unframe.ts", + sourceText: 'export const title: string = "😀" as unknown as number;', + }, + ]), + ); + + expect(result).toEqual({ + ok: false, + diagnostics: [ + { + code: "compiler-source-type-error", + fileName: "presentation.unframe.ts", + message: "Type 'number' is not assignable to type 'string'.", + start: 13, + end: 18, + line: 1, + column: 14, + typescriptCode: 2322, + }, + ], + }); + }); + + it("uses explicit virtual declaration files instead of a host standard library", () => { + const result = typecheckAuthoringProject( + project([ + { + fileName: "presentation.unframe.ts", + sourceText: "export const title: string = virtualStandardLibraryValue;", + }, + { + fileName: "lib.d.ts", + sourceText: "declare const virtualStandardLibraryValue: string;", + }, + ]), + ); + + expect(result).toEqual({ ok: true, diagnostics: [] }); + }); + + it("applies strict ES2022 semantic checking", () => { + const result = typecheckAuthoringProject( + project([ + { + fileName: "presentation.unframe.ts", + sourceText: + "export const callback = (value) => value;\nexport const title: string = undefined;", + }, + ]), + ); + + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.diagnostics.map((item) => item.typescriptCode)).toEqual( + expect.arrayContaining([7006, 2322]), + ); + }); + + it("collects export-from and static dynamic-import module specifiers through public AST APIs", () => { + const result = typecheckAuthoringProject( + project([ + { + fileName: "presentation.unframe.ts", + sourceText: 'export { value } from "./exported";\nvoid import("./missing");', + }, + { fileName: "exported.ts", sourceText: "export const value = 1;" }, + ]), + ); + + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.diagnostics.map((item) => item.code)).toEqual(["compiler-module-unresolved"]); + }); + + it("resolves TSX and declaration imports with their matching TypeScript extensions", () => { + const result = typecheckAuthoringProject( + project([ + { + fileName: "presentation.unframe.ts", + sourceText: + 'import { tsxValue } from "./component";\nimport { declarationValue } from "./declaration";\nexport const total: number = tsxValue + declarationValue;', + }, + { fileName: "component.tsx", sourceText: "export const tsxValue: number = 1;" }, + { + fileName: "declaration.d.ts", + sourceText: "export declare const declarationValue: number;", + }, + ]), + ); + + expect(result).toEqual({ ok: true, diagnostics: [] }); + }); + + it("fails closed for project-root escapes, unresolved relative imports, and bare packages", () => { + const cases = [ + ['import "../../outside";', "compiler-module-root-escape"], + ['import "/outside";', "compiler-module-root-escape"], + ['import "./missing";', "compiler-module-unresolved"], + ['import ".private-package";', "compiler-module-package-unsupported"], + ['import "@unframe/presentation";', "compiler-module-package-unsupported"], + ] as const; + + for (const [sourceText, code] of cases) { + const result = typecheckAuthoringProject( + project([{ fileName: "presentation.unframe.ts", sourceText }]), + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.diagnostics.map((item) => item.code)).toContain(code); + } + }); + + it("does not resolve an explicit source extension through an index fallback", () => { + const result = typecheckAuthoringProject( + project([ + { fileName: "presentation.unframe.ts", sourceText: 'import "./module.ts";' }, + { fileName: "module.ts/index.ts", sourceText: "export {};" }, + ]), + ); + + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.diagnostics.map((item) => item.code)).toEqual(["compiler-module-unresolved"]); + }); +}); diff --git a/packages/presentation-compiler/test/typecheck-locked-packages.test.ts b/packages/presentation-compiler/test/typecheck-locked-packages.test.ts new file mode 100644 index 0000000..4447015 --- /dev/null +++ b/packages/presentation-compiler/test/typecheck-locked-packages.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from "vitest"; + +import { parseAuthoringProject } from "../src/project/parse-authoring-project.js"; +import { typecheckAuthoringProject } from "../src/resolution/typecheck-authoring-project.js"; +import { analyzeAuthoringProject } from "../src/resolution/typecheck-authoring-project.js"; +import { collectPackageValueProvenance } from "../src/resolution/symbol-provenance.js"; + +type PackageInput = { + packageName: string; + packageVersion?: string; + packageIntegrity?: string; + files: { fileName: string; sourceText: string }[]; + exports: { subpath: string; targetFile: string }[]; + dependencies?: { packageName: string; packageVersion: string; packageIntegrity: string }[]; +}; + +const lockedPackage = ({ + packageName, + packageVersion = "1", + packageIntegrity = "integrity", + files, + exports, + dependencies = [], +}: PackageInput) => ({ + packageName, + packageVersion, + packageIntegrity, + files, + exports, + dependencies, +}); + +const project = (sourceText: string, packages: readonly ReturnType[]) => { + const parsed = parseAuthoringProject({ + projectRoot: "/virtual/presentation", + entryFile: "presentation.unframe.ts", + files: [{ fileName: "presentation.unframe.ts", sourceText }], + packageDependencies: packages.map(({ packageName, packageVersion, packageIntegrity }) => ({ + packageName, + packageVersion, + packageIntegrity, + })), + packages, + }); + if (!parsed.ok) throw new Error(JSON.stringify(parsed.diagnostics)); + return parsed.value; +}; + +describe("typecheckAuthoringProject locked packages", () => { + it("derives direct locked package named value provenance through TypeChecker aliases", () => { + const parsed = project('import { definePresentation as define } from "pkg"; define();', [ + lockedPackage({ + packageName: "pkg", + files: [ + { + fileName: "index.ts", + sourceText: "export const definePresentation = () => undefined;", + }, + ], + exports: [{ subpath: ".", targetFile: "index.ts" }], + }), + ]); + const analyzed = analyzeAuthoringProject(parsed); + expect(analyzed.ok).toBe(true); + if (!analyzed.ok) return; + expect(collectPackageValueProvenance(analyzed)).toMatchObject([ + { + packageName: "pkg", + exportName: "definePresentation", + targetFile: "index.ts", + declarationFile: "index.ts", + }, + ]); + }); + + it("keeps a project source root when its logical path collides with a package namespace path", () => { + const packageName = "pkg"; + const packageVersion = "1"; + const packageIntegrity = "integrity"; + const parsed = parseAuthoringProject({ + projectRoot: "/.unframe/packages/p0070006B0067/p0031/p0069006E0074006500670072006900740079", + entryFile: "index.ts", + files: [{ fileName: "index.ts", sourceText: "export const projectValue: string = 1;" }], + packageDependencies: [{ packageName, packageVersion, packageIntegrity }], + packages: [ + lockedPackage({ + packageName, + packageVersion, + packageIntegrity, + files: [{ fileName: "index.ts", sourceText: "export const packageValue = 1;" }], + exports: [{ subpath: ".", targetFile: "index.ts" }], + }), + ], + }); + if (!parsed.ok) throw new Error(JSON.stringify(parsed.diagnostics)); + + const result = typecheckAuthoringProject(parsed.value); + + expect(result).toMatchObject({ + ok: false, + diagnostics: [{ code: "compiler-source-type-error", fileName: "index.ts" }], + }); + }); + + it("resolves a declared project package root and package-local relative module", () => { + const result = typecheckAuthoringProject( + project('import { value } from "pkg"; export const total: number = value;', [ + lockedPackage({ + packageName: "pkg", + files: [ + { fileName: "index.ts", sourceText: 'export { value } from "./inner";' }, + { fileName: "inner.ts", sourceText: "export const value: number = 1;" }, + ], + exports: [{ subpath: ".", targetFile: "index.ts" }], + }), + ]), + ); + + expect(result).toEqual({ ok: true, diagnostics: [] }); + }); + + it("resolves a package direct dependency through an explicit deep export", () => { + const dependency = lockedPackage({ + packageName: "dependency", + files: [{ fileName: "deep.ts", sourceText: "export const deep: number = 1;" }], + exports: [{ subpath: "./deep", targetFile: "deep.ts" }], + }); + const owner = lockedPackage({ + packageName: "owner", + files: [{ fileName: "index.ts", sourceText: 'export { deep } from "dependency/deep";' }], + exports: [{ subpath: ".", targetFile: "index.ts" }], + dependencies: [ + { + packageName: "dependency", + packageVersion: dependency.packageVersion, + packageIntegrity: dependency.packageIntegrity, + }, + ], + }); + + expect( + typecheckAuthoringProject( + project('import { deep } from "owner"; export { deep };', [owner, dependency]), + ), + ).toEqual({ ok: true, diagnostics: [] }); + }); + + it("rejects undeclared and unexported bare imports with stable owner-aware diagnostics", () => { + const pkg = lockedPackage({ + packageName: "pkg", + files: [{ fileName: "index.ts", sourceText: 'import "undeclared"; export {};' }], + exports: [{ subpath: ".", targetFile: "index.ts" }], + }); + const undeclared = typecheckAuthoringProject(project('import "undeclared";', [])); + const unexported = typecheckAuthoringProject(project('import "pkg/private";', [pkg])); + const packageUndeclared = typecheckAuthoringProject(project('import "pkg";', [pkg])); + + for (const result of [undeclared, packageUndeclared]) { + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.diagnostics.map((item) => item.code)).toContain( + "compiler-module-package-unsupported", + ); + } + expect(unexported.ok).toBe(false); + if (!unexported.ok) + expect(unexported.diagnostics.map((item) => item.code)).toContain( + "compiler-module-deep-import-forbidden", + ); + }); + + it("validates literal import-type specifiers against direct dependencies and exact exports", () => { + const pkg = lockedPackage({ + packageName: "pkg", + files: [{ fileName: "index.d.ts", sourceText: "export interface Public {}" }], + exports: [{ subpath: ".", targetFile: "index.d.ts" }], + }); + const privateImport = typecheckAuthoringProject( + project('type Private = import("pkg/private").Private;', [pkg]), + ); + const unknownImport = typecheckAuthoringProject( + project('type Unknown = import("unknown").Unknown;', []), + ); + + expect(privateImport).toMatchObject({ + ok: false, + diagnostics: [{ code: "compiler-module-deep-import-forbidden" }], + }); + expect(unknownImport).toMatchObject({ + ok: false, + diagnostics: [{ code: "compiler-module-package-unsupported" }], + }); + }); + + it("preflights module specifiers in otherwise unreachable locked packages", () => { + const unreachable = lockedPackage({ + packageName: "unreachable", + files: [{ fileName: "index.ts", sourceText: 'import "unknown"; export {};' }], + exports: [{ subpath: ".", targetFile: "index.ts" }], + }); + + const result = typecheckAuthoringProject(project("export {};", [unreachable])); + + expect(result).toMatchObject({ + ok: false, + diagnostics: [ + { code: "compiler-module-package-unsupported", fileName: "unreachable@1/index.ts" }, + ], + }); + }); + + it("keeps package root escape, unresolved relative, and semantic diagnostics in raw package display names", () => { + const escaping = lockedPackage({ + packageName: "escaping", + files: [{ fileName: "index.ts", sourceText: 'import "../../outside";' }], + exports: [{ subpath: ".", targetFile: "index.ts" }], + }); + const unresolved = lockedPackage({ + packageName: "unresolved", + files: [{ fileName: "index.ts", sourceText: 'import "./missing";' }], + exports: [{ subpath: ".", targetFile: "index.ts" }], + }); + const semantic = lockedPackage({ + packageName: "semantic", + files: [{ fileName: "index.ts", sourceText: "export const title: string = 1;" }], + exports: [{ subpath: ".", targetFile: "index.ts" }], + }); + const escaped = typecheckAuthoringProject(project('import "escaping";', [escaping])); + const missing = typecheckAuthoringProject(project('import "unresolved";', [unresolved])); + const typed = typecheckAuthoringProject(project('import "semantic";', [semantic])); + + expect(escaped).toMatchObject({ + ok: false, + diagnostics: [{ code: "compiler-module-root-escape", fileName: "escaping@1/index.ts" }], + }); + expect(typed).toMatchObject({ + ok: false, + diagnostics: [{ code: "compiler-source-type-error", fileName: "semantic@1/index.ts" }], + }); + expect(missing).toMatchObject({ + ok: false, + diagnostics: [{ code: "compiler-module-unresolved", fileName: "unresolved@1/index.ts" }], + }); + }); + + it("does not leak ambient declarations from packages that are not reachable from project roots", () => { + const ambient = lockedPackage({ + packageName: "ambient", + files: [{ fileName: "global.d.ts", sourceText: "declare const leaked: string;" }], + exports: [{ subpath: ".", targetFile: "global.d.ts" }], + }); + + const result = typecheckAuthoringProject(project("export const value = leaked;", [ambient])); + + expect(result).toMatchObject({ + ok: false, + diagnostics: [{ code: "compiler-source-type-error", fileName: "presentation.unframe.ts" }], + }); + }); +}); diff --git a/packages/presentation-compiler/tsconfig.json b/packages/presentation-compiler/tsconfig.json new file mode 100644 index 0000000..034348e --- /dev/null +++ b/packages/presentation-compiler/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../config/tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM"], + "noEmit": true, + "noPropertyAccessFromIndexSignature": false + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/presentation-components/ARCHITECTURE.md b/packages/presentation-components/ARCHITECTURE.md new file mode 100644 index 0000000..400e4cc --- /dev/null +++ b/packages/presentation-components/ARCHITECTURE.md @@ -0,0 +1,106 @@ +# Presentation Components Architecture + +- **Status**: Initial Structured Surface primitive implemented +- **Scope**: Unframe 標準 Primitive、Component、Theme の package +- **Related**: + - [Presentation Architecture](../../docs/presentation/ARCHITECTURE.md) + - [Presentation Implementation Design](../../docs/presentation/DESIGN.md) + - [Presentation Authoring Architecture](../presentation-authoring/ARCHITECTURE.md) + +## 1. Role + +`presentation-components` は Unframe が提供する再利用可能な標準 Primitive、Component、Theme を所有する。Presentation 全体の composition や renderer implementation ではなく、author が利用する versioned component contract と宣言的 source の配布単位である。 + +`presentation-authoring` が Manifest / Theme / Structure の宣言 API と形式を所有し、この package はその API で表現する標準値と標準 source を所有する。 + +## 2. Owned content + +- Spatial Primitive の Manifest +- Surface Primitive の Manifest と structured definition +- 標準 Structured Component と Variant +- 必要な場合の標準 Opaque Component source +- 標準 Theme、Token、Named Style +- renderer compatibility と required capability の declaration +- component contract fixture と任意の preview fixture + +```text +components// +├─ .manifest.ts +├─ .structure.tsx # Structured の場合 +├─ .web.tsx # Opaque baked-web の場合 +└─ .css # Opaque source が必要な場合 +``` + +## 3. Structured and opaque ownership + +Structured Component は Manifest と Structure を正本とし、Component 固有 renderer implementation を持たない。generic renderer が lower 後の Primitive graph を描画する。 + +Opaque Component は Manifest を意味の正本、renderer entry を描画の正本とする。DOM、React tree、CSS、実行結果から Manifest にない State、Interaction、Action、Output を追加しない。 + +Authoring mode は Component version ごとに固定する。Structured / Opaque の変更は migration を伴う公開 contract 変更として扱う。 + +## 4. Component contract + +各 Component は必要な範囲で次を公開する。 + +- Component ID、version、migration metadata +- Props、Slots、Parts、Variants +- Runtime States +- compile-time Actions と Outputs +- Theme requirements +- Semantic Node / Surface / Interaction の公開 binding +- 対応 renderer ID と required capability +- Editor metadata + +Preview は開発補助であり、default Props、Manifest、Structure の正本ではない。Preview がなくても check、build、publish できなければならない。 + +## 5. Invariants + +- Manifest にない interaction や Output を renderer source が発生させない。 +- Structured renderer は Structure にない semantic information を追加しない。 +- local ID は Component Instance への展開時に deterministic な global stable ID へ変換できる。 +- local ID を配列位置、React key、renderer の実行順序から暗黙生成しない。 +- Action / Output は Component 固有 runtime command を残さず canonical contract へ lower できる。 +- package lock は Component ID、version、integrity、Manifest hash を固定し、Structured では Structure hash、Opaque では renderer entry hash も固定する。 +- renderer compatibility は data として宣言し、concrete renderer package へ import 依存しない。 + +## 6. Non-responsibilities + +- Presentation 全体の composition と Global Flow +- Compiler pipeline と plugin selection +- generic renderer implementation +- product-specific template と user content +- Control Plane publication、Delivery、Runtime progression + +## 7. Dependency rules + +`presentation-components` は `presentation-authoring` と `presentation-core` に依存できる。`presentation-compiler`、`presentation-renderer-api`、concrete renderer、Web Editor には依存しない。 + +Opaque source が React などを必要とする場合、その依存は Component renderer entry の build input として明示し、semantic package の初期化 side effect にしない。 + +## 8. Validation strategy + +- Manifest / Structure schema と semantic validation +- Action / Output lowering fixture +- package lock と integrity drift test +- Structured Component の generic renderer conformance test +- Opaque Manifest と renderer binding の completeness test +- Component version / migration fixture +- visual regression は renderer package と共有する fixture に対して実行する + +## 9. Deferred decisions + +- Surface 以外に提供する Spatial / Surface Primitive と標準 Component set +- Component distribution と lockfile format +- Opaque dependency capability と sandbox policy +- Component / renderer drift の完全な検証方式 + +## 10. Current implementation + +最初の milestone は `@unframe/presentation-components` から `standardSurfaceManifest`、`standardSurfaceStructure`、`standardTheme` と、それらを束ねる `standardComponents` を公開する。Structure は固定の `Surface → Frame → Text` Primitive graph、absolute layout、一つの `default` state、空の Interaction、`static / none / baked-web / reject` Render Intent だけを持つ。 + +Manifest は実際に Structure へ結合できない Props、Slots、Parts、Variants、Actions、Outputs を先行公開しない。Structure の local ID はすべて source に明示し、Frame / Text は Standard Theme の Named Style ID だけを参照する。Renderer compatibility は `baked-web` という data で宣言し、renderer API や concrete renderer へ依存しない。 + +現行 Authoring contract は Theme Token category と Named Style property schema をまだ定義していないため、Standard Theme は参照可能な空の Named Style record だけを提供する。これは generic renderer default を選ぶための最小 fixture であり、型付き Theme や標準 visual design を実装済みとは扱わない。 + +Component Props / Variant の Structure への値注入、Spatial 3D Primitive、Interaction、Action / Output lowering、Opaque entry、package lock / integrity、migration、preview、visual regression は含めない。これらを暗黙の名前規約で補わず、Authoring / Compiler / Renderer の対応 contract が実装された後に追加する。 diff --git a/packages/presentation-components/package.json b/packages/presentation-components/package.json new file mode 100644 index 0000000..19b2551 --- /dev/null +++ b/packages/presentation-components/package.json @@ -0,0 +1,23 @@ +{ + "name": "@unframe/presentation-components", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "lint": "vp check src test", + "test": "vp test run", + "check": "pnpm typecheck && pnpm lint && pnpm test" + }, + "dependencies": { + "@unframe/presentation": "workspace:*" + }, + "devDependencies": { + "typescript": "^7.0.0", + "vite-plus": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/presentation-components/src/catalog/standard-components.ts b/packages/presentation-components/src/catalog/standard-components.ts new file mode 100644 index 0000000..0d9c1da --- /dev/null +++ b/packages/presentation-components/src/catalog/standard-components.ts @@ -0,0 +1,15 @@ +export { standardSurfaceManifest } from "../standard-surface.manifest.js"; +export { standardSurfaceStructure } from "../standard-surface.structure.js"; +export { standardTheme, standardThemeStyleIds } from "../standard-theme.js"; + +import { standardSurfaceManifest } from "../standard-surface.manifest.js"; +import { standardSurfaceStructure } from "../standard-surface.structure.js"; +import { standardTheme } from "../standard-theme.js"; + +export const standardComponents = { + surface: { + manifest: standardSurfaceManifest, + structure: standardSurfaceStructure, + }, + theme: standardTheme, +} as const; diff --git a/packages/presentation-components/src/index.ts b/packages/presentation-components/src/index.ts new file mode 100644 index 0000000..e642090 --- /dev/null +++ b/packages/presentation-components/src/index.ts @@ -0,0 +1,4 @@ +export * from "./catalog/standard-components.js"; +export * from "./standard-surface.manifest.js"; +export * from "./standard-surface.structure.js"; +export * from "./standard-theme.js"; diff --git a/packages/presentation-components/src/standard-surface.manifest.ts b/packages/presentation-components/src/standard-surface.manifest.ts new file mode 100644 index 0000000..81cc731 --- /dev/null +++ b/packages/presentation-components/src/standard-surface.manifest.ts @@ -0,0 +1,21 @@ +import { defineComponentManifest, state } from "@unframe/presentation"; + +export const standardSurfaceManifest = defineComponentManifest({ + componentId: "@unframe/components/Surface", + version: 1, + source: { file: "standard-surface.manifest.ts" }, + authoring: { + mode: "structured", + structure: "./standard-surface.structure.ts", + }, + props: {}, + slots: {}, + parts: {}, + variants: {}, + states: { + default: state({ initial: true }), + }, + actions: {}, + outputs: {}, + renderers: ["baked-web"], +}); diff --git a/packages/presentation-components/src/standard-surface.structure.ts b/packages/presentation-components/src/standard-surface.structure.ts new file mode 100644 index 0000000..bcc4625 --- /dev/null +++ b/packages/presentation-components/src/standard-surface.structure.ts @@ -0,0 +1,71 @@ +import { + defineComponentStructure, + frame, + namedStyleRef, + surface, + text, +} from "@unframe/presentation"; +import { standardThemeStyleIds } from "./standard-theme.js"; + +const textContent = text({ + id: "text-content", + source: { file: "standard-surface.structure.ts" }, + value: "", + layout: { kind: "absolute", x: 0, y: 0, width: 1920, height: 1080 }, + style: namedStyleRef({ styleId: standardThemeStyleIds.surfaceText }), +}); + +const rootFrame = frame({ + id: "frame-root", + source: { file: "standard-surface.structure.ts" }, + layout: { kind: "absolute", x: 0, y: 0, width: 1920, height: 1080 }, + style: namedStyleRef({ styleId: standardThemeStyleIds.surfaceRoot }), + children: [textContent], +}); + +const rootSurface = surface({ + id: "surface-root", + source: { file: "standard-surface.structure.ts" }, + physicalSizeMeters: [1.6, 0.9], + logicalSize: [1920, 1080], + fit: "contain", + root: rootFrame, + baseSemanticTree: { + rootNodeIds: ["semantic-text"], + nodes: { + "semantic-text": { + id: "semantic-text", + parentId: null, + order: 0, + role: "paragraph", + text: "", + }, + }, + }, + interactions: {}, + initialStateId: "default", + states: { + default: { + id: "default", + semanticOverrides: [], + enabledInteractionIds: [], + }, + }, + renderIntent: { + updateModel: "static", + interaction: "none", + internalAnimation: "none", + rendererPreference: "baked-web", + fallbackPolicy: "reject", + }, +}); + +export const standardSurfaceStructure = defineComponentStructure({ + id: "standard-surface-structure", + componentId: "@unframe/components/Surface", + source: { file: "standard-surface.structure.ts" }, + root: rootSurface, + partBindings: {}, + slotPlacements: {}, + timelines: [], +}); diff --git a/packages/presentation-components/src/standard-theme.ts b/packages/presentation-components/src/standard-theme.ts new file mode 100644 index 0000000..19c47f7 --- /dev/null +++ b/packages/presentation-components/src/standard-theme.ts @@ -0,0 +1,16 @@ +import { defineTheme } from "@unframe/presentation"; + +export const standardThemeStyleIds = { + surfaceRoot: "surface.root", + surfaceText: "surface.text", +} as const; + +export const standardTheme = defineTheme({ + id: "@unframe/themes/standard", + source: { file: "standard-theme.ts" }, + tokens: {}, + namedStyles: { + [standardThemeStyleIds.surfaceRoot]: {}, + [standardThemeStyleIds.surfaceText]: {}, + }, +}); diff --git a/packages/presentation-components/test/standard-surface.test.ts b/packages/presentation-components/test/standard-surface.test.ts new file mode 100644 index 0000000..6f4d08a --- /dev/null +++ b/packages/presentation-components/test/standard-surface.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + standardComponents, + standardSurfaceManifest, + standardSurfaceStructure, + standardTheme, + standardThemeStyleIds, +} from "../src/index.js"; + +describe("standard Surface contract", () => { + it("exposes only the implemented structured baked-web contract", () => { + expect(standardSurfaceManifest).toMatchObject({ + componentId: "@unframe/components/Surface", + version: 1, + authoring: { + mode: "structured", + structure: "./standard-surface.structure.ts", + }, + }); + expect(standardSurfaceManifest.props).toEqual({}); + expect(standardSurfaceManifest.slots).toEqual({}); + expect(standardSurfaceManifest.parts).toEqual({}); + expect(standardSurfaceManifest.variants).toEqual({}); + expect(standardSurfaceManifest.actions).toEqual({}); + expect(standardSurfaceManifest.outputs).toEqual({}); + expect(standardSurfaceManifest.states).toEqual({ + default: { kind: "state", initial: true }, + }); + expect(standardSurfaceManifest.renderers).toEqual(["baked-web"]); + expect(standardSurfaceManifest).not.toHaveProperty("semantics"); + expect(standardSurfaceManifest.componentId).toBe(standardSurfaceStructure.componentId); + }); + + it("owns an explicit Surface to Frame to Text primitive graph", () => { + const surface = standardSurfaceStructure.root; + expect(surface).toMatchObject({ + id: "surface-root", + kind: "surface", + physicalSizeMeters: [1.6, 0.9], + logicalSize: [1920, 1080], + fit: "contain", + initialStateId: "default", + renderIntent: { + updateModel: "static", + interaction: "none", + internalAnimation: "none", + rendererPreference: "baked-web", + fallbackPolicy: "reject", + }, + }); + expect(surface.root).toMatchObject({ + id: "frame-root", + kind: "frame", + layout: { kind: "absolute", x: 0, y: 0, width: 1920, height: 1080 }, + }); + expect(surface.root.children).toHaveLength(1); + expect(surface.root.children[0]).toMatchObject({ + id: "text-content", + kind: "text", + value: "", + layout: { kind: "absolute", x: 0, y: 0, width: 1920, height: 1080 }, + }); + }); + + it("keeps state and semantic meaning static and non-interactive", () => { + const surface = standardSurfaceStructure.root; + expect(Object.keys(surface.states)).toEqual(["default"]); + expect(Object.keys(standardSurfaceManifest.states)).toEqual(Object.keys(surface.states)); + expect(surface.states.default).toEqual({ + id: "default", + semanticOverrides: [], + enabledInteractionIds: [], + }); + expect(surface.interactions).toEqual({}); + expect(surface.baseSemanticTree).toEqual({ + rootNodeIds: ["semantic-text"], + nodes: { + "semantic-text": { + id: "semantic-text", + parentId: null, + order: 0, + role: "paragraph", + text: "", + }, + }, + }); + }); + + it("uses explicit unique local IDs instead of position-derived identity", () => { + const surface = standardSurfaceStructure.root; + const ids = [ + standardSurfaceStructure.id, + surface.id, + surface.root.id, + surface.root.children[0]?.id, + surface.baseSemanticTree.nodes["semantic-text"]?.id, + surface.states.default.id, + ]; + expect(ids.every((id) => typeof id === "string" && id.length > 0)).toBe(true); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("resolves every Named Style reference from the standard Theme", () => { + const surface = standardSurfaceStructure.root; + const styleIds = [surface.root.style?.styleId, surface.root.children[0]?.style?.styleId]; + expect(styleIds).toEqual([ + standardThemeStyleIds.surfaceRoot, + standardThemeStyleIds.surfaceText, + ]); + for (const styleId of styleIds) { + expect(styleId).toBeDefined(); + expect(standardTheme.namedStyles).toHaveProperty(styleId as string); + } + }); + + it("exports stable JSON-safe plain data without hidden registry state", () => { + const serialized = JSON.stringify(standardComponents); + expect(JSON.parse(serialized)).toEqual(standardComponents); + expect(standardComponents.surface.manifest).toBe(standardSurfaceManifest); + expect(standardComponents.surface.structure).toBe(standardSurfaceStructure); + expect(standardComponents.theme).toBe(standardTheme); + }); + + it("preserves literal public contract types", () => { + expectTypeOf( + standardSurfaceManifest.componentId, + ).toEqualTypeOf<"@unframe/components/Surface">(); + expectTypeOf(standardSurfaceManifest.authoring.mode).toEqualTypeOf<"structured">(); + expectTypeOf(standardSurfaceManifest.renderers[0]).toEqualTypeOf<"baked-web">(); + expectTypeOf(standardSurfaceStructure.root.id).toEqualTypeOf<"surface-root">(); + }); +}); diff --git a/packages/presentation-components/tsconfig.json b/packages/presentation-components/tsconfig.json new file mode 100644 index 0000000..a2c27e0 --- /dev/null +++ b/packages/presentation-components/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../config/tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "noPropertyAccessFromIndexSignature": false + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/presentation-core/ARCHITECTURE.md b/packages/presentation-core/ARCHITECTURE.md new file mode 100644 index 0000000..4e9e379 --- /dev/null +++ b/packages/presentation-core/ARCHITECTURE.md @@ -0,0 +1,129 @@ +# Presentation Core Architecture + +- **Status**: v1 semantic core and v2 publication integrity boundary +- **Scope**: Runtime-neutral な Presentation semantic model、validation、canonicalization +- **Related**: + - [Presentation Architecture](../../docs/presentation/ARCHITECTURE.md) + - [Presentation Implementation Design](../../docs/presentation/DESIGN.md) + - [Contracts Architecture](../contracts/ARCHITECTURE.md) + +## 1. Role + +現在の公開helperには`materializeCompletedSemanticTree`を含む。rendererとCompilerの +orchestrationはこのpackageの責務外である。 + +`presentation-core` は Presentation の pure TypeScript semantic core である。portable contract から導出した in-memory model と、構造 schema だけでは表せない invariant を所有する。 + +Web、Compiler、Control Plane が同じ意味を利用できるようにするが、Browser、Node.js、Cloudflare Workers、React、filesystem、network の runtime object は持ち込まない。Go Realtime と Unity C# はこの実装を共有せず、generated contract と conformance fixture を介して意味を一致させる。 + +## 2. Owned model + +### Current first milestone + +- generated contractから導出したPresentationDefinition / RenderBundle model +- Stage、SurfaceNode、Frame / Text、Surface State、baked-web artifactのsemantic invariant +- stable diagnostic codeとsemantic path +- Presentation固有の意味上のset正規化、RFC 8785 canonical JSON、SHA-256 content hash + +### Target extensions + +- Stable ID、Scalar、typed reference、Transform などの value +- Semantic Authoring IR の normalized data model +- Spatial Node、SurfaceNode、SemanticSurface、RenderSurface の identity と参照 +- ResourceOwner、lifetime、Group activation の規則 +- RuntimeActor、RuntimeSubject、TriggerActorSelector、Anchor owner +- ProjectionAudience、ProjectionProfile、ProjectionInstance、ParticipantRuntimeView +- pause-aware logical clock、StepExecutionSnapshot、RuntimeRunSnapshot、CanonicalRuntimeSnapshot +- schema version に対応する pure migration + +Semantic Authoring IR と PresentationDefinition は同じものではない。前者は編集情報と Source Map 対応を保持し、後者は renderer-independent な実行意味だけを保持する。 + +## 3. Internal boundaries + +将来の拡張では次の関心を分離する。 + +```text +src/ +├─ values/ # IDs, scalar, references, transforms +├─ authoring/ # normalized Semantic Authoring IR model +├─ definition/ # PresentationDefinition semantic model +├─ render-bundle/ # build metadata model +├─ runtime/ # runtime-neutral snapshot/view values +├─ validation/ # reference and semantic invariants +├─ canonicalization/ # stable ordering, serialization, hashing +└─ migration/ # versioned pure migrations +``` + +初期実装は、Stage、SurfaceNode、Frame / Text、Surface State、baked-web RenderBundle subsetのsemantic validation、canonical JSON、SHA-256 hashを実装する。現在の実装はdomain model、Definition / RenderBundle / artifact validation、Semantic Tree materialization、canonicalizationへ責務を分け、Presentation固有の意味上のset正規化後に`canonicalize`でRFC 8785 JSONへ直列化する。 + +## 4. Public API + +初期実装は `validatePresentationDefinition`、`validateRenderBundle`、`validatePresentationArtifacts`、`canonicalizePresentationDefinition`、`canonicalizeRenderBundle`、`hashPresentationDefinition`、`hashRenderBundle` を公開する。入力型は`@unframe/contracts/presentation`のZod schemaから推論した型を正本とし、Core内でserialized modelを再定義しない。 + +Compiler、renderer、asset transformer の read boundary には、この生成型から導出した read-only の `SemanticSurface`、`SurfaceRenderIntent`、`SurfaceContentNode`、`CompletedSemanticTree`、`HitRegion`、`TextureArtifact` を公開する。これらは別の normalized model ではなく、構造・意味検証を通過した current serialized subset を mutation せず参照するための alias である。 + +すべてのAPIは`ValidationResult`を返す。失敗はthrowせず、stable diagnostic code、semantic path、必要ならrelated pathを返す。semantic pathはIDに`/`を含む場合も一つのsegmentとして保持する。 + +公開validation APIは、descriptor-safeなplain JSON snapshotを作成した後、`packages/contracts`が正本として公開するZod 4 schemaで構造を検証する。Zodへcaller-owned objectを直接渡さないため、accessor、sparse array、symbol、cycle、非plain prototypeを実行時データへ混入させない。構造検証済みの値に対して、Coreは参照、cardinality、tree、lifetime、cross-artifact整合などのsemantic invariantだけを検証する。JSON parse、I/O、renderer、transport adapterは公開しない。 + +## 5. Invariants + +初期実装は、Record keyとID、Spatial / content / semantic tree、Surfaceの1:1関係、Group ownerとSpatial parent lifetime、State / Interaction / override、DefinitionとRenderBundleのsurface / state / semantic tree / hit region対応を検証する。canonicalizationはRecord挿入順に依存せず、意味上のsetだけをsortし、semantic overrideのlayer順を保持する。 + +次はtarget全体でCoreが所有するinvariantである。初期schemaにまだ存在しないmodelの検証は未実装である。 + +- SurfaceNode と SemanticSurface は v1 で 1:1、SemanticSurface と RenderSurface は 1:N とする。 +- Runtime contract の Surface ID は SemanticSurfaceId とし、RenderSurfaceId を progression に含めない。 +- Resource owner は `presentation` または一つの `group` に限定する。 +- reference は同じか長い lifetime の resource へだけ向ける。 +- ProjectionAudience は host Spatial Node から派生 resource へ継承し、profile ごとの visibility closure が参照 closure を満たす。 +- actor、subject、Anchor owner は canonical identity と resource ownership に従い、client payload から任意値として受理しない。 +- Component Action / Output は Runtime model に残さず、canonical Action / Trigger へ lower 済みとする。 +- v1 canonicalization は意味上の set の入力順と object insertion order に依存しない。v2 の JCS hash は配列順を保持する。 +- CanonicalRuntimeSnapshot は renderer、participant、connection、transport から独立させる。 + +## 6. Non-responsibilities + +- TSX / JSX runtime、TypeScript compiler API、module resolution +- Lossless Syntax Tree と source patching +- filesystem、cache、network、process environment +- Browser capture、texture / video encoding +- renderer plugin orchestration +- progression の authoritative evaluation +- D1、R2、HTTP、gRPC、Unity object + +## 7. Dependency rules + +`presentation-core` は generated TypeScript presentation contract 以外の presentation package に依存しない。runtime構造検証は`packages/contracts`のZod 4 schemaへ委譲し、Core内に同じ構造schemaを再定義しない。RFC 8785直列化には`canonicalize`、content hashには`@noble/hashes`を用いる。Authoring、Compiler、Renderer、CLI から Core へ依存する逆向きだけを許可する。 + +## 8. Validation strategy + +- portable fixtureに対するvalid / invalid semantic test +- Zod contract schemaのissue pathとstable diagnosticの対応test +- accessorを実行しないdescriptor snapshotとstrict contract fieldの境界test +- ID、reference、lifetime、cardinality、tree、override、hit regionの境界test +- object insertion orderとdiagnostic順序の決定性test +- canonical number serializationとSHA-256のgolden test +- production sourceがNode.js専用APIへ依存しないことのtest + +property test、migration fixture、Go / C# consumerとのsemantic conformanceは対象contractの実装時に追加する。 + +## 9. Deferred decisions + +- Cue / Trigger / Guard / Action、Timeline、Native UI、Video artifactのsemantic validation +- Spatial parent以外のResource lifetimeとProjectionAudienceの参照閉包 +- data constructor、normalize、pure migration API +- migration support window + +## 10. v2 公開物の整合性検証 + +`verifyPublicationIntegrityV2` は、`definition`、`renderBundle`、`assetSet`、`buildManifest`、 +`publishedPresentation` をまとめて受け取り、`ValidationResult` を返す。 +型は `@unframe/contracts/presentation/v2` を正本とする。 + +この入口は安全な plain JSON snapshot、v2 構造、素材の参照集合と descriptor、モデル・clip 参照、 +成果物間の hash と公開 manifest の一致を検証する。入力を変更せず、JCS hash では配列順を保持する。 + +入力はデコード済みの値である。raw JSON の重複 key 検出はデコードする adapter が担当する。 +成功は公開権限、epoch の更新可否、素材バイトの形式、端末への配信可否を保証しない。 +Scene / Flow / State の完全な意味検証と既存 Compiler の v2 出力への移行も別途必要になる。 diff --git a/packages/presentation-core/package.json b/packages/presentation-core/package.json new file mode 100644 index 0000000..89ff9ab --- /dev/null +++ b/packages/presentation-core/package.json @@ -0,0 +1,27 @@ +{ + "name": "@unframe/presentation-core", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "lint": "vp check src test", + "test": "vp test run", + "check": "pnpm typecheck && pnpm lint && pnpm test" + }, + "dependencies": { + "@noble/hashes": "2.3.0", + "@unframe/contracts": "workspace:*", + "canonicalize": "3.0.0", + "zod": "4.4.3" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^7.0.0", + "vite-plus": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/presentation-core/src/canonicalization/artifacts.ts b/packages/presentation-core/src/canonicalization/artifacts.ts new file mode 100644 index 0000000..dbe973c --- /dev/null +++ b/packages/presentation-core/src/canonicalization/artifacts.ts @@ -0,0 +1,41 @@ +import type { ValidationResult } from "../domain/model.js"; +import { canonicalJson } from "./canonical-json.js"; +import { hashCanonicalJson } from "./hash.js"; +import { validatePresentationDefinition } from "../validation/definition.js"; +import { validateRenderBundle } from "../validation/render-bundle.js"; +import { diagnostic } from "../validation/shared.js"; +const validatedCanonicalJson = ( + input: unknown, + validate: (value: unknown) => ValidationResult, +): ValidationResult => { + const result = validate(input); + if (!result.valid) return result; + try { + return { valid: true, value: canonicalJson(result.value), diagnostics: [] }; + } catch { + return { + valid: false, + diagnostics: [ + diagnostic( + "invalid-canonical-json", + [], + "Artifact cannot be represented as canonical JSON.", + ), + ], + }; + } +}; + +export const canonicalizePresentationDefinition = (input: unknown) => + validatedCanonicalJson(input, validatePresentationDefinition); +export const canonicalizeRenderBundle = (input: unknown) => + validatedCanonicalJson(input, validateRenderBundle); + +const validatedHash = (canonical: ValidationResult): ValidationResult => + canonical.valid + ? { valid: true, value: hashCanonicalJson(canonical.value), diagnostics: [] } + : canonical; + +export const hashPresentationDefinition = (input: unknown) => + validatedHash(canonicalizePresentationDefinition(input)); +export const hashRenderBundle = (input: unknown) => validatedHash(canonicalizeRenderBundle(input)); diff --git a/packages/presentation-core/src/canonicalization/canonical-json.ts b/packages/presentation-core/src/canonicalization/canonical-json.ts new file mode 100644 index 0000000..6898a9c --- /dev/null +++ b/packages/presentation-core/src/canonicalization/canonical-json.ts @@ -0,0 +1,199 @@ +import canonicalize from "canonicalize"; + +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; +type JsonRecord = Record; + +const setArrayKeys = new Set([ + "rootNodeIds", + "enabledInteractionIds", + "stateIds", + "events", + "renderSurfaceIds", + "artifactIds", +]); + +const compareStrings = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0); + +const isRecord = (value: unknown): value is JsonRecord => + typeof value === "object" && value !== null && !Array.isArray(value); + +const assertValidUnicode = (value: string) => { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + if (index + 1 >= value.length) + throw new TypeError("Canonical JSON does not permit lone Unicode surrogates."); + const next = value.charCodeAt(index + 1); + if (next < 0xdc00 || next > 0xdfff) + throw new TypeError("Canonical JSON does not permit lone Unicode surrogates."); + index += 1; + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + throw new TypeError("Canonical JSON does not permit lone Unicode surrogates."); + } + } +}; + +const assertCanonicalUnicode = (value: unknown): void => { + if (typeof value === "string") { + assertValidUnicode(value); + return; + } + if (Array.isArray(value)) { + for (const item of value) assertCanonicalUnicode(item); + return; + } + if (isRecord(value)) + for (const [key, item] of Object.entries(value)) { + assertValidUnicode(key); + assertCanonicalUnicode(item); + } +}; + +export const normalizePresentationValue = (value: unknown, key?: string): JsonValue => { + if (value === null || typeof value === "boolean") return value; + if (typeof value === "string") return value; + if (typeof value === "number") { + if (!Number.isFinite(value)) + throw new TypeError("Canonical JSON does not permit non-finite numbers."); + return value; + } + if (Array.isArray(value)) { + const normalized = value.map((item) => normalizePresentationValue(item)); + return key !== undefined && setArrayKeys.has(key) + ? [...normalized].sort((left, right) => compareStrings(String(left), String(right))) + : normalized; + } + if (isRecord(value)) { + const result = Object.create(null) as Record; + for (const entryKey of Object.keys(value)) + result[entryKey] = normalizePresentationValue(value[entryKey], entryKey); + + const nodes = value.nodes; + if (Array.isArray(result.rootNodeIds) && isRecord(nodes)) { + result.rootNodeIds.sort((left, right) => { + const leftNode = nodes[String(left)]; + const rightNode = nodes[String(right)]; + const order = + isRecord(leftNode) && isRecord(rightNode) + ? Number(leftNode.order) - Number(rightNode.order) + : 0; + return order === 0 ? compareStrings(String(left), String(right)) : order; + }); + } + + const sourceContentNodes = isRecord(value.contentNodes) ? value.contentNodes : undefined; + const resultContentNodes = isRecord(result.contentNodes) ? result.contentNodes : undefined; + if (sourceContentNodes !== undefined && resultContentNodes !== undefined) + for (const nodeId of Object.keys(sourceContentNodes)) { + const resultNode = resultContentNodes[nodeId]; + if (!isRecord(resultNode) || !Array.isArray(resultNode.children)) continue; + resultNode.children.sort((left, right) => { + const leftNode = sourceContentNodes[String(left)]; + const rightNode = sourceContentNodes[String(right)]; + const order = + isRecord(leftNode) && isRecord(rightNode) + ? Number(leftNode.order) - Number(rightNode.order) + : 0; + return order === 0 ? compareStrings(String(left), String(right)) : order; + }); + } + return result; + } + throw new TypeError("Canonical JSON only supports JSON values."); +}; + +export const normalizedJson = (value: unknown): string => { + const serialized = canonicalize(normalizePresentationValue(value)); + if (serialized === undefined) + throw new TypeError("Canonical JSON serialization did not produce a value."); + return serialized; +}; + +export const canonicalJson = (value: unknown): string => { + assertCanonicalUnicode(value); + return normalizedJson(value); +}; + +const invalidPayload = (): never => { + throw new TypeError("Canonical JSON payload must contain only observably plain JSON values."); +}; + +const arrayIndex = (key: string): number | undefined => { + if (key === "0") return 0; + if (!/^[1-9][0-9]*$/u.test(key)) return undefined; + const value = Number(key); + return Number.isSafeInteger(value) && value < 2 ** 32 - 1 ? value : undefined; +}; + +const observedJsonValue = (value: unknown, ancestors: WeakSet): JsonValue => { + if (value === null || typeof value === "boolean") return value; + if (typeof value === "string") { + assertValidUnicode(value); + return value; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) invalidPayload(); + return value; + } + if (typeof value !== "object" || value === null) return invalidPayload(); + + try { + if (ancestors.has(value)) return invalidPayload(); + ancestors.add(value); + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) return invalidPayload(); + const keys = Reflect.ownKeys(value); + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length"); + if (lengthDescriptor === undefined || !("value" in lengthDescriptor)) + return invalidPayload(); + const length = lengthDescriptor.value; + if (!Number.isSafeInteger(length) || length < 0 || length >= 2 ** 32) + return invalidPayload(); + const result: JsonValue[] = Array.from({ length }); + const seen = new Set(); + for (const key of keys) { + if (key === "length") continue; + if (typeof key !== "string") return invalidPayload(); + const index = arrayIndex(key); + if (index === undefined || index >= length) return invalidPayload(); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if ( + descriptor === undefined || + !descriptor.enumerable || + !("value" in descriptor) || + seen.has(index) + ) + return invalidPayload(); + seen.add(index); + result[index] = observedJsonValue(descriptor.value, ancestors); + } + if (seen.size !== length) return invalidPayload(); + return result; + } + + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return invalidPayload(); + const result = Object.create(null) as Record; + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== "string") return invalidPayload(); + assertValidUnicode(key); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) + return invalidPayload(); + result[key] = observedJsonValue(descriptor.value, ancestors); + } + return result; + } finally { + ancestors.delete(value); + } + } catch (error) { + if (error instanceof TypeError) throw error; + return invalidPayload(); + } +}; + +export const canonicalJsonPayload = (value: unknown): string => { + const serialized = canonicalize(observedJsonValue(value, new WeakSet())); + return serialized ?? invalidPayload(); +}; diff --git a/packages/presentation-core/src/canonicalization/hash.ts b/packages/presentation-core/src/canonicalization/hash.ts new file mode 100644 index 0000000..1c6d01b --- /dev/null +++ b/packages/presentation-core/src/canonicalization/hash.ts @@ -0,0 +1,30 @@ +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex } from "@noble/hashes/utils.js"; + +const utf8Bytes = (value: string) => { + const bytes: number[] = []; + for (let index = 0; index < value.length; index++) { + let codePoint = value.codePointAt(index)!; + if (codePoint > 0xffff) index++; + else if (codePoint >= 0xd800 && codePoint <= 0xdfff) codePoint = 0xfffd; + if (codePoint <= 0x7f) bytes.push(codePoint); + else if (codePoint <= 0x7ff) bytes.push(0xc0 | (codePoint >> 6), 0x80 | (codePoint & 0x3f)); + else if (codePoint <= 0xffff) + bytes.push( + 0xe0 | (codePoint >> 12), + 0x80 | ((codePoint >> 6) & 0x3f), + 0x80 | (codePoint & 0x3f), + ); + else + bytes.push( + 0xf0 | (codePoint >> 18), + 0x80 | ((codePoint >> 12) & 0x3f), + 0x80 | ((codePoint >> 6) & 0x3f), + 0x80 | (codePoint & 0x3f), + ); + } + return Uint8Array.from(bytes); +}; + +export const hashCanonicalJson = (canonical: string) => + `sha256:${bytesToHex(sha256(utf8Bytes(canonical)))}`; diff --git a/packages/presentation-core/src/canonicalization/payload.ts b/packages/presentation-core/src/canonicalization/payload.ts new file mode 100644 index 0000000..3ef6c19 --- /dev/null +++ b/packages/presentation-core/src/canonicalization/payload.ts @@ -0,0 +1,6 @@ +import { canonicalJsonPayload } from "./canonical-json.js"; +import { hashCanonicalJson } from "./hash.js"; + +/** Hashes a payload that is observably composed only of plain JSON values. */ +export const hashCanonicalJsonPayload = (value: unknown): string => + hashCanonicalJson(canonicalJsonPayload(value)); diff --git a/packages/presentation-core/src/domain/model.ts b/packages/presentation-core/src/domain/model.ts new file mode 100644 index 0000000..71eeb73 --- /dev/null +++ b/packages/presentation-core/src/domain/model.ts @@ -0,0 +1,43 @@ +import type { + SerializedPresentationDefinitionV1, + SerializedRenderBundleV1, +} from "@unframe/contracts/presentation"; + +type DeepReadonly = T extends readonly unknown[] + ? { readonly [Key in keyof T]: DeepReadonly } + : T extends object + ? { readonly [Key in keyof T]: DeepReadonly } + : T; + +export type PresentationDefinition = SerializedPresentationDefinitionV1; +export type RenderBundle = SerializedRenderBundleV1; +export type SemanticSurface = DeepReadonly< + SerializedPresentationDefinitionV1["scene"]["surfaces"][string] +>; +export type SurfaceRenderIntent = SemanticSurface["renderIntent"]; +export type SurfaceContentNode = SemanticSurface["contentNodes"][string]; +export type CompletedSemanticTree = DeepReadonly< + SerializedRenderBundleV1["surfaces"][string]["semanticsByState"][string] +>; +export type HitRegion = DeepReadonly< + SerializedRenderBundleV1["surfaces"][string]["interactionsByState"][string][number] +>; +export type TextureArtifact = DeepReadonly< + SerializedRenderBundleV1["surfaces"][string]["renderSurfaces"][string]["artifacts"][string]["states"][string]["textures"][number] +>; + +export type Diagnostic = { + code: string; + path: readonly (string | number)[]; + message: string; + relatedPath?: readonly (string | number)[]; +}; + +export type ValidationResult = + | { valid: true; value: T; diagnostics: [] } + | { valid: false; diagnostics: Diagnostic[] }; + +export type PresentationArtifacts = { + definition: PresentationDefinition; + renderBundle: RenderBundle; +}; diff --git a/packages/presentation-core/src/index.ts b/packages/presentation-core/src/index.ts new file mode 100644 index 0000000..21f5bdb --- /dev/null +++ b/packages/presentation-core/src/index.ts @@ -0,0 +1,8 @@ +export * from "./domain/model.js"; +export * from "./validation/presentation.js"; +export { hashCanonicalJsonPayload } from "./canonicalization/payload.js"; +export { + verifyPublicationIntegrityV2, + type PublicationArtifactsV2, + type PublicationIntegrityInputV2, +} from "./publication-v2/integrity.js"; diff --git a/packages/presentation-core/src/publication-v2/integrity.ts b/packages/presentation-core/src/publication-v2/integrity.ts new file mode 100644 index 0000000..9bd4271 --- /dev/null +++ b/packages/presentation-core/src/publication-v2/integrity.ts @@ -0,0 +1,608 @@ +import { + assetSetManifestV2Schema, + buildManifestV2Schema, + presentationDefinitionV2Schema, + publishedPresentationV2Schema, + renderBundleV2Schema, + type AssetDescriptorV2, + type AssetSetManifestV2, + type BuildManifestV2, + type PresentationDefinitionV2, + type PublishedPresentationV2, + type RenderBundleV2, +} from "@unframe/contracts/presentation/v2"; +import type { ZodType } from "zod"; + +import { hashCanonicalJsonPayload } from "../canonicalization/payload.js"; +import type { Diagnostic, ValidationResult } from "../domain/model.js"; +import { snapshotPlainJson } from "./plain-json.js"; + +export type PublicationArtifactsV2 = { + definition: PresentationDefinitionV2; + renderBundle: RenderBundleV2; + assetSet: AssetSetManifestV2; + buildManifest: BuildManifestV2; + publishedPresentation: PublishedPresentationV2; +}; + +export type PublicationIntegrityInputV2 = { + definition: unknown; + renderBundle: unknown; + assetSet: unknown; + buildManifest: unknown; + publishedPresentation: unknown; +}; + +type ArtifactName = keyof PublicationIntegrityInputV2; +type Path = readonly (string | number)[]; + +const artifactNames = [ + "assetSet", + "buildManifest", + "definition", + "publishedPresentation", + "renderBundle", +] as const satisfies readonly ArtifactName[]; + +const diagnostic = (code: string, path: Path, message: string): Diagnostic => ({ + code, + path, + message, +}); + +const compareText = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0); +const pointer = (path: Path) => + path.map((segment) => String(segment).replaceAll("~", "~0").replaceAll("/", "~1")).join("/"); +const sortedDiagnostics = (diagnostics: Diagnostic[]) => + diagnostics.sort((left, right) => { + const artifact = compareText(String(left.path[0] ?? ""), String(right.path[0] ?? "")); + if (artifact !== 0) return artifact; + const path = compareText(pointer(left.path), pointer(right.path)); + return path !== 0 ? path : compareText(left.code, right.code); + }); + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); +const ownValue = (record: Record, key: string): T | undefined => + Object.hasOwn(record, key) ? record[key] : undefined; + +const parseArtifact = ( + name: ArtifactName, + schema: ZodType, + value: unknown, + diagnostics: Diagnostic[], +): T | undefined => { + const result = schema.safeParse(value); + if (result.success) return result.data; + for (const issue of result.error.issues) + diagnostics.push( + diagnostic( + "structure.invalid", + [ + name, + ...issue.path.map((segment) => (typeof segment === "symbol" ? String(segment) : segment)), + ], + issue.message, + ), + ); + return undefined; +}; + +type AssetReference = { + path: Path; + descriptor?: AssetDescriptorV2; + allowedMediaTypes: readonly AssetDescriptorV2["mediaType"][]; +}; + +const fontMediaTypes = ["font/ttf", "font/otf"] as const; + +const collectDefinitionAssetReferences = ( + definition: PresentationDefinitionV2, + references: Map, +) => { + const add = ( + assetId: string, + path: Path, + allowedMediaTypes: readonly AssetDescriptorV2["mediaType"][], + ) => { + const entries = references.get(assetId) ?? []; + entries.push({ path, allowedMediaTypes }); + references.set(assetId, entries); + }; + + for (const [nodeId, node] of Object.entries(definition.scene.nodes)) + if (node.kind === "model") + add(node.assetId, ["definition", "scene", "nodes", nodeId, "assetId"], ["model/gltf-binary"]); + + for (const [surfaceId, surface] of Object.entries(definition.scene.surfaces)) { + for (const [contentId, content] of Object.entries(surface.contentNodes)) { + const base = [ + "definition", + "scene", + "surfaces", + surfaceId, + "contentNodes", + contentId, + ] as const; + if (content.kind === "image") + add(content.assetId, [...base, "assetId"], ["image/png", "image/jpeg"]); + if (content.kind === "video") add(content.assetId, [...base, "assetId"], ["video/mp4"]); + if (content.kind === "text") { + add(content.style.fontAssetId, [...base, "style", "fontAssetId"], fontMediaTypes); + for (const [index, assetId] of content.style.fallbackFontAssetIds.entries()) + add(assetId, [...base, "style", "fallbackFontAssetIds", index], fontMediaTypes); + } + } + for (const [stateId, state] of Object.entries(surface.states)) + for (const [contentId, override] of Object.entries(state.contentOverrides)) + if (override.kind === "image" && override.assetId !== undefined) + add( + override.assetId, + [ + "definition", + "scene", + "surfaces", + surfaceId, + "states", + stateId, + "contentOverrides", + contentId, + "assetId", + ], + ["image/png", "image/jpeg"], + ); + else if (override.kind === "text" && override.style !== undefined) { + add( + override.style.fontAssetId, + [ + "definition", + "scene", + "surfaces", + surfaceId, + "states", + stateId, + "contentOverrides", + contentId, + "style", + "fontAssetId", + ], + fontMediaTypes, + ); + for (const [index, assetId] of override.style.fallbackFontAssetIds.entries()) + add( + assetId, + [ + "definition", + "scene", + "surfaces", + surfaceId, + "states", + stateId, + "contentOverrides", + contentId, + "style", + "fallbackFontAssetIds", + index, + ], + fontMediaTypes, + ); + } + } +}; + +const collectBundleAssetReferences = ( + renderBundle: RenderBundleV2, + references: Map, +) => { + const add = ( + assetId: string, + path: Path, + allowedMediaTypes: readonly AssetDescriptorV2["mediaType"][], + descriptor?: AssetDescriptorV2, + ) => { + const entries = references.get(assetId) ?? []; + entries.push( + descriptor === undefined + ? { path, allowedMediaTypes } + : { path, descriptor, allowedMediaTypes }, + ); + references.set(assetId, entries); + }; + + for (const [surfaceId, surface] of Object.entries(renderBundle.surfaces)) + for (const [renderSurfaceId, renderSurface] of Object.entries(surface.renderSurfaces)) + for (const [artifactId, artifact] of Object.entries(renderSurface.artifacts)) { + const base = [ + "renderBundle", + "surfaces", + surfaceId, + "renderSurfaces", + renderSurfaceId, + "artifacts", + artifactId, + ] as const; + if (artifact.kind === "baked-web") + for (const [stateId, state] of Object.entries(artifact.states)) + add( + state.texture.assetId, + [...base, "states", stateId, "texture", "assetId"], + ["image/png"], + { + checksum: state.texture.checksum, + mediaType: state.texture.mediaType, + encodedSizeBytes: state.texture.encodedSizeBytes, + }, + ); + else if (artifact.kind === "video") + add(artifact.assetId, [...base, "assetId"], ["video/mp4"], { + checksum: artifact.checksum, + mediaType: artifact.mediaType, + encodedSizeBytes: artifact.encodedSizeBytes, + }); + else + for (const [nodeId, node] of Object.entries(artifact.nodes)) + if (node.kind === "text") { + add( + node.font.primary.assetId, + [...base, "nodes", nodeId, "font", "primary", "assetId"], + fontMediaTypes, + ); + for (const [index, font] of node.font.fallbacks.entries()) + add( + font.assetId, + [...base, "nodes", nodeId, "font", "fallbacks", index, "assetId"], + fontMediaTypes, + ); + } + } + + for (const [modelId, model] of Object.entries(renderBundle.models)) + add(model.assetId, ["renderBundle", "models", modelId, "assetId"], ["model/gltf-binary"], { + checksum: model.checksum, + mediaType: model.mediaType, + encodedSizeBytes: model.encodedSizeBytes, + }); +}; + +const descriptorEquals = (left: AssetDescriptorV2, right: AssetDescriptorV2) => + left.checksum === right.checksum && + left.mediaType === right.mediaType && + left.encodedSizeBytes === right.encodedSizeBytes; + +const verifyAssetClosure = ( + definition: PresentationDefinitionV2, + renderBundle: RenderBundleV2, + assetSet: AssetSetManifestV2, + diagnostics: Diagnostic[], +) => { + const references = new Map(); + collectDefinitionAssetReferences(definition, references); + collectBundleAssetReferences(renderBundle, references); + + for (const [assetId, assetReferences] of references) { + const descriptor = ownValue(assetSet.assets, assetId); + if (descriptor === undefined) { + for (const reference of assetReferences) + diagnostics.push( + diagnostic("reference.invalid", reference.path, `Referenced Asset ${assetId} is absent.`), + ); + continue; + } + for (const reference of assetReferences) + if (reference.descriptor !== undefined && !descriptorEquals(reference.descriptor, descriptor)) + diagnostics.push( + diagnostic( + "artifact.invalid", + reference.path, + `Embedded descriptor for Asset ${assetId} does not match AssetSetManifest.`, + ), + ); + else if (!reference.allowedMediaTypes.includes(descriptor.mediaType)) + diagnostics.push( + diagnostic( + "artifact.invalid", + reference.path, + `Asset ${assetId} has incompatible media type ${descriptor.mediaType}.`, + ), + ); + } + + for (const assetId of Object.keys(assetSet.assets)) + if (!references.has(assetId)) + diagnostics.push( + diagnostic( + "reference.invalid", + ["assetSet", "assets", assetId], + `Asset ${assetId} is not referenced by Definition or RenderBundle.`, + ), + ); +}; + +const verifyModelReferences = ( + definition: PresentationDefinitionV2, + renderBundle: RenderBundleV2, + diagnostics: Diagnostic[], +) => { + for (const [modelId, model] of Object.entries(renderBundle.models)) + if (model.assetId !== modelId) + diagnostics.push( + diagnostic( + "artifact.invalid", + ["renderBundle", "models", modelId, "assetId"], + "Compiled model key and assetId must agree.", + ), + ); + + const modelNodes = new Map( + Object.entries(definition.scene.nodes).filter((entry) => entry[1].kind === "model"), + ); + for (const [nodeId, node] of modelNodes) { + if (node.kind !== "model") continue; + const model = ownValue(renderBundle.models, node.assetId); + if (model === undefined) + diagnostics.push( + diagnostic( + "reference.invalid", + ["definition", "scene", "nodes", nodeId, "assetId"], + `Model Asset ${node.assetId} has no compiled model entry.`, + ), + ); + } + + const verifyModelNode = (nodeId: string, path: Path) => { + const node = ownValue(definition.scene.nodes, nodeId); + if (node === undefined || node.kind !== "model") { + diagnostics.push( + diagnostic( + "reference.invalid", + [...path, "nodeId"], + `Model Node ${nodeId} does not exist.`, + ), + ); + return undefined; + } + return node; + }; + const verifyClip = (nodeId: string, clipId: string, path: Path) => { + const node = verifyModelNode(nodeId, path); + if (node === undefined) return; + const model = ownValue(renderBundle.models, node.assetId); + if (model !== undefined && ownValue(model.clips, clipId) === undefined) + diagnostics.push( + diagnostic( + "reference.invalid", + [...path, "clipId"], + `Clip ${clipId} is absent from compiled Model ${node.assetId}.`, + ), + ); + }; + + for (const [groupId, group] of Object.entries(definition.flow.groups)) + for (const [stepId, step] of Object.entries(group.steps)) + for (const [cueIndex, cue] of step.cues.entries()) { + const cuePath = [ + "definition", + "flow", + "groups", + groupId, + "steps", + stepId, + "cues", + cueIndex, + ] as const; + if (cue.trigger.kind === "modelClipCompleted") + verifyClip(cue.trigger.nodeId, cue.trigger.clipId, [...cuePath, "trigger"]); + for (const [actionIndex, action] of cue.actions.entries()) { + const actionPath = [...cuePath, "actions", actionIndex] as const; + if (action.kind === "modelClip.play") + verifyClip(action.nodeId, action.clipId, actionPath); + else if ( + action.kind === "modelClip.pause" || + action.kind === "modelClip.resume" || + action.kind === "modelClip.stop" + ) + verifyModelNode(action.nodeId, actionPath); + } + } +}; + +const verifyHashes = (artifacts: PublicationArtifactsV2, diagnostics: Diagnostic[]) => { + const definitionHash = hashCanonicalJsonPayload(artifacts.definition); + const renderBundleHash = hashCanonicalJsonPayload(artifacts.renderBundle); + const assetSetHash = hashCanonicalJsonPayload(artifacts.assetSet); + const expectedHashes = { definitionHash, renderBundleHash, assetSetHash } as const; + + if (artifacts.renderBundle.definitionHash !== definitionHash) + diagnostics.push( + diagnostic( + "hash.invalid", + ["renderBundle", "definitionHash"], + "RenderBundle definitionHash does not match PresentationDefinition.", + ), + ); + for (const [field, expected] of Object.entries(expectedHashes)) + if (artifacts.buildManifest[field as keyof typeof expectedHashes] !== expected) + diagnostics.push( + diagnostic( + "hash.invalid", + ["buildManifest", field], + `${field} does not match its canonical artifact hash.`, + ), + ); +}; + +const verifyPublication = (artifacts: PublicationArtifactsV2, diagnostics: Diagnostic[]) => { + if (artifacts.definition.presentationId !== artifacts.buildManifest.presentationId) + diagnostics.push( + diagnostic( + "artifact.invalid", + ["buildManifest", "presentationId"], + "BuildManifest presentationId must equal PresentationDefinition presentationId.", + ), + ); + + const sharedFields = [ + "schemaVersion", + "buildId", + "presentationId", + "sourceDraftRevision", + "definitionHash", + "renderBundleHash", + "assetSetHash", + "contractVersions", + ] as const; + for (const field of sharedFields) { + const buildValue = artifacts.buildManifest[field]; + const publishedValue = artifacts.publishedPresentation[field]; + const equal = + typeof buildValue === "object" + ? hashCanonicalJsonPayload(buildValue) === hashCanonicalJsonPayload(publishedValue) + : buildValue === publishedValue; + if (!equal) + diagnostics.push( + diagnostic( + "publication.invalid", + ["publishedPresentation", field], + `${field} must equal BuildManifest.`, + ), + ); + } + + const { publicationManifestHash: _excluded, ...publicationPayload } = + artifacts.publishedPresentation; + const expectedPublicationHash = hashCanonicalJsonPayload(publicationPayload); + if (artifacts.publishedPresentation.publicationManifestHash !== expectedPublicationHash) + diagnostics.push( + diagnostic( + "publication.invalid", + ["publishedPresentation", "publicationManifestHash"], + "publicationManifestHash must hash every other PublishedPresentation field.", + ), + ); +}; + +const verifyUnparsedPublicationAgreement = ( + buildManifest: BuildManifestV2, + publishedPresentation: unknown, + diagnostics: Diagnostic[], +) => { + if (!isRecord(publishedPresentation)) return; + const sharedFields = [ + "schemaVersion", + "buildId", + "presentationId", + "sourceDraftRevision", + "definitionHash", + "renderBundleHash", + "assetSetHash", + "contractVersions", + ] as const; + for (const field of sharedFields) { + const publishedValue = publishedPresentation[field]; + const buildValue = buildManifest[field]; + const equal = + publishedValue !== undefined && typeof buildValue === "object" + ? hashCanonicalJsonPayload(buildValue) === hashCanonicalJsonPayload(publishedValue) + : buildValue === publishedValue; + if (!equal) + diagnostics.push( + diagnostic( + "publication.invalid", + ["publishedPresentation", field], + `${field} must equal BuildManifest.`, + ), + ); + } +}; + +export const verifyPublicationIntegrityV2 = ( + input: PublicationIntegrityInputV2, +): ValidationResult => { + const snapshot = snapshotPlainJson(input); + if (!snapshot.valid) + return { + valid: false, + diagnostics: [ + diagnostic("canonical.invalid", snapshot.failure.path, snapshot.failure.message), + ], + }; + if (!isRecord(snapshot.value)) + return { + valid: false, + diagnostics: [diagnostic("structure.invalid", [], "The input envelope must be an object.")], + }; + + const diagnostics: Diagnostic[] = []; + const expectedNames = new Set(artifactNames); + for (const key of Object.keys(snapshot.value)) + if (!expectedNames.has(key)) + diagnostics.push( + diagnostic("structure.invalid", [key], `Unknown publication artifact ${key}.`), + ); + for (const name of artifactNames) + if (!(name in snapshot.value)) + diagnostics.push( + diagnostic("structure.invalid", [name], `Publication artifact ${name} is required.`), + ); + + const definition = parseArtifact( + "definition", + presentationDefinitionV2Schema, + snapshot.value.definition, + diagnostics, + ); + const renderBundle = parseArtifact( + "renderBundle", + renderBundleV2Schema, + snapshot.value.renderBundle, + diagnostics, + ); + const assetSet = parseArtifact( + "assetSet", + assetSetManifestV2Schema, + snapshot.value.assetSet, + diagnostics, + ); + const buildManifest = parseArtifact( + "buildManifest", + buildManifestV2Schema, + snapshot.value.buildManifest, + diagnostics, + ); + const publishedPresentation = parseArtifact( + "publishedPresentation", + publishedPresentationV2Schema, + snapshot.value.publishedPresentation, + diagnostics, + ); + if ( + definition === undefined || + renderBundle === undefined || + assetSet === undefined || + buildManifest === undefined || + publishedPresentation === undefined + ) { + if (buildManifest !== undefined && publishedPresentation === undefined) + verifyUnparsedPublicationAgreement( + buildManifest, + snapshot.value.publishedPresentation, + diagnostics, + ); + return { valid: false, diagnostics: sortedDiagnostics(diagnostics) }; + } + + const artifacts = { + definition, + renderBundle, + assetSet, + buildManifest, + publishedPresentation, + }; + verifyAssetClosure(definition, renderBundle, assetSet, diagnostics); + verifyModelReferences(definition, renderBundle, diagnostics); + verifyHashes(artifacts, diagnostics); + verifyPublication(artifacts, diagnostics); + return diagnostics.length === 0 + ? { valid: true, value: artifacts, diagnostics: [] } + : { valid: false, diagnostics: sortedDiagnostics(diagnostics) }; +}; diff --git a/packages/presentation-core/src/publication-v2/plain-json.ts b/packages/presentation-core/src/publication-v2/plain-json.ts new file mode 100644 index 0000000..1604666 --- /dev/null +++ b/packages/presentation-core/src/publication-v2/plain-json.ts @@ -0,0 +1,135 @@ +type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; + +export type PlainJsonFailure = { + path: readonly (string | number)[]; + message: string; +}; + +type PlainJsonResult = + | { valid: true; value: JsonValue } + | { valid: false; failure: PlainJsonFailure }; + +class InvalidPlainJson extends Error { + constructor( + readonly path: readonly (string | number)[], + message: string, + ) { + super(message); + } +} + +const reject = (path: readonly (string | number)[], message: string): never => { + throw new InvalidPlainJson(path, message); +}; + +const assertValidUnicode = (value: string, path: readonly (string | number)[]) => { + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + if (index + 1 >= value.length) reject(path, "Lone Unicode surrogates are not JSON values."); + const next = value.charCodeAt(index + 1); + if (next < 0xdc00 || next > 0xdfff) + reject(path, "Lone Unicode surrogates are not JSON values."); + index += 1; + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + reject(path, "Lone Unicode surrogates are not JSON values."); + } + } +}; + +const arrayIndex = (key: string): number | undefined => { + if (key === "0") return 0; + if (!/^[1-9][0-9]*$/u.test(key)) return undefined; + const value = Number(key); + return Number.isSafeInteger(value) && value < 2 ** 32 - 1 ? value : undefined; +}; + +const snapshotValue = ( + value: unknown, + path: readonly (string | number)[], + ancestors: WeakSet, +): JsonValue => { + if (value === null || typeof value === "boolean") return value; + if (typeof value === "string") { + assertValidUnicode(value, path); + return value; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) reject(path, "Non-finite numbers are not JSON values."); + if (Object.is(value, -0)) reject(path, "Negative zero is not a canonical JSON value."); + return value; + } + if (typeof value !== "object" || value === null) + return reject(path, "Only plain JSON values are accepted."); + if (ancestors.has(value)) return reject(path, "Cyclic values are not JSON values."); + + ancestors.add(value); + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) + return reject(path, "Arrays must use the standard Array prototype."); + const keys = Reflect.ownKeys(value); + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length"); + if (lengthDescriptor === undefined || !("value" in lengthDescriptor)) + return reject(path, "Array length must be a data property."); + const length = lengthDescriptor.value; + if (!Number.isSafeInteger(length) || length < 0 || length >= 2 ** 32) + return reject(path, "Array length is invalid."); + const seen = new Set(); + const elements: { index: number; value: unknown }[] = []; + for (const key of keys) { + if (key === "length") continue; + if (typeof key !== "string") return reject(path, "Symbol properties are not JSON values."); + const index = arrayIndex(key); + if (index === undefined || index >= length) + return reject(path, "Arrays may only contain indexed data properties."); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if ( + descriptor === undefined || + !descriptor.enumerable || + !("value" in descriptor) || + seen.has(index) + ) + return reject([...path, index], "Array elements must be enumerable data properties."); + seen.add(index); + elements.push({ index, value: descriptor.value }); + } + if (seen.size !== length) return reject(path, "Sparse arrays are not JSON values."); + const result: JsonValue[] = []; + for (const element of elements) + result[element.index] = snapshotValue(element.value, [...path, element.index], ancestors); + return result; + } + + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) + return reject(path, "Objects must use Object.prototype or a null prototype."); + const result = Object.create(null) as Record; + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== "string") return reject(path, "Symbol properties are not JSON values."); + assertValidUnicode(key, [...path, key]); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) + return reject([...path, key], "Object members must be enumerable data properties."); + result[key] = snapshotValue(descriptor.value, [...path, key], ancestors); + } + return result; + } catch (error) { + if (error instanceof InvalidPlainJson) throw error; + return reject(path, "The input could not be inspected as plain JSON."); + } finally { + ancestors.delete(value); + } +}; + +export const snapshotPlainJson = (value: unknown): PlainJsonResult => { + try { + return { valid: true, value: snapshotValue(value, [], new WeakSet()) }; + } catch (error) { + const failure = + error instanceof InvalidPlainJson + ? { path: error.path, message: error.message } + : { path: [], message: "The input could not be inspected as plain JSON." }; + return { valid: false, failure }; + } +}; diff --git a/packages/presentation-core/src/semantic-tree/materialize.ts b/packages/presentation-core/src/semantic-tree/materialize.ts new file mode 100644 index 0000000..afc95c8 --- /dev/null +++ b/packages/presentation-core/src/semantic-tree/materialize.ts @@ -0,0 +1,282 @@ +import type { + CompletedSemanticTree, + Diagnostic, + SemanticSurface, + ValidationResult, +} from "../domain/model.js"; +import { parseIdInput, parseSemanticSurfaceInput } from "../validation/contract-input.js"; +import { + diagnostic, + hasOnlyFields, + hasOwnFields, + id, + isDenseArray, + isRecord, + pathSegment, + recordEntries, + semanticOverrideFields, + semanticRoles, + sorted, + validateTree, + type JsonRecord, +} from "../validation/shared.js"; + +const cloneJsonValue = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(cloneJsonValue); + if (!isRecord(value)) return value; + const clone = Object.create(null) as JsonRecord; + for (const [key, item] of Object.entries(value)) + Object.defineProperty(clone, key, { + value: cloneJsonValue(item), + enumerable: true, + configurable: true, + writable: true, + }); + return clone; +}; + +const cloneJsonRecord = (value: JsonRecord): JsonRecord => cloneJsonValue(value) as JsonRecord; + +export const validateMaterializableSemanticTree = ( + diagnostics: Diagnostic[], + tree: unknown, + path: string, +) => { + if ( + !isRecord(tree) || + !hasOwnFields(tree, ["rootNodeIds", "nodes"]) || + !isDenseArray(tree.rootNodeIds) || + !isRecord(tree.nodes) + ) { + diagnostics.push(diagnostic("invalid-semantic-tree", path, "Semantic tree is invalid.")); + return; + } + if (!tree.rootNodeIds.every(id) || new Set(tree.rootNodeIds).size !== tree.rootNodeIds.length) + diagnostics.push( + diagnostic("invalid-semantic-roots", `${path}/rootNodeIds`, "Roots must be unique IDs."), + ); + for (const [nodeId, node] of Object.entries(tree.nodes)) { + const nodePath = `${path}/nodes/${pathSegment(nodeId)}`; + if (!isRecord(node)) { + diagnostics.push(diagnostic("invalid-semantic-node", nodePath, "Semantic node is invalid.")); + continue; + } + if ( + !hasOnlyFields(node, [ + "id", + "parentId", + "order", + "role", + "text", + "language", + "alt", + "interactionId", + ]) || + !hasOwnFields(node, ["id", "parentId", "order", "role"]) || + node.id !== nodeId || + (node.parentId !== null && !id(node.parentId)) || + !Number.isInteger(node.order) || + (node.order as number) < 0 || + !semanticRoles.has(node.role as string) || + (node.text !== undefined && typeof node.text !== "string") || + (node.language !== undefined && (!id(node.language) || typeof node.language !== "string")) || + (node.alt !== undefined && typeof node.alt !== "string") || + (node.interactionId !== undefined && !id(node.interactionId)) + ) + diagnostics.push(diagnostic("invalid-semantic-node", nodePath, "Semantic node is invalid.")); + } + validateTree(diagnostics, tree.nodes, tree.rootNodeIds, path); +}; + +export const validateMaterializableSemanticOverrides = ( + diagnostics: Diagnostic[], + tree: unknown, + state: unknown, + path: string, +) => { + if ( + !isRecord(state) || + !Object.hasOwn(state, "semanticOverrides") || + !isDenseArray(state.semanticOverrides) + ) { + diagnostics.push( + diagnostic("invalid-semantic-overrides", path, "Semantic overrides are invalid."), + ); + return; + } + const nodes = isRecord(tree) && isRecord(tree.nodes) ? tree.nodes : undefined; + for (const [layerIndex, layer] of state.semanticOverrides.entries()) { + const layerPath = `${path}/${layerIndex}`; + if ( + !isRecord(layer) || + !Object.hasOwn(layer, "nodes") || + !hasOnlyFields(layer, ["nodes"]) || + !isRecord(layer.nodes) + ) { + diagnostics.push( + diagnostic("invalid-semantic-override", layerPath, "Semantic override is invalid."), + ); + continue; + } + for (const [nodeId, override] of Object.entries(layer.nodes)) { + const overridePath = `${layerPath}/nodes/${pathSegment(nodeId)}`; + if (!isRecord(override)) { + diagnostics.push( + diagnostic("invalid-semantic-override", overridePath, "Node override is invalid."), + ); + continue; + } + if (nodes === undefined || !Object.hasOwn(nodes, nodeId) || !isRecord(nodes[nodeId])) + diagnostics.push( + diagnostic( + "missing-semantic-node", + overridePath, + "Semantic override references a missing node.", + ), + ); + if ( + !hasOnlyFields(override, semanticOverrideFields) || + (Object.hasOwn(override, "included") && typeof override.included !== "boolean") || + (Object.hasOwn(override, "text") && + override.text !== null && + typeof override.text !== "string") || + (Object.hasOwn(override, "language") && + override.language !== null && + (!id(override.language) || typeof override.language !== "string")) || + (Object.hasOwn(override, "alt") && + override.alt !== null && + typeof override.alt !== "string") + ) + diagnostics.push( + diagnostic("invalid-semantic-override", overridePath, "Node override is invalid."), + ); + } + } +}; + +export const materializeSemanticTree = ( + surface: JsonRecord, + state: JsonRecord, + diagnostics: Diagnostic[] = [], + path = "", +) => { + const base: JsonRecord = isRecord(surface.baseSemanticTree) + ? cloneJsonRecord(surface.baseSemanticTree) + : {}; + const nodes = isRecord(base.nodes) ? (base.nodes as Record) : {}; + const excluded = new Set(); + const touched = new Set(); + const isExcluded = (nodeId: string) => { + const visited = new Set(); + let current: string | null = nodeId; + while (current !== null && !visited.has(current)) { + if (excluded.has(current)) return true; + visited.add(current); + const node: unknown = nodes[current]; + current = isRecord(node) && id(node.parentId) ? node.parentId : null; + } + return false; + }; + + const layers = state.semanticOverrides as unknown[]; + for (const [layerIndex, layer] of layers.entries()) { + for (const [nodeId, override] of recordEntries((layer as JsonRecord).nodes)) { + const overridePath = `${path}/${layerIndex}/nodes/${pathSegment(nodeId)}`; + const fields = semanticOverrideFields.filter((field) => Object.hasOwn(override, field)); + for (const field of fields) { + const claim = `${pathSegment(nodeId)}\u0000${field}`; + if (touched.has(claim)) + diagnostics.push( + diagnostic( + "duplicate-semantic-override-property", + `${overridePath}/${field}`, + "A semantic node property may be overridden only once per state.", + ), + ); + touched.add(claim); + } + if (override.included === true && isExcluded(nodeId)) + diagnostics.push( + diagnostic( + "semantic-node-reincluded", + `${overridePath}/included`, + "A node excluded by an earlier override or ancestor cannot be re-included.", + ), + ); + if ( + (override.included === false || isExcluded(nodeId)) && + fields.some((field) => field !== "included") + ) + diagnostics.push( + diagnostic( + "excluded-semantic-node-property", + overridePath, + "Excluded semantic nodes cannot override text, language, or alt.", + ), + ); + if (override.included === false) excluded.add(nodeId); + const node = Object.hasOwn(nodes, nodeId) ? nodes[nodeId] : undefined; + if (node === undefined) continue; + for (const field of ["text", "language", "alt"] as const) + if (Object.hasOwn(override, field)) { + if (override[field] === null) delete node[field]; + else node[field] = override[field]; + } + } + } + for (const nodeId of Object.keys(nodes)) if (isExcluded(nodeId)) delete nodes[nodeId]; + base.rootNodeIds = Array.isArray(base.rootNodeIds) + ? base.rootNodeIds.filter((nodeId) => Object.hasOwn(nodes, nodeId)) + : []; + return base; +}; + +export const materializeCompletedSemanticTree = ( + surface: SemanticSurface, + stateId: string, +): ValidationResult => { + try { + const parsedSurface = parseSemanticSurfaceInput(surface); + if (!parsedSurface.success || !parseIdInput(stateId).success) + return { + valid: false, + diagnostics: [diagnostic("invalid-semantic-surface", "", "Surface input is invalid.")], + }; + const validatedSurface = parsedSurface.data; + if (!Object.hasOwn(validatedSurface.states, stateId)) + return { + valid: false, + diagnostics: [ + diagnostic("unknown-surface-state", "/states", "Surface state does not exist."), + ], + }; + const state = validatedSurface.states[stateId]!; + const diagnostics: Diagnostic[] = []; + validateMaterializableSemanticTree( + diagnostics, + validatedSurface.baseSemanticTree, + "/baseSemanticTree", + ); + validateMaterializableSemanticOverrides( + diagnostics, + validatedSurface.baseSemanticTree, + state, + `/states/${pathSegment(stateId)}/semanticOverrides`, + ); + if (diagnostics.length > 0) return { valid: false, diagnostics: sorted(diagnostics) }; + const tree = materializeSemanticTree( + validatedSurface, + state, + diagnostics, + `/states/${pathSegment(stateId)}/semanticOverrides`, + ); + return diagnostics.length === 0 + ? { valid: true, value: tree as CompletedSemanticTree, diagnostics: [] } + : { valid: false, diagnostics: sorted(diagnostics) }; + } catch { + return { + valid: false, + diagnostics: [diagnostic("invalid-semantic-surface", "", "Surface input is invalid.")], + }; + } +}; diff --git a/packages/presentation-core/src/validation/artifacts.ts b/packages/presentation-core/src/validation/artifacts.ts new file mode 100644 index 0000000..642269a --- /dev/null +++ b/packages/presentation-core/src/validation/artifacts.ts @@ -0,0 +1,248 @@ +import type { PresentationArtifacts, ValidationResult } from "../domain/model.js"; +import { hashPresentationDefinition } from "../canonicalization/artifacts.js"; +import { materializeSemanticTree } from "../semantic-tree/materialize.js"; +import { normalizedJson } from "../canonicalization/canonical-json.js"; +import { + diagnostic, + finite, + id, + isRecord, + pathSegment, + positive, + recordEntries, + recordKeys, + sorted, +} from "./shared.js"; +import { validatePresentationDefinition } from "./definition.js"; +import { validateRenderBundle } from "./render-bundle.js"; +export const validatePresentationArtifacts = ( + definition: unknown, + renderBundle: unknown, +): ValidationResult => { + const definitionResult = validatePresentationDefinition(definition); + const bundleResult = validateRenderBundle(renderBundle); + const diagnostics = [...definitionResult.diagnostics, ...bundleResult.diagnostics]; + if (definitionResult.valid && bundleResult.valid) { + const expectedHash = hashPresentationDefinition(definitionResult.value); + if (!expectedHash.valid || bundleResult.value.definitionHash !== expectedHash.value) + diagnostics.push( + diagnostic( + "definition-hash-mismatch", + "/definitionHash", + "RenderBundle definitionHash must match the canonical PresentationDefinition hash.", + ), + ); + const definitionSurfaces = recordEntries(definitionResult.value.scene.surfaces); + const definitionSurfaceMap = new Map(definitionSurfaces); + const bundleSurfaceIds = new Set( + recordEntries(bundleResult.value.surfaces).map(([key]) => key), + ); + for (const [surfaceId] of definitionSurfaces) + if (!bundleSurfaceIds.has(surfaceId)) + diagnostics.push( + diagnostic( + "missing-bundle-surface", + `/scene/surfaces/${pathSegment(surfaceId)}`, + "Definition surface must have a RenderBundle surface.", + ), + ); + for (const [surfaceId, bundleSurface] of recordEntries(bundleResult.value.surfaces)) { + const definitionSurface = definitionSurfaceMap.get(surfaceId); + if (definitionSurface === undefined) { + diagnostics.push( + diagnostic( + "missing-definition-surface", + `/surfaces/${pathSegment(surfaceId)}`, + "RenderBundle surface does not exist in PresentationDefinition.", + ), + ); + continue; + } + if ( + JSON.stringify(bundleSurface.logicalSize) !== + JSON.stringify(definitionSurface.logicalSize) || + JSON.stringify(bundleSurface.physicalSizeMeters) !== + JSON.stringify(definitionSurface.physicalSizeMeters) + ) + diagnostics.push( + diagnostic( + "compiled-surface-size-mismatch", + `/surfaces/${pathSegment(surfaceId)}`, + "Compiled surface sizes must match the Definition.", + ), + ); + const definitionStates = new Set(recordEntries(definitionSurface.states).map(([key]) => key)); + const requireExactStateSet = (record: unknown, path: string) => { + const actual = new Set(recordKeys(record)); + if ( + actual.size !== definitionStates.size || + [...definitionStates].some((stateId) => !actual.has(stateId)) + ) + diagnostics.push( + diagnostic( + "surface-state-set-mismatch", + path, + "State records must exactly match the Definition states.", + ), + ); + }; + requireExactStateSet( + bundleSurface.semanticsByState, + `/surfaces/${pathSegment(surfaceId)}/semanticsByState`, + ); + requireExactStateSet( + bundleSurface.interactionsByState, + `/surfaces/${pathSegment(surfaceId)}/interactionsByState`, + ); + const interactionIds = new Set( + recordEntries(definitionSurface.interactions).map(([key]) => key), + ); + const interactionMap = new Map(recordEntries(definitionSurface.interactions)); + for (const stateId of definitionStates) { + const state = isRecord(definitionSurface.states) + ? definitionSurface.states[stateId] + : undefined; + const enabledInteractionIds = + isRecord(state) && Array.isArray(state.enabledInteractionIds) + ? new Set(state.enabledInteractionIds.filter(id)) + : new Set(); + const semanticTree = isRecord(bundleSurface.semanticsByState) + ? bundleSurface.semanticsByState[stateId] + : undefined; + if ( + isRecord(state) && + isRecord(semanticTree) && + normalizedJson(materializeSemanticTree(definitionSurface, state)) !== + normalizedJson(semanticTree) + ) + diagnostics.push( + diagnostic( + "materialized-semantic-tree-mismatch", + `/surfaces/${pathSegment(surfaceId)}/semanticsByState/${pathSegment(stateId)}`, + "Bundle semantic tree must equal the Definition state materialization.", + ), + ); + const semanticNodes = new Map( + recordEntries(isRecord(semanticTree) ? semanticTree.nodes : undefined), + ); + const regions = isRecord(bundleSurface.interactionsByState) + ? bundleSurface.interactionsByState[stateId] + : undefined; + if (!Array.isArray(regions)) continue; + const coveredInteractionIds = new Set(); + for (const [regionIndex, region] of regions.entries()) { + if (!isRecord(region)) continue; + if ( + !id(region.interactionId) || + !interactionIds.has(region.interactionId) || + !enabledInteractionIds.has(region.interactionId) + ) + diagnostics.push( + diagnostic( + "invalid-hit-region-interaction", + `/surfaces/${pathSegment(surfaceId)}/interactionsByState/${pathSegment(stateId)}/${regionIndex}/interactionId`, + "Hit region must reference an enabled Definition interaction.", + ), + ); + if (id(region.interactionId)) coveredInteractionIds.add(region.interactionId); + const interaction = id(region.interactionId) + ? interactionMap.get(region.interactionId) + : undefined; + if (interaction !== undefined && region.event !== interaction.event) + diagnostics.push( + diagnostic( + "hit-region-event-mismatch", + `/surfaces/${pathSegment(surfaceId)}/interactionsByState/${pathSegment(stateId)}/${regionIndex}/event`, + "Hit region event must match the Definition interaction event.", + ), + ); + const semanticNode = id(region.semanticNodeId) + ? semanticNodes.get(region.semanticNodeId) + : undefined; + if (semanticNode === undefined || semanticNode.interactionId !== region.interactionId) + diagnostics.push( + diagnostic( + "invalid-hit-region-semantic-node", + `/surfaces/${pathSegment(surfaceId)}/interactionsByState/${pathSegment(stateId)}/${regionIndex}/semanticNodeId`, + "Hit region must reference its interactive semantic node.", + ), + ); + const bounds = region.bounds; + if ( + !isRecord(bounds) || + !finite(bounds.x) || + !finite(bounds.y) || + !positive(bounds.width) || + !positive(bounds.height) || + (bounds.x as number) < 0 || + (bounds.y as number) < 0 || + (bounds.x as number) + (bounds.width as number) > 1 || + (bounds.y as number) + (bounds.height as number) > 1 + ) + diagnostics.push( + diagnostic( + "invalid-hit-region-bounds", + `/surfaces/${pathSegment(surfaceId)}/interactionsByState/${pathSegment(stateId)}/${regionIndex}/bounds`, + "Normalized hit region bounds must be finite and within [0, 1].", + ), + ); + } + for (const interactionId of [...enabledInteractionIds].sort()) + if (!coveredInteractionIds.has(interactionId)) + diagnostics.push( + diagnostic( + "missing-enabled-interaction-region", + `/surfaces/${pathSegment(surfaceId)}/interactionsByState/${pathSegment(stateId)}`, + "Every enabled interaction must have at least one hit region.", + ), + ); + } + for (const [renderSurfaceId, renderSurface] of recordEntries(bundleSurface.renderSurfaces)) { + const bounds = isRecord(renderSurface.logicalBounds) + ? renderSurface.logicalBounds + : undefined; + const logicalSize = Array.isArray(bundleSurface.logicalSize) + ? bundleSurface.logicalSize + : []; + if ( + !finite(bounds?.x) || + !finite(bounds?.y) || + !positive(bounds?.width) || + !positive(bounds?.height) || + (bounds.x as number) < 0 || + (bounds.y as number) < 0 || + !positive(logicalSize[0]) || + !positive(logicalSize[1]) || + (bounds.x as number) + (bounds.width as number) > logicalSize[0] || + (bounds.y as number) + (bounds.height as number) > logicalSize[1] + ) + diagnostics.push( + diagnostic( + "invalid-render-surface-bounds", + `/surfaces/${pathSegment(surfaceId)}/renderSurfaces/${pathSegment(renderSurfaceId)}/logicalBounds`, + "RenderSurface bounds must be finite, positive, and inside the SemanticSurface.", + ), + ); + requireExactStateSet( + renderSurface.stateBindings, + `/surfaces/${pathSegment(surfaceId)}/renderSurfaces/${pathSegment(renderSurfaceId)}/stateBindings`, + ); + for (const [artifactId, artifact] of recordEntries(renderSurface.artifacts)) + requireExactStateSet( + artifact.states, + `/surfaces/${pathSegment(surfaceId)}/renderSurfaces/${pathSegment(renderSurfaceId)}/artifacts/${pathSegment(artifactId)}/states`, + ); + } + } + } + return diagnostics.length === 0 && definitionResult.valid && bundleResult.valid + ? { + valid: true, + value: { + definition: definitionResult.value, + renderBundle: bundleResult.value, + }, + diagnostics: [], + } + : { valid: false, diagnostics: sorted(diagnostics) }; +}; diff --git a/packages/presentation-core/src/validation/contract-input.ts b/packages/presentation-core/src/validation/contract-input.ts new file mode 100644 index 0000000..9e40bc9 --- /dev/null +++ b/packages/presentation-core/src/validation/contract-input.ts @@ -0,0 +1,126 @@ +import { + idSchema, + presentationDefinitionSchema, + renderBundleSchema, + semanticSurfaceSchema, + type SerializedPresentationDefinitionV1, + type SerializedRenderBundleV1, +} from "@unframe/contracts/presentation"; +import type * as z from "zod"; + +type SnapshotResult = { readonly ok: true; readonly value: unknown } | { readonly ok: false }; + +type ContractParseResult = + | { readonly success: true; readonly data: T } + | { + readonly success: false; + readonly issues: readonly z.core.$ZodIssue[]; + readonly snapshot?: unknown; + }; + +const invalidSnapshot: SnapshotResult = Object.freeze({ ok: false }); + +const snapshotJsonData = (input: unknown): SnapshotResult => { + const ancestors = new WeakSet(); + + const visit = (value: unknown): SnapshotResult => { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) + return { ok: true, value }; + if (typeof value !== "object" || ancestors.has(value)) return invalidSnapshot; + + try { + ancestors.add(value); + const descriptors = Object.getOwnPropertyDescriptors(value); + if (Object.getOwnPropertySymbols(value).length !== 0) return invalidSnapshot; + + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype) return invalidSnapshot; + const length = descriptors["length"]?.value; + if (!Number.isSafeInteger(length) || length < 0) return invalidSnapshot; + const keys = Array.from({ length }, (_, index) => String(index)); + if ( + Object.keys(descriptors).length !== length + 1 || + keys.some((key) => { + const descriptor = descriptors[key]; + return ( + descriptor === undefined || + descriptor.get !== undefined || + descriptor.set !== undefined || + !descriptor.enumerable + ); + }) + ) + return invalidSnapshot; + const result: unknown[] = []; + for (const key of keys) { + const item = visit(descriptors[key]!.value); + if (!item.ok) return invalidSnapshot; + result.push(item.value); + } + return { ok: true, value: result }; + } + + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return invalidSnapshot; + const result = Object.create(null) as Record; + for (const [key, descriptor] of Object.entries(descriptors)) { + if ( + descriptor.get !== undefined || + descriptor.set !== undefined || + !descriptor.enumerable || + !("value" in descriptor) + ) + return invalidSnapshot; + const item = visit(descriptor.value); + if (!item.ok) return invalidSnapshot; + Object.defineProperty(result, key, { + value: item.value, + enumerable: true, + configurable: true, + writable: true, + }); + } + return { ok: true, value: result }; + } catch { + return invalidSnapshot; + } finally { + ancestors.delete(value); + } + }; + + return visit(input); +}; + +const parseContract = (input: unknown, schema: z.ZodType): ContractParseResult => { + const snapshot = snapshotJsonData(input); + if (!snapshot.ok) + return { + success: false, + issues: [{ code: "custom", path: [], message: "Input must be safe plain JSON data." }], + }; + const parsed = schema.safeParse(snapshot.value); + return parsed.success + ? { success: true, data: snapshot.value as T } + : { success: false, issues: parsed.error.issues, snapshot: snapshot.value }; +}; + +export const parsePresentationDefinitionInput = ( + input: unknown, +): ContractParseResult => + parseContract(input, presentationDefinitionSchema); + +export const parseRenderBundleInput = ( + input: unknown, +): ContractParseResult => parseContract(input, renderBundleSchema); + +export const parseSemanticSurfaceInput = ( + input: unknown, +): ContractParseResult => + parseContract(input, semanticSurfaceSchema); + +export const parseIdInput = (input: unknown) => idSchema.safeParse(input); diff --git a/packages/presentation-core/src/validation/definition.ts b/packages/presentation-core/src/validation/definition.ts new file mode 100644 index 0000000..fddc4fe --- /dev/null +++ b/packages/presentation-core/src/validation/definition.ts @@ -0,0 +1,528 @@ +import type { SerializedPresentationDefinitionV1 } from "@unframe/contracts/presentation"; +import type { Diagnostic, ValidationResult } from "../domain/model.js"; +import { + compareStrings, + diagnostic, + finite, + hasOnlyFields, + id, + isRecord, + pathSegment, + positive, + recordEntries, + recordKeys, + sorted, + structuralDiagnostic, + validateGroupOwner, + validateQuaternion, + validateRecordIds, + validateReferences, + validateTree, + validateVector, + type JsonRecord, +} from "./shared.js"; +import { + materializeSemanticTree, + validateMaterializableSemanticOverrides, + validateMaterializableSemanticTree, +} from "../semantic-tree/materialize.js"; +import { parsePresentationDefinitionInput } from "./contract-input.js"; +export const validatePresentationDefinition = ( + input: unknown, +): ValidationResult => { + const parsed = parsePresentationDefinitionInput(input); + if (!parsed.success && parsed.snapshot === undefined) + return { + valid: false, + diagnostics: sorted(parsed.issues.map((issue) => structuralDiagnostic("definition", issue))), + }; + input = parsed.success ? parsed.data : parsed.snapshot; + const diagnostics: Diagnostic[] = parsed.success + ? [] + : parsed.issues.map((issue) => structuralDiagnostic("definition", issue)); + if (!isRecord(input)) + return { + valid: false, + diagnostics: [ + diagnostic("invalid-definition", "", "PresentationDefinition must be an object."), + ], + }; + const scene = input.scene; + const flow = input.flow; + if (!isRecord(scene) || !isRecord(flow)) + return { + valid: false, + diagnostics: [ + diagnostic("invalid-definition", "", "PresentationDefinition is missing scene or flow."), + ], + }; + const nodes = scene.nodes; + const surfaces = scene.surfaces; + validateRecordIds(diagnostics, nodes, "/scene/nodes"); + validateRecordIds(diagnostics, surfaces, "/scene/surfaces"); + const nodeEntries = recordEntries(nodes); + const surfaceEntries = recordEntries(surfaces); + const nodeIds = new Set(nodeEntries.map(([key]) => key)); + const surfaceIds = new Set(surfaceEntries.map(([key]) => key)); + const groupIds = new Set(recordEntries(flow.groups).map(([key]) => key)); + const resourceIds = new Map(); + const registerResourceIds = (record: unknown, path: string) => { + for (const resourceId of recordKeys(record).sort(compareStrings)) { + const previousPath = resourceIds.get(resourceId); + const currentPath = `${path}/${pathSegment(resourceId)}`; + if (previousPath !== undefined) + diagnostics.push( + diagnostic( + "duplicate-resource-id", + currentPath, + "Presentation resource IDs must be globally unique.", + previousPath, + ), + ); + else resourceIds.set(resourceId, currentPath); + } + }; + registerResourceIds(input.assets, "/assets"); + registerResourceIds(isRecord(input.stage) ? input.stage.zones : undefined, "/stage/zones"); + registerResourceIds(nodes, "/scene/nodes"); + registerResourceIds(surfaces, "/scene/surfaces"); + registerResourceIds(flow.groups, "/flow/groups"); + registerResourceIds(flow.variables, "/flow/variables"); + const surfaceHosts = new Map(); + const spatialParents = new Map(); + const spatialOrders = new Map>(); + + const stage = isRecord(input.stage) ? input.stage : undefined; + validateVector(diagnostics, stage?.size, 3, "/stage/size", true); + validateRecordIds(diagnostics, stage?.zones, "/stage/zones"); + for (const [zoneId, zone] of recordEntries(stage?.zones)) { + validateGroupOwner(diagnostics, zone, groupIds, `/stage/zones/${pathSegment(zoneId)}`); + validateVector(diagnostics, zone.center, 3, `/stage/zones/${pathSegment(zoneId)}/center`); + validateVector(diagnostics, zone.size, 3, `/stage/zones/${pathSegment(zoneId)}/size`, true); + } + validateRecordIds(diagnostics, input.assets, "/assets"); + for (const [assetId, value] of Object.entries(isRecord(input.assets) ? input.assets : {})) { + const asset = isRecord(value) ? value : undefined; + if ( + asset === undefined || + !hasOnlyFields(asset, ["id", "mediaType", "checksum"]) || + asset.id !== assetId || + !id(asset.mediaType) || + !id(asset.checksum) + ) + diagnostics.push( + diagnostic( + "invalid-asset", + `/assets/${pathSegment(assetId)}`, + "Asset descriptor must match the portable contract shape.", + ), + ); + } + + for (const [nodeId, node] of nodeEntries) { + if ( + !hasOnlyFields(node, [ + "id", + "name", + "kind", + "owner", + "audience", + "parent", + "order", + "transform", + "active", + "visible", + "opacity", + "surfaceId", + ]) + ) + diagnostics.push( + diagnostic( + "unknown-surface-node-property", + `/scene/nodes/${pathSegment(nodeId)}`, + "SurfaceNode contains an unknown property.", + ), + ); + validateGroupOwner(diagnostics, node, groupIds, `/scene/nodes/${pathSegment(nodeId)}`); + const surfaceId = node.surfaceId; + if (!id(surfaceId) || !surfaceIds.has(surfaceId)) + diagnostics.push( + diagnostic( + "missing-surface", + `/scene/nodes/${pathSegment(nodeId)}/surfaceId`, + "SurfaceNode must reference a SemanticSurface.", + ), + ); + else if (surfaceHosts.has(surfaceId)) + diagnostics.push( + diagnostic( + "surface-node-cardinality", + `/scene/nodes/${pathSegment(nodeId)}/surfaceId`, + "A SemanticSurface may have one host node.", + `/scene/nodes/${pathSegment(surfaceHosts.get(surfaceId))}`, + ), + ); + else surfaceHosts.set(surfaceId, nodeId); + const parent = node.parent; + const parentId = + isRecord(parent) && parent.kind === "node" && id(parent.nodeId) ? parent.nodeId : null; + spatialParents.set(nodeId, parentId); + if ( + isRecord(parent) && + parent.kind === "node" && + (!id(parent.nodeId) || !nodeIds.has(parent.nodeId)) + ) + diagnostics.push( + diagnostic( + "missing-spatial-parent", + `/scene/nodes/${pathSegment(nodeId)}/parent/nodeId`, + "Spatial parent does not exist.", + ), + ); + if (!Number.isInteger(node.order) || (node.order as number) < 0) + diagnostics.push( + diagnostic( + "invalid-order", + `/scene/nodes/${pathSegment(nodeId)}/order`, + "order must be a non-negative integer.", + ), + ); + else { + const siblingKey = parentId ?? ""; + const orders = spatialOrders.get(siblingKey) ?? new Set(); + if (orders.has(node.order as number)) + diagnostics.push( + diagnostic( + "duplicate-sibling-order", + `/scene/nodes/${pathSegment(nodeId)}/order`, + "Sibling order must be unique.", + ), + ); + orders.add(node.order as number); + spatialOrders.set(siblingKey, orders); + } + validateVector( + diagnostics, + isRecord(node.transform) ? node.transform.position : undefined, + 3, + `/scene/nodes/${pathSegment(nodeId)}/transform/position`, + ); + validateQuaternion( + diagnostics, + isRecord(node.transform) ? node.transform.rotation : undefined, + `/scene/nodes/${pathSegment(nodeId)}/transform/rotation`, + ); + validateVector( + diagnostics, + isRecord(node.transform) ? node.transform.scale : undefined, + 3, + `/scene/nodes/${pathSegment(nodeId)}/transform/scale`, + true, + ); + } + for (const [nodeId, node] of nodeEntries) { + const parent = isRecord(node.parent) ? node.parent : undefined; + if (parent?.kind !== "node" || !id(parent.nodeId)) continue; + const target = recordEntries(nodes).find(([key]) => key === parent.nodeId)?.[1]; + const sourceOwner = isRecord(node.owner) ? node.owner : undefined; + const targetOwner = isRecord(target?.owner) ? target.owner : undefined; + if (sourceOwner?.kind === "presentation" && targetOwner?.kind === "group") + diagnostics.push( + diagnostic( + "invalid-owner-parent-lifetime", + `/scene/nodes/${pathSegment(nodeId)}/parent/nodeId`, + "Presentation-owned nodes cannot parent under group-owned nodes.", + ), + ); + if ( + sourceOwner?.kind === "group" && + targetOwner?.kind === "group" && + sourceOwner.groupId !== targetOwner.groupId + ) + diagnostics.push( + diagnostic( + "invalid-owner-parent-lifetime", + `/scene/nodes/${pathSegment(nodeId)}/parent/nodeId`, + "Nodes from different groups cannot form a parent relation.", + ), + ); + } + for (const nodeId of nodeIds) { + const visited = new Set(); + let current: string | null | undefined = nodeId; + while (current !== null && current !== undefined) { + if (visited.has(current)) { + diagnostics.push( + diagnostic( + "spatial-cycle", + `/scene/nodes/${pathSegment(nodeId)}`, + "Spatial tree must not contain a cycle.", + ), + ); + break; + } + visited.add(current); + current = spatialParents.get(current); + } + } + for (const [surfaceId, surface] of surfaceEntries) { + if (!id(surface.hostNodeId) || surfaceHosts.get(surfaceId) !== surface.hostNodeId) + diagnostics.push( + diagnostic( + "surface-node-cardinality", + `/scene/surfaces/${pathSegment(surfaceId)}/hostNodeId`, + "SemanticSurface and SurfaceNode must form a 1:1 relation.", + ), + ); + validateVector( + diagnostics, + surface.physicalSizeMeters, + 2, + `/scene/surfaces/${pathSegment(surfaceId)}/physicalSizeMeters`, + true, + ); + validateVector( + diagnostics, + surface.logicalSize, + 2, + `/scene/surfaces/${pathSegment(surfaceId)}/logicalSize`, + true, + ); + validateRecordIds( + diagnostics, + surface.contentNodes, + `/scene/surfaces/${pathSegment(surfaceId)}/contentNodes`, + ); + const contentNodes = recordEntries(surface.contentNodes); + const contentIds = new Set(contentNodes.map(([key]) => key)); + const rootFrameId = surface.rootFrameId; + if (!id(rootFrameId) || !contentIds.has(rootFrameId)) + diagnostics.push( + diagnostic( + "missing-root-frame", + `/scene/surfaces/${pathSegment(surfaceId)}/rootFrameId`, + "rootFrameId must exist.", + ), + ); + const rootFrame = id(rootFrameId) + ? recordEntries(surface.contentNodes).find(([key]) => key === rootFrameId)?.[1] + : undefined; + if (rootFrame?.kind !== "frame" || rootFrame.parentId !== null) + diagnostics.push( + diagnostic( + "invalid-root-frame", + `/scene/surfaces/${pathSegment(surfaceId)}/rootFrameId`, + "rootFrameId must reference a parentless Frame.", + ), + ); + const pseudoNodes: Record = {}; + for (const [contentId, content] of contentNodes) + pseudoNodes[contentId] = { ...content, children: content.children ?? [] }; + validateTree( + diagnostics, + pseudoNodes, + rootFrameId && id(rootFrameId) ? [rootFrameId] : [], + `/scene/surfaces/${pathSegment(surfaceId)}/contentNodes`, + "children", + ); + for (const [contentId, content] of contentNodes) { + const children = Array.isArray(content.children) ? content.children.filter(id) : []; + for (const childId of children) + if ( + recordEntries(surface.contentNodes).find(([key]) => key === childId)?.[1].parentId !== + contentId + ) + diagnostics.push( + diagnostic( + "content-parent-child-mismatch", + `/scene/surfaces/${pathSegment(surfaceId)}/contentNodes/${pathSegment(contentId)}/children`, + "Child must reference this Frame as parent.", + ), + ); + const parentChildren = id(content.parentId) + ? recordEntries(surface.contentNodes).find(([key]) => key === content.parentId)?.[1] + .children + : undefined; + if ( + id(content.parentId) && + (!Array.isArray(parentChildren) || !parentChildren.includes(contentId)) + ) + diagnostics.push( + diagnostic( + "orphan-content-node", + `/scene/surfaces/${pathSegment(surfaceId)}/contentNodes/${pathSegment(contentId)}`, + "Non-root content node must appear in its parent children.", + ), + ); + if (content.kind === "text") { + const parent = id(content.parentId) + ? recordEntries(surface.contentNodes).find(([key]) => key === content.parentId)?.[1] + : undefined; + if (parent?.kind !== "frame") + diagnostics.push( + diagnostic( + "invalid-text-parent", + `/scene/surfaces/${pathSegment(surfaceId)}/contentNodes/${pathSegment(contentId)}/parentId`, + "Text nodes must have a Frame parent.", + ), + ); + const placement = isRecord(content.placement) ? content.placement : undefined; + if ( + !finite(placement?.x) || + !finite(placement?.y) || + !positive(placement?.width) || + !positive(placement?.height) + ) + diagnostics.push( + diagnostic( + "invalid-text-placement", + `/scene/surfaces/${pathSegment(surfaceId)}/contentNodes/${pathSegment(contentId)}/placement`, + "Text placement must use finite coordinates and positive dimensions.", + ), + ); + } + } + const semanticTree = isRecord(surface.baseSemanticTree) ? surface.baseSemanticTree : undefined; + const semanticTreeDiagnosticsStart = diagnostics.length; + validateMaterializableSemanticTree( + diagnostics, + semanticTree, + `/scene/surfaces/${pathSegment(surfaceId)}/baseSemanticTree`, + ); + const hasInvalidSemanticTree = diagnostics.length > semanticTreeDiagnosticsStart; + validateRecordIds( + diagnostics, + surface.interactions, + `/scene/surfaces/${pathSegment(surfaceId)}/interactions`, + ); + validateRecordIds( + diagnostics, + surface.states, + `/scene/surfaces/${pathSegment(surfaceId)}/states`, + ); + const interactionIds = new Set(recordEntries(surface.interactions).map(([key]) => key)); + for (const [semanticNodeId, semanticNode] of recordEntries(semanticTree?.nodes)) + if ( + semanticNode.interactionId !== undefined && + (!id(semanticNode.interactionId) || !interactionIds.has(semanticNode.interactionId)) + ) + diagnostics.push( + diagnostic( + "missing-interaction", + `/scene/surfaces/${pathSegment(surfaceId)}/baseSemanticTree/nodes/${pathSegment(semanticNodeId)}/interactionId`, + "Semantic node interaction must exist.", + ), + ); + const stateIds = new Set(recordEntries(surface.states).map(([key]) => key)); + const finiteStateIds = + isRecord(surface.renderIntent) && + isRecord(surface.renderIntent.updateModel) && + surface.renderIntent.updateModel.kind === "finite-state" && + Array.isArray(surface.renderIntent.updateModel.stateIds) + ? new Set(surface.renderIntent.updateModel.stateIds.filter(id)) + : undefined; + if ( + finiteStateIds !== undefined && + (finiteStateIds.size !== stateIds.size || + [...stateIds].some((stateId) => !finiteStateIds.has(stateId))) + ) + diagnostics.push( + diagnostic( + "render-intent-state-set-mismatch", + `/scene/surfaces/${pathSegment(surfaceId)}/renderIntent/updateModel/stateIds`, + "finite-state stateIds must exactly match surface states.", + ), + ); + if (!id(surface.initialStateId) || !stateIds.has(surface.initialStateId)) + diagnostics.push( + diagnostic( + "missing-initial-state", + `/scene/surfaces/${pathSegment(surfaceId)}/initialStateId`, + "initialStateId must exist.", + ), + ); + for (const [stateId, state] of recordEntries(surface.states)) { + const enabled = Array.isArray(state.enabledInteractionIds) + ? state.enabledInteractionIds.filter(id) + : []; + validateReferences( + diagnostics, + enabled, + interactionIds, + `/scene/surfaces/${pathSegment(surfaceId)}/states/${pathSegment(stateId)}/enabledInteractionIds`, + "missing-interaction", + ); + const semanticOverrideDiagnosticsStart = diagnostics.length; + validateMaterializableSemanticOverrides( + diagnostics, + semanticTree, + state, + `/scene/surfaces/${pathSegment(surfaceId)}/states/${pathSegment(stateId)}/semanticOverrides`, + ); + if (hasInvalidSemanticTree || diagnostics.length > semanticOverrideDiagnosticsStart) continue; + const materializedTree = materializeSemanticTree( + surface, + state, + diagnostics, + `/scene/surfaces/${pathSegment(surfaceId)}/states/${pathSegment(stateId)}/semanticOverrides`, + ); + validateTree( + diagnostics, + materializedTree.nodes, + materializedTree.rootNodeIds, + `/scene/surfaces/${pathSegment(surfaceId)}/states/${pathSegment(stateId)}/materializedSemanticTree`, + ); + } + } + validateRecordIds(diagnostics, flow.groups, "/flow/groups"); + validateRecordIds(diagnostics, flow.variables, "/flow/variables"); + const groups = recordEntries(flow.groups); + const flowGroupIds = new Set(groups.map(([key]) => key)); + if (!id(flow.initialGroupId) || !flowGroupIds.has(flow.initialGroupId)) + diagnostics.push( + diagnostic("missing-initial-group", "/flow/initialGroupId", "initialGroupId must exist."), + ); + for (const [groupId, group] of groups) { + validateRecordIds(diagnostics, group.steps, `/flow/groups/${pathSegment(groupId)}/steps`); + const stepIds = new Set(recordEntries(group.steps).map(([key]) => key)); + registerResourceIds(group.steps, `/flow/groups/${pathSegment(groupId)}/steps`); + if (!id(group.initialStepId) || !stepIds.has(group.initialStepId)) + diagnostics.push( + diagnostic( + "missing-initial-step", + `/flow/groups/${pathSegment(groupId)}/initialStepId`, + "initialStepId must exist.", + ), + ); + } + for (const [variableId, variable] of recordEntries(flow.variables)) { + validateGroupOwner( + diagnostics, + variable, + groupIds, + `/flow/variables/${pathSegment(variableId)}`, + ); + const type = variable.type; + const value = variable.initialValue; + if ( + (type === "string" && typeof value !== "string") || + (type === "boolean" && typeof value !== "boolean") || + (type === "number" && !finite(value)) || + (type === "null" && value !== null) + ) + diagnostics.push( + diagnostic( + "variable-type-mismatch", + `/flow/variables/${pathSegment(variableId)}/initialValue`, + "initialValue must match the declared scalar type.", + ), + ); + } + return diagnostics.length === 0 + ? { + valid: true, + value: input as unknown as SerializedPresentationDefinitionV1, + diagnostics: [], + } + : { valid: false, diagnostics: sorted(diagnostics) }; +}; diff --git a/packages/presentation-core/src/validation/presentation.ts b/packages/presentation-core/src/validation/presentation.ts new file mode 100644 index 0000000..a770036 --- /dev/null +++ b/packages/presentation-core/src/validation/presentation.ts @@ -0,0 +1,10 @@ +export { validatePresentationArtifacts } from "./artifacts.js"; +export { validatePresentationDefinition } from "./definition.js"; +export { validateRenderBundle } from "./render-bundle.js"; +export { materializeCompletedSemanticTree } from "../semantic-tree/materialize.js"; +export { + canonicalizePresentationDefinition, + canonicalizeRenderBundle, + hashPresentationDefinition, + hashRenderBundle, +} from "../canonicalization/artifacts.js"; diff --git a/packages/presentation-core/src/validation/render-bundle.ts b/packages/presentation-core/src/validation/render-bundle.ts new file mode 100644 index 0000000..13be296 --- /dev/null +++ b/packages/presentation-core/src/validation/render-bundle.ts @@ -0,0 +1,167 @@ +import type { SerializedRenderBundleV1 } from "@unframe/contracts/presentation"; +import type { Diagnostic, ValidationResult } from "../domain/model.js"; +import { + diagnostic, + id, + isRecord, + pathSegment, + recordEntries, + sorted, + structuralDiagnostic, + validateRecordIds, + validateReferences, + validateTree, + validateVector, +} from "./shared.js"; +import { parseRenderBundleInput } from "./contract-input.js"; +export const validateRenderBundle = ( + input: unknown, +): ValidationResult => { + const parsed = parseRenderBundleInput(input); + if (!parsed.success && parsed.snapshot === undefined) + return { + valid: false, + diagnostics: sorted( + parsed.issues.map((issue) => structuralDiagnostic("render-bundle", issue)), + ), + }; + input = parsed.success ? parsed.data : parsed.snapshot; + const diagnostics: Diagnostic[] = parsed.success + ? [] + : parsed.issues.map((issue) => structuralDiagnostic("render-bundle", issue)); + if (!isRecord(input) || !isRecord(input.surfaces)) + return { + valid: false, + diagnostics: [diagnostic("invalid-render-bundle", "", "RenderBundle must contain surfaces.")], + }; + const renderSurfacePaths = new Map(); + const artifactPaths = new Map(); + validateRecordIds(diagnostics, input.surfaces, "/surfaces", "semanticSurfaceId"); + for (const [surfaceId, surface] of recordEntries(input.surfaces)) { + validateVector( + diagnostics, + surface.logicalSize, + 2, + `/surfaces/${pathSegment(surfaceId)}/logicalSize`, + true, + ); + validateVector( + diagnostics, + surface.physicalSizeMeters, + 2, + `/surfaces/${pathSegment(surfaceId)}/physicalSizeMeters`, + true, + ); + for (const [stateId, semanticTree] of recordEntries(surface.semanticsByState)) + validateTree( + diagnostics, + semanticTree.nodes, + semanticTree.rootNodeIds, + `/surfaces/${pathSegment(surfaceId)}/semanticsByState/${pathSegment(stateId)}`, + ); + validateRecordIds( + diagnostics, + surface.renderSurfaces, + `/surfaces/${pathSegment(surfaceId)}/renderSurfaces`, + ); + const renderSurfaceIds = new Set(recordEntries(surface.renderSurfaces).map(([key]) => key)); + const declaredRenderSurfaceIds = Array.isArray(surface.renderSurfaceIds) + ? surface.renderSurfaceIds.filter(id) + : []; + validateReferences( + diagnostics, + declaredRenderSurfaceIds, + renderSurfaceIds, + `/surfaces/${pathSegment(surfaceId)}/renderSurfaceIds`, + "missing-render-surface", + ); + if ( + declaredRenderSurfaceIds.length !== renderSurfaceIds.size || + new Set(declaredRenderSurfaceIds).size !== renderSurfaceIds.size + ) + diagnostics.push( + diagnostic( + "render-surface-set-mismatch", + `/surfaces/${pathSegment(surfaceId)}/renderSurfaceIds`, + "renderSurfaceIds must contain each render surface exactly once.", + ), + ); + for (const [renderSurfaceId, renderSurface] of recordEntries(surface.renderSurfaces)) { + const renderSurfacePath = `/surfaces/${pathSegment(surfaceId)}/renderSurfaces/${pathSegment(renderSurfaceId)}`; + const previousRenderSurfacePath = renderSurfacePaths.get(renderSurfaceId); + if (previousRenderSurfacePath !== undefined) + diagnostics.push( + diagnostic( + "duplicate-render-surface-id", + renderSurfacePath, + "RenderSurface IDs must be globally unique within a RenderBundle.", + previousRenderSurfacePath, + ), + ); + else renderSurfacePaths.set(renderSurfaceId, renderSurfacePath); + if (renderSurface.semanticSurfaceId !== surfaceId) + diagnostics.push( + diagnostic( + "render-surface-semantic-surface-mismatch", + `/surfaces/${pathSegment(surfaceId)}/renderSurfaces/${pathSegment(renderSurfaceId)}/semanticSurfaceId`, + "RenderSurface must belong to its enclosing SemanticSurface.", + ), + ); + validateRecordIds( + diagnostics, + renderSurface.artifacts, + `/surfaces/${pathSegment(surfaceId)}/renderSurfaces/${pathSegment(renderSurfaceId)}/artifacts`, + ); + const artifactIds = new Set(recordEntries(renderSurface.artifacts).map(([key]) => key)); + for (const [stateId, binding] of recordEntries(renderSurface.stateBindings)) { + if (binding.kind === "artifacts") + validateReferences( + diagnostics, + Array.isArray(binding.artifactIds) ? binding.artifactIds.filter(id) : [], + artifactIds, + `/surfaces/${pathSegment(surfaceId)}/renderSurfaces/${pathSegment(renderSurfaceId)}/stateBindings/${pathSegment(stateId)}/artifactIds`, + "missing-artifact", + ); + } + for (const [artifactId, artifact] of recordEntries(renderSurface.artifacts)) { + const artifactPath = `${renderSurfacePath}/artifacts/${pathSegment(artifactId)}`; + const previousArtifactPath = artifactPaths.get(artifactId); + if (previousArtifactPath !== undefined) + diagnostics.push( + diagnostic( + "duplicate-renderer-artifact-id", + artifactPath, + "Renderer artifact IDs must be globally unique within a RenderBundle.", + previousArtifactPath, + ), + ); + else artifactPaths.set(artifactId, artifactPath); + validateRecordIds( + diagnostics, + artifact.states, + `/surfaces/${pathSegment(surfaceId)}/renderSurfaces/${pathSegment(renderSurfaceId)}/artifacts/${pathSegment(artifactId)}/states`, + "stateId", + ); + for (const [stateId, state] of recordEntries(artifact.states)) + for (const [textureIndex, texture] of (Array.isArray(state.textures) + ? state.textures + : [] + ).entries()) + validateVector( + diagnostics, + isRecord(texture) ? texture.pixelSize : undefined, + 2, + `${artifactPath}/states/${pathSegment(stateId)}/textures/${textureIndex}/pixelSize`, + true, + ); + } + } + } + return diagnostics.length === 0 + ? { + valid: true, + value: input as unknown as SerializedRenderBundleV1, + diagnostics: [], + } + : { valid: false, diagnostics: sorted(diagnostics) }; +}; diff --git a/packages/presentation-core/src/validation/shared.ts b/packages/presentation-core/src/validation/shared.ts new file mode 100644 index 0000000..51ecdae --- /dev/null +++ b/packages/presentation-core/src/validation/shared.ts @@ -0,0 +1,372 @@ +import type { Diagnostic } from "../domain/model.js"; + +export type JsonRecord = Record; +type DiagnosticPath = string | readonly (string | number)[]; + +const pathSegment = (value: unknown) => String(value).replaceAll("~", "~0").replaceAll("/", "~1"); + +const toPath = (path: DiagnosticPath): readonly (string | number)[] => + typeof path === "string" + ? path + .split("/") + .filter(Boolean) + .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~")) + : path; + +const diagnostic = ( + code: string, + path: DiagnosticPath, + message: string, + relatedPath?: DiagnosticPath, +): Diagnostic => + relatedPath === undefined + ? { code, path: toPath(path), message } + : { code, path: toPath(path), message, relatedPath: toPath(relatedPath) }; + +const sorted = (diagnostics: Diagnostic[]) => + [...diagnostics].sort((left, right) => { + const leftKey = `${left.path.join("/")}\u0000${left.code}`; + const rightKey = `${right.path.join("/")}\u0000${right.code}`; + return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; + }); + +const schemaPath = (path: readonly PropertyKey[]): readonly (string | number)[] => + path.map((segment) => (typeof segment === "number" ? segment : String(segment))); + +const containerPath = ( + path: readonly (string | number)[], + fields: readonly string[], +): readonly (string | number)[] | undefined => { + for (let index = path.length - 1; index >= 0; index--) + if (fields.includes(String(path[index]))) return path.slice(0, index + 1); + return undefined; +}; + +const structuralDiagnostic = ( + contract: "definition" | "render-bundle", + issue: { readonly path: readonly PropertyKey[]; readonly message: string }, +): Diagnostic => { + const path = schemaPath(issue.path); + const joined = path.join("/"); + if (contract === "definition") { + if (joined.startsWith("assets/")) + return diagnostic("invalid-asset", path.slice(0, 2), "Asset descriptor is invalid."); + if (joined.startsWith("scene/nodes/") && issue.message.includes("Unrecognized key")) + return diagnostic( + "unknown-surface-node-property", + path.slice(0, 3), + "SurfaceNode contains an unknown property.", + ); + const vectorPath = containerPath(path, [ + "physicalSizeMeters", + "logicalSize", + "position", + "scale", + "center", + "size", + ]); + if (vectorPath) + return diagnostic("invalid-vector", vectorPath, "Vector does not match the contract schema."); + const rotationPath = containerPath(path, ["rotation"]); + if (rotationPath) + return diagnostic( + "invalid-quaternion", + rotationPath, + "Quaternion does not match the contract schema.", + ); + const placementPath = containerPath(path, ["placement"]); + if (joined.includes("/contentNodes/") && placementPath) + return diagnostic( + "invalid-text-placement", + placementPath, + "Text placement does not match the contract schema.", + ); + return diagnostic("invalid-definition", path, issue.message); + } + const vectorPath = containerPath(path, ["logicalSize", "physicalSizeMeters", "pixelSize"]); + if (vectorPath) + return diagnostic("invalid-vector", vectorPath, "Vector does not match the contract schema."); + return diagnostic("invalid-render-bundle", path, issue.message); +}; + +const compareStrings = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0); + +const isRecord = (value: unknown): value is JsonRecord => + typeof value === "object" && value !== null && !Array.isArray(value); + +const recordEntries = (value: unknown): [string, JsonRecord][] => + isRecord(value) + ? Object.entries(value) + .filter((entry): entry is [string, JsonRecord] => isRecord(entry[1])) + .sort(([left], [right]) => compareStrings(left, right)) + : []; + +const recordKeys = (value: unknown): string[] => (isRecord(value) ? Object.keys(value) : []); + +const id = (value: unknown): value is string => typeof value === "string" && value.length > 0; + +const finite = (value: unknown): value is number => + typeof value === "number" && Number.isFinite(value); + +const positive = (value: unknown): value is number => finite(value) && value > 0; + +const validateRecordIds = ( + diagnostics: Diagnostic[], + value: unknown, + path: string, + idField = "id", +) => { + for (const [key, item] of recordEntries(value)) { + if (item[idField] !== key) + diagnostics.push( + diagnostic( + "record-key-id-mismatch", + `${path}/${pathSegment(key)}`, + `Record key must match value.${idField}.`, + ), + ); + } +}; + +const validateReferences = ( + diagnostics: Diagnostic[], + references: readonly string[], + target: Set, + path: string, + code: string, +) => { + for (const reference of references) { + if (!target.has(reference)) + diagnostics.push( + diagnostic(code, `${path}/${pathSegment(reference)}`, "Reference does not exist."), + ); + } +}; + +const validateGroupOwner = ( + diagnostics: Diagnostic[], + resource: JsonRecord, + groupIds: Set, + path: string, +) => { + const owner = isRecord(resource.owner) ? resource.owner : undefined; + if (owner?.kind === "group" && (!id(owner.groupId) || !groupIds.has(owner.groupId))) + diagnostics.push( + diagnostic( + "missing-owner-group", + `${path}/owner/groupId`, + "Group owner must reference an existing group.", + ), + ); +}; + +const validateQuaternion = (diagnostics: Diagnostic[], value: unknown, path: string) => { + if (!Array.isArray(value) || value.length !== 4 || !value.every(finite)) { + diagnostics.push( + diagnostic("invalid-quaternion", path, "Rotation must be a finite [x, y, z, w] quaternion."), + ); + return; + } + const magnitude = Math.hypot(...value); + if (magnitude === 0 || Math.abs(magnitude - 1) > 1e-9) { + diagnostics.push( + diagnostic( + "unnormalized-quaternion", + path, + "Rotation quaternion must be nonzero and normalized.", + ), + ); + } +}; + +const validateVector = ( + diagnostics: Diagnostic[], + value: unknown, + dimensions: number, + path: string, + strictlyPositive = false, +) => { + if ( + !Array.isArray(value) || + value.length !== dimensions || + !value.every(strictlyPositive ? positive : finite) + ) { + diagnostics.push( + diagnostic( + "invalid-vector", + path, + `Expected a ${strictlyPositive ? "positive " : "finite "}${dimensions}-vector.`, + ), + ); + } +}; + +const validateTree = ( + diagnostics: Diagnostic[], + nodes: unknown, + roots: unknown, + path: string, + childField?: string, +) => { + const entries = recordEntries(nodes); + const nodeIds = new Set(entries.map(([key]) => key)); + validateRecordIds(diagnostics, nodes, `${path}/nodes`); + const rootIds = Array.isArray(roots) ? roots.filter(id) : []; + const rootSet = new Set(rootIds); + const parentById = new Map(); + + for (const [nodeId, node] of entries) { + const parentId = node.parentId; + if (parentId !== null && !id(parentId)) { + diagnostics.push( + diagnostic( + "invalid-parent", + `${path}/nodes/${pathSegment(nodeId)}/parentId`, + "parentId must be an ID or null.", + ), + ); + } else { + parentById.set(nodeId, parentId); + if (parentId !== null && !nodeIds.has(parentId)) + diagnostics.push( + diagnostic( + "missing-parent", + `${path}/nodes/${pathSegment(nodeId)}/parentId`, + "Parent does not exist.", + ), + ); + if (parentId === null && !rootSet.has(nodeId)) + diagnostics.push( + diagnostic( + "orphan-root", + `${path}/nodes/${pathSegment(nodeId)}`, + "Root node must appear in rootNodeIds.", + ), + ); + if (parentId !== null && rootSet.has(nodeId)) + diagnostics.push( + diagnostic( + "non-root-in-root-list", + `${path}/rootNodeIds`, + "Only parentless nodes may be roots.", + ), + ); + } + if (!Number.isInteger(node.order) || (node.order as number) < 0) + diagnostics.push( + diagnostic( + "invalid-order", + `${path}/nodes/${pathSegment(nodeId)}/order`, + "order must be a non-negative integer.", + ), + ); + if (childField !== undefined) { + const children = node[childField]; + if (!Array.isArray(children) || !children.every(id)) + diagnostics.push( + diagnostic( + "invalid-children", + `${path}/nodes/${pathSegment(nodeId)}/${childField}`, + "children must be an ID array.", + ), + ); + else + validateReferences( + diagnostics, + children, + nodeIds, + `${path}/nodes/${pathSegment(nodeId)}/${childField}`, + "missing-child", + ); + } + } + + for (const rootId of rootIds) + if (!nodeIds.has(rootId)) + diagnostics.push(diagnostic("missing-root", `${path}/rootNodeIds`, "Root does not exist.")); + const siblingOrders = new Map>(); + for (const [nodeId, node] of entries) { + const parentId = parentById.get(nodeId); + if (parentId === undefined || !Number.isInteger(node.order)) continue; + const key = parentId ?? ""; + const orders = siblingOrders.get(key) ?? new Set(); + if (orders.has(node.order as number)) + diagnostics.push( + diagnostic( + "duplicate-sibling-order", + `${path}/nodes/${pathSegment(nodeId)}/order`, + "Sibling order must be unique.", + ), + ); + orders.add(node.order as number); + siblingOrders.set(key, orders); + } + for (const nodeId of nodeIds) { + const visited = new Set(); + let current: string | null | undefined = nodeId; + while (current !== null && current !== undefined) { + if (visited.has(current)) { + diagnostics.push( + diagnostic( + "tree-cycle", + `${path}/nodes/${pathSegment(nodeId)}`, + "Tree must not contain a cycle.", + ), + ); + break; + } + visited.add(current); + current = parentById.get(current); + } + } +}; + +const semanticOverrideFields = ["included", "text", "language", "alt"] as const; + +const semanticRoles = new Set([ + "heading", + "paragraph", + "image", + "button", + "table", + "list", + "listItem", +]); + +const isDenseArray = (value: unknown): value is unknown[] => + Array.isArray(value) && + Object.keys(value).length === value.length && + Array.from({ length: value.length }, (_, index) => Object.hasOwn(value, index)).every(Boolean); + +const hasOnlyFields = (value: JsonRecord, fields: readonly string[]) => + Object.keys(value).every((field) => fields.includes(field)); + +const hasOwnFields = (value: JsonRecord, fields: readonly string[]) => + fields.every((field) => Object.hasOwn(value, field)); + +export { + compareStrings, + containerPath, + diagnostic, + finite, + hasOnlyFields, + hasOwnFields, + id, + isDenseArray, + isRecord, + pathSegment, + positive, + recordEntries, + recordKeys, + schemaPath, + semanticOverrideFields, + semanticRoles, + sorted, + structuralDiagnostic, + validateGroupOwner, + validateQuaternion, + validateRecordIds, + validateReferences, + validateTree, + validateVector, +}; diff --git a/packages/presentation-core/test/presentation-core.test.ts b/packages/presentation-core/test/presentation-core.test.ts new file mode 100644 index 0000000..ebc47ef --- /dev/null +++ b/packages/presentation-core/test/presentation-core.test.ts @@ -0,0 +1,373 @@ +import { describe, expect, it } from "vitest"; + +import definitionFixture from "../../contracts/presentation/fixtures/minimal.presentation-definition.v1.json"; +import bundleFixture from "../../contracts/presentation/fixtures/minimal.render-bundle.v1.json"; +import { + canonicalizePresentationDefinition, + hashCanonicalJsonPayload, + hashPresentationDefinition, + materializeCompletedSemanticTree, + validatePresentationArtifacts, + validatePresentationDefinition, + validateRenderBundle, +} from "../src/index.js"; + +describe("presentation-core", () => { + describe("hashCanonicalJsonPayload", () => { + it("hashes an unmodified generic JSON payload using RFC 8785 key ordering", () => { + const payload = { b: 1, a: "x", values: ["second", "first"] }; + const before = structuredClone(payload); + + expect(hashCanonicalJsonPayload({ b: 1, a: "x" })).toBe( + "sha256:cdab067e9f3beb32d1252cfd63e492592fecbf591b0d08cadb24bb17f3864246", + ); + expect(hashCanonicalJsonPayload(payload)).not.toBe( + hashCanonicalJsonPayload({ a: "x", b: 1, values: ["first", "second"] }), + ); + expect(hashCanonicalJsonPayload(Object.freeze({ a: Object.freeze([1, 2]) }))).toBe( + hashCanonicalJsonPayload({ a: [1, 2] }), + ); + expect(payload).toEqual(before); + }); + + it("rejects values that are not observably plain JSON", () => { + const accessor = {}; + Object.defineProperty(accessor, "value", { + enumerable: true, + get() { + return 1; + }, + }); + const hostileProxy = new Proxy( + {}, + { + ownKeys() { + throw new Error("hostile"); + }, + }, + ); + + for (const value of [ + undefined, + () => undefined, + Symbol("value"), + 1n, + Number.NaN, + Number.POSITIVE_INFINITY, + "invalid-\ud800-unicode", + new Date(), + accessor, + hostileProxy, + ]) + expect(() => hashCanonicalJsonPayload(value)).toThrow(TypeError); + }); + }); + + it("does not require ambient runtime globals for hashing or semantic materialization", () => { + const textEncoder = Object.getOwnPropertyDescriptor(globalThis, "TextEncoder"); + const clone = Object.getOwnPropertyDescriptor(globalThis, "structuredClone"); + Object.defineProperty(globalThis, "TextEncoder", { value: undefined, configurable: true }); + Object.defineProperty(globalThis, "structuredClone", { value: undefined, configurable: true }); + try { + expect(hashPresentationDefinition(definitionFixture).valid).toBe(true); + expect( + materializeCompletedSemanticTree( + definitionFixture.scene.surfaces["surface-title"] as never, + "state-default", + ).valid, + ).toBe(true); + } finally { + if (textEncoder) Object.defineProperty(globalThis, "TextEncoder", textEncoder); + else Reflect.deleteProperty(globalThis, "TextEncoder"); + if (clone) Object.defineProperty(globalThis, "structuredClone", clone); + else Reflect.deleteProperty(globalThis, "structuredClone"); + } + }); + + it("materializes a state and rejects unknown or hostile surfaces", () => { + const surface = definitionFixture.scene.surfaces["surface-title"] as never; + expect(materializeCompletedSemanticTree(surface, "state-default").valid).toBe(true); + expect(materializeCompletedSemanticTree(surface, "missing").valid).toBe(false); + const hostile = new Proxy( + {}, + { + get() { + throw new Error("hostile"); + }, + }, + ); + expect(materializeCompletedSemanticTree(hostile as never, "state-default").valid).toBe(false); + }); + it("applies semantic inclusion and null text overrides", () => { + const surface = structuredClone(definitionFixture.scene.surfaces["surface-title"]) as never as { + states: Record; + baseSemanticTree: { nodes: Record }; + }; + surface.states["state-default"]!.semanticOverrides = [ + { nodes: { "semantic-title": { included: false } } }, + ]; + surface.baseSemanticTree.nodes["semantic-title"] = { + ...surface.baseSemanticTree.nodes["semantic-title"], + text: "Hello", + }; + const result = materializeCompletedSemanticTree(surface as never, "state-default"); + expect(result.valid).toBe(true); + if (result.valid) expect(result.value.nodes["semantic-title"]).toBeUndefined(); + const textSurface = structuredClone( + definitionFixture.scene.surfaces["surface-title"], + ) as never as typeof surface; + textSurface.states["state-default"]!.semanticOverrides = [ + { nodes: { "semantic-title": { text: null } } }, + ]; + textSurface.baseSemanticTree.nodes["semantic-title"] = { + ...textSurface.baseSemanticTree.nodes["semantic-title"], + text: "Hello", + }; + const textResult = materializeCompletedSemanticTree(textSurface as never, "state-default"); + expect(textResult.valid).toBe(true); + if (textResult.valid) expect(textResult.value.nodes["semantic-title"]?.text).toBeUndefined(); + }); + it("rejects prototype states, malformed trees, and unknown semantic overrides", () => { + const surface = structuredClone( + definitionFixture.scene.surfaces["surface-title"], + ) as never as Record; + expect( + materializeCompletedSemanticTree( + { ...surface, states: Object.create({ __proto__: {} }) } as never, + "__proto__", + ).valid, + ).toBe(false); + expect( + materializeCompletedSemanticTree( + { ...surface, baseSemanticTree: {} } as never, + "state-default", + ).valid, + ).toBe(false); + const overridden = structuredClone(surface) as { + states: Record; + }; + overridden.states["state-default"]!.semanticOverrides = [{ nodes: { missing: { text: "x" } } }]; + const result = materializeCompletedSemanticTree(overridden as never, "state-default"); + expect(result.valid).toBe(false); + if (!result.valid) + expect(result.diagnostics.map(({ code }) => code)).toContain("missing-semantic-node"); + }); + it("fails closed for malformed semantic materialization inputs", () => { + const makeSurface = () => + structuredClone(definitionFixture.scene.surfaces["surface-title"]) as { + baseSemanticTree: { + rootNodeIds: unknown[]; + nodes: Record>; + }; + states: Record; + }; + const malformedTrees = [ + (surface: ReturnType) => { + delete surface.baseSemanticTree.nodes["semantic-title"]; + }, + (surface: ReturnType) => { + surface.baseSemanticTree.nodes["semantic-title"]!.id = "other-id"; + }, + (surface: ReturnType) => { + surface.baseSemanticTree.rootNodeIds = ["missing-root"]; + }, + ]; + for (const mutate of malformedTrees) { + const surface = makeSurface(); + mutate(surface); + expect(materializeCompletedSemanticTree(surface as never, "state-default").valid).toBe(false); + } + + const malformedOverrides = [ + (surface: ReturnType) => { + const overrides: unknown[] = [{ nodes: {} }]; + overrides.length = 2; + surface.states["state-default"]!.semanticOverrides = overrides; + }, + (surface: ReturnType) => { + surface.states["state-default"]!.semanticOverrides = [{}]; + }, + (surface: ReturnType) => { + surface.states["state-default"]!.semanticOverrides = [ + { nodes: { "semantic-title": { text: 1 } } }, + ]; + }, + (surface: ReturnType) => { + surface.states["state-default"]!.semanticOverrides = [ + { nodes: { "semantic-title": { included: "false", language: 1, alt: false } } }, + ]; + }, + ]; + for (const mutate of malformedOverrides) { + const surface = makeSurface(); + mutate(surface); + expect(materializeCompletedSemanticTree(surface as never, "state-default").valid).toBe(false); + } + + const definition = structuredClone(definitionFixture) as { + scene: { + surfaces: Record }>; + }; + }; + const overrides: unknown[] = [{ nodes: {} }]; + overrides.length = 2; + definition.scene.surfaces["surface-title"]!.states["state-default"]!.semanticOverrides = + overrides; + expect(() => validatePresentationDefinition(definition)).not.toThrow(); + expect(validatePresentationDefinition(definition).valid).toBe(false); + }); + it("rejects prototype-key semantic overrides without mutating Object.prototype", () => { + const surface = structuredClone(definitionFixture.scene.surfaces["surface-title"]) as { + states: Record; + }; + const nodes: Record = {}; + Object.defineProperty(nodes, "__proto__", { + value: { text: "polluted" }, + enumerable: true, + }); + surface.states["state-default"]!.semanticOverrides = [{ nodes }]; + + const before = (Object.prototype as { text?: unknown }).text; + const result = materializeCompletedSemanticTree(surface as never, "state-default"); + + expect(result.valid).toBe(false); + expect((Object.prototype as { text?: unknown }).text).toBe(before); + if (!result.valid) + expect(result.diagnostics.map(({ code }) => code)).toContain("missing-semantic-node"); + }); + it("reports a stable diagnostic for a structurally invalid definition", () => { + const result = validatePresentationDefinition({}); + expect(result.valid).toBe(false); + if (!result.valid) + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "invalid-definition" }), + ); + }); + + it("rejects unknown contract fields through the Zod schema boundary", () => { + const definition = { ...structuredClone(definitionFixture), unexpected: true }; + + const result = validatePresentationDefinition(definition); + + expect(result.valid).toBe(false); + if (!result.valid) + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "invalid-definition", path: [] }), + ); + }); + + it("does not execute accessors while snapshotting contract input", () => { + let reads = 0; + const definition = structuredClone(definitionFixture) as Record; + Object.defineProperty(definition, "scene", { + enumerable: true, + get: () => { + reads++; + return definitionFixture.scene; + }, + }); + + expect(validatePresentationDefinition(definition).valid).toBe(false); + expect(reads).toBe(0); + }); + + it("rejects unknown SurfaceNode fields", () => { + const definition = structuredClone(definitionFixture) as typeof definitionFixture & { + scene: { nodes: Record> }; + }; + (definition.scene.nodes["surface-node-title"] as Record).source = { + leaked: true, + }; + const result = validatePresentationDefinition(definition); + expect(result.valid).toBe(false); + if (!result.valid) + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "unknown-surface-node-property" }), + ); + }); + + it("rejects malformed asset descriptors", () => { + const definition = structuredClone(definitionFixture) as typeof definitionFixture & { + assets: Record; + }; + definition.assets["asset-invalid"] = { + id: "asset-invalid", + mediaType: 123, + checksum: null, + }; + + const result = validatePresentationDefinition(definition); + + expect(result.valid).toBe(false); + if (!result.valid) + expect(result.diagnostics.map(({ code }) => code)).toContain("invalid-asset"); + }); + + it("accepts the first-milestone definition and RenderBundle fixtures", () => { + expect(validatePresentationDefinition(definitionFixture).valid).toBe(true); + expect(validateRenderBundle(bundleFixture).valid).toBe(true); + }); + + it("rejects semantic record, tree, quaternion, variable, and artifact references", () => { + const definition = structuredClone(definitionFixture); + definition.scene.nodes["surface-node-title"].id = "wrong-id"; + definition.scene.nodes["surface-node-title"].transform.rotation = [0, 0, 0, 2]; + definition.scene.surfaces["surface-title"].contentNodes["text-title"].parentId = + "missing-frame"; + (definition as unknown as { flow: { variables: Record } }).flow.variables = { + count: { + id: "count", + owner: { kind: "presentation" }, + type: "number", + initialValue: "one", + }, + }; + const definitionResult = validatePresentationDefinition(definition); + expect(definitionResult.valid).toBe(false); + if (!definitionResult.valid) + expect(definitionResult.diagnostics.map((item) => item.code)).toEqual( + expect.arrayContaining([ + "record-key-id-mismatch", + "unnormalized-quaternion", + "missing-parent", + "variable-type-mismatch", + ]), + ); + + const bundle = structuredClone(bundleFixture); + bundle.surfaces["surface-title"].renderSurfaces["render-surface-title"].stateBindings[ + "state-default" + ] = { + kind: "artifacts", + artifactIds: ["missing"], + }; + const bundleResult = validateRenderBundle(bundle); + expect(bundleResult.valid).toBe(false); + if (!bundleResult.valid) + expect(bundleResult.diagnostics.map((item) => item.code)).toContain("missing-artifact"); + }); + + it("canonicalizes without mutating input and hashes the exact canonical Definition", () => { + const definition = structuredClone(definitionFixture); + const before = structuredClone(definition); + const canonicalResult = canonicalizePresentationDefinition(definition); + expect(canonicalResult.valid).toBe(true); + if (!canonicalResult.valid) return; + const canonical = canonicalResult.value; + expect(definition).toEqual(before); + const reparsed = canonicalizePresentationDefinition(JSON.parse(canonical)); + expect(reparsed).toEqual({ valid: true, value: canonical, diagnostics: [] }); + const hash = hashPresentationDefinition(definition); + expect(hash.valid && hash.value).toMatch(/^sha256:[0-9a-f]{64}$/); + + const bundle = structuredClone(bundleFixture); + if (!hash.valid) return; + bundle.definitionHash = hash.value; + expect(validatePresentationArtifacts(definition, bundle).valid).toBe(true); + bundle.definitionHash = "sha256:wrong"; + const result = validatePresentationArtifacts(definition, bundle); + expect(result.valid).toBe(false); + if (!result.valid) + expect(result.diagnostics.map((item) => item.code)).toContain("definition-hash-mismatch"); + }); +}); diff --git a/packages/presentation-core/test/publication-integrity-v2.test.ts b/packages/presentation-core/test/publication-integrity-v2.test.ts new file mode 100644 index 0000000..ba9c38e --- /dev/null +++ b/packages/presentation-core/test/publication-integrity-v2.test.ts @@ -0,0 +1,460 @@ +import { createHash } from "node:crypto"; + +import canonicalize from "canonicalize"; +import { describe, expect, it } from "vitest"; + +import definitionFixture from "../../contracts/presentation/v2/fixtures/presentation-definition.json"; +import assetSetFixture from "../../contracts/presentation/v2/fixtures/asset-set-manifest.json"; +import buildManifestFixture from "../../contracts/presentation/v2/fixtures/build-manifest.json"; +import publishedPresentationFixture from "../../contracts/presentation/v2/fixtures/published-presentation.json"; +import renderBundleFixture from "../../contracts/presentation/v2/fixtures/render-bundle.json"; +import { hashCanonicalJsonPayload, verifyPublicationIntegrityV2 } from "../src/index.js"; + +type JsonRecord = Record; + +type PublicationFixture = { + definition: JsonRecord; + renderBundle: JsonRecord; + assetSet: JsonRecord; + buildManifest: JsonRecord; + publishedPresentation: JsonRecord; +}; + +const makeFixture = (): PublicationFixture => ({ + definition: structuredClone(definitionFixture) as JsonRecord, + renderBundle: structuredClone(renderBundleFixture) as JsonRecord, + assetSet: structuredClone(assetSetFixture) as JsonRecord, + buildManifest: structuredClone(buildManifestFixture) as JsonRecord, + publishedPresentation: structuredClone(publishedPresentationFixture) as JsonRecord, +}); + +const recordAt = (value: unknown, ...segments: string[]): JsonRecord => { + let current = value; + for (const segment of segments) { + if (typeof current !== "object" || current === null || Array.isArray(current)) + throw new TypeError(`Expected an object at ${segments.join("/")}`); + current = (current as JsonRecord)[segment]; + } + if (typeof current !== "object" || current === null || Array.isArray(current)) + throw new TypeError(`Expected an object at ${segments.join("/")}`); + return current as JsonRecord; +}; + +const arrayAt = (value: unknown, ...segments: string[]): unknown[] => { + const record = recordAt(value, ...segments.slice(0, -1)); + const child = record[segments.at(-1)!]; + if (!Array.isArray(child)) throw new TypeError(`Expected an array at ${segments.join("/")}`); + return child; +}; + +const differentHash = `sha256:${"0".repeat(64)}`; + +const expectDiagnostic = ( + result: ReturnType, + code: string, +) => { + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toContain(code); + for (const diagnostic of result.diagnostics) expect(Array.isArray(diagnostic.path)).toBe(true); + } +}; + +const canonicalHash = (value: JsonRecord): string => { + const canonical = canonicalize(value); + if (canonical === undefined) throw new TypeError("Expected a canonical publication payload."); + return `sha256:${createHash("sha256").update(canonical, "utf8").digest("hex")}`; +}; + +const publicationHashWithoutSelf = (publication: JsonRecord): string => { + const payload = structuredClone(publication); + delete payload.publicationManifestHash; + return canonicalHash(payload); +}; + +describe("verifyPublicationIntegrityV2", () => { + it("rejects a trailing lone high surrogate in the generic canonical JSON helper", () => { + expect(() => hashCanonicalJsonPayload("invalid\ud800")).toThrow(TypeError); + }); + + it("accepts the complete v2 publication fixture when all artifact hashes agree", () => { + const result = verifyPublicationIntegrityV2(makeFixture()); + + expect(result).toMatchObject({ valid: true, diagnostics: [] }); + }); + + it.each([ + "definition", + "renderBundle", + "assetSet", + "buildManifest", + "publishedPresentation", + ] as const)("rejects a malformed %s artifact at the schema boundary", (artifact) => { + const input = makeFixture(); + recordAt(input[artifact]).schemaVersion = 1; + + expectDiagnostic(verifyPublicationIntegrityV2(input), "structure.invalid"); + }); + + it.each([ + "definition", + "renderBundle", + "assetSet", + "buildManifest", + "publishedPresentation", + ] as const)("rejects an unknown field in the %s artifact", (artifact) => { + const input = makeFixture(); + recordAt(input[artifact]).unexpected = true; + + expectDiagnostic(verifyPublicationIntegrityV2(input), "structure.invalid"); + }); + + it("rejects a non-finite number before schema validation", () => { + const input = makeFixture(); + (recordAt(input.definition, "stage").size as number[])[0] = Number.NaN; + + expectDiagnostic(verifyPublicationIntegrityV2(input), "canonical.invalid"); + }); + + it("rejects negative zero instead of canonicalizing it to zero", () => { + const input = makeFixture(); + (recordAt(input.definition, "stage").size as number[])[0] = -0; + + expectDiagnostic(verifyPublicationIntegrityV2(input), "canonical.invalid"); + }); + + it("rejects a trailing lone high surrogate", () => { + const input = makeFixture(); + recordAt(input.definition, "metadata").title = "invalid\ud800"; + + expectDiagnostic(verifyPublicationIntegrityV2(input), "canonical.invalid"); + }); + + it("rejects a cyclic artifact without recursing indefinitely", () => { + const input = makeFixture(); + const metadata = recordAt(input.definition, "metadata"); + metadata.self = metadata; + + expectDiagnostic(verifyPublicationIntegrityV2(input), "canonical.invalid"); + }); + + it("rejects a sparse array at the maximum representable array length", () => { + const input = makeFixture(); + const sparse = [] as unknown[]; + sparse.length = 2 ** 32 - 1; + recordAt(input.definition, "stage").size = sparse; + + expectDiagnostic(verifyPublicationIntegrityV2(input), "canonical.invalid"); + }); + + it("rejects a nested accessor without invoking it", () => { + const input = makeFixture(); + let reads = 0; + Object.defineProperty(recordAt(input.definition, "metadata"), "title", { + enumerable: true, + configurable: true, + get() { + reads += 1; + throw new Error("accessor must not be invoked"); + }, + }); + + const result = verifyPublicationIntegrityV2(input); + + expectDiagnostic(result, "canonical.invalid"); + expect(reads).toBe(0); + }); + + it("rejects a top-level envelope accessor without invoking it", () => { + const input = makeFixture() as PublicationFixture & JsonRecord; + let reads = 0; + Object.defineProperty(input, "definition", { + enumerable: true, + configurable: true, + get() { + reads += 1; + throw new Error("accessor must not be invoked"); + }, + }); + + const result = verifyPublicationIntegrityV2(input); + + expectDiagnostic(result, "canonical.invalid"); + expect(reads).toBe(0); + }); + + it("keeps array order significant when validating the bundle hash", () => { + const input = makeFixture(); + const features = arrayAt(input.renderBundle, "models", "model-asset", "requiredFeatures"); + features.reverse(); + + const result = verifyPublicationIntegrityV2(input); + + expectDiagnostic(result, "hash.invalid"); + expect(features).toEqual(["unlit", "animation"]); + }); + + it("does not mutate artifacts while inspecting publication integrity", () => { + const input = makeFixture(); + const before = structuredClone(input); + + verifyPublicationIntegrityV2(input); + + expect(input).toEqual(before); + }); + + it("rejects a RenderBundle whose definition hash does not match the Definition", () => { + const input = makeFixture(); + recordAt(input.renderBundle).definitionHash = differentHash; + + expectDiagnostic(verifyPublicationIntegrityV2(input), "hash.invalid"); + }); + + it.each(["definitionHash", "renderBundleHash", "assetSetHash"] as const)( + "rejects a BuildManifest with a stale %s", + (field) => { + const input = makeFixture(); + recordAt(input.buildManifest)[field] = differentHash; + + expectDiagnostic(verifyPublicationIntegrityV2(input), "hash.invalid"); + }, + ); + + it("computes publicationManifestHash after excluding its own field", () => { + const input = makeFixture(); + const publication = recordAt(input.publishedPresentation); + publication.publicationEpoch = 2; + publication.publicationManifestHash = publicationHashWithoutSelf(publication); + + const result = verifyPublicationIntegrityV2(input); + + expect(result).toMatchObject({ valid: true, diagnostics: [] }); + }); + + it("allows a RenderBundle ID to differ from its BuildManifest ID", () => { + const input = makeFixture(); + recordAt(input.renderBundle).bundleId = "independent-bundle"; + const renderBundleHash = canonicalHash(input.renderBundle); + recordAt(input.buildManifest).renderBundleHash = renderBundleHash; + const publication = recordAt(input.publishedPresentation); + publication.renderBundleHash = renderBundleHash; + publication.publicationManifestHash = publicationHashWithoutSelf(publication); + + const result = verifyPublicationIntegrityV2(input); + + expect(result).toMatchObject({ valid: true, diagnostics: [] }); + }); + + it("rejects a publication with a stale self hash", () => { + const input = makeFixture(); + recordAt(input.publishedPresentation).publicationManifestHash = differentHash; + + expectDiagnostic(verifyPublicationIntegrityV2(input), "publication.invalid"); + }); + + it.each([ + ["buildId", "other-build"], + ["presentationId", "other-presentation"], + ["sourceDraftRevision", 2], + ["definitionHash", differentHash], + ["renderBundleHash", differentHash], + ["assetSetHash", differentHash], + ] as const)( + "rejects a PublishedPresentation with a different %s than its BuildManifest", + (field, value) => { + const input = makeFixture(); + recordAt(input.publishedPresentation)[field] = value; + + expectDiagnostic(verifyPublicationIntegrityV2(input), "publication.invalid"); + }, + ); + + it("rejects a PublishedPresentation with different contract versions", () => { + const input = makeFixture(); + recordAt(input.publishedPresentation, "contractVersions").runtime = 1; + + expectDiagnostic(verifyPublicationIntegrityV2(input), "publication.invalid"); + }); + + it.each(["image", "texture", "font", "video-asset", "model-asset"] as const)( + "rejects an AssetSet missing directly referenced asset %s", + (assetId) => { + const input = makeFixture(); + delete recordAt(input.assetSet, "assets")[assetId]; + + expectDiagnostic(verifyPublicationIntegrityV2(input), "reference.invalid"); + }, + ); + + it("rejects an AssetSet missing an image selected by a State content override", () => { + const input = makeFixture(); + recordAt( + input.definition, + "scene", + "surfaces", + "baked", + "states", + "default", + "contentOverrides", + ).image = { + kind: "image", + assetId: "state-image", + }; + + expectDiagnostic(verifyPublicationIntegrityV2(input), "reference.invalid"); + }); + + it("rejects an AssetSet missing a font selected by a State text override", () => { + const input = makeFixture(); + const style = structuredClone( + recordAt(input.definition, "scene", "surfaces", "baked", "contentNodes", "text", "style"), + ); + recordAt(style).fontAssetId = "state-font"; + recordAt( + input.definition, + "scene", + "surfaces", + "baked", + "states", + "default", + "contentOverrides", + ).text = { kind: "text", style }; + + expectDiagnostic(verifyPublicationIntegrityV2(input), "reference.invalid"); + }); + + it("rejects an AssetSet missing a Native UI fallback font", () => { + const input = makeFixture(); + const fallbacks = arrayAt( + input.renderBundle, + "surfaces", + "native", + "renderSurfaces", + "render-native", + "artifacts", + "artifact-native", + "nodes", + "text", + "font", + "fallbacks", + ); + fallbacks.push({ + assetId: "fallback-font", + supportedCodePointRanges: [[32, 126]], + }); + + expectDiagnostic(verifyPublicationIntegrityV2(input), "reference.invalid"); + }); + + it("rejects an AssetSet containing an unrelated asset", () => { + const input = makeFixture(); + recordAt(input.assetSet, "assets").unused = { + checksum: differentHash, + mediaType: "image/png", + encodedSizeBytes: 1, + }; + + expectDiagnostic(verifyPublicationIntegrityV2(input), "reference.invalid"); + }); + + it("rejects a Bundle asset descriptor that differs from the AssetSet", () => { + const input = makeFixture(); + recordAt( + input.renderBundle, + "surfaces", + "baked", + "renderSurfaces", + "render-baked", + "artifacts", + "artifact-baked", + "states", + "default", + "texture", + ).checksum = differentHash; + + expectDiagnostic(verifyPublicationIntegrityV2(input), "artifact.invalid"); + }); + + it("rejects a Bundle asset whose media type differs from the AssetSet descriptor", () => { + const input = makeFixture(); + recordAt(input.assetSet, "assets", "texture").mediaType = "image/jpeg"; + + expectDiagnostic(verifyPublicationIntegrityV2(input), "artifact.invalid"); + }); + + it("rejects a ModelNode that references no compiled model asset", () => { + const input = makeFixture(); + recordAt(input.definition, "scene", "nodes", "model").assetId = "missing-model"; + + expectDiagnostic(verifyPublicationIntegrityV2(input), "reference.invalid"); + }); + + it("rejects a model clip action that references no compiled clip", () => { + const input = makeFixture(); + const step = recordAt(input.definition, "flow", "groups", "intro", "steps", "start"); + const cue = recordAt(arrayAt(step, "cues")[0]); + recordAt(arrayAt(cue, "actions")[0]).clipId = "missing-clip"; + + expectDiagnostic(verifyPublicationIntegrityV2(input), "reference.invalid"); + }); + + it("rejects a model clip action that resolves only through Object.prototype", () => { + const input = makeFixture(); + const step = recordAt(input.definition, "flow", "groups", "intro", "steps", "start"); + const cue = recordAt(arrayAt(step, "cues")[0]); + recordAt(arrayAt(cue, "actions")[0]).clipId = "constructor"; + + expectDiagnostic(verifyPublicationIntegrityV2(input), "reference.invalid"); + }); + + it.each([ + ["pause", "node-baked"], + ["pause", "missing-node"], + ["resume", "node-baked"], + ["resume", "missing-node"], + ["stop", "node-baked"], + ["stop", "missing-node"], + ] as const)("rejects modelClip.%s for a non-model or missing node", (kind, nodeId) => { + const input = makeFixture(); + const step = recordAt(input.definition, "flow", "groups", "intro", "steps", "start"); + const cue = recordAt(arrayAt(step, "cues")[0]); + arrayAt(cue, "actions")[0] = { kind: `modelClip.${kind}`, nodeId }; + + expectDiagnostic(verifyPublicationIntegrityV2(input), "reference.invalid"); + }); + + it("rejects a compiled model whose referenced clip was removed", () => { + const input = makeFixture(); + delete recordAt(input.renderBundle, "models", "model-asset", "clips").wave; + + expectDiagnostic(verifyPublicationIntegrityV2(input), "reference.invalid"); + }); + + it("rejects a compiled model whose record key differs from assetId without a Definition ModelNode", () => { + const input = makeFixture(); + delete recordAt(input.definition, "scene", "nodes").model; + arrayAt( + recordAt(input.definition, "flow", "groups", "intro", "steps", "start"), + "cues", + ).forEach((cue) => { + const actions = arrayAt(cue, "actions"); + actions.splice(0, 1); + }); + + const models = recordAt(input.renderBundle, "models"); + models["compiled-key"] = models["model-asset"]; + delete models["model-asset"]; + + const definitionHash = canonicalHash(input.definition); + recordAt(input.renderBundle).definitionHash = definitionHash; + const renderBundleHash = canonicalHash(input.renderBundle); + recordAt(input.buildManifest).definitionHash = definitionHash; + recordAt(input.buildManifest).renderBundleHash = renderBundleHash; + const publication = recordAt(input.publishedPresentation); + publication.definitionHash = definitionHash; + publication.renderBundleHash = renderBundleHash; + publication.publicationManifestHash = publicationHashWithoutSelf(publication); + + expectDiagnostic(verifyPublicationIntegrityV2(input), "artifact.invalid"); + }); +}); diff --git a/packages/presentation-core/test/review-invariants.test.ts b/packages/presentation-core/test/review-invariants.test.ts new file mode 100644 index 0000000..b4c9e03 --- /dev/null +++ b/packages/presentation-core/test/review-invariants.test.ts @@ -0,0 +1,514 @@ +import { readFile } from "node:fs/promises"; + +import { describe, expect, it } from "vitest"; + +import definitionFixture from "../../contracts/presentation/fixtures/minimal.presentation-definition.v1.json"; +import bundleFixture from "../../contracts/presentation/fixtures/minimal.render-bundle.v1.json"; +import { + canonicalizePresentationDefinition, + hashPresentationDefinition, + validatePresentationArtifacts, + validatePresentationDefinition, + validateRenderBundle, +} from "../src/index.js"; + +type MutableRecord = Record; + +const recordAt = (value: unknown, ...path: string[]): MutableRecord => { + let current = value; + for (const segment of path) { + if (typeof current !== "object" || current === null || Array.isArray(current)) + throw new TypeError(`Expected object at ${path.join("/")}`); + current = (current as MutableRecord)[segment]; + } + if (typeof current !== "object" || current === null || Array.isArray(current)) + throw new TypeError(`Expected object at ${path.join("/")}`); + return current as MutableRecord; +}; + +const definitionCodes = (value: unknown) => { + const result = validatePresentationDefinition(value); + return result.valid ? [] : result.diagnostics.map((item) => item.code); +}; + +const pair = () => { + const definition = structuredClone(definitionFixture); + const bundle = structuredClone(bundleFixture); + const hash = hashPresentationDefinition(definition); + if (!hash.valid) throw new TypeError("The definition fixture must be hashable."); + recordAt(bundle).definitionHash = hash.value; + return { definition, bundle }; +}; + +const makeInteractive = () => { + const artifacts = pair(); + const surface = recordAt(artifacts.definition, "scene", "surfaces", "surface-title"); + surface.interactions = { + tap: { id: "tap", kind: "click", event: "tap-event" }, + }; + recordAt(surface, "states", "state-default").enabledInteractionIds = ["tap"]; + recordAt(surface, "renderIntent").interaction = { + kind: "regions", + events: ["tap-event"], + }; + recordAt(surface, "baseSemanticTree", "nodes", "semantic-title").interactionId = "tap"; + recordAt( + artifacts.bundle, + "surfaces", + "surface-title", + "semanticsByState", + "state-default", + "nodes", + "semantic-title", + ).interactionId = "tap"; + recordAt(artifacts.bundle, "surfaces", "surface-title", "interactionsByState")["state-default"] = + [ + { + interactionId: "tap", + semanticNodeId: "semantic-title", + event: "tap-event", + priority: 0, + coordinateSpace: "normalized", + bounds: { x: 0, y: 0, width: 1, height: 1 }, + }, + ]; + const hash = hashPresentationDefinition(artifacts.definition); + if (!hash.valid) throw new TypeError("The interactive definition must be hashable."); + recordAt(artifacts.bundle).definitionHash = hash.value; + return artifacts; +}; + +const artifactCodes = (definition: unknown, bundle: unknown) => { + const result = validatePresentationArtifacts(definition, bundle); + return result.valid ? [] : result.diagnostics.map((item) => item.code); +}; + +describe("definition semantic invariants", () => { + it("rejects missing group owners and shorter-lifetime Spatial parents", () => { + const missingOwner = structuredClone(definitionFixture); + recordAt(missingOwner, "flow", "variables", "optional-subtitle").owner = { + kind: "group", + groupId: "missing", + }; + expect(definitionCodes(missingOwner)).toContain("missing-owner-group"); + + const invalidLifetime = structuredClone(definitionFixture); + const nodes = recordAt(invalidLifetime, "scene", "nodes"); + recordAt(nodes, "surface-node-title").owner = { + kind: "group", + groupId: "group-intro", + }; + nodes["surface-node-child"] = { + ...structuredClone(recordAt(nodes, "surface-node-title")), + id: "surface-node-child", + owner: { kind: "presentation" }, + parent: { kind: "node", nodeId: "surface-node-title" }, + surfaceId: "surface-child", + }; + const surfaces = recordAt(invalidLifetime, "scene", "surfaces"); + surfaces["surface-child"] = { + ...structuredClone(recordAt(surfaces, "surface-title")), + id: "surface-child", + hostNodeId: "surface-node-child", + }; + expect(definitionCodes(invalidLifetime)).toContain("invalid-owner-parent-lifetime"); + }); + + it("rejects incomplete finite-state models and every Scalar type mismatch", () => { + const value = structuredClone(definitionFixture); + recordAt(value, "scene", "surfaces", "surface-title", "renderIntent").updateModel = { + kind: "finite-state", + stateIds: ["missing"], + }; + const variable = recordAt(value, "flow", "variables", "optional-subtitle"); + variable.type = "null"; + variable.initialValue = true; + expect(definitionCodes(value)).toEqual( + expect.arrayContaining(["render-intent-state-set-mismatch", "variable-type-mismatch"]), + ); + }); + + it("rejects invalid Frame/Text relationships, sizes, and placement", () => { + const value = structuredClone(definitionFixture); + const surface = recordAt(value, "scene", "surfaces", "surface-title"); + surface.rootFrameId = "text-title"; + surface.logicalSize = [Number.POSITIVE_INFINITY, 1080]; + const text = recordAt(surface, "contentNodes", "text-title"); + text.parentId = "text-title"; + recordAt(text, "placement").width = Number.NaN; + expect(definitionCodes(value)).toEqual( + expect.arrayContaining([ + "invalid-root-frame", + "invalid-vector", + "invalid-text-parent", + "invalid-text-placement", + ]), + ); + }); + + it("validates Asset and Zone Record keys", () => { + const value = structuredClone(definitionFixture); + recordAt(value).assets = { + "asset-key": { + id: "asset-value", + mediaType: "image/png", + checksum: "sha256:asset", + }, + }; + recordAt(value, "stage").zones = { + "zone-key": { + id: "zone-value", + owner: { kind: "presentation" }, + center: [0, 0, 0], + size: [1, 1, 1], + }, + }; + const result = validatePresentationDefinition(value); + expect(result.valid).toBe(false); + if (!result.valid) { + const mismatchPaths = result.diagnostics + .filter((item) => item.code === "record-key-id-mismatch") + .map((item) => item.path); + expect(mismatchPaths).toEqual( + expect.arrayContaining([ + ["assets", "asset-key"], + ["stage", "zones", "zone-key"], + ]), + ); + } + }); + + it("validates Stage and Zone vectors", () => { + const value = structuredClone(definitionFixture); + recordAt(value, "stage").size = [1, Number.POSITIVE_INFINITY, 1]; + recordAt(value, "stage").zones = { + zone: { + id: "zone", + owner: { kind: "presentation" }, + center: [Number.NaN, 0, 0], + size: [1, 0, 1], + }, + }; + const result = validatePresentationDefinition(value); + expect(result.valid).toBe(false); + if (!result.valid) + expect( + result.diagnostics + .filter((item) => item.code === "invalid-vector") + .map((item) => item.path), + ).toEqual( + expect.arrayContaining([ + ["stage", "size"], + ["stage", "zones", "zone", "center"], + ["stage", "zones", "zone", "size"], + ]), + ); + }); + + it("rejects resource IDs reused across resource kinds", () => { + const value = structuredClone(definitionFixture); + recordAt(value).assets = { + "surface-node-title": { + id: "surface-node-title", + mediaType: "image/png", + checksum: "sha256:asset", + }, + }; + expect(definitionCodes(value)).toContain("duplicate-resource-id"); + }); + + it("rejects duplicate override claims and excluded descendant re-inclusion", () => { + const value = structuredClone(definitionFixture); + const surface = recordAt(value, "scene", "surfaces", "surface-title"); + recordAt(surface, "baseSemanticTree").rootNodeIds = ["semantic-root"]; + recordAt(surface, "baseSemanticTree").nodes = { + "semantic-root": { + id: "semantic-root", + parentId: null, + order: 0, + role: "heading", + }, + "semantic-title": { + id: "semantic-title", + parentId: "semantic-root", + order: 0, + role: "paragraph", + text: "Hello", + }, + }; + recordAt(surface, "states", "state-default").semanticOverrides = [ + { nodes: { "semantic-root": { included: false } } }, + { + nodes: { + "semantic-root": { included: false, text: "hidden" }, + "semantic-title": { included: true }, + }, + }, + ]; + expect(definitionCodes(value)).toEqual( + expect.arrayContaining([ + "duplicate-semantic-override-property", + "excluded-semantic-node-property", + "semantic-node-reincluded", + ]), + ); + }); +}); + +describe("Definition and RenderBundle conformance", () => { + it("validates texture sizes and global RenderSurface/artifact IDs", () => { + const invalidTexture = structuredClone(bundleFixture); + recordAt( + invalidTexture, + "surfaces", + "surface-title", + "renderSurfaces", + "render-surface-title", + "artifacts", + "artifact-title-default", + "states", + "state-default", + ).textures = [ + { + assetId: "asset-title-default", + mediaType: "image/png", + pixelSize: [0, 1152], + checksum: "sha256:texture", + colorSpace: "srgb", + alphaMode: "straight", + }, + ]; + const textureResult = validateRenderBundle(invalidTexture); + expect(textureResult.valid).toBe(false); + if (!textureResult.valid) + expect(textureResult.diagnostics.map((item) => item.code)).toContain("invalid-vector"); + + const duplicateIds = structuredClone(bundleFixture); + const surfaces = recordAt(duplicateIds, "surfaces"); + surfaces["surface-second"] = { + ...structuredClone(recordAt(surfaces, "surface-title")), + semanticSurfaceId: "surface-second", + }; + const duplicateResult = validateRenderBundle(duplicateIds); + expect(duplicateResult.valid).toBe(false); + if (!duplicateResult.valid) + expect(duplicateResult.diagnostics.map((item) => item.code)).toEqual( + expect.arrayContaining(["duplicate-render-surface-id", "duplicate-renderer-artifact-id"]), + ); + }); + + it("rejects malformed and non-materialized Bundle semantic trees", () => { + const { bundle } = pair(); + const semanticNode = recordAt( + bundle, + "surfaces", + "surface-title", + "semanticsByState", + "state-default", + "nodes", + "semantic-title", + ); + semanticNode.parentId = "semantic-title"; + const bundleResult = validateRenderBundle(bundle); + expect(bundleResult.valid).toBe(false); + if (!bundleResult.valid) + expect(bundleResult.diagnostics.map((item) => item.code)).toContain("tree-cycle"); + + const mismatched = pair(); + recordAt( + mismatched.bundle, + "surfaces", + "surface-title", + "semanticsByState", + "state-default", + "nodes", + "semantic-title", + ).text = "different"; + expect(artifactCodes(mismatched.definition, mismatched.bundle)).toContain( + "materialized-semantic-tree-mismatch", + ); + }); + + it("rejects missing hit regions and mismatched interaction events", () => { + const missing = makeInteractive(); + recordAt(missing.bundle, "surfaces", "surface-title", "interactionsByState")["state-default"] = + []; + expect(artifactCodes(missing.definition, missing.bundle)).toContain( + "missing-enabled-interaction-region", + ); + + const mismatched = makeInteractive(); + const regions = recordAt(mismatched.bundle, "surfaces", "surface-title", "interactionsByState")[ + "state-default" + ] as MutableRecord[]; + regions[0]!.event = "wrong-event"; + expect(artifactCodes(mismatched.definition, mismatched.bundle)).toContain( + "hit-region-event-mismatch", + ); + }); + + it("requires exact surface/state sets and matching compiled sizes", () => { + const missingSurface = pair(); + delete recordAt(missingSurface.bundle, "surfaces")["surface-title"]; + expect(artifactCodes(missingSurface.definition, missingSurface.bundle)).toContain( + "missing-bundle-surface", + ); + + const mismatched = pair(); + const compiled = recordAt(mismatched.bundle, "surfaces", "surface-title"); + compiled.logicalSize = [1280, 720]; + delete recordAt(compiled, "semanticsByState")["state-default"]; + expect(artifactCodes(mismatched.definition, mismatched.bundle)).toEqual( + expect.arrayContaining(["compiled-surface-size-mismatch", "surface-state-set-mismatch"]), + ); + }); +}); + +describe("canonical JSON and diagnostics", () => { + it("preserves prototype-shaped IDs in canonical records", () => { + const value = structuredClone(definitionFixture); + const variable = structuredClone(recordAt(value, "flow", "variables", "optional-subtitle")); + variable.id = "__proto__"; + const variables = Object.create(null) as MutableRecord; + variables["__proto__"] = variable; + recordAt(value, "flow").variables = variables; + const result = canonicalizePresentationDefinition(value); + expect(result.valid).toBe(true); + if (!result.valid) return; + const parsed = JSON.parse(result.value) as { + flow: { variables: Record }; + }; + expect(Object.hasOwn(parsed.flow.variables, "__proto__")).toBe(true); + }); + + it("canonicalizes Frame children by sibling order", () => { + const first = structuredClone(definitionFixture); + const contentNodes = recordAt(first, "scene", "surfaces", "surface-title", "contentNodes"); + const original = structuredClone(recordAt(contentNodes, "text-title")); + delete contentNodes["text-title"]; + contentNodes.a = { ...structuredClone(original), id: "a", order: 0 }; + contentNodes.z = { ...structuredClone(original), id: "z", order: 1 }; + recordAt(contentNodes, "frame-root").children = ["z", "a"]; + const second = structuredClone(first); + recordAt(second, "scene", "surfaces", "surface-title", "contentNodes", "frame-root").children = + ["a", "z"]; + expect(canonicalizePresentationDefinition(first)).toEqual( + canonicalizePresentationDefinition(second), + ); + expect(hashPresentationDefinition(first)).toEqual(hashPresentationDefinition(second)); + }); + + it("orders roots by node order, sorts set arrays, and preserves override layers", () => { + const value = structuredClone(definitionFixture); + const surface = recordAt(value, "scene", "surfaces", "surface-title"); + recordAt(surface, "baseSemanticTree").rootNodeIds = ["b", "a"]; + recordAt(surface, "baseSemanticTree").nodes = { + a: { id: "a", parentId: null, order: 0, role: "heading" }, + b: { id: "b", parentId: null, order: 1, role: "paragraph" }, + }; + recordAt(surface, "states", "state-default").semanticOverrides = [ + { nodes: { a: { text: "first" } } }, + { nodes: { b: { text: "second" } } }, + ]; + recordAt(surface, "renderIntent").updateModel = { + kind: "finite-state", + stateIds: ["state-z", "state-default"], + }; + recordAt(surface, "states")["state-z"] = { + id: "state-z", + semanticOverrides: [], + enabledInteractionIds: [], + }; + const before = structuredClone(value); + const result = canonicalizePresentationDefinition(value); + expect(value).toEqual(before); + expect(result.valid).toBe(true); + if (!result.valid) return; + expect(result.value).toContain('"rootNodeIds":["a","b"]'); + expect(result.value).toContain('"stateIds":["state-default","state-z"]'); + expect(result.value.indexOf('"first"')).toBeLessThan(result.value.indexOf('"second"')); + }); + + it("uses ECMAScript/JCS number rendering and a stable SHA-256 golden value", () => { + const value = structuredClone(definitionFixture); + recordAt(value, "scene", "nodes", "surface-node-title", "transform").position = [ + -0, 1e-7, 1e21, + ]; + const canonical = canonicalizePresentationDefinition(value); + expect(canonical.valid).toBe(true); + if (!canonical.valid) return; + expect(canonical.value).toContain('"position":[0,1e-7,1e+21]'); + const hash = hashPresentationDefinition(value); + expect(hash).toEqual({ + valid: true, + value: "sha256:16241603b0d2e5c265dbc6999bfb4c416d8bf14b981a5265bff20e83d82ebc85", + diagnostics: [], + }); + }); + + it("rejects lone Unicode surrogates that RFC 8785 cannot canonicalize", () => { + const value = structuredClone(definitionFixture); + recordAt(value, "metadata").title = "invalid-\ud800-title"; + + expect(canonicalizePresentationDefinition(value)).toEqual({ + valid: false, + diagnostics: [ + { + code: "invalid-canonical-json", + path: [], + message: "Artifact cannot be represented as canonical JSON.", + }, + ], + }); + }); + + it("sorts complete diagnostics independently of Record insertion order", () => { + const first = structuredClone(definitionFixture); + const second = structuredClone(definitionFixture); + const variables = { + z: { + id: "wrong-z", + owner: { kind: "group", groupId: "missing-z" }, + type: "null", + initialValue: true, + }, + a: { + id: "wrong-a", + owner: { kind: "group", groupId: "missing-a" }, + type: "number", + initialValue: "not-number", + }, + }; + recordAt(first, "flow").variables = variables; + recordAt(second, "flow").variables = { a: variables.a, z: variables.z }; + expect(validatePresentationDefinition(first)).toEqual(validatePresentationDefinition(second)); + }); + + it("keeps IDs containing slashes as one diagnostic path segment", () => { + const value = structuredClone(definitionFixture); + recordAt(value, "flow").variables = { + "optional/subtitle": { + id: "optional/subtitle", + owner: { kind: "presentation" }, + type: "null", + initialValue: true, + }, + }; + const result = validatePresentationDefinition(value); + expect(result.valid).toBe(false); + if (!result.valid) + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "variable-type-mismatch", + path: ["flow", "variables", "optional/subtitle", "initialValue"], + }), + ); + }); + + it("returns diagnostics instead of throwing and keeps production code portable", async () => { + expect(canonicalizePresentationDefinition({}).valid).toBe(false); + expect(hashPresentationDefinition({}).valid).toBe(false); + const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); + expect(source).not.toMatch(/from ["']node:|\bBuffer\b|\bprocess\b/); + }); +}); diff --git a/packages/presentation-core/tsconfig.json b/packages/presentation-core/tsconfig.json new file mode 100644 index 0000000..8b84660 --- /dev/null +++ b/packages/presentation-core/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../config/tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "noPropertyAccessFromIndexSignature": false, + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/presentation-renderer-api/ARCHITECTURE.md b/packages/presentation-renderer-api/ARCHITECTURE.md new file mode 100644 index 0000000..69f054a --- /dev/null +++ b/packages/presentation-renderer-api/ARCHITECTURE.md @@ -0,0 +1,120 @@ +# Presentation Renderer API Architecture + +- **Status**: Initial baked-web plugin contract implemented +- **Scope**: Compiler と concrete renderer の間の plugin contract +- **Related**: + - [Presentation Architecture](../../docs/presentation/ARCHITECTURE.md) + - [Presentation Implementation Design](../../docs/presentation/DESIGN.md) + - [Presentation Core Architecture](../presentation-core/ARCHITECTURE.md) + +## 1. Role + +現在のAPIには、一回だけ実行する `executeRendererPlugin` と、入力・plugin を own-data +snapshot へ変換する `prepareRendererBuildInput` を含む。決定性を確認するための反復実行は +conformance harnessの責務に残す。 + +`presentation-renderer-api` は、Compiler が renderer implementation を選択・実行するための runtime-neutral plugin boundary である。Concrete renderer の処理や自動選択 policy は持たず、入力 capability、出力 artifact、diagnostics、provenance の共通契約を定義する。 + +## 2. Owned contract + +- renderer identifier と version +- supported Surface Render Intent と capability declaration +- structured Primitive graph / opaque renderer entry の入力 contract +- renderer build context +- raw RGBA capture、Hit Region、resolved geometry の出力 contract +- deterministic output metadata と provenance +- stable diagnostic code +- renderer fixture と conformance harness + +Conceptual interface は次の責務を持つ。 + +```text +RendererPlugin +├─ identity and version +├─ capability negotiation +├─ support(resolvedIntent, input) +├─ build(context, input) +└─ provenance and diagnostics +``` + +正確な公開 TypeScript API の正本は [`src/index.ts`](./src/index.ts) とする。 + +## 3. Input and output boundary + +入力は `presentation-core` の semantic / build model と、Compiler が解決した明示的 build context に限定する。Semantic Surface が宣言した source intent と、Compiler が renderer 選択だけを解決した resolved intent は分離する。これにより `rendererPreference: auto` を元の意味として保持したまま、選択済み renderer ID を plugin に渡せる。Renderer が project filesystem、environment variable、network を暗黙に探索しない。 + +公開境界は caller 所有 object を検証した後で再利用しない。`prepareRendererBuildInput` は +plain own-data descriptor から snapshot を作った後、Zod 4 schema で runtime shape を検証し、元 input の accessor、 +Proxy の `get`、後続 mutation を build 実行へ持ち込まない。`executeRendererPlugin` と +conformance harness も、identity / capabilities / method reference を一度固定した plugin wrapper と +prepared input だけを `support` / `build` へ渡す。Concrete renderer が同じ境界を直接利用する場合も、 +返された prepared input を以後の唯一の入力とする。 + +target出力はencode前のRGBA capture、Surface Stateごとのpartition-local `RendererPrivateHitRegion`、resolved geometry、diagnostics、provenanceとする。Raw bytesの所有権はbuild resultとともにcallerへ移り、Rendererは返却後にbufferを変更しない。RendererはPresentationDefinitionの意味を書き換えず、Asset ID、portable `HitRegion`、最終RenderBundle bindingも決定しない。 + +Surface PartitionのauthorityはCompilerにあり、Renderer APIは [ADR-0011](../../docs/decisions/0011-surface-partition-contract.md) で確定した一つのplanだけを処理する。target planはexactly-once ownershipの`ownedContentNodeIds`と複製可能な`contextNodeIds`を分離する。renderer outputのregionはpartition-private geometryであり、Compiler aggregate stageが再clipせずSemantic Surface normalized regionへ変換・結合する。APIは別partitionを探索、merge、reorderしない。 + +## 4. Invariants + +- Renderer ID / version と output provenance を Compiler の cache key と RenderBundle provenance の明示入力として渡せる。 +- Compiler が選択した renderer ID と実際に呼び出した plugin identity が一致する。 +- `auto` 以外の明示 renderer preference を別 renderer へ暗黙 fallback しない。 +- `support` 判定と `build` 結果が同じ capability contract に従う。 +- 検証した input / plugin と実行する input / plugin を同じ prepared snapshot に固定する。 +- plan、Surface、完成 Semantic Tree の state 集合が完全一致する。 +- Hit Region は state で有効な interaction と、それを参照する Semantic Node に結び付き、有効な interaction を漏れなく覆う。 +- renderer output から Semantic Tree の意味を推測しない。 +- RenderSurfaceId は build-local であり、canonical progression contract へ漏らさない。 +- deterministic と宣言する plugin は同じ明示入力から同じ raw bytes、geometry、metadata を生成する。 +- renderer failure は semantic model の silent fallback に変換せず、diagnostic として返す。 + +## 5. Non-responsibilities + +- Browser lifecycle、React / CSS rendering +- texture / video encoding +- concrete artifact generation +- renderer の自動選択 policy +- Compiler pass と cache orchestration +- CLI command、publish、Delivery + +## 6. Dependency rules + +`presentation-renderer-api` は `presentation-core` と runtime boundary validation のための `zod` にだけ依存する。Compiler と concrete renderer が API に依存する。API から concrete renderer、Compiler、CLI への依存は禁止する。Concrete renderer 同士も依存しない。 + +## 7. Conformance strategy + +- capability support matrix fixture +- valid / invalid build input fixture +- sparse array、accessor、Proxy、extra property、後続 mutationを含む prepared boundary fixture +- deterministic output / provenance fixture +- diagnostic code contract test +- Structured Semantic Tree を変更しないことの test +- renderer version と cache invalidation の fixture + +Conformance harness は renderer implementation の process topology を固定せず、in-process / child process / remote adapter のいずれでも同じ観測可能な contract を検証できるようにする。Raw bytes は realm に依存しない `Uint8Array` view として検証し、child process / remote transport adapter は build result をこの API の型へ復元してから harness へ渡す。 + +## 8. Deferred decisions + +- plugin discovery と version negotiation +- process / isolate boundary +- capability vocabulary +- ADR-0012のcapture前budget、deadline / abort、resource guard APIの実装 +- Native UI / Video renderer API の追加時期 +- Compiler cache key と RenderBundle `environmentHash` への `rendererFingerprint` 結合、およびその integration test + +## 9. Current implementation + +最初のmilestoneはCompilerが一つのSemantic Surface全体を一つのRender Surface planへlowerし、`static` / `interaction: none` / `internalAnimation: none` / `baked-web` / `reject`のStructured Frame / Text rendererへ渡すsubsetを実装する。ADR-0011のmulti-partition planning、provenance、aggregate regionはM3〜M4で接続する。 + +現行Rendererはstateごとの未encode RGBA captureとSemantic Surface normalized `HitRegion`を返す一partition subsetである。M3でplanのowned/context分離とpartition-local `RendererPrivateHitRegion`へ置換し、Compiler aggregateによるportable `HitRegion`生成と同時に移行する。PNG encode、checksum、Asset ID、最終的なRenderBundle artifact / state bindingは`presentation-assets`とCompilerが所有する。Rendererがplan、完成Semantic Tree、入力hashを変更することを許可しない。 + +`presentation-core` が generated contract から導出した read-only Surface / Semantic Tree 型を入力に使用し、この package で canonical contract を再定義しない。Renderer identity、contract version、implementation hash、明示 config hash から `rendererFingerprint` を作り、入力 context と provenance の一致を conformance harness で検査する。Compiler はこの fingerprint を cache key と `environmentHash` の入力に含める責務を持つが、Compiler 未実装の現時点では integration test も未実装であり、この API 単体は hash への包含を検証したとは主張しない。current RenderBundle schema に独立 field がないため、schema 拡張時に明示 field へ移す。 + +共通 conformance harness は support / build の整合、unsupported failure、malformed output、入力不変性、state / capture completeness、RGBA、Hit Region の有効性と completeness、provenance、同一入力二回の determinism を検査する。Browser process、Opaque execution、encode、cache orchestrationは含めない。 + +`prepareRendererBuildInput` は現行 contract の Surface、Frame / Text、State、Semantic Tree、 +Render Surface plan、build context の shape を `src/validation/schemas.ts` の Zod schema で検査し、 +参照関係だけを API 固有の invariant として検査する。Plugin capability、support decision、build result、 +diagnostic、capture、Hit Region も同じく Zod schema を通す。各 schema の前段では dense own-data snapshot を +作るため、accessor、Proxy、sparse array、extra array property は value read 前に拒否する。plugin method は固定 receiver から呼び出す。境界通過後の renderer が +prepared input を変更した場合は `executeRendererPlugin` / conformance harness が diagnostic にする。 diff --git a/packages/presentation-renderer-api/package.json b/packages/presentation-renderer-api/package.json new file mode 100644 index 0000000..e829b6b --- /dev/null +++ b/packages/presentation-renderer-api/package.json @@ -0,0 +1,25 @@ +{ + "name": "@unframe/presentation-renderer-api", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "lint": "vp check src test", + "test": "vp test run", + "check": "pnpm typecheck && pnpm lint && pnpm test" + }, + "dependencies": { + "@unframe/presentation-core": "workspace:*", + "zod": "4.4.3" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^7.0.0", + "vite-plus": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/presentation-renderer-api/src/boundary/plugin-validation.ts b/packages/presentation-renderer-api/src/boundary/plugin-validation.ts new file mode 100644 index 0000000..53a9b9a --- /dev/null +++ b/packages/presentation-renderer-api/src/boundary/plugin-validation.ts @@ -0,0 +1,110 @@ +import type { Diagnostic, ValidationResult } from "@unframe/presentation-core"; +import type { RendererPlugin } from "../public-types.js"; +import { diagnostic } from "../capabilities/evaluate-first-milestone.js"; +import { + rendererCapabilitiesSchema, + rendererFunctionSchema, + rendererIdentitySchema, +} from "../validation/schemas.js"; +import { applyFunction, plainDataRecord, snapshotUnknown } from "./shared/safe-data.js"; + +export const defineRendererPlugin = ( + plugin: Plugin, +): Plugin => { + const diagnostics = validateRendererPlugin(plugin); + if (diagnostics.some(({ code }) => code === "invalid-renderer-identity")) + throw new TypeError("Renderer identity fields must be non-empty."); + if (diagnostics.length > 0) + throw new TypeError("Renderer capabilities must match the first-milestone contract."); + + return plugin; +}; + +export const prepareRendererPlugin = (plugin: unknown): ValidationResult => { + try { + const snapshot = plainDataRecord(plugin); + if (!snapshot) + return { + valid: false, + diagnostics: [diagnostic("invalid-renderer-plugin", "Renderer plugin is invalid.", [])], + }; + if ( + !rendererFunctionSchema.safeParse(snapshot.support).success || + !rendererFunctionSchema.safeParse(snapshot.build).success + ) + return { + valid: false, + diagnostics: [ + diagnostic( + "invalid-renderer-plugin", + "Renderer plugins must provide callable support() and build() methods.", + [], + ), + ], + }; + const identityResult = rendererIdentitySchema.safeParse(snapshotUnknown(snapshot.identity)); + if (!identityResult.success) + return { + valid: false, + diagnostics: [ + diagnostic( + "invalid-renderer-identity", + "Renderer identity fields must be non-empty.", + [], + ), + ], + }; + const capabilityResult = rendererCapabilitiesSchema.safeParse( + snapshotUnknown(snapshot.capabilities), + ); + if (!capabilityResult.success) + return { + valid: false, + diagnostics: [ + diagnostic( + "invalid-renderer-capabilities", + "Renderer capabilities must match the first-milestone contract.", + [], + ), + ], + }; + const frozenIdentity = Object.freeze({ + ...identityResult.data, + }); + const frozenCapabilities = Object.freeze({ + inputKinds: Object.freeze(capabilityResult.data.inputKinds), + updateModels: Object.freeze(capabilityResult.data.updateModels), + interactions: Object.freeze(capabilityResult.data.interactions), + internalAnimations: Object.freeze(capabilityResult.data.internalAnimations), + rendererPreferences: Object.freeze(capabilityResult.data.rendererPreferences), + fallbackPolicies: Object.freeze(capabilityResult.data.fallbackPolicies), + deterministic: capabilityResult.data.deterministic, + }); + const support = snapshot.support as RendererPlugin["support"]; + const build = snapshot.build as RendererPlugin["build"]; + const receiver = Object.freeze({ + identity: frozenIdentity, + capabilities: frozenCapabilities, + support, + build, + }); + return { + valid: true, + value: Object.freeze({ + identity: frozenIdentity, + capabilities: frozenCapabilities, + support: (request) => applyFunction(support, receiver, [request]), + build: (input) => applyFunction(build, receiver, [input]), + }), + diagnostics: [], + }; + } catch { + return { + valid: false, + diagnostics: [diagnostic("invalid-renderer-plugin", "Renderer plugin is invalid.", [])], + }; + } +}; + +export const validateRendererPlugin = (plugin: unknown): readonly Diagnostic[] => + prepareRendererPlugin(plugin).diagnostics; diff --git a/packages/presentation-renderer-api/src/boundary/renderer-plugin.ts b/packages/presentation-renderer-api/src/boundary/renderer-plugin.ts new file mode 100644 index 0000000..c58fd1f --- /dev/null +++ b/packages/presentation-renderer-api/src/boundary/renderer-plugin.ts @@ -0,0 +1,408 @@ +import type { + CompletedSemanticTree, + Diagnostic, + ValidationResult, +} from "@unframe/presentation-core"; + +import type { + CompilerResolvedSurfaceInput, + RendererBuildResult, + RendererPlugin, + RendererSupportDecision, +} from "../public-types.js"; + +import { + logicalBoundsConstraintSchema, + pixelTargetSchema, + rendererBuildInputSchema, + rendererBuildResultSchema, + rendererIdSchema, + rendererSupportDecisionSchema, + renderLayerSchema, + renderStateIdsSchema, +} from "../validation/schemas.js"; +import { createRendererFingerprint, diagnostic } from "../capabilities/evaluate-first-milestone.js"; +import { prepareRendererPlugin } from "./plugin-validation.js"; +import { copyUint8Array, snapshotUnknown } from "./shared/safe-data.js"; + +const compareStrings = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0); + +const validateInputReferences = (input: CompilerResolvedSurfaceInput): boolean => { + const { surface, semanticsByState } = input; + if (!Object.hasOwn(surface.states, surface.initialStateId)) return false; + const root = surface.contentNodes[surface.rootFrameId]; + if (!root || root.kind !== "frame" || root.parentId !== null) return false; + const reachableContentNodeIds = new Set(); + const pendingContentNodeIds = [surface.rootFrameId]; + while (pendingContentNodeIds.length > 0) { + const nodeId = pendingContentNodeIds.pop(); + if (nodeId === undefined || reachableContentNodeIds.has(nodeId)) continue; + const node = surface.contentNodes[nodeId]; + if (!node) return false; + reachableContentNodeIds.add(nodeId); + if (node.kind === "frame") pendingContentNodeIds.push(...node.children); + } + if (reachableContentNodeIds.size !== Object.keys(surface.contentNodes).length) return false; + const contentOrders = new Set(); + for (const [id, node] of Object.entries(surface.contentNodes)) { + if ( + node.id !== id || + (node.parentId === null ? id !== surface.rootFrameId : !surface.contentNodes[node.parentId]) + ) + return false; + const orderKey = `${node.parentId === null ? "\0root" : `id:${node.parentId}`}\0${node.order}`; + if (contentOrders.has(orderKey)) return false; + contentOrders.add(orderKey); + if (node.kind === "frame") { + if ( + new Set(node.children).size !== node.children.length || + node.children.some((childId) => surface.contentNodes[childId]?.parentId !== id) + ) + return false; + } else { + const parent = surface.contentNodes[node.parentId ?? ""]; + if (!parent || parent.kind !== "frame" || !parent.children.includes(id)) return false; + } + } + const validateTree = (tree: CompletedSemanticTree) => { + const roots = new Set(tree.rootNodeIds); + if (tree.rootNodeIds.some((id) => !tree.nodes[id])) return false; + const siblingOrders = new Set(); + for (const [id, node] of Object.entries(tree.nodes)) { + if (node.id !== id || (node.parentId === null ? !roots.has(id) : !tree.nodes[node.parentId])) + return false; + const orderKey = `${node.parentId === null ? "\0root" : `id:${node.parentId}`}\0${node.order}`; + if (siblingOrders.has(orderKey)) return false; + siblingOrders.add(orderKey); + const seen = new Set([id]); + for ( + let parentId = node.parentId; + parentId !== null; + parentId = tree.nodes[parentId]?.parentId ?? null + ) { + if (seen.has(parentId)) return false; + seen.add(parentId); + } + } + return Object.keys(tree.nodes).every( + (id) => roots.has(id) === (tree.nodes[id]?.parentId === null), + ); + }; + if ( + !validateTree(surface.baseSemanticTree) || + !Object.values(semanticsByState).every(validateTree) + ) + return false; + if (Object.entries(surface.interactions).some(([id, interaction]) => interaction.id !== id)) + return false; + const interactionEvents = new Set(Object.values(surface.interactions).map(({ event }) => event)); + for (const intent of [surface.renderIntent, input.sourceIntent, input.resolvedIntent]) { + if ( + intent.updateModel.kind === "finite-state" && + intent.updateModel.stateIds.some((id) => !surface.states[id]) + ) + return false; + if ( + intent.interaction.kind === "regions" && + intent.interaction.events.some((event) => !interactionEvents.has(event)) + ) + return false; + } + for (const [stateId, state] of Object.entries(surface.states)) { + if (state.id !== stateId || state.enabledInteractionIds.some((id) => !surface.interactions[id])) + return false; + if ( + state.semanticOverrides.some(({ nodes }) => + Object.keys(nodes).some((id) => !surface.baseSemanticTree.nodes[id]), + ) + ) + return false; + } + return [surface.baseSemanticTree, ...Object.values(semanticsByState)].every((tree) => + Object.values(tree.nodes).every( + ({ interactionId }) => interactionId === undefined || !!surface.interactions[interactionId], + ), + ); +}; + +export const sortedDiagnostics = (diagnostics: readonly Diagnostic[]) => + [...diagnostics].sort((left, right) => { + const leftKey = `${JSON.stringify(left.path)}\0${left.code}\0${left.message}`; + const rightKey = `${JSON.stringify(right.path)}\0${right.code}\0${right.message}`; + return compareStrings(leftKey, rightKey); + }); + +const comparable = (value: unknown): unknown => { + const bytes = copyUint8Array(value); + if (bytes) return Array.from({ length: bytes.length }, (_, index) => bytes[index]); + if (Array.isArray(value)) return value.map(comparable); + if (typeof value === "object" && value !== null) + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => compareStrings(left, right)) + .map(([key, item]) => [key, comparable(item)]), + ); + return value; +}; + +export const snapshot = (value: unknown) => JSON.stringify(comparable(value)); +const sameKeySet = (left: object, right: object) => + snapshot(Object.keys(left).sort(compareStrings)) === + snapshot(Object.keys(right).sort(compareStrings)); + +export const parseSupportDecision = (value: unknown) => { + const boundarySnapshot = snapshotUnknown(value); + const parsed = rendererSupportDecisionSchema.safeParse(boundarySnapshot); + return parsed.success + ? { success: true as const, data: boundarySnapshot as RendererSupportDecision } + : parsed; +}; + +export const parseBuildResult = (value: unknown) => { + const boundarySnapshot = snapshotUnknown(value); + const parsed = rendererBuildResultSchema.safeParse(boundarySnapshot); + return parsed.success + ? { success: true as const, data: boundarySnapshot as RendererBuildResult } + : parsed; +}; + +const validateInput = ( + input: CompilerResolvedSurfaceInput, + plugin: RendererPlugin, + diagnostics: Diagnostic[], + prefix: readonly (string | number)[], +) => { + if (input.plan.semanticSurfaceId !== input.surface.id) + diagnostics.push( + diagnostic( + "surface-plan-mismatch", + "Render plan must reference the input Semantic Surface.", + [...prefix, "plan", "semanticSurfaceId"], + ), + ); + + if ( + !logicalBoundsConstraintSchema.safeParse({ + bounds: input.plan.logicalBounds, + logicalSize: input.surface.logicalSize, + }).success + ) + diagnostics.push( + diagnostic("invalid-logical-bounds", "Logical bounds must be finite and positive.", [ + ...prefix, + "plan", + "logicalBounds", + ]), + ); + + const expectedFingerprint = createRendererFingerprint( + plugin.identity, + input.context.rendererConfigHash, + ); + if (input.context.rendererFingerprint !== expectedFingerprint) + diagnostics.push( + diagnostic( + "renderer-fingerprint-mismatch", + "Renderer fingerprint must identify the implementation and explicit configuration.", + [...prefix, "context", "rendererFingerprint"], + ), + ); + if (!renderLayerSchema.safeParse(input.plan.layer).success) + diagnostics.push( + diagnostic("invalid-render-layer", "Render layer must be a non-negative integer.", [ + ...prefix, + "plan", + "layer", + ]), + ); + if (!pixelTargetSchema.safeParse(input.context.pixelTarget).success) + diagnostics.push( + diagnostic("invalid-pixel-target", "Pixel target must contain positive integers.", [ + ...prefix, + "context", + "pixelTarget", + ]), + ); + + const stateIds = Object.keys(input.plan.states); + if (!renderStateIdsSchema.safeParse(stateIds).success) { + const code = stateIds.length === 0 ? "missing-render-states" : "invalid-render-state-id"; + diagnostics.push( + diagnostic( + code, + stateIds.length === 0 + ? "Render plan must include a reachable state." + : "Render state IDs must be non-empty.", + [...prefix, "plan", "states"], + ), + ); + } + for (const stateId of Object.keys(input.plan.states)) + if (!Object.hasOwn(input.semanticsByState, stateId)) + diagnostics.push( + diagnostic("missing-state-semantics", "Planned state has no completed Semantic Tree.", [ + ...prefix, + "semanticsByState", + stateId, + ]), + ); + if ( + !sameKeySet(input.plan.states, input.surface.states) || + !sameKeySet(input.plan.states, input.semanticsByState) + ) + diagnostics.push( + diagnostic( + "surface-state-set-mismatch", + "Plan and completed semantics must exactly match the Semantic Surface states.", + [...prefix, "plan", "states"], + ), + ); + for (const [stateId, state] of Object.entries(input.surface.states)) + if (state.id !== stateId) + diagnostics.push( + diagnostic("surface-state-id-mismatch", "Surface state keys must match state IDs.", [ + ...prefix, + "surface", + "states", + stateId, + "id", + ]), + ); + if (snapshot(input.sourceIntent) !== snapshot(input.surface.renderIntent)) + diagnostics.push( + diagnostic( + "source-render-intent-mismatch", + "Source intent must match the Semantic Surface.", + [...prefix, "sourceIntent"], + ), + ); + const expectedResolvedIntent = { + updateModel: input.sourceIntent.updateModel, + interaction: input.sourceIntent.interaction, + internalAnimation: input.sourceIntent.internalAnimation, + fallbackPolicy: input.sourceIntent.fallbackPolicy, + }; + const actualResolvedIntent = { + updateModel: input.resolvedIntent.updateModel, + interaction: input.resolvedIntent.interaction, + internalAnimation: input.resolvedIntent.internalAnimation, + fallbackPolicy: input.resolvedIntent.fallbackPolicy, + }; + if (snapshot(actualResolvedIntent) !== snapshot(expectedResolvedIntent)) + diagnostics.push( + diagnostic( + "resolved-render-intent-mismatch", + "Resolution may select a renderer but must preserve the source semantic intent.", + [...prefix, "resolvedIntent"], + ), + ); + if (input.resolvedIntent.selectedRendererId !== plugin.identity.id) + diagnostics.push( + diagnostic( + "selected-renderer-plugin-mismatch", + "Compiler-selected renderer ID must match the invoked plugin.", + [...prefix, "resolvedIntent", "selectedRendererId"], + ), + ); + if ( + input.sourceIntent.rendererPreference !== "auto" && + input.sourceIntent.rendererPreference !== input.resolvedIntent.selectedRendererId + ) + diagnostics.push( + diagnostic( + "renderer-preference-mismatch", + "An explicit renderer preference cannot resolve to another renderer.", + [...prefix, "resolvedIntent", "selectedRendererId"], + ), + ); + for (const contentNodeId of input.plan.contentNodeIds) + if ( + !rendererIdSchema.safeParse(contentNodeId).success || + !Object.hasOwn(input.surface.contentNodes, contentNodeId) + ) + diagnostics.push( + diagnostic("missing-content-node", "Render plan references an unknown content node.", [ + ...prefix, + "plan", + "contentNodeIds", + contentNodeId, + ]), + ); + if (new Set(input.plan.contentNodeIds).size !== input.plan.contentNodeIds.length) + diagnostics.push( + diagnostic("duplicate-content-node", "Render plan content node IDs must be unique.", [ + ...prefix, + "plan", + "contentNodeIds", + ]), + ); +}; + +type PreparedRendererBoundary = { + readonly input: CompilerResolvedSurfaceInput; + readonly plugin: RendererPlugin; +}; + +export const prepareRendererBoundary = ( + input: unknown, + plugin: unknown, + prefix: readonly (string | number)[], + preparedPlugin?: RendererPlugin, +): ValidationResult => { + try { + const pluginResult = preparedPlugin + ? { valid: true as const, value: preparedPlugin } + : prepareRendererPlugin(plugin); + if (!pluginResult.valid) return pluginResult; + const inputSnapshot = snapshotUnknown(input); + const inputResult = rendererBuildInputSchema.safeParse(inputSnapshot); + if (!inputResult.success) { + return { + valid: false, + diagnostics: [ + diagnostic("invalid-renderer-input", "Renderer build input is invalid.", prefix), + ], + }; + } + const diagnostics: Diagnostic[] = []; + const preparedInput = inputSnapshot as CompilerResolvedSurfaceInput; + if (!validateInputReferences(preparedInput)) { + return { + valid: false, + diagnostics: [ + diagnostic("invalid-renderer-input", "Renderer build input is invalid.", prefix), + ], + }; + } + validateInput(preparedInput, pluginResult.value, diagnostics, prefix); + return diagnostics.length === 0 + ? { + valid: true, + value: Object.freeze({ input: preparedInput, plugin: pluginResult.value }), + diagnostics: [], + } + : { valid: false, diagnostics: sortedDiagnostics(diagnostics) }; + } catch { + return { + valid: false, + diagnostics: [ + diagnostic("invalid-renderer-input", "Renderer build input is invalid.", prefix), + ], + }; + } +}; + +export const prepareRendererBuildInput = ( + input: unknown, + plugin: unknown, +): ValidationResult => { + const prepared = prepareRendererBoundary(input, plugin, []); + return prepared.valid + ? { valid: true, value: prepared.value.input, diagnostics: [] } + : { valid: false, diagnostics: prepared.diagnostics }; +}; + +export const validateRendererBuildInput = ( + input: unknown, + plugin: unknown, +): readonly Diagnostic[] => prepareRendererBuildInput(input, plugin).diagnostics; diff --git a/packages/presentation-renderer-api/src/boundary/shared/safe-data.ts b/packages/presentation-renderer-api/src/boundary/shared/safe-data.ts new file mode 100644 index 0000000..e048df5 --- /dev/null +++ b/packages/presentation-renderer-api/src/boundary/shared/safe-data.ts @@ -0,0 +1,74 @@ +export const applyFunction = Reflect.apply; + +export const plainDataRecord = (value: unknown): Record | undefined => { + try { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + Object.getPrototypeOf(value); + if (Object.getOwnPropertySymbols(value).length !== 0) return undefined; + const descriptors: Record = Object.getOwnPropertyDescriptors(value); + if (Object.keys(descriptors).some((key) => descriptors[key]?.get || descriptors[key]?.set)) + return undefined; + return Object.fromEntries( + Object.keys(descriptors).map((key) => [key, descriptors[key]?.value]), + ); + } catch { + return undefined; + } +}; +const invalidSnapshot = Symbol("invalid-renderer-boundary-snapshot"); + +export const snapshotUnknown = (value: unknown, seen = new Set()): unknown => { + if (value === null || ["string", "number", "boolean"].includes(typeof value)) return value; + const bytes = copyUint8Array(value); + if (bytes) return bytes; + if (typeof value !== "object" || seen.has(value)) return invalidSnapshot; + seen.add(value); + try { + if (Array.isArray(value)) { + const descriptors = Object.getOwnPropertyDescriptors(value); + const length = (descriptors["length"] as unknown as PropertyDescriptor | undefined)?.value; + if ( + !Number.isSafeInteger(length) || + length < 0 || + Object.getOwnPropertySymbols(value).length !== 0 + ) + return invalidSnapshot; + const expected = Array.from({ length }, (_, index) => String(index)); + if ( + Object.keys(descriptors).length !== length + 1 || + expected.some((key) => { + const descriptor = descriptors[key]; + return !descriptor || descriptor.get || descriptor.set; + }) + ) + return invalidSnapshot; + return expected.map((key) => snapshotUnknown(descriptors[key]?.value, seen)); + } + const record = plainDataRecord(value); + if (!record) return invalidSnapshot; + return Object.fromEntries( + Object.keys(record).map((key) => [key, snapshotUnknown(record[key], seen)]), + ); + } finally { + seen.delete(value); + } +}; +const typedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype); +const typedArrayByteLength = Object.getOwnPropertyDescriptor( + typedArrayPrototype, + "byteLength", +)?.get; +const typedArrayTag = Object.getOwnPropertyDescriptor(typedArrayPrototype, Symbol.toStringTag)?.get; +export const copyUint8Array = (value: unknown): Uint8Array | undefined => { + try { + if (!ArrayBuffer.isView(value) || !typedArrayByteLength || !typedArrayTag) return undefined; + if (typedArrayTag.call(value) !== "Uint8Array") return undefined; + const byteLength = typedArrayByteLength.call(value); + if (!Number.isSafeInteger(byteLength) || byteLength < 0) return undefined; + const copy = new Uint8Array(byteLength); + Uint8Array.prototype.set.call(copy, value as Uint8Array); + return copy; + } catch { + return undefined; + } +}; diff --git a/packages/presentation-renderer-api/src/capabilities/evaluate-first-milestone.ts b/packages/presentation-renderer-api/src/capabilities/evaluate-first-milestone.ts new file mode 100644 index 0000000..08a255a --- /dev/null +++ b/packages/presentation-renderer-api/src/capabilities/evaluate-first-milestone.ts @@ -0,0 +1,49 @@ +import type { Diagnostic } from "@unframe/presentation-core"; +import type { + RendererIdentity, + RendererSupportDecision, + RendererSupportRequest, +} from "../public-types.js"; + +export const diagnostic = ( + code: string, + message: string, + path: readonly (string | number)[] = [], +): Diagnostic => ({ code, path, message }); + +const unsupported = ( + code: string, + path: readonly (string | number)[], +): RendererSupportDecision => ({ + supported: false, + diagnostics: [diagnostic(code, "Renderer capability is not supported.", path)], +}); + +export const evaluateFirstMilestoneSupport = ( + request: RendererSupportRequest, +): RendererSupportDecision => { + if (request.entry.kind !== "structured") return unsupported("unsupported-input-kind", ["entry"]); + if (request.resolvedIntent.updateModel.kind !== "static") + return unsupported("unsupported-update-model", ["resolvedIntent", "updateModel"]); + if (request.resolvedIntent.interaction.kind !== "none") + return unsupported("unsupported-interaction", ["resolvedIntent", "interaction"]); + if (request.resolvedIntent.internalAnimation.kind !== "none") + return unsupported("unsupported-internal-animation", ["resolvedIntent", "internalAnimation"]); + if (request.resolvedIntent.selectedRendererId !== "baked-web") + return unsupported("unsupported-renderer", ["resolvedIntent", "selectedRendererId"]); + if (request.resolvedIntent.fallbackPolicy !== "reject") + return unsupported("unsupported-fallback-policy", ["resolvedIntent", "fallbackPolicy"]); + return { supported: true, diagnostics: [] }; +}; + +export const createRendererFingerprint = ( + identity: RendererIdentity, + rendererConfigHash: string, +): string => + JSON.stringify([ + identity.id, + identity.version, + identity.contractVersion, + identity.implementationHash, + rendererConfigHash, + ]); diff --git a/packages/presentation-renderer-api/src/execution/plugin-execution.ts b/packages/presentation-renderer-api/src/execution/plugin-execution.ts new file mode 100644 index 0000000..5d94646 --- /dev/null +++ b/packages/presentation-renderer-api/src/execution/plugin-execution.ts @@ -0,0 +1,546 @@ +import type { Diagnostic, HitRegion } from "@unframe/presentation-core"; +import { + type CompilerResolvedSurfaceInput, + type RendererBuildResult, + type RendererBuildSuccess, + type RendererConformanceFixture, + type RendererPlugin, + type RendererSupportDecision, + type RendererSupportRequest, + type ValidationResult, +} from "../public-types.js"; +import { + diagnostic, + evaluateFirstMilestoneSupport, +} from "../capabilities/evaluate-first-milestone.js"; +import { + capturePixelSizeSchema, + hitRegionPrioritySchema, + normalizedHitRegionBoundsSchema, +} from "../validation/schemas.js"; +import { + parseBuildResult, + parseSupportDecision, + prepareRendererBoundary, +} from "../boundary/renderer-plugin.js"; +import { copyUint8Array } from "../boundary/shared/safe-data.js"; +import { prepareRendererPlugin } from "../boundary/plugin-validation.js"; + +import { snapshot, sortedDiagnostics } from "../boundary/renderer-plugin.js"; + +const validateProvenance = ( + fixture: RendererConformanceFixture, + plugin: RendererPlugin, + result: RendererBuildSuccess, + diagnostics: Diagnostic[], +) => { + const expected = { + ...plugin.identity, + inputHash: fixture.input.context.inputHash, + buildContextHash: fixture.input.context.buildContextHash, + environmentHash: fixture.input.context.environmentHash, + rendererConfigHash: fixture.input.context.rendererConfigHash, + rendererFingerprint: fixture.input.context.rendererFingerprint, + }; + if (snapshot(result.provenance) !== snapshot(expected)) + diagnostics.push( + diagnostic( + "invalid-renderer-provenance", + "Renderer provenance does not match identity/context.", + [fixture.name, "output", "provenance"], + ), + ); +}; + +const validateHitRegion = ( + fixture: RendererConformanceFixture, + stateId: string, + region: HitRegion, + diagnostics: Diagnostic[], +) => { + const path = [ + fixture.name, + "output", + "hitRegionsByState", + stateId, + region.interactionId, + ] as const; + if (!normalizedHitRegionBoundsSchema.safeParse(region.bounds).success) + diagnostics.push( + diagnostic( + "invalid-hit-region-bounds", + "Hit Region must use finite normalized bounds.", + path, + ), + ); + + const interaction = fixture.input.surface.interactions[region.interactionId]; + const semanticNode = fixture.input.semanticsByState[stateId]?.nodes[region.semanticNodeId]; + const surfaceState = fixture.input.surface.states[stateId]; + if ( + interaction === undefined || + interaction.event !== region.event || + !surfaceState?.enabledInteractionIds.includes(region.interactionId) + ) + diagnostics.push( + diagnostic( + "invalid-hit-region-interaction", + "Hit Region must match a declared interaction.", + path, + ), + ); + if (semanticNode === undefined || semanticNode.interactionId !== region.interactionId) + diagnostics.push( + diagnostic( + "invalid-hit-region-semantic-node", + "Hit Region must match a Semantic Node.", + path, + ), + ); + if (!hitRegionPrioritySchema.safeParse(region.priority).success) + diagnostics.push( + diagnostic("invalid-hit-region-priority", "Hit Region priority must be non-negative.", path), + ); +}; + +const validateSuccess = ( + fixture: RendererConformanceFixture, + plugin: RendererPlugin, + result: RendererBuildSuccess, + diagnostics: Diagnostic[], +) => { + const { input, name } = fixture; + const expectedSurface = { + id: input.plan.id, + semanticSurfaceId: input.plan.semanticSurfaceId, + logicalBounds: input.plan.logicalBounds, + layer: input.plan.layer, + }; + if (snapshot(result.renderSurface) !== snapshot(expectedSurface)) + diagnostics.push( + diagnostic("render-surface-plan-mismatch", "Renderer changed the Compiler render plan.", [ + name, + "output", + "renderSurface", + ]), + ); + + validateProvenance(fixture, plugin, result, diagnostics); + + const captureIds = new Set(); + const capturesByState = new Map(); + for (const capture of result.captures) { + if (captureIds.has(capture.id)) + diagnostics.push( + diagnostic("duplicate-capture-id", "Capture IDs must be unique and non-empty.", [ + name, + "output", + "captures", + capture.id, + ]), + ); + captureIds.add(capture.id); + capturesByState.set(capture.stateId, (capturesByState.get(capture.stateId) ?? 0) + 1); + if (!capturePixelSizeSchema.safeParse(capture.pixelSize).success) + diagnostics.push( + diagnostic("invalid-capture-size", "Capture size must contain positive integers.", [ + name, + "output", + "captures", + capture.id, + "pixelSize", + ]), + ); + else if (snapshot(capture.pixelSize) !== snapshot(input.context.pixelTarget)) + diagnostics.push( + diagnostic("capture-size-mismatch", "Capture size must match the requested pixel target.", [ + name, + "output", + "captures", + capture.id, + "pixelSize", + ]), + ); + const rgba = copyUint8Array(capture.rgba); + const expectedBytes = capture.pixelSize[0] * capture.pixelSize[1] * 4; + if (rgba && rgba.length !== expectedBytes) + diagnostics.push( + diagnostic("invalid-rgba-length", "Raw RGBA byte length does not match pixel size.", [ + name, + "output", + "captures", + capture.id, + "rgba", + ]), + ); + if ( + rgba && + capture.alphaMode === "opaque" && + rgba.some((_, index) => index % 4 === 3 && rgba[index] !== 255) + ) + diagnostics.push( + diagnostic("invalid-opaque-alpha", "Opaque captures must use alpha 255 for every pixel.", [ + name, + "output", + "captures", + capture.id, + "rgba", + ]), + ); + } + + for (const [stateId, statePlan] of Object.entries(input.plan.states)) { + const count = capturesByState.get(stateId) ?? 0; + if ( + (statePlan.kind === "capture" && count !== 1) || + (statePlan.kind === "empty" && count !== 0) + ) + diagnostics.push( + diagnostic( + "state-capture-mismatch", + "Capture output must satisfy the planned state binding.", + [name, "output", "captures", stateId], + ), + ); + if (!Object.hasOwn(result.hitRegionsByState, stateId)) + diagnostics.push( + diagnostic("missing-state-hit-regions", "Every planned state needs Hit Region output.", [ + name, + "output", + "hitRegionsByState", + stateId, + ]), + ); + const regions = result.hitRegionsByState[stateId]; + if (!regions) continue; + if (input.resolvedIntent.interaction.kind === "none" && regions.length > 0) + diagnostics.push( + diagnostic( + "unexpected-hit-region", + "Interaction-free surfaces must not produce Hit Regions.", + [name, "output", "hitRegionsByState", stateId], + ), + ); + const coveredInteractionIds = new Set(regions.map((region) => region.interactionId)); + for (const interactionId of input.surface.states[stateId]?.enabledInteractionIds ?? []) + if (!coveredInteractionIds.has(interactionId)) + diagnostics.push( + diagnostic( + "missing-enabled-interaction-region", + "Every enabled interaction must have at least one Hit Region.", + [name, "output", "hitRegionsByState", stateId], + ), + ); + } + for (const stateId of capturesByState.keys()) + if (!Object.hasOwn(input.plan.states, stateId)) + diagnostics.push( + diagnostic("unexpected-capture-state", "Capture references an unplanned state.", [ + name, + "output", + "captures", + stateId, + ]), + ); + for (const [stateId, regions] of Object.entries(result.hitRegionsByState)) { + if (!Object.hasOwn(input.plan.states, stateId)) + diagnostics.push( + diagnostic("unexpected-hit-region-state", "Hit Regions reference an unplanned state.", [ + name, + "output", + "hitRegionsByState", + stateId, + ]), + ); + for (const region of regions) validateHitRegion(fixture, stateId, region, diagnostics); + } +}; + +type CallResult = { readonly threw: true } | { readonly threw: false; readonly value: unknown }; + +const callBuild = async ( + plugin: RendererPlugin, + input: CompilerResolvedSurfaceInput, +): Promise => { + try { + return { threw: false, value: await plugin.build(input) }; + } catch { + return { threw: true }; + } +}; + +const callSupport = (plugin: RendererPlugin, request: RendererSupportRequest): CallResult => { + try { + return { threw: false, value: plugin.support(request) }; + } catch { + return { threw: true }; + } +}; + +export const executeRendererPlugin = async ( + plugin: RendererPlugin, + input: CompilerResolvedSurfaceInput, +): Promise> => { + try { + const diagnostics: Diagnostic[] = []; + const prepared = prepareRendererBoundary(input, plugin, ["single", "input"]); + if (!prepared.valid) return { valid: false, diagnostics: [...prepared.diagnostics] }; + const fixture: RendererConformanceFixture = { name: "single", input: prepared.value.input }; + const preparedPlugin = prepared.value.plugin; + const before = snapshot(fixture.input); + const request = { entry: fixture.input.entry, resolvedIntent: fixture.input.resolvedIntent }; + const supportCall = callSupport(preparedPlugin, request); + if (supportCall.threw) + diagnostics.push( + diagnostic("renderer-support-threw", "support() must return a diagnostic decision.", []), + ); + else if (!parseSupportDecision(supportCall.value).success) + diagnostics.push( + diagnostic( + "malformed-support-decision", + "support() must return a structured diagnostic decision.", + [], + ), + ); + if (snapshot(fixture.input) !== before) + diagnostics.push( + diagnostic("renderer-mutated-input", "Renderer mutated Compiler-owned input.", []), + ); + if (diagnostics.length > 0) + return { valid: false, diagnostics: sortedDiagnostics(diagnostics) }; + const expected = evaluateFirstMilestoneSupport(request); + if ( + snapshot((supportCall as { readonly value: RendererSupportDecision }).value) !== + snapshot(expected) + ) + diagnostics.push( + diagnostic("invalid-support-decision", "support() must follow declared capabilities.", []), + ); + if (diagnostics.length > 0) + return { valid: false, diagnostics: sortedDiagnostics(diagnostics) }; + if (!expected.supported) + return { valid: false, diagnostics: sortedDiagnostics([...expected.diagnostics]) }; + const buildCall = await callBuild(preparedPlugin, fixture.input); + if (buildCall.threw) + diagnostics.push( + diagnostic("renderer-threw", "Renderer failures must be returned as diagnostics.", []), + ); + else if (!parseBuildResult(buildCall.value).success) + diagnostics.push( + diagnostic( + "malformed-renderer-output", + "build() must return a structured renderer result.", + [], + ), + ); + if (snapshot(fixture.input) !== before) + diagnostics.push( + diagnostic("renderer-mutated-input", "Renderer mutated Compiler-owned input.", []), + ); + if (diagnostics.length > 0) + return { valid: false, diagnostics: sortedDiagnostics(diagnostics) }; + const support = parseSupportDecision((supportCall as { readonly value: unknown }).value) + .data as RendererSupportDecision; + const result = parseBuildResult((buildCall as { readonly value: unknown }).value) + .data as RendererBuildResult; + if (support.supported !== result.ok) + diagnostics.push(diagnostic("support-build-mismatch", "support() and build() disagree.", [])); + if (result.ok) validateSuccess(fixture, preparedPlugin, result, diagnostics); + else if (result.diagnostics.length === 0) + diagnostics.push( + diagnostic("missing-failure-diagnostic", "Renderer failure must include a diagnostic.", []), + ); + if (!result.ok) + return { + valid: false, + diagnostics: sortedDiagnostics([...diagnostics, ...result.diagnostics]), + }; + return diagnostics.length === 0 + ? { valid: true, value: result, diagnostics: [] } + : { valid: false, diagnostics: sortedDiagnostics(diagnostics) }; + } catch { + return { + valid: false, + diagnostics: [ + diagnostic("invalid-renderer-boundary", "Renderer boundary input is invalid.", []), + ], + }; + } +}; + +const runRendererConformanceUnchecked = async ( + plugin: RendererPlugin, + fixtures: readonly RendererConformanceFixture[], +): Promise> => { + const diagnostics: Diagnostic[] = []; + const results: RendererBuildResult[] = []; + + for (const fixture of fixtures) { + const prepared = prepareRendererBoundary( + fixture.input, + plugin, + [fixture.name, "input"], + plugin, + ); + if (!prepared.valid) { + diagnostics.push(...prepared.diagnostics); + continue; + } + const preparedFixture: RendererConformanceFixture = { + name: fixture.name, + input: prepared.value.input, + }; + const inputSnapshot = snapshot(preparedFixture.input); + const request = { + entry: preparedFixture.input.entry, + resolvedIntent: preparedFixture.input.resolvedIntent, + }; + const expectedSupport = evaluateFirstMilestoneSupport(request); + const supportCall = callSupport(plugin, request); + if (supportCall.threw) { + diagnostics.push( + diagnostic("renderer-support-threw", "support() must return a diagnostic decision.", [ + fixture.name, + "support", + ]), + ); + if (snapshot(preparedFixture.input) !== inputSnapshot) + diagnostics.push( + diagnostic("renderer-mutated-input", "Renderer mutated Compiler-owned input.", [ + fixture.name, + "input", + ]), + ); + continue; + } + const supportResult = parseSupportDecision(supportCall.value); + if (!supportResult.success) { + diagnostics.push( + diagnostic( + "malformed-support-decision", + "support() must return a structured diagnostic decision.", + [fixture.name, "support"], + ), + ); + if (snapshot(preparedFixture.input) !== inputSnapshot) + diagnostics.push( + diagnostic("renderer-mutated-input", "Renderer mutated Compiler-owned input.", [ + fixture.name, + "input", + ]), + ); + continue; + } + const support = supportResult.data as RendererSupportDecision; + if (snapshot(support) !== snapshot(expectedSupport)) + diagnostics.push( + diagnostic("invalid-support-decision", "support() must follow declared capabilities.", [ + fixture.name, + "support", + ]), + ); + + const firstCall = await callBuild(plugin, preparedFixture.input); + if (firstCall.threw) { + diagnostics.push( + diagnostic("renderer-threw", "Renderer failures must be returned as diagnostics.", [ + fixture.name, + "build", + ]), + ); + if (snapshot(preparedFixture.input) !== inputSnapshot) + diagnostics.push( + diagnostic("renderer-mutated-input", "Renderer mutated Compiler-owned input.", [ + fixture.name, + "input", + ]), + ); + continue; + } + const firstResult = parseBuildResult(firstCall.value); + if (!firstResult.success) { + diagnostics.push( + diagnostic( + "malformed-renderer-output", + "build() must return a structured renderer result.", + [fixture.name, "build"], + ), + ); + if (snapshot(preparedFixture.input) !== inputSnapshot) + diagnostics.push( + diagnostic("renderer-mutated-input", "Renderer mutated Compiler-owned input.", [ + fixture.name, + "input", + ]), + ); + continue; + } + const first = firstResult.data as RendererBuildResult; + const firstSnapshot = snapshot(first); + results.push(first); + + if (support.supported !== first.ok) + diagnostics.push( + diagnostic("support-build-mismatch", "support() and build() disagree.", [ + fixture.name, + "build", + ]), + ); + + if (!first.ok) { + if (first.diagnostics.length === 0) + diagnostics.push( + diagnostic("missing-failure-diagnostic", "Renderer failure must include a diagnostic.", [ + fixture.name, + "build", + "diagnostics", + ]), + ); + } else validateSuccess(preparedFixture, plugin, first, diagnostics); + if (plugin.capabilities.deterministic) { + const secondCall = await callBuild(plugin, preparedFixture.input); + if ( + secondCall.threw || + !parseBuildResult(secondCall.value).success || + firstSnapshot !== snapshot(parseBuildResult(secondCall.value).data) + ) + diagnostics.push( + diagnostic( + "non-deterministic-renderer-output", + "Deterministic renderer output changed.", + [fixture.name, "build"], + ), + ); + } + if (snapshot(preparedFixture.input) !== inputSnapshot) + diagnostics.push( + diagnostic("renderer-mutated-input", "Renderer mutated Compiler-owned input.", [ + fixture.name, + "input", + ]), + ); + } + + return diagnostics.length === 0 + ? { valid: true, value: results, diagnostics: [] } + : { valid: false, diagnostics: sortedDiagnostics(diagnostics) }; +}; + +export const runRendererConformance = async ( + plugin: RendererPlugin, + fixtures: readonly RendererConformanceFixture[], +): Promise> => { + try { + const preparedPlugin = prepareRendererPlugin(plugin); + if (!preparedPlugin.valid) + return { valid: false, diagnostics: [...preparedPlugin.diagnostics] }; + return await runRendererConformanceUnchecked(preparedPlugin.value, fixtures); + } catch { + return { + valid: false, + diagnostics: [ + diagnostic("invalid-renderer-boundary", "Renderer boundary input is invalid.", []), + ], + }; + } +}; diff --git a/packages/presentation-renderer-api/src/index.ts b/packages/presentation-renderer-api/src/index.ts new file mode 100644 index 0000000..604fe3c --- /dev/null +++ b/packages/presentation-renderer-api/src/index.ts @@ -0,0 +1,37 @@ +export { + prepareRendererBuildInput, + validateRendererBuildInput, +} from "./boundary/renderer-plugin.js"; + +export { defineRendererPlugin, validateRendererPlugin } from "./boundary/plugin-validation.js"; + +export { executeRendererPlugin, runRendererConformance } from "./execution/plugin-execution.js"; + +export { + createRendererFingerprint, + evaluateFirstMilestoneSupport, +} from "./capabilities/evaluate-first-milestone.js"; + +export type { + CompilerResolvedSurfaceInput, + Diagnostic, + LogicalBounds, + RawSurfaceCapture, + RenderStatePlan, + RendererBuildContext, + RendererBuildFailure, + RendererBuildResult, + RendererBuildSuccess, + RendererCapabilities, + RendererConformanceFixture, + RendererEntry, + RendererIdentity, + RendererPlugin, + RendererProvenance, + RendererSupportDecision, + RendererSupportRequest, + ResolvedRendererIntent, + ResolvedRenderSurface, + RenderSurfacePlan, + ValidationResult, +} from "./public-types.js"; diff --git a/packages/presentation-renderer-api/src/public-types.ts b/packages/presentation-renderer-api/src/public-types.ts new file mode 100644 index 0000000..b9209ad --- /dev/null +++ b/packages/presentation-renderer-api/src/public-types.ts @@ -0,0 +1,137 @@ +import type { + CompletedSemanticTree, + Diagnostic, + HitRegion, + SemanticSurface, + SurfaceRenderIntent, + ValidationResult, +} from "@unframe/presentation-core"; + +export type { Diagnostic, ValidationResult }; + +export type RendererIdentity = { + readonly id: string; + readonly version: string; + readonly contractVersion: string; + readonly implementationHash: string; +}; + +export type RendererCapabilities = { + readonly inputKinds: readonly ["structured"]; + readonly updateModels: readonly ["static"]; + readonly interactions: readonly ["none"]; + readonly internalAnimations: readonly ["none"]; + readonly rendererPreferences: readonly ["baked-web"]; + readonly fallbackPolicies: readonly ["reject"]; + readonly deterministic: true; +}; + +export type RendererBuildContext = { + readonly locale: string; + readonly timezone: string; + readonly colorScheme: "light" | "dark"; + readonly themeId: string; + readonly themeHash: string; + readonly inputHash: string; + readonly buildContextHash: string; + readonly environmentHash: string; + readonly rendererConfigHash: string; + readonly rendererFingerprint: string; + readonly pixelTarget: readonly [width: number, height: number]; +}; + +export type LogicalBounds = { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +}; + +export type RenderStatePlan = { readonly kind: "capture" } | { readonly kind: "empty" }; + +export type RenderSurfacePlan = { + readonly id: string; + readonly semanticSurfaceId: string; + readonly logicalBounds: LogicalBounds; + readonly layer: number; + readonly contentNodeIds: readonly string[]; + readonly states: Readonly>; +}; + +export type RendererEntry = + | { readonly kind: "structured" } + | { readonly kind: "opaque"; readonly entryId: string; readonly moduleHash: string }; + +export type ResolvedRendererIntent = { + readonly updateModel: SurfaceRenderIntent["updateModel"]; + readonly interaction: SurfaceRenderIntent["interaction"]; + readonly internalAnimation: SurfaceRenderIntent["internalAnimation"]; + readonly selectedRendererId: string; + readonly fallbackPolicy: SurfaceRenderIntent["fallbackPolicy"]; +}; + +export type CompilerResolvedSurfaceInput = { + readonly surface: SemanticSurface; + readonly sourceIntent: SurfaceRenderIntent; + readonly resolvedIntent: ResolvedRendererIntent; + readonly semanticsByState: Readonly>; + readonly plan: RenderSurfacePlan; + readonly entry: RendererEntry; + readonly context: RendererBuildContext; +}; + +export type RawSurfaceCapture = { + readonly id: string; + readonly stateId: string; + readonly rgba: Uint8Array; + readonly pixelSize: readonly [width: number, height: number]; + readonly colorSpace: "srgb"; + readonly alphaMode: "opaque" | "straight" | "premultiplied"; +}; + +export type RendererProvenance = RendererIdentity & { + readonly inputHash: string; + readonly buildContextHash: string; + readonly environmentHash: string; + readonly rendererConfigHash: string; + readonly rendererFingerprint: string; +}; + +export type ResolvedRenderSurface = { + readonly id: string; + readonly semanticSurfaceId: string; + readonly logicalBounds: LogicalBounds; + readonly layer: number; +}; + +export type RendererBuildSuccess = { + readonly ok: true; + readonly renderSurface: ResolvedRenderSurface; + readonly captures: readonly RawSurfaceCapture[]; + readonly hitRegionsByState: Readonly>; + readonly provenance: RendererProvenance; + readonly diagnostics: readonly Diagnostic[]; +}; + +export type RendererBuildFailure = { + readonly ok: false; + readonly diagnostics: readonly Diagnostic[]; +}; + +export type RendererBuildResult = RendererBuildSuccess | RendererBuildFailure; +export type RendererSupportRequest = Pick; +export type RendererSupportDecision = + | { readonly supported: true; readonly diagnostics: readonly [] } + | { readonly supported: false; readonly diagnostics: readonly Diagnostic[] }; + +export type RendererPlugin = { + readonly identity: RendererIdentity; + readonly capabilities: RendererCapabilities; + support(input: RendererSupportRequest): RendererSupportDecision; + build(input: CompilerResolvedSurfaceInput): Promise | RendererBuildResult; +}; + +export type RendererConformanceFixture = { + readonly name: string; + readonly input: CompilerResolvedSurfaceInput; +}; diff --git a/packages/presentation-renderer-api/src/validation/schemas.ts b/packages/presentation-renderer-api/src/validation/schemas.ts new file mode 100644 index 0000000..768a791 --- /dev/null +++ b/packages/presentation-renderer-api/src/validation/schemas.ts @@ -0,0 +1,270 @@ +import * as z from "zod"; + +export const rendererIdSchema = z.string().min(1); +const finiteNumberSchema = z.number().finite(); +const positiveNumberSchema = finiteNumberSchema.positive(); +const nonNegativeIntegerSchema = z.int().nonnegative(); +const uniqueIdArraySchema = z + .array(rendererIdSchema) + .refine((values) => new Set(values).size === values.length); + +const boundsSchema = z.strictObject({ + x: finiteNumberSchema, + y: finiteNumberSchema, + width: finiteNumberSchema, + height: finiteNumberSchema, +}); + +export const renderLayerSchema = nonNegativeIntegerSchema; +export const pixelTargetSchema = z.tuple([z.int().positive(), z.int().positive()]); +export const renderStateIdsSchema = z.array(rendererIdSchema).min(1); +export const capturePixelSizeSchema = pixelTargetSchema; +export const hitRegionPrioritySchema = nonNegativeIntegerSchema; +export const normalizedHitRegionBoundsSchema = boundsSchema.refine( + ({ x, y, width, height }) => + x >= 0 && y >= 0 && width > 0 && height > 0 && x + width <= 1 && y + height <= 1, +); +export const logicalBoundsConstraintSchema = z + .strictObject({ + bounds: boundsSchema, + logicalSize: z.tuple([positiveNumberSchema, positiveNumberSchema]), + }) + .refine( + ({ bounds, logicalSize }) => + bounds.x >= 0 && + bounds.y >= 0 && + bounds.width > 0 && + bounds.height > 0 && + bounds.x + bounds.width <= logicalSize[0] && + bounds.y + bounds.height <= logicalSize[1], + ); + +const semanticNodeSchema = z.strictObject({ + id: rendererIdSchema, + parentId: rendererIdSchema.nullable(), + order: nonNegativeIntegerSchema, + role: z.enum(["heading", "paragraph", "image", "button", "table", "list", "listItem"]), + text: z.string().optional(), + language: rendererIdSchema.optional(), + alt: z.string().optional(), + interactionId: rendererIdSchema.optional(), +}); + +const semanticTreeSchema = z.strictObject({ + rootNodeIds: uniqueIdArraySchema, + nodes: z.record(rendererIdSchema, semanticNodeSchema), +}); + +const interactionSchema = z.strictObject({ + id: rendererIdSchema, + kind: z.literal("click"), + event: rendererIdSchema, +}); + +const stateSchema = z.strictObject({ + id: rendererIdSchema, + semanticOverrides: z.array( + z.strictObject({ + nodes: z.record( + z.string(), + z.strictObject({ + included: z.boolean().optional(), + text: z.string().nullable().optional(), + language: rendererIdSchema.nullable().optional(), + alt: z.string().nullable().optional(), + }), + ), + }), + ), + enabledInteractionIds: uniqueIdArraySchema, +}); + +const sourceIntentSchema = z.strictObject({ + updateModel: z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("static") }), + z.strictObject({ kind: z.literal("finite-state"), stateIds: uniqueIdArraySchema.min(1) }), + z.strictObject({ + kind: z.literal("continuous"), + source: z.enum(["timeline", "runtime-data", "user-input"]), + maximumUpdateRateHz: positiveNumberSchema.optional(), + }), + ]), + interaction: z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("none") }), + z.strictObject({ kind: z.literal("native-input") }), + z.strictObject({ kind: z.literal("regions"), events: uniqueIdArraySchema.min(1) }), + ]), + internalAnimation: z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("none") }), + z.strictObject({ kind: z.literal("runtime") }), + z.strictObject({ kind: z.literal("precomputed"), durationSeconds: positiveNumberSchema }), + ]), + rendererPreference: z.enum(["auto", "baked-web", "native-ui", "video"]), + fallbackPolicy: z.enum(["reject", "degrade"]), +}); + +const resolvedIntentSchema = z.strictObject({ + updateModel: sourceIntentSchema.shape.updateModel, + interaction: sourceIntentSchema.shape.interaction, + internalAnimation: sourceIntentSchema.shape.internalAnimation, + selectedRendererId: rendererIdSchema, + fallbackPolicy: z.enum(["reject", "degrade"]), +}); + +const contentNodeSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + id: rendererIdSchema, + kind: z.literal("frame"), + parentId: rendererIdSchema.nullable(), + order: nonNegativeIntegerSchema, + layout: z.strictObject({ kind: z.literal("absolute") }), + children: z.array(rendererIdSchema), + }), + z.strictObject({ + id: rendererIdSchema, + kind: z.literal("text"), + parentId: rendererIdSchema.nullable(), + order: nonNegativeIntegerSchema, + placement: z.strictObject({ + kind: z.literal("absolute"), + x: finiteNumberSchema, + y: finiteNumberSchema, + width: positiveNumberSchema, + height: positiveNumberSchema, + }), + text: z.string(), + }), +]); + +const surfaceSchema = z.strictObject({ + id: rendererIdSchema, + hostNodeId: rendererIdSchema, + physicalSizeMeters: z.tuple([positiveNumberSchema, positiveNumberSchema]), + logicalSize: z.tuple([positiveNumberSchema, positiveNumberSchema]), + fit: z.enum(["contain", "cover", "stretch"]), + rootFrameId: rendererIdSchema, + contentNodes: z.record(rendererIdSchema, contentNodeSchema), + baseSemanticTree: semanticTreeSchema, + interactions: z.record(rendererIdSchema, interactionSchema), + initialStateId: rendererIdSchema, + states: z.record(rendererIdSchema, stateSchema), + renderIntent: sourceIntentSchema, +}); + +const renderSurfacePlanSchema = z.strictObject({ + id: rendererIdSchema, + semanticSurfaceId: rendererIdSchema, + logicalBounds: boundsSchema, + layer: finiteNumberSchema, + contentNodeIds: z.array(z.string()), + states: z.record( + z.string(), + z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("capture") }), + z.strictObject({ kind: z.literal("empty") }), + ]), + ), +}); + +const rendererEntrySchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("structured") }), + z.strictObject({ + kind: z.literal("opaque"), + entryId: rendererIdSchema, + moduleHash: rendererIdSchema, + }), +]); + +export const rendererIdentitySchema = z.strictObject({ + id: rendererIdSchema, + version: rendererIdSchema, + contractVersion: rendererIdSchema, + implementationHash: rendererIdSchema, +}); + +export const rendererFunctionSchema = z.function(); + +export const rendererCapabilitiesSchema = z.strictObject({ + inputKinds: z.tuple([z.literal("structured")]), + updateModels: z.tuple([z.literal("static")]), + interactions: z.tuple([z.literal("none")]), + internalAnimations: z.tuple([z.literal("none")]), + rendererPreferences: z.tuple([z.literal("baked-web")]), + fallbackPolicies: z.tuple([z.literal("reject")]), + deterministic: z.literal(true), +}); + +export const rendererBuildInputSchema = z.strictObject({ + surface: surfaceSchema, + sourceIntent: sourceIntentSchema, + resolvedIntent: resolvedIntentSchema, + semanticsByState: z.record(rendererIdSchema, semanticTreeSchema), + plan: renderSurfacePlanSchema, + entry: rendererEntrySchema, + context: z.strictObject({ + locale: rendererIdSchema, + timezone: rendererIdSchema, + colorScheme: z.enum(["light", "dark"]), + themeId: rendererIdSchema, + themeHash: rendererIdSchema, + inputHash: rendererIdSchema, + buildContextHash: rendererIdSchema, + environmentHash: rendererIdSchema, + rendererConfigHash: rendererIdSchema, + rendererFingerprint: rendererIdSchema, + pixelTarget: z.tuple([finiteNumberSchema, finiteNumberSchema]), + }), +}); + +export const diagnosticSchema = z.strictObject({ + code: z.string(), + path: z.array(z.union([z.string(), finiteNumberSchema])), + message: z.string(), + relatedPath: z.array(z.union([z.string(), finiteNumberSchema])).optional(), +}); + +export const rendererSupportDecisionSchema = z.discriminatedUnion("supported", [ + z.strictObject({ supported: z.literal(true), diagnostics: z.tuple([]) }), + z.strictObject({ supported: z.literal(false), diagnostics: z.array(diagnosticSchema) }), +]); + +const captureSchema = z.strictObject({ + id: rendererIdSchema, + stateId: rendererIdSchema, + rgba: z.instanceof(Uint8Array), + pixelSize: z.tuple([finiteNumberSchema, finiteNumberSchema]), + colorSpace: z.literal("srgb"), + alphaMode: z.enum(["opaque", "straight", "premultiplied"]), +}); + +const hitRegionSchema = z.strictObject({ + interactionId: rendererIdSchema, + semanticNodeId: rendererIdSchema, + bounds: boundsSchema, + coordinateSpace: z.literal("normalized"), + event: rendererIdSchema, + priority: finiteNumberSchema, +}); + +export const rendererBuildResultSchema = z.discriminatedUnion("ok", [ + z.strictObject({ ok: z.literal(false), diagnostics: z.array(diagnosticSchema) }), + z.strictObject({ + ok: z.literal(true), + renderSurface: z.strictObject({ + id: rendererIdSchema, + semanticSurfaceId: rendererIdSchema, + logicalBounds: boundsSchema, + layer: finiteNumberSchema, + }), + captures: z.array(captureSchema), + hitRegionsByState: z.record(rendererIdSchema, z.array(hitRegionSchema)), + provenance: rendererIdentitySchema.extend({ + inputHash: rendererIdSchema, + buildContextHash: rendererIdSchema, + environmentHash: rendererIdSchema, + rendererConfigHash: rendererIdSchema, + rendererFingerprint: rendererIdSchema, + }), + diagnostics: z.array(diagnosticSchema), + }), +]); diff --git a/packages/presentation-renderer-api/test/api.test.ts b/packages/presentation-renderer-api/test/api.test.ts new file mode 100644 index 0000000..256c704 --- /dev/null +++ b/packages/presentation-renderer-api/test/api.test.ts @@ -0,0 +1,1453 @@ +import { runInNewContext } from "node:vm"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + createRendererFingerprint, + defineRendererPlugin, + executeRendererPlugin, + evaluateFirstMilestoneSupport, + prepareRendererBuildInput, + runRendererConformance, + validateRendererBuildInput, + validateRendererPlugin, + type CompilerResolvedSurfaceInput, + type RendererBuildResult, + type RendererCapabilities, + type RendererConformanceFixture, + type RendererPlugin, +} from "../src/index.js"; + +const identity = { + id: "baked-web", + version: "1.0.0", + contractVersion: "1", + implementationHash: "sha256:renderer", +} as const; + +const rendererConfigHash = "sha256:renderer-config"; + +const capabilities = { + inputKinds: ["structured"], + updateModels: ["static"], + interactions: ["none"], + internalAnimations: ["none"], + rendererPreferences: ["baked-web"], + fallbackPolicies: ["reject"], + deterministic: true, +} as const satisfies RendererCapabilities; + +const input = { + surface: { + id: "surface-title", + hostNodeId: "surface-node-title", + physicalSizeMeters: [1.6, 0.9], + logicalSize: [1920, 1080], + fit: "contain", + rootFrameId: "frame-root", + contentNodes: { + "frame-root": { + id: "frame-root", + kind: "frame", + parentId: null, + order: 0, + layout: { kind: "absolute" }, + children: ["text-title"], + }, + "text-title": { + id: "text-title", + kind: "text", + parentId: "frame-root", + order: 0, + placement: { kind: "absolute", x: 120, y: 80, width: 1680, height: 200 }, + text: "Hello", + }, + }, + baseSemanticTree: { + rootNodeIds: ["semantic-title"], + nodes: { + "semantic-title": { + id: "semantic-title", + parentId: null, + order: 0, + role: "heading", + text: "Hello", + }, + }, + }, + interactions: {}, + initialStateId: "state-default", + states: { + "state-default": { + id: "state-default", + semanticOverrides: [], + enabledInteractionIds: [], + }, + }, + renderIntent: { + updateModel: { kind: "static" }, + interaction: { kind: "none" }, + internalAnimation: { kind: "none" }, + rendererPreference: "auto", + fallbackPolicy: "reject", + }, + }, + sourceIntent: { + updateModel: { kind: "static" }, + interaction: { kind: "none" }, + internalAnimation: { kind: "none" }, + rendererPreference: "auto", + fallbackPolicy: "reject", + }, + resolvedIntent: { + updateModel: { kind: "static" }, + interaction: { kind: "none" }, + internalAnimation: { kind: "none" }, + selectedRendererId: "baked-web", + fallbackPolicy: "reject", + }, + semanticsByState: { + "state-default": { + rootNodeIds: ["semantic-title"], + nodes: { + "semantic-title": { + id: "semantic-title", + parentId: null, + order: 0, + role: "heading", + text: "Hello", + }, + }, + }, + }, + plan: { + id: "render-surface-title", + semanticSurfaceId: "surface-title", + logicalBounds: { x: 0, y: 0, width: 1920, height: 1080 }, + layer: 0, + contentNodeIds: ["frame-root", "text-title"], + states: { "state-default": { kind: "capture" } }, + }, + entry: { kind: "structured" }, + context: { + locale: "ja-JP", + timezone: "Asia/Tokyo", + colorScheme: "dark", + themeId: "theme-default", + themeHash: "sha256:theme", + inputHash: "sha256:input", + buildContextHash: "sha256:context", + environmentHash: "sha256:environment", + rendererConfigHash, + rendererFingerprint: createRendererFingerprint(identity, rendererConfigHash), + pixelTarget: [2, 1], + }, +} as const satisfies CompilerResolvedSurfaceInput; + +const fixture = (value: CompilerResolvedSurfaceInput = input): RendererConformanceFixture => ({ + name: "title-surface", + input: value, +}); + +const provenance = (value: CompilerResolvedSurfaceInput) => ({ + ...identity, + inputHash: value.context.inputHash, + buildContextHash: value.context.buildContextHash, + environmentHash: value.context.environmentHash, + rendererConfigHash: value.context.rendererConfigHash, + rendererFingerprint: value.context.rendererFingerprint, +}); + +const renderSurface = (value: CompilerResolvedSurfaceInput) => ({ + id: value.plan.id, + semanticSurfaceId: value.plan.semanticSurfaceId, + logicalBounds: value.plan.logicalBounds, + layer: value.plan.layer, +}); + +const successfulResult = (value: CompilerResolvedSurfaceInput): RendererBuildResult => ({ + ok: true, + renderSurface: renderSurface(value), + captures: Object.entries(value.plan.states) + .filter(([, statePlan]) => statePlan.kind === "capture") + .map(([stateId]) => ({ + id: `capture-${stateId}`, + stateId, + rgba: new Uint8Array(value.context.pixelTarget[0] * value.context.pixelTarget[1] * 4), + pixelSize: value.context.pixelTarget, + colorSpace: "srgb" as const, + alphaMode: "straight" as const, + })), + hitRegionsByState: Object.fromEntries( + Object.keys(value.plan.states).map((stateId) => [stateId, []]), + ), + provenance: provenance(value), + diagnostics: [], +}); + +const goodPlugin = defineRendererPlugin({ + identity, + capabilities, + support: evaluateFirstMilestoneSupport, + build: (value) => { + const support = evaluateFirstMilestoneSupport({ + entry: value.entry, + resolvedIntent: value.resolvedIntent, + }); + return support.supported + ? successfulResult(value) + : { ok: false, diagnostics: support.diagnostics }; + }, +}); + +describe("first-milestone plugin contract", () => { + it("prepared boundary は nested Proxy の get trap を実行せず入力を独立 snapshot 化する", async () => { + const source = structuredClone(input) as unknown as CompilerResolvedSurfaceInput; + let getTrapCalls = 0; + const denyGet = (target: T): T => + new Proxy(target, { + get() { + getTrapCalls++; + throw new Error("prepared boundary must not read through Proxy getters"); + }, + }); + const pixelTarget = [...source.context.pixelTarget]; + const contextTarget = { ...source.context, pixelTarget: denyGet(pixelTarget) }; + const textTarget = { ...source.surface.contentNodes["text-title"] } as { text?: unknown }; + const contentNodesTarget = { + ...source.surface.contentNodes, + "text-title": denyGet(textTarget), + }; + const surfaceTarget = { + ...source.surface, + contentNodes: denyGet(contentNodesTarget), + }; + const proxiedInput = { + ...source, + context: denyGet(contextTarget), + surface: denyGet(surfaceTarget), + } as unknown as CompilerResolvedSurfaceInput; + + const prepared = prepareRendererBuildInput(proxiedInput, goodPlugin); + expect(prepared.valid).toBe(true); + expect(getTrapCalls).toBe(0); + if (prepared.valid) { + contextTarget.locale = "en-US"; + textTarget.text = "Mutated after preparation"; + expect(prepared.value.context.locale).toBe("ja-JP"); + const preparedText = prepared.value.surface.contentNodes["text-title"]; + expect(preparedText?.kind).toBe("text"); + if (preparedText?.kind === "text") expect(preparedText.text).toBe("Hello"); + } + + const execution = await executeRendererPlugin(goodPlugin, proxiedInput); + expect(execution.valid).toBe(true); + expect(getTrapCalls).toBe(0); + }); + + it("prepared boundary は plugin Proxy の identity/capabilities/methods を descriptor から固定する", async () => { + let getTrapCalls = 0; + const proxiedPlugin = new Proxy(goodPlugin, { + get() { + getTrapCalls++; + throw new Error("prepared boundary must not read through Proxy getters"); + }, + }); + + const prepared = prepareRendererBuildInput(input, proxiedPlugin); + expect(prepared.valid).toBe(true); + expect(getTrapCalls).toBe(0); + + const execution = await executeRendererPlugin(proxiedPlugin, input); + expect(execution.valid).toBe(true); + expect(getTrapCalls).toBe(0); + }); + + it.each([ + [ + "frame children is missing", + () => ({ + ...input, + surface: { + ...input.surface, + contentNodes: { + ...input.surface.contentNodes, + "frame-root": (() => { + const { children: _children, ...frame } = input.surface.contentNodes["frame-root"]; + return frame; + })(), + }, + }, + }), + ], + [ + "frame children references an unknown node", + () => ({ + ...input, + surface: { + ...input.surface, + contentNodes: { + ...input.surface.contentNodes, + "frame-root": { ...input.surface.contentNodes["frame-root"], children: ["unknown"] }, + }, + }, + }), + ], + [ + "Text placement is missing a numeric field", + () => ({ + ...input, + surface: { + ...input.surface, + contentNodes: { + ...input.surface.contentNodes, + "text-title": { + ...input.surface.contentNodes["text-title"], + placement: { kind: "absolute", x: 120, y: 80, width: 1680 }, + }, + }, + }, + }), + ], + [ + "Text placement kind is missing", + () => ({ + ...input, + surface: { + ...input.surface, + contentNodes: { + ...input.surface.contentNodes, + "text-title": { + ...input.surface.contentNodes["text-title"], + placement: { x: 120, y: 80, width: 1680, height: 200 }, + }, + }, + }, + }), + ], + [ + "semantic node shape is malformed", + () => ({ + ...input, + semanticsByState: { + ...input.semanticsByState, + "state-default": { + ...input.semanticsByState["state-default"], + nodes: { + ...input.semanticsByState["state-default"].nodes, + "semantic-title": { + ...input.semanticsByState["state-default"].nodes["semantic-title"], + role: 42, + }, + }, + }, + }, + }), + ], + [ + "surface required field is missing", + () => { + const { hostNodeId: _hostNodeId, ...surface } = input.surface; + return { ...input, surface }; + }, + ], + [ + "state shape is malformed", + () => ({ + ...input, + surface: { + ...input.surface, + states: { + "state-default": { + ...input.surface.states["state-default"], + enabledInteractionIds: [42], + }, + }, + }, + }), + ], + [ + "interaction shape is malformed", + () => ({ + ...input, + surface: { + ...input.surface, + interactions: { tap: { id: "tap" } }, + }, + }), + ], + [ + "opaque renderer entry fields are missing", + () => ({ + ...input, + entry: { kind: "opaque" }, + }), + ], + ] as const)("prepare rejects %s", (_name, createMalformedInput) => { + const malformed = createMalformedInput() as unknown as CompilerResolvedSurfaceInput; + const result = prepareRendererBuildInput(malformed, goodPlugin); + + expect(result.valid).toBe(false); + if (!result.valid) + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ code: "invalid-renderer-input", path: [] }), + ); + }); + + it.each([ + [ + "non-finite logical size", + { ...input, surface: { ...input.surface, logicalSize: [Infinity, 1080] } }, + ], + [ + "unknown enabled interaction", + { + ...input, + surface: { + ...input.surface, + states: { + "state-default": { + ...input.surface.states["state-default"], + enabledInteractionIds: ["missing"], + }, + }, + }, + }, + ], + [ + "unknown semantic override node", + { + ...input, + surface: { + ...input.surface, + states: { + "state-default": { + ...input.surface.states["state-default"], + semanticOverrides: [{ nodes: { missing: { text: "x" } } }], + }, + }, + }, + }, + ], + [ + "unsupported optional value", + { + ...input, + semanticsByState: { + ...input.semanticsByState, + "state-default": { + ...input.semanticsByState["state-default"], + nodes: { + ...input.semanticsByState["state-default"].nodes, + "semantic-title": { + ...input.semanticsByState["state-default"].nodes["semantic-title"], + text: () => "not plain data", + }, + }, + }, + }, + }, + ], + ] as const)("prepare rejects %s", (_name, malformed) => { + expect( + prepareRendererBuildInput(malformed as unknown as CompilerResolvedSurfaceInput, goodPlugin), + ).toMatchObject({ + valid: false, + diagnostics: [expect.objectContaining({ code: "invalid-renderer-input" })], + }); + }); + + it.each([ + ["invalid color scheme", { ...input, context: { ...input.context, colorScheme: "invalid" } }], + ["empty surface host", { ...input, surface: { ...input.surface, hostNodeId: "" } }], + [ + "duplicate finite states", + { + ...input, + surface: { + ...input.surface, + renderIntent: { + ...input.surface.renderIntent, + updateModel: { kind: "finite-state", stateIds: ["state-default", "state-default"] }, + }, + }, + sourceIntent: { + ...input.sourceIntent, + updateModel: { kind: "finite-state", stateIds: ["state-default", "state-default"] }, + }, + resolvedIntent: { + ...input.resolvedIntent, + updateModel: { kind: "finite-state", stateIds: ["state-default", "state-default"] }, + }, + }, + ], + [ + "semantic cycle", + { + ...input, + semanticsByState: { + "state-default": { + rootNodeIds: [], + nodes: { + a: { id: "a", parentId: "b", order: 0, role: "paragraph" }, + b: { id: "b", parentId: "a", order: 0, role: "paragraph" }, + }, + }, + }, + }, + ], + [ + "empty semantic language", + { + ...input, + semanticsByState: { + "state-default": { + ...input.semanticsByState["state-default"], + nodes: { + "semantic-title": { + ...input.semanticsByState["state-default"].nodes["semantic-title"], + language: "", + }, + }, + }, + }, + }, + ], + [ + "duplicate enabled interactions", + { + ...input, + surface: { + ...input.surface, + interactions: { tap: { id: "tap", kind: "click", event: "advance" } }, + states: { + "state-default": { + ...input.surface.states["state-default"], + enabledInteractionIds: ["tap", "tap"], + }, + }, + }, + }, + ], + [ + "disconnected root frame", + { + ...input, + surface: { + ...input.surface, + contentNodes: { + ...input.surface.contentNodes, + detached: { + id: "detached", + kind: "frame", + parentId: null, + order: 1, + layout: { kind: "absolute" }, + children: [], + }, + }, + }, + }, + ], + [ + "parentless text", + { + ...input, + surface: { + ...input.surface, + contentNodes: { + ...input.surface.contentNodes, + "text-title": { + ...input.surface.contentNodes["text-title"], + parentId: null, + order: 2, + }, + }, + }, + }, + ], + ] as const)("prepare rejects strict contract violation: %s", (_name, malformed) => { + expect( + prepareRendererBuildInput(malformed as unknown as CompilerResolvedSurfaceInput, goodPlugin), + ).toMatchObject({ valid: false }); + }); + + it("prepare rejects interactions whose record key differs from interaction.id", () => { + const malformed = { + ...input, + surface: { + ...input.surface, + interactions: { tap: { id: "different-id", kind: "click", event: "advance" } }, + states: { + "state-default": { + ...input.surface.states["state-default"], + enabledInteractionIds: ["tap"], + }, + }, + }, + } as unknown as CompilerResolvedSurfaceInput; + + expect(prepareRendererBuildInput(malformed, goodPlugin)).toMatchObject({ valid: false }); + }); + + it("prepare rejects content-node cycles detached from the root frame", () => { + const malformed = { + ...input, + surface: { + ...input.surface, + contentNodes: { + ...input.surface.contentNodes, + "detached-a": { + id: "detached-a", + kind: "frame", + parentId: "detached-b", + order: 0, + layout: { kind: "absolute" }, + children: ["detached-b"], + }, + "detached-b": { + id: "detached-b", + kind: "frame", + parentId: "detached-a", + order: 0, + layout: { kind: "absolute" }, + children: ["detached-a"], + }, + }, + }, + } as unknown as CompilerResolvedSurfaceInput; + + expect(prepareRendererBuildInput(malformed, goodPlugin)).toMatchObject({ valid: false }); + }); + + it("RGBA output の偽装 brand と iterator を実行せず拒否する", async () => { + let iteratorCalls = 0; + const bytes = new Uint8ClampedArray(8); + Object.defineProperties(bytes, { + [Symbol.toStringTag]: { value: "Uint8Array" }, + [Symbol.iterator]: { + value() { + iteratorCalls++; + throw new Error("must not iterate hostile bytes"); + }, + }, + }); + const hostile = defineRendererPlugin({ + ...goodPlugin, + build(value: CompilerResolvedSurfaceInput): RendererBuildResult { + const result = successfulResult(value); + if (!result.ok) return result; + return { + ...result, + captures: [{ ...result.captures[0]!, rgba: bytes as unknown as Uint8Array }], + }; + }, + }); + const result = await runRendererConformance(hostile, [fixture()]); + expect(result.valid).toBe(false); + expect(iteratorCalls).toBe(0); + if (!result.valid) + expect(result.diagnostics.map(({ code }) => code)).toContain("malformed-renderer-output"); + }); + + it("sparse failure diagnostics と不透明でない opaque capture を拒否する", async () => { + const sparseFailure = defineRendererPlugin({ + ...goodPlugin, + build: () => + ({ + ok: false, + diagnostics: Object.assign([], { length: 1 }), + }) as RendererBuildResult, + }); + const sparseResult = await runRendererConformance(sparseFailure, [fixture()]); + expect(sparseResult.valid).toBe(false); + if (!sparseResult.valid) + expect(sparseResult.diagnostics.map(({ code }) => code)).toContain( + "malformed-renderer-output", + ); + + const invalidOpaque = defineRendererPlugin({ + ...goodPlugin, + build(value: CompilerResolvedSurfaceInput): RendererBuildResult { + const result = successfulResult(value); + if (!result.ok) return result; + const rgba = new Uint8Array(result.captures[0]!.rgba); + rgba[3] = 1; + return { + ...result, + captures: [{ ...result.captures[0]!, rgba, alphaMode: "opaque" as const }], + }; + }, + }); + const opaqueResult = await runRendererConformance(invalidOpaque, [fixture()]); + expect(opaqueResult.valid).toBe(false); + if (!opaqueResult.valid) + expect(opaqueResult.diagnostics.map(({ code }) => code)).toContain("invalid-opaque-alpha"); + }); + + it("sparse または非有限な diagnostic path を拒否する", async () => { + const sparsePath = Object.assign([], { length: 1 }) as unknown as (string | number)[]; + for (const path of [sparsePath, [Number.NaN], [Number.POSITIVE_INFINITY]]) { + const plugin = defineRendererPlugin({ + ...goodPlugin, + build: () => ({ + ok: false as const, + diagnostics: [{ code: "failure", message: "failure", path }], + }), + }); + const result = await runRendererConformance(plugin, [fixture()]); + expect(result.valid).toBe(false); + if (!result.valid) + expect(result.diagnostics.map(({ code }) => code)).toContain("malformed-renderer-output"); + } + }); + + it("Zod boundary は build output の accessor と未知 field を実行せず拒否する", async () => { + let accessorReads = 0; + const hostileOutput = defineRendererPlugin({ + ...goodPlugin, + build(value: CompilerResolvedSurfaceInput): RendererBuildResult { + const result = successfulResult(value); + if (!result.ok) return result; + return Object.defineProperties( + { ...result, unexpected: true }, + { + captures: { + get() { + accessorReads++; + throw new Error("renderer boundary must not execute accessors"); + }, + }, + }, + ) as RendererBuildResult; + }, + }); + + const result = await runRendererConformance(hostileOutput, [fixture()]); + expect(accessorReads).toBe(0); + expect(result.valid).toBe(false); + if (!result.valid) + expect(result.diagnostics.map(({ code }) => code)).toContain("malformed-renderer-output"); + }); + + it("固定した method の mutable call property を参照しない", async () => { + const support = (request: Parameters[0]) => + evaluateFirstMilestoneSupport(request); + const build = (value: CompilerResolvedSurfaceInput) => successfulResult(value); + Object.defineProperty(support, "call", { value: () => Promise.reject(new Error("unused")) }); + Object.defineProperty(build, "call", { value: () => Promise.reject(new Error("unused")) }); + const plugin = defineRendererPlugin({ ...goodPlugin, support, build }); + await expect(executeRendererPlugin(plugin, input)).resolves.toMatchObject({ valid: true }); + }); + + it("公開入力validatorは hostile input を実行せず診断化する", () => { + expect(validateRendererBuildInput(null, {})).toMatchObject([ + { code: "invalid-renderer-plugin" }, + ]); + expect(validateRendererBuildInput({ plan: {} }, { identity, capabilities })).toMatchObject([ + { code: "invalid-renderer-plugin" }, + ]); + let contextReads = 0; + const getterInput = Object.defineProperty({ ...input }, "context", { + get() { + contextReads++; + return input.context; + }, + }); + expect(validateRendererBuildInput(getterInput, goodPlugin)).toContainEqual( + expect.objectContaining({ code: "invalid-renderer-input", path: [] }), + ); + expect(contextReads).toBe(0); + }); + it("sparse boundary values と malformed Text node を prefix 付きで拒否する", async () => { + const sparsePixelTarget = [2, undefined] as unknown as number[]; + delete sparsePixelTarget[1]; + expect( + validateRendererBuildInput( + { ...input, context: { ...input.context, pixelTarget: sparsePixelTarget } }, + goodPlugin, + ), + ).toContainEqual(expect.objectContaining({ code: "invalid-renderer-input", path: [] })); + const malformedText = { + ...input, + surface: { + ...input.surface, + contentNodes: { + ...input.surface.contentNodes, + "text-title": { ...input.surface.contentNodes["text-title"], text: 1 }, + }, + }, + } as unknown as CompilerResolvedSurfaceInput; + expect(validateRendererBuildInput(malformedText, goodPlugin)).toContainEqual( + expect.objectContaining({ code: "invalid-renderer-input", path: [] }), + ); + const malformedFixture = await runRendererConformance(goodPlugin, [ + { name: "malformed-fixture", input: malformedText }, + ]); + expect(malformedFixture.valid).toBe(false); + if (!malformedFixture.valid) + expect(malformedFixture.diagnostics).toContainEqual( + expect.objectContaining({ + code: "invalid-renderer-input", + path: ["malformed-fixture", "input"], + }), + ); + const sparseCapabilities = [undefined] as unknown as string[]; + delete sparseCapabilities[0]; + expect( + validateRendererPlugin({ + ...goodPlugin, + capabilities: { ...capabilities, inputKinds: sparseCapabilities }, + }), + ).toEqual( + expect.arrayContaining([expect.objectContaining({ code: "invalid-renderer-capabilities" })]), + ); + }); + it("公開validatorは root path を二重にせず、conformance fixture 名は保持する", async () => { + const invalid = { + ...input, + sourceIntent: { ...input.sourceIntent, rendererPreference: "native-ui" }, + } as const satisfies CompilerResolvedSurfaceInput; + expect(validateRendererBuildInput(invalid, goodPlugin)).toContainEqual( + expect.objectContaining({ code: "source-render-intent-mismatch", path: ["sourceIntent"] }), + ); + const execution = await executeRendererPlugin(goodPlugin, invalid); + expect(execution.valid).toBe(false); + if (!execution.valid) + expect(execution.diagnostics).toContainEqual( + expect.objectContaining({ + code: "source-render-intent-mismatch", + path: ["single", "input", "sourceIntent"], + }), + ); + const result = await runRendererConformance(goodPlugin, [ + { name: "fixture-a", input: invalid }, + ]); + expect(result.valid).toBe(false); + if (!result.valid) + expect(result.diagnostics).toContainEqual( + expect.objectContaining({ + code: "source-render-intent-mismatch", + path: ["fixture-a", "input", "sourceIntent"], + }), + ); + }); + it("rejects malformed plugins before invoking them", async () => { + const invalid = { ...goodPlugin, identity: { ...identity, id: "" } }; + expect(validateRendererPlugin(invalid).map(({ code }) => code)).toContain( + "invalid-renderer-identity", + ); + expect((await executeRendererPlugin(invalid as never, input)).valid).toBe(false); + }); + it("rejects plugins without callable support and build methods", async () => { + const withoutSupport = { ...goodPlugin, support: undefined }; + const withoutBuild = { ...goodPlugin, build: null }; + + expect(validateRendererPlugin(withoutSupport).map(({ code }) => code)).toContain( + "invalid-renderer-plugin", + ); + expect(validateRendererPlugin(withoutBuild).map(({ code }) => code)).toContain( + "invalid-renderer-plugin", + ); + expect((await executeRendererPlugin(withoutSupport as never, input)).valid).toBe(false); + expect((await executeRendererPlugin(withoutBuild as never, input)).valid).toBe(false); + }); + it("executes build exactly once", async () => { + let calls = 0; + const plugin = { + ...goodPlugin, + build: (value: CompilerResolvedSurfaceInput) => { + calls++; + return successfulResult(value); + }, + }; + expect((await executeRendererPlugin(plugin, input)).valid).toBe(true); + expect(calls).toBe(1); + }); + it("does not build after support failure", async () => { + let calls = 0; + const plugin = { + ...goodPlugin, + support: () => { + throw new Error("no"); + }, + build: () => { + calls++; + return successfulResult(input); + }, + }; + expect((await executeRendererPlugin(plugin, input)).valid).toBe(false); + expect(calls).toBe(0); + }); + it("stops before build when support mutates input", async () => { + let calls = 0; + const plugin = { + ...goodPlugin, + support: () => { + (input.plan.states as Record).changed = {}; + return evaluateFirstMilestoneSupport({ + entry: input.entry, + resolvedIntent: input.resolvedIntent, + }); + }, + build: () => { + calls++; + return successfulResult(input); + }, + }; + const result = await executeRendererPlugin(plugin, input); + delete (input.plan.states as Record).changed; + expect(result.valid).toBe(false); + if (!result.valid) + expect(result.diagnostics.map(({ code }) => code)).not.toContain("renderer-mutated-input"); + expect(calls).toBe(1); + }); + it("does not build unsupported input", async () => { + let calls = 0; + const unsupported = { + ...input, + entry: { kind: "opaque" as const, entryId: "x", moduleHash: "h" }, + }; + const plugin = { + ...goodPlugin, + build: () => { + calls++; + return successfulResult(input); + }, + }; + expect((await executeRendererPlugin(plugin, unsupported)).valid).toBe(false); + expect(calls).toBe(0); + }); + it("does not invoke plugins for invalid plans", async () => { + let calls = 0; + const invalid = { + ...input, + plan: { ...input.plan, states: { "state-default": { kind: "bad" } } }, + } as never as CompilerResolvedSurfaceInput; + const plugin = { + ...goodPlugin, + support: () => { + calls++; + return { supported: true, diagnostics: [] } as const; + }, + build: () => successfulResult(input), + }; + expect((await executeRendererPlugin(plugin, invalid)).valid).toBe(false); + expect(calls).toBe(0); + }); + it("does not invoke conformance plugins for invalid inputs", async () => { + let supportCalls = 0; + let buildCalls = 0; + const invalid = { + ...input, + plan: { ...input.plan, contentNodeIds: ["missing-node"] }, + } as const satisfies CompilerResolvedSurfaceInput; + const plugin = { + ...goodPlugin, + support: () => { + supportCalls++; + return { supported: true, diagnostics: [] } as const; + }, + build: () => { + buildCalls++; + return successfulResult(input); + }, + }; + + expect((await runRendererConformance(plugin, [fixture(invalid)])).valid).toBe(false); + expect(supportCalls).toBe(0); + expect(buildCalls).toBe(0); + }); + it("accepts a deterministic renderer after resolving an auto source preference", async () => { + const result = await runRendererConformance(goodPlugin, [fixture()]); + + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.value[0]).toMatchObject({ + ok: true, + renderSurface: { id: "render-surface-title", semanticSurfaceId: "surface-title" }, + provenance: identity, + }); + } + }); + + it("rejects unsupported input and intent variants with stable decisions", () => { + expect( + evaluateFirstMilestoneSupport({ + entry: { kind: "opaque", entryId: "chart", moduleHash: "h" }, + resolvedIntent: input.resolvedIntent, + }), + ).toMatchObject({ supported: false, diagnostics: [{ code: "unsupported-input-kind" }] }); + expect( + evaluateFirstMilestoneSupport({ + entry: input.entry, + resolvedIntent: { + ...input.resolvedIntent, + interaction: { kind: "regions", events: ["click"] }, + }, + }), + ).toMatchObject({ supported: false, diagnostics: [{ code: "unsupported-interaction" }] }); + expect( + evaluateFirstMilestoneSupport({ + entry: input.entry, + resolvedIntent: { + ...input.resolvedIntent, + updateModel: { kind: "continuous", source: "timeline" }, + }, + }), + ).toMatchObject({ supported: false, diagnostics: [{ code: "unsupported-update-model" }] }); + expect( + evaluateFirstMilestoneSupport({ + entry: input.entry, + resolvedIntent: { ...input.resolvedIntent, selectedRendererId: "video" }, + }), + ).toMatchObject({ + supported: false, + diagnostics: [{ code: "unsupported-renderer" }], + }); + }); + + it("conforms when unsupported inputs return diagnostic failures", async () => { + const opaque = { + ...input, + entry: { kind: "opaque", entryId: "chart", moduleHash: "sha256:module" }, + } as const satisfies CompilerResolvedSurfaceInput; + + const result = await runRendererConformance(goodPlugin, [fixture(opaque)]); + expect(result.valid).toBe(true); + if (result.valid) expect(result.value[0]).toMatchObject({ ok: false }); + }); + + it("rejects implicit fallback from an explicit renderer preference", async () => { + const explicitNative = { + ...input, + surface: { + ...input.surface, + renderIntent: { ...input.surface.renderIntent, rendererPreference: "native-ui" }, + }, + sourceIntent: { ...input.sourceIntent, rendererPreference: "native-ui" }, + } as const satisfies CompilerResolvedSurfaceInput; + + const result = await runRendererConformance(goodPlugin, [fixture(explicitNative)]); + expect(result.valid).toBe(false); + if (!result.valid) + expect(result.diagnostics.map(({ code }) => code)).toContain("renderer-preference-mismatch"); + }); + + it("rejects incomplete identity and capability declarations", () => { + expect(() => + defineRendererPlugin({ ...goodPlugin, identity: { ...identity, id: "" } }), + ).toThrow(/identity/); + expect(() => + defineRendererPlugin({ + ...goodPlugin, + capabilities: { ...capabilities, deterministic: false }, + } as never), + ).toThrow(/capabilities/); + }); +}); + +describe("conformance diagnostics", () => { + const withBuild = (build: RendererPlugin["build"]): RendererPlugin => ({ ...goodPlugin, build }); + + it("rejects inherited state, content, and Hit Region entries", async () => { + const inheritedSemantics = Object.create({ + "state-default": input.semanticsByState["state-default"], + }) as CompilerResolvedSurfaceInput["semanticsByState"]; + const inheritedContentNodes = Object.create({ + "text-title": input.surface.contentNodes["text-title"], + }) as CompilerResolvedSurfaceInput["surface"]["contentNodes"]; + const inheritedInput = { + ...input, + surface: { ...input.surface, contentNodes: inheritedContentNodes }, + semanticsByState: inheritedSemantics, + } as const satisfies CompilerResolvedSurfaceInput; + const inputResult = await executeRendererPlugin(goodPlugin, inheritedInput); + expect(inputResult.valid).toBe(false); + if (!inputResult.valid) + expect(inputResult.diagnostics.map(({ code }) => code)).toContain("invalid-renderer-input"); + + const inheritedHitRegions = withBuild((value) => ({ + ...successfulResult(value), + hitRegionsByState: Object.create({ "state-default": [] }), + })); + const outputResult = await runRendererConformance(inheritedHitRegions, [fixture()]); + expect(outputResult.valid).toBe(false); + if (!outputResult.valid) + expect(outputResult.diagnostics.map(({ code }) => code)).toContain( + "missing-state-hit-regions", + ); + }); + + it("detects RGBA length, state completeness, duplicate IDs, and provenance drift", async () => { + const invalid = withBuild((value) => ({ + ok: true, + renderSurface: renderSurface(value), + captures: [ + { + id: "duplicate", + stateId: "state-default", + rgba: new Uint8Array(1), + pixelSize: [2, 1], + colorSpace: "srgb", + alphaMode: "straight", + }, + { + id: "duplicate", + stateId: "state-default", + rgba: new Uint8Array(8), + pixelSize: [2, 1], + colorSpace: "srgb", + alphaMode: "straight", + }, + ], + hitRegionsByState: {}, + provenance: { ...provenance(value), environmentHash: "wrong" }, + diagnostics: [], + })); + + const result = await runRendererConformance(invalid, [fixture()]); + expect(result.valid).toBe(false); + if (!result.valid) + expect(result.diagnostics.map(({ code }) => code)).toEqual( + expect.arrayContaining([ + "duplicate-capture-id", + "invalid-rgba-length", + "invalid-renderer-provenance", + "missing-state-hit-regions", + "state-capture-mismatch", + ]), + ); + }); + + it("detects input mutation, thrown errors, and support/build disagreement", async () => { + const mutable = structuredClone(input) as unknown as CompilerResolvedSurfaceInput; + const mutating = withBuild((value) => { + (value.plan as { layer: number }).layer = 2; + return successfulResult(value); + }); + const mutation = await runRendererConformance(mutating, [fixture(mutable)]); + expect(mutation.valid).toBe(false); + if (!mutation.valid) + expect(mutation.diagnostics.map(({ code }) => code)).toContain("renderer-mutated-input"); + + const throwing = withBuild(() => { + throw new Error("browser crashed"); + }); + const thrown = await runRendererConformance(throwing, [fixture()]); + expect(thrown.valid).toBe(false); + if (!thrown.valid) + expect(thrown.diagnostics.map(({ code }) => code)).toContain("renderer-threw"); + + const supportThrowing: RendererPlugin = { + ...goodPlugin, + support: () => { + throw new Error("support crashed"); + }, + }; + const supportThrown = await runRendererConformance(supportThrowing, [fixture()]); + expect(supportThrown.valid).toBe(false); + if (!supportThrown.valid) + expect(supportThrown.diagnostics.map(({ code }) => code)).toContain("renderer-support-threw"); + + const disagreeing = withBuild(() => ({ ok: false, diagnostics: [] })); + const disagreement = await runRendererConformance(disagreeing, [fixture()]); + expect(disagreement.valid).toBe(false); + if (!disagreement.valid) + expect(disagreement.diagnostics.map(({ code }) => code)).toEqual( + expect.arrayContaining(["missing-failure-diagnostic", "support-build-mismatch"]), + ); + }); + + it("reports malformed support and build values without throwing", async () => { + const malformedSupport: RendererPlugin = { + ...goodPlugin, + support: () => null as never, + }; + const supportResult = await runRendererConformance(malformedSupport, [fixture()]); + expect(supportResult.valid).toBe(false); + if (!supportResult.valid) + expect(supportResult.diagnostics.map(({ code }) => code)).toContain( + "malformed-support-decision", + ); + + const malformedBuild = withBuild(() => null as never); + const buildResult = await runRendererConformance(malformedBuild, [fixture()]); + expect(buildResult.valid).toBe(false); + if (!buildResult.valid) + expect(buildResult.diagnostics.map(({ code }) => code)).toContain( + "malformed-renderer-output", + ); + + const nonByteCapture = withBuild((value) => { + const result = successfulResult(value); + if (!result.ok) return result; + return { + ...result, + captures: [{ ...result.captures[0], rgba: Array(8).fill(0) }], + } as never; + }); + const byteResult = await runRendererConformance(nonByteCapture, [fixture()]); + expect(byteResult.valid).toBe(false); + if (!byteResult.valid) + expect(byteResult.diagnostics.map(({ code }) => code)).toContain("malformed-renderer-output"); + }); + + it("accepts Uint8Array bytes restored from another JavaScript realm", async () => { + const crossRealmBytes = runInNewContext("new Uint8Array(8)") as Uint8Array; + expect(crossRealmBytes).not.toBeInstanceOf(Uint8Array); + const crossRealmRenderer = withBuild((value) => { + const result = successfulResult(value); + if (!result.ok) return result; + return { + ...result, + captures: [{ ...result.captures[0], rgba: crossRealmBytes }], + } as RendererBuildResult; + }); + + const result = await runRendererConformance(crossRealmRenderer, [fixture()]); + expect(result.valid).toBe(true); + }); + + it("binds renderer identity and configuration to the input fingerprint", async () => { + const staleContext = { + ...input, + context: { ...input.context, rendererFingerprint: "stale" }, + } as const satisfies CompilerResolvedSurfaceInput; + const staleResult = await runRendererConformance(goodPlugin, [fixture(staleContext)]); + expect(staleResult.valid).toBe(false); + if (!staleResult.valid) + expect(staleResult.diagnostics.map(({ code }) => code)).toContain( + "renderer-fingerprint-mismatch", + ); + + const upgradedPlugin = defineRendererPlugin({ + ...goodPlugin, + identity: { ...identity, version: "2.0.0" }, + }); + const upgradedResult = await runRendererConformance(upgradedPlugin, [fixture()]); + expect(upgradedResult.valid).toBe(false); + if (!upgradedResult.valid) + expect(upgradedResult.diagnostics.map(({ code }) => code)).toContain( + "renderer-fingerprint-mismatch", + ); + }); + + it("detects non-deterministic bytes and produces stable diagnostic ordering", async () => { + let byte = 0; + const nondeterministic = withBuild((value) => { + const result = successfulResult(value); + if (result.ok) result.captures[0]?.rgba.fill(byte++); + return result; + }); + + const first = await runRendererConformance(nondeterministic, [fixture()]); + byte = 0; + const second = await runRendererConformance(nondeterministic, [fixture()]); + expect(first.valid).toBe(false); + expect(second).toEqual(first); + if (!first.valid) + expect(first.diagnostics.map(({ code }) => code)).toContain( + "non-deterministic-renderer-output", + ); + }); + + it("detects non-determinism when builds reuse and mutate the same byte buffer", async () => { + const sharedBytes = new Uint8Array(8); + let byte = 0; + const sharedBufferRenderer = withBuild((value) => { + sharedBytes.fill(byte++); + const result = successfulResult(value); + if (!result.ok) return result; + return { + ...result, + captures: [{ ...result.captures[0], rgba: sharedBytes }], + } as RendererBuildResult; + }); + + const result = await runRendererConformance(sharedBufferRenderer, [fixture()]); + expect(result.valid).toBe(false); + if (!result.valid) + expect(result.diagnostics.map(({ code }) => code)).toContain( + "non-deterministic-renderer-output", + ); + }); + + it("validates normalized Hit Region geometry and semantic references", async () => { + const invalidHitRegion = withBuild((value) => { + const result = successfulResult(value); + if (!result.ok) return result; + return { + ...result, + hitRegionsByState: { + "state-default": [ + { + interactionId: "missing-interaction", + semanticNodeId: "missing-node", + bounds: { x: 0.9, y: 0, width: 0.2, height: 1 }, + coordinateSpace: "normalized", + event: "missing-event", + priority: -1, + }, + ], + }, + }; + }); + + const result = await runRendererConformance(invalidHitRegion, [fixture()]); + expect(result.valid).toBe(false); + if (!result.valid) + expect(result.diagnostics.map(({ code }) => code)).toEqual( + expect.arrayContaining([ + "invalid-hit-region-bounds", + "invalid-hit-region-interaction", + "invalid-hit-region-priority", + "invalid-hit-region-semantic-node", + ]), + ); + }); + + it("requires exact enabled-interaction coverage and semantic binding", async () => { + const interactiveSurface = { + ...input, + surface: { + ...input.surface, + interactions: { + tap: { id: "tap", kind: "click", event: "advance" }, + other: { id: "other", kind: "click", event: "other" }, + }, + baseSemanticTree: { + rootNodeIds: ["semantic-title"], + nodes: { + "semantic-title": { + ...input.surface.baseSemanticTree.nodes["semantic-title"], + interactionId: "other", + }, + }, + }, + states: { + "state-default": { + ...input.surface.states["state-default"], + enabledInteractionIds: [], + }, + }, + }, + semanticsByState: { + "state-default": { + rootNodeIds: ["semantic-title"], + nodes: { + "semantic-title": { + ...input.semanticsByState["state-default"].nodes["semantic-title"], + interactionId: "other", + }, + }, + }, + }, + } as const satisfies CompilerResolvedSurfaceInput; + const invalidBinding = withBuild((value) => { + const result = successfulResult(value); + if (!result.ok) return result; + return { + ...result, + hitRegionsByState: { + "state-default": [ + { + interactionId: "tap", + semanticNodeId: "semantic-title", + bounds: { x: 0, y: 0, width: 1, height: 1 }, + coordinateSpace: "normalized", + event: "advance", + priority: 0, + }, + ], + }, + }; + }); + + const bindingResult = await runRendererConformance(invalidBinding, [ + fixture(interactiveSurface), + ]); + expect(bindingResult.valid).toBe(false); + if (!bindingResult.valid) + expect(bindingResult.diagnostics.map(({ code }) => code)).toEqual( + expect.arrayContaining([ + "invalid-hit-region-interaction", + "invalid-hit-region-semantic-node", + "unexpected-hit-region", + ]), + ); + + const enabledWithoutRegion = { + ...interactiveSurface, + surface: { + ...interactiveSurface.surface, + states: { + "state-default": { + ...interactiveSurface.surface.states["state-default"], + enabledInteractionIds: ["tap"], + }, + }, + }, + } as const satisfies CompilerResolvedSurfaceInput; + const coverageResult = await runRendererConformance(goodPlugin, [ + fixture(enabledWithoutRegion), + ]); + expect(coverageResult.valid).toBe(false); + if (!coverageResult.valid) + expect(coverageResult.diagnostics.map(({ code }) => code)).toContain( + "missing-enabled-interaction-region", + ); + }); + + it("validates Compiler plans before accepting renderer output", async () => { + const invalidInput = { + ...input, + plan: { + ...input.plan, + semanticSurfaceId: "other-surface", + contentNodeIds: ["missing-node"], + states: { "missing-state": { kind: "capture" } }, + }, + context: { ...input.context, pixelTarget: [0, 1] }, + } as const satisfies CompilerResolvedSurfaceInput; + + const result = await runRendererConformance(goodPlugin, [fixture(invalidInput)]); + expect(result.valid).toBe(false); + if (!result.valid) + expect(result.diagnostics.map(({ code }) => code)).toEqual( + expect.arrayContaining([ + "invalid-pixel-target", + "missing-content-node", + "missing-state-semantics", + "surface-state-set-mismatch", + "surface-plan-mismatch", + ]), + ); + }); + + it("rejects duplicate content nodes in Compiler plans", async () => { + const duplicateContent = { + ...input, + plan: { + ...input.plan, + contentNodeIds: ["frame-root", "frame-root"], + }, + } as const satisfies CompilerResolvedSurfaceInput; + + const result = await executeRendererPlugin(goodPlugin, duplicateContent); + + expect(result.valid).toBe(false); + if (!result.valid) + expect(result.diagnostics.map(({ code }) => code)).toContain("duplicate-content-node"); + }); +}); + +const typeContractChecks = (value: CompilerResolvedSurfaceInput) => { + // @ts-expect-error Compiler-owned semantic input is read-only + value.surface.id = "changed"; + const result = successfulResult(value); + if (result.ok) { + // @ts-expect-error raw captures are not encoded Texture/Asset descriptors + result.captures[0].checksum = "sha256:encoded"; + } +}; + +expectTypeOf(typeContractChecks).toBeFunction(); diff --git a/packages/presentation-renderer-api/tsconfig.json b/packages/presentation-renderer-api/tsconfig.json new file mode 100644 index 0000000..8b84660 --- /dev/null +++ b/packages/presentation-renderer-api/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../config/tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "noPropertyAccessFromIndexSignature": false, + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/presentation-renderer-web/ARCHITECTURE.md b/packages/presentation-renderer-web/ARCHITECTURE.md new file mode 100644 index 0000000..0c96500 --- /dev/null +++ b/packages/presentation-renderer-web/ARCHITECTURE.md @@ -0,0 +1,156 @@ +# Presentation Web Renderer Architecture + +- **Status**: Initial implementation +- **Renderer ID**: `baked-web` +- **Scope**: Fixed Browser 環境での Web rendering、layout、capture +- **Related**: + - [Presentation Architecture](../../docs/presentation/ARCHITECTURE.md) + - [Presentation Implementation Design](../../docs/presentation/DESIGN.md) + - [Renderer API Architecture](../presentation-renderer-api/ARCHITECTURE.md) + - [Assets Architecture](../presentation-assets/ARCHITECTURE.md) + +## 1. Role + +### Current + +`presentation-renderer-web` は `baked-web` concrete renderer を実装する。Structured Component から lower された Primitive graph を、固定された Browser 環境で layout / capture し、RenderBundle 候補を生成する。 + +現在は `FixedBrowserAdapter` を注入する Structured 初期実装と、Opaque renderer sourceを実行せずにbundleする境界を持つ。固定 environment(Browser / font / locale / timezone / sRGB / DSF 1 / network・filesystem deny / fixed clock・random)と adapter identity を plugin 作成時に snapshot し、後続の adapter mutation を build に反映しない。 + +`openPlaywrightFixedBrowser` は `playwright-core@1.62.1` が managed Chromium headless shell を選択して起動し、外部 binary path / fallback を持たない。`install chromium --only-shell` と headless 起動を対応させるため、adapterはPlaywrightのmanaged browser registryに起動先の選択を委譲する。Chromium sandbox 有効、Node global signal handler 無効で起動し、Noto Sans CJK JP、`ja-JP`、`Asia/Tokyo`、DSF 1、sRGB、2000-01-01T00:00:00Z、seed `0x5eed` を固定する。font fingerprint は固定glyph baselineの実capture RGBAから内部導出し、adapter identity hash に font profile とともに結合する。capture ごとに隔離 context を作成し、offline、service worker block、download deny、全 route abort、clock / random init、font ready の後に CSS scale、背景保持、animation無効、caret非表示の PNG screenshot を decode して所有済み RGBA として返す。capture trust boundary は各辺4096px・総16,777,216pxまでで fail closed とし、これは後続のM2 2K artifact policyとは別の入力上限である。abort または session close は進行中 context を閉じ、context cleanup 後に browser を閉じる。通常 check は `*.integration.test.ts` を除外してこの adapter を fake で検証する unit test だけを実行し、browser binary の provision / 実機 integration は明示 script の後にのみ実行する。 + +M1のfixed script environmentはcall / construct両方の`Date`、`performance.now` / `timeOrigin`、`Math.random`、`crypto.getRandomValues` / `randomUUID`を固定する。deterministicな鍵生成や暗号乱数の意味を仮実装しないため`crypto.subtle`は拒否する。Opaque renderer execution自体は引き続きDeferredである。 + +初期 Structured path は absolute root `Frame` と、その直接の absolute `Text` 子だけを deterministic な HTML/CSS に lower する。logical bounds は requested pixel target へ明示的に scale し、color scheme も Browser media emulation input として渡す。DOM から semantic を推測しない。State は UTF-16 lexical 順に一回ずつ raw RGBA capture する。ただしこの段階では visual state 差分を lower できないため、capture 対象の completed semantics が異なれば fail closed にする。interaction は `none` のため全 State の Hit Region は空である。renderer config hash が Compiler context と一致しない build は拒否する。 + +初期 renderer config は CSS を受け取らない。background は `[r, g, b, a]` の 0–255 byte、font family は ASCII 英数字・space・`_`・`-` の allowlist とし、factory は config を clone/freeze して作成後の外部 mutation を遮断する。 + +build は Renderer API が prepared snapshot として返した input だけを以後の処理に使用し、呼出元の input object を再読しない。 + +Adapter から返る raw capture は requested pixel size の sRGB `opaque` または `straight` RGBA に限定する。renderer は RGBA bytes と pixel size をコピーして ownership を引き受け、`premultiplied` は Assets encoder と互換しないため拒否する。 + +Web technology は build-time renderer implementation であり、Control Plane や Unity Runtime に HTML、CSS、React、JavaScript を配信して実行させるものではない。 + +Compiler が決定した Render Surface partition を build input として受け取り、その geometry と artifact を解決する。renderer が Component graph を再分割したり partition policy を選択したりしない。 + +### Target / Deferred + +設計上は renderer が `presentation-core` と `presentation-assets` を直接利用する target である。一方、現在は Renderer API の型境界だけに直接依存し、Core validation・Assets encode は Compiler 経由で行う。 + +## 2. Owned pipeline + +### Current + +- injected `FixedBrowserAdapter` の identity / fixed environment を snapshot した Structured build +- absolute root `Frame` と direct `Text` の HTML/CSS lower、state capture、raw RGBA ownership transfer +- locked virtual packageからのOpaque TS/TSX/JS/JSX/JSON bundleとCSS/asset emit +- Compilerが入力を検証し、現行subsetではSemantic Surface全体を一partitionにして、`presentation-assets`へのencode / checksum委譲とRenderBundle組立を行う + +### Target + +- generic Web renderer による Structured Primitive graph の描画 +- Opaque renderer TS / React / CSS の isolated execution +- Opaque renderer向けのBrowser isolate lifecycle +- Surface State ごとの layout と capture +- Hit Region の concrete geometry 解決 +- unencoded Surface capture の生成 +- Browser、font、locale、timezone、viewport、layout provenance +- visual regression fixture + +### Deferred + +- Opaque execution と interaction geometry + +```text +resolved semantic input + renderer source + ↓ + isolated Browser render + ↓ + layout / hit-region geometry / raw capture + ↓ + presentation-assets encode and checksum + ↓ + RenderBundle artifact candidate +``` + +## 3. Structured and opaque paths + +### Current + +Structured path は absolute root `Frame` とその direct `Text` だけを扱う。semantic tree の意味は入力として比較するだけで DOM から推測しない。Opaque sourceのbundle APIは実装済みだがBrowser execution/captureとは未接続であり、Renderer pluginの`support()`はOpaque entryを引き続き拒否する。 + +### Target + +Structured path は Primitive graph を generic renderer で描画する。Component 固有 React / CSS を参照せず、Structure に宣言されていない semantics を追加しない。 + +Opaque path は Component 固有 renderer entry を bundle / execute できる。ただし、Semantic Tree、State、Interaction、Action、Output の意味は Manifest から受け取り、DOM や実行結果から推測しない。Renderer binding key と resolved element geometry の対応だけを解決する。 + +### Deferred + +Opaque Browser executionとReact/CSS runtime isolation、Frame/Text 以外の Primitive と state visual variation の lower は未実装である。 + +## 4. Invariants + +- Browser version、font、locale、timezone、viewport、device scale、color space を provenance に固定する。 +- network、clock、randomness、host filesystem などの capability は既定で許可せず、許可時は入力と provenance に含める。 +- State ごとの capture と Hit Region geometry は同じ layout result から生成する。 +- 一つの Render Surface の集合、bounds、layer は全 reachable State で共通とし、各 State に artifact または明示的な empty binding を持たせる。 +- semantic information が Manifest / Structure と一致しない場合は build error とする。 +- raw capture の resize、encode、checksum は `presentation-assets` に委譲する。 +- PresentationDefinition の意味と Surface partition policy を変更しない。 + +## 5. Non-responsibilities + +- PresentationDefinition の semantic validation +- Component Manifest から宣言されていない意味の推測 +- Structured Component 固有 React / CSS implementation +- Surface partition と renderer 自動選択 policy +- Asset upload、Signed URL、Unity renderer +- Native UI / Video renderer の仮実装 + +## 6. Dependency rules + +`presentation-renderer-web` は `presentation-renderer-api`、hash utility、Zod 4、固定versionの`rolldown`、`playwright-core`、`pngjs`に直接依存する。Playwrightはmanaged headless shellのhost lifecycleだけ、pngjsはpreflight済みscreenshotのRGBA decodeだけを担当する。font fingerprintはcaller入力ではなく固定Noto glyph baselineのactual RGBA bytesから導出する。ZodはOpaque locked module input、renderer config、Browser identity / environment、capture metadataのruntime validationを所有する。Opaque source bundleはRolldownのprogrammatic APIと固定内部pluginだけを使用し、callerから任意pluginを受け取らない。raw RGBA encode / checksum は Compiler 経由で `presentation-assets` に委譲し、`presentation-core` は Renderer API の型境界を通して参照する。Compiler から plugin として注入され、Compiler へ逆依存しない。他の concrete renderer にも依存しない。 + +## 7. Isolation boundary + +### Current + +Adapter とOpaque bundle inputのown data descriptorをproperty value accessなしでsnapshotし、後続mutationやaccessorをbuild inputに混入させない。hostile objectをZodへ直接渡さず、descriptor検査から再構築したplain-data snapshotだけを渡すことで、Zodのproperty readによるgetter実行を防ぐ。Zodはsnapshot後のstrict object、tuple、enum、文字列制約、module path / type整合、entry / module集合の関係を検証する。 + +### Target / Deferred + +Browser process / isolate は semantic Compiler process と分離可能な adapter とする。Opaque code の failure、timeout、resource exhaustion を renderer diagnostic へ変換し、Compiler host を暗黙に汚染しない。 + +Capability はallowlistとする。現行bundle境界はlocked virtual package内の相対module、`react`、`react/jsx-runtime`、固定`@unframe/renderer-runtime`、package内CSS/assetsだけを許可する。`node:*`、絶対path、network import、任意bare import、`react-dom`、`node_modules`、PostCSS/Tailwind/Vite/Rolldown config、caller pluginは拒否する。React DOMは将来のBrowser hostが所有する。Embedded Browser runtimeを生成する機能は持たない。 + +## 8. Validation strategy + +### Current + +- Renderer API conformance、fixed adapter / config / environment / fingerprint の境界テスト +- HTML/CSS golden、state order、capture ownership、hostile output / direct build input の回帰テスト +- Zod schemaによるconfig / environment / capture metadataとOpaque module inputのvalidation test +- Opaque module/asset bundle、field path diagnostic、accessor非実行、capability denyの境界テスト + +### Target + +- generic Primitive renderer の conformance fixture +- Surface State ごとの visual regression +- Semantic Tree / Hit Region binding の completeness test +- fixed environment の reproducibility test +- Opaque capability deny / timeout / failure test +- renderer / font / locale 変更時の cache invalidation test +- raw capture と encoded artifact descriptor の integration test + +### Deferred + +- real Browser visual regression baseline、Opaque timeout / resource budget、Assets との end-to-end artifact test + +## 9. Deferred decisions + +- Opaque bundleとRenderer plugin/Browser isolateの接続 +- ADR-0011で確定したmulti-partition plan / private region aggregateの実装 +- ADR-0012で確定した2K capture resolution / capture budgetの実装 +- visual regression tolerance と platform baseline +- Frame/Text 以外の Structured Primitive、style token の concrete 解決、interaction Hit Region diff --git a/packages/presentation-renderer-web/package.json b/packages/presentation-renderer-web/package.json new file mode 100644 index 0000000..68682fb --- /dev/null +++ b/packages/presentation-renderer-web/package.json @@ -0,0 +1,30 @@ +{ + "name": "@unframe/presentation-renderer-web", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "lint": "vp check src test", + "test": "vp test run --exclude test/*.integration.test.ts", + "check": "pnpm typecheck && pnpm lint && pnpm test" + }, + "dependencies": { + "@noble/hashes": "2.3.0", + "@unframe/presentation-renderer-api": "workspace:*", + "playwright-core": "1.62.1", + "pngjs": "7.0.0", + "rolldown": "1.0.1", + "zod": "4.4.3" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "@types/pngjs": "6.0.5", + "typescript": "^7.0.0", + "vite-plus": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/presentation-renderer-web/src/browser/playwright-fixed-browser.ts b/packages/presentation-renderer-web/src/browser/playwright-fixed-browser.ts new file mode 100644 index 0000000..96066cc --- /dev/null +++ b/packages/presentation-renderer-web/src/browser/playwright-fixed-browser.ts @@ -0,0 +1,445 @@ +import { + chromium, + type BrowserContext as PlaywrightBrowserContext, + type Page as PlaywrightPage, + type Route, +} from "playwright-core"; +import { PNG } from "pngjs"; + +import type { + BrowserCaptureRequest, + BrowserRgbaCapture, + FixedBrowserSession, +} from "../public-types.js"; +import { hash } from "../config/config-environment.js"; + +type BrowserContextOptions = { + readonly viewport: { readonly width: number; readonly height: number }; + readonly deviceScaleFactor: 1; + readonly locale: "ja-JP"; + readonly timezoneId: "Asia/Tokyo"; + readonly colorScheme: "light" | "dark"; + readonly offline: true; + readonly acceptDownloads: false; + readonly serviceWorkers: "block"; +}; + +type BrowserLaunchOptions = { + readonly headless: true; + readonly chromiumSandbox: true; + readonly handleSIGINT: false; + readonly handleSIGTERM: false; + readonly handleSIGHUP: false; +}; + +type BrowserPage = { + setContent(document: string, options: { readonly waitUntil: "load" }): Promise; + evaluate(): Promise; + screenshot(options: { + readonly type: "png"; + readonly scale: "css"; + readonly omitBackground: false; + readonly animations: "disabled"; + readonly caret: "hide"; + }): Promise>; +}; + +type BrowserContext = { + addInitScript(script: string): Promise; + route(url: string, handler: () => Promise): Promise; + newPage(): Promise; + close(): Promise; +}; + +type LaunchedBrowser = { + version(): string; + newContext(options: BrowserContextOptions): Promise; + close(): Promise; +}; + +export type BrowserDriver = { + launch(options: BrowserLaunchOptions): Promise; + decodePng(bytes: Uint8Array): { + readonly width: number; + readonly height: number; + readonly data: Uint8Array; + }; +}; + +const FIXED_TIME_MS = 946684800000; +const RANDOM_SEED = 0x5eed; +const FONT_PROFILE = "Noto Sans CJK JP"; +const MAX_CAPTURE_DIMENSION = 4096; +const MAX_CAPTURE_PIXELS = 16_777_216; +const MAX_ENCODED_PNG_BYTES = 65 * 1024 * 1024; +const PNG_SIGNATURE = Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10); +const initScript = `(() => { + const fixedTime = ${FIXED_TIME_MS}; + const OriginalDate = Date; + function FixedDate(...args) { + if (new.target) + return Reflect.construct(OriginalDate, args.length === 0 ? [fixedTime] : args, new.target); + return new OriginalDate(fixedTime).toString(); + } + Object.setPrototypeOf(FixedDate, OriginalDate); + FixedDate.prototype = OriginalDate.prototype; + FixedDate.now = () => fixedTime; + let state = 0x${RANDOM_SEED.toString(16)}; + const nextUint32 = () => { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + return state >>> 0; + }; + Math.random = () => nextUint32() / 4294967296; + Object.defineProperty(performance, "now", { value: () => 0 }); + Object.defineProperty(performance, "timeOrigin", { value: fixedTime }); + const fillRandom = (array) => { + const bytes = new Uint8Array(array.buffer, array.byteOffset, array.byteLength); + for (let index = 0; index < bytes.length; index += 1) + bytes[index] = nextUint32() & 0xff; + return array; + }; + Object.defineProperty(crypto, "getRandomValues", { value: fillRandom }); + Object.defineProperty(crypto, "subtle", { + get: () => { throw new DOMException("Web Crypto is unavailable in the fixed renderer.", "NotAllowedError"); }, + }); + Object.defineProperty(crypto, "randomUUID", { + value: () => { + const bytes = fillRandom(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = [...bytes].map((value) => value.toString(16).padStart(2, "0")); + return hex.slice(0, 4).join("") + "-" + hex.slice(4, 6).join("") + "-" + + hex.slice(6, 8).join("") + "-" + hex.slice(8, 10).join("") + "-" + + hex.slice(10).join(""); + }, + }); + globalThis.Date = FixedDate; +})();`; + +const aborted = () => { + const error = new Error("Fixed Browser capture was aborted."); + error.name = "AbortError"; + return error; +}; + +const alphaModeFor = (rgba: Uint8Array): "opaque" | "straight" => + rgba.every((value, index) => index % 4 !== 3 || value === 255) ? "opaque" : "straight"; + +const hasFixedEnvironment = (request: BrowserCaptureRequest, fontFingerprint: string) => + request.environment.browser.id === "playwright-chromium" && + request.environment.browser.version.length > 0 && + request.environment.browser.fontFingerprint === fontFingerprint && + request.environment.locale === "ja-JP" && + request.environment.timezone === "Asia/Tokyo" && + request.environment.colorSpace === "srgb" && + request.environment.deviceScaleFactor === 1 && + request.environment.network === "deny" && + request.environment.filesystem === "deny" && + request.environment.clock === "fixed" && + request.environment.random === "fixed" && + request.capabilities.colorSpace === "srgb" && + request.capabilities.deviceScaleFactor === 1 && + request.capabilities.network === "deny" && + request.capabilities.filesystem === "deny" && + request.capabilities.clock === "fixed" && + request.capabilities.random === "fixed"; + +const hasPixelTarget = (value: readonly [number, number]) => + value.every( + (dimension) => + Number.isSafeInteger(dimension) && dimension > 0 && dimension <= MAX_CAPTURE_DIMENSION, + ) && value[0] * value[1] <= MAX_CAPTURE_PIXELS; + +const abortSignalAborted = Object.getOwnPropertyDescriptor(AbortSignal.prototype, "aborted")?.get; +const isAbortSignal = (value: unknown): value is AbortSignal => { + try { + return ( + typeof value === "object" && value !== null && abortSignalAborted?.call(value) !== undefined + ); + } catch { + return false; + } +}; + +const preflightPng = (bytes: Uint8Array) => { + if (bytes.byteLength > MAX_ENCODED_PNG_BYTES) + throw new TypeError("PNG bytes exceed the Fixed Browser trust boundary."); + if ( + bytes.byteLength < 24 || + PNG_SIGNATURE.some((value, index) => bytes[index] !== value) || + bytes[12] !== 73 || + bytes[13] !== 72 || + bytes[14] !== 68 || + bytes[15] !== 82 || + bytes[8] !== 0 || + bytes[9] !== 0 || + bytes[10] !== 0 || + bytes[11] !== 13 + ) + throw new TypeError("Browser screenshot must be a PNG with an IHDR header."); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const width = view.getUint32(16); + const height = view.getUint32(20); + if ( + width === 0 || + height === 0 || + width > MAX_CAPTURE_DIMENSION || + height > MAX_CAPTURE_DIMENSION || + width * height > MAX_CAPTURE_PIXELS + ) + throw new TypeError("PNG dimensions exceed the Fixed Browser trust boundary."); +}; + +const decodePng = (bytes: Uint8Array) => { + preflightPng(bytes); + const decoded = PNG.sync.read(Buffer.from(bytes)); + return { width: decoded.width, height: decoded.height, data: Uint8Array.from(decoded.data) }; +}; + +const playwrightDriver: BrowserDriver = { + launch: async (options) => { + const browser = await chromium.launch(options); + return { + version: () => browser.version(), + newContext: async (contextOptions) => contextFor(await browser.newContext(contextOptions)), + close: async () => browser.close(), + }; + }, + decodePng, +}; + +const FONT_PROBE_DOCUMENT = `あいう漢字ABC123`; +const screenshotOptions = Object.freeze({ + type: "png" as const, + scale: "css" as const, + omitBackground: false as const, + animations: "disabled" as const, + caret: "hide" as const, +}); + +const fontFingerprintFor = async (browser: LaunchedBrowser, driver: BrowserDriver) => { + const context = await browser.newContext({ + viewport: { width: 256, height: 64 }, + deviceScaleFactor: 1, + locale: "ja-JP", + timezoneId: "Asia/Tokyo", + colorScheme: "light", + offline: true, + acceptDownloads: false, + serviceWorkers: "block", + }); + try { + await context.addInitScript(initScript); + await context.route("**/*", async () => undefined); + const page = await context.newPage(); + await page.setContent(FONT_PROBE_DOCUMENT, { waitUntil: "load" }); + await page.evaluate(); + const png = await page.screenshot(screenshotOptions); + preflightPng(png); + const decoded = driver.decodePng(png); + if (decoded.width !== 256 || decoded.height !== 64 || decoded.data.byteLength !== 256 * 64 * 4) + throw new TypeError("Fixed Browser font probe must produce the complete baseline RGBA."); + return hash({ fontProfile: FONT_PROFILE, rgba: Array.from(decoded.data) }); + } finally { + await context.close(); + } +}; + +const contextFor = (context: PlaywrightBrowserContext): BrowserContext => ({ + addInitScript: async (script) => { + await context.addInitScript({ content: script }); + }, + route: async (url, handler) => { + await context.route(url, async (route: Route) => { + await handler(); + await route.abort("blockedbyclient"); + }); + }, + newPage: async () => pageFor(await context.newPage()), + close: async () => context.close(), +}); + +const pageFor = (page: PlaywrightPage): BrowserPage => ({ + setContent: async (document, options) => page.setContent(document, options), + evaluate: async () => { + await page.evaluate("document.fonts.ready"); + }, + screenshot: async (options) => Uint8Array.from(await page.screenshot(options)), +}); + +type CaptureOperation = { + readonly controller: AbortController; + closeContext(): Promise; +}; + +const abortable = async (promise: Promise, signal: AbortSignal): Promise => { + if (signal.aborted) throw aborted(); + return await new Promise((resolve, reject) => { + const onAbort = () => reject(aborted()); + signal.addEventListener("abort", onAbort, { once: true }); + void promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort)); + }); +}; + +export const createPlaywrightFixedBrowserFactory = + (driver: BrowserDriver) => + async (options: { readonly signal?: AbortSignal } = {}): Promise => { + const launch = driver.launch({ + headless: true, + chromiumSandbox: true, + handleSIGINT: false, + handleSIGTERM: false, + handleSIGHUP: false, + }); + let browser: LaunchedBrowser; + try { + browser = options.signal ? await abortable(launch, options.signal) : await launch; + } catch (error) { + if (options.signal?.aborted) + void launch.then((lateBrowser) => lateBrowser.close()).catch(() => undefined); + throw error; + } + let fontFingerprint: string; + try { + const fingerprint = fontFingerprintFor(browser, driver); + fontFingerprint = options.signal + ? await abortable(fingerprint, options.signal) + : await fingerprint; + } catch (error) { + await browser.close(); + throw error; + } + const environment = Object.freeze({ + browser: Object.freeze({ + id: "playwright-chromium", + version: browser.version(), + fontFingerprint, + }), + locale: "ja-JP" as const, + timezone: "Asia/Tokyo" as const, + colorSpace: "srgb" as const, + deviceScaleFactor: 1 as const, + network: "deny" as const, + filesystem: "deny" as const, + clock: "fixed" as const, + random: "fixed" as const, + }); + const identity = Object.freeze({ + id: "playwright-chromium", + implementationHash: hash({ + adapter: "playwright-fixed-browser", + browser: environment.browser, + locale: environment.locale, + timezone: environment.timezone, + fixedTimeMs: FIXED_TIME_MS, + randomSeed: RANDOM_SEED, + fontProfile: FONT_PROFILE, + }), + }); + const active = new Set(); + let closing = false; + let closePromise: Promise | undefined; + + const close = (): Promise => { + closePromise ??= (async () => { + closing = true; + const operations = [...active]; + for (const operation of operations) operation.controller.abort(); + const results = await Promise.allSettled( + operations.map((operation) => operation.closeContext()), + ); + const rejected = results.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + let browserCloseError: unknown; + try { + await browser.close(); + } catch (error) { + browserCloseError = error; + } + if (rejected) throw rejected.reason; + if (browserCloseError) throw browserCloseError; + })(); + return closePromise; + }; + + const capture = async ( + request: BrowserCaptureRequest, + captureOptions?: { readonly signal?: AbortSignal }, + ): Promise => { + if (closing) throw aborted(); + const externalSignal = captureOptions?.signal; + if ( + !hasFixedEnvironment(request, fontFingerprint) || + request.environment.browser.version !== environment.browser.version || + !hasPixelTarget(request.pixelTarget) || + (request.colorScheme !== "light" && request.colorScheme !== "dark") || + (externalSignal !== undefined && !isAbortSignal(externalSignal)) + ) + throw new TypeError("Capture request must match the fixed Browser environment."); + const controller = new AbortController(); + let contextClose: Promise | undefined; + const contextReady = browser.newContext({ + viewport: { width: request.pixelTarget[0], height: request.pixelTarget[1] }, + deviceScaleFactor: 1, + locale: "ja-JP", + timezoneId: "Asia/Tokyo", + colorScheme: request.colorScheme, + offline: true, + acceptDownloads: false, + serviceWorkers: "block", + }); + const operation: CaptureOperation = { + controller, + closeContext: () => + (contextClose ??= contextReady.then(async (context) => { + await context.close(); + })), + }; + active.add(operation); + try { + const signal = AbortSignal.any( + [controller.signal, externalSignal].filter( + (value): value is AbortSignal => value !== undefined, + ), + ); + if (closing || signal.aborted) throw aborted(); + const context = await abortable(contextReady, signal); + await abortable(context.addInitScript(initScript), signal); + await abortable( + context.route("**/*", async () => undefined), + signal, + ); + const page = await abortable(context.newPage(), signal); + await abortable(page.setContent(request.document, { waitUntil: "load" }), signal); + await abortable(page.evaluate(), signal); + const screenshot = await abortable(page.screenshot(screenshotOptions), signal); + preflightPng(screenshot); + const decoded = driver.decodePng(screenshot); + const [width, height] = request.pixelTarget; + if (decoded.width !== width || decoded.height !== height) + throw new TypeError("PNG dimensions must match the requested pixel target."); + const rgba = Uint8Array.from(decoded.data); + if (rgba.byteLength !== width * height * 4) + throw new TypeError("PNG RGBA bytes must match the requested pixel target."); + return { + rgba, + pixelSize: [width, height], + colorSpace: "srgb", + alphaMode: alphaModeFor(rgba), + }; + } finally { + try { + await operation.closeContext(); + } finally { + active.delete(operation); + } + } + }; + + return Object.freeze({ identity, environment, capture, close }); + }; + +export const openPlaywrightFixedBrowser = createPlaywrightFixedBrowserFactory(playwrightDriver); diff --git a/packages/presentation-renderer-web/src/config/config-environment.ts b/packages/presentation-renderer-web/src/config/config-environment.ts new file mode 100644 index 0000000..7eded0e --- /dev/null +++ b/packages/presentation-renderer-web/src/config/config-environment.ts @@ -0,0 +1,123 @@ +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex } from "@noble/hashes/utils.js"; +import type { + FixedBrowserAdapter, + FixedBrowserEnvironment, + WebRendererConfig, +} from "../public-types.js"; +import { snapshotDenseArray, snapshotStrictRecord } from "../validation/safe-data.js"; +import { + adapterIdentitySchema, + fixedBrowserEnvironmentSchema, + webRendererConfigSchema, +} from "../validation/schemas.js"; + +const encoder = new TextEncoder(); +export const hash = (value: unknown) => + `sha256:${bytesToHex(sha256(encoder.encode(JSON.stringify(value))))}`; +const typedArrayByteLength = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(Uint8Array.prototype), + "byteLength", +)?.get; +const typedArrayTag = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(Uint8Array.prototype), + Symbol.toStringTag, +)?.get; +export const copyRgba = (value: unknown): Uint8Array | undefined => { + try { + if (!ArrayBuffer.isView(value) || !typedArrayByteLength || !typedArrayTag) return undefined; + if (typedArrayTag.call(value) !== "Uint8Array") return undefined; + const byteLength = typedArrayByteLength.call(value); + if (!Number.isSafeInteger(byteLength) || byteLength < 0) return undefined; + const copy = new Uint8Array(byteLength); + Uint8Array.prototype.set.call(copy, value as Uint8Array); + return copy; + } catch { + return undefined; + } +}; + +const environmentKeys = [ + "browser", + "clock", + "colorSpace", + "deviceScaleFactor", + "filesystem", + "locale", + "network", + "random", + "timezone", +] as const; +const browserKeys = ["fontFingerprint", "id", "version"] as const; +const adapterIdentityKeys = ["id", "implementationHash"] as const; + +export const snapshotAdapterIdentity = ( + identity: unknown, +): Readonly | undefined => { + const record = snapshotStrictRecord(identity, adapterIdentityKeys); + if (!record) return undefined; + const parsed = adapterIdentitySchema.safeParse(record); + return parsed.success ? Object.freeze(parsed.data) : undefined; +}; + +export const normalizedEnvironment = (environment: FixedBrowserEnvironment) => ({ + browser: { + id: environment.browser.id, + version: environment.browser.version, + fontFingerprint: environment.browser.fontFingerprint, + }, + locale: environment.locale, + timezone: environment.timezone, + colorSpace: environment.colorSpace, + deviceScaleFactor: environment.deviceScaleFactor, + network: environment.network, + filesystem: environment.filesystem, + clock: environment.clock, + random: environment.random, +}); + +export const snapshotEnvironment = (value: unknown): FixedBrowserEnvironment | undefined => { + const record = snapshotStrictRecord(value, environmentKeys); + const browser = record && snapshotStrictRecord(record.browser, browserKeys); + if (!record || !browser) return undefined; + const parsed = fixedBrowserEnvironmentSchema.safeParse({ ...record, browser }); + if (!parsed.success) return undefined; + return Object.freeze({ + ...parsed.data, + browser: Object.freeze(parsed.data.browser), + }); +}; + +export const snapshotConfig = (config: unknown): WebRendererConfig | undefined => { + const record = snapshotStrictRecord(config, ["documentBackground", "fontFamily"]); + const values = record && snapshotDenseArray(record.documentBackground, 4); + if (!record || !values) return undefined; + const parsed = webRendererConfigSchema.safeParse({ + documentBackground: values, + fontFamily: record.fontFamily, + }); + return parsed.success ? frozenConfig(parsed.data) : undefined; +}; + +export const configHashFromSnapshot = (config: WebRendererConfig): string => + hash({ documentBackground: config.documentBackground, fontFamily: config.fontFamily }); + +export const createWebRendererConfigHash = (config: WebRendererConfig): string => { + const snapshot = snapshotConfig(config); + if (!snapshot) + throw new TypeError( + "Web renderer config must use finite RGBA bytes and a non-empty font family.", + ); + return configHashFromSnapshot(snapshot); +}; + +const frozenConfig = (config: WebRendererConfig): WebRendererConfig => + Object.freeze({ + documentBackground: Object.freeze([...config.documentBackground]) as readonly [ + number, + number, + number, + number, + ], + fontFamily: config.fontFamily.trim(), + }); diff --git a/packages/presentation-renderer-web/src/index.ts b/packages/presentation-renderer-web/src/index.ts new file mode 100644 index 0000000..7bf3060 --- /dev/null +++ b/packages/presentation-renderer-web/src/index.ts @@ -0,0 +1,24 @@ +export { createBakedWebRenderer } from "./rendering/baked-web-renderer.js"; + +export { createWebRendererConfigHash } from "./config/config-environment.js"; + +export { openPlaywrightFixedBrowser } from "./browser/playwright-fixed-browser.js"; + +export { + bundleOpaqueRenderer, + type OpaqueBundleDiagnostic, + type OpaqueRendererBundleInput, + type OpaqueRendererBundleResult, + type OpaqueRendererModule, + type OpaqueRendererModuleType, +} from "./opaque/bundle-opaque-renderer.js"; + +export type { + BrowserCaptureRequest, + BrowserRgbaCapture, + CreateBakedWebRendererOptions, + FixedBrowserAdapter, + FixedBrowserEnvironment, + FixedBrowserSession, + WebRendererConfig, +} from "./public-types.js"; diff --git a/packages/presentation-renderer-web/src/opaque/bundle-opaque-renderer.ts b/packages/presentation-renderer-web/src/opaque/bundle-opaque-renderer.ts new file mode 100644 index 0000000..76d50b5 --- /dev/null +++ b/packages/presentation-renderer-web/src/opaque/bundle-opaque-renderer.ts @@ -0,0 +1,311 @@ +import { rolldown, type RolldownBuild, type RolldownOutput } from "rolldown"; + +import { + extensionOf, + opaqueRendererBundleInputSchema, + type SourceModuleType, +} from "./input-schema.js"; +import { + copyUint8Array, + snapshotDenseArray, + snapshotStrictRecord, +} from "../validation/safe-data.js"; + +export type { OpaqueRendererModuleType } from "./input-schema.js"; + +type OpaqueRendererAssetModule = { + readonly path: string; + readonly source: string | Uint8Array; + readonly moduleType: "asset"; +}; + +type OpaqueRendererSourceModule = { + readonly path: string; + readonly source: string; + readonly moduleType: SourceModuleType; +}; + +export type OpaqueRendererModule = OpaqueRendererAssetModule | OpaqueRendererSourceModule; + +export type OpaqueRendererBundleInput = { + readonly entry: string; + readonly modules: readonly OpaqueRendererModule[]; +}; + +export type OpaqueBundleDiagnostic = { + readonly code: + | "opaque-bundle-failed" + | "opaque-bundle-input-invalid" + | "opaque-import-denied" + | "opaque-module-not-found"; + readonly path: readonly string[]; + readonly message: string; +}; + +export type OpaqueRendererBundleResult = + | { + readonly ok: true; + readonly javascript: string; + readonly assets: readonly { + readonly fileName: string; + readonly source: string | Uint8Array; + }[]; + readonly externalImports: readonly string[]; + readonly diagnostics: []; + } + | { readonly ok: false; readonly diagnostics: readonly OpaqueBundleDiagnostic[] }; + +type ModuleSnapshot = Readonly; + +const VIRTUAL_PREFIX = "\0unframe:opaque/"; +const RUNTIME_ID = "\0unframe:renderer-runtime"; +const RUNTIME_SPECIFIER = "@unframe/renderer-runtime"; +const RUNTIME_SOURCE = "export const defineOpaqueRenderer = (renderer) => renderer;"; +const externalImports = new Set(["react", "react/jsx-runtime"]); +const sourceExtensions = [".ts", ".tsx", ".js", ".jsx", ".json", ".css"] as const; +const compareStrings = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0); + +const failure = ( + code: OpaqueBundleDiagnostic["code"], + path: readonly string[], + message: string, +): OpaqueRendererBundleResult => ({ ok: false, diagnostics: [{ code, path, message }] }); + +const snapshotInputUnchecked = ( + input: unknown, +): + | { ok: true; value: { entry: string; modules: ReadonlyMap } } + | { ok: false; path: readonly string[] } => { + const record = snapshotStrictRecord(input, ["entry", "modules"]); + if (!record) return { ok: false, path: [] }; + const moduleValues = snapshotDenseArray(record.modules); + if (!moduleValues) return { ok: false, path: ["modules"] }; + const safeModules: Record[] = []; + for (const [index, value] of moduleValues.entries()) { + const item = snapshotStrictRecord(value, ["moduleType", "path", "source"]); + if (!item) return { ok: false, path: ["modules", String(index)] }; + const source = typeof item.source === "string" ? item.source : copyUint8Array(item.source); + if (source === undefined) return { ok: false, path: ["modules", String(index), "source"] }; + safeModules.push({ ...item, source }); + } + const parsed = opaqueRendererBundleInputSchema.safeParse({ + entry: record.entry, + modules: safeModules, + }); + if (!parsed.success) + return { + ok: false, + path: parsed.error.issues[0]?.path.map(String) ?? [], + }; + const modules = new Map(); + for (const item of parsed.data.modules) modules.set(item.path, Object.freeze(item)); + return { ok: true, value: { entry: parsed.data.entry, modules } }; +}; + +const snapshotInput = (input: unknown) => { + try { + return snapshotInputUnchecked(input); + } catch { + return { ok: false as const, path: [] }; + } +}; + +const resolveRelativePath = (importerPath: string, specifier: string) => { + if (!specifier.startsWith("./") && !specifier.startsWith("../")) return undefined; + if ( + specifier.includes("\\") || + specifier.includes(":") || + specifier.includes("?") || + specifier.includes("#") + ) + return undefined; + const segments = importerPath.split("/").slice(0, -1); + for (const segment of specifier.split("/")) { + if (segment === "." || segment === "") continue; + if (segment === "..") { + if (segments.length === 0) return undefined; + segments.pop(); + } else { + segments.push(segment); + } + } + return segments.join("/"); +}; + +const resolvedModulePath = (path: string, modules: ReadonlyMap) => { + if (modules.has(path)) return path; + if (extensionOf(path) !== "") return undefined; + for (const extension of sourceExtensions) + if (modules.has(`${path}${extension}`)) return `${path}${extension}`; + return undefined; +}; + +const cssReferences = (source: string) => { + const references: string[] = []; + for (const pattern of [ + /@import\s+(?:url\(\s*)?["']([^"']+)["']/g, + /url\(\s*["']?([^"')]+)["']?\s*\)/g, + ]) + for (const match of source.matchAll(pattern)) if (match[1]) references.push(match[1]); + return references; +}; + +const invalidCssReference = (snapshot: { + entry: string; + modules: ReadonlyMap; +}): OpaqueBundleDiagnostic | undefined => { + for (const item of snapshot.modules.values()) { + if (item.moduleType !== "css") continue; + for (const specifier of cssReferences(item.source)) { + const relativePath = resolveRelativePath(item.path, specifier); + const target = relativePath === undefined ? undefined : snapshot.modules.get(relativePath); + if (!target || (target.moduleType !== "asset" && target.moduleType !== "css")) + return { + code: "opaque-import-denied", + path: [item.path, specifier], + message: `CSS reference is outside the locked package: ${specifier}`, + }; + } + } + return undefined; +}; + +const copyAssetSource = (source: string | Uint8Array) => + typeof source === "string" ? source : new Uint8Array(source); + +const resultFromOutput = (output: RolldownOutput): OpaqueRendererBundleResult => { + const chunks = output.output.filter((item) => item.type === "chunk"); + if (chunks.length !== 1) + return failure( + "opaque-bundle-failed", + [], + "Opaque renderer must produce one JavaScript chunk.", + ); + const chunk = chunks[0]; + if (!chunk) return failure("opaque-bundle-failed", [], "Opaque renderer output is missing."); + const imports = [...new Set(chunk.imports)].sort(); + if (imports.some((specifier) => !externalImports.has(specifier))) + return failure( + "opaque-bundle-failed", + [], + "Opaque renderer emitted an unapproved external import.", + ); + const assets = output.output + .filter((item) => item.type === "asset") + .map((asset) => ({ fileName: asset.fileName, source: copyAssetSource(asset.source) })) + .sort((left, right) => compareStrings(left.fileName, right.fileName)); + return { + ok: true, + javascript: chunk.code, + assets, + externalImports: imports, + diagnostics: [], + }; +}; + +export const bundleOpaqueRenderer = async (input: unknown): Promise => { + const snapshotResult = snapshotInput(input); + if (!snapshotResult.ok) + return failure( + "opaque-bundle-input-invalid", + snapshotResult.path, + "Opaque bundle input must be a locked TS/TSX/JS/JSX/JSON/CSS/asset module set.", + ); + const snapshot = snapshotResult.value; + const cssFailure = invalidCssReference(snapshot); + if (cssFailure) return { ok: false, diagnostics: [cssFailure] }; + + let resolutionFailure: OpaqueBundleDiagnostic | undefined; + let bundle: RolldownBuild | undefined; + const rememberFailure = ( + code: OpaqueBundleDiagnostic["code"], + path: readonly string[], + message: string, + ) => { + resolutionFailure ??= { code, path, message }; + throw new Error(message); + }; + + try { + const entryId = `${VIRTUAL_PREFIX}${snapshot.entry}`; + bundle = await rolldown({ + input: entryId, + platform: "browser", + transform: { jsx: "react-jsx" }, + plugins: [ + { + name: "unframe-opaque-modules", + buildStart() { + for (const item of snapshot.modules.values()) + if (item.moduleType === "asset" || item.moduleType === "css") + this.emitFile({ + type: "asset", + fileName: `assets/${item.path}`, + source: item.source, + }); + }, + resolveId(specifier, importer) { + if (specifier === entryId && importer === undefined) return entryId; + if (externalImports.has(specifier)) return { id: specifier, external: true }; + if (specifier === RUNTIME_SPECIFIER) return RUNTIME_ID; + if (!importer?.startsWith(VIRTUAL_PREFIX)) + return rememberFailure( + "opaque-import-denied", + [snapshot.entry, specifier], + `Import is outside the locked package: ${specifier}`, + ); + const importerPath = importer.slice(VIRTUAL_PREFIX.length); + const relativePath = resolveRelativePath(importerPath, specifier); + if (relativePath === undefined) + return rememberFailure( + "opaque-import-denied", + [importerPath, specifier], + `Import is not allowed: ${specifier}`, + ); + const modulePath = resolvedModulePath(relativePath, snapshot.modules); + if (modulePath === undefined) + return rememberFailure( + "opaque-module-not-found", + [importerPath, specifier], + `Locked package module was not found: ${specifier}`, + ); + return `${VIRTUAL_PREFIX}${modulePath}`; + }, + load(id) { + if (id === RUNTIME_ID) return { code: RUNTIME_SOURCE, moduleType: "js" }; + if (!id.startsWith(VIRTUAL_PREFIX)) return null; + const modulePath = id.slice(VIRTUAL_PREFIX.length); + const item = snapshot.modules.get(modulePath); + if (!item) + return rememberFailure( + "opaque-module-not-found", + [modulePath], + `Locked package module was not found: ${modulePath}`, + ); + if (item.moduleType === "asset" || item.moduleType === "css") + return { + code: `export default ${JSON.stringify(`assets/${item.path}`)};`, + moduleType: "js", + moduleSideEffects: "no-treeshake", + }; + return { code: item.source, moduleType: item.moduleType }; + }, + }, + ], + }); + const output = await bundle.generate({ + format: "es", + entryFileNames: "renderer.js", + assetFileNames: "assets/[name]-[hash:16][extname]", + codeSplitting: false, + sourcemap: false, + }); + return resultFromOutput(output); + } catch { + return resolutionFailure + ? { ok: false, diagnostics: [resolutionFailure] } + : failure("opaque-bundle-failed", [], "Rolldown could not bundle the opaque renderer."); + } finally { + await bundle?.close().catch(() => undefined); + } +}; diff --git a/packages/presentation-renderer-web/src/opaque/input-schema.ts b/packages/presentation-renderer-web/src/opaque/input-schema.ts new file mode 100644 index 0000000..f392cd6 --- /dev/null +++ b/packages/presentation-renderer-web/src/opaque/input-schema.ts @@ -0,0 +1,128 @@ +import * as z from "zod"; + +export const opaqueRendererModuleTypeSchema = z.enum([ + "asset", + "css", + "js", + "jsx", + "json", + "ts", + "tsx", +]); +export type OpaqueRendererModuleType = z.output; +export type SourceModuleType = Exclude; + +const assetExtensions = new Set([ + ".avif", + ".gif", + ".jpeg", + ".jpg", + ".png", + ".svg", + ".webp", + ".woff", + ".woff2", +]); +const sourceTypeExtensions: Record> = { + css: new Set([".css"]), + js: new Set([".js"]), + jsx: new Set([".jsx"]), + json: new Set([".json"]), + ts: new Set([".ts"]), + tsx: new Set([".tsx"]), +}; +const deniedConfigPattern = /(?:^|\/)(?:postcss|rolldown|tailwind|vite)\.config\.[^/]+$/; + +export const extensionOf = (path: string) => { + const fileName = path.slice(path.lastIndexOf("/") + 1); + const index = fileName.lastIndexOf("."); + return index < 0 ? "" : fileName.slice(index).toLowerCase(); +}; + +const modulePathSchema = z + .string() + .min(1) + .refine( + (path) => + !path.startsWith("/") && + !path.includes("\\") && + !path.includes(":") && + !path.includes("?") && + !path.includes("#") && + !deniedConfigPattern.test(path) && + path + .split("/") + .every( + (segment) => + segment.length > 0 && segment !== "." && segment !== ".." && segment !== "node_modules", + ), + ); + +const assetModuleSchema = z + .strictObject({ + path: modulePathSchema, + source: z.union([z.string(), z.instanceof(Uint8Array)]), + moduleType: z.literal("asset"), + }) + .superRefine((module, context) => { + const extension = extensionOf(module.path); + if (!assetExtensions.has(extension)) + context.addIssue({ + code: "custom", + path: ["moduleType"], + message: "Module type must match the module path extension.", + }); + }); + +const sourceModuleSchema = z + .strictObject({ + path: modulePathSchema, + source: z.string(), + moduleType: z.enum(["css", "js", "jsx", "json", "ts", "tsx"]), + }) + .superRefine((module, context) => { + if (!sourceTypeExtensions[module.moduleType].has(extensionOf(module.path))) + context.addIssue({ + code: "custom", + path: ["moduleType"], + message: "Module type must match the module path extension.", + }); + }); + +export const opaqueRendererModuleSchema = z.union([assetModuleSchema, sourceModuleSchema]); + +export const opaqueRendererBundleInputSchema = z + .strictObject({ + entry: modulePathSchema, + modules: z.array(opaqueRendererModuleSchema).min(1), + }) + .superRefine((input, context) => { + const seen = new Set(); + for (const [index, module] of input.modules.entries()) { + if (seen.has(module.path)) + context.addIssue({ + code: "custom", + path: ["modules", index, "path"], + message: "Module paths must be unique.", + }); + seen.add(module.path); + } + const entryIndex = input.modules.findIndex((module) => module.path === input.entry); + if (entryIndex < 0) { + context.addIssue({ + code: "custom", + path: ["entry"], + message: "Entry must identify a locked package module.", + }); + return; + } + const entry = input.modules[entryIndex]; + if (entry?.moduleType === "asset" || entry?.moduleType === "css") + context.addIssue({ + code: "custom", + path: ["modules", entryIndex, "moduleType"], + message: "Entry must be an executable source module.", + }); + }); + +export type ParsedOpaqueRendererBundleInput = z.output; diff --git a/packages/presentation-renderer-web/src/public-types.ts b/packages/presentation-renderer-web/src/public-types.ts new file mode 100644 index 0000000..646939b --- /dev/null +++ b/packages/presentation-renderer-web/src/public-types.ts @@ -0,0 +1,60 @@ +export type FixedBrowserEnvironment = { + readonly browser: { + readonly id: string; + readonly version: string; + readonly fontFingerprint: string; + }; + readonly locale: string; + readonly timezone: string; + readonly colorSpace: "srgb"; + readonly deviceScaleFactor: 1; + readonly network: "deny"; + readonly filesystem: "deny"; + readonly clock: "fixed"; + readonly random: "fixed"; +}; + +export type BrowserCaptureRequest = { + readonly stateId: string; + readonly document: string; + readonly pixelTarget: readonly [width: number, height: number]; + readonly colorScheme: "light" | "dark"; + readonly environment: FixedBrowserEnvironment; + readonly capabilities: Pick< + FixedBrowserEnvironment, + "network" | "filesystem" | "clock" | "random" | "deviceScaleFactor" | "colorSpace" + >; +}; + +export type BrowserRgbaCapture = { + readonly rgba: Uint8Array; + readonly pixelSize: readonly [width: number, height: number]; + readonly colorSpace: "srgb"; + readonly alphaMode: "opaque" | "straight" | "premultiplied"; +}; + +export type FixedBrowserAdapter = { + readonly identity: { + readonly id: string; + readonly implementationHash: string; + }; + readonly environment: FixedBrowserEnvironment; + capture( + request: BrowserCaptureRequest, + options?: { readonly signal?: AbortSignal }, + ): Promise | BrowserRgbaCapture; +}; + +export type FixedBrowserSession = FixedBrowserAdapter & { + close(): Promise; +}; + +export type WebRendererConfig = { + readonly documentBackground: readonly [red: number, green: number, blue: number, alpha: number]; + readonly fontFamily: string; +}; + +export type CreateBakedWebRendererOptions = { + readonly adapter: FixedBrowserAdapter; + readonly config: WebRendererConfig; +}; diff --git a/packages/presentation-renderer-web/src/rendering/baked-web-renderer.ts b/packages/presentation-renderer-web/src/rendering/baked-web-renderer.ts new file mode 100644 index 0000000..73fdd2f --- /dev/null +++ b/packages/presentation-renderer-web/src/rendering/baked-web-renderer.ts @@ -0,0 +1,408 @@ +import { + evaluateFirstMilestoneSupport, + prepareRendererBuildInput, + type CompilerResolvedSurfaceInput, + type Diagnostic, + type RawSurfaceCapture, + type RendererBuildFailure, + type RendererBuildResult, + type RendererCapabilities, + type RendererPlugin, + type RendererSupportRequest, +} from "@unframe/presentation-renderer-api"; + +import { snapshotDenseArray, snapshotStrictRecord } from "../validation/safe-data.js"; +import { + adapterCaptureSchema, + browserCaptureSchemaFor, + createBakedWebRendererOptionsSchema, +} from "../validation/schemas.js"; +import type { + BrowserCaptureRequest, + BrowserRgbaCapture, + CreateBakedWebRendererOptions, + FixedBrowserAdapter, + WebRendererConfig, +} from "../public-types.js"; +import { + configHashFromSnapshot, + hash, + copyRgba, + normalizedEnvironment, + snapshotAdapterIdentity, + snapshotConfig, + snapshotEnvironment, +} from "../config/config-environment.js"; + +const RENDERER_VERSION = "1"; +const CONTRACT_VERSION = "1"; +const applyFunction = Reflect.apply; +const capabilities = Object.freeze({ + inputKinds: Object.freeze(["structured"] as const), + updateModels: Object.freeze(["static"] as const), + interactions: Object.freeze(["none"] as const), + internalAnimations: Object.freeze(["none"] as const), + rendererPreferences: Object.freeze(["baked-web"] as const), + fallbackPolicies: Object.freeze(["reject"] as const), + deterministic: true as const, +}) satisfies RendererCapabilities; + +const diagnostic = ( + code: string, + message: string, + path: readonly (string | number)[] = [], +): Diagnostic => ({ + code, + message, + path, +}); + +const failure = ( + code: string, + message: string, + path: readonly (string | number)[] = [], +): RendererBuildFailure => ({ + ok: false, + diagnostics: [diagnostic(code, message, path)], +}); + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); +const compare = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0); +const escapeHtml = (value: string) => { + let escaped = ""; + for (let index = 0; index < value.length; index++) { + const character = value[index]; + escaped += + character === "&" + ? "&" + : character === "<" + ? "<" + : character === ">" + ? ">" + : character === '"' + ? """ + : character === "'" + ? "'" + : character; + } + return escaped; +}; + +const cssString = (value: string) => JSON.stringify(value); +const finite = (value: number) => Number.isFinite(value); +const cssNumber = (value: number) => { + const normalized = Object.is(value, -0) ? 0 : value; + return String(normalized); +}; + +const stableValue = (value: unknown, seen = new Set()): unknown => { + if (Array.isArray(value)) return value.map((item) => stableValue(item, seen)); + if (isRecord(value)) { + if (seen.has(value)) throw new TypeError("Cyclic semantic tree."); + seen.add(value); + const result = Object.fromEntries( + Object.keys(value) + .sort(compare) + .map((key) => [key, stableValue(value[key], seen)]), + ); + seen.delete(value); + return result; + } + return value; +}; + +const stableSnapshot = (value: unknown) => JSON.stringify(stableValue(value)); + +const documentFor = ( + input: CompilerResolvedSurfaceInput, + config: WebRendererConfig, +): { readonly document: string } | RendererBuildFailure => { + const root = input.surface.contentNodes[input.surface.rootFrameId]; + if (!root || root.kind !== "frame" || root.parentId !== null || root.layout.kind !== "absolute") + return failure( + "unsupported-structured-tree", + "Structured rendering requires an absolute root Frame.", + ["surface", "rootFrameId"], + ); + if (!input.plan.contentNodeIds.includes(root.id)) + return failure("unsupported-structured-tree", "Render plan must include the root Frame.", [ + "plan", + "contentNodeIds", + ]); + const planned = new Set(input.plan.contentNodeIds); + const children = [...root.children]; + if (new Set(children).size !== children.length) + return failure("unsupported-structured-tree", "Root Frame children must be unique.", [ + "surface", + "contentNodes", + root.id, + "children", + ]); + if ( + children.some((id) => !planned.has(id)) || + [...planned].some((id) => id !== root.id && !children.includes(id)) + ) + return failure( + "unsupported-structured-tree", + "Initial renderer accepts only root Frame direct children.", + ["plan", "contentNodeIds"], + ); + + const bounds = input.plan.logicalBounds; + const xScale = input.context.pixelTarget[0] / bounds.width; + const yScale = input.context.pixelTarget[1] / bounds.height; + if (!finite(xScale) || !finite(yScale)) + return failure("invalid-render-scale", "Render scale must remain finite.", [ + "plan", + "logicalBounds", + ]); + const textNodes: string[] = []; + for (const id of children) { + const node = input.surface.contentNodes[id]; + if ( + !node || + node.kind !== "text" || + node.parentId !== root.id || + node.placement.kind !== "absolute" + ) + return failure( + "unsupported-structured-tree", + "Initial renderer accepts direct absolute Text children only.", + ["surface", "contentNodes", id], + ); + const placement = node.placement; + if ( + placement.x < bounds.x || + placement.y < bounds.y || + placement.x + placement.width > bounds.x + bounds.width || + placement.y + placement.height > bounds.y + bounds.height + ) + return failure( + "text-outside-render-surface", + "Text placement must fit inside the Render Surface bounds.", + ["surface", "contentNodes", id, "placement"], + ); + const [left, top, width, height] = [ + (placement.x - bounds.x) * xScale, + (placement.y - bounds.y) * yScale, + placement.width * xScale, + placement.height * yScale, + ]; + if (![left, top, width, height].every(finite)) + return failure("invalid-render-geometry", "Scaled render geometry must remain finite.", [ + "surface", + "contentNodes", + id, + "placement", + ]); + textNodes.push( + `
${escapeHtml(node.text)}
`, + ); + } + const [red, green, blue, alpha] = config.documentBackground; + const style = `html,body{margin:0;width:100%;height:100%;overflow:hidden}#surface{position:relative;width:${input.context.pixelTarget[0]}px;height:${input.context.pixelTarget[1]}px;background:rgba(${red},${green},${blue},${cssNumber(alpha / 255)});font-family:${cssString(config.fontFamily)};color-scheme:${input.context.colorScheme}}.text{position:absolute;overflow:hidden;white-space:pre-wrap;box-sizing:border-box}`; + return Object.freeze({ + document: `
${textNodes.join("")}
`, + }); +}; + +const snapshotCapture = ( + value: unknown, + pixelTarget: readonly [number, number], +): BrowserRgbaCapture | undefined => { + const record = snapshotStrictRecord(value, ["alphaMode", "colorSpace", "pixelSize", "rgba"]); + const pixelSize = record && snapshotDenseArray(record.pixelSize, 2); + const rgba = record && copyRgba(record.rgba); + if (!record || !pixelSize || !rgba) return undefined; + const parsed = browserCaptureSchemaFor(pixelTarget).safeParse({ ...record, pixelSize, rgba }); + if (!parsed.success) return undefined; + const [width, height] = parsed.data.pixelSize; + return { + rgba, + pixelSize: [width, height], + colorSpace: "srgb", + alphaMode: parsed.data.alphaMode, + }; +}; + +export const createBakedWebRenderer = (options: CreateBakedWebRendererOptions): RendererPlugin => { + const optionsRecord = snapshotStrictRecord(options, ["adapter", "config"]); + const parsedOptions = createBakedWebRendererOptionsSchema.safeParse(optionsRecord); + const adapter = parsedOptions.success ? parsedOptions.data.adapter : undefined; + const config = parsedOptions.success ? parsedOptions.data.config : undefined; + const initialAdapter = snapshotStrictRecord(adapter, ["capture", "environment", "identity"]); + const parsedCapture = adapterCaptureSchema.safeParse(initialAdapter?.capture); + const fixedCapture = parsedCapture.success + ? (initialAdapter?.capture as FixedBrowserAdapter["capture"]) + : undefined; + const environment = snapshotEnvironment(initialAdapter?.environment); + const adapterIdentity = snapshotAdapterIdentity(initialAdapter?.identity); + const resolvedConfig = snapshotConfig(config); + const configHash = resolvedConfig ? configHashFromSnapshot(resolvedConfig) : undefined; + const adapterReceiver = + environment && adapterIdentity && fixedCapture + ? Object.freeze({ identity: adapterIdentity, environment, capture: fixedCapture }) + : undefined; + const implementationHash = + environment && adapterIdentity + ? hash({ + renderer: "unframe-baked-web", + version: RENDERER_VERSION, + environment: normalizedEnvironment(environment), + adapter: adapterIdentity, + }) + : "sha256:invalid-browser-environment"; + const identity = Object.freeze({ + id: "baked-web", + version: RENDERER_VERSION, + contractVersion: CONTRACT_VERSION, + implementationHash, + }); + const validatorPlugin: RendererPlugin = { + identity, + capabilities, + support: (request: RendererSupportRequest) => evaluateFirstMilestoneSupport(request), + build: () => failure("renderer-not-invoked", "Validation must not invoke build."), + }; + const build = async (rawInput: CompilerResolvedSurfaceInput): Promise => { + try { + const prepared = prepareRendererBuildInput(rawInput, validatorPlugin); + if (!prepared.valid) return { ok: false, diagnostics: [...prepared.diagnostics] }; + const input = prepared.value; + const support = evaluateFirstMilestoneSupport({ + entry: input.entry, + resolvedIntent: input.resolvedIntent, + }); + if (!support.supported) return { ok: false, diagnostics: support.diagnostics }; + if (!environment || !adapterIdentity || !fixedCapture || !adapterReceiver) + return failure( + "invalid-browser-environment", + "Browser adapter must provide the fixed environment contract.", + ["adapter", "environment"], + ); + if (!configHash) + return failure("invalid-renderer-config", "Renderer config is invalid.", ["config"]); + if (input.context.rendererConfigHash !== configHash) + return failure( + "renderer-config-hash-mismatch", + "Compiler context must use this renderer config hash.", + ["context", "rendererConfigHash"], + ); + if ( + input.context.locale !== environment.locale || + input.context.timezone !== environment.timezone + ) + return failure( + "browser-environment-context-mismatch", + "Compiler locale/timezone must match the fixed Browser environment.", + ["context"], + ); + if (resolvedConfig === undefined) + return failure("invalid-renderer-config", "Renderer config is invalid.", ["config"]); + const rendered = documentFor(input, resolvedConfig); + if ("ok" in rendered) return rendered; + const captureStateIds = Object.keys(input.plan.states) + .filter((stateId) => input.plan.states[stateId]?.kind === "capture") + .sort(compare); + const firstSemanticState = captureStateIds[0]; + if ( + firstSemanticState !== undefined && + captureStateIds.some( + (stateId) => + stableSnapshot(input.semanticsByState[stateId]) !== + stableSnapshot(input.semanticsByState[firstSemanticState]), + ) + ) + return failure( + "unsupported-state-visual-variation", + "Initial Structured rendering cannot represent differing capture-state semantics.", + ["semanticsByState"], + ); + const captures: RawSurfaceCapture[] = []; + const hitRegionsByState: Record = Object.create(null) as Record< + string, + readonly [] + >; + for (const stateId of Object.keys(input.plan.states).sort(compare)) { + const state = input.plan.states[stateId]; + if (!state) + return failure("renderer-invalid-input", "Render state plan is invalid.", [ + "plan", + "states", + stateId, + ]); + hitRegionsByState[stateId] = []; + if (state.kind === "empty") continue; + const request: BrowserCaptureRequest = Object.freeze({ + stateId, + document: rendered.document, + pixelTarget: Object.freeze([...input.context.pixelTarget]) as readonly [number, number], + colorScheme: input.context.colorScheme, + environment, + capabilities: Object.freeze({ + network: "deny", + filesystem: "deny", + clock: "fixed", + random: "fixed", + deviceScaleFactor: 1, + colorSpace: "srgb", + }), + }); + let rawCapture: unknown; + try { + rawCapture = await applyFunction(fixedCapture, adapterReceiver, [request]); + } catch { + return failure("browser-capture-failed", "Fixed Browser capture failed.", [ + "states", + stateId, + ]); + } + const capture = snapshotCapture(rawCapture, input.context.pixelTarget); + if (!capture) + return failure( + "invalid-browser-capture", + "Browser capture must be RGBA sRGB at the requested pixel size.", + ["states", stateId], + ); + captures.push({ + id: `web:${encodeURIComponent(input.plan.id)}:${encodeURIComponent(stateId)}`, + stateId, + rgba: capture.rgba, + pixelSize: capture.pixelSize, + colorSpace: capture.colorSpace, + alphaMode: capture.alphaMode, + }); + } + const provenance = { + ...identity, + inputHash: input.context.inputHash, + buildContextHash: input.context.buildContextHash, + environmentHash: input.context.environmentHash, + rendererConfigHash: input.context.rendererConfigHash, + rendererFingerprint: input.context.rendererFingerprint, + }; + return { + ok: true, + renderSurface: { + id: input.plan.id, + semanticSurfaceId: input.plan.semanticSurfaceId, + logicalBounds: input.plan.logicalBounds, + layer: input.plan.layer, + }, + captures, + hitRegionsByState, + provenance, + diagnostics: [], + }; + } catch { + return failure("renderer-invalid-input", "Renderer input is invalid.", []); + } + }; + return Object.freeze({ + identity, + capabilities, + support: (request: RendererSupportRequest) => evaluateFirstMilestoneSupport(request), + build, + }); +}; diff --git a/packages/presentation-renderer-web/src/validation/safe-data.ts b/packages/presentation-renderer-web/src/validation/safe-data.ts new file mode 100644 index 0000000..4f1e689 --- /dev/null +++ b/packages/presentation-renderer-web/src/validation/safe-data.ts @@ -0,0 +1,94 @@ +const compareStrings = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0); + +const typedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype); +const typedArrayByteLength = Object.getOwnPropertyDescriptor( + typedArrayPrototype, + "byteLength", +)?.get; +const typedArrayTag = Object.getOwnPropertyDescriptor(typedArrayPrototype, Symbol.toStringTag)?.get; + +export const copyUint8Array = (value: unknown): Uint8Array | undefined => { + try { + if (!ArrayBuffer.isView(value) || !typedArrayByteLength || !typedArrayTag) return undefined; + if (typedArrayTag.call(value) !== "Uint8Array") return undefined; + const byteLength = typedArrayByteLength.call(value); + if (!Number.isSafeInteger(byteLength) || byteLength < 0) return undefined; + const copy = new Uint8Array(byteLength); + Uint8Array.prototype.set.call(copy, value as Uint8Array); + return copy; + } catch { + return undefined; + } +}; + +export const snapshotStrictRecord = ( + value: unknown, + expectedKeys: readonly string[], +): Record | undefined => { + try { + if ( + typeof value !== "object" || + value === null || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype || + Object.getOwnPropertySymbols(value).length !== 0 + ) + return undefined; + const descriptors: Record = Object.getOwnPropertyDescriptors(value); + const actualKeys = Object.keys(descriptors).sort(compareStrings); + const keys = [...expectedKeys].sort(compareStrings); + if (actualKeys.length !== keys.length || actualKeys.some((key, index) => key !== keys[index])) + return undefined; + if ( + keys.some((key) => { + const descriptor = descriptors[key]; + return !descriptor || descriptor.get !== undefined || descriptor.set !== undefined; + }) + ) + return undefined; + return Object.fromEntries(keys.map((key) => [key, descriptors[key]?.value])); + } catch { + return undefined; + } +}; + +export const snapshotDenseArray = ( + value: unknown, + expectedLength?: number, +): readonly unknown[] | undefined => { + try { + if ( + !Array.isArray(value) || + Object.getPrototypeOf(value) !== Array.prototype || + Object.getOwnPropertySymbols(value).length !== 0 + ) + return undefined; + const descriptors: Record = Object.getOwnPropertyDescriptors(value); + const length = descriptors["length"]?.value; + if ( + !Number.isSafeInteger(length) || + length < 0 || + (expectedLength !== undefined && length !== expectedLength) + ) + return undefined; + const keys = Array.from({ length }, (_, index) => String(index)); + const actualKeys = Object.keys(descriptors); + if ( + actualKeys.length !== keys.length + 1 || + actualKeys.some((key, index) => key !== (keys[index] ?? "length")) || + keys.some((key) => { + const descriptor = descriptors[key]; + return ( + !descriptor || + descriptor.get !== undefined || + descriptor.set !== undefined || + descriptor.enumerable !== true + ); + }) + ) + return undefined; + return Object.freeze(keys.map((key) => descriptors[key]?.value)); + } catch { + return undefined; + } +}; diff --git a/packages/presentation-renderer-web/src/validation/schemas.ts b/packages/presentation-renderer-web/src/validation/schemas.ts new file mode 100644 index 0000000..e81c856 --- /dev/null +++ b/packages/presentation-renderer-web/src/validation/schemas.ts @@ -0,0 +1,77 @@ +import * as z from "zod"; + +const nonEmptyStringSchema = z.string().min(1); +const rgbaByteSchema = z.number().int().min(0).max(255); + +export const adapterIdentitySchema = z.strictObject({ + id: nonEmptyStringSchema, + implementationHash: nonEmptyStringSchema, +}); + +export const adapterCaptureSchema = z.function(); + +export const createBakedWebRendererOptionsSchema = z.strictObject({ + adapter: z.unknown(), + config: z.unknown(), +}); + +export const fixedBrowserEnvironmentSchema = z.strictObject({ + browser: z.strictObject({ + id: nonEmptyStringSchema, + version: nonEmptyStringSchema, + fontFingerprint: nonEmptyStringSchema, + }), + locale: nonEmptyStringSchema, + timezone: nonEmptyStringSchema, + colorSpace: z.literal("srgb"), + deviceScaleFactor: z.literal(1), + network: z.literal("deny"), + filesystem: z.literal("deny"), + clock: z.literal("fixed"), + random: z.literal("fixed"), +}); + +export const webRendererConfigSchema = z.strictObject({ + documentBackground: z.tuple([rgbaByteSchema, rgbaByteSchema, rgbaByteSchema, rgbaByteSchema]), + fontFamily: z + .string() + .trim() + .min(1) + .regex(/^[A-Za-z0-9 _-]+$/), +}); + +export const browserCaptureSchema = z + .strictObject({ + rgba: z.instanceof(Uint8Array), + pixelSize: z.tuple([z.number().int().positive(), z.number().int().positive()]), + colorSpace: z.literal("srgb"), + alphaMode: z.enum(["opaque", "straight"]), + }) + .superRefine((capture, context) => { + const [width, height] = capture.pixelSize; + if (capture.rgba.byteLength !== width * height * 4) + context.addIssue({ + code: "custom", + path: ["rgba"], + message: "RGBA byte length must match pixel size.", + }); + if ( + capture.alphaMode === "opaque" && + capture.rgba.some((_, index) => index % 4 === 3 && capture.rgba[index] !== 255) + ) + context.addIssue({ + code: "custom", + path: ["rgba"], + message: "Opaque capture alpha bytes must be 255.", + }); + }); + +export const browserCaptureSchemaFor = (pixelTarget: readonly [number, number]) => + browserCaptureSchema.superRefine((capture, context) => { + if (capture.pixelSize[0] !== pixelTarget[0] || capture.pixelSize[1] !== pixelTarget[1]) + context.addIssue({ + code: "custom", + path: ["pixelSize"], + message: "Capture pixel size must match the requested target.", + }); + }); diff --git a/packages/presentation-renderer-web/test/opaque-bundle.test.ts b/packages/presentation-renderer-web/test/opaque-bundle.test.ts new file mode 100644 index 0000000..8bbdafc --- /dev/null +++ b/packages/presentation-renderer-web/test/opaque-bundle.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest"; + +import { bundleOpaqueRenderer } from "../src/index.js"; + +const module = ( + path: string, + source: string, + moduleType: "asset" | "css" | "js" | "jsx" | "json" | "ts" | "tsx" = "ts", +) => ({ path, source, moduleType }); + +describe("bundleOpaqueRenderer", () => { + it("bundles locked relative TSX, CSS, assets, React, and the fixed renderer runtime", async () => { + const result = await bundleOpaqueRenderer({ + entry: "src/renderer.tsx", + modules: [ + module( + "src/renderer.tsx", + [ + 'import React from "react";', + 'import { defineOpaqueRenderer } from "@unframe/renderer-runtime";', + 'import { label } from "./label";', + 'import "./style.css";', + 'import iconUrl from "./icon.svg";', + "export { React };", + "export default defineOpaqueRenderer(() => {label});", + ].join("\n"), + "tsx", + ), + module("src/label.ts", 'export const label: string = "locked";'), + module("src/style.css", '.root { background-image: url("./icon.svg"); }', "css"), + module("src/icon.svg", '', "asset"), + ], + }); + + expect(result.ok ? [] : result.diagnostics).toEqual([]); + if (!result.ok) return; + expect(result.javascript).toContain("locked"); + expect(result.externalImports).toEqual(["react", "react/jsx-runtime"]); + expect(result.assets.map((asset) => asset.fileName)).toEqual( + expect.arrayContaining([expect.stringMatching(/\.css$/), expect.stringMatching(/\.svg$/)]), + ); + }); + + it.each(["node:fs", "/etc/passwd", "https://example.com/x.js", "lodash", "react-dom"])( + "denies the unapproved import %s", + async (specifier) => { + const result = await bundleOpaqueRenderer({ + entry: "renderer.ts", + modules: [ + module( + "renderer.ts", + `import value from ${JSON.stringify(specifier)}; export { value };`, + ), + ], + }); + + expect(result).toMatchObject({ + ok: false, + diagnostics: [{ code: "opaque-import-denied", path: ["renderer.ts", specifier] }], + }); + }, + ); + + it("denies package traversal and unresolved relative modules", async () => { + for (const specifier of ["../outside.ts", "./missing.ts"]) + await expect( + bundleOpaqueRenderer({ + entry: "renderer.ts", + modules: [module("renderer.ts", `import ${JSON.stringify(specifier)};`)], + }), + ).resolves.toMatchObject({ + ok: false, + diagnostics: [ + { + code: + specifier === "../outside.ts" ? "opaque-import-denied" : "opaque-module-not-found", + }, + ], + }); + }); + + it("denies network and untracked references from package CSS", async () => { + for (const reference of ["https://example.com/image.png", "./missing.png"]) + await expect( + bundleOpaqueRenderer({ + entry: "renderer.ts", + modules: [ + module("renderer.ts", 'import "./style.css";'), + module("style.css", `.root { background: url(${JSON.stringify(reference)}); }`, "css"), + ], + }), + ).resolves.toMatchObject({ + ok: false, + diagnostics: [{ code: "opaque-import-denied", path: ["style.css", reference] }], + }); + }); + + it("produces the same output independently of module input order", async () => { + const modules = [ + module("renderer.ts", 'import { value } from "./value"; export default value;'), + module("value.ts", 'export const value = "stable";'), + ]; + const first = await bundleOpaqueRenderer({ entry: "renderer.ts", modules }); + const second = await bundleOpaqueRenderer({ + entry: "renderer.ts", + modules: [...modules].reverse(), + }); + + expect(first).toEqual(second); + expect(first).toMatchObject({ ok: true }); + }); + + it("preserves a defensive copy of binary asset bytes", async () => { + const source = Uint8Array.of(0x00, 0x80, 0xff, 0x7f); + const resultPromise = bundleOpaqueRenderer({ + entry: "renderer.ts", + modules: [ + module("renderer.ts", 'import image from "./image.png"; export default image;'), + { path: "image.png", source, moduleType: "asset" }, + ], + }); + source.fill(0); + + const result = await resultPromise; + + expect(result.ok ? [] : result.diagnostics).toEqual([]); + if (!result.ok) return; + const asset = result.assets.find((item) => item.fileName.endsWith(".png")); + expect(asset?.source).toEqual(Uint8Array.of(0x00, 0x80, 0xff, 0x7f)); + }); + + it("rejects binary source for executable modules", async () => { + await expect( + bundleOpaqueRenderer({ + entry: "renderer.ts", + modules: [{ path: "renderer.ts", source: Uint8Array.of(1), moduleType: "ts" }], + }), + ).resolves.toMatchObject({ + ok: false, + diagnostics: [{ code: "opaque-bundle-input-invalid" }], + }); + }); + + it("keeps hostile input objects on the diagnostic boundary", async () => { + const input = new Proxy( + {}, + { + getPrototypeOf() { + throw new Error("must not escape"); + }, + }, + ); + + await expect(bundleOpaqueRenderer(input)).resolves.toMatchObject({ + ok: false, + diagnostics: [{ code: "opaque-bundle-input-invalid" }], + }); + }); + + it("reports the invalid field path from schema validation", async () => { + await expect( + bundleOpaqueRenderer({ + entry: "renderer.ts", + modules: [module("renderer.ts", "export default {};", "css")], + }), + ).resolves.toMatchObject({ + ok: false, + diagnostics: [ + { + code: "opaque-bundle-input-invalid", + path: ["modules", "0", "moduleType"], + }, + ], + }); + }); + + it("does not execute accessors before schema validation", async () => { + let reads = 0; + const modules: unknown[] = []; + Object.defineProperty(modules, "0", { + enumerable: true, + get() { + reads++; + return module("renderer.ts", "export default {};"); + }, + }); + modules.length = 1; + + await expect(bundleOpaqueRenderer({ entry: "renderer.ts", modules })).resolves.toMatchObject({ + ok: false, + diagnostics: [{ code: "opaque-bundle-input-invalid" }], + }); + expect(reads).toBe(0); + }); + + it("does not accept runtime plugin injection or configuration module types", async () => { + await expect( + bundleOpaqueRenderer({ + entry: "renderer.ts", + modules: [module("renderer.ts", "export default {};"), module("theme.scss", "")], + plugins: [{ name: "untrusted" }], + } as never), + ).resolves.toMatchObject({ + ok: false, + diagnostics: [{ code: "opaque-bundle-input-invalid" }], + }); + }); +}); diff --git a/packages/presentation-renderer-web/test/playwright-fixed-browser.integration.test.ts b/packages/presentation-renderer-web/test/playwright-fixed-browser.integration.test.ts new file mode 100644 index 0000000..7b16cbc --- /dev/null +++ b/packages/presentation-renderer-web/test/playwright-fixed-browser.integration.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { openPlaywrightFixedBrowser } from "../src/index.js"; + +describe("Playwright Fixed Browser integration", () => { + it("provision済みmanaged ChromiumでFrame/Text相当のdocumentをPNG captureする", async () => { + const session = await openPlaywrightFixedBrowser(); + const secondSession = await openPlaywrightFixedBrowser(); + try { + const request = { + stateId: "default", + document: + '', + pixelTarget: [2, 1], + colorScheme: "light", + environment: session.environment, + capabilities: { + network: "deny", + filesystem: "deny", + clock: "fixed", + random: "fixed", + deviceScaleFactor: 1, + colorSpace: "srgb", + }, + } as const; + const capture = await session.capture(request); + const repeated = await session.capture(request); + expect(capture.pixelSize).toEqual([2, 1]); + expect(capture.rgba).toHaveLength(8); + expect(capture.colorSpace).toBe("srgb"); + expect(session.environment.browser.fontFingerprint).toMatch(/^sha256:/); + expect(secondSession.environment.browser.fontFingerprint).toBe( + session.environment.browser.fontFingerprint, + ); + expect(repeated.rgba).toEqual(capture.rgba); + expect(Array.from(capture.rgba.slice(0, 4))).not.toEqual([255, 0, 0, 255]); + } finally { + await session.close(); + await secondSession.close(); + } + }); +}); diff --git a/packages/presentation-renderer-web/test/playwright-fixed-browser.test.ts b/packages/presentation-renderer-web/test/playwright-fixed-browser.test.ts new file mode 100644 index 0000000..e1b018b --- /dev/null +++ b/packages/presentation-renderer-web/test/playwright-fixed-browser.test.ts @@ -0,0 +1,447 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createPlaywrightFixedBrowserFactory, + type BrowserDriver, +} from "../src/browser/playwright-fixed-browser.js"; +import type { FixedBrowserSession } from "../src/index.js"; + +const request = { + stateId: "default", + document: "capture", + pixelTarget: [2, 1] as const, + colorScheme: "dark" as const, + environment: { + browser: { + id: "playwright-chromium", + version: "123.4.5", + fontFingerprint: "sha256:477649c3440112aa6ceb0ec1967fd774def2a28b041fcd29030879d6f32ef398", + }, + locale: "ja-JP", + timezone: "Asia/Tokyo", + colorSpace: "srgb" as const, + deviceScaleFactor: 1 as const, + network: "deny" as const, + filesystem: "deny" as const, + clock: "fixed" as const, + random: "fixed" as const, + }, + capabilities: { + network: "deny" as const, + filesystem: "deny" as const, + clock: "fixed" as const, + random: "fixed" as const, + deviceScaleFactor: 1 as const, + colorSpace: "srgb" as const, + }, +}; + +const png = Uint8Array.of( + 137, + 80, + 78, + 71, + 13, + 10, + 26, + 10, + 0, + 0, + 0, + 13, + 73, + 72, + 68, + 82, + 0, + 0, + 0, + 2, + 0, + 0, + 0, + 1, + 8, + 6, + 0, + 0, + 0, + 244, + 34, + 127, + 138, + 0, + 0, + 0, + 17, + 73, + 68, + 65, + 84, + 120, + 1, + 99, + 248, + 207, + 192, + 240, + 31, + 132, + 25, + 0, + 15, + 248, + 3, + 253, + 19, + 109, + 42, + 112, + 0, + 0, + 0, + 0, + 73, + 69, + 78, + 68, + 174, + 66, + 96, + 130, +); + +const probePng = Uint8Array.from(png); +probePng.set([0, 0, 1, 0], 16); +probePng.set([0, 0, 0, 64], 20); + +const driver = (probeValue = 0) => { + let screenshotCount = 0; + const page = { + setContent: vi.fn(async () => undefined), + evaluate: vi.fn(async () => undefined), + screenshot: vi.fn(async () => (screenshotCount++ === 0 ? probePng : png)), + }; + const context = { + addInitScript: vi.fn(async () => undefined), + route: vi.fn(async () => undefined), + newPage: vi.fn(async () => page), + close: vi.fn(async () => undefined), + }; + const browser = { + version: vi.fn(() => "123.4.5"), + newContext: vi.fn(async () => context), + close: vi.fn(async () => undefined), + }; + const value: BrowserDriver = { + launch: vi.fn(async () => browser), + decodePng: vi.fn((bytes) => { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const width = view.getUint32(16); + const height = view.getUint32(20); + if (width === 256 && height === 64) { + const data = new Uint8Array(width * height * 4); + for (let index = 0; index < data.length; index += 4) { + data[index] = probeValue; + data[index + 3] = 255; + } + return { width, height, data }; + } + return { + width: 2, + height: 1, + data: Uint8Array.of(1, 2, 3, 255, 4, 5, 6, 255), + }; + }), + }; + return { value, page, context, browser }; +}; + +const captureRequest = (session: FixedBrowserSession) => ({ + ...request, + environment: session.environment, +}); + +describe("Playwright Fixed Browser", () => { + it("起動中のabortでsessionを公開せず、遅れて起動したbrowserも閉じる", async () => { + const fake = driver(); + let finishLaunch: (() => void) | undefined; + fake.value.launch = vi.fn( + () => + new Promise((resolve) => { + finishLaunch = () => resolve(fake.browser); + }), + ); + const controller = new AbortController(); + const opening = createPlaywrightFixedBrowserFactory(fake.value)({ signal: controller.signal }); + + controller.abort(); + await expect(opening).rejects.toMatchObject({ name: "AbortError" }); + finishLaunch?.(); + await vi.waitFor(() => expect(fake.browser.close).toHaveBeenCalledOnce()); + expect(fake.browser.newContext).not.toHaveBeenCalled(); + }); + + it("managed Chromiumだけを固定環境で起動し、隔離contextからRGBAをcaptureする", async () => { + const fake = driver(); + const session = await createPlaywrightFixedBrowserFactory(fake.value)(); + + expect(fake.value.launch).toHaveBeenCalledWith({ + headless: true, + chromiumSandbox: true, + handleSIGINT: false, + handleSIGTERM: false, + handleSIGHUP: false, + }); + expect(session.environment).toMatchObject({ + browser: { + id: "playwright-chromium", + version: "123.4.5", + fontFingerprint: expect.stringMatching(/^sha256:/), + }, + locale: "ja-JP", + timezone: "Asia/Tokyo", + deviceScaleFactor: 1, + }); + + await expect(session.capture(captureRequest(session))).resolves.toEqual({ + rgba: Uint8Array.of(1, 2, 3, 255, 4, 5, 6, 255), + pixelSize: [2, 1], + colorSpace: "srgb", + alphaMode: "opaque", + }); + expect(fake.browser.newContext).toHaveBeenCalledWith({ + viewport: { width: 2, height: 1 }, + deviceScaleFactor: 1, + locale: "ja-JP", + timezoneId: "Asia/Tokyo", + colorScheme: "dark", + offline: true, + acceptDownloads: false, + serviceWorkers: "block", + }); + expect(fake.context.addInitScript).toHaveBeenCalledTimes(2); + const init = fake.context.addInitScript.mock.calls[1] as unknown as [string]; + expect(init[0]).toContain("946684800000"); + expect(init[0]).toContain("0x5eed"); + expect(init[0]).toContain("performance"); + expect(init[0]).toContain("getRandomValues"); + expect(init[0]).toContain("randomUUID"); + expect(init[0]).toContain("Web Crypto is unavailable"); + expect(fake.context.route).toHaveBeenCalledWith("**/*", expect.any(Function)); + expect(fake.page.setContent).toHaveBeenCalledWith(request.document, { waitUntil: "load" }); + expect(fake.page.evaluate).toHaveBeenCalledTimes(2); + expect(fake.page.screenshot).toHaveBeenCalledWith({ + type: "png", + scale: "css", + omitBackground: false, + animations: "disabled", + caret: "hide", + }); + expect(fake.value.decodePng).toHaveBeenCalledWith(png); + expect(fake.context.close).toHaveBeenCalledTimes(2); + await session.close(); + }); + + it("abortとcloseで進行中contextを閉じ、browserを一度だけ閉じる", async () => { + const fake = driver(); + let unblockScreenshot: (() => void) | undefined; + const session = await createPlaywrightFixedBrowserFactory(fake.value)(); + fake.page.screenshot.mockImplementation( + () => + new Promise>((resolve) => { + unblockScreenshot = () => resolve(new Uint8Array(png)); + }), + ); + const controller = new AbortController(); + const capture = session.capture(captureRequest(session), { signal: controller.signal }); + await vi.waitFor(() => expect(fake.page.screenshot).toHaveBeenCalledOnce()); + controller.abort(); + await expect(capture).rejects.toMatchObject({ name: "AbortError" }); + expect(fake.context.close).toHaveBeenCalledTimes(2); + + const second = session.capture(captureRequest(session)); + await vi.waitFor(() => expect(fake.page.screenshot).toHaveBeenCalledTimes(2)); + const closing = session.close(); + await expect(second).rejects.toMatchObject({ name: "AbortError" }); + await Promise.all([closing, session.close()]); + expect(fake.browser.close).toHaveBeenCalledOnce(); + unblockScreenshot?.(); + }); + + it("壊れたPNGをcaptureとして公開せず、失敗してもcontextを閉じる", async () => { + const fake = driver(); + const session = await createPlaywrightFixedBrowserFactory(fake.value)(); + fake.value.decodePng = vi.fn(() => ({ + width: 1, + height: 1, + data: Uint8Array.of(0, 0, 0, 255), + })); + await expect(session.capture(captureRequest(session))).rejects.toThrow( + "PNG dimensions must match", + ); + expect(fake.context.close).toHaveBeenCalledTimes(2); + await session.close(); + }); + + it("context cleanupが失敗してもsession closeはbrowser closeを試みる", async () => { + const fake = driver(); + const session = await createPlaywrightFixedBrowserFactory(fake.value)(); + fake.page.screenshot.mockImplementation( + () => new Promise>(() => undefined), + ); + fake.context.close.mockRejectedValue(new Error("context close failed")); + fake.browser.close.mockRejectedValue(new Error("browser close failed")); + const capture = session.capture(captureRequest(session)); + await vi.waitFor(() => expect(fake.page.screenshot).toHaveBeenCalledOnce()); + await expect(session.close()).rejects.toThrow("context close failed"); + await expect(capture).rejects.toThrow("context close failed"); + expect(fake.browser.close).toHaveBeenCalledOnce(); + }); + + it("固定profile以外のrequestを起動前に拒否し、transparent PNGはstraight RGBAとして返す", async () => { + const fake = driver(); + const session = await createPlaywrightFixedBrowserFactory(fake.value)(); + fake.value.decodePng = vi.fn(() => ({ + width: 2, + height: 1, + data: Uint8Array.of(1, 2, 3, 128, 4, 5, 6, 255), + })); + await expect(session.capture(captureRequest(session))).resolves.toMatchObject({ + alphaMode: "straight", + }); + await expect( + session.capture({ + ...captureRequest(session), + environment: { ...session.environment, locale: "en-US" }, + }), + ).rejects.toThrow("fixed Browser environment"); + await expect( + session.capture({ + ...captureRequest(session), + environment: { + ...session.environment, + browser: { ...session.environment.browser, fontFingerprint: "sha256:forged" }, + }, + }), + ).rejects.toThrow("fixed Browser environment"); + await expect( + session.capture({ ...captureRequest(session), pixelTarget: [0, 1] }), + ).rejects.toThrow("fixed Browser environment"); + expect(fake.browser.newContext).toHaveBeenCalledTimes(2); + await session.close(); + }); + + it("trust-boundaryの4096px/16,777,216px上限をcapture前に適用する", async () => { + const fake = driver(); + const session = await createPlaywrightFixedBrowserFactory(fake.value)(); + await expect( + session.capture({ ...captureRequest(session), pixelTarget: [4097, 1] }), + ).rejects.toThrow("fixed Browser environment"); + await expect( + session.capture({ ...captureRequest(session), pixelTarget: [4096, 4096] }), + ).rejects.toThrow("PNG dimensions must match"); + expect(fake.browser.newContext).toHaveBeenCalledWith( + expect.objectContaining({ viewport: { width: 4096, height: 4096 } }), + ); + await session.close(); + }); + + it("font probeの異なるactual bytesは異なるfingerprintになる", async () => { + const fake = driver(); + const first = await createPlaywrightFixedBrowserFactory(fake.value)(); + const different = driver(9); + const second = await createPlaywrightFixedBrowserFactory(different.value)(); + expect(first.environment.browser.fontFingerprint).not.toBe( + second.environment.browser.fontFingerprint, + ); + await Promise.all([first.close(), second.close()]); + }); + + it("不完全なfont probeをidentityとして公開せずcontextとbrowserを閉じる", async () => { + const fake = driver(); + fake.value.decodePng = vi.fn(() => ({ + width: 2, + height: 1, + data: Uint8Array.of(1, 2, 3, 255, 4, 5, 6, 255), + })); + + await expect(createPlaywrightFixedBrowserFactory(fake.value)()).rejects.toThrow( + "complete baseline RGBA", + ); + expect(fake.context.close).toHaveBeenCalledOnce(); + expect(fake.browser.close).toHaveBeenCalledOnce(); + }); + + it("不正なcolor schemeとAbortSignalをcontext作成前に拒否する", async () => { + const fake = driver(); + const session = await createPlaywrightFixedBrowserFactory(fake.value)(); + await expect( + session.capture({ ...captureRequest(session), colorScheme: "contrast" as never }), + ).rejects.toThrow("fixed Browser environment"); + await expect( + session.capture(captureRequest(session), { signal: {} as AbortSignal }), + ).rejects.toThrow("fixed Browser environment"); + expect(fake.browser.newContext).toHaveBeenCalledOnce(); + await session.close(); + }); + + it("PNG preflightはinjected decoder前にencoded bytesとIHDR上限を拒否する", async () => { + const fake = driver(); + const session = await createPlaywrightFixedBrowserFactory(fake.value)(); + fake.page.screenshot.mockResolvedValueOnce(new Uint8Array(65 * 1024 * 1024 + 1)); + await expect(session.capture(captureRequest(session))).rejects.toThrow("PNG bytes exceed"); + expect(fake.value.decodePng).toHaveBeenCalledOnce(); + + const huge = Uint8Array.from(png); + huge.set([0, 0, 16, 1], 16); + fake.page.screenshot.mockResolvedValueOnce(huge); + await expect(session.capture(captureRequest(session))).rejects.toThrow("PNG dimensions exceed"); + expect(fake.value.decodePng).toHaveBeenCalledOnce(); + + await expect(session.capture(captureRequest(session))).resolves.toMatchObject({ + pixelSize: [2, 1], + }); + expect(fake.value.decodePng).toHaveBeenCalledTimes(2); + await session.close(); + }); + + it("pending newContextをcloseが待ち、context cleanup後にbrowserを閉じる", async () => { + const fake = driver(); + const session = await createPlaywrightFixedBrowserFactory(fake.value)(); + let resolveContext: ((context: typeof fake.context) => void) | undefined; + fake.browser.newContext.mockImplementationOnce( + () => new Promise((resolve) => (resolveContext = resolve)), + ); + const capture = session.capture(captureRequest(session)); + const closing = session.close(); + expect(fake.browser.close).not.toHaveBeenCalled(); + resolveContext?.(fake.context); + await expect(capture).rejects.toMatchObject({ name: "AbortError" }); + await closing; + expect(fake.context.close.mock.invocationCallOrder[1]).toBeLessThan( + fake.browser.close.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER, + ); + }); + + it("複数captureのcleanup失敗でも他contextのcleanup完了後にbrowserを閉じる", async () => { + const fake = driver(); + const session = await createPlaywrightFixedBrowserFactory(fake.value)(); + const second = { ...fake.context, close: vi.fn(async () => undefined) }; + fake.browser.newContext.mockResolvedValueOnce(fake.context).mockResolvedValueOnce(second); + fake.page.screenshot.mockImplementation( + () => new Promise>(() => undefined), + ); + const firstCapture = session.capture(captureRequest(session)); + const secondCapture = session.capture(captureRequest(session)); + await vi.waitFor(() => expect(fake.page.screenshot).toHaveBeenCalledTimes(3)); + fake.context.close.mockRejectedValueOnce(new Error("first cleanup failed")); + await expect(session.close()).rejects.toThrow("first cleanup failed"); + await expect(firstCapture).rejects.toThrow(); + await expect(secondCapture).rejects.toThrow(); + expect(second.close).toHaveBeenCalled(); + expect(second.close.mock.invocationCallOrder[0]).toBeLessThan( + fake.browser.close.mock.invocationCallOrder.at(-1) ?? Number.MAX_SAFE_INTEGER, + ); + }); +}); diff --git a/packages/presentation-renderer-web/test/renderer.test.ts b/packages/presentation-renderer-web/test/renderer.test.ts new file mode 100644 index 0000000..6e6f928 --- /dev/null +++ b/packages/presentation-renderer-web/test/renderer.test.ts @@ -0,0 +1,815 @@ +import { runInNewContext } from "node:vm"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + createBakedWebRenderer, + createWebRendererConfigHash, + type BrowserCaptureRequest, + type FixedBrowserAdapter, + type WebRendererConfig, +} from "../src/index.js"; +import { + createRendererFingerprint, + runRendererConformance, + type CompilerResolvedSurfaceInput, +} from "@unframe/presentation-renderer-api"; + +const config = { + documentBackground: [0, 0, 0, 255], + fontFamily: "Unframe Fixed", +} as const satisfies WebRendererConfig; + +const environment = { + browser: { id: "test-browser", version: "1", fontFingerprint: "sha256:fonts" }, + locale: "ja-JP", + timezone: "Asia/Tokyo", + colorSpace: "srgb", + deviceScaleFactor: 1, + network: "deny", + filesystem: "deny", + clock: "fixed", + random: "fixed", +} as const; +const adapterIdentity = { id: "test-adapter", implementationHash: "sha256:adapter" } as const; + +const inputFor = (rendererConfigHash: string): CompilerResolvedSurfaceInput => { + const identity = { + id: "baked-web", + version: "1", + contractVersion: "1", + implementationHash: "unused", + }; + return { + surface: { + id: "surface", + hostNodeId: "host", + physicalSizeMeters: [1, 1], + logicalSize: [100, 50], + fit: "contain", + rootFrameId: "root", + contentNodes: { + root: { + id: "root", + kind: "frame", + parentId: null, + order: 0, + layout: { kind: "absolute" }, + children: ["text"], + }, + text: { + id: "text", + kind: "text", + parentId: "root", + order: 0, + placement: { kind: "absolute", x: 10, y: 5, width: 40, height: 20 }, + text: "<&>\"'", + }, + }, + baseSemanticTree: { rootNodeIds: [], nodes: {} }, + interactions: {}, + initialStateId: "a", + states: { + a: { id: "a", semanticOverrides: [], enabledInteractionIds: [] }, + z: { id: "z", semanticOverrides: [], enabledInteractionIds: [] }, + }, + renderIntent: { + updateModel: { kind: "static" }, + interaction: { kind: "none" }, + internalAnimation: { kind: "none" }, + rendererPreference: "baked-web", + fallbackPolicy: "reject", + }, + }, + sourceIntent: { + updateModel: { kind: "static" }, + interaction: { kind: "none" }, + internalAnimation: { kind: "none" }, + rendererPreference: "baked-web", + fallbackPolicy: "reject", + }, + resolvedIntent: { + updateModel: { kind: "static" }, + interaction: { kind: "none" }, + internalAnimation: { kind: "none" }, + selectedRendererId: "baked-web", + fallbackPolicy: "reject", + }, + semanticsByState: { a: { rootNodeIds: [], nodes: {} }, z: { rootNodeIds: [], nodes: {} } }, + plan: { + id: "render", + semanticSurfaceId: "surface", + logicalBounds: { x: 0, y: 0, width: 100, height: 50 }, + layer: 0, + contentNodeIds: ["root", "text"], + states: { z: { kind: "capture" }, a: { kind: "capture" } }, + }, + entry: { kind: "structured" }, + context: { + locale: "ja-JP", + timezone: "Asia/Tokyo", + colorScheme: "dark", + themeId: "theme", + themeHash: "sha256:theme", + inputHash: "sha256:input", + buildContextHash: "sha256:context", + environmentHash: "sha256:environment", + rendererConfigHash, + rendererFingerprint: createRendererFingerprint(identity, rendererConfigHash), + pixelTarget: [2, 1], + }, + } as CompilerResolvedSurfaceInput; +}; + +const adapter = (requests: BrowserCaptureRequest[] = []): FixedBrowserAdapter => ({ + identity: adapterIdentity, + environment, + async capture(request) { + requests.push(request); + return { + rgba: new Uint8Array([0, 1, 2, 255, 3, 4, 5, 255]), + pixelSize: request.pixelTarget, + colorSpace: "srgb", + alphaMode: "opaque", + }; + }, +}); + +const withRendererFingerprint = ( + input: CompilerResolvedSurfaceInput, + renderer: ReturnType, +): CompilerResolvedSurfaceInput => ({ + ...input, + context: { + ...input.context, + rendererFingerprint: createRendererFingerprint( + renderer.identity, + input.context.rendererConfigHash, + ), + }, +}); + +describe("baked web renderer", () => { + it("固定環境と設定から決定論的な plugin を作り、capture を状態順に生成する", async () => { + const requests: BrowserCaptureRequest[] = []; + const hash = createWebRendererConfigHash(config); + expect(hash).toBe("sha256:e3a9a16a67ed2193c6871e66fafe5f506a833e7f667d3c80ce69215669921d23"); + const renderer = createBakedWebRenderer({ adapter: adapter(requests), config }); + const input = withRendererFingerprint(inputFor(hash), renderer); + const result = await renderer.build(input); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(requests.map((request) => request.stateId)).toEqual(["a", "z"]); + const firstRequest = requests[0]; + expect(firstRequest).toMatchObject({ + pixelTarget: [2, 1], + colorScheme: "dark", + capabilities: { network: "deny", filesystem: "deny", clock: "fixed", random: "fixed" }, + }); + expect(firstRequest?.document).toBe( + '
<&>"'
', + ); + expect(result.captures.map((capture) => capture.stateId)).toEqual(["a", "z"]); + expect(result.hitRegionsByState).toEqual({ a: [], z: [] }); + expect(result.provenance.implementationHash).toMatch(/^sha256:/); + expect(await runRendererConformance(renderer, [{ name: "web", input }])).toMatchObject({ + valid: true, + }); + }); + + it("adapter へ渡す request を固定し、Compiler input を変更させない", async () => { + const renderer = createBakedWebRenderer({ + adapter: { + identity: adapterIdentity, + environment, + async capture(request) { + expect(Object.isFrozen(request)).toBe(true); + expect(Object.isFrozen(request.pixelTarget)).toBe(true); + expect(Object.isFrozen(request.environment)).toBe(true); + return { + rgba: new Uint8Array([0, 0, 0, 255, 0, 0, 0, 255]), + pixelSize: request.pixelTarget, + colorSpace: "srgb", + alphaMode: "opaque", + }; + }, + }, + config, + }); + const input = withRendererFingerprint(inputFor(createWebRendererConfigHash(config)), renderer); + const before = JSON.stringify(input); + await expect(renderer.build(input)).resolves.toMatchObject({ ok: true }); + expect(JSON.stringify(input)).toBe(before); + }); + + it("作成時の Browser environment と frozen receiver を capture に渡す", async () => { + const mutableEnvironment = { + browser: { id: "test-browser", version: "1", fontFingerprint: "sha256:fonts" }, + locale: "ja-JP", + timezone: "Asia/Tokyo", + colorSpace: "srgb" as const, + deviceScaleFactor: 1 as const, + network: "deny" as const, + filesystem: "deny" as const, + clock: "fixed" as const, + random: "fixed" as const, + }; + const renderer = createBakedWebRenderer({ + adapter: { + identity: adapterIdentity, + environment: mutableEnvironment, + async capture(this: unknown) { + const receiver = this as { readonly environment: FixedBrowserAdapter["environment"] }; + expect(Object.isFrozen(receiver)).toBe(true); + expect(receiver.environment.browser.version).toBe("1"); + return { + rgba: new Uint8Array([0, 0, 0, 255, 0, 0, 0, 255]), + pixelSize: [2, 1], + colorSpace: "srgb", + alphaMode: "opaque", + }; + }, + }, + config, + }); + mutableEnvironment.browser.version = "changed"; + const input = withRendererFingerprint(inputFor(createWebRendererConfigHash(config)), renderer); + await expect(renderer.build(input)).resolves.toMatchObject({ ok: true }); + }); + + it("visual state 差分と unsafe font config を fail closed にする", async () => { + expect(() => + createWebRendererConfigHash({ documentBackground: [0, 0, 0, 255], fontFamily: "x" }), + ).toThrow(); + const renderer = createBakedWebRenderer({ adapter: adapter(), config }); + const source = withRendererFingerprint(inputFor(createWebRendererConfigHash(config)), renderer); + const input: CompilerResolvedSurfaceInput = { + ...source, + semanticsByState: { + ...source.semanticsByState, + z: { + rootNodeIds: ["changed"], + nodes: { + changed: { + id: "changed", + parentId: null, + order: 0, + role: "paragraph", + text: "changed", + }, + }, + }, + }, + }; + await expect(renderer.build(input)).resolves.toMatchObject({ + ok: false, + diagnostics: [{ code: "unsupported-state-visual-variation" }], + }); + }); + + it("factory config と capture bytes の所有権を固定し、cross-realm Uint8Array を受け取る", async () => { + const mutableConfig = { + documentBackground: [0, 0, 0, 255] as [number, number, number, number], + fontFamily: "Unframe Fixed", + }; + const requests: BrowserCaptureRequest[] = []; + const foreignBytes = runInNewContext( + "new Uint8Array([1, 2, 3, 255, 4, 5, 6, 255])", + ) as Uint8Array; + const foreignPixelSize: [number, number] = [2, 1]; + const renderer = createBakedWebRenderer({ + adapter: { + identity: adapterIdentity, + environment, + async capture(request) { + requests.push(request); + return { + rgba: foreignBytes, + pixelSize: foreignPixelSize, + colorSpace: "srgb", + alphaMode: "opaque", + }; + }, + }, + config: mutableConfig, + }); + mutableConfig.documentBackground[0] = 255; + const input = withRendererFingerprint( + inputFor( + createWebRendererConfigHash({ + documentBackground: [0, 0, 0, 255], + fontFamily: "Unframe Fixed", + } as const), + ), + renderer, + ); + const result = await renderer.build(input); + expect(result).toMatchObject({ ok: true }); + if (!result.ok) return; + expect(requests[0]?.document).toContain("background:rgba(0,0,0,1)"); + foreignBytes[0] = 99; + foreignPixelSize[0] = 99; + expect(result.captures[0]?.rgba[0]).toBe(1); + expect(result.captures[0]?.pixelSize).toEqual([2, 1]); + }); + + it("factory 作成時の capture 実装を固定し、premultiplied output を拒否する", async () => { + const mutableAdapter: FixedBrowserAdapter = { + identity: adapterIdentity, + environment, + async capture(request) { + return { + rgba: new Uint8Array([0, 0, 0, 255, 0, 0, 0, 255]), + pixelSize: request.pixelTarget, + colorSpace: "srgb", + alphaMode: "opaque", + }; + }, + }; + const renderer = createBakedWebRenderer({ adapter: mutableAdapter, config }); + mutableAdapter.capture = async () => { + throw new Error("replacement must not run"); + }; + const input = withRendererFingerprint(inputFor(createWebRendererConfigHash(config)), renderer); + await expect(renderer.build(input)).resolves.toMatchObject({ ok: true }); + const premultiplied = createBakedWebRenderer({ + adapter: { + identity: adapterIdentity, + environment, + async capture(request) { + return { + rgba: new Uint8Array([0, 0, 0, 255, 0, 0, 0, 255]), + pixelSize: request.pixelTarget, + colorSpace: "srgb", + alphaMode: "premultiplied", + }; + }, + }, + config, + }); + await expect( + premultiplied.build( + withRendererFingerprint(inputFor(createWebRendererConfigHash(config)), premultiplied), + ), + ).resolves.toMatchObject({ ok: false, diagnostics: [{ code: "invalid-browser-capture" }] }); + }); + + it("capture の mutable call property を参照せず、非有限 scale を拒否する", async () => { + const capture: FixedBrowserAdapter["capture"] = async (request) => ({ + rgba: new Uint8Array([0, 0, 0, 255, 0, 0, 0, 255]), + pixelSize: request.pixelTarget, + colorSpace: "srgb", + alphaMode: "opaque", + }); + Object.defineProperty(capture, "call", { + value: () => Promise.reject(new Error("mutable call must not run")), + }); + const renderer = createBakedWebRenderer({ + adapter: { identity: adapterIdentity, environment, capture }, + config, + }); + const input = withRendererFingerprint(inputFor(createWebRendererConfigHash(config)), renderer); + await expect(renderer.build(input)).resolves.toMatchObject({ ok: true }); + await expect( + renderer.build({ + ...input, + surface: { + ...input.surface, + logicalSize: [Number.MIN_VALUE, 50], + contentNodes: { + ...input.surface.contentNodes, + text: { + id: "text", + kind: "text", + parentId: "root", + order: 0, + placement: { + kind: "absolute", + x: 0, + y: 0, + width: Number.MIN_VALUE, + height: 20, + }, + text: "scaled", + }, + }, + }, + plan: { + ...input.plan, + logicalBounds: { x: 0, y: 0, width: Number.MIN_VALUE, height: 50 }, + }, + }), + ).resolves.toMatchObject({ ok: false, diagnostics: [{ code: "invalid-render-scale" }] }); + }); + + it("semantic record の挿入順だけが異なる capture states を同値として扱う", async () => { + const renderer = createBakedWebRenderer({ adapter: adapter(), config }); + const source = withRendererFingerprint(inputFor(createWebRendererConfigHash(config)), renderer); + const first = { + rootNodeIds: ["a", "b"], + nodes: { + a: { id: "a", parentId: null, order: 0, role: "paragraph" as const, text: "A" }, + b: { id: "b", parentId: null, order: 1, role: "paragraph" as const, text: "B" }, + }, + }; + const second = { + rootNodeIds: ["a", "b"], + nodes: { + b: { id: "b", parentId: null, order: 1, role: "paragraph" as const, text: "B" }, + a: { id: "a", parentId: null, order: 0, role: "paragraph" as const, text: "A" }, + }, + }; + await expect( + renderer.build({ ...source, semanticsByState: { a: first, z: second } }), + ).resolves.toMatchObject({ ok: true }); + }); + + it("設定 hash の不一致、adapter failure、hostile capture を診断に変換する", async () => { + const renderer = createBakedWebRenderer({ adapter: adapter(), config }); + const mismatch = await renderer.build(inputFor("sha256:other")); + expect(mismatch).toMatchObject({ ok: false }); + if (!mismatch.ok) expect(mismatch.diagnostics[0]?.code).toBe("renderer-fingerprint-mismatch"); + const fails = createBakedWebRenderer({ + adapter: { + identity: adapterIdentity, + environment, + async capture() { + throw new Error("no"); + }, + }, + config, + }); + const failedInput = withRendererFingerprint( + inputFor(createWebRendererConfigHash(config)), + fails, + ); + await expect(fails.build(failedInput)).resolves.toMatchObject({ + ok: false, + diagnostics: [{ code: "browser-capture-failed" }], + }); + const hostile = createBakedWebRenderer({ + adapter: { + identity: adapterIdentity, + environment, + async capture() { + return { + rgba: new Uint8Array(1), + pixelSize: [2, 1], + colorSpace: "srgb", + alphaMode: "opaque", + }; + }, + }, + config, + }); + const hostileInput = withRendererFingerprint( + inputFor(createWebRendererConfigHash(config)), + hostile, + ); + await expect(hostile.build(hostileInput)).resolves.toMatchObject({ + ok: false, + diagnostics: [{ code: "invalid-browser-capture" }], + }); + }); + + it("直接 build の capability と compiler input 境界を検証する", async () => { + const renderer = createBakedWebRenderer({ adapter: adapter(), config }); + const input = withRendererFingerprint(inputFor(createWebRendererConfigHash(config)), renderer); + await expect( + renderer.build({ ...input, entry: { kind: "opaque", entryId: "x", moduleHash: "x" } }), + ).resolves.toMatchObject({ + ok: false, + diagnostics: [{ code: "unsupported-input-kind" }], + }); + await expect( + renderer.build({ ...input, context: { ...input.context, rendererFingerprint: "bad" } }), + ).resolves.toMatchObject({ + ok: false, + diagnostics: [{ code: "renderer-fingerprint-mismatch" }], + }); + await expect( + renderer.build({ + ...input, + plan: { ...input.plan, logicalBounds: { x: 0, y: 0, width: 0, height: 1 } }, + }), + ).resolves.toMatchObject({ ok: false, diagnostics: [{ code: "invalid-logical-bounds" }] }); + await expect( + renderer.build({ ...input, plan: { ...input.plan, semanticSurfaceId: "other" } }), + ).resolves.toMatchObject({ ok: false, diagnostics: [{ code: "surface-plan-mismatch" }] }); + }); + + it("adapter identity を implementation hash に含め、固定する", async () => { + const first = createBakedWebRenderer({ adapter: adapter(), config }); + const second = createBakedWebRenderer({ + adapter: { + identity: { id: "test-adapter", implementationHash: "sha256:other" }, + environment, + async capture(request) { + return { + rgba: new Uint8Array([0, 0, 0, 255, 0, 0, 0, 255]), + pixelSize: request.pixelTarget, + colorSpace: "srgb", + alphaMode: "opaque", + }; + }, + }, + config, + }); + expect(first.identity.implementationHash).not.toBe(second.identity.implementationHash); + const invalid = createBakedWebRenderer({ + adapter: { environment, capture: async () => ({}) } as unknown as FixedBrowserAdapter, + config, + }); + await expect( + invalid.build( + withRendererFingerprint(inputFor(createWebRendererConfigHash(config)), invalid), + ), + ).resolves.toMatchObject({ + ok: false, + diagnostics: [{ code: "invalid-browser-environment" }], + }); + }); + + it("任意の own state ID に空 hit region を作成する", async () => { + const renderer = createBakedWebRenderer({ adapter: adapter(), config }); + expect(Object.isFrozen(renderer)).toBe(true); + const input = withRendererFingerprint(inputFor(createWebRendererConfigHash(config)), renderer); + const states = Object.create(null); + const surfaceStates = Object.create(null); + const semantics = Object.create(null); + Object.defineProperty(states, "__proto__", { value: { kind: "capture" }, enumerable: true }); + Object.defineProperty(surfaceStates, "__proto__", { + value: { id: "__proto__", semanticOverrides: [], enabledInteractionIds: [] }, + enumerable: true, + }); + Object.defineProperty(semantics, "__proto__", { + value: { rootNodeIds: [], nodes: {} }, + enumerable: true, + }); + const result = await renderer.build({ + ...input, + surface: { ...input.surface, initialStateId: "__proto__", states: surfaceStates }, + plan: { ...input.plan, states }, + semanticsByState: semantics, + } as CompilerResolvedSurfaceInput); + expect(result).toMatchObject({ ok: true }); + if (result.ok) expect(Object.hasOwn(result.hitRegionsByState, "__proto__")).toBe(true); + }); + + it("config と opaque capture の厳格な byte 境界を検証する", async () => { + const sparse = Object.assign([], { length: 4, 0: 0, 2: 0, 3: 255 }) as number[]; + expect(() => + createWebRendererConfigHash({ + documentBackground: sparse, + fontFamily: "x", + } as unknown as WebRendererConfig), + ).toThrow(); + expect( + createWebRendererConfigHash({ documentBackground: [0, 0, 0, 255], fontFamily: " x " }), + ).toBe(createWebRendererConfigHash({ documentBackground: [0, 0, 0, 255], fontFamily: "x" })); + const opaque = createBakedWebRenderer({ + adapter: { + identity: adapterIdentity, + environment, + async capture(request) { + return { + rgba: new Uint8Array([0, 0, 0, 1, 0, 0, 0, 255]), + pixelSize: request.pixelTarget, + colorSpace: "srgb", + alphaMode: "opaque", + }; + }, + }, + config, + }); + await expect( + opaque.build(withRendererFingerprint(inputFor(createWebRendererConfigHash(config)), opaque)), + ).resolves.toMatchObject({ + ok: false, + diagnostics: [{ code: "invalid-browser-capture" }], + }); + }); + + it("config、environment、pixel size の descriptor snapshot で TOCTOU を遮断する", async () => { + let configReads = 0; + const hostileConfig = new Proxy(config, { + get(target, property, receiver) { + configReads++; + return Reflect.get(target, property, receiver); + }, + }); + expect(() => + createWebRendererConfigHash(hostileConfig as unknown as WebRendererConfig), + ).not.toThrow(); + expect(configReads).toBe(0); + + let environmentReads = 0; + const hostileEnvironment = new Proxy(environment, { + get(target, property, receiver) { + environmentReads++; + return Reflect.get(target, property, receiver); + }, + }); + const renderer = createBakedWebRenderer({ + adapter: { + identity: adapterIdentity, + environment: hostileEnvironment, + async capture() { + return { + rgba: new Uint8Array([0, 0, 0, 255, 0, 0, 0, 255]), + pixelSize: new Proxy([2, 1], { + get() { + throw new Error("pixel size must not be read as a value"); + }, + }), + colorSpace: "srgb", + alphaMode: "opaque", + } as unknown as import("../src/index.js").BrowserRgbaCapture; + }, + }, + config, + }); + expect(environmentReads).toBe(0); + await expect( + renderer.build( + withRendererFingerprint(inputFor(createWebRendererConfigHash(config)), renderer), + ), + ).resolves.toMatchObject({ ok: true }); + }); + + it("intrinsic RGBA copy は byteLength と iterator の shadow を実行しない", async () => { + let byteLengthReads = 0; + let iteratorReads = 0; + class HostileBytes extends Uint8Array { + override get byteLength(): number { + byteLengthReads++; + return super.byteLength; + } + + override [Symbol.iterator](): ArrayIterator { + iteratorReads++; + return super[Symbol.iterator](); + } + } + const renderer = createBakedWebRenderer({ + adapter: { + identity: adapterIdentity, + environment, + capture: () => ({ + rgba: new HostileBytes([0, 0, 0, 255, 0, 0, 0, 255]), + pixelSize: [2, 1], + colorSpace: "srgb", + alphaMode: "opaque", + }), + }, + config, + }); + await expect( + renderer.build( + withRendererFingerprint(inputFor(createWebRendererConfigHash(config)), renderer), + ), + ).resolves.toMatchObject({ ok: true }); + expect(byteLengthReads).toBe(0); + expect(iteratorReads).toBe(0); + }); + + it("Uint8Array 以外の ArrayBuffer view を RGBA として拒否する", async () => { + for (const rgba of [ + new Uint8ClampedArray(8), + new Uint16Array(4), + new DataView(new ArrayBuffer(8)), + ]) { + const renderer = createBakedWebRenderer({ + adapter: { + identity: adapterIdentity, + environment, + capture: () => + ({ rgba, pixelSize: [2, 1], colorSpace: "srgb", alphaMode: "opaque" }) as never, + }, + config, + }); + await expect( + renderer.build( + withRendererFingerprint(inputFor(createWebRendererConfigHash(config)), renderer), + ), + ).resolves.toMatchObject({ ok: false, diagnostics: [{ code: "invalid-browser-capture" }] }); + } + }); + + it("malformed Text node は Browser capture 前に shared validator が拒否する", async () => { + let captures = 0; + const renderer = createBakedWebRenderer({ + adapter: { + identity: adapterIdentity, + environment, + capture: () => { + captures++; + return { + rgba: new Uint8Array([0, 0, 0, 255, 0, 0, 0, 255]), + pixelSize: [2, 1], + colorSpace: "srgb" as const, + alphaMode: "opaque" as const, + }; + }, + }, + config, + }); + const source = withRendererFingerprint(inputFor(createWebRendererConfigHash(config)), renderer); + const malformed = { + ...source, + surface: { + ...source.surface, + contentNodes: { + ...source.surface.contentNodes, + text: { ...source.surface.contentNodes.text, text: 1 }, + }, + }, + } as unknown as CompilerResolvedSurfaceInput; + await expect(renderer.build(malformed)).resolves.toMatchObject({ + ok: false, + diagnostics: [{ code: "invalid-renderer-input" }], + }); + expect(captures).toBe(0); + }); + + it("capabilities を deep freeze し、capture getter を読まずに拒否する", async () => { + const renderer = createBakedWebRenderer({ adapter: adapter(), config }); + expect(Object.isFrozen(renderer.capabilities)).toBe(true); + expect(Object.isFrozen(renderer.capabilities.inputKinds)).toBe(true); + let reads = 0; + const hostile = createBakedWebRenderer({ + adapter: { + identity: adapterIdentity, + environment, + async capture() { + return Object.defineProperty({}, "rgba", { + get() { + reads++; + return new Uint8Array(8); + }, + }) as unknown as import("../src/index.js").BrowserRgbaCapture; + }, + }, + config, + }); + await expect( + hostile.build( + withRendererFingerprint(inputFor(createWebRendererConfigHash(config)), hostile), + ), + ).resolves.toMatchObject({ + ok: false, + diagnostics: [{ code: "invalid-browser-capture" }], + }); + expect(reads).toBe(0); + }); + + it("inherited または getter の Browser environment を安全に拒否する", async () => { + const inheritedEnvironment = Object.create(environment); + const inherited = createBakedWebRenderer({ + adapter: { + identity: adapterIdentity, + environment: inheritedEnvironment, + async capture() { + throw new Error("must not run"); + }, + } as unknown as FixedBrowserAdapter, + config, + }); + await expect( + inherited.build( + withRendererFingerprint(inputFor(createWebRendererConfigHash(config)), inherited), + ), + ).resolves.toMatchObject({ + ok: false, + diagnostics: [{ code: "invalid-browser-environment" }], + }); + const hostileAdapter = Object.defineProperty( + { identity: adapterIdentity, capture: async () => ({}) }, + "environment", + { + get() { + throw new Error("getter"); + }, + }, + ) as unknown as FixedBrowserAdapter; + expect(() => createBakedWebRenderer({ adapter: hostileAdapter, config })).not.toThrow(); + }); + + it("factory optionsをZod境界の前にsnapshotし、accessorを実行しない", () => { + let reads = 0; + const hostileOptions = Object.defineProperty({ config }, "adapter", { + enumerable: true, + get() { + reads++; + return adapter(); + }, + }); + + expect(() => createBakedWebRenderer(null as never)).not.toThrow(); + expect(() => createBakedWebRenderer(hostileOptions as never)).not.toThrow(); + expect(reads).toBe(0); + }); + + it("公開型を固定する", () => { + expectTypeOf(createWebRendererConfigHash).returns.toEqualTypeOf(); + expectTypeOf(createBakedWebRenderer).returns.toMatchTypeOf<{ build: Function }>(); + }); +}); diff --git a/packages/presentation-renderer-web/tsconfig.json b/packages/presentation-renderer-web/tsconfig.json new file mode 100644 index 0000000..8b84660 --- /dev/null +++ b/packages/presentation-renderer-web/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../config/tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "noPropertyAccessFromIndexSignature": false, + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f15657..7e25fcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -232,7 +232,7 @@ importers: dependencies: '@better-auth/infra': specifier: 0.4.2 - version: 0.4.2(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.2))(better-auth@1.7.1(@cloudflare/workers-types@5.20260825.1)(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.10)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.10)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(better-sqlite3@12.0.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.10)(vitest@4.1.11))(zod@4.4.3) + version: 0.4.2(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.2))(better-auth@1.7.1(@cloudflare/workers-types@5.20260825.1)(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.10)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.10)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(better-sqlite3@12.0.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(solid-js@1.9.12)(svelte@5.56.10)(vitest@4.1.11))(zod@4.4.3) '@hono/zod-openapi': specifier: ^1.5.2 version: 1.6.1(hono@4.13.4)(zod@4.4.3) @@ -241,7 +241,7 @@ importers: version: 1.0.20 better-auth: specifier: 1.7.1 - version: 1.7.1(@cloudflare/workers-types@5.20260825.1)(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.10)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.10)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(better-sqlite3@12.0.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.10)(vitest@4.1.11) + version: 1.7.1(@cloudflare/workers-types@5.20260825.1)(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.10)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.10)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(better-sqlite3@12.0.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(solid-js@1.9.12)(svelte@5.56.10)(vitest@4.1.11) drizzle-orm: specifier: 0.45.2 version: 0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4) @@ -260,7 +260,7 @@ importers: version: 5.20260825.1 auth: specifier: 1.7.1 - version: 1.7.1(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.10)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.10)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(better-call@1.4.0(zod@4.4.3))(better-sqlite3@12.0.0)(chokidar@5.0.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4))(giget@3.3.1)(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(svelte@5.56.10)(vitest@4.1.11(@types/node@24.13.3)(@vitest/browser-preview@4.1.11)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))) + version: 1.7.1(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.10)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.10)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(better-call@1.4.0(zod@4.4.3))(better-sqlite3@12.0.0)(chokidar@5.0.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4))(giget@3.3.1)(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(solid-js@1.9.12)(supports-color@10.2.2)(svelte@5.56.10)(vitest@4.1.11(@types/node@24.13.3)(@vitest/browser-preview@4.1.11)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))) typescript: specifier: ^7.0.0 version: 7.0.2 @@ -281,7 +281,7 @@ importers: version: 1.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@hookform/resolvers': specifier: 5.9.1 - version: 5.9.1(@standard-schema/spec@1.1.0)(react-hook-form@7.86.0(react@19.2.8))(zod@4.4.3) + version: 5.9.1(@standard-schema/spec@1.1.0)(ajv@8.17.1)(react-hook-form@7.86.0(react@19.2.8))(zod@4.4.3) '@phosphor-icons/react': specifier: ^2.1.10 version: 2.1.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -444,7 +444,7 @@ importers: version: link:../../app/server/control-plane better-auth: specifier: 1.7.1 - version: 1.7.1(@cloudflare/workers-types@5.20260825.1)(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.10)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.10)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0))(better-sqlite3@12.0.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.10) + version: 1.7.1(@cloudflare/workers-types@5.20260825.1)(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.10)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.10)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0))(better-sqlite3@12.0.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(solid-js@1.9.12)(svelte@5.56.10) hono: specifier: ^4.13.1 version: 4.13.4 @@ -464,18 +464,273 @@ importers: packages/config: devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.13.3 + tsx: + specifier: ^4.22.1 + version: 4.23.12 + typescript: + specifier: ^7.0.0 + version: 7.0.2 vite-plus: specifier: 'catalog:' version: 0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) packages/contracts: + dependencies: + zod: + specifier: 4.4.3 + version: 4.4.3 devDependencies: + '@cloudflare/workers-types': + specifier: 5.20260825.1 + version: 5.20260825.1 + '@types/node': + specifier: ^24.0.0 + version: 24.13.3 + ajv: + specifier: 8.17.1 + version: 8.17.1 + canonicalize: + specifier: 3.0.0 + version: 3.0.0 openapi-typescript: specifier: ^7.12.0 version: 7.13.0(typescript@5.9.3) tsx: specifier: ^4.22.1 version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite-plus: + specifier: 'catalog:' + version: 0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@5.9.3)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) + + packages/presentation-assets: + dependencies: + '@noble/hashes': + specifier: 2.3.0 + version: 2.3.0 + '@unframe/presentation-core': + specifier: workspace:* + version: link:../presentation-core + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.13.3 + typescript: + specifier: ^7.0.0 + version: 7.0.2 + vite-plus: + specifier: 'catalog:' + version: 0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) + vitest: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0)' + + packages/presentation-authoring: + dependencies: + '@unframe/presentation-core': + specifier: workspace:* + version: link:../presentation-core + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + typescript: + specifier: ^7.0.0 + version: 7.0.2 + vite-plus: + specifier: 'catalog:' + version: 0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) + vitest: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0)' + + packages/presentation-cli: + dependencies: + '@opentui/core': + specifier: 0.4.5 + version: 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/keymap': + specifier: 0.4.5 + version: 0.4.5(@opentui/solid@0.4.5(solid-js@1.9.12)(supports-color@10.2.2)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.8)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/solid': + specifier: 0.4.5 + version: 0.4.5(solid-js@1.9.12)(supports-color@10.2.2)(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@unframe/presentation-compiler': + specifier: workspace:* + version: link:../presentation-compiler + '@unframe/presentation-core': + specifier: workspace:* + version: link:../presentation-core + '@unframe/presentation-renderer-web': + specifier: workspace:* + version: link:../presentation-renderer-web + solid-js: + specifier: 1.9.12 + version: 1.9.12 + typescript: + specifier: 5.9.3 + version: 5.9.3 + web-tree-sitter: + specifier: 0.25.10 + version: 0.25.10 + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@unframe/presentation-components': + specifier: workspace:* + version: link:../presentation-components + vite-plus: + specifier: 'catalog:' + version: 0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@5.9.3)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) + vitest: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(tsx@4.23.12)(typescript@5.9.3)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0)' + + packages/presentation-compiler: + dependencies: + '@noble/hashes': + specifier: 2.3.0 + version: 2.3.0 + '@unframe/presentation': + specifier: workspace:* + version: link:../presentation-authoring + '@unframe/presentation-assets': + specifier: workspace:* + version: link:../presentation-assets + '@unframe/presentation-core': + specifier: workspace:* + version: link:../presentation-core + '@unframe/presentation-renderer-api': + specifier: workspace:* + version: link:../presentation-renderer-api + typescript: + specifier: 6.0.3 + version: 6.0.3 + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@unframe/presentation-components': + specifier: workspace:* + version: link:../presentation-components + vite-plus: + specifier: 'catalog:' + version: 0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@6.0.3)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) + vitest: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(tsx@4.23.12)(typescript@6.0.3)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0)' + + packages/presentation-components: + dependencies: + '@unframe/presentation': + specifier: workspace:* + version: link:../presentation-authoring + devDependencies: + typescript: + specifier: ^7.0.0 + version: 7.0.2 + vite-plus: + specifier: 'catalog:' + version: 0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) + vitest: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0)' + + packages/presentation-core: + dependencies: + '@noble/hashes': + specifier: 2.3.0 + version: 2.3.0 + '@unframe/contracts': + specifier: workspace:* + version: link:../contracts + canonicalize: + specifier: 3.0.0 + version: 3.0.0 + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.13.3 + typescript: + specifier: ^7.0.0 + version: 7.0.2 + vite-plus: + specifier: 'catalog:' + version: 0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) + vitest: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0)' + + packages/presentation-renderer-api: + dependencies: + '@unframe/presentation-core': + specifier: workspace:* + version: link:../presentation-core + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.13.3 + typescript: + specifier: ^7.0.0 + version: 7.0.2 + vite-plus: + specifier: 'catalog:' + version: 0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) + vitest: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0)' + + packages/presentation-renderer-web: + dependencies: + '@noble/hashes': + specifier: 2.3.0 + version: 2.3.0 + '@unframe/presentation-renderer-api': + specifier: workspace:* + version: link:../presentation-renderer-api + playwright-core: + specifier: 1.62.1 + version: 1.62.1 + pngjs: + specifier: 7.0.0 + version: 7.0.0 + rolldown: + specifier: 1.0.1 + version: 1.0.1 + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.13.3 + '@types/pngjs': + specifier: 6.0.5 + version: 6.0.5 + typescript: + specifier: ^7.0.0 + version: 7.0.2 + vite-plus: + specifier: 'catalog:' + version: 0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) + vitest: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0)' scripts/docs/notion-sync: dependencies: @@ -501,6 +756,10 @@ packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@6.0.7': resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} engines: {node: ^22.13.0 || >=24.0.0} @@ -522,6 +781,10 @@ packages: resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} engines: {node: '>=6.9.0'} + '@babel/core@7.28.0': + resolution: {integrity: sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==} + engines: {node: '>=6.9.0'} + '@babel/core@7.29.7': resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} @@ -552,6 +815,10 @@ packages: resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + '@babel/helper-module-imports@7.29.7': resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} @@ -655,6 +922,12 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/preset-typescript@7.27.1': + resolution: {integrity: sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/preset-typescript@7.29.7': resolution: {integrity: sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==} engines: {node: '>=6.9.0'} @@ -1639,6 +1912,75 @@ packages: resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} + '@opentui/core-darwin-arm64@0.4.5': + resolution: {integrity: sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg==} + cpu: [arm64] + os: [darwin] + + '@opentui/core-darwin-x64@0.4.5': + resolution: {integrity: sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA==} + cpu: [x64] + os: [darwin] + + '@opentui/core-linux-arm64-musl@0.4.5': + resolution: {integrity: sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@opentui/core-linux-arm64@0.4.5': + resolution: {integrity: sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ==} + cpu: [arm64] + os: [linux] + + '@opentui/core-linux-x64-musl@0.4.5': + resolution: {integrity: sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@opentui/core-linux-x64@0.4.5': + resolution: {integrity: sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w==} + cpu: [x64] + os: [linux] + + '@opentui/core-win32-arm64@0.4.5': + resolution: {integrity: sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ==} + cpu: [arm64] + os: [win32] + + '@opentui/core-win32-x64@0.4.5': + resolution: {integrity: sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg==} + cpu: [x64] + os: [win32] + + '@opentui/core@0.4.5': + resolution: {integrity: sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig==} + peerDependencies: + web-tree-sitter: 0.25.10 + + '@opentui/keymap@0.4.5': + resolution: {integrity: sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg==} + peerDependencies: + '@opentui/react': 0.4.5 + '@opentui/solid': 0.4.5 + react: '>=19.2.0' + solid-js: 1.9.12 + peerDependenciesMeta: + '@opentui/react': + optional: true + '@opentui/solid': + optional: true + react: + optional: true + solid-js: + optional: true + + '@opentui/solid@0.4.5': + resolution: {integrity: sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g==} + peerDependencies: + solid-js: 1.9.12 + '@oxc-project/runtime@0.133.0': resolution: {integrity: sha512-PkvjA1Lq5++V5S1E6Patr92ZVcieE6EalDr1VJTqv4BnjZdOUC4W3p8k1wMXSd5/2aFP4b/A6N5sg2Bkzcr9vQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2346,6 +2688,9 @@ packages: '@types/offscreencanvas@2019.7.3': resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} + '@types/pngjs@6.0.5': + resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==} + '@types/react-dom@19.2.5': resolution: {integrity: sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==} peerDependencies: @@ -2898,6 +3243,9 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -2906,6 +3254,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + engines: {node: '>=12'} + ansi-styles@5.2.0: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} @@ -2936,6 +3288,23 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + babel-plugin-jsx-dom-expressions@0.40.10: + resolution: {integrity: sha512-lxve6Y02YiZTldB7efKpnbf1BH00XCFZNYYW235jSGsYaJNFtHrYlKV6/O+miHbjqpIr9FTe5+0no4hofAMbfA==} + peerDependencies: + '@babel/core': ^7.20.12 + + babel-plugin-module-resolver@5.0.2: + resolution: {integrity: sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg==} + + babel-preset-solid@1.9.12: + resolution: {integrity: sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==} + peerDependencies: + '@babel/core': ^7.0.0 + solid-js: ^1.9.12 + peerDependenciesMeta: + solid-js: + optional: true + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -3055,6 +3424,11 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bun-ffi-structs@0.2.4: + resolution: {integrity: sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg==} + peerDependencies: + typescript: ^5 + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -3088,6 +3462,11 @@ packages: caniuse-lite@1.0.30001809: resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} + canonicalize@3.0.0: + resolution: {integrity: sha512-yYLfHyDMIXRyRqsKBRLX023riFLpXY2YOfdtqKXZRZy9qsfOJ9U+4F9YZL7MEzL5+ziN2x2nlBvY/Voi3EBljA==} + engines: {node: '>=18'} + hasBin: true + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -3227,6 +3606,10 @@ packages: devalue@5.8.1: resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -3335,6 +3718,9 @@ packages: electron-to-chromium@1.5.402: resolution: {integrity: sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -3342,6 +3728,14 @@ packages: resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -3349,6 +3743,10 @@ packages: error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -3403,6 +3801,9 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.6: + resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} + fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -3424,9 +3825,19 @@ packages: file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + find-babel-config@2.1.2: + resolution: {integrity: sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg==} + + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3437,10 +3848,17 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-tsconfig@4.14.1: resolution: {integrity: sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==} @@ -3451,12 +3869,21 @@ packages: github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob@9.3.5: + resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} + engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + glsl-noise@0.0.0: resolution: {integrity: sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==} graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hls.js@1.6.16: resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==} @@ -3468,6 +3895,9 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -3495,6 +3925,10 @@ packages: ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + is-docker@3.0.0: resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3747,8 +4181,15 @@ packages: locate-character@3.0.0: resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} - lru-cache@11.5.2: - resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} lru-cache@5.1.1: @@ -3773,6 +4214,11 @@ packages: markdown-table@2.0.0: resolution: {integrity: sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==} + marked@17.0.1: + resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + engines: {node: '>= 20'} + hasBin: true + mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} @@ -3809,9 +4255,21 @@ packages: resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} engines: {node: '>=10'} + minimatch@8.0.7: + resolution: {integrity: sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@4.2.8: + resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} + engines: {node: '>=8'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -3909,17 +4367,43 @@ packages: vite-plus: optional: true + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + parse-json@8.3.0: resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} engines: {node: '>=18'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -3940,6 +4424,10 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + pkg-up@3.1.0: + resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} + engines: {node: '>=8'} + playwright-core@1.62.1: resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} engines: {node: '>=20'} @@ -4067,12 +4555,20 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@4.1.8: + resolution: {integrity: sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==} + reselect@5.2.0: resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + rolldown@1.0.1: resolution: {integrity: sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4088,6 +4584,9 @@ packages: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} + s-js@0.4.9: + resolution: {integrity: sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ==} + sade@1.8.1: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} @@ -4111,12 +4610,22 @@ packages: engines: {node: '>=10'} hasBin: true + seroval-plugins@1.5.6: + resolution: {integrity: sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + seroval-plugins@1.6.3: resolution: {integrity: sha512-zQIy62HW683pMIUOKv9yKueQmOr+xMjpP2eH/qEPhr6nTAC+pJTh508md571Oduf/K/QpAoyeWpOhOp1+RFeDA==} engines: {node: '>=10'} peerDependencies: seroval: ^1.0 + seroval@1.5.6: + resolution: {integrity: sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA==} + engines: {node: '>=10'} + seroval@1.6.3: resolution: {integrity: sha512-5PwLbVPpT4DEki1hYVSSsoxWY6xCNMVucAVLK/CH0mXSDfp/I4KaAex72q80GNt5hpn7CCMGdSpIg4YGqH/wXQ==} engines: {node: '>=10'} @@ -4152,6 +4661,9 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + solid-js@1.9.12: + resolution: {integrity: sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -4171,9 +4683,17 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -4186,6 +4706,10 @@ packages: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + suspend-react@0.1.3: resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==} peerDependencies: @@ -4482,6 +5006,14 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + web-tree-sitter@0.25.10: + resolution: {integrity: sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==} + peerDependencies: + '@types/emscripten': ^1.40.0 + peerDependenciesMeta: + '@types/emscripten': + optional: true + webgl-constants@1.1.1: resolution: {integrity: sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==} @@ -4654,6 +5186,11 @@ snapshots: '@adobe/css-tools@4.5.0': {} + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@asamuzakjp/css-color@6.0.7': dependencies: '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) @@ -4682,12 +5219,32 @@ snapshots: '@babel/compat-data@7.29.7': {} + '@babel/core@7.28.0(supports-color@10.2.2)': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.28.0(supports-color@10.2.2)) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) + '@babel/types': 7.29.7 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@10.2.2) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/core@7.29.7(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 @@ -4722,6 +5279,19 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.28.0(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.28.0(supports-color@10.2.2) + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.28.0(supports-color@10.2.2)) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) @@ -4744,6 +5314,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.29.7 + '@babel/helper-module-imports@7.29.7': dependencies: '@babel/traverse': 7.29.7(supports-color@10.2.2) @@ -4751,7 +5325,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.28.0(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.28.0(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-module-imports': 7.29.7 @@ -4766,6 +5349,15 @@ snapshots: '@babel/helper-plugin-utils@7.29.7': {} + '@babel/helper-replace-supers@7.29.7(@babel/core@7.28.0(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.28.0(supports-color@10.2.2) + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) @@ -4797,20 +5389,38 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.28.0(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.28.0(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.28.0(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.28.0(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.28.0(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.28.0(supports-color@10.2.2) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.28.0(supports-color@10.2.2)) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color @@ -4844,6 +5454,17 @@ snapshots: '@babel/helper-annotate-as-pure': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.28.0(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.28.0(supports-color@10.2.2) + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.28.0(supports-color@10.2.2)) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.28.0(supports-color@10.2.2)) + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) @@ -4867,6 +5488,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/preset-typescript@7.27.1(@babel/core@7.28.0(supports-color@10.2.2))': + dependencies: + '@babel/core': 7.28.0(supports-color@10.2.2) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.28.0(supports-color@10.2.2)) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.28.0(supports-color@10.2.2)) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.28.0(supports-color@10.2.2)) + transitivePeerDependencies: + - supports-color + '@babel/preset-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) @@ -4947,14 +5579,14 @@ snapshots: optionalDependencies: drizzle-orm: 0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4) - '@better-auth/infra@0.4.2(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.2))(better-auth@1.7.1(@cloudflare/workers-types@5.20260825.1)(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.10)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.10)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(better-sqlite3@12.0.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.10)(vitest@4.1.11))(zod@4.4.3)': + '@better-auth/infra@0.4.2(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.2))(better-auth@1.7.1(@cloudflare/workers-types@5.20260825.1)(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.10)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.10)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(better-sqlite3@12.0.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(solid-js@1.9.12)(svelte@5.56.10)(vitest@4.1.11))(zod@4.4.3)': dependencies: '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.2) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@noble/hashes': 2.3.0 '@xmldom/xmldom': 0.9.12 - better-auth: 1.7.1(@cloudflare/workers-types@5.20260825.1)(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.10)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.10)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(better-sqlite3@12.0.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.10)(vitest@4.1.11) + better-auth: 1.7.1(@cloudflare/workers-types@5.20260825.1)(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.10)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.10)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(better-sqlite3@12.0.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(solid-js@1.9.12)(svelte@5.56.10)(vitest@4.1.11) better-call: 1.3.7(zod@4.4.3) jose: 6.2.8 libphonenumber-js: 1.13.11 @@ -5346,12 +5978,13 @@ snapshots: hono: 4.13.4 zod: 4.4.3 - '@hookform/resolvers@5.9.1(@standard-schema/spec@1.1.0)(react-hook-form@7.86.0(react@19.2.8))(zod@4.4.3)': + '@hookform/resolvers@5.9.1(@standard-schema/spec@1.1.0)(ajv@8.17.1)(react-hook-form@7.86.0(react@19.2.8))(zod@4.4.3)': dependencies: '@standard-schema/utils': 0.3.0 react-hook-form: 7.86.0(react@19.2.8) optionalDependencies: '@standard-schema/spec': 1.1.0 + ajv: 8.17.1 zod: 4.4.3 '@iconify/svelte@5.2.2(svelte@5.56.10)': @@ -5522,6 +6155,76 @@ snapshots: '@opentelemetry/semantic-conventions@1.43.0': {} + '@opentui/core-darwin-arm64@0.4.5': + optional: true + + '@opentui/core-darwin-x64@0.4.5': + optional: true + + '@opentui/core-linux-arm64-musl@0.4.5': + optional: true + + '@opentui/core-linux-arm64@0.4.5': + optional: true + + '@opentui/core-linux-x64-musl@0.4.5': + optional: true + + '@opentui/core-linux-x64@0.4.5': + optional: true + + '@opentui/core-win32-arm64@0.4.5': + optional: true + + '@opentui/core-win32-x64@0.4.5': + optional: true + + '@opentui/core@0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + bun-ffi-structs: 0.2.4(typescript@5.9.3) + diff: 9.0.0 + marked: 17.0.1 + string-width: 7.2.0 + strip-ansi: 7.1.2 + web-tree-sitter: 0.25.10 + optionalDependencies: + '@opentui/core-darwin-arm64': 0.4.5 + '@opentui/core-darwin-x64': 0.4.5 + '@opentui/core-linux-arm64': 0.4.5 + '@opentui/core-linux-arm64-musl': 0.4.5 + '@opentui/core-linux-x64': 0.4.5 + '@opentui/core-linux-x64-musl': 0.4.5 + '@opentui/core-win32-arm64': 0.4.5 + '@opentui/core-win32-x64': 0.4.5 + transitivePeerDependencies: + - typescript + + '@opentui/keymap@0.4.5(@opentui/solid@0.4.5(solid-js@1.9.12)(supports-color@10.2.2)(typescript@5.9.3)(web-tree-sitter@0.25.10))(react@19.2.8)(solid-js@1.9.12)(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + '@opentui/core': 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) + optionalDependencies: + '@opentui/solid': 0.4.5(solid-js@1.9.12)(supports-color@10.2.2)(typescript@5.9.3)(web-tree-sitter@0.25.10) + react: 19.2.8 + solid-js: 1.9.12 + transitivePeerDependencies: + - typescript + - web-tree-sitter + + '@opentui/solid@0.4.5(solid-js@1.9.12)(supports-color@10.2.2)(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + '@babel/core': 7.28.0(supports-color@10.2.2) + '@babel/preset-typescript': 7.27.1(@babel/core@7.28.0(supports-color@10.2.2)) + '@opentui/core': 0.4.5(typescript@5.9.3)(web-tree-sitter@0.25.10) + babel-plugin-module-resolver: 5.0.2 + babel-preset-solid: 1.9.12(@babel/core@7.28.0(supports-color@10.2.2))(solid-js@1.9.12) + entities: 7.0.1 + s-js: 0.4.9 + solid-js: 1.9.12 + transitivePeerDependencies: + - supports-color + - typescript + - web-tree-sitter + '@oxc-project/runtime@0.133.0': {} '@oxc-project/runtime@0.146.0': {} @@ -6072,6 +6775,10 @@ snapshots: '@types/offscreencanvas@2019.7.3': {} + '@types/pngjs@6.0.5': + dependencies: + '@types/node': 24.13.3 + '@types/react-dom@19.2.5(@types/react@19.2.18)': dependencies: '@types/react': 19.2.18 @@ -6282,6 +6989,36 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.1 + '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0)': + dependencies: + '@oxc-project/runtime': 0.133.0 + '@oxc-project/types': 0.133.0 + lightningcss: 1.33.0 + postcss: 8.5.14 + optionalDependencies: + '@types/node': 24.13.3 + esbuild: 0.28.2 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.23.12 + typescript: 5.9.3 + yaml: 2.9.0 + + '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0)': + dependencies: + '@oxc-project/runtime': 0.133.0 + '@oxc-project/types': 0.133.0 + lightningcss: 1.33.0 + postcss: 8.5.14 + optionalDependencies: + '@types/node': 24.13.3 + esbuild: 0.28.2 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.23.12 + typescript: 6.0.3 + yaml: 2.9.0 + '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0)': dependencies: '@oxc-project/runtime': 0.133.0 @@ -6297,6 +7034,31 @@ snapshots: typescript: 7.0.2 yaml: 2.9.0 + '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0)': + dependencies: + '@oxc-project/runtime': 0.146.0 + '@oxc-project/types': 0.146.0 + lightningcss: 1.33.0 + postcss: 8.5.14 + yuku-codegen: 0.5.48 + yuku-parser: 0.5.48 + optionalDependencies: + '@types/node': 24.13.3 + '@voidzero-dev/vite-plus-darwin-arm64': 0.3.0 + '@voidzero-dev/vite-plus-darwin-x64': 0.3.0 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.3.0 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.3.0 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.3.0 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.3.0 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.3.0 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.3.0 + esbuild: 0.28.2 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.23.12 + typescript: 5.9.3 + yaml: 2.9.0 + '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0)': dependencies: '@oxc-project/runtime': 0.146.0 @@ -6365,11 +7127,11 @@ snapshots: '@voidzero-dev/vite-plus-linux-x64-musl@0.3.0': optional: true - '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0)': + '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(tsx@4.23.12)(typescript@5.9.3)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0)': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@voidzero-dev/vite-plus-core': 0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0) + '@voidzero-dev/vite-plus-core': 0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0) es-module-lexer: 1.7.0 obug: 2.1.4 pixelmatch: 7.2.0 @@ -6406,19 +7168,101 @@ snapshots: - utf-8-validate - yaml - '@voidzero-dev/vite-plus-win32-arm64-msvc@0.3.0': - optional: true - - '@voidzero-dev/vite-plus-win32-x64-msvc@0.3.0': - optional: true - - '@xmldom/xmldom@0.9.12': {} - - '@yuku-codegen/binding-darwin-arm64@0.5.48': - optional: true + '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(tsx@4.23.12)(typescript@6.0.3)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@voidzero-dev/vite-plus-core': 0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0) + es-module-lexer: 1.7.0 + obug: 2.1.4 + pixelmatch: 7.2.0 + pngjs: 7.0.0 + sirv: 3.0.2 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + vite: 8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + ws: 8.21.0 + optionalDependencies: + '@types/node': 24.13.3 + jsdom: 30.0.1(@noble/hashes@2.3.0) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@tsdown/css' + - '@tsdown/exe' + - '@vitejs/devtools' + - bufferutil + - esbuild + - jiti + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - yaml - '@yuku-codegen/binding-darwin-x64@0.5.48': - optional: true + '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@voidzero-dev/vite-plus-core': 0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@7.0.2)(yaml@2.9.0) + es-module-lexer: 1.7.0 + obug: 2.1.4 + pixelmatch: 7.2.0 + pngjs: 7.0.0 + sirv: 3.0.2 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + vite: 8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0) + ws: 8.21.0 + optionalDependencies: + '@types/node': 24.13.3 + jsdom: 30.0.1(@noble/hashes@2.3.0) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@tsdown/css' + - '@tsdown/exe' + - '@vitejs/devtools' + - bufferutil + - esbuild + - jiti + - less + - publint + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - yaml + + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.3.0': + optional: true + + '@voidzero-dev/vite-plus-win32-x64-msvc@0.3.0': + optional: true + + '@xmldom/xmldom@0.9.12': {} + + '@yuku-codegen/binding-darwin-arm64@0.5.48': + optional: true + + '@yuku-codegen/binding-darwin-x64@0.5.48': + optional: true '@yuku-codegen/binding-freebsd-x64@0.5.48': optional: true @@ -6486,10 +7330,19 @@ snapshots: agent-base@7.1.4: {} + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.6 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} + ansi-regex@6.3.0: {} + ansi-styles@5.2.0: {} argparse@2.0.1: {} @@ -6502,7 +7355,7 @@ snapshots: assertion-error@2.0.1: {} - auth@1.7.1(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.10)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.10)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(better-call@1.4.0(zod@4.4.3))(better-sqlite3@12.0.0)(chokidar@5.0.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4))(giget@3.3.1)(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(svelte@5.56.10)(vitest@4.1.11(@types/node@24.13.3)(@vitest/browser-preview@4.1.11)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))): + auth@1.7.1(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.10)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.10)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(better-call@1.4.0(zod@4.4.3))(better-sqlite3@12.0.0)(chokidar@5.0.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4))(giget@3.3.1)(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(solid-js@1.9.12)(supports-color@10.2.2)(svelte@5.56.10)(vitest@4.1.11(@types/node@24.13.3)(@vitest/browser-preview@4.1.11)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))): dependencies: '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/preset-react': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) @@ -6512,7 +7365,7 @@ snapshots: '@better-auth/utils': 0.4.2 '@clack/prompts': 1.7.0 '@mrleebo/prisma-ast': 0.16.0 - better-auth: 1.7.1(@cloudflare/workers-types@5.20260825.1)(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.10)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.10)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(better-sqlite3@12.0.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.10)(vitest@4.1.11) + better-auth: 1.7.1(@cloudflare/workers-types@5.20260825.1)(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.10)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.10)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(better-sqlite3@12.0.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(solid-js@1.9.12)(svelte@5.56.10)(vitest@4.1.11) c12: 4.0.0-beta.5(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0) chalk: 5.6.2 commander: 15.0.0 @@ -6561,13 +7414,37 @@ snapshots: axobject-query@4.1.0: {} + babel-plugin-jsx-dom-expressions@0.40.10(@babel/core@7.28.0(supports-color@10.2.2)): + dependencies: + '@babel/core': 7.28.0(supports-color@10.2.2) + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.28.0(supports-color@10.2.2)) + '@babel/types': 7.29.7 + html-entities: 2.3.3 + parse5: 7.3.0 + + babel-plugin-module-resolver@5.0.2: + dependencies: + find-babel-config: 2.1.2 + glob: 9.3.5 + pkg-up: 3.1.0 + reselect: 4.1.8 + resolve: 1.22.12 + + babel-preset-solid@1.9.12(@babel/core@7.28.0(supports-color@10.2.2))(solid-js@1.9.12): + dependencies: + '@babel/core': 7.28.0(supports-color@10.2.2) + babel-plugin-jsx-dom-expressions: 0.40.10(@babel/core@7.28.0(supports-color@10.2.2)) + optionalDependencies: + solid-js: 1.9.12 + balanced-match@1.0.2: {} base64-js@1.5.1: {} baseline-browser-mapping@2.11.12: {} - better-auth@1.7.1(@cloudflare/workers-types@5.20260825.1)(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.10)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.10)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0))(better-sqlite3@12.0.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.10): + better-auth@1.7.1(@cloudflare/workers-types@5.20260825.1)(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.10)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.10)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0))(better-sqlite3@12.0.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(solid-js@1.9.12)(svelte@5.56.10): dependencies: '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.2) '@better-auth/drizzle-adapter': 1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4)) @@ -6592,13 +7469,14 @@ snapshots: drizzle-orm: 0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) + solid-js: 1.9.12 svelte: 5.56.10 vitest: '@voidzero-dev/vite-plus-test@0.1.24(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0)' transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' - better-auth@1.7.1(@cloudflare/workers-types@5.20260825.1)(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.10)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.10)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(better-sqlite3@12.0.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(svelte@5.56.10)(vitest@4.1.11): + better-auth@1.7.1(@cloudflare/workers-types@5.20260825.1)(@sveltejs/kit@2.70.3(@sveltejs/vite-plugin-svelte@7.3.0(svelte@5.56.10)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(svelte@5.56.10)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)))(better-sqlite3@12.0.0)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(solid-js@1.9.12)(svelte@5.56.10)(vitest@4.1.11): dependencies: '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.2) '@better-auth/drizzle-adapter': 1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@5.20260825.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.8)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4)) @@ -6623,6 +7501,7 @@ snapshots: drizzle-orm: 0.45.2(@cloudflare/workers-types@5.20260825.1)(better-sqlite3@12.0.0)(kysely@0.29.4) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) + solid-js: 1.9.12 svelte: 5.56.10 vitest: 4.1.11(@types/node@24.13.3)(@vitest/browser-preview@4.1.11)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) transitivePeerDependencies: @@ -6694,6 +7573,10 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bun-ffi-structs@0.2.4(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -6718,6 +7601,8 @@ snapshots: caniuse-lite@1.0.30001809: {} + canonicalize@3.0.0: {} + chai@6.2.2: {} chalk@5.6.2: {} @@ -6833,6 +7718,8 @@ snapshots: devalue@5.8.1: {} + diff@9.0.0: {} + dom-accessibility-api@0.5.16: {} dom-accessibility-api@0.6.3: {} @@ -6849,6 +7736,8 @@ snapshots: electron-to-chromium@1.5.402: {} + emoji-regex@10.6.0: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -6859,10 +7748,16 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.3 + entities@6.0.1: {} + + entities@7.0.1: {} + entities@8.0.0: {} error-stack-parser-es@1.0.5: {} + es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} es-module-lexer@2.3.2: {} @@ -6952,6 +7847,8 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 + fast-uri@3.1.6: {} + fast-wrap-ansi@0.2.2: dependencies: fast-string-width: 3.0.2 @@ -6967,17 +7864,31 @@ snapshots: file-uri-to-path@1.0.0: optional: true + find-babel-config@2.1.2: + dependencies: + json5: 2.2.3 + + find-up@3.0.0: + dependencies: + locate-path: 3.0.0 + fs-constants@1.0.0: optional: true + fs.realpath@1.0.0: {} + fsevents@2.3.2: optional: true fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + gensync@1.0.0-beta.2: {} + get-east-asian-width@1.6.0: {} + get-tsconfig@4.14.1: dependencies: resolve-pkg-maps: 1.0.0 @@ -6988,10 +7899,21 @@ snapshots: github-from-package@0.0.0: optional: true + glob@9.3.5: + dependencies: + fs.realpath: 1.0.0 + minimatch: 8.0.7 + minipass: 4.2.8 + path-scurry: 1.11.1 + glsl-noise@0.0.0: {} graceful-fs@4.2.11: {} + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hls.js@1.6.16: {} hono@4.13.4: {} @@ -7002,6 +7924,8 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-entities@2.3.3: {} + https-proxy-agent@7.0.6(supports-color@10.2.2): dependencies: agent-base: 7.1.4 @@ -7025,6 +7949,10 @@ snapshots: ini@1.3.8: optional: true + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + is-docker@3.0.0: {} is-in-ssh@1.0.0: {} @@ -7214,6 +8142,13 @@ snapshots: locate-character@3.0.0: {} + locate-path@3.0.0: + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 + + lru-cache@10.4.3: {} + lru-cache@11.5.2: {} lru-cache@5.1.1: @@ -7239,6 +8174,8 @@ snapshots: dependencies: repeat-string: 1.6.1 + marked@17.0.1: {} + mdn-data@2.27.1: {} mdsvex@0.12.8(svelte@5.56.10): @@ -7290,9 +8227,17 @@ snapshots: dependencies: brace-expansion: 2.1.4 + minimatch@8.0.7: + dependencies: + brace-expansion: 2.1.4 + minimist@1.2.8: optional: true + minipass@4.2.8: {} + + minipass@7.1.3: {} + mkdirp-classic@0.5.3: optional: true @@ -7357,6 +8302,58 @@ snapshots: dependencies: yaml: 2.9.0 + oxfmt@0.64.0(svelte@5.56.10)(vite-plus@0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@5.9.3)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0)): + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.64.0 + '@oxfmt/binding-android-arm64': 0.64.0 + '@oxfmt/binding-darwin-arm64': 0.64.0 + '@oxfmt/binding-darwin-x64': 0.64.0 + '@oxfmt/binding-freebsd-x64': 0.64.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.64.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.64.0 + '@oxfmt/binding-linux-arm64-gnu': 0.64.0 + '@oxfmt/binding-linux-arm64-musl': 0.64.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.64.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.64.0 + '@oxfmt/binding-linux-riscv64-musl': 0.64.0 + '@oxfmt/binding-linux-s390x-gnu': 0.64.0 + '@oxfmt/binding-linux-x64-gnu': 0.64.0 + '@oxfmt/binding-linux-x64-musl': 0.64.0 + '@oxfmt/binding-openharmony-arm64': 0.64.0 + '@oxfmt/binding-win32-arm64-msvc': 0.64.0 + '@oxfmt/binding-win32-ia32-msvc': 0.64.0 + '@oxfmt/binding-win32-x64-msvc': 0.64.0 + svelte: 5.56.10 + vite-plus: 0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@5.9.3)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) + + oxfmt@0.64.0(svelte@5.56.10)(vite-plus@0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@6.0.3)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0)): + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.64.0 + '@oxfmt/binding-android-arm64': 0.64.0 + '@oxfmt/binding-darwin-arm64': 0.64.0 + '@oxfmt/binding-darwin-x64': 0.64.0 + '@oxfmt/binding-freebsd-x64': 0.64.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.64.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.64.0 + '@oxfmt/binding-linux-arm64-gnu': 0.64.0 + '@oxfmt/binding-linux-arm64-musl': 0.64.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.64.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.64.0 + '@oxfmt/binding-linux-riscv64-musl': 0.64.0 + '@oxfmt/binding-linux-s390x-gnu': 0.64.0 + '@oxfmt/binding-linux-x64-gnu': 0.64.0 + '@oxfmt/binding-linux-x64-musl': 0.64.0 + '@oxfmt/binding-openharmony-arm64': 0.64.0 + '@oxfmt/binding-win32-arm64-msvc': 0.64.0 + '@oxfmt/binding-win32-ia32-msvc': 0.64.0 + '@oxfmt/binding-win32-x64-msvc': 0.64.0 + svelte: 5.56.10 + vite-plus: 0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@6.0.3)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) + oxfmt@0.64.0(svelte@5.56.10)(vite-plus@0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0)): dependencies: tinypool: 2.1.0 @@ -7392,6 +8389,54 @@ snapshots: '@oxlint-tsgolint/win32-arm64': 7.0.2001 '@oxlint-tsgolint/win32-x64': 7.0.2001 + oxlint@1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@5.9.3)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0)): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.79.0 + '@oxlint/binding-android-arm64': 1.79.0 + '@oxlint/binding-darwin-arm64': 1.79.0 + '@oxlint/binding-darwin-x64': 1.79.0 + '@oxlint/binding-freebsd-x64': 1.79.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.79.0 + '@oxlint/binding-linux-arm-musleabihf': 1.79.0 + '@oxlint/binding-linux-arm64-gnu': 1.79.0 + '@oxlint/binding-linux-arm64-musl': 1.79.0 + '@oxlint/binding-linux-ppc64-gnu': 1.79.0 + '@oxlint/binding-linux-riscv64-gnu': 1.79.0 + '@oxlint/binding-linux-riscv64-musl': 1.79.0 + '@oxlint/binding-linux-s390x-gnu': 1.79.0 + '@oxlint/binding-linux-x64-gnu': 1.79.0 + '@oxlint/binding-linux-x64-musl': 1.79.0 + '@oxlint/binding-openharmony-arm64': 1.79.0 + '@oxlint/binding-win32-arm64-msvc': 1.79.0 + '@oxlint/binding-win32-ia32-msvc': 1.79.0 + '@oxlint/binding-win32-x64-msvc': 1.79.0 + oxlint-tsgolint: 7.0.2001 + vite-plus: 0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@5.9.3)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) + + oxlint@1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@6.0.3)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0)): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.79.0 + '@oxlint/binding-android-arm64': 1.79.0 + '@oxlint/binding-darwin-arm64': 1.79.0 + '@oxlint/binding-darwin-x64': 1.79.0 + '@oxlint/binding-freebsd-x64': 1.79.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.79.0 + '@oxlint/binding-linux-arm-musleabihf': 1.79.0 + '@oxlint/binding-linux-arm64-gnu': 1.79.0 + '@oxlint/binding-linux-arm64-musl': 1.79.0 + '@oxlint/binding-linux-ppc64-gnu': 1.79.0 + '@oxlint/binding-linux-riscv64-gnu': 1.79.0 + '@oxlint/binding-linux-riscv64-musl': 1.79.0 + '@oxlint/binding-linux-s390x-gnu': 1.79.0 + '@oxlint/binding-linux-x64-gnu': 1.79.0 + '@oxlint/binding-linux-x64-musl': 1.79.0 + '@oxlint/binding-openharmony-arm64': 1.79.0 + '@oxlint/binding-win32-arm64-msvc': 1.79.0 + '@oxlint/binding-win32-ia32-msvc': 1.79.0 + '@oxlint/binding-win32-x64-msvc': 1.79.0 + oxlint-tsgolint: 7.0.2001 + vite-plus: 0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@6.0.3)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) + oxlint@1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0)): optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.79.0 @@ -7416,18 +8461,41 @@ snapshots: oxlint-tsgolint: 7.0.2001 vite-plus: 0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0) + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@3.0.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + parse-json@8.3.0: dependencies: '@babel/code-frame': 7.29.7 index-to-position: 1.2.0 type-fest: 4.41.0 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + parse5@8.0.1: dependencies: entities: 8.0.0 + path-exists@3.0.0: {} + path-key@3.1.1: {} + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-to-regexp@6.3.0: {} pathe@2.0.3: {} @@ -7446,6 +8514,10 @@ snapshots: exsolve: 1.1.1 pathe: 2.0.3 + pkg-up@3.1.0: + dependencies: + find-up: 3.0.0 + playwright-core@1.62.1: {} playwright@1.62.1: @@ -7569,10 +8641,19 @@ snapshots: require-from-string@2.0.2: {} + reselect@4.1.8: {} + reselect@5.2.0: {} resolve-pkg-maps@1.0.0: {} + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + rolldown@1.0.1: dependencies: '@oxc-project/types': 0.130.0 @@ -7600,6 +8681,8 @@ snapshots: run-applescript@7.1.0: {} + s-js@0.4.9: {} + sade@1.8.1: dependencies: mri: 1.2.0 @@ -7617,10 +8700,16 @@ snapshots: semver@7.8.5: {} + seroval-plugins@1.5.6(seroval@1.5.6): + dependencies: + seroval: 1.5.6 + seroval-plugins@1.6.3(seroval@1.6.3): dependencies: seroval: 1.6.3 + seroval@1.5.6: {} + seroval@1.6.3: {} set-cookie-parser@3.1.2: {} @@ -7683,6 +8772,12 @@ snapshots: sisteransi@1.0.5: {} + solid-js@1.9.12: + dependencies: + csstype: 3.2.3 + seroval: 1.5.6 + seroval-plugins: 1.5.6(seroval@1.5.6) + source-map-js@1.2.1: {} stackback@0.0.2: {} @@ -7696,11 +8791,21 @@ snapshots: std-env@4.2.0: {} + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.1.2 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 optional: true + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.3.0 + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -7710,6 +8815,8 @@ snapshots: supports-color@10.2.2: {} + supports-preserve-symlinks-flag@1.0.0: {} + suspend-react@0.1.3(react@19.2.8): dependencies: react: 19.2.8 @@ -7934,6 +9041,122 @@ snapshots: '@types/unist': 2.0.11 unist-util-stringify-position: 2.0.3 + vite-plus@0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@5.9.3)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0): + dependencies: + '@oxc-project/types': 0.146.0 + '@oxlint/plugins': 1.79.0 + '@vitest/browser': 4.1.11(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.11) + '@vitest/browser-preview': 4.1.11(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.11) + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + '@voidzero-dev/vite-plus-core': 0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@5.9.3)(yaml@2.9.0) + oxfmt: 0.64.0(svelte@5.56.10)(vite-plus@0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@5.9.3)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0)) + oxlint: 1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@5.9.3)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0)) + oxlint-tsgolint: 7.0.2001 + vitest: 4.1.11(@types/node@24.13.3)(@vitest/browser-preview@4.1.11)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + optionalDependencies: + '@voidzero-dev/vite-plus-darwin-arm64': 0.3.0 + '@voidzero-dev/vite-plus-darwin-x64': 0.3.0 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.3.0 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.3.0 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.3.0 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.3.0 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.3.0 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.3.0 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - msw + - publint + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vite + - yaml + + vite-plus@0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@6.0.3)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0): + dependencies: + '@oxc-project/types': 0.146.0 + '@oxlint/plugins': 1.79.0 + '@vitest/browser': 4.1.11(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.11) + '@vitest/browser-preview': 4.1.11(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(vitest@4.1.11) + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + '@voidzero-dev/vite-plus-core': 0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(typescript@6.0.3)(yaml@2.9.0) + oxfmt: 0.64.0(svelte@5.56.10)(vite-plus@0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@6.0.3)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0)) + oxlint: 1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@6.0.3)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0)) + oxlint-tsgolint: 7.0.2001 + vitest: 4.1.11(@types/node@24.13.3)(@vitest/browser-preview@4.1.11)(jsdom@30.0.1(@noble/hashes@2.3.0))(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0)) + optionalDependencies: + '@voidzero-dev/vite-plus-darwin-arm64': 0.3.0 + '@voidzero-dev/vite-plus-darwin-x64': 0.3.0 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.3.0 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.3.0 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.3.0 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.3.0 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.3.0 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.3.0 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - msw + - publint + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vite + - yaml + vite-plus@0.3.0(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.0.1(@noble/hashes@2.3.0))(svelte@5.56.10)(tsx@4.23.12)(typescript@7.0.2)(vite@8.0.13(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0): dependencies: '@oxc-project/types': 0.146.0 @@ -8078,6 +9301,8 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + web-tree-sitter@0.25.10: {} + webgl-constants@1.1.1: {} webgl-sdf-generator@1.1.1: {} diff --git a/scripts/README.md b/scripts/README.md index c53acf4..87aebcd 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -2,21 +2,36 @@ タスクの実処理を置くディレクトリです。公式な実行入口は `flake.nix` の apps で、`flake.nix` はここにあるスクリプトをラップします。 -| ディレクトリ | 役割 | -| --- | --- | -| `lib/` | 共有ヘルパ(パス定数 `paths.sh` など) | -| `ci/` | 品質ゲート | -| `dev/` | 依存関係・Git hook のセットアップ | -| `docs/` | Notion → `docs/notion/` 同期 | +| ディレクトリ | 役割 | +| ------------ | -------------------------------------- | +| `lib/` | 共有ヘルパ(パス定数 `paths.sh` など) | +| `ci/` | 品質ゲート | +| `dev/` | 依存関係・Git hook のセットアップ | +| `docs/` | Notion → `docs/notion/` 同期 | ```bash nix develop nix run .#setup nix run .#check nix run .#control-plane +nix run .#presentation nix run .#realtime nix run .#notion-sync nix flake check ``` -`packages/contracts/` は次の Control Plane OpenAPI と Realtime Protocol Buffers の共有境界です。source of truth と生成手順は、対応する component 実装と合わせて定義します。 +`packages/contracts/` は Control Plane OpenAPI、Realtime Protocol Buffers、Presentation artifact schema の共有境界です。`nix run .#presentation` は実装済みの `packages/presentation-*` packageを検証した後、repository-local Fixed Browser で `examples/presentation` の check と実 build を2回行います。Definition、RenderBundle、PNG asset set の relative path と SHA-256 manifest が一致することまで確認します。source of truth と生成手順は、対応する component 実装と合わせて定義します。 + +Fixed Browser の実機captureをローカルで試す前には、次を明示的に実行します。通常の package / repository check は browser binary を download / 起動せず、unit test だけを実行します。 + +```bash +nix develop --command scripts/dev/install-presentation-browser.sh +``` + +browser binary は repository の `.cache/playwright` にのみ配置し、`playwright-core install chromium --only-shell` で provision します。 +Linuxでは `flake.nix` のNix devShellがmanaged headless shellの共有ライブラリと固定Noto CJK fontconfigを提供します。font provenanceはcaller文字列ではなく、この固定font setでcaptureしたglyph baseline bytesから導出します。 +通常の renderer-web test は `*.integration.test.ts` を明示的に除外し、provision済み環境でだけ実 Browser capture の integration test を次で実行できます。binary がない場合は skip せず失敗します。`nix run .#presentation` の check mode も同じく provision を必須とし、fix mode と package 単体 check は Browser を起動しません。 + +```bash +nix develop --command scripts/dev/test-presentation-browser.sh +``` diff --git a/scripts/ci/check.sh b/scripts/ci/check.sh index 98bf508..0cc213c 100755 --- a/scripts/ci/check.sh +++ b/scripts/ci/check.sh @@ -1,12 +1,13 @@ #!/usr/bin/env bash # 品質ゲートの集約 (ローカル用の nix run .#check)。 -# ローカルでは Control Plane、Realtime、LP、Web の品質処理を集約する。CI は ci.yml が領域別 workflow を呼び分ける。 +# ローカルでは Control Plane、Presentation packages、Realtime、LP、Web の品質処理を集約する。CI は ci.yml が領域別 workflow を呼び分ける。 set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=../lib/paths.sh source "${DIR}/../lib/paths.sh" "${DIR}/control-plane.sh" check +"${DIR}/presentation.sh" check "${DIR}/realtime.sh" check "${DIR}/lp.sh" check "${DIR}/web.sh" check diff --git a/scripts/ci/presentation.sh b/scripts/ci/presentation.sh new file mode 100755 index 0000000..83b921a --- /dev/null +++ b/scripts/ci/presentation.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# packages/presentation-* の品質処理。 +# check (既定): 実装済み package の check +# fix : Vite+ formatter +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/paths.sh +source "${DIR}/../lib/paths.sh" + +manifest() { + local dist="$1/dist" + test -L "${dist}" + test -f "${dist}/definition.json" + test -f "${dist}/render-bundle.json" + test -d "${dist}/assets" + find "${dist}/assets" -type f -name '*.png' -print -quit | grep -q . + ( + cd "${dist}" + find . -type f -printf '%P\0' | LC_ALL=C sort -z | xargs -0r sha256sum + ) +} + +reference_acceptance() { + export PLAYWRIGHT_BROWSERS_PATH="${REPO_ROOT}/.cache/playwright" + if ! find "${PLAYWRIGHT_BROWSERS_PATH}" -type f -name chrome-headless-shell -perm -111 -print -quit | grep -q .; then + echo "Fixed Browser is not provisioned; run scripts/dev/install-presentation-browser.sh first." >&2 + return 1 + fi + local temp first_manifest second_manifest + temp="$(mktemp -d)" + trap 'rm -rf -- "$temp"' RETURN + cp -R "${REPO_ROOT}/examples/presentation/." "${temp}/" + log "presentation(check): reference project check" + pnpm --dir "${REPO_ROOT}" --filter @unframe/presentation-cli run presentation -- check "${temp}" + log "presentation(check): Fixed Browser reference build (first)" + pnpm --dir "${REPO_ROOT}" --filter @unframe/presentation-cli run presentation -- build "${temp}" + first_manifest="$(manifest "${temp}")" + log "presentation(check): Fixed Browser reference build (second)" + pnpm --dir "${REPO_ROOT}" --filter @unframe/presentation-cli run presentation -- build "${temp}" + second_manifest="$(manifest "${temp}")" + test "${first_manifest}" = "${second_manifest}" + trap - RETURN + rm -rf "${temp}" +} + +mode="${1:-check}" +case "${mode}" in + fix) + log "presentation(fix): vp fmt" + pnpm --config.verify-deps-before-run=false exec vp fmt \ + "${REPO_ROOT}"/packages/presentation-* + ;; + check) + log "presentation(check): shared config / package checks" + pnpm --config.verify-deps-before-run=false --filter "${CONFIG_FILTER}" run check + pnpm --config.verify-deps-before-run=false \ + --filter "${PRESENTATION_AUTHORING_FILTER}" \ + --filter "${PRESENTATION_PACKAGES_FILTER}" \ + run check + reference_acceptance + ;; + *) + echo "usage: presentation.sh [check|fix]" >&2 + exit 2 + ;; +esac diff --git a/scripts/dev/install-presentation-browser.sh b/scripts/dev/install-presentation-browser.sh new file mode 100755 index 0000000..da8d476 --- /dev/null +++ b/scripts/dev/install-presentation-browser.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Presentation Fixed Browser の明示的なローカル provision。通常の check は Browser を起動しない。 +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/paths.sh +source "${DIR}/../lib/paths.sh" + +export PLAYWRIGHT_BROWSERS_PATH="${REPO_ROOT}/.cache/playwright" +mkdir -p "${PLAYWRIGHT_BROWSERS_PATH}" +pnpm --dir "${REPO_ROOT}" --filter @unframe/presentation-renderer-web exec playwright-core install chromium --only-shell diff --git a/scripts/dev/test-presentation-browser.sh b/scripts/dev/test-presentation-browser.sh new file mode 100755 index 0000000..a810494 --- /dev/null +++ b/scripts/dev/test-presentation-browser.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Provision済みの Fixed Browser を使う明示的 integration test。通常 check には含めない。 +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/paths.sh +source "${DIR}/../lib/paths.sh" + +export PLAYWRIGHT_BROWSERS_PATH="${REPO_ROOT}/.cache/playwright" +if [ ! -d "${PLAYWRIGHT_BROWSERS_PATH}" ]; then + log "Fixed Browser が未provisionです。${DIR}/install-presentation-browser.sh を先に実行してください。" + exit 1 +fi + +pnpm --dir "${REPO_ROOT}" --filter @unframe/presentation-renderer-web exec vp test run \ + test/playwright-fixed-browser.integration.test.ts diff --git a/scripts/lib/paths.sh b/scripts/lib/paths.sh index 853a069..7a329c7 100755 --- a/scripts/lib/paths.sh +++ b/scripts/lib/paths.sh @@ -18,6 +18,12 @@ export LP_DIR="${REPO_ROOT}/lp" export LP_FILTER="@unframe/site" # packages/contracts (future API / protocol boundaries) export CONTRACTS_DIR="${REPO_ROOT}/packages/contracts" +# packages/config (shared TypeScript and repository tooling configuration) +export CONFIG_FILTER="@unframe/config" +# packages/presentation-authoring (public SDK name: @unframe/presentation) +export PRESENTATION_AUTHORING_FILTER="@unframe/presentation" +# packages/presentation-* (Presentation build pipeline packages) +export PRESENTATION_PACKAGES_FILTER="@unframe/presentation-*" # Notion 同期 (scripts/docs/notion-sync) export NOTION_SYNC_FILTER="unframe-notion-sync" # 共有 git hooks (packages/config/githooks)。core.hooksPath はリポジトリ相対で設定する。