From d4e8634f67ab09d45ad7fe112df13d330dca676f Mon Sep 17 00:00:00 2001 From: Amp Date: Mon, 27 Jul 2026 02:53:44 +0000 Subject: [PATCH] feat: add render graph driven renderer architecture Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab Co-authored-by: Heaust Azure --- Cargo.lock | 1118 +---- Cargo.toml | 1 - agent-browser.json | 2 +- level-editor/src/lib.rs | 86 +- level-editor/src/program.wgsl | 6 +- package.json | 1 + renderer/Cargo.toml | 3 +- renderer/src/app_setup.rs | 47 +- renderer/src/command_ring.rs | 153 + renderer/src/gltf.rs | 404 +- renderer/src/gltf.wgsl | 10 +- renderer/src/lib.rs | 39 + renderer/src/message.rs | 2 +- .../src/platform/web/worker/mainWorker.js | 64 +- renderer/src/platform/web/worker/mod.rs | 41 +- renderer/src/render_data/handle.rs | 240 + renderer/src/render_data/mod.rs | 1141 +++++ renderer/src/render_data/range_allocator.rs | 85 + renderer/src/render_data/tests.rs | 522 +++ renderer/src/render_graph/compiler.rs | 1099 +++++ renderer/src/render_graph/mod.rs | 58 + renderer/src/render_graph/registry.rs | 114 + renderer/src/render_graph/runtime.rs | 215 + renderer/src/render_graph/schema.rs | 188 + renderer/src/render_graph/tests.rs | 702 +++ renderer/src/renderer/gpu_scene.rs | 394 ++ renderer/src/renderer/mod.rs | 1045 ++++- renderer/src/renderer/scene.rs | 355 +- renderer/src/shared_snapshot.rs | 420 ++ static/bvh-core.js | 25 + static/bvh-worker.js | 27 + static/demo-loadouts.js | 60 + static/index.html | 47 +- static/index.js | 67 +- static/render-data-snapshot.js | 95 + static/render-graph/adapter.js | 75 + static/render-graph/authoring-controller.js | 14 + static/render-graph/browser-host.js | 61 + static/render-graph/catalog.js | 29 + static/render-graph/fxnode-editor.js | 25 + static/render-graph/presets.js | 5 + static/renderer-client.js | 287 ++ tests/demo-loadouts.test.js | 10 + tests/render-graph-authoring.test.js | 15 + tests/render-graph-presets.test.js | 4 + tests/renderer-client.test.js | 135 + tests/snapshot-bvh.test.js | 130 + vendor/fxnode/.agents/ORB_WORKFLOW.md | 122 + vendor/fxnode/.agents/resume | 7 + vendor/fxnode/.agents/setup | 16 + vendor/fxnode/.amp/services.yaml | 6 + vendor/fxnode/.gitattributes | 3 + vendor/fxnode/.gitignore | 16 + vendor/fxnode/.prettierignore | 16 + vendor/fxnode/.prettierrc | 14 + vendor/fxnode/AGENTS.md | 5 + vendor/fxnode/LICENSE | 21 + vendor/fxnode/NOTICE.md | 7 + vendor/fxnode/README.md | 326 ++ vendor/fxnode/UPSTREAM.md | 25 + vendor/fxnode/docs/.vitepress/config.mts | 66 + .../worker-transport-protobuf-benchmark.md | 55 + vendor/fxnode/docs/index.md | 31 + .../fxnode/docs/learn/concepts/composition.md | 11 + .../learn/concepts/graph-state-and-events.md | 15 + vendor/fxnode/docs/learn/concepts/index.md | 10 + .../learn/concepts/state-and-persistence.md | 11 + .../docs/learn/concepts/worker-authority.md | 13 + vendor/fxnode/docs/learn/examples/index.md | 33 + .../fxnode/docs/learn/guides/accessibility.md | 5 + .../fxnode/docs/learn/guides/browser-host.md | 20 + .../docs/learn/guides/browser-support.md | 7 + vendor/fxnode/docs/learn/guides/csp.md | 5 + vendor/fxnode/docs/learn/guides/index.md | 5 + .../fxnode/docs/learn/guides/interactions.md | 7 + .../learn/guides/rendering-and-lifecycle.md | 7 + vendor/fxnode/docs/learn/index.md | 23 + .../docs/learn/tutorials/color-balance.md | 57 + .../fxnode/docs/learn/tutorials/first-node.md | 72 + vendor/fxnode/docs/learn/tutorials/index.md | 9 + .../docs/learn/tutorials/live-composition.md | 47 + vendor/fxnode/docs/reference/index.md | 9 + .../fxnode/docs/research/blender-fixtures.md | 13 + .../blender-references/4.5.0/README.md | 3 + vendor/fxnode/docs/research/blender.md | 20 + vendor/fxnode/docs/research/parity-survey.md | 60 + .../docs/research/performance-phase7.md | 24 + .../fxnode/examples/assets/color-balance.png | Bin 0 -> 42797 bytes .../examples/assets/live-composition.png | Bin 0 -> 32275 bytes vendor/fxnode/examples/assets/minimal.png | Bin 0 -> 20084 bytes vendor/fxnode/examples/assets/multi-view.png | Bin 0 -> 91682 bytes .../examples/blender/all-supported/index.html | 12 + .../blender/all-supported/initialLayout.json | 1832 ++++++++ .../examples/blender/all-supported/main.ts | 20 + .../examples/blender/all-supported/style.css | 13 + .../examples/blender/application-browser.ts | 86 + .../examples/blender/control-test/index.html | 23 + .../blender/control-test/initialLayout.json | 293 ++ .../examples/blender/control-test/main.ts | 30 + vendor/fxnode/examples/blender/globals.d.ts | 18 + vendor/fxnode/examples/blender/index.html | 304 ++ .../examples/blender/initialLayout.json | 697 +++ .../blender/link-tools-test/index.html | 23 + .../link-tools-test/initialLayout.json | 855 ++++ .../examples/blender/link-tools-test/main.ts | 30 + vendor/fxnode/examples/blender/main.ts | 35 + .../examples/blender/nodes/application.ts | 49 + .../examples/blender/nodes/common/frame.ts | 15 + .../blender/nodes/common/group-input.ts | 42 + .../blender/nodes/common/group-output.ts | 42 + .../examples/blender/nodes/common/reroute.ts | 43 + .../blender/nodes/compositor/image.ts | 170 + .../blender/nodes/geometry/join-geometry.ts | 43 + .../blender/nodes/geometry/mesh-cube.ts | 116 + .../blender/nodes/geometry/position.ts | 30 + .../blender/nodes/geometry/set-position.ts | 81 + .../nodes/geometry/transform-geometry.ts | 100 + .../blender/nodes/shader/color-ramp.ts | 127 + .../examples/blender/nodes/shader/color.ts | 44 + .../blender/nodes/shader/image-texture.ts | 157 + .../blender/nodes/shader/material-output.ts | 62 + .../examples/blender/nodes/shader/math.ts | 92 + .../examples/blender/nodes/shader/mix.ts | 117 + .../blender/nodes/shader/noise-texture.ts | 364 ++ .../blender/nodes/shader/principled-bsdf.ts | 154 + .../nodes/shader/texture-coordinate.ts | 69 + .../examples/blender/nodes/shader/value.ts | 43 + .../blender/nodes/shader/vector-math.ts | 98 + .../examples/blender/nodes/socket-types.ts | 25 + .../fxnode/examples/blender/parity/index.html | 12 + .../blender/parity/initialLayout.json | 754 ++++ vendor/fxnode/examples/blender/parity/main.ts | 20 + .../fxnode/examples/blender/parity/style.css | 13 + .../examples/blender/ramp-test/index.html | 23 + .../blender/ramp-test/initialLayout.json | 128 + .../fxnode/examples/blender/ramp-test/main.ts | 30 + vendor/fxnode/examples/blender/style.css | 30 + .../fxnode/examples/color-balance/index.html | 20 + vendor/fxnode/examples/color-balance/main.ts | 67 + vendor/fxnode/examples/gallery.css | 255 ++ vendor/fxnode/examples/index.html | 80 + .../examples/live-composition/definitions.ts | 57 + .../examples/live-composition/index.html | 22 + .../fxnode/examples/live-composition/main.ts | 82 + vendor/fxnode/examples/minimal/definition.ts | 35 + vendor/fxnode/examples/minimal/index.html | 17 + vendor/fxnode/examples/minimal/main.ts | 56 + vendor/fxnode/examples/multi-view/index.html | 45 + vendor/fxnode/examples/multi-view/main.ts | 124 + vendor/fxnode/examples/multi-view/style.css | 84 + .../fxnode/examples/shared/add-node-menu.ts | 154 + vendor/fxnode/examples/shared/browser-host.ts | 439 ++ vendor/fxnode/examples/shared/example.css | 38 + vendor/fxnode/examples/shared/globals.d.ts | 26 + .../examples/shared/nodes/color-balance.ts | 352 ++ vendor/fxnode/examples/shared/theme.ts | 33 + vendor/fxnode/package-lock.json | 3983 +++++++++++++++++ vendor/fxnode/package.json | 89 + vendor/fxnode/playwright.config.ts | 15 + vendor/fxnode/playwright.examples.config.ts | 19 + vendor/fxnode/playwright.visual.config.ts | 13 + vendor/fxnode/src/browser/client.ts | 1238 +++++ vendor/fxnode/src/browser/host-decode.ts | 229 + vendor/fxnode/src/browser/host-types.ts | 100 + vendor/fxnode/src/browser/pointer-lane.ts | 114 + vendor/fxnode/src/browser/protocol.ts | 833 ++++ vendor/fxnode/src/browser/view-limits.ts | 44 + vendor/fxnode/src/color/oklab.ts | 67 + vendor/fxnode/src/commands/limits.ts | 2 + vendor/fxnode/src/commands/save-data.ts | 201 + vendor/fxnode/src/commands/types.ts | 85 + vendor/fxnode/src/commands/validate.ts | 143 + .../fxnode/src/composition/bound-document.ts | 845 ++++ vendor/fxnode/src/composition/bound-engine.ts | 597 +++ vendor/fxnode/src/composition/compile.ts | 110 + vendor/fxnode/src/composition/compose.ts | 127 + vendor/fxnode/src/composition/index.ts | 12 + vendor/fxnode/src/composition/references.ts | 88 + .../fxnode/src/composition/resource-policy.ts | 20 + .../src/composition/save-compatibility.ts | 125 + vendor/fxnode/src/composition/types.ts | 259 ++ vendor/fxnode/src/composition/validate.ts | 751 ++++ .../fxnode/src/composition/value-matcher.ts | 33 + vendor/fxnode/src/core/json.ts | 188 + vendor/fxnode/src/core/types.ts | 113 + vendor/fxnode/src/engine/mutations.ts | 29 + vendor/fxnode/src/engine/reducer.ts | 28 + vendor/fxnode/src/headless-runtime.ts | 70 + vendor/fxnode/src/headless.ts | 29 + vendor/fxnode/src/index.ts | 30 + .../fxnode/src/layout/color-picker-layout.ts | 33 + vendor/fxnode/src/layout/constants.ts | 22 + vendor/fxnode/src/layout/geometry.ts | 27 + .../fxnode/src/layout/indexed-layout-store.ts | 53 + vendor/fxnode/src/layout/layout-graph.ts | 588 +++ vendor/fxnode/src/layout/link-mute.ts | 32 + vendor/fxnode/src/layout/node-dimensions.ts | 96 + vendor/fxnode/src/layout/spatial-index.ts | 166 + vendor/fxnode/src/layout/types.ts | 200 + vendor/fxnode/src/render/canvas-renderer.ts | 804 ++++ .../src/render/color-picker-renderer.ts | 159 + .../fxnode/src/research/reference-manifest.ts | 21 + vendor/fxnode/src/research/reference-types.ts | 48 + vendor/fxnode/src/widgets/color-ramp.ts | 188 + vendor/fxnode/src/worker/atlas-allocator.ts | 260 ++ vendor/fxnode/src/worker/control-edit.ts | 80 + vendor/fxnode/src/worker/fxnode.worker.ts | 2689 +++++++++++ vendor/fxnode/src/worker/interaction.ts | 292 ++ vendor/fxnode/src/worker/journal.ts | 103 + vendor/fxnode/src/worker/knife-path.ts | 42 + vendor/fxnode/src/worker/render-scheduler.ts | 92 + vendor/fxnode/src/worker/selection-actions.ts | 38 + vendor/fxnode/src/worker/session.ts | 151 + vendor/fxnode/src/worker/view-atlas.ts | 428 ++ vendor/fxnode/test/application.ts | 88 + vendor/fxnode/test/atlas-allocator.test.ts | 82 + .../test/browser-client-boundary.test.ts | 43 + .../fxnode/test/browser/add-node-menu.spec.ts | 141 + .../fxnode/test/browser/all-supported.spec.ts | 10 + .../all-supported-linux.png | Bin 0 -> 133972 bytes .../browser/application-live-startup.spec.ts | 129 + .../test/browser/client-multiview.spec.ts | 261 ++ .../fxnode/test/browser/client-runtime.html | 8 + vendor/fxnode/test/browser/controls.spec.ts | 277 ++ .../test/browser/examples.docs.visual.spec.ts | 10 + vendor/fxnode/test/browser/examples.spec.ts | 193 + vendor/fxnode/test/browser/fixture.ts | 27 + vendor/fxnode/test/browser/fxnode.spec.ts | 450 ++ vendor/fxnode/test/browser/globals.d.ts | 28 + vendor/fxnode/test/browser/index.html | 315 ++ .../fxnode/test/browser/interactions.spec.ts | 507 +++ vendor/fxnode/test/browser/knife-mute.spec.ts | 222 + vendor/fxnode/test/browser/lifecycle.spec.ts | 490 ++ vendor/fxnode/test/browser/link-tools.spec.ts | 177 + .../fxnode/test/browser/parity.visual.spec.ts | 51 + .../parity-image-grading-linux.png | Bin 0 -> 54272 bytes .../parity-ramp-noise-linux.png | Bin 0 -> 59961 bytes .../parity-structural-linux.png | Bin 0 -> 141472 bytes .../test/browser/presentation-render.html | 7 + .../test/browser/presentation-render.spec.ts | 28 + .../test/browser/presentation-render.ts | 130 + vendor/fxnode/test/browser/ramp.spec.ts | 274 ++ .../test/browser/requested-nodes.spec.ts | 299 ++ .../test/browser/visual-baselines.sha256 | 9 + vendor/fxnode/test/browser/visual.spec.ts | 82 + .../color-picker-linux.png | Bin 0 -> 55734 bytes .../numeric-editing-linux.png | Bin 0 -> 31476 bytes .../numeric-fields-linux.png | Bin 0 -> 31554 bytes .../phase-4-example-linux.png | Bin 0 -> 56539 bytes .../zoomed-out-lod-linux.png | Bin 0 -> 41550 bytes .../test/browser/worker-composition.html | 27 + .../test/browser/worker-composition.spec.ts | 1166 +++++ .../fxnode/test/browser/worker-composition.ts | 372 ++ .../test/browser/worker-multiview.spec.ts | 156 + vendor/fxnode/test/color-ramp-noise.test.ts | 116 + vendor/fxnode/test/command-log.test.ts | 327 ++ vendor/fxnode/test/composition-types.test.ts | 578 +++ vendor/fxnode/test/composition.test.ts | 627 +++ vendor/fxnode/test/control-edit.test.ts | 71 + vendor/fxnode/test/engine.test.ts | 397 ++ vendor/fxnode/test/headless-runtime.test.ts | 494 ++ vendor/fxnode/test/import-boundary.test.ts | 36 + vendor/fxnode/test/knife-mute.test.ts | 65 + vendor/fxnode/test/layout.test.ts | 807 ++++ vendor/fxnode/test/oklab.test.ts | 22 + .../test/persistence-migrations.test.ts | 536 +++ vendor/fxnode/test/pointer-lane.test.ts | 42 + .../test/presentation-composition.test.ts | 304 ++ vendor/fxnode/test/protocol.test.ts | 879 ++++ vendor/fxnode/test/readme-snippets.compile.ts | 41 + vendor/fxnode/test/render-scheduler.test.ts | 83 + vendor/fxnode/test/view-atlas-manager.test.ts | 311 ++ vendor/fxnode/test/worker-journal.test.ts | 70 + vendor/fxnode/tools/all-supported.ts | 132 + vendor/fxnode/tools/blender-fixture.ts | 79 + .../blender/create-reference-fixtures.py | 106 + vendor/fxnode/tools/browser-fixtures.ts | 106 + vendor/fxnode/tools/check-api-docs.ts | 83 + .../tools/check-application-composition.ts | 90 + vendor/fxnode/tools/check-package.ts | 509 +++ vendor/fxnode/tools/check-readme.ts | 45 + vendor/fxnode/tools/check-references.ts | 38 + vendor/fxnode/tools/parity.ts | 46 + vendor/fxnode/tools/performance-phase7.ts | 72 + vendor/fxnode/tsconfig.build.json | 13 + vendor/fxnode/tsconfig.json | 22 + vendor/fxnode/typedoc.json | 30 + vendor/fxnode/vite-env.d.ts | 1 + vendor/fxnode/vite.config.ts | 40 + vite.config.js | 43 +- 290 files changed, 48804 insertions(+), 1995 deletions(-) create mode 100644 renderer/src/command_ring.rs create mode 100644 renderer/src/render_data/handle.rs create mode 100644 renderer/src/render_data/mod.rs create mode 100644 renderer/src/render_data/range_allocator.rs create mode 100644 renderer/src/render_data/tests.rs create mode 100644 renderer/src/render_graph/compiler.rs create mode 100644 renderer/src/render_graph/mod.rs create mode 100644 renderer/src/render_graph/registry.rs create mode 100644 renderer/src/render_graph/runtime.rs create mode 100644 renderer/src/render_graph/schema.rs create mode 100644 renderer/src/render_graph/tests.rs create mode 100644 renderer/src/renderer/gpu_scene.rs create mode 100644 renderer/src/shared_snapshot.rs create mode 100644 static/bvh-core.js create mode 100644 static/bvh-worker.js create mode 100644 static/demo-loadouts.js create mode 100644 static/render-data-snapshot.js create mode 100644 static/render-graph/adapter.js create mode 100644 static/render-graph/authoring-controller.js create mode 100644 static/render-graph/browser-host.js create mode 100644 static/render-graph/catalog.js create mode 100644 static/render-graph/fxnode-editor.js create mode 100644 static/render-graph/presets.js create mode 100644 static/renderer-client.js create mode 100644 tests/demo-loadouts.test.js create mode 100644 tests/render-graph-authoring.test.js create mode 100644 tests/render-graph-presets.test.js create mode 100644 tests/renderer-client.test.js create mode 100644 tests/snapshot-bvh.test.js create mode 100644 vendor/fxnode/.agents/ORB_WORKFLOW.md create mode 100755 vendor/fxnode/.agents/resume create mode 100755 vendor/fxnode/.agents/setup create mode 100644 vendor/fxnode/.amp/services.yaml create mode 100644 vendor/fxnode/.gitattributes create mode 100644 vendor/fxnode/.gitignore create mode 100644 vendor/fxnode/.prettierignore create mode 100644 vendor/fxnode/.prettierrc create mode 100644 vendor/fxnode/AGENTS.md create mode 100644 vendor/fxnode/LICENSE create mode 100644 vendor/fxnode/NOTICE.md create mode 100644 vendor/fxnode/README.md create mode 100644 vendor/fxnode/UPSTREAM.md create mode 100644 vendor/fxnode/docs/.vitepress/config.mts create mode 100644 vendor/fxnode/docs/decisions/worker-transport-protobuf-benchmark.md create mode 100644 vendor/fxnode/docs/index.md create mode 100644 vendor/fxnode/docs/learn/concepts/composition.md create mode 100644 vendor/fxnode/docs/learn/concepts/graph-state-and-events.md create mode 100644 vendor/fxnode/docs/learn/concepts/index.md create mode 100644 vendor/fxnode/docs/learn/concepts/state-and-persistence.md create mode 100644 vendor/fxnode/docs/learn/concepts/worker-authority.md create mode 100644 vendor/fxnode/docs/learn/examples/index.md create mode 100644 vendor/fxnode/docs/learn/guides/accessibility.md create mode 100644 vendor/fxnode/docs/learn/guides/browser-host.md create mode 100644 vendor/fxnode/docs/learn/guides/browser-support.md create mode 100644 vendor/fxnode/docs/learn/guides/csp.md create mode 100644 vendor/fxnode/docs/learn/guides/index.md create mode 100644 vendor/fxnode/docs/learn/guides/interactions.md create mode 100644 vendor/fxnode/docs/learn/guides/rendering-and-lifecycle.md create mode 100644 vendor/fxnode/docs/learn/index.md create mode 100644 vendor/fxnode/docs/learn/tutorials/color-balance.md create mode 100644 vendor/fxnode/docs/learn/tutorials/first-node.md create mode 100644 vendor/fxnode/docs/learn/tutorials/index.md create mode 100644 vendor/fxnode/docs/learn/tutorials/live-composition.md create mode 100644 vendor/fxnode/docs/reference/index.md create mode 100644 vendor/fxnode/docs/research/blender-fixtures.md create mode 100644 vendor/fxnode/docs/research/blender-references/4.5.0/README.md create mode 100644 vendor/fxnode/docs/research/blender.md create mode 100644 vendor/fxnode/docs/research/parity-survey.md create mode 100644 vendor/fxnode/docs/research/performance-phase7.md create mode 100644 vendor/fxnode/examples/assets/color-balance.png create mode 100644 vendor/fxnode/examples/assets/live-composition.png create mode 100644 vendor/fxnode/examples/assets/minimal.png create mode 100644 vendor/fxnode/examples/assets/multi-view.png create mode 100644 vendor/fxnode/examples/blender/all-supported/index.html create mode 100644 vendor/fxnode/examples/blender/all-supported/initialLayout.json create mode 100644 vendor/fxnode/examples/blender/all-supported/main.ts create mode 100644 vendor/fxnode/examples/blender/all-supported/style.css create mode 100644 vendor/fxnode/examples/blender/application-browser.ts create mode 100644 vendor/fxnode/examples/blender/control-test/index.html create mode 100644 vendor/fxnode/examples/blender/control-test/initialLayout.json create mode 100644 vendor/fxnode/examples/blender/control-test/main.ts create mode 100644 vendor/fxnode/examples/blender/globals.d.ts create mode 100644 vendor/fxnode/examples/blender/index.html create mode 100644 vendor/fxnode/examples/blender/initialLayout.json create mode 100644 vendor/fxnode/examples/blender/link-tools-test/index.html create mode 100644 vendor/fxnode/examples/blender/link-tools-test/initialLayout.json create mode 100644 vendor/fxnode/examples/blender/link-tools-test/main.ts create mode 100644 vendor/fxnode/examples/blender/main.ts create mode 100644 vendor/fxnode/examples/blender/nodes/application.ts create mode 100644 vendor/fxnode/examples/blender/nodes/common/frame.ts create mode 100644 vendor/fxnode/examples/blender/nodes/common/group-input.ts create mode 100644 vendor/fxnode/examples/blender/nodes/common/group-output.ts create mode 100644 vendor/fxnode/examples/blender/nodes/common/reroute.ts create mode 100644 vendor/fxnode/examples/blender/nodes/compositor/image.ts create mode 100644 vendor/fxnode/examples/blender/nodes/geometry/join-geometry.ts create mode 100644 vendor/fxnode/examples/blender/nodes/geometry/mesh-cube.ts create mode 100644 vendor/fxnode/examples/blender/nodes/geometry/position.ts create mode 100644 vendor/fxnode/examples/blender/nodes/geometry/set-position.ts create mode 100644 vendor/fxnode/examples/blender/nodes/geometry/transform-geometry.ts create mode 100644 vendor/fxnode/examples/blender/nodes/shader/color-ramp.ts create mode 100644 vendor/fxnode/examples/blender/nodes/shader/color.ts create mode 100644 vendor/fxnode/examples/blender/nodes/shader/image-texture.ts create mode 100644 vendor/fxnode/examples/blender/nodes/shader/material-output.ts create mode 100644 vendor/fxnode/examples/blender/nodes/shader/math.ts create mode 100644 vendor/fxnode/examples/blender/nodes/shader/mix.ts create mode 100644 vendor/fxnode/examples/blender/nodes/shader/noise-texture.ts create mode 100644 vendor/fxnode/examples/blender/nodes/shader/principled-bsdf.ts create mode 100644 vendor/fxnode/examples/blender/nodes/shader/texture-coordinate.ts create mode 100644 vendor/fxnode/examples/blender/nodes/shader/value.ts create mode 100644 vendor/fxnode/examples/blender/nodes/shader/vector-math.ts create mode 100644 vendor/fxnode/examples/blender/nodes/socket-types.ts create mode 100644 vendor/fxnode/examples/blender/parity/index.html create mode 100644 vendor/fxnode/examples/blender/parity/initialLayout.json create mode 100644 vendor/fxnode/examples/blender/parity/main.ts create mode 100644 vendor/fxnode/examples/blender/parity/style.css create mode 100644 vendor/fxnode/examples/blender/ramp-test/index.html create mode 100644 vendor/fxnode/examples/blender/ramp-test/initialLayout.json create mode 100644 vendor/fxnode/examples/blender/ramp-test/main.ts create mode 100644 vendor/fxnode/examples/blender/style.css create mode 100644 vendor/fxnode/examples/color-balance/index.html create mode 100644 vendor/fxnode/examples/color-balance/main.ts create mode 100644 vendor/fxnode/examples/gallery.css create mode 100644 vendor/fxnode/examples/index.html create mode 100644 vendor/fxnode/examples/live-composition/definitions.ts create mode 100644 vendor/fxnode/examples/live-composition/index.html create mode 100644 vendor/fxnode/examples/live-composition/main.ts create mode 100644 vendor/fxnode/examples/minimal/definition.ts create mode 100644 vendor/fxnode/examples/minimal/index.html create mode 100644 vendor/fxnode/examples/minimal/main.ts create mode 100644 vendor/fxnode/examples/multi-view/index.html create mode 100644 vendor/fxnode/examples/multi-view/main.ts create mode 100644 vendor/fxnode/examples/multi-view/style.css create mode 100644 vendor/fxnode/examples/shared/add-node-menu.ts create mode 100644 vendor/fxnode/examples/shared/browser-host.ts create mode 100644 vendor/fxnode/examples/shared/example.css create mode 100644 vendor/fxnode/examples/shared/globals.d.ts create mode 100644 vendor/fxnode/examples/shared/nodes/color-balance.ts create mode 100644 vendor/fxnode/examples/shared/theme.ts create mode 100644 vendor/fxnode/package-lock.json create mode 100644 vendor/fxnode/package.json create mode 100644 vendor/fxnode/playwright.config.ts create mode 100644 vendor/fxnode/playwright.examples.config.ts create mode 100644 vendor/fxnode/playwright.visual.config.ts create mode 100644 vendor/fxnode/src/browser/client.ts create mode 100644 vendor/fxnode/src/browser/host-decode.ts create mode 100644 vendor/fxnode/src/browser/host-types.ts create mode 100644 vendor/fxnode/src/browser/pointer-lane.ts create mode 100644 vendor/fxnode/src/browser/protocol.ts create mode 100644 vendor/fxnode/src/browser/view-limits.ts create mode 100644 vendor/fxnode/src/color/oklab.ts create mode 100644 vendor/fxnode/src/commands/limits.ts create mode 100644 vendor/fxnode/src/commands/save-data.ts create mode 100644 vendor/fxnode/src/commands/types.ts create mode 100644 vendor/fxnode/src/commands/validate.ts create mode 100644 vendor/fxnode/src/composition/bound-document.ts create mode 100644 vendor/fxnode/src/composition/bound-engine.ts create mode 100644 vendor/fxnode/src/composition/compile.ts create mode 100644 vendor/fxnode/src/composition/compose.ts create mode 100644 vendor/fxnode/src/composition/index.ts create mode 100644 vendor/fxnode/src/composition/references.ts create mode 100644 vendor/fxnode/src/composition/resource-policy.ts create mode 100644 vendor/fxnode/src/composition/save-compatibility.ts create mode 100644 vendor/fxnode/src/composition/types.ts create mode 100644 vendor/fxnode/src/composition/validate.ts create mode 100644 vendor/fxnode/src/composition/value-matcher.ts create mode 100644 vendor/fxnode/src/core/json.ts create mode 100644 vendor/fxnode/src/core/types.ts create mode 100644 vendor/fxnode/src/engine/mutations.ts create mode 100644 vendor/fxnode/src/engine/reducer.ts create mode 100644 vendor/fxnode/src/headless-runtime.ts create mode 100644 vendor/fxnode/src/headless.ts create mode 100644 vendor/fxnode/src/index.ts create mode 100644 vendor/fxnode/src/layout/color-picker-layout.ts create mode 100644 vendor/fxnode/src/layout/constants.ts create mode 100644 vendor/fxnode/src/layout/geometry.ts create mode 100644 vendor/fxnode/src/layout/indexed-layout-store.ts create mode 100644 vendor/fxnode/src/layout/layout-graph.ts create mode 100644 vendor/fxnode/src/layout/link-mute.ts create mode 100644 vendor/fxnode/src/layout/node-dimensions.ts create mode 100644 vendor/fxnode/src/layout/spatial-index.ts create mode 100644 vendor/fxnode/src/layout/types.ts create mode 100644 vendor/fxnode/src/render/canvas-renderer.ts create mode 100644 vendor/fxnode/src/render/color-picker-renderer.ts create mode 100644 vendor/fxnode/src/research/reference-manifest.ts create mode 100644 vendor/fxnode/src/research/reference-types.ts create mode 100644 vendor/fxnode/src/widgets/color-ramp.ts create mode 100644 vendor/fxnode/src/worker/atlas-allocator.ts create mode 100644 vendor/fxnode/src/worker/control-edit.ts create mode 100644 vendor/fxnode/src/worker/fxnode.worker.ts create mode 100644 vendor/fxnode/src/worker/interaction.ts create mode 100644 vendor/fxnode/src/worker/journal.ts create mode 100644 vendor/fxnode/src/worker/knife-path.ts create mode 100644 vendor/fxnode/src/worker/render-scheduler.ts create mode 100644 vendor/fxnode/src/worker/selection-actions.ts create mode 100644 vendor/fxnode/src/worker/session.ts create mode 100644 vendor/fxnode/src/worker/view-atlas.ts create mode 100644 vendor/fxnode/test/application.ts create mode 100644 vendor/fxnode/test/atlas-allocator.test.ts create mode 100644 vendor/fxnode/test/browser-client-boundary.test.ts create mode 100644 vendor/fxnode/test/browser/add-node-menu.spec.ts create mode 100644 vendor/fxnode/test/browser/all-supported.spec.ts create mode 100644 vendor/fxnode/test/browser/all-supported.spec.ts-snapshots/all-supported-linux.png create mode 100644 vendor/fxnode/test/browser/application-live-startup.spec.ts create mode 100644 vendor/fxnode/test/browser/client-multiview.spec.ts create mode 100644 vendor/fxnode/test/browser/client-runtime.html create mode 100644 vendor/fxnode/test/browser/controls.spec.ts create mode 100644 vendor/fxnode/test/browser/examples.docs.visual.spec.ts create mode 100644 vendor/fxnode/test/browser/examples.spec.ts create mode 100644 vendor/fxnode/test/browser/fixture.ts create mode 100644 vendor/fxnode/test/browser/fxnode.spec.ts create mode 100644 vendor/fxnode/test/browser/globals.d.ts create mode 100644 vendor/fxnode/test/browser/index.html create mode 100644 vendor/fxnode/test/browser/interactions.spec.ts create mode 100644 vendor/fxnode/test/browser/knife-mute.spec.ts create mode 100644 vendor/fxnode/test/browser/lifecycle.spec.ts create mode 100644 vendor/fxnode/test/browser/link-tools.spec.ts create mode 100644 vendor/fxnode/test/browser/parity.visual.spec.ts create mode 100644 vendor/fxnode/test/browser/parity.visual.spec.ts-snapshots/parity-image-grading-linux.png create mode 100644 vendor/fxnode/test/browser/parity.visual.spec.ts-snapshots/parity-ramp-noise-linux.png create mode 100644 vendor/fxnode/test/browser/parity.visual.spec.ts-snapshots/parity-structural-linux.png create mode 100644 vendor/fxnode/test/browser/presentation-render.html create mode 100644 vendor/fxnode/test/browser/presentation-render.spec.ts create mode 100644 vendor/fxnode/test/browser/presentation-render.ts create mode 100644 vendor/fxnode/test/browser/ramp.spec.ts create mode 100644 vendor/fxnode/test/browser/requested-nodes.spec.ts create mode 100644 vendor/fxnode/test/browser/visual-baselines.sha256 create mode 100644 vendor/fxnode/test/browser/visual.spec.ts create mode 100644 vendor/fxnode/test/browser/visual.spec.ts-snapshots/color-picker-linux.png create mode 100644 vendor/fxnode/test/browser/visual.spec.ts-snapshots/numeric-editing-linux.png create mode 100644 vendor/fxnode/test/browser/visual.spec.ts-snapshots/numeric-fields-linux.png create mode 100644 vendor/fxnode/test/browser/visual.spec.ts-snapshots/phase-4-example-linux.png create mode 100644 vendor/fxnode/test/browser/visual.spec.ts-snapshots/zoomed-out-lod-linux.png create mode 100644 vendor/fxnode/test/browser/worker-composition.html create mode 100644 vendor/fxnode/test/browser/worker-composition.spec.ts create mode 100644 vendor/fxnode/test/browser/worker-composition.ts create mode 100644 vendor/fxnode/test/browser/worker-multiview.spec.ts create mode 100644 vendor/fxnode/test/color-ramp-noise.test.ts create mode 100644 vendor/fxnode/test/command-log.test.ts create mode 100644 vendor/fxnode/test/composition-types.test.ts create mode 100644 vendor/fxnode/test/composition.test.ts create mode 100644 vendor/fxnode/test/control-edit.test.ts create mode 100644 vendor/fxnode/test/engine.test.ts create mode 100644 vendor/fxnode/test/headless-runtime.test.ts create mode 100644 vendor/fxnode/test/import-boundary.test.ts create mode 100644 vendor/fxnode/test/knife-mute.test.ts create mode 100644 vendor/fxnode/test/layout.test.ts create mode 100644 vendor/fxnode/test/oklab.test.ts create mode 100644 vendor/fxnode/test/persistence-migrations.test.ts create mode 100644 vendor/fxnode/test/pointer-lane.test.ts create mode 100644 vendor/fxnode/test/presentation-composition.test.ts create mode 100644 vendor/fxnode/test/protocol.test.ts create mode 100644 vendor/fxnode/test/readme-snippets.compile.ts create mode 100644 vendor/fxnode/test/render-scheduler.test.ts create mode 100644 vendor/fxnode/test/view-atlas-manager.test.ts create mode 100644 vendor/fxnode/test/worker-journal.test.ts create mode 100644 vendor/fxnode/tools/all-supported.ts create mode 100644 vendor/fxnode/tools/blender-fixture.ts create mode 100644 vendor/fxnode/tools/blender/create-reference-fixtures.py create mode 100644 vendor/fxnode/tools/browser-fixtures.ts create mode 100644 vendor/fxnode/tools/check-api-docs.ts create mode 100644 vendor/fxnode/tools/check-application-composition.ts create mode 100644 vendor/fxnode/tools/check-package.ts create mode 100644 vendor/fxnode/tools/check-readme.ts create mode 100644 vendor/fxnode/tools/check-references.ts create mode 100644 vendor/fxnode/tools/parity.ts create mode 100644 vendor/fxnode/tools/performance-phase7.ts create mode 100644 vendor/fxnode/tsconfig.build.json create mode 100644 vendor/fxnode/tsconfig.json create mode 100644 vendor/fxnode/typedoc.json create mode 100644 vendor/fxnode/vite-env.d.ts create mode 100644 vendor/fxnode/vite.config.ts diff --git a/Cargo.lock b/Cargo.lock index 2bab96e..bdf5757 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" @@ -50,45 +41,18 @@ dependencies = [ "libloading", ] -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] - [[package]] name = "base64" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - [[package]] name = "bit-set" version = "0.8.0" @@ -163,21 +127,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" -[[package]] -name = "bytes" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" - -[[package]] -name = "cc" -version = "1.2.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ee0f8803222ba5a7e2777dd72ca451868909b1ac410621b676adf07280e9b5f" -dependencies = [ - "shlex", -] - [[package]] name = "cfg-if" version = "1.0.1" @@ -231,16 +180,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -264,7 +203,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.9.1", - "core-foundation 0.10.1", + "core-foundation", "libc", ] @@ -283,17 +222,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "document-features" version = "0.2.11" @@ -303,37 +231,12 @@ dependencies = [ "litrs", ] -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "errno" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - [[package]] name = "fdeflate" version = "0.3.7" @@ -353,27 +256,12 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared 0.1.1", -] - [[package]] name = "foreign-types" version = "0.5.0" @@ -381,7 +269,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared 0.3.1", + "foreign-types-shared", ] [[package]] @@ -395,27 +283,12 @@ dependencies = [ "syn", ] -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "foreign-types-shared" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" -[[package]] -name = "form_urlencoded" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" -dependencies = [ - "percent-encoding", -] - [[package]] name = "futures" version = "0.3.31" @@ -505,35 +378,6 @@ dependencies = [ "slab", ] -[[package]] -name = "getrandom" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", -] - -[[package]] -name = "getrandom" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasi 0.14.2+wasi-0.2.4", -] - -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - [[package]] name = "gl_generator" version = "0.14.0" @@ -563,7 +407,7 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7" dependencies = [ - "base64 0.13.1", + "base64", "byteorder", "gltf-json", "image", @@ -656,25 +500,6 @@ dependencies = [ "bitflags 2.9.1", ] -[[package]] -name = "h2" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "half" version = "2.6.0" @@ -701,231 +526,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" -[[package]] -name = "http" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "hyper" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "h2", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "system-configuration", - "tokio", - "tower-service", - "tracing", - "windows-registry", -] - -[[package]] -name = "icu_collections" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" - -[[package]] -name = "icu_properties" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "potential_utf", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" - -[[package]] -name = "icu_provider" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" -dependencies = [ - "displaydoc", - "icu_locale_core", - "stable_deref_trait", - "tinystr", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - [[package]] name = "image" version = "0.25.6" @@ -956,33 +556,6 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" -[[package]] -name = "io-uring" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" -dependencies = [ - "bitflags 2.9.1", - "cfg-if", - "libc", -] - -[[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "itoa" version = "1.0.15" @@ -1067,18 +640,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" -[[package]] -name = "linux-raw-sys" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" - -[[package]] -name = "litemap" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" - [[package]] name = "litrs" version = "0.4.2" @@ -1125,18 +686,12 @@ dependencies = [ "bitflags 2.9.1", "block", "core-graphics-types", - "foreign-types 0.5.0", + "foreign-types", "log", "objc", "paste", ] -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1147,17 +702,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "mio" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" -dependencies = [ - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.59.0", -] - [[package]] name = "naga" version = "26.0.0" @@ -1184,23 +728,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -1229,65 +756,12 @@ dependencies = [ "malloc_buf", ] -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -[[package]] -name = "openssl" -version = "0.10.73" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" -dependencies = [ - "bitflags 2.9.1", - "cfg-if", - "foreign-types 0.3.2", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-sys" -version = "0.9.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "ordered-float" version = "5.0.0" @@ -1326,12 +800,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "percent-encoding" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" - [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1378,15 +846,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "potential_utf" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" -dependencies = [ - "zerovec", -] - [[package]] name = "presser" version = "0.3.1" @@ -1417,12 +876,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "range-alloc" version = "0.1.4" @@ -1463,7 +916,8 @@ dependencies = [ "js-sys", "log", "raw-window-handle", - "reqwest", + "serde", + "serde_json", "thiserror 2.0.15", "ultraviolet", "wasm-bindgen", @@ -1473,118 +927,12 @@ dependencies = [ "wgpu", ] -[[package]] -name = "reqwest" -version = "0.12.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" -dependencies = [ - "base64 0.22.1", - "bytes", - "encoding_rs", - "futures-core", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-tls", - "hyper-util", - "js-sys", - "log", - "mime", - "native-tls", - "percent-encoding", - "pin-project-lite", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-native-tls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.16", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" - [[package]] name = "rustc-hash" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" -[[package]] -name = "rustix" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" -dependencies = [ - "bitflags 2.9.1", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustls" -version = "0.23.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" -dependencies = [ - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.21" @@ -1606,44 +954,12 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "schannel" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" -dependencies = [ - "windows-sys 0.59.0", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.9.1", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "serde" version = "1.0.219" @@ -1676,24 +992,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "simd-adler32" version = "0.3.7" @@ -1721,16 +1019,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "socket2" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "spirv" version = "0.3.0+sdk-1.3.268.0" @@ -1740,24 +1028,12 @@ dependencies = [ "bitflags 2.9.1", ] -[[package]] -name = "stable_deref_trait" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" - [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "2.0.104" @@ -1769,60 +1045,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "system-configuration" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" -dependencies = [ - "bitflags 2.9.1", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tempfile" -version = "3.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" -dependencies = [ - "fastrand", - "getrandom 0.3.3", - "once_cell", - "rustix", - "windows-sys 0.59.0", -] - [[package]] name = "termcolor" version = "1.4.1" @@ -1872,136 +1094,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tinystr" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tokio" -version = "1.47.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" -dependencies = [ - "backtrace", - "bytes", - "io-uring", - "libc", - "mio", - "pin-project-lite", - "slab", - "socket2", - "windows-sys 0.59.0", -] - -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tower" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" -dependencies = [ - "bitflags 2.9.1", - "bytes", - "futures-util", - "http", - "http-body", - "iri-string", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" -dependencies = [ - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "ultraviolet" version = "0.10.0" @@ -2023,71 +1115,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", -] - [[package]] name = "urlencoding" version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" -dependencies = [ - "wit-bindgen-rt", -] - [[package]] name = "wasm-bindgen" version = "0.2.104" @@ -2346,7 +1385,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.59.0", + "windows-sys", ] [[package]] @@ -2367,8 +1406,8 @@ checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" dependencies = [ "windows-implement", "windows-interface", - "windows-result 0.2.0", - "windows-strings 0.1.0", + "windows-result", + "windows-strings", "windows-targets 0.52.6", ] @@ -2400,17 +1439,6 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" -[[package]] -name = "windows-registry" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" -dependencies = [ - "windows-link", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - [[package]] name = "windows-result" version = "0.2.0" @@ -2420,40 +1448,13 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-strings" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" dependencies = [ - "windows-result 0.2.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ + "windows-result", "windows-targets 0.52.6", ] @@ -2595,111 +1596,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" -[[package]] -name = "wit-bindgen-rt" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags 2.9.1", -] - -[[package]] -name = "writeable" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" - [[package]] name = "xml-rs" version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7" -[[package]] -name = "yoke" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" -dependencies = [ - "serde", - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" - -[[package]] -name = "zerotrie" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index aafe135..411470e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,6 @@ bytemuck = { version = "1.23.1", features = ["derive"] } cgmath = "0.18" raw-window-handle = "0.6.2" wgpu = "26.0.1" -reqwest = { version = "0.12.23", features = ["json"] } thiserror = "2.0.15" ultraviolet = "0.10.0" futures = "0.3" diff --git a/agent-browser.json b/agent-browser.json index bd6a3b0..1f03968 100644 --- a/agent-browser.json +++ b/agent-browser.json @@ -1,5 +1,5 @@ { "$schema": "https://agent-browser.dev/schema.json", "webgpu": true, - "args": "--enable-unsafe-webgpu,--enable-features=Vulkan,--use-angle=vulkan,--use-vulkan=swiftshader,--use-webgpu-adapter=swiftshader,--disable-vulkan-surface" + "args": "--enable-unsafe-webgpu,--enable-features=Vulkan,--use-angle=vulkan,--use-vulkan=swiftshader,--use-webgpu-adapter=swiftshader" } diff --git a/level-editor/src/lib.rs b/level-editor/src/lib.rs index 0d9f507..f198b87 100644 --- a/level-editor/src/lib.rs +++ b/level-editor/src/lib.rs @@ -6,15 +6,16 @@ use wasm_bindgen::prelude::*; use renderer::app_setup::WebApp; use renderer::camera::Camera; use renderer::message::WindowEvent; +use renderer::render_data::{MeshCreateInfo, RenderData, RenderFlags}; use renderer::renderer as gpu_renderer; -use renderer::renderer::scene::{mesh_vertex_layout, FrameMetadata, Mesh, MeshBuilder}; +use renderer::renderer::gpu_scene::vertex_layouts; +use renderer::renderer::scene::FrameMetadata; /// Simple vertex format. #[repr(C)] #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] struct Vertex { pos: [f32; 3], - color: [f32; 3], } pub struct EditorScene { @@ -23,13 +24,13 @@ pub struct EditorScene { bind_group_layouts: [wgpu::BindGroupLayout; 2], frame_metadata: FrameMetadata, cam: Camera, - meshes: Vec, } impl renderer::renderer::scene::Scene for EditorScene { fn setup( renderer_context: &gpu_renderer::RendererContext, resources: &mut gpu_renderer::GpuResources, + render_data: &mut RenderData, ) -> Self { let dimension = ultraviolet::Vec2::new( renderer_context.surface_config.width as f32, @@ -57,12 +58,12 @@ impl renderer::renderer::scene::Scene for EditorScene { bind_group_layouts, frame_metadata, cam: camera, - meshes: Vec::new(), }; scene.create_default_scene( &renderer_context.device, resources, + render_data, renderer_context.surface_config.format, ); @@ -77,18 +78,14 @@ impl renderer::renderer::scene::Scene for EditorScene { Some(&mut self.cam) } - fn uniform_buffers(&self) -> Option<&[wgpu::Buffer]> { - Some(&self.uniform_buffers) + fn uniform_buffers(&self) -> Option<[&wgpu::Buffer; 2]> { + Some([&self.uniform_buffers[0], &self.uniform_buffers[1]]) } fn bind_groups(&self) -> &[wgpu::BindGroup] { &self.bind_groups } - fn meshes(&self) -> &[Mesh] { - &self.meshes - } - fn handle_mouse_click(&mut self, x: f32, y: f32) { self.frame_metadata.mouse_click = [x, y]; } @@ -101,14 +98,6 @@ impl renderer::renderer::scene::Scene for EditorScene { self.cam.orbit(delta_x, delta_y); } - fn clear(&mut self) { - self.meshes.clear(); - } - - fn add_mesh(&mut self, mesh: Mesh) { - self.meshes.push(mesh); - } - fn set_camera_depth_range(&mut self, near: f32, far: f32) { self.cam.set_depth_range(near, far); } @@ -135,28 +124,22 @@ impl EditorScene { // First triangle of quad Vertex { pos: [-5.0, 0.0, -5.0], - color: [0.2, 0.8, 0.2], // Green }, Vertex { pos: [5.0, 0.0, -5.0], - color: [0.2, 0.8, 0.2], // Green }, Vertex { pos: [-5.0, 0.0, 5.0], - color: [0.2, 0.8, 0.2], // Green }, // Second triangle of quad Vertex { pos: [5.0, 0.0, -5.0], - color: [0.2, 0.8, 0.2], // Green }, Vertex { pos: [5.0, 0.0, 5.0], - color: [0.2, 0.8, 0.2], // Green }, Vertex { pos: [-5.0, 0.0, 5.0], - color: [0.2, 0.8, 0.2], // Green }, ]; // Wind the ground plane so the upward-facing side is front-facing (CCW from @@ -167,6 +150,7 @@ impl EditorScene { &mut self, device: &wgpu::Device, resources: &mut gpu_renderer::GpuResources, + render_data: &mut RenderData, surface_format: wgpu::TextureFormat, ) { let positions: Vec<[f32; 3]> = Self::VERTICES.iter().map(|v| v.pos).collect(); @@ -181,7 +165,7 @@ impl EditorScene { [0.0, 1.0], ]; - let vertex_layout = mesh_vertex_layout(); + let vertex_layout = vertex_layouts(); let pipeline_index = resources.get_or_create_pipeline( device, @@ -194,28 +178,52 @@ impl EditorScene { let scale_factor = 100.0; let scale_matrix = Mat4::from_scale(scale_factor); - let mesh = MeshBuilder::default() - .with_vertices(device, resources, &positions, &normals, uvs) - .with_indices(device, resources, Self::INDICES) - .with_pipeline(pipeline_index) - .with_model_matrix(device, resources, scale_matrix) - .build(); - - self.meshes.push(mesh); + let transform: [[f32; 4]; 4] = scale_matrix.into(); + render_data + .create_mesh(MeshCreateInfo { + positions: &positions, + normals: &normals, + uvs, + indices: Self::INDICES, + pipeline: pipeline_index, + flags: RenderFlags::VISIBLE, + default_instance_flags: RenderFlags::VISIBLE, + default_transform: transform, + }) + .expect("ground plane geometry is valid"); } } /// Entrypoint for the level editor #[wasm_bindgen] -pub fn main() { +pub fn main() -> Result { std::panic::set_hook(Box::new(console_error_panic_hook::hook)); wasm_logger::init(wasm_logger::Config::default()); - wasm_bindgen_futures::spawn_local(async { - let runtime = LevelEditor::setup_runtime().unwrap(); - // Keep the runtime running and prevent drops - Box::leak(Box::new(runtime)); - }); + let runtime = LevelEditor::setup_runtime()?; + Ok(RendererBridge { runtime }) +} + +/// Opaque owner of the worker, event listeners, and pinned command ring. +#[wasm_bindgen] +pub struct RendererBridge { + runtime: renderer::app_setup::WebAppRuntime, +} + +#[wasm_bindgen] +impl RendererBridge { + #[wasm_bindgen(getter)] + pub fn worker(&self) -> web_sys::Worker { + web_sys::Worker::clone(&*self.runtime.worker()) + } + #[wasm_bindgen(getter, js_name = ringPtr)] + pub fn ring_ptr(&self) -> u32 { + self.runtime.ring_ptr() + } + #[wasm_bindgen(getter)] + pub fn memory(&self) -> JsValue { + wasm_bindgen::memory() + } } renderer::export_worker_entrypoint!(); diff --git a/level-editor/src/program.wgsl b/level-editor/src/program.wgsl index e3dd314..f88c9e5 100644 --- a/level-editor/src/program.wgsl +++ b/level-editor/src/program.wgsl @@ -18,6 +18,9 @@ struct VertexInput { @location(4) model_col1: vec4, @location(5) model_col2: vec4, @location(6) model_col3: vec4, + @location(7) normal_col0: vec4, + @location(8) normal_col1: vec4, + @location(9) normal_col2: vec4, } struct VertexOutput { @@ -39,7 +42,8 @@ fn vs_main(in: VertexInput) -> VertexOutput { let world_position = model * vec4(in.pos, 1.0); out.clip_position = view_proj * world_position; out.world_pos = world_position.xyz; - out.normal = normalize(in.normal); + let normal_matrix = mat3x3(in.normal_col0.xyz, in.normal_col1.xyz, in.normal_col2.xyz); + out.normal = normalize(normal_matrix * in.normal); return out; } diff --git a/package.json b/package.json index c4a7f60..130115e 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "build-release": "run-s clean wasm-release bundle-release", "build-renderer": "run-s clean wasm-renderer-dev bundle-dev", "build-renderer-release": "run-s clean wasm-renderer-release bundle-release", + "test:js": "node --test tests/*.test.js", "start": "vite preview", "clean": "rimraf --glob dist **/pkg", "clean-all": "rimraf --glob dist **/pkg target node_modules" diff --git a/renderer/Cargo.toml b/renderer/Cargo.toml index 86ff745..4bede4e 100644 --- a/renderer/Cargo.toml +++ b/renderer/Cargo.toml @@ -42,11 +42,12 @@ bytemuck = { workspace = true } cgmath = { workspace = true } raw-window-handle = { workspace = true } wgpu = { workspace = true } -reqwest = { workspace = true } thiserror = { workspace = true } ultraviolet = { workspace = true } futures = { workspace = true } gltf = { workspace = true } +serde = { version = "1", features = ["derive"] } +serde_json = "1" [package.metadata.wasm-pack.profile.release] wasm-opt = false diff --git a/renderer/src/app_setup.rs b/renderer/src/app_setup.rs index 14b0b7d..2eee25a 100644 --- a/renderer/src/app_setup.rs +++ b/renderer/src/app_setup.rs @@ -3,11 +3,7 @@ use wasm_bindgen::closure::Closure; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; -#[cfg(target_arch = "wasm32")] -use web_sys::AddEventListenerOptions; -#[cfg(target_arch = "wasm32")] -use wgpu::Error; - +use crate::command_ring::CommandRing; use crate::message::WindowEvent; #[cfg(target_arch = "wasm32")] use crate::platform::web; @@ -15,6 +11,8 @@ use crate::platform::web; use crate::platform::web::worker::MainWorker; #[cfg(target_arch = "wasm32")] use wasm_bindgen_futures::spawn_local; +#[cfg(target_arch = "wasm32")] +use web_sys::AddEventListenerOptions; /// Helper struct to store event listener closures #[cfg(target_arch = "wasm32")] @@ -41,16 +39,20 @@ impl EventListeners { /// Setup default window event listeners that forward events to the worker thread #[cfg(target_arch = "wasm32")] -pub fn setup_event_listeners(worker_chan: &Sender) -> Result { +pub fn setup_event_listeners( + worker_chan: &Sender, + canvas: &web_sys::HtmlCanvasElement, +) -> Result { let window = web_sys::window().unwrap(); let resize_worker_chan = worker_chan.clone(); + let resize_canvas = canvas.clone(); let resize_listener: Closure = Closure::new(move || { use crate::message::ResizeMessage; let window = web_sys::window().unwrap(); - let width = window.inner_width().ok().unwrap().as_f64().unwrap(); - let height = window.inner_height().ok().unwrap().as_f64().unwrap(); + let width = f64::from(resize_canvas.client_width().max(1)); + let height = f64::from(resize_canvas.client_height().max(1)); resize_worker_chan .send(WindowEvent::Resize(ResizeMessage { @@ -155,29 +157,41 @@ pub struct WebAppRuntime { worker: MainWorker, worker_chan: Sender, _event_listeners: EventListeners, + ring: Box, } #[cfg(target_arch = "wasm32")] impl WebAppRuntime { /// Initialize the web worker, canvas ownership, and event listeners. - pub fn new(worker_name: &str, canvas_selector: &str) -> Result { + pub fn new( + worker_name: &str, + canvas_selector: &str, + ) -> Result { let (sender, receiver) = mpsc::channel::(); let canvas = web::get_canvas_element(canvas_selector); - let worker = MainWorker::spawn(worker_name, 1, move || { + let window = web_sys::window().unwrap(); + let dpr = window.device_pixel_ratio(); + canvas.set_width((canvas.client_width() as f64 * dpr).round() as u32); + canvas.set_height((canvas.client_height() as f64 * dpr).round() as u32); + let ring = CommandRing::new(); + let ring_ptr = ring.ptr(); + let worker = MainWorker::spawn(worker_name, 1, ring_ptr, move || { spawn_local(async move { - MainWorker::run_render_loop::(receiver).await; + let ring = unsafe { &*(ring_ptr as *const CommandRing) }; + MainWorker::run_render_loop::(receiver, ring).await; }); })?; worker.transfer_ownership(&canvas); - let event_listeners = setup_event_listeners(&sender)?; + let event_listeners = setup_event_listeners(&sender, &canvas)?; Ok(Self { worker, worker_chan: sender, _event_listeners: event_listeners, + ring, }) } @@ -190,6 +204,9 @@ impl WebAppRuntime { pub fn worker(&self) -> &MainWorker { &self.worker } + pub fn ring_ptr(&self) -> u32 { + self.ring.ptr() + } } /// Trait for applications that rely on the renderer's default WASM setup. @@ -212,10 +229,8 @@ pub trait WebApp { /// Perform the default WASM initialization routine. fn setup_runtime() -> Result { - let mut runtime = WebAppRuntime::new::( - Self::worker_name(), - Self::canvas_selector(), - )?; + let mut runtime = + WebAppRuntime::new::(Self::worker_name(), Self::canvas_selector())?; Self::on_runtime_initialized(&mut runtime); Ok(runtime) } diff --git a/renderer/src/command_ring.rs b/renderer/src/command_ring.rs new file mode 100644 index 0000000..d03d088 --- /dev/null +++ b/renderer/src/command_ring.rs @@ -0,0 +1,153 @@ +//! Versioned, fixed-slot SPSC command transport in shared WebAssembly memory. +use std::sync::atomic::{AtomicU32, Ordering}; + +pub const MAGIC: u32 = u32::from_le_bytes(*b"YAWN"); +pub const VERSION: u32 = 1; +pub const CAPACITY: usize = 1024; +pub const SLOT_WORDS: usize = 24; +pub const SLOT_BYTES: usize = 96; +pub const HEADER_BYTES: usize = 64; +pub const SLOT_VERSION: u32 = 1; +const STATE_OPEN: u32 = 0; +const STATE_CORRUPT: u32 = 1; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RingError { + Closed, + Backlog, + SlotVersion, + ZeroRequest, +} + +#[repr(C, align(64))] +pub struct CommandRing { + header: [AtomicU32; 16], + slots: [[AtomicU32; SLOT_WORDS]; CAPACITY], +} + +impl CommandRing { + pub fn new() -> Box { + let ring = Box::new(Self { + header: std::array::from_fn(|_| AtomicU32::new(0)), + slots: std::array::from_fn(|_| std::array::from_fn(|_| AtomicU32::new(0))), + }); + ring.header[0].store(MAGIC, Ordering::Relaxed); + ring.header[1].store(VERSION, Ordering::Relaxed); + ring.header[2].store(CAPACITY as u32, Ordering::Relaxed); + ring.header[3].store(SLOT_WORDS as u32, Ordering::Relaxed); + ring + } + + pub fn ptr(&self) -> u32 { + self as *const Self as usize as u32 + } + + /// Consumer-only. The producer publishes word zero (slot version) last, then write_index. + pub fn drain(&self, mut visit: impl FnMut([u32; SLOT_WORDS])) -> Result<(), RingError> { + if self.header[6].load(Ordering::Acquire) != STATE_OPEN { + return Err(RingError::Closed); + } + let mut read = self.header[4].load(Ordering::Relaxed); + let write = self.header[5].load(Ordering::Acquire); + if write.wrapping_sub(read) > CAPACITY as u32 { + self.header[6].store(STATE_CORRUPT, Ordering::Release); + return Err(RingError::Backlog); + } + while read != write { + let slot = &self.slots[read as usize % CAPACITY]; + let mut words = [0; SLOT_WORDS]; + for (out, word) in words.iter_mut().zip(slot) { + *out = word.load(Ordering::Relaxed); + } + let error = if words[0] != SLOT_VERSION { + Some(RingError::SlotVersion) + } else if words[2] == 0 { + Some(RingError::ZeroRequest) + } else { + None + }; + if let Some(error) = error { + self.header[6].store(STATE_CORRUPT, Ordering::Release); + return Err(error); + } + visit(words); + read = read.wrapping_add(1); + self.header[4].store(read, Ordering::Release); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn exact_layout() { + assert_eq!(std::mem::size_of::<[AtomicU32; 16]>(), HEADER_BYTES); + assert_eq!(std::mem::size_of::<[AtomicU32; SLOT_WORDS]>(), SLOT_BYTES); + assert_eq!( + std::mem::size_of::(), + HEADER_BYTES + CAPACITY * SLOT_BYTES + ); + assert_eq!(std::mem::align_of::(), 64); + } + #[test] + fn tagged_header_and_fifo_drain() { + let ring = CommandRing::new(); + assert_eq!(ring.header[0].load(Ordering::Relaxed), MAGIC); + assert_eq!(ring.header[1].load(Ordering::Relaxed), VERSION); + ring.slots[0][0].store(SLOT_VERSION, Ordering::Relaxed); + ring.slots[0][1].store(7, Ordering::Relaxed); + ring.slots[0][2].store(99, Ordering::Relaxed); + ring.header[5].store(1, Ordering::Release); + let mut seen = vec![]; + ring.drain(|w| seen.push((w[1], w[2]))).unwrap(); + assert_eq!(seen, [(7, 99)]); + assert_eq!(ring.header[4].load(Ordering::Acquire), 1); + } + #[test] + fn wraps_slots() { + let ring = CommandRing::new(); + ring.header[4].store(CAPACITY as u32, Ordering::Relaxed); + ring.slots[0][0].store(SLOT_VERSION, Ordering::Relaxed); + ring.slots[0][1].store(3, Ordering::Relaxed); + ring.slots[0][2].store(1, Ordering::Relaxed); + ring.header[5].store(CAPACITY as u32 + 1, Ordering::Release); + let mut opcode = 0; + ring.drain(|w| opcode = w[1]).unwrap(); + assert_eq!(opcode, 3); + } + + #[test] + fn malformed_slot_fails_closed() { + for (version, request, expected) in [ + (2, 1, RingError::SlotVersion), + (SLOT_VERSION, 0, RingError::ZeroRequest), + ] { + let ring = CommandRing::new(); + ring.slots[0][0].store(version, Ordering::Relaxed); + ring.slots[0][2].store(request, Ordering::Relaxed); + ring.header[5].store(1, Ordering::Release); + assert_eq!(ring.drain(|_| {}), Err(expected)); + assert_eq!(ring.drain(|_| {}), Err(RingError::Closed)); + } + } + + #[test] + fn full_is_valid_but_overfull_is_corrupt() { + let full = CommandRing::new(); + for slot in &full.slots { + slot[0].store(SLOT_VERSION, Ordering::Relaxed); + slot[2].store(1, Ordering::Relaxed); + } + full.header[5].store(CAPACITY as u32, Ordering::Release); + let mut count = 0; + full.drain(|_| count += 1).unwrap(); + assert_eq!(count, CAPACITY); + + let overfull = CommandRing::new(); + overfull.header[5].store(CAPACITY as u32 + 1, Ordering::Release); + assert_eq!(overfull.drain(|_| {}), Err(RingError::Backlog)); + assert_eq!(overfull.drain(|_| {}), Err(RingError::Closed)); + } +} diff --git a/renderer/src/gltf.rs b/renderer/src/gltf.rs index bf798f4..4319e83 100644 --- a/renderer/src/gltf.rs +++ b/renderer/src/gltf.rs @@ -1,216 +1,252 @@ +use std::collections::HashMap; + use gltf::Gltf; use ultraviolet::{Mat4, Vec3}; -use wgpu::TextureFormat; -use crate::renderer::scene::{mesh_vertex_layout, MeshBuilder}; +use crate::render_data::{ + InstanceHandle, MeshCreateInfo, MeshHandle, ModelTransform, PipelineKey, RenderData, + RenderDataError, RenderFlags, +}; -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] +pub struct InstalledScene { + pub meshes: Vec, + pub instances: Vec, + pub bounds: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq)] pub struct ModelBounds { pub min: [f32; 3], pub max: [f32; 3], } - impl ModelBounds { - fn new(min: [f32; 3], max: [f32; 3]) -> Self { - Self { min, max } - } - - fn include_point(&mut self, point: [f32; 3]) { + fn include(&mut self, p: [f32; 3]) { for i in 0..3 { - self.min[i] = self.min[i].min(point[i]); - self.max[i] = self.max[i].max(point[i]); + self.min[i] = self.min[i].min(p[i]); + self.max[i] = self.max[i].max(p[i]); } } } +fn focus_bounds(points: &[[f32; 3]]) -> Option { + let first = *points.first()?; + if points.len() < 200 { + let mut bounds = ModelBounds { + min: first, + max: first, + }; + for point in &points[1..] { + bounds.include(*point); + } + return Some(bounds); + } + + let trim = points.len() / 100; + let mut min = [0.0; 3]; + let mut max = [0.0; 3]; + for axis in 0..3 { + let mut values: Vec<_> = points.iter().map(|point| point[axis]).collect(); + values.sort_by(f32::total_cmp); + min[axis] = values[trim]; + max[axis] = values[values.len() - trim - 1]; + } + Some(ModelBounds { min, max }) +} + #[derive(Debug, thiserror::Error)] pub enum ImportError { - #[error("failed to fetch the model")] - Http(#[from] reqwest::Error), - #[error("failed to decode bytes")] GltfParse(#[from] gltf::Error), - - #[error("failed to load model")] - LoadError, - - #[error("{0}")] - Other(String), + #[error("unsupported or malformed primitive: {0}")] + InvalidPrimitive(String), + #[error("failed to install imported scene")] + Install(#[from] RenderDataError), } -fn convert_tex_coords(tex_coords: gltf::mesh::util::ReadTexCoords<'_>) -> Vec<[f32; 2]> { - use gltf::mesh::util::ReadTexCoords; - - match tex_coords { - ReadTexCoords::F32(iter) => iter.collect(), - ReadTexCoords::U16(iter) => iter - .map(|[u, v]| [u as f32 / u16::MAX as f32, v as f32 / u16::MAX as f32]) - .collect(), - ReadTexCoords::U8(iter) => iter - .map(|[u, v]| [u as f32 / u8::MAX as f32, v as f32 / u8::MAX as f32]) - .collect(), - } +#[derive(Clone, Debug)] +pub struct ImportedGeometry { + pub key: (usize, usize), + pub double_sided: bool, + pub positions: Vec<[f32; 3]>, + pub normals: Vec<[f32; 3]>, + pub uvs: Vec<[f32; 2]>, + pub indices: Vec, +} +#[derive(Clone, Debug)] +pub struct ImportedOccurrence { + pub key: (usize, usize), + pub transform: ModelTransform, +} +#[derive(Clone, Debug, Default)] +pub struct ImportedScene { + pub geometries: Vec, + pub occurrences: Vec, } -fn convert_indices(indices: gltf::mesh::util::ReadIndices<'_>) -> Vec { - use gltf::mesh::util::ReadIndices; - - match indices { - ReadIndices::U8(iter) => iter.map(|i| i as u32).collect(), - ReadIndices::U16(iter) => iter.map(|i| i as u32).collect(), - ReadIndices::U32(iter) => iter.collect(), - } -} - -fn visit_node<'a>( - node: gltf::Node<'a>, - parent_transform: Mat4, - device: &wgpu::Device, - resources: &mut crate::renderer::GpuResources, - meshes: &mut Vec, - data_blob: &[u8], - pipeline_index: usize, - model_bounds: &mut Option, -) { - let local_transform = Mat4::from(node.transform().matrix()); - let world_transform = parent_transform * local_transform; - let normal_matrix = world_transform.inversed().transposed(); - - if let Some(mesh) = node.mesh() { - for primitive in mesh.primitives() { - let reader = primitive.reader(|buffer| match buffer.source() { - gltf::buffer::Source::Bin => Some(&data_blob[..]), - _ => None, - }); - - let positions: Vec<[f32; 3]> = match reader.read_positions() { - Some(iter) => iter.collect(), - None => Vec::new(), - }; - - if positions.is_empty() { - continue; - } - - let vertex_count = positions.len(); - - let default_normal_vec = normal_matrix.transform_vec3(Vec3::unit_y()).normalized(); - let default_normal = [ - default_normal_vec.x, - default_normal_vec.y, - default_normal_vec.z, - ]; - - let mut normals: Vec<[f32; 3]> = reader - .read_normals() - .map(|iter| { - iter.map(|normal| { - let vec = Vec3::new(normal[0], normal[1], normal[2]); - let transformed = normal_matrix.transform_vec3(vec).normalized(); - [transformed.x, transformed.y, transformed.z] - }) - .collect() - }) - .unwrap_or_else(|| vec![default_normal; vertex_count]); - - if normals.len() != vertex_count { - normals.resize(vertex_count, default_normal); - } - - let mut uvs: Vec<[f32; 2]> = reader - .read_tex_coords(0) - .map(convert_tex_coords) - .unwrap_or_else(|| vec![[0.0, 0.0]; vertex_count]); - - if uvs.len() != vertex_count { - uvs.resize(vertex_count, [0.0, 0.0]); - } - - for position in &positions { - let vec = Vec3::new(position[0], position[1], position[2]); - let transformed = world_transform.transform_point3(vec); - let world_point = [transformed.x, transformed.y, transformed.z]; - if let Some(bounds) = model_bounds.as_mut() { - bounds.include_point(world_point); - } else { - *model_bounds = Some(ModelBounds::new(world_point, world_point)); +pub fn decode_gltf(bytes: &[u8]) -> Result { + let model = Gltf::from_slice(bytes)?; + let buffers = gltf::import_buffers(&model.document, None, model.blob.clone())?; + let mut result = ImportedScene::default(); + let mut seen = HashMap::new(); + fn visit( + node: gltf::Node<'_>, + parent: Mat4, + buffers: &[gltf::buffer::Data], + result: &mut ImportedScene, + seen: &mut HashMap<(usize, usize), ()>, + ) -> Result<(), ImportError> { + let world = parent * Mat4::from(node.transform().matrix()); + if let Some(mesh) = node.mesh() { + for primitive in mesh.primitives() { + if primitive.mode() != gltf::mesh::Mode::Triangles { + return Err(ImportError::InvalidPrimitive( + "only triangle primitives are supported".into(), + )); } + let key = (mesh.index(), primitive.index()); + if seen.insert(key, ()).is_none() { + let reader = primitive + .reader(|buffer| buffers.get(buffer.index()).map(|data| data.0.as_slice())); + let Some(read_positions) = reader.read_positions() else { + continue; + }; + let positions: Vec<_> = read_positions.collect(); + let count = positions.len(); + if count == 0 { + continue; + } + let mut normals: Vec<_> = reader + .read_normals() + .map(|x| x.collect()) + .unwrap_or_default(); + normals.resize(count, [0., 1., 0.]); + normals.truncate(count); + let mut uvs: Vec<_> = reader + .read_tex_coords(0) + .map(|x| x.into_f32().collect()) + .unwrap_or_default(); + uvs.resize(count, [0., 0.]); + uvs.truncate(count); + let indices: Vec = if let Some(indices) = reader.read_indices() { + indices.into_u32().collect() + } else { + let count = u32::try_from(count).map_err(|_| { + ImportError::InvalidPrimitive("vertex count exceeds u32".into()) + })?; + (0..count).collect() + }; + if indices.is_empty() { + continue; + } + result.geometries.push(ImportedGeometry { + key, + double_sided: primitive.material().double_sided(), + positions, + normals, + uvs, + indices, + }); + } else if !result.geometries.iter().any(|geometry| geometry.key == key) { + continue; + } + result.occurrences.push(ImportedOccurrence { + key, + transform: world.into(), + }); } - - let indices: Vec = reader - .read_indices() - .map(convert_indices) - .unwrap_or_else(|| (0..vertex_count as u32).collect()); - - if indices.is_empty() { - continue; - } - - let mesh = MeshBuilder::default() - .with_vertices(device, resources, &positions, &normals, &uvs) - .with_indices(device, resources, &indices) - .with_pipeline(pipeline_index) - .with_model_matrix(device, resources, world_transform) - .build(); - - meshes.push(mesh); } + for child in node.children() { + visit(child, world, buffers, result, seen)? + } + Ok(()) } - - for child in node.children() { - visit_node( - child, - world_transform, - device, - resources, - meshes, - data_blob, - pipeline_index, - model_bounds, - ); - } -} - -pub async fn load_gltf_model( - device: &wgpu::Device, - resources: &mut crate::renderer::GpuResources, - meshes: &mut Vec, - surface_format: TextureFormat, -) -> Result, ImportError> { - let glb_data = reqwest::get("http://localhost:8080/themanor.glb") - .await? - .bytes() - .await?; - - let model = Gltf::from_slice(&glb_data)?; - let data_blob = model.blob.as_ref().ok_or(ImportError::LoadError)?; - - let vertex_layout = mesh_vertex_layout(); - - let pipeline_index = resources.get_or_create_pipeline( - device, - "gltf_standard", - &vertex_layout, - include_str!("./gltf.wgsl"), - surface_format, - ); - - let mut model_bounds: Option = None; - for scene in model.scenes() { for node in scene.nodes() { - visit_node( - node, - Mat4::identity(), - device, - resources, - meshes, - data_blob, - pipeline_index, - &mut model_bounds, - ); + visit(node, Mat4::identity(), &buffers, &mut result, &mut seen)? } } + Ok(result) +} - Ok(model_bounds) +pub fn install_imported( + target: &mut RenderData, + imported: &ImportedScene, + pipelines: [PipelineKey; 2], +) -> Result { + let mut stage = target.replacement_stage()?; + let mut handles = HashMap::new(); + let mut mesh_handles = Vec::with_capacity(imported.geometries.len()); + let mut instance_handles = Vec::new(); + let mut first = HashMap::new(); + for occurrence in &imported.occurrences { + first.entry(occurrence.key).or_insert(occurrence.transform); + } + for geometry in &imported.geometries { + let transform = *first + .get(&geometry.key) + .ok_or_else(|| ImportError::InvalidPrimitive("geometry has no occurrence".into()))?; + let created = stage.create_mesh(MeshCreateInfo { + positions: &geometry.positions, + normals: &geometry.normals, + uvs: &geometry.uvs, + indices: &geometry.indices, + pipeline: pipelines[usize::from(geometry.double_sided)], + flags: RenderFlags::VISIBLE, + default_instance_flags: RenderFlags::VISIBLE, + default_transform: transform, + })?; + handles.insert(geometry.key, created.mesh); + mesh_handles.push(created.mesh); + instance_handles.push(created.default_instance); + } + let mut consumed = HashMap::new(); + let mut bounds: Option = None; + let geometries: HashMap<_, _> = imported + .geometries + .iter() + .map(|geometry| (geometry.key, geometry)) + .collect(); + let mut focus_points = Vec::new(); + for occurrence in &imported.occurrences { + let mesh = *handles + .get(&occurrence.key) + .ok_or_else(|| ImportError::InvalidPrimitive("occurrence has no geometry".into()))?; + if consumed.insert(occurrence.key, ()).is_some() { + instance_handles.push(stage.create_instance( + mesh, + occurrence.transform, + RenderFlags::VISIBLE, + )?); + } + let geometry = geometries + .get(&occurrence.key) + .expect("installed occurrence must have geometry"); + let transform = Mat4::from(occurrence.transform); + focus_points.extend(geometry.positions.iter().map(|position| { + let point = transform.transform_point3(Vec3::from(*position)); + [point.x, point.y, point.z] + })); + let local = stage.mesh(mesh).unwrap().aabb; + for x in [local.min[0], local.max[0]] { + for y in [local.min[1], local.max[1]] { + for z in [local.min[2], local.max[2]] { + let p = Mat4::from(occurrence.transform).transform_point3(Vec3::new(x, y, z)); + let p = [p.x, p.y, p.z]; + if let Some(b) = bounds.as_mut() { + b.include(p) + } else { + bounds = Some(ModelBounds { min: p, max: p }) + } + } + } + } + } + bounds = focus_bounds(&focus_points).or(bounds); + target.replace_with(stage)?; + Ok(InstalledScene { + meshes: mesh_handles, + instances: instance_handles, + bounds, + }) } diff --git a/renderer/src/gltf.wgsl b/renderer/src/gltf.wgsl index e3dd314..733a703 100644 --- a/renderer/src/gltf.wgsl +++ b/renderer/src/gltf.wgsl @@ -18,6 +18,9 @@ struct VertexInput { @location(4) model_col1: vec4, @location(5) model_col2: vec4, @location(6) model_col3: vec4, + @location(7) normal_col0: vec4, + @location(8) normal_col1: vec4, + @location(9) normal_col2: vec4, } struct VertexOutput { @@ -39,7 +42,8 @@ fn vs_main(in: VertexInput) -> VertexOutput { let world_position = model * vec4(in.pos, 1.0); out.clip_position = view_proj * world_position; out.world_pos = world_position.xyz; - out.normal = normalize(in.normal); + let normal_matrix = mat3x3(in.normal_col0.xyz, in.normal_col1.xyz, in.normal_col2.xyz); + out.normal = normalize(normal_matrix * in.normal); return out; } @@ -47,13 +51,13 @@ fn vs_main(in: VertexInput) -> VertexOutput { fn fs_main(in: VertexOutput) -> @location(0) vec4 { let light_direction = normalize(vec3(0.35, 1.0, 0.45)); let light_color = vec3(1.0, 0.95, 0.85); - let base_color = vec3(0.2, 0.2, 0.2); + let base_color = vec3(0.55, 0.58, 0.62); let normal = normalize(in.normal); let view_dir = normalize(uni.camera_position.xyz - in.world_pos); let diffuse_strength = max(dot(normal, light_direction), 0.0); - let ambient = 0.15; + let ambient = 0.45; var specular = 0.0; if diffuse_strength > 0.0 { diff --git a/renderer/src/lib.rs b/renderer/src/lib.rs index 1cbb74c..692aa11 100644 --- a/renderer/src/lib.rs +++ b/renderer/src/lib.rs @@ -1,9 +1,48 @@ pub mod app_setup; pub mod camera; +pub mod command_ring; pub mod gltf; pub mod message; pub mod platform; +pub mod render_data; +pub mod render_graph; pub mod renderer; +pub mod shared_snapshot; + +#[cfg(target_arch = "wasm32")] +thread_local! { static PAYLOADS: std::cell::RefCell>> = Default::default(); } + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn stage_payload(id: u32, bytes: js_sys::Uint8Array) { + PAYLOADS.with(|payloads| { + payloads.borrow_mut().insert(id, bytes.to_vec()); + }); +} + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn discard_payload(id: u32) { + PAYLOADS.with(|payloads| { + payloads.borrow_mut().remove(&id); + }); +} + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn clear_payloads() { + PAYLOADS.with(|payloads| payloads.borrow_mut().clear()); +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn take_payload(id: u32) -> Option> { + PAYLOADS.with(|payloads| payloads.borrow_mut().remove(&id)) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn take_payload(_id: u32) -> Option> { + None +} /// Worker entrypoint helper - executes the closure it is spawned with /// Applications should export this with #[wasm_bindgen] diff --git a/renderer/src/message.rs b/renderer/src/message.rs index 9337244..60719c1 100644 --- a/renderer/src/message.rs +++ b/renderer/src/message.rs @@ -1,6 +1,6 @@ use core::fmt; -use std::sync::mpsc::TryRecvError; use std::cell::BorrowMutError; +use std::sync::mpsc::TryRecvError; #[derive(Debug)] pub enum WindowEvent { diff --git a/renderer/src/platform/web/worker/mainWorker.js b/renderer/src/platform/web/worker/mainWorker.js index 1e136be..ee7eea8 100644 --- a/renderer/src/platform/web/worker/mainWorker.js +++ b/renderer/src/platform/web/worker/mainWorker.js @@ -1,21 +1,32 @@ // Generic worker that imports the app's WASM module relative to the generated pkg folder. // Works for any application because the relative depth from this file to pkg is stable. -import initWasm, { worker_entrypoint } from "/level-editor/pkg/level_editor.js"; +import initWasm, { clear_payloads, discard_payload, stage_payload, worker_entrypoint } from "/level-editor/pkg/level_editor.js"; export function attachMain() {} -let isReady = false; +export function listenerReady() { + if (state !== "waiting-listener") return; + state = "replaying"; + for (const queued of pending.splice(0)) route(queued); + state = "ready"; +} -onmessage = async (event) => { - console.log("worker received message", event); - if (isReady) return; +let api; +let state = "uninitialized"; +const pending = []; - isReady = true; - - const wasmModule = event.data[0]; // WebAssembly.Module from wasm_bindgen::module() - const workerId = event.data[1]; // worker ID - const memory = event.data[2]; // shared memory - const entryPtr = event.data[3]; // worker entrypoint function pointer +// This listener is never replaced: canvas and payload transfers that race WASM +// initialization remain ordered and are replayed after init. +addEventListener("message", async (event) => { + const message = event.data; + if (message?.type !== "init") { + if (state !== "ready") pending.push(message); + else route(message); + return; + } + if (state !== "uninitialized") return; + state = "initializing"; + const { wasmModule, workerId, memory, entryPtr } = message; console.log( "worker: initializing with WASM module", @@ -25,8 +36,31 @@ onmessage = async (event) => { ); // Initialize WASM with the shared module and memory forwarded from the main thread. - await initWasm({ module_or_path: wasmModule, memory }); + try { + api = await initWasm({ module_or_path: wasmModule, memory }); + state = "waiting-listener"; + worker_entrypoint(entryPtr); + } catch (error) { + fatal("WORKER_INIT_FAILED", String(error)); + } +}); - // Call the app-provided worker entrypoint once initialization completes. - worker_entrypoint(entryPtr); -}; +function route(message) { + if (message?.type === "canvas") { + dispatchEvent(new MessageEvent("renderer-canvas", { data: message.canvas })); + } else if (message?.type === "payload") { + stage_payload(message.id, new Uint8Array(message.buffer)); + postMessage({ type: "payload-ready", id: message.id }); + } else if (message?.type === "payload-release") { + discard_payload(message.id); + } +} + +function fatal(code, message) { + state = "failed"; + pending.length = 0; + try { clear_payloads?.(); } catch { /* best effort during a fatal failure */ } + postMessage({type:"fatal",code,message}); +} +addEventListener("error", event => fatal("WORKER_RUNTIME_ERROR", event.error?.stack || `${event.message} (${event.filename}:${event.lineno}:${event.colno})`)); +addEventListener("unhandledrejection", event => fatal("WORKER_UNHANDLED_REJECTION",String(event.reason))); diff --git a/renderer/src/platform/web/worker/mod.rs b/renderer/src/platform/web/worker/mod.rs index 6addc66..95c0fc3 100644 --- a/renderer/src/platform/web/worker/mod.rs +++ b/renderer/src/platform/web/worker/mod.rs @@ -1,3 +1,4 @@ +use crate::command_ring::CommandRing; use crate::message::WindowEvent; use log::info; use std::sync::mpsc::Receiver; @@ -22,6 +23,9 @@ extern "C" { /// Nothing to do. #[wasm_bindgen] fn attachMain(); + + #[wasm_bindgen(js_name = "listenerReady")] + fn listener_ready(); } pub struct MainWorker { @@ -52,6 +56,7 @@ impl MainWorker { pub fn spawn( name: &str, id: usize, + ring_ptr: u32, f: impl FnOnce() + Send + 'static, ) -> Result { // Creates a new worker. @@ -62,19 +67,20 @@ impl MainWorker { let ptr = Box::into_raw(Box::new(Box::new(f) as Box)); // Sets default callback. - let callback = Closure::new(|_ev| { - info!("got a message..canvas?"); - }); + let callback = Closure::new(|_ev| {}); handle.set_onmessage(Some(callback.as_ref().unchecked_ref())); - let msg: js_sys::Array = [ - &wasm_bindgen::module(), - &id.into(), - &wasm_bindgen::memory(), - &JsValue::from(ptr as u32), - ] - .into_iter() - .collect(); + let msg = js_sys::Object::new(); + for (key, value) in [ + ("type", JsValue::from("init")), + ("wasmModule", wasm_bindgen::module()), + ("workerId", id.into()), + ("memory", wasm_bindgen::memory()), + ("entryPtr", (ptr as u32).into()), + ("ringPtr", ring_ptr.into()), + ] { + js_sys::Reflect::set(&msg, &key.into(), &value)?; + } info!("posting message"); handle.post_message(&msg)?; @@ -90,21 +96,26 @@ impl MainWorker { let offscreen_canvas = canvas.transfer_control_to_offscreen().unwrap(); let transfer_list = js_sys::Array::new(); transfer_list.push(&offscreen_canvas); + let msg = js_sys::Object::new(); + js_sys::Reflect::set(&msg, &"type".into(), &"canvas".into()).unwrap(); + js_sys::Reflect::set(&msg, &"canvas".into(), &offscreen_canvas).unwrap(); info!("posting canvas (is_undefined: {})", canvas.is_undefined()); self.handle - .post_message_with_transfer(&offscreen_canvas, &transfer_list) + .post_message_with_transfer(&msg, &transfer_list) .unwrap(); } pub async fn run_render_loop( events_chan: Receiver, + ring: &'static CommandRing, ) { use crate::renderer::Renderer; let canvas = wait_for_canvas_transfer().await; let renderer = Rc::new(RefCell::new(Renderer::::new(canvas, events_chan).await)); + renderer.borrow_mut().command_ring = Some(ring); Renderer::run_render_loop(renderer); } } @@ -135,8 +146,12 @@ pub async fn wait_for_canvas_transfer() -> web_sys::OffscreenCanvas { } }); - global.set_onmessage(Some(handler.as_ref().unchecked_ref())); + global + .add_event_listener_with_callback("renderer-canvas", handler.as_ref().unchecked_ref()) + .unwrap(); handler.forget(); + + listener_ready(); }); let canvas: web_sys::OffscreenCanvas = JsFuture::from(promise) diff --git a/renderer/src/render_data/handle.rs b/renderer/src/render_data/handle.rs new file mode 100644 index 0000000..8a9fff3 --- /dev/null +++ b/renderer/src/render_data/handle.rs @@ -0,0 +1,240 @@ +use bytemuck::{Pod, Zeroable}; + +macro_rules! handle { + ($name:ident) => { + #[repr(C)] + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Pod, Zeroable)] + pub struct $name { + slot: u32, + generation: u32, + } + + impl $name { + pub const fn from_parts(slot: u32, generation: u32) -> Self { + Self { slot, generation } + } + + pub const fn slot(self) -> u32 { + self.slot + } + + pub const fn generation(self) -> u32 { + self.generation + } + } + }; +} + +handle!(MeshHandle); +handle!(InstanceHandle); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum SlotState { + Occupied, + Vacant { next: Option }, + Retired, +} + +#[derive(Clone, Copy, Debug)] +pub(super) struct PreparedSlot { + pub slot: u32, + pub generation: u32, + reused_next: Option, + append: bool, +} + +pub(super) struct SlotTable { + pub(super) generations: Vec, + pub(super) states: Vec, + free_head: Option, + live_count: u32, + logical_capacity: u32, + pub(super) max_capacity: Option, +} + +impl SlotTable { + pub fn new( + initial: u32, + max: Option, + resource: &'static str, + ) -> Result { + let mut table = Self { + generations: Vec::new(), + states: Vec::new(), + free_head: None, + live_count: 0, + logical_capacity: 0, + max_capacity: max, + }; + table.reserve_for_len(initial, resource)?; + Ok(table) + } + + pub fn live_count(&self) -> u32 { + self.live_count + } + + pub fn logical_capacity(&self) -> u32 { + self.logical_capacity + } + + pub fn max_capacity(&self) -> Option { + self.max_capacity + } + + pub fn required_len_for_prepare(&self) -> Result { + if self.free_head.is_some() { + u32::try_from(self.generations.len()).map_err(|_| { + crate::render_data::RenderDataError::CapacityOverflow { resource: "slots" } + }) + } else { + let len = u32::try_from(self.generations.len()).map_err(|_| { + crate::render_data::RenderDataError::CapacityOverflow { resource: "slots" } + })?; + len.checked_add(1) + .ok_or(crate::render_data::RenderDataError::CapacityOverflow { resource: "slots" }) + } + } + + pub fn reserve_for_len( + &mut self, + required: u32, + resource: &'static str, + ) -> Result<(), crate::render_data::RenderDataError> { + let target = crate::render_data::next_capacity( + self.logical_capacity, + required, + self.max_capacity, + resource, + )?; + crate::render_data::reserve_vec(&mut self.generations, target, resource)?; + crate::render_data::reserve_vec(&mut self.states, target, resource)?; + self.logical_capacity = target; + Ok(()) + } + + pub fn prepare(&self) -> Result { + if let Some(slot) = self.free_head { + let index = slot as usize; + let SlotState::Vacant { next } = self.states[index] else { + unreachable!("free list points to a non-vacant slot") + }; + Ok(PreparedSlot { + slot, + generation: self.generations[index], + reused_next: next, + append: false, + }) + } else { + let slot = u32::try_from(self.generations.len()).map_err(|_| { + crate::render_data::RenderDataError::CapacityOverflow { resource: "slots" } + })?; + Ok(PreparedSlot { + slot, + generation: 1, + reused_next: None, + append: true, + }) + } + } + + pub fn commit(&mut self, prepared: PreparedSlot) { + if prepared.append { + self.generations.push(prepared.generation); + self.states.push(SlotState::Occupied); + } else { + self.free_head = prepared.reused_next; + self.states[prepared.slot as usize] = SlotState::Occupied; + } + self.live_count += 1; + } + + pub fn contains(&self, slot: u32, generation: u32) -> bool { + let index = slot as usize; + self.generations.get(index) == Some(&generation) + && matches!(self.states.get(index), Some(SlotState::Occupied)) + } + + pub fn remove(&mut self, slot: u32, generation: u32) -> bool { + if !self.contains(slot, generation) { + return false; + } + let index = slot as usize; + self.live_count -= 1; + if generation == u32::MAX { + self.states[index] = SlotState::Retired; + } else { + self.generations[index] = generation + 1; + self.states[index] = SlotState::Vacant { + next: self.free_head, + }; + self.free_head = Some(slot); + } + true + } + + pub fn clear(&mut self) { + self.free_head = None; + self.live_count = 0; + for index in (0..self.states.len()).rev() { + match self.states[index] { + SlotState::Occupied if self.generations[index] == u32::MAX => { + self.states[index] = SlotState::Retired; + } + SlotState::Occupied => { + self.generations[index] += 1; + self.states[index] = SlotState::Vacant { + next: self.free_head, + }; + self.free_head = Some( + u32::try_from(index).expect("slot table length was checked before append"), + ); + } + SlotState::Vacant { .. } => { + self.states[index] = SlotState::Vacant { + next: self.free_head, + }; + self.free_head = Some( + u32::try_from(index).expect("slot table length was checked before append"), + ); + } + SlotState::Retired => {} + } + } + } + + pub fn seed_successor(&mut self, predecessor: &Self) { + self.generations.clear(); + self.states.clear(); + self.free_head = None; + self.live_count = 0; + for generation in predecessor.generations.iter().copied() { + let generation = generation.saturating_add(1); + self.generations.push(generation); + if generation == u32::MAX { + self.states.push(SlotState::Retired); + } else { + self.states.push(SlotState::Vacant { + next: self.free_head, + }); + self.free_head = Some((self.states.len() - 1) as u32); + } + } + } + + pub fn occupied(&self) -> impl Iterator + '_ { + self.states.iter().enumerate().filter_map(|(index, state)| { + matches!(state, SlotState::Occupied).then(|| { + ( + u32::try_from(index).expect("slot table length was checked before append"), + self.generations[index], + ) + }) + }) + } + + #[cfg(test)] + pub fn force_generation(&mut self, slot: u32, generation: u32) { + self.generations[slot as usize] = generation; + } +} diff --git a/renderer/src/render_data/mod.rs b/renderer/src/render_data/mod.rs new file mode 100644 index 0000000..452489e --- /dev/null +++ b/renderer/src/render_data/mod.rs @@ -0,0 +1,1141 @@ +mod handle; +mod range_allocator; + +pub use handle::{InstanceHandle, MeshHandle}; + +use std::{ + ops::{BitAnd, BitAndAssign, BitOr, BitOrAssign, Deref}, + sync::atomic::{AtomicU64, Ordering}, +}; + +use handle::{PreparedSlot, SlotTable}; +use range_allocator::RangeAllocator; +use thiserror::Error; + +static NEXT_LINEAGE: AtomicU64 = AtomicU64::new(1); + +pub type ModelTransform = [[f32; 4]; 4]; +pub type NormalMatrix = [[f32; 3]; 3]; + +pub const IDENTITY_MODEL_TRANSFORM: ModelTransform = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], +]; +pub const IDENTITY_NORMAL_MATRIX: NormalMatrix = + [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + +#[repr(transparent)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PipelineKey(u32); + +impl PipelineKey { + pub const fn new(value: u32) -> Self { + Self(value) + } + + pub const fn get(self) -> u32 { + self.0 + } +} + +#[repr(transparent)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct RenderFlags(u32); + +impl RenderFlags { + pub const NONE: Self = Self(0); + pub const VISIBLE: Self = Self(1); + + pub const fn from_bits_retain(bits: u32) -> Self { + Self(bits) + } + + pub const fn bits(self) -> u32 { + self.0 + } + + pub const fn contains(self, other: Self) -> bool { + self.0 & other.0 == other.0 + } +} + +impl BitOr for RenderFlags { + type Output = Self; + fn bitor(self, rhs: Self) -> Self { + Self(self.0 | rhs.0) + } +} +impl BitOrAssign for RenderFlags { + fn bitor_assign(&mut self, rhs: Self) { + self.0 |= rhs.0; + } +} +impl BitAnd for RenderFlags { + type Output = Self; + fn bitand(self, rhs: Self) -> Self { + Self(self.0 & rhs.0) + } +} +impl BitAndAssign for RenderFlags { + fn bitand_assign(&mut self, rhs: Self) { + self.0 &= rhs.0; + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Aabb { + pub min: [f32; 3], + pub max: [f32; 3], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GeometryRange { + pub vertex_start: u32, + pub vertex_count: u32, + pub index_start: u32, + pub index_count: u32, +} + +pub struct MeshCreateInfo<'a> { + pub positions: &'a [[f32; 3]], + pub normals: &'a [[f32; 3]], + pub uvs: &'a [[f32; 2]], + pub indices: &'a [u32], + pub pipeline: PipelineKey, + pub flags: RenderFlags, + pub default_instance_flags: RenderFlags, + pub default_transform: ModelTransform, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CreatedMesh { + pub mesh: MeshHandle, + pub default_instance: InstanceHandle, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MeshView { + pub handle: MeshHandle, + pub geometry: GeometryRange, + pub pipeline: PipelineKey, + pub flags: RenderFlags, + pub aabb: Aabb, + pub default_instance: InstanceHandle, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct InstanceView { + pub handle: InstanceHandle, + pub mesh: MeshHandle, + pub model: ModelTransform, + pub normal: NormalMatrix, + pub flags: RenderFlags, + pub is_default: bool, +} + +/// Computes the exact axis-aligned bounds of an AABB under a finite affine transform. +pub fn affine_world_aabb(local: Aabb, model: ModelTransform) -> Result { + validate_affine(model)?; + let center = [ + (local.min[0] + local.max[0]) * 0.5, + (local.min[1] + local.max[1]) * 0.5, + (local.min[2] + local.max[2]) * 0.5, + ]; + let extent = [ + (local.max[0] - local.min[0]) * 0.5, + (local.max[1] - local.min[1]) * 0.5, + (local.max[2] - local.min[2]) * 0.5, + ]; + let mut world_center = [0.0; 3]; + let mut world_extent = [0.0; 3]; + for row in 0..3 { + world_center[row] = model[3][row] + + model[0][row] * center[0] + + model[1][row] * center[1] + + model[2][row] * center[2]; + world_extent[row] = model[0][row].abs() * extent[0] + + model[1][row].abs() * extent[1] + + model[2][row].abs() * extent[2]; + } + let result = Aabb { + min: std::array::from_fn(|i| world_center[i] - world_extent[i]), + max: std::array::from_fn(|i| world_center[i] + world_extent[i]), + }; + if result + .min + .iter() + .chain(result.max.iter()) + .all(|v| v.is_finite()) + { + Ok(result) + } else { + Err(RenderDataError::InvalidTransform) + } +} + +pub struct VertexStreams<'a> { + pub positions: &'a [[f32; 3]], + pub normals: &'a [[f32; 3]], + pub uvs: &'a [[f32; 2]], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RenderDataCapacities { + pub vertices: u32, + pub indices: u32, + pub meshes: u32, + pub instances: u32, +} + +#[derive(Clone, Debug)] +pub struct RenderDataConfig { + pub initial_vertices: u32, + pub max_vertices: Option, + pub initial_indices: u32, + pub max_indices: Option, + pub initial_meshes: u32, + pub max_meshes: Option, + pub initial_instances: u32, + pub max_instances: Option, +} + +impl Default for RenderDataConfig { + fn default() -> Self { + Self { + initial_vertices: 262_144, + max_vertices: None, + initial_indices: 524_288, + max_indices: None, + initial_meshes: 16_384, + max_meshes: None, + initial_instances: 65_536, + max_instances: None, + } + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum RenderDataError { + #[error("initial {resource} capacity exceeds configured maximum")] + InvalidCapacityConfig { resource: &'static str }, + #[error("capacity arithmetic overflow for {resource}")] + CapacityOverflow { resource: &'static str }, + #[error("{resource} capacity {required} exceeds maximum {maximum}")] + CapacityExceeded { + resource: &'static str, + required: u32, + maximum: u32, + }, + #[error("allocation failed while reserving {resource}")] + AllocationFailed { resource: &'static str }, + #[error("input length does not fit in u32")] + InputTooLarge, + #[error("vertex streams must be nonempty")] + EmptyVertices, + #[error("vertex stream lengths do not match")] + MismatchedVertexStreams, + #[error("indices must be nonempty")] + EmptyIndices, + #[error("an index refers beyond the supplied vertices")] + IndexOutOfBounds, + #[error("geometry contains non-finite values")] + NonFiniteGeometry, + #[error("invalid mesh handle")] + InvalidMeshHandle, + #[error("invalid instance handle")] + InvalidInstanceHandle, + #[error("transform must be finite and invertible")] + InvalidTransform, + #[error("the default instance cannot be destroyed directly")] + CannotDestroyDefaultInstance, + #[error("range is empty")] + EmptyRange, + #[error("range arithmetic overflow")] + RangeOverflow, + #[error("range is beyond the allocator high-water mark")] + RangeOutOfBounds, + #[error("range overlaps an already-free range")] + RangeOverlap, + #[error("render data revision overflow")] + RevisionOverflow, + #[error("replacement stage no longer matches its source render data")] + StaleReplacementStage, +} + +struct VertexSoa { + positions: Vec<[f32; 3]>, + normals: Vec<[f32; 3]>, + uvs: Vec<[f32; 2]>, + logical_capacity: u32, + max_capacity: Option, + allocator: RangeAllocator, +} + +struct IndexSoa { + values: Vec, + logical_capacity: u32, + max_capacity: Option, + allocator: RangeAllocator, +} + +struct MeshSoa { + slots: SlotTable, + vertex_starts: Vec, + vertex_counts: Vec, + index_starts: Vec, + index_counts: Vec, + pipeline_keys: Vec, + flags: Vec, + aabb_mins: Vec<[f32; 3]>, + aabb_maxs: Vec<[f32; 3]>, + default_instance_slots: Vec, + default_instance_generations: Vec, +} + +struct InstanceSoa { + slots: SlotTable, + mesh_slots: Vec, + mesh_generations: Vec, + model_col_0: Vec<[f32; 4]>, + model_col_1: Vec<[f32; 4]>, + model_col_2: Vec<[f32; 4]>, + model_col_3: Vec<[f32; 4]>, + normal_col_0: Vec<[f32; 3]>, + normal_col_1: Vec<[f32; 3]>, + normal_col_2: Vec<[f32; 3]>, + flags: Vec, +} + +pub struct RenderData { + vertices: VertexSoa, + indices: IndexSoa, + meshes: MeshSoa, + instances: InstanceSoa, + revision: u64, + lineage: u64, +} + +/// A transaction prepared specifically as a successor to an existing scene. +pub struct ReplacementStage { + data: RenderData, + source_lineage: u64, + base_revision: u64, + mesh_generations: Vec, + instance_generations: Vec, +} + +impl Deref for ReplacementStage { + type Target = RenderData; + fn deref(&self) -> &Self::Target { + &self.data + } +} + +impl ReplacementStage { + pub fn create_mesh( + &mut self, + info: MeshCreateInfo<'_>, + ) -> Result { + self.data.create_mesh(info) + } + + pub fn create_instance( + &mut self, + mesh: MeshHandle, + model: ModelTransform, + flags: RenderFlags, + ) -> Result { + self.data.create_instance(mesh, model, flags) + } +} + +pub(super) fn next_capacity( + old: u32, + required: u32, + maximum: Option, + resource: &'static str, +) -> Result { + if let Some(maximum) = maximum { + if required > maximum { + return Err(RenderDataError::CapacityExceeded { + resource, + required, + maximum, + }); + } + } + if required <= old { + return Ok(old); + } + let doubled = match old.checked_mul(2) { + Some(value) => value, + None => maximum.ok_or(RenderDataError::CapacityOverflow { resource })?, + }; + let target = required.max(1).max(doubled); + Ok(maximum.map_or(target, |maximum| target.min(maximum))) +} + +pub(super) fn reserve_vec( + vector: &mut Vec, + target_capacity: u32, + resource: &'static str, +) -> Result<(), RenderDataError> { + let target = usize::try_from(target_capacity) + .map_err(|_| RenderDataError::CapacityOverflow { resource })?; + if vector.capacity() < target { + vector + .try_reserve_exact(target - vector.len()) + .map_err(|_| RenderDataError::AllocationFailed { resource })?; + } + Ok(()) +} + +impl RenderData { + pub fn new(config: RenderDataConfig) -> Result { + for (resource, initial, maximum) in [ + ("vertices", config.initial_vertices, config.max_vertices), + ("indices", config.initial_indices, config.max_indices), + ("meshes", config.initial_meshes, config.max_meshes), + ("instances", config.initial_instances, config.max_instances), + ] { + if maximum.is_some_and(|maximum| initial > maximum) { + return Err(RenderDataError::InvalidCapacityConfig { resource }); + } + } + + let lineage = NEXT_LINEAGE + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| { + value.checked_add(1) + }) + .map_err(|_| RenderDataError::CapacityOverflow { + resource: "render data lineage", + })?; + let mut data = Self { + vertices: VertexSoa { + positions: Vec::new(), + normals: Vec::new(), + uvs: Vec::new(), + logical_capacity: 0, + max_capacity: config.max_vertices, + allocator: RangeAllocator::default(), + }, + indices: IndexSoa { + values: Vec::new(), + logical_capacity: 0, + max_capacity: config.max_indices, + allocator: RangeAllocator::default(), + }, + meshes: MeshSoa::new(config.initial_meshes, config.max_meshes)?, + instances: InstanceSoa::new(config.initial_instances, config.max_instances)?, + revision: 0, + lineage, + }; + data.reserve_vertices(config.initial_vertices)?; + data.reserve_indices(config.initial_indices)?; + Ok(data) + } + + pub fn capacities(&self) -> RenderDataCapacities { + RenderDataCapacities { + vertices: self.vertices.logical_capacity, + indices: self.indices.logical_capacity, + meshes: self.meshes.slots.logical_capacity(), + instances: self.instances.slots.logical_capacity(), + } + } + + pub fn revision(&self) -> u64 { + self.revision + } + + /// Creates an empty transactional successor whose handles cannot alias this data. + pub fn replacement_stage(&self) -> Result { + let capacities = self.capacities(); + let mut stage = Self::new(RenderDataConfig { + initial_vertices: capacities.vertices, + max_vertices: self.vertices.max_capacity, + initial_indices: capacities.indices, + max_indices: self.indices.max_capacity, + initial_meshes: capacities.meshes, + max_meshes: self.meshes.slots.max_capacity(), + initial_instances: capacities.instances, + max_instances: self.instances.slots.max_capacity(), + })?; + stage.meshes.slots.seed_successor(&self.meshes.slots); + stage.instances.slots.seed_successor(&self.instances.slots); + Ok(ReplacementStage { + data: stage, + source_lineage: self.lineage, + base_revision: self.revision, + mesh_generations: self.meshes.slots.generations.clone(), + instance_generations: self.instances.slots.generations.clone(), + }) + } + + /// Atomically installs a successfully prepared replacement. + pub fn replace_with(&mut self, mut stage: ReplacementStage) -> Result<(), RenderDataError> { + if stage.source_lineage != self.lineage + || stage.base_revision != self.revision + || stage.mesh_generations != self.meshes.slots.generations + || stage.instance_generations != self.instances.slots.generations + { + return Err(RenderDataError::StaleReplacementStage); + } + stage.data.revision = self.next_revision()?; + stage.data.lineage = self.lineage; + *self = stage.data; + Ok(()) + } + + fn next_revision(&self) -> Result { + self.revision + .checked_add(1) + .ok_or(RenderDataError::RevisionOverflow) + } + + pub fn mesh_count(&self) -> u32 { + self.meshes.slots.live_count() + } + + pub fn instance_count(&self) -> u32 { + self.instances.slots.live_count() + } + + pub fn create_mesh( + &mut self, + info: MeshCreateInfo<'_>, + ) -> Result { + let vertex_count = validate_geometry(&info)?; + let index_count = + u32::try_from(info.indices.len()).map_err(|_| RenderDataError::InputTooLarge)?; + let normal = normal_matrix(info.default_transform)?; + let bounds = aabb(info.positions); + affine_world_aabb(bounds, info.default_transform)?; + let next_revision = self.next_revision()?; + + let mesh_required = self.meshes.slots.required_len_for_prepare()?; + self.meshes.reserve(mesh_required)?; + let prepared_mesh = self.meshes.slots.prepare()?; + let mesh = MeshHandle::from_parts(prepared_mesh.slot, prepared_mesh.generation); + + let vertex_range = self.vertices.allocator.allocate(vertex_count)?; + let index_range = match self.indices.allocator.allocate(index_count) { + Ok(range) => range, + Err(error) => { + self.vertices.allocator.free(vertex_range).unwrap(); + return Err(error); + } + }; + + let preparation = (|| { + self.reserve_vertices(vertex_range.end)?; + self.reserve_indices(index_range.end)?; + let instance_required = self.instances.slots.required_len_for_prepare()?; + self.instances.reserve(instance_required)?; + self.instances.slots.prepare() + })(); + let prepared_instance = match preparation { + Ok(prepared) => prepared, + Err(error) => { + self.vertices.allocator.free(vertex_range).unwrap(); + self.indices.allocator.free(index_range).unwrap(); + self.trim_streams(); + return Err(error); + } + }; + let default_instance = + InstanceHandle::from_parts(prepared_instance.slot, prepared_instance.generation); + + self.resize_streams(); + self.vertices.positions[as_usize(vertex_range.start)..as_usize(vertex_range.end)] + .copy_from_slice(info.positions); + self.vertices.normals[as_usize(vertex_range.start)..as_usize(vertex_range.end)] + .copy_from_slice(info.normals); + self.vertices.uvs[as_usize(vertex_range.start)..as_usize(vertex_range.end)] + .copy_from_slice(info.uvs); + self.indices.values[as_usize(index_range.start)..as_usize(index_range.end)] + .copy_from_slice(info.indices); + + self.meshes.commit( + prepared_mesh, + GeometryRange { + vertex_start: vertex_range.start, + vertex_count, + index_start: index_range.start, + index_count, + }, + info.pipeline, + info.flags, + bounds, + default_instance, + ); + self.instances.commit( + prepared_instance, + mesh, + info.default_transform, + normal, + info.default_instance_flags, + ); + self.revision = next_revision; + Ok(CreatedMesh { + mesh, + default_instance, + }) + } + + pub fn create_instance( + &mut self, + mesh: MeshHandle, + model: ModelTransform, + flags: RenderFlags, + ) -> Result { + if !self.meshes.slots.contains(mesh.slot(), mesh.generation()) { + return Err(RenderDataError::InvalidMeshHandle); + } + let normal = normal_matrix(model)?; + affine_world_aabb(self.mesh(mesh).unwrap().aabb, model)?; + let next_revision = self.next_revision()?; + let required = self.instances.slots.required_len_for_prepare()?; + self.instances.reserve(required)?; + let prepared = self.instances.slots.prepare()?; + let handle = InstanceHandle::from_parts(prepared.slot, prepared.generation); + self.instances.commit(prepared, mesh, model, normal, flags); + self.revision = next_revision; + Ok(handle) + } + + pub fn destroy_instance(&mut self, handle: InstanceHandle) -> Result<(), RenderDataError> { + let view = self + .instance(handle) + .ok_or(RenderDataError::InvalidInstanceHandle)?; + if self + .mesh(view.mesh) + .is_some_and(|mesh| mesh.default_instance == handle) + { + return Err(RenderDataError::CannotDestroyDefaultInstance); + } + let next_revision = self.next_revision()?; + self.instances + .slots + .remove(handle.slot(), handle.generation()); + self.revision = next_revision; + Ok(()) + } + + pub fn destroy_mesh(&mut self, handle: MeshHandle) -> Result<(), RenderDataError> { + let view = self + .mesh(handle) + .ok_or(RenderDataError::InvalidMeshHandle)?; + let next_revision = self.next_revision()?; + let owned: Vec<_> = self + .instances + .slots + .occupied() + .filter_map(|(slot, generation)| { + let instance = InstanceHandle::from_parts(slot, generation); + (self.instances.mesh_handle(slot) == handle).then_some(instance) + }) + .collect(); + for instance in owned { + self.instances + .slots + .remove(instance.slot(), instance.generation()); + } + self.vertices + .allocator + .free( + view.geometry.vertex_start + ..view + .geometry + .vertex_start + .checked_add(view.geometry.vertex_count) + .expect("live vertex range must not overflow"), + ) + .expect("live geometry range must be allocated"); + self.indices + .allocator + .free( + view.geometry.index_start + ..view + .geometry + .index_start + .checked_add(view.geometry.index_count) + .expect("live index range must not overflow"), + ) + .expect("live geometry range must be allocated"); + self.trim_streams(); + self.meshes.slots.remove(handle.slot(), handle.generation()); + self.revision = next_revision; + Ok(()) + } + + pub fn set_mesh_flags( + &mut self, + handle: MeshHandle, + flags: RenderFlags, + ) -> Result<(), RenderDataError> { + if !self + .meshes + .slots + .contains(handle.slot(), handle.generation()) + { + return Err(RenderDataError::InvalidMeshHandle); + } + let next_revision = self.next_revision()?; + self.meshes.flags[handle.slot() as usize] = flags; + self.revision = next_revision; + Ok(()) + } + + pub fn set_instance_flags( + &mut self, + handle: InstanceHandle, + flags: RenderFlags, + ) -> Result<(), RenderDataError> { + if !self + .instances + .slots + .contains(handle.slot(), handle.generation()) + { + return Err(RenderDataError::InvalidInstanceHandle); + } + let next_revision = self.next_revision()?; + self.instances.flags[handle.slot() as usize] = flags; + self.revision = next_revision; + Ok(()) + } + + pub fn set_instance_transform( + &mut self, + handle: InstanceHandle, + model: ModelTransform, + ) -> Result<(), RenderDataError> { + if !self + .instances + .slots + .contains(handle.slot(), handle.generation()) + { + return Err(RenderDataError::InvalidInstanceHandle); + } + let normal = normal_matrix(model)?; + let mesh = self.instances.mesh_handle(handle.slot()); + affine_world_aabb(self.mesh(mesh).unwrap().aabb, model)?; + let next_revision = self.next_revision()?; + self.instances.set_transform(handle.slot(), model, normal); + self.revision = next_revision; + Ok(()) + } + + pub fn mesh(&self, handle: MeshHandle) -> Option { + self.meshes + .slots + .contains(handle.slot(), handle.generation()) + .then(|| self.meshes.view(handle)) + } + + pub fn instance(&self, handle: InstanceHandle) -> Option { + self.instances + .slots + .contains(handle.slot(), handle.generation()) + .then(|| { + let mesh = self.instances.mesh_handle(handle.slot()); + let default = self + .mesh(mesh) + .is_some_and(|owner| owner.default_instance == handle); + self.instances.view(handle, default) + }) + } + + pub fn meshes(&self) -> impl Iterator + '_ { + self.meshes.slots.occupied().map(|(slot, generation)| { + let handle = MeshHandle::from_parts(slot, generation); + (handle, self.meshes.view(handle)) + }) + } + + pub fn instances(&self) -> impl Iterator + '_ { + self.instances.slots.occupied().map(|(slot, generation)| { + let handle = InstanceHandle::from_parts(slot, generation); + (handle, self.instance(handle).unwrap()) + }) + } + + pub fn streams(&self) -> VertexStreams<'_> { + VertexStreams { + positions: &self.vertices.positions, + normals: &self.vertices.normals, + uvs: &self.vertices.uvs, + } + } + + pub fn indices(&self) -> &[u32] { + &self.indices.values + } + + pub fn clear(&mut self) -> Result<(), RenderDataError> { + if self.mesh_count() == 0 && self.instance_count() == 0 { + return Ok(()); + } + let next_revision = self.next_revision()?; + self.meshes.slots.clear(); + self.instances.slots.clear(); + self.vertices.positions.clear(); + self.vertices.normals.clear(); + self.vertices.uvs.clear(); + self.indices.values.clear(); + self.vertices.allocator.clear(); + self.indices.allocator.clear(); + self.revision = next_revision; + Ok(()) + } + + fn reserve_vertices(&mut self, required: u32) -> Result<(), RenderDataError> { + let target = next_capacity( + self.vertices.logical_capacity, + required, + self.vertices.max_capacity, + "vertices", + )?; + reserve_vec(&mut self.vertices.positions, target, "vertices")?; + reserve_vec(&mut self.vertices.normals, target, "vertices")?; + reserve_vec(&mut self.vertices.uvs, target, "vertices")?; + self.vertices.logical_capacity = target; + Ok(()) + } + + fn reserve_indices(&mut self, required: u32) -> Result<(), RenderDataError> { + let target = next_capacity( + self.indices.logical_capacity, + required, + self.indices.max_capacity, + "indices", + )?; + reserve_vec(&mut self.indices.values, target, "indices")?; + self.indices.logical_capacity = target; + Ok(()) + } + + fn resize_streams(&mut self) { + let vertices = as_usize(self.vertices.allocator.high_water()); + self.vertices.positions.resize(vertices, [0.0; 3]); + self.vertices.normals.resize(vertices, [0.0; 3]); + self.vertices.uvs.resize(vertices, [0.0; 2]); + self.indices + .values + .resize(as_usize(self.indices.allocator.high_water()), 0); + } + + fn trim_streams(&mut self) { + self.vertices + .positions + .truncate(as_usize(self.vertices.allocator.high_water())); + self.vertices + .normals + .truncate(as_usize(self.vertices.allocator.high_water())); + self.vertices + .uvs + .truncate(as_usize(self.vertices.allocator.high_water())); + self.indices + .values + .truncate(as_usize(self.indices.allocator.high_water())); + } +} + +impl MeshSoa { + fn new(initial: u32, maximum: Option) -> Result { + let mut soa = Self { + slots: SlotTable::new(0, maximum, "meshes")?, + vertex_starts: Vec::new(), + vertex_counts: Vec::new(), + index_starts: Vec::new(), + index_counts: Vec::new(), + pipeline_keys: Vec::new(), + flags: Vec::new(), + aabb_mins: Vec::new(), + aabb_maxs: Vec::new(), + default_instance_slots: Vec::new(), + default_instance_generations: Vec::new(), + }; + soa.reserve(initial)?; + Ok(soa) + } + + fn reserve(&mut self, required: u32) -> Result<(), RenderDataError> { + let old = self.slots.logical_capacity(); + let maximum = self.slots.max_capacity(); + let target = next_capacity(old, required, maximum, "meshes")?; + reserve_vec(&mut self.vertex_starts, target, "meshes")?; + reserve_vec(&mut self.vertex_counts, target, "meshes")?; + reserve_vec(&mut self.index_starts, target, "meshes")?; + reserve_vec(&mut self.index_counts, target, "meshes")?; + reserve_vec(&mut self.pipeline_keys, target, "meshes")?; + reserve_vec(&mut self.flags, target, "meshes")?; + reserve_vec(&mut self.aabb_mins, target, "meshes")?; + reserve_vec(&mut self.aabb_maxs, target, "meshes")?; + reserve_vec(&mut self.default_instance_slots, target, "meshes")?; + reserve_vec(&mut self.default_instance_generations, target, "meshes")?; + self.slots.reserve_for_len(target, "meshes")?; + Ok(()) + } + + fn commit( + &mut self, + prepared: PreparedSlot, + geometry: GeometryRange, + pipeline: PipelineKey, + flags: RenderFlags, + bounds: Aabb, + default: InstanceHandle, + ) { + let len = prepared.slot as usize + 1; + resize_column(&mut self.vertex_starts, len, 0); + resize_column(&mut self.vertex_counts, len, 0); + resize_column(&mut self.index_starts, len, 0); + resize_column(&mut self.index_counts, len, 0); + resize_column(&mut self.pipeline_keys, len, PipelineKey::new(0)); + resize_column(&mut self.flags, len, RenderFlags::NONE); + resize_column(&mut self.aabb_mins, len, [0.0; 3]); + resize_column(&mut self.aabb_maxs, len, [0.0; 3]); + resize_column(&mut self.default_instance_slots, len, 0); + resize_column(&mut self.default_instance_generations, len, 0); + let index = prepared.slot as usize; + self.vertex_starts[index] = geometry.vertex_start; + self.vertex_counts[index] = geometry.vertex_count; + self.index_starts[index] = geometry.index_start; + self.index_counts[index] = geometry.index_count; + self.pipeline_keys[index] = pipeline; + self.flags[index] = flags; + self.aabb_mins[index] = bounds.min; + self.aabb_maxs[index] = bounds.max; + self.default_instance_slots[index] = default.slot(); + self.default_instance_generations[index] = default.generation(); + self.slots.commit(prepared); + } + + fn view(&self, handle: MeshHandle) -> MeshView { + let index = handle.slot() as usize; + MeshView { + handle, + geometry: GeometryRange { + vertex_start: self.vertex_starts[index], + vertex_count: self.vertex_counts[index], + index_start: self.index_starts[index], + index_count: self.index_counts[index], + }, + pipeline: self.pipeline_keys[index], + flags: self.flags[index], + aabb: Aabb { + min: self.aabb_mins[index], + max: self.aabb_maxs[index], + }, + default_instance: InstanceHandle::from_parts( + self.default_instance_slots[index], + self.default_instance_generations[index], + ), + } + } +} + +impl InstanceSoa { + fn new(initial: u32, maximum: Option) -> Result { + let mut soa = Self { + slots: SlotTable::new(0, maximum, "instances")?, + mesh_slots: Vec::new(), + mesh_generations: Vec::new(), + model_col_0: Vec::new(), + model_col_1: Vec::new(), + model_col_2: Vec::new(), + model_col_3: Vec::new(), + normal_col_0: Vec::new(), + normal_col_1: Vec::new(), + normal_col_2: Vec::new(), + flags: Vec::new(), + }; + soa.reserve(initial)?; + Ok(soa) + } + + fn reserve(&mut self, required: u32) -> Result<(), RenderDataError> { + let old = self.slots.logical_capacity(); + let maximum = self.slots.max_capacity(); + let target = next_capacity(old, required, maximum, "instances")?; + reserve_vec(&mut self.mesh_slots, target, "instances")?; + reserve_vec(&mut self.mesh_generations, target, "instances")?; + reserve_vec(&mut self.model_col_0, target, "instances")?; + reserve_vec(&mut self.model_col_1, target, "instances")?; + reserve_vec(&mut self.model_col_2, target, "instances")?; + reserve_vec(&mut self.model_col_3, target, "instances")?; + reserve_vec(&mut self.normal_col_0, target, "instances")?; + reserve_vec(&mut self.normal_col_1, target, "instances")?; + reserve_vec(&mut self.normal_col_2, target, "instances")?; + reserve_vec(&mut self.flags, target, "instances")?; + self.slots.reserve_for_len(target, "instances")?; + Ok(()) + } + + fn commit( + &mut self, + prepared: PreparedSlot, + mesh: MeshHandle, + model: ModelTransform, + normal: NormalMatrix, + flags: RenderFlags, + ) { + let len = prepared.slot as usize + 1; + resize_column(&mut self.mesh_slots, len, 0); + resize_column(&mut self.mesh_generations, len, 0); + resize_column(&mut self.model_col_0, len, [0.0; 4]); + resize_column(&mut self.model_col_1, len, [0.0; 4]); + resize_column(&mut self.model_col_2, len, [0.0; 4]); + resize_column(&mut self.model_col_3, len, [0.0; 4]); + resize_column(&mut self.normal_col_0, len, [0.0; 3]); + resize_column(&mut self.normal_col_1, len, [0.0; 3]); + resize_column(&mut self.normal_col_2, len, [0.0; 3]); + resize_column(&mut self.flags, len, RenderFlags::NONE); + let index = prepared.slot as usize; + self.mesh_slots[index] = mesh.slot(); + self.mesh_generations[index] = mesh.generation(); + self.flags[index] = flags; + self.set_transform(prepared.slot, model, normal); + self.slots.commit(prepared); + } + + fn mesh_handle(&self, slot: u32) -> MeshHandle { + let index = slot as usize; + MeshHandle::from_parts(self.mesh_slots[index], self.mesh_generations[index]) + } + + fn set_transform(&mut self, slot: u32, model: ModelTransform, normal: NormalMatrix) { + let index = slot as usize; + self.model_col_0[index] = model[0]; + self.model_col_1[index] = model[1]; + self.model_col_2[index] = model[2]; + self.model_col_3[index] = model[3]; + self.normal_col_0[index] = normal[0]; + self.normal_col_1[index] = normal[1]; + self.normal_col_2[index] = normal[2]; + } + + fn view(&self, handle: InstanceHandle, is_default: bool) -> InstanceView { + let index = handle.slot() as usize; + InstanceView { + handle, + mesh: self.mesh_handle(handle.slot()), + model: [ + self.model_col_0[index], + self.model_col_1[index], + self.model_col_2[index], + self.model_col_3[index], + ], + normal: [ + self.normal_col_0[index], + self.normal_col_1[index], + self.normal_col_2[index], + ], + flags: self.flags[index], + is_default, + } + } +} + +fn resize_column(column: &mut Vec, len: usize, value: T) { + if column.len() < len { + column.resize(len, value); + } +} + +fn as_usize(value: u32) -> usize { + usize::try_from(value).expect("u32 must fit in usize on supported targets") +} + +fn validate_geometry(info: &MeshCreateInfo<'_>) -> Result { + if info.positions.is_empty() { + return Err(RenderDataError::EmptyVertices); + } + if info.positions.len() != info.normals.len() || info.positions.len() != info.uvs.len() { + return Err(RenderDataError::MismatchedVertexStreams); + } + let vertex_count = + u32::try_from(info.positions.len()).map_err(|_| RenderDataError::InputTooLarge)?; + if info.indices.is_empty() { + return Err(RenderDataError::EmptyIndices); + } + u32::try_from(info.indices.len()).map_err(|_| RenderDataError::InputTooLarge)?; + if info + .positions + .iter() + .flatten() + .chain(info.normals.iter().flatten()) + .chain(info.uvs.iter().flatten()) + .any(|value| !value.is_finite()) + { + return Err(RenderDataError::NonFiniteGeometry); + } + if info.indices.iter().any(|index| *index >= vertex_count) { + return Err(RenderDataError::IndexOutOfBounds); + } + Ok(vertex_count) +} + +fn aabb(positions: &[[f32; 3]]) -> Aabb { + let mut min = positions[0]; + let mut max = positions[0]; + for position in &positions[1..] { + for axis in 0..3 { + min[axis] = min[axis].min(position[axis]); + max[axis] = max[axis].max(position[axis]); + } + } + Aabb { min, max } +} + +fn normal_matrix(model: ModelTransform) -> Result { + validate_affine(model)?; + let (a, b, c) = (model[0][0], model[0][1], model[0][2]); + let (d, e, f) = (model[1][0], model[1][1], model[1][2]); + let (g, h, i) = (model[2][0], model[2][1], model[2][2]); + let determinant = a * (e * i - f * h) - d * (b * i - c * h) + g * (b * f - c * e); + if determinant == 0.0 || !determinant.is_finite() { + return Err(RenderDataError::InvalidTransform); + } + let normal = [ + [ + (e * i - f * h) / determinant, + (f * g - d * i) / determinant, + (d * h - e * g) / determinant, + ], + [ + (c * h - b * i) / determinant, + (a * i - c * g) / determinant, + (b * g - a * h) / determinant, + ], + [ + (b * f - c * e) / determinant, + (c * d - a * f) / determinant, + (a * e - b * d) / determinant, + ], + ]; + if normal.iter().flatten().any(|value| !value.is_finite()) { + Err(RenderDataError::InvalidTransform) + } else { + Ok(normal) + } +} + +fn validate_affine(model: ModelTransform) -> Result<(), RenderDataError> { + if model.iter().flatten().any(|value| !value.is_finite()) + || model[0][3] != 0.0 + || model[1][3] != 0.0 + || model[2][3] != 0.0 + || model[3][3] != 1.0 + { + Err(RenderDataError::InvalidTransform) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/renderer/src/render_data/range_allocator.rs b/renderer/src/render_data/range_allocator.rs new file mode 100644 index 0000000..2abe57e --- /dev/null +++ b/renderer/src/render_data/range_allocator.rs @@ -0,0 +1,85 @@ +use std::ops::Range; + +use super::RenderDataError; + +#[derive(Default, Debug)] +pub(super) struct RangeAllocator { + free: Vec>, + pub(super) high_water: u32, +} + +impl RangeAllocator { + pub fn allocate(&mut self, count: u32) -> Result, RenderDataError> { + if count == 0 { + return Err(RenderDataError::EmptyRange); + } + if let Some(index) = self + .free + .iter() + .position(|range| range.end - range.start >= count) + { + let start = self.free[index].start; + let end = start + .checked_add(count) + .ok_or(RenderDataError::RangeOverflow)?; + self.free[index].start = end; + if self.free[index].is_empty() { + self.free.remove(index); + } + return Ok(start..end); + } + let end = self + .high_water + .checked_add(count) + .ok_or(RenderDataError::RangeOverflow)?; + let range = self.high_water..end; + self.high_water = end; + Ok(range) + } + + pub fn free(&mut self, range: Range) -> Result { + if range.start >= range.end { + return Err(RenderDataError::EmptyRange); + } + if range.end > self.high_water { + return Err(RenderDataError::RangeOutOfBounds); + } + let index = self + .free + .partition_point(|candidate| candidate.start < range.start); + if index > 0 && self.free[index - 1].end > range.start + || index < self.free.len() && self.free[index].start < range.end + { + return Err(RenderDataError::RangeOverlap); + } + + let joins_left = index > 0 && self.free[index - 1].end == range.start; + let joins_right = index < self.free.len() && self.free[index].start == range.end; + match (joins_left, joins_right) { + (true, true) => { + let right_end = self.free.remove(index).end; + self.free[index - 1].end = right_end; + } + (true, false) => self.free[index - 1].end = range.end, + (false, true) => self.free[index].start = range.start, + (false, false) => self.free.insert(index, range), + } + while self + .free + .last() + .is_some_and(|range| range.end == self.high_water) + { + self.high_water = self.free.pop().unwrap().start; + } + Ok(self.high_water) + } + + pub fn high_water(&self) -> u32 { + self.high_water + } + + pub fn clear(&mut self) { + self.free.clear(); + self.high_water = 0; + } +} diff --git a/renderer/src/render_data/tests.rs b/renderer/src/render_data/tests.rs new file mode 100644 index 0000000..aeaec67 --- /dev/null +++ b/renderer/src/render_data/tests.rs @@ -0,0 +1,522 @@ +use super::*; +use crate::render_data::handle::SlotState; + +const POSITIONS: [[f32; 3]; 3] = [[-1.0, 2.0, 3.0], [4.0, -2.0, 1.0], [0.0, 1.0, -3.0]]; +const NORMALS: [[f32; 3]; 3] = [[0.0, 1.0, 0.0]; 3]; +const UVS: [[f32; 2]; 3] = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]]; +const INDICES: [u32; 3] = [0, 1, 2]; + +fn info() -> MeshCreateInfo<'static> { + MeshCreateInfo { + positions: &POSITIONS, + normals: &NORMALS, + uvs: &UVS, + indices: &INDICES, + pipeline: PipelineKey::new(7), + flags: RenderFlags::from_bits_retain(2), + default_instance_flags: RenderFlags::VISIBLE, + default_transform: IDENTITY_MODEL_TRANSFORM, + } +} + +fn data() -> RenderData { + RenderData::new(RenderDataConfig { + initial_vertices: 0, + initial_indices: 0, + initial_meshes: 0, + initial_instances: 0, + ..RenderDataConfig::default() + }) + .unwrap() +} + +#[test] +fn affine_world_bounds_cover_translation_scale_shear_and_planes() { + let local = Aabb { + min: [-1.0, -2.0, 0.0], + max: [1.0, 2.0, 0.0], + }; + assert_eq!( + affine_world_aabb(local, IDENTITY_MODEL_TRANSFORM), + Ok(local) + ); + + let model = [ + [-2.0, 0.0, 0.0, 0.0], + [0.5, 3.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [10.0, -4.0, 2.0, 1.0], + ]; + assert_eq!( + affine_world_aabb(local, model), + Ok(Aabb { + min: [7.0, -10.0, 2.0], + max: [13.0, 2.0, 2.0], + }) + ); +} + +#[test] +fn world_bounds_reject_projective_and_overflowing_transforms() { + let local = Aabb { + min: [-1.0; 3], + max: [1.0; 3], + }; + let mut projective = IDENTITY_MODEL_TRANSFORM; + projective[0][3] = 0.5; + assert_eq!( + affine_world_aabb(local, projective), + Err(RenderDataError::InvalidTransform) + ); + let mut overflowing = IDENTITY_MODEL_TRANSFORM; + overflowing[0][0] = f32::MAX; + overflowing[1][0] = f32::MAX; + assert_eq!( + affine_world_aabb(local, overflowing), + Err(RenderDataError::InvalidTransform) + ); +} + +#[test] +fn default_instance_is_protected_and_flags_are_separate() { + let mut data = data(); + let created = data.create_mesh(info()).unwrap(); + assert!(data.instance(created.default_instance).unwrap().is_default); + assert_eq!(data.mesh(created.mesh).unwrap().flags.bits(), 2); + assert_eq!( + data.instance(created.default_instance).unwrap().flags, + RenderFlags::VISIBLE + ); + assert_eq!( + data.destroy_instance(created.default_instance), + Err(RenderDataError::CannotDestroyDefaultInstance) + ); + data.set_mesh_flags(created.mesh, RenderFlags::NONE) + .unwrap(); + assert_eq!(data.mesh(created.mesh).unwrap().flags, RenderFlags::NONE); + assert_eq!( + data.instance(created.default_instance).unwrap().flags, + RenderFlags::VISIBLE + ); +} + +#[test] +fn stale_mesh_and_instance_handles_are_rejected_after_reuse() { + let mut data = data(); + let first = data.create_mesh(info()).unwrap(); + let old_instance = data + .create_instance(first.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE) + .unwrap(); + data.destroy_instance(old_instance).unwrap(); + let replacement = data + .create_instance(first.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE) + .unwrap(); + assert_eq!(old_instance.slot(), replacement.slot()); + assert_ne!(old_instance.generation(), replacement.generation()); + assert!(data.instance(old_instance).is_none()); + data.destroy_mesh(first.mesh).unwrap(); + let second = data.create_mesh(info()).unwrap(); + assert_eq!(first.mesh.slot(), second.mesh.slot()); + assert_ne!(first.mesh.generation(), second.mesh.generation()); + assert!(data.mesh(first.mesh).is_none()); +} + +#[test] +fn clear_handles_all_slot_states_retains_capacity_and_never_reuses_retired() { + let mut data = data(); + let mesh = data.create_mesh(info()).unwrap(); + let vacant = data + .create_instance(mesh.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE) + .unwrap(); + data.destroy_instance(vacant).unwrap(); + data.instances + .slots + .force_generation(mesh.default_instance.slot(), u32::MAX); + let old_capacity = data.capacities(); + data.clear().unwrap(); + assert_eq!(data.capacities(), old_capacity); + assert_eq!(data.mesh_count(), 0); + assert_eq!(data.instance_count(), 0); + assert!(data.mesh(mesh.mesh).is_none()); + assert!(matches!( + data.instances.slots.states[mesh.default_instance.slot() as usize], + SlotState::Retired + )); + let new_mesh = data.create_mesh(info()).unwrap(); + assert_ne!( + new_mesh.default_instance.slot(), + mesh.default_instance.slot() + ); +} + +#[test] +fn capacity_math_has_exact_bounded_and_unbounded_overflow_behavior() { + assert_eq!(next_capacity(0, 1, None, "x"), Ok(1)); + assert_eq!(next_capacity(1, 2, None, "x"), Ok(2)); + assert_eq!(next_capacity(2, 3, Some(3), "x"), Ok(3)); + assert_eq!( + next_capacity(u32::MAX - 1, u32::MAX, Some(u32::MAX), "x"), + Ok(u32::MAX) + ); + assert!(matches!( + next_capacity(u32::MAX - 1, u32::MAX, None, "x"), + Err(RenderDataError::CapacityOverflow { .. }) + )); + assert!(matches!( + next_capacity(2, 4, Some(3), "x"), + Err(RenderDataError::CapacityExceeded { .. }) + )); +} + +#[test] +fn all_storage_classes_grow_and_retired_slots_force_max_checked_append() { + let mut data = data(); + let mesh = data.create_mesh(info()).unwrap(); + assert_eq!( + data.capacities(), + RenderDataCapacities { + vertices: 3, + indices: 3, + meshes: 1, + instances: 1, + } + ); + data.create_instance(mesh.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE) + .unwrap(); + assert_eq!(data.capacities().instances, 2); + + let mut slots = SlotTable::new(0, Some(1), "test").unwrap(); + slots.reserve_for_len(1, "test").unwrap(); + let prepared = slots.prepare().unwrap(); + slots.commit(prepared); + slots.force_generation(0, u32::MAX); + slots.remove(0, u32::MAX); + assert!(matches!( + slots.reserve_for_len(slots.required_len_for_prepare().unwrap(), "test"), + Err(RenderDataError::CapacityExceeded { .. }) + )); +} + +#[test] +fn allocator_checks_errors_splits_first_fit_coalesces_and_trims_tail() { + let mut allocator = RangeAllocator::default(); + assert_eq!(allocator.allocate(0), Err(RenderDataError::EmptyRange)); + let left = allocator.allocate(2).unwrap(); + let middle = allocator.allocate(4).unwrap(); + let right = allocator.allocate(2).unwrap(); + assert_eq!(allocator.free(middle.clone()), Ok(8)); + assert_eq!(allocator.allocate(2).unwrap(), 2..4); + assert_eq!(allocator.free(2..4), Ok(8)); + assert_eq!(allocator.free(2..4), Err(RenderDataError::RangeOverlap)); + assert_eq!(allocator.free(8..9), Err(RenderDataError::RangeOutOfBounds)); + assert_eq!(allocator.free(3..3), Err(RenderDataError::EmptyRange)); + assert_eq!(allocator.free(left), Ok(8)); + assert_eq!(allocator.free(right), Ok(0)); + + let mut bridge = RangeAllocator::default(); + bridge.allocate(6).unwrap(); + bridge.free(0..2).unwrap(); + bridge.free(4..6).unwrap(); + bridge.free(2..4).unwrap(); + assert_eq!(bridge.high_water(), 0); + + let mut overflow = RangeAllocator::default(); + overflow.high_water = u32::MAX; + assert_eq!(overflow.allocate(1), Err(RenderDataError::RangeOverflow)); +} + +#[test] +fn streams_remain_coordinated_across_interior_delete_tail_delete_and_reuse() { + let mut data = data(); + let first = data.create_mesh(info()).unwrap(); + let second = data.create_mesh(info()).unwrap(); + assert_eq!(data.streams().positions.len(), 6); + assert_eq!(data.indices().len(), 6); + data.destroy_mesh(first.mesh).unwrap(); + assert_eq!(data.streams().positions.len(), 6); + let reused = data.create_mesh(info()).unwrap(); + assert_eq!(data.mesh(reused.mesh).unwrap().geometry.vertex_start, 0); + data.destroy_mesh(second.mesh).unwrap(); + assert_eq!(data.streams().positions.len(), 3); + assert_eq!(data.streams().normals.len(), 3); + assert_eq!(data.streams().uvs.len(), 3); + assert_eq!(data.indices().len(), 3); +} + +#[test] +fn failed_default_instance_preparation_rolls_back_empty_and_existing_geometry() { + let mut data = RenderData::new(RenderDataConfig { + initial_vertices: 0, + initial_indices: 0, + initial_meshes: 0, + initial_instances: 0, + max_instances: Some(0), + ..RenderDataConfig::default() + }) + .unwrap(); + for _ in 0..2 { + let generations = data.meshes.slots.generations.clone(); + assert!(matches!( + data.create_mesh(info()), + Err(RenderDataError::CapacityExceeded { + resource: "instances", + .. + }) + )); + assert_eq!(data.vertices.allocator.high_water(), 0); + assert_eq!(data.indices.allocator.high_water(), 0); + assert!(data.streams().positions.is_empty()); + assert!(data.indices().is_empty()); + assert_eq!(data.meshes.slots.generations, generations); + } + + data.instances.slots.max_capacity = Some(1); + let existing = data.create_mesh(info()).unwrap(); + data.instances.slots.max_capacity = Some(0); + assert!(data.create_mesh(info()).is_err()); + assert_eq!(data.vertices.allocator.high_water(), 3); + assert_eq!(data.mesh_count(), 1); + assert!(data.mesh(existing.mesh).is_some()); +} + +#[test] +fn aabb_supports_one_point_and_multiple_points() { + let point = [[2.0, -3.0, 4.0]]; + let normal = [[0.0, 1.0, 0.0]]; + let uv = [[0.0, 0.0]]; + let index = [0]; + let mut one = info(); + one.positions = &point; + one.normals = &normal; + one.uvs = &uv; + one.indices = &index; + let mut data = data(); + let mesh = data.create_mesh(one).unwrap(); + assert_eq!( + data.mesh(mesh.mesh).unwrap().aabb, + Aabb { + min: point[0], + max: point[0] + } + ); + let mesh = data.create_mesh(info()).unwrap(); + assert_eq!( + data.mesh(mesh.mesh).unwrap().aabb, + Aabb { + min: [-1.0, -2.0, -3.0], + max: [4.0, 2.0, 3.0], + } + ); +} + +#[test] +fn malformed_geometry_matrix_is_rejected_without_consumption() { + let mut data = data(); + let mut candidate = info(); + candidate.positions = &[]; + assert_eq!( + data.create_mesh(candidate).unwrap_err(), + RenderDataError::EmptyVertices + ); + let short_normals = &NORMALS[..2]; + let mut candidate = info(); + candidate.normals = short_normals; + assert_eq!( + data.create_mesh(candidate).unwrap_err(), + RenderDataError::MismatchedVertexStreams + ); + let short_uvs = &UVS[..2]; + let mut candidate = info(); + candidate.uvs = short_uvs; + assert_eq!( + data.create_mesh(candidate).unwrap_err(), + RenderDataError::MismatchedVertexStreams + ); + let mut candidate = info(); + candidate.indices = &[]; + assert_eq!( + data.create_mesh(candidate).unwrap_err(), + RenderDataError::EmptyIndices + ); + + for stream in 0..3 { + for bad in [f32::NAN, f32::INFINITY] { + let mut positions = POSITIONS; + let mut normals = NORMALS; + let mut uvs = UVS; + match stream { + 0 => positions[0][0] = bad, + 1 => normals[0][0] = bad, + _ => uvs[0][0] = bad, + } + let mut candidate = info(); + candidate.positions = &positions; + candidate.normals = &normals; + candidate.uvs = &uvs; + assert_eq!( + data.create_mesh(candidate).unwrap_err(), + RenderDataError::NonFiniteGeometry + ); + } + } + let invalid = [3]; + let mut candidate = info(); + candidate.indices = &invalid; + assert_eq!( + data.create_mesh(candidate).unwrap_err(), + RenderDataError::IndexOutOfBounds + ); + let valid_last = [2]; + let mut candidate = info(); + candidate.indices = &valid_last; + assert!(data.create_mesh(candidate).is_ok()); +} + +#[test] +fn normal_matrices_and_failed_transform_operations_are_transactional() { + let mut data = data(); + let mesh = data.create_mesh(info()).unwrap(); + assert_eq!( + data.instance(mesh.default_instance).unwrap().normal, + IDENTITY_NORMAL_MATRIX + ); + let translation = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [4.0, 5.0, 6.0, 1.0], + ]; + data.set_instance_transform(mesh.default_instance, translation) + .unwrap(); + assert_eq!( + data.instance(mesh.default_instance).unwrap().normal, + IDENTITY_NORMAL_MATRIX + ); + let scale = [ + [2.0, 0.0, 0.0, 0.0], + [0.0, 4.0, 0.0, 0.0], + [0.0, 0.0, 0.5, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + data.set_instance_transform(mesh.default_instance, scale) + .unwrap(); + assert_eq!( + data.instance(mesh.default_instance).unwrap().normal, + [[0.5, 0.0, 0.0], [0.0, 0.25, 0.0], [0.0, 0.0, 2.0]] + ); + let rotation = [ + [0.0, 1.0, 0.0, 0.0], + [-1.0, 0.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + data.set_instance_transform(mesh.default_instance, rotation) + .unwrap(); + let old = data.instance(mesh.default_instance).unwrap(); + for invalid in [[[0.0; 4]; 4], { + let mut value = IDENTITY_MODEL_TRANSFORM; + value[0][0] = f32::INFINITY; + value + }] { + assert_eq!( + data.set_instance_transform(mesh.default_instance, invalid), + Err(RenderDataError::InvalidTransform) + ); + assert_eq!(data.instance(mesh.default_instance).unwrap(), old); + let count = data.instance_count(); + assert_eq!( + data.create_instance(mesh.mesh, invalid, RenderFlags::NONE), + Err(RenderDataError::InvalidTransform) + ); + assert_eq!(data.instance_count(), count); + let mut candidate = info(); + candidate.default_transform = invalid; + assert_eq!( + data.create_mesh(candidate).unwrap_err(), + RenderDataError::InvalidTransform + ); + } +} + +#[test] +fn destroying_mesh_invalidates_exact_owner_instances_with_reused_generations() { + let mut data = data(); + let first = data.create_mesh(info()).unwrap(); + let second = data.create_mesh(info()).unwrap(); + let first_extra = data + .create_instance(first.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE) + .unwrap(); + let second_extra = data + .create_instance(second.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE) + .unwrap(); + data.destroy_instance(first_extra).unwrap(); + let reused = data + .create_instance(second.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::NONE) + .unwrap(); + assert_eq!(first_extra.slot(), reused.slot()); + data.destroy_mesh(first.mesh).unwrap(); + assert!(data.instance(first.default_instance).is_none()); + assert!(data.instance(second.default_instance).is_some()); + assert!(data.instance(second_extra).is_some()); + assert!(data.instance(reused).is_some()); + assert_eq!(data.instances().count(), 3); +} + +#[test] +fn revision_changes_only_after_success_and_replacement_rejects_old_handles() { + let mut data = data(); + assert_eq!(data.revision(), 0); + assert!(data.destroy_mesh(MeshHandle::from_parts(9, 9)).is_err()); + assert_eq!(data.revision(), 0); + let old = data.create_mesh(info()).unwrap(); + assert_eq!(data.revision(), 1); + let mut stage = data.replacement_stage().unwrap(); + let new = stage.create_mesh(info()).unwrap(); + assert_ne!(old.mesh, new.mesh); + data.replace_with(stage).unwrap(); + assert_eq!(data.revision(), 2); + assert!(data.mesh(old.mesh).is_none()); + assert!(data.mesh(new.mesh).is_some()); +} + +#[test] +fn replacement_stage_is_rejected_after_source_mutation() { + let mut data = data(); + let original = data.create_mesh(info()).unwrap(); + let mut stage = data.replacement_stage().unwrap(); + stage.create_mesh(info()).unwrap(); + + data.destroy_mesh(original.mesh).unwrap(); + let current = data.create_mesh(info()).unwrap(); + assert_eq!(current.mesh.slot(), original.mesh.slot()); + + assert_eq!( + data.replace_with(stage), + Err(RenderDataError::StaleReplacementStage) + ); + assert!(data.mesh(current.mesh).is_some()); +} + +#[test] +fn replacement_stage_is_rejected_by_a_different_render_data() { + let source = data(); + let stage = source.replacement_stage().unwrap(); + let mut other = data(); + + assert_eq!( + other.replace_with(stage), + Err(RenderDataError::StaleReplacementStage) + ); +} + +#[test] +fn revision_overflow_rejects_mutation_without_committing() { + let mut data = data(); + data.revision = u64::MAX; + assert_eq!( + data.create_mesh(info()), + Err(RenderDataError::RevisionOverflow) + ); + assert_eq!(data.mesh_count(), 0); + assert_eq!(data.revision(), u64::MAX); +} diff --git a/renderer/src/render_graph/compiler.rs b/renderer/src/render_graph/compiler.rs new file mode 100644 index 0000000..9c561ba --- /dev/null +++ b/renderer/src/render_graph/compiler.rs @@ -0,0 +1,1099 @@ +use std::cmp::Reverse; +use std::collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, HashSet}; + +use serde::Serialize; + +use super::schema::*; +use super::{GraphError, MAX_JSON_BYTES, MAX_OUTPUTS, MAX_PASSES, MAX_RESOURCES, MAX_USES}; + +pub enum ExecutorResolution<'a> { + Found(&'a dyn ExecutorContract), + UnknownKey, + UnsupportedVersion, +} +pub trait ExecutorRegistry { + fn resolve(&self, executor: &ExecutorRef) -> ExecutorResolution<'_>; +} +pub trait ExecutorContract { + fn inherently_observable(&self) -> bool; + fn normalize_parameters( + &self, + parameters: &serde_json::Value, + ) -> Result; + fn validate_bindings( + &self, + pass: &Pass, + resources: &HashMap, + ) -> Result<(), String>; +} +pub struct SceneForwardExecutors; +static SCENE_FORWARD: SceneForward = SceneForward; +struct SceneForward; +impl ExecutorRegistry for SceneForwardExecutors { + fn resolve(&self, e: &ExecutorRef) -> ExecutorResolution<'_> { + if e.key != "scene_forward" { + ExecutorResolution::UnknownKey + } else if e.version != 1 { + ExecutorResolution::UnsupportedVersion + } else { + ExecutorResolution::Found(&SCENE_FORWARD) + } + } +} +impl ExecutorContract for SceneForward { + fn inherently_observable(&self) -> bool { + false + } + fn normalize_parameters( + &self, + value: &serde_json::Value, + ) -> Result { + if value == &serde_json::json!({}) { + Ok(NormalizedParameters::SceneForward) + } else { + Err("requires parameters {}".into()) + } + } + fn validate_bindings( + &self, + p: &Pass, + r: &HashMap, + ) -> Result<(), String> { + if !p.reads.is_empty() || p.writes.len() != 2 { + return Err( + "requires parameters {}, no reads, and exactly color and depth bindings".into(), + ); + } + let color = p.writes.iter().find(|x| x.binding == "color"); + let depth = p.writes.iter().find(|x| x.binding == "depth"); + let (Some(c), Some(d)) = (color, depth) else { + return Err("requires bindings named color and depth".into()); + }; + if !matches!(c.access, WriteAccess::ColorAttachment { location: 0, .. }) + || !matches!(d.access, WriteAccess::DepthAttachment { .. }) + { + return Err("color must be attachment location 0 and depth a depth attachment".into()); + } + let (c, d) = (r[&c.resource], r[&d.resource]); + if d.texture.format != Format::Depth32Float + || c.texture.format == Format::Depth32Float + || c.texture.extent != d.texture.extent + || c.texture.sample_count != d.texture.sample_count + { + return Err( + "attachments must match extent/sample count and depth must be depth32_float".into(), + ); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum NormalizedParameters { + SceneForward, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CompiledRead { + pub binding: String, + pub resource: u32, + pub access: ReadAccess, +} +#[derive(Debug, Clone, Serialize)] +pub struct CompiledWrite { + pub binding: String, + pub resource: u32, + pub access: WriteAccess, +} + +#[derive(Debug, Clone, Serialize)] +pub struct CompiledPass { + pub id: String, + pub original_index: u32, + pub executor: ExecutorRef, + pub parameters: NormalizedParameters, + pub reads: Vec, + pub writes: Vec, +} +#[derive(Debug, Clone, Copy, Serialize)] +pub struct Lifetime { + pub first_use: u32, + pub last_use: u32, +} +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] +pub enum TextureUsage { + Sampled, + Storage, + CopySrc, + CopyDst, + ColorAttachment, + DepthAttachment, +} +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)] +pub struct TextureAllocationKey { + pub descriptor: TextureDescriptor, + pub usage: Vec, + #[serde(rename = "viewFormats")] + pub view_formats: Vec, +} +#[derive(Debug, Clone, Serialize)] +pub struct CompiledResource { + pub original_index: u32, + #[serde(rename = "ref")] + pub resource_ref: ResourceRef, + pub residency: Residency, + pub descriptor: TextureDescriptor, + pub writer: Option, + pub lifetime: Lifetime, + pub allocation: Option, +} +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +pub struct TransientAllocation { + pub class: u32, + pub slot: u32, +} +#[derive(Debug, Clone, Serialize)] +pub struct CompiledOutput { + pub name: String, + pub resource: u32, +} +#[derive(Debug, Clone, Serialize)] +pub struct AllocationClass { + pub key: TextureAllocationKey, + pub slot_count: u32, +} +#[derive(Debug, Clone, Serialize)] +pub struct CompiledGraph { + pub schema_version: u32, + pub graph_id: String, + pub revision: u32, + pub passes: Vec, + pub resources: Vec, + pub outputs: Vec, + pub allocation_classes: Vec, + pub culled_pass_count: u32, + pub culled_resource_count: u32, + pub transient_slot_count: u32, +} +impl CompiledGraph { + pub fn summary(&self, id: [u32; 2]) -> serde_json::Value { + serde_json::json!({"compiledId":id,"graphId":self.graph_id,"revision":self.revision,"schemaVersion":self.schema_version,"passCount":self.passes.len(),"resourceCount":self.resources.len(),"culledPassCount":self.culled_pass_count,"culledResourceCount":self.culled_resource_count,"transientSlotCount":self.transient_slot_count}) + } +} + +fn fail(code: &'static str, msg: impl Into, path: impl Into) -> GraphError { + GraphError::at(code, msg, path) +} +fn norm(mut d: TextureDescriptor) -> TextureDescriptor { + fn gcd(mut a: u32, mut b: u32) -> u32 { + while b != 0 { + let n = a % b; + a = b; + b = n + } + a + } + if let Extent::SurfaceRelative { width, height, .. } = &mut d.extent { + let g = gcd(width.numerator, width.denominator); + width.numerator /= g; + width.denominator /= g; + let g = gcd(height.numerator, height.denominator); + height.numerator /= g; + height.denominator /= g + } + d +} +fn usage_read(a: ReadAccess) -> TextureUsage { + match a { + ReadAccess::Sampled => TextureUsage::Sampled, + ReadAccess::Storage => TextureUsage::Storage, + ReadAccess::CopySrc => TextureUsage::CopySrc, + } +} +fn usage_write(a: &WriteAccess) -> TextureUsage { + match a { + WriteAccess::Storage => TextureUsage::Storage, + WriteAccess::CopyDst => TextureUsage::CopyDst, + WriteAccess::ColorAttachment { .. } => TextureUsage::ColorAttachment, + WriteAccess::DepthAttachment { .. } => TextureUsage::DepthAttachment, + } +} + +pub fn parse_and_compile(bytes: &[u8]) -> Result { + compile_with(bytes, &SceneForwardExecutors) +} +pub fn compile_with( + bytes: &[u8], + executors: &dyn ExecutorRegistry, +) -> Result { + if bytes.len() > MAX_JSON_BYTES { + return Err(GraphError::new( + "GRAPH_PAYLOAD_TOO_LARGE", + "graph payload exceeds 1 MiB", + )); + } + let text = std::str::from_utf8(bytes) + .map_err(|_| GraphError::new("GRAPH_ENCODING_INVALID", "graph payload is not UTF-8"))?; + let value: serde_json::Value = serde_json::from_str(text) + .map_err(|e| GraphError::new("GRAPH_JSON_INVALID", e.to_string()))?; + let version = value.get("schemaVersion").and_then(|v| v.as_u64()); + if version != Some(1) { + return Err(GraphError::new( + "GRAPH_SCHEMA_UNSUPPORTED", + "schemaVersion must be 1", + )); + } + let g: GraphV1 = serde_json::from_str(text) + .map_err(|e| GraphError::new("GRAPH_JSON_INVALID", e.to_string()))?; + compile(g, executors) +} +pub fn compile( + mut g: GraphV1, + executors: &dyn ExecutorRegistry, +) -> Result { + if g.schema_version != 1 { + return Err(GraphError::new( + "GRAPH_SCHEMA_UNSUPPORTED", + "schemaVersion must be 1", + )); + } + if g.resources.len() > MAX_RESOURCES + || g.passes.len() > MAX_PASSES + || g.outputs.len() > MAX_OUTPUTS + || g.passes + .iter() + .map(|p| p.reads.len() + p.writes.len()) + .sum::() + > MAX_USES + { + return Err(GraphError::new( + "GRAPH_LIMIT_EXCEEDED", + "graph limit exceeded", + )); + } + let identifiers = std::iter::once(&g.graph_id) + .chain(g.resources.iter().map(|resource| &resource.id)) + .chain(g.passes.iter().flat_map(|pass| { + std::iter::once(&pass.id) + .chain(std::iter::once(&pass.executor.key)) + .chain( + pass.reads + .iter() + .flat_map(|binding| [&binding.binding, &binding.resource.id]), + ) + .chain( + pass.writes + .iter() + .flat_map(|binding| [&binding.binding, &binding.resource.id]), + ) + })) + .chain( + g.outputs + .iter() + .flat_map(|output| [&output.name, &output.resource.id]), + ); + if identifiers + .into_iter() + .any(|identifier| identifier.len() > 64) + { + return Err(GraphError::new( + "GRAPH_LIMIT_EXCEEDED", + "identifier exceeds 64 UTF-8 bytes", + )); + } + if !identifier(&g.graph_id) || g.revision == 0 { + return Err(fail( + "GRAPH_INVALID_ID", + "invalid graphId or revision", + "graphId", + )); + } + let mut resource_ids = HashSet::new(); + let mut external = HashSet::new(); + for (i, r) in g.resources.iter().enumerate() { + let rr = ResourceRef { + id: r.id.clone(), + version: r.version, + }; + if !identifier(&r.id) { + return Err(fail( + "GRAPH_INVALID_ID", + "invalid resource id", + format!("resources[{i}].id"), + )); + } + if !resource_ids.insert(rr) { + return Err(fail( + "GRAPH_DUPLICATE_ID", + "duplicate resource id/version", + format!("resources[{i}]"), + )); + } + if let Residency::External { source } = r.residency { + if !external.insert(source) { + return Err(fail( + "GRAPH_DUPLICATE_ID", + "duplicate external source", + format!("resources[{i}].residency"), + )); + } + } + } + let mut pass_ids = HashSet::new(); + for (pi, p) in g.passes.iter().enumerate() { + if !identifier(&p.id) || !identifier(&p.executor.key) { + return Err(fail( + "GRAPH_INVALID_ID", + "invalid pass or executor id", + format!("passes[{pi}]"), + )); + } + if !pass_ids.insert(&p.id) { + return Err(fail( + "GRAPH_DUPLICATE_ID", + "duplicate pass id", + format!("passes[{pi}].id"), + )); + } + } + let mut output_names = HashSet::new(); + for (i, o) in g.outputs.iter().enumerate() { + if !identifier(&o.name) || !output_names.insert(&o.name) { + return Err(fail( + if identifier(&o.name) { + "GRAPH_DUPLICATE_ID" + } else { + "GRAPH_INVALID_ID" + }, + "invalid or duplicate output", + format!("outputs[{i}]"), + )); + } + } + for (i, resource) in g.resources.iter_mut().enumerate() { + let valid_ratio = match &resource.texture.extent { + Extent::SurfaceRelative { width, height, .. } => { + width.numerator != 0 + && width.denominator != 0 + && height.numerator != 0 + && height.denominator != 0 + } + Extent::Absolute { .. } => true, + }; + if !valid_ratio { + return Err(fail( + "GRAPH_ILLEGAL_ACCESS", + "invalid extent", + format!("resources[{i}].texture.extent"), + )); + } + resource.texture = norm(resource.texture.clone()); + } + for (i, r) in g.resources.iter().enumerate() { + if r.texture.mip_level_count != 1 || r.texture.sample_count != 1 { + return Err(fail( + "GRAPH_UNSUPPORTED_FEATURE", + "V1 mipLevels and sampleCount must be 1", + format!("resources[{i}].texture"), + )); + } + let valid = match &r.texture.extent { + Extent::Absolute { + width, + height, + depth_or_array_layers, + } => *width > 0 && *height > 0 && *depth_or_array_layers > 0, + Extent::SurfaceRelative { + width, + height, + depth_or_array_layers, + } => { + width.numerator > 0 + && width.denominator > 0 + && height.numerator > 0 + && height.denominator > 0 + && *depth_or_array_layers > 0 + } + }; + if !valid { + return Err(fail( + "GRAPH_ILLEGAL_ACCESS", + "invalid extent", + format!("resources[{i}].texture.extent"), + )); + } + match r.residency { + Residency::External { .. } => { + let surface = TextureDescriptor { + dimension: Dimension::D2, + format: Format::Surface, + extent: Extent::SurfaceRelative { + width: Ratio { + numerator: 1, + denominator: 1, + }, + height: Ratio { + numerator: 1, + denominator: 1, + }, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + }; + if r.texture != surface { + return Err(fail( + "GRAPH_ILLEGAL_ACCESS", + "external source must use the exact surface descriptor", + format!("resources[{i}]"), + )); + } + } + Residency::Transient => { + if r.texture.format == Format::Surface { + return Err(fail( + "GRAPH_ILLEGAL_ACCESS", + "transient cannot use surface format", + format!("resources[{i}]"), + )); + } + } + } + if matches!(r.texture.extent, Extent::SurfaceRelative { .. }) + && r.texture.dimension != Dimension::D2 + { + return Err(fail( + "GRAPH_ILLEGAL_ACCESS", + "surface-relative extent requires d2", + format!("resources[{i}].texture"), + )); + } + if r.texture.format == Format::Depth32Float && r.texture.dimension != Dimension::D2 { + return Err(fail( + "GRAPH_ILLEGAL_ACCESS", + "depth32_float requires d2", + format!("resources[{i}].texture"), + )); + } + if r.texture.dimension == Dimension::D1 + && !matches!( + r.texture.extent, + Extent::Absolute { + height: 1, + depth_or_array_layers: 1, + .. + } + ) + { + return Err(fail( + "GRAPH_ILLEGAL_ACCESS", + "d1 textures require height and depthOrArrayLayers to be 1", + format!("resources[{i}].texture.extent"), + )); + } + } + let map: HashMap = g + .resources + .iter() + .map(|r| { + ( + ResourceRef { + id: r.id.clone(), + version: r.version, + }, + r, + ) + }) + .collect(); + for (pi, pass) in g.passes.iter().enumerate() { + for resource_ref in pass + .reads + .iter() + .map(|binding| &binding.resource) + .chain(pass.writes.iter().map(|binding| &binding.resource)) + { + if !identifier(&resource_ref.id) { + return Err(fail( + "GRAPH_INVALID_ID", + "invalid resource reference id", + format!("passes[{pi}]"), + )); + } + if !resource_ids.contains(resource_ref) { + return Err(fail( + "GRAPH_UNKNOWN_RESOURCE", + "unknown resource reference", + format!("passes[{pi}]"), + )); + } + } + } + for (i, output) in g.outputs.iter().enumerate() { + if !identifier(&output.resource.id) { + return Err(fail( + "GRAPH_INVALID_ID", + "invalid output resource id", + format!("outputs[{i}].resource.id"), + )); + } + if !resource_ids.contains(&output.resource) { + return Err(fail( + "GRAPH_UNKNOWN_RESOURCE", + "unknown output resource", + format!("outputs[{i}]"), + )); + } + } + let mut observable = vec![false; g.passes.len()]; + let mut parameters = Vec::with_capacity(g.passes.len()); + // Validate executor signatures and pass-local binding identity before building + // the graph-wide writer map. Access legality is deliberately checked later. + for (pi, p) in g.passes.iter().enumerate() { + let contract = match executors.resolve(&p.executor) { + ExecutorResolution::Found(contract) => contract, + resolution => { + return Err(GraphError::at( + if matches!(resolution, ExecutorResolution::UnsupportedVersion) { + "GRAPH_EXECUTOR_VERSION_UNSUPPORTED" + } else { + "GRAPH_UNKNOWN_EXECUTOR" + }, + "executor is not registered", + format!("passes[{pi}].executor"), + )) + } + }; + observable[pi] = contract.inherently_observable(); + parameters.push(contract.normalize_parameters(&p.parameters).map_err(|m| { + GraphError::at( + "GRAPH_PARAMETERS_INVALID", + m, + format!("passes[{pi}].parameters"), + ) + })?); + let mut names = HashSet::new(); + let mut refs = HashSet::new(); + let mut color_locations = HashSet::new(); + for b in &p.reads { + if !identifier(&b.binding) { + return Err(fail( + "GRAPH_INVALID_ID", + "invalid binding", + format!("passes[{pi}].reads"), + )); + } + if !names.insert(&b.binding) || !refs.insert(&b.resource) { + return Err(fail( + "GRAPH_BINDING_INVALID", + "duplicate binding or resource use", + format!("passes[{pi}]"), + )); + } + } + for b in &p.writes { + if !identifier(&b.binding) { + return Err(fail( + "GRAPH_INVALID_ID", + "invalid binding", + format!("passes[{pi}].writes"), + )); + } + if !names.insert(&b.binding) || !refs.insert(&b.resource) { + return Err(fail( + "GRAPH_BINDING_INVALID", + "duplicate binding or resource use", + format!("passes[{pi}]"), + )); + } + if let WriteAccess::ColorAttachment { location, .. } = b.access { + if !color_locations.insert(location) { + return Err(fail( + "GRAPH_BINDING_INVALID", + "duplicate color location", + format!("passes[{pi}].writes"), + )); + } + } + } + contract + .validate_bindings(p, &map) + .map_err(|m| GraphError::at("GRAPH_BINDING_INVALID", m, format!("passes[{pi}]")))?; + } + + let mut writer: HashMap = HashMap::new(); + for (pi, pass) in g.passes.iter().enumerate() { + if pass.state != PassState::Enabled { + continue; + } + for binding in &pass.writes { + if writer.insert(binding.resource.clone(), pi).is_some() { + return Err(fail( + "GRAPH_DUPLICATE_WRITER", + "resource has multiple enabled writers", + format!("passes[{pi}]"), + )); + } + } + } + + for (pi, p) in g.passes.iter().enumerate() { + for b in &p.reads { + let r = map[&b.resource]; + if (r.texture.format.depth() && matches!(b.access, ReadAccess::Storage)) + || (matches!(b.access, ReadAccess::Storage) + && !matches!( + r.texture.format, + Format::Rgba8Unorm | Format::Rgba16Float | Format::R32Float + )) + || (r.texture.format == Format::Surface && !matches!(b.access, ReadAccess::CopySrc)) + { + return Err(fail( + "GRAPH_ILLEGAL_ACCESS", + "format/access mismatch", + format!("passes[{pi}].reads"), + )); + } + } + let mut attachment_descriptor: Option<&TextureDescriptor> = None; + for b in &p.writes { + let r = map[&b.resource]; + let depth = r.texture.format.depth(); + let is_attachment = matches!( + b.access, + WriteAccess::ColorAttachment { .. } | WriteAccess::DepthAttachment { .. } + ); + if (matches!(b.access, WriteAccess::DepthAttachment { .. }) && !depth) + || (matches!(b.access, WriteAccess::ColorAttachment { .. }) && depth) + || (matches!(b.access, WriteAccess::Storage) && depth) + || (matches!(b.access, WriteAccess::Storage) + && !matches!( + r.texture.format, + Format::Rgba8Unorm | Format::Rgba16Float | Format::R32Float + )) + || (is_attachment && r.texture.dimension != Dimension::D2) + { + return Err(fail( + "GRAPH_ILLEGAL_ACCESS", + "format/access mismatch", + format!("passes[{pi}].writes"), + )); + } + if is_attachment { + if let Some(first) = attachment_descriptor { + if first.dimension != r.texture.dimension + || first.extent != r.texture.extent + || first.sample_count != r.texture.sample_count + { + return Err(fail( + "GRAPH_ILLEGAL_ACCESS", + "attachments must have matching dimensions, extents, and sample counts", + format!("passes[{pi}].writes"), + )); + } + } else { + attachment_descriptor = Some(&r.texture); + } + } + match &b.access { + WriteAccess::ColorAttachment { load, .. } => { + if let ColorLoad::Clear { value } = load { + if value.iter().any(|x| !x.is_finite()) { + return Err(fail( + "GRAPH_ILLEGAL_ACCESS", + "clear values must be finite", + format!("passes[{pi}]"), + )); + } + } + } + WriteAccess::DepthAttachment { load, .. } => { + if let DepthLoad::Clear { value } = load { + if !value.is_finite() || !(0.0..=1.0).contains(value) { + return Err(fail( + "GRAPH_ILLEGAL_ACCESS", + "depth clear must be finite and in [0,1]", + format!("passes[{pi}]"), + )); + } + } + } + _ => {} + } + } + } + let mut roots = Vec::new(); + for (i, o) in g.outputs.iter().enumerate() { + let Some(r) = map.get(&o.resource) else { + return Err(fail( + "GRAPH_UNKNOWN_RESOURCE", + "unknown output resource", + format!("outputs[{i}]"), + )); + }; + if writer.get(&o.resource).is_none() { + return Err(fail( + "GRAPH_UNINITIALIZED_RESOURCE", + if matches!(r.residency, Residency::Transient) { + "transient output is uninitialized" + } else { + "external output requires a live writer" + }, + format!("outputs[{i}]"), + )); + } + roots.push(o.resource.clone()) + } + for (pi, p) in g.passes.iter().enumerate() { + if p.state == PassState::Enabled { + for b in &p.reads { + if matches!(map[&b.resource].residency, Residency::Transient) + && !writer.contains_key(&b.resource) + { + return Err(fail( + "GRAPH_UNINITIALIZED_RESOURCE", + "transient read is uninitialized", + format!("passes[{pi}]"), + )); + } + } + } + } + for (pi, pass) in g.passes.iter().enumerate() { + for binding in &pass.writes { + if matches!(map[&binding.resource].residency, Residency::Transient) + && matches!( + binding.access, + WriteAccess::ColorAttachment { + load: ColorLoad::Load, + .. + } | WriteAccess::DepthAttachment { + load: DepthLoad::Load, + .. + } + ) + { + return Err(fail( + "GRAPH_ILLEGAL_ACCESS", + "transient attachment load is illegal", + format!("passes[{pi}]"), + )); + } + } + } + let mut live: Vec = observable + .iter() + .enumerate() + .map(|(i, x)| *x && g.passes[i].state == PassState::Enabled) + .collect(); + let mut stack = roots.clone(); + for (i, is_live) in live.iter().enumerate() { + if *is_live { + stack.extend(g.passes[i].reads.iter().map(|b| b.resource.clone())); + } + } + while let Some(r) = stack.pop() { + if let Some(&p) = writer.get(&r) { + if !live[p] { + live[p] = true; + stack.extend(g.passes[p].reads.iter().map(|b| b.resource.clone())) + } + } + } + let resource_indices: HashMap = g + .resources + .iter() + .enumerate() + .map(|(index, resource)| { + ( + ResourceRef { + id: resource.id.clone(), + version: resource.version, + }, + index, + ) + }) + .collect(); + let mut edges = vec![Vec::<(usize, usize, ResourceRef)>::new(); g.passes.len()]; + let mut indeg = vec![0u32; g.passes.len()]; + for (b, p) in g.passes.iter().enumerate() { + if live[b] { + for u in &p.reads { + if let Some(&a) = writer.get(&u.resource) { + if live[a] { + edges[a].push((b, resource_indices[&u.resource], u.resource.clone())); + indeg[b] += 1 + } + } + } + } + } + for e in &mut edges { + e.sort_by_key(|edge| (edge.0, edge.1)) + } + let mut q = BinaryHeap::new(); + for i in 0..g.passes.len() { + if live[i] && indeg[i] == 0 { + q.push(Reverse(i)) + } + } + let mut order = Vec::new(); + while let Some(Reverse(a)) = q.pop() { + order.push(a); + for (b, _, _) in &edges[a] { + indeg[*b] -= 1; + if indeg[*b] == 0 { + q.push(Reverse(*b)) + } + } + } + if order.len() != live.iter().filter(|x| **x).count() { + fn visit( + node: usize, + edges: &[Vec<(usize, usize, ResourceRef)>], + residual: &[bool], + colors: &mut [u8], + stack: &mut Vec, + incoming: &mut Vec, + ) -> Option<(Vec, Vec)> { + colors[node] = 1; + stack.push(node); + for (next, _, resource) in &edges[node] { + if !residual[*next] { + continue; + } + if colors[*next] == 0 { + incoming.push(resource.clone()); + if let Some(cycle) = visit(*next, edges, residual, colors, stack, incoming) { + return Some(cycle); + } + incoming.pop(); + } else if colors[*next] == 1 { + let start = stack.iter().position(|pass| pass == next)?; + let mut resources = incoming[start..].to_vec(); + resources.push(resource.clone()); + return Some((stack[start..].to_vec(), resources)); + } + } + stack.pop(); + colors[node] = 2; + None + } + let residual: Vec = (0..g.passes.len()) + .map(|i| live[i] && indeg[i] > 0) + .collect(); + let mut colors = vec![0; g.passes.len()]; + let mut stack = Vec::new(); + let mut incoming = Vec::new(); + let mut found = None; + for node in 0..g.passes.len() { + if residual[node] && colors[node] == 0 { + found = visit( + node, + &edges, + &residual, + &mut colors, + &mut stack, + &mut incoming, + ); + if found.is_some() { + break; + } + } + } + let (cycle_passes, cycle_resources) = found.unwrap_or_default(); + let cycle_edges: Vec<_> = cycle_resources.iter().enumerate().map(|(i, r)| serde_json::json!({"from":g.passes[cycle_passes[i]].id,"resource":r,"to":g.passes[cycle_passes[(i+1)%cycle_passes.len()]].id})).collect(); + return Err(GraphError { + code: "GRAPH_CYCLE", + message: "live graph contains a cycle".into(), + details: serde_json::json!({"message":"live graph contains a cycle","kind":"cycle","edges":cycle_edges}), + }); + } + let pos: HashMap = order + .iter() + .enumerate() + .map(|(i, p)| u32::try_from(i).map(|i| (*p, i))) + .collect::>() + .map_err(|_| GraphError::new("GRAPH_LIMIT_EXCEEDED", "pass index overflow"))?; + let boundary = u32::try_from(order.len()) + .map_err(|_| GraphError::new("GRAPH_LIMIT_EXCEEDED", "pass index overflow"))?; + let output_refs: HashSet<_> = roots.into_iter().collect(); + let mut resources = Vec::new(); + let mut keys = Vec::new(); + for (ri, r) in g.resources.iter().enumerate() { + let rr = ResourceRef { + id: r.id.clone(), + version: r.version, + }; + let mut points = Vec::new(); + let mut usage = BTreeSet::new(); + for (pi, p) in g.passes.iter().enumerate() { + if let Some(&x) = pos.get(&pi) { + for b in &p.reads { + if b.resource == rr { + points.push(x); + usage.insert(usage_read(b.access)); + } + } + for b in &p.writes { + if b.resource == rr { + points.push(x); + usage.insert(usage_write(&b.access)); + } + } + } + } + if output_refs.contains(&rr) { + points.push(boundary) + } + if points.is_empty() { + continue; + } + let key = TextureAllocationKey { + descriptor: norm(r.texture.clone()), + usage: usage.into_iter().collect(), + view_formats: vec![], + }; + keys.push(if matches!(r.residency, Residency::Transient) { + Some(key) + } else { + None + }); + resources.push(CompiledResource { + original_index: u32::try_from(ri) + .map_err(|_| GraphError::new("GRAPH_LIMIT_EXCEEDED", "resource index overflow"))?, + resource_ref: rr.clone(), + residency: r.residency.clone(), + descriptor: norm(r.texture.clone()), + writer: writer.get(&rr).and_then(|p| pos.get(p)).copied(), + lifetime: Lifetime { + first_use: *points.iter().min().unwrap(), + last_use: *points.iter().max().unwrap(), + }, + allocation: None, + }) + } + let resource_remap: HashMap = resources + .iter() + .enumerate() + .map(|(i, r)| u32::try_from(i).map(|i| (r.resource_ref.clone(), i))) + .collect::>() + .map_err(|_| GraphError::new("GRAPH_LIMIT_EXCEEDED", "resource index overflow"))?; + let mut groups: BTreeMap> = BTreeMap::new(); + for (i, k) in keys.into_iter().enumerate() { + if let Some(k) = k { + groups.entry(k).or_default().push(i) + } + } + let mut classes = Vec::new(); + let mut next = 0u32; + for (class_index, (k, mut ix)) in groups.into_iter().enumerate() { + ix.sort_by_key(|i| { + ( + resources[*i].lifetime.first_use, + resources[*i].lifetime.last_use, + resources[*i].original_index, + ) + }); + let mut active = BinaryHeap::>::new(); + let mut free = BinaryHeap::>::new(); + let mut count = 0; + for i in ix { + while let Some(Reverse((last, slot))) = active.peek().copied() { + if last < resources[i].lifetime.first_use { + active.pop(); + free.push(Reverse(slot)) + } else { + break; + } + } + let slot = free.pop().map(|x| x.0).unwrap_or_else(|| { + let x = count; + count += 1; + x + }); + resources[i].allocation = Some(TransientAllocation { + class: u32::try_from(class_index) + .map_err(|_| GraphError::new("GRAPH_LIMIT_EXCEEDED", "class index overflow"))?, + slot, + }); + active.push(Reverse((resources[i].lifetime.last_use, slot))) + } + next = next + .checked_add(count) + .ok_or_else(|| GraphError::new("GRAPH_LIMIT_EXCEEDED", "allocation overflow"))?; + classes.push(AllocationClass { + key: k, + slot_count: count, + }) + } + Ok(CompiledGraph { + schema_version: 1, + graph_id: g.graph_id, + revision: g.revision, + passes: order + .into_iter() + .map(|i| { + Ok(CompiledPass { + id: g.passes[i].id.clone(), + original_index: u32::try_from(i).map_err(|_| { + GraphError::new("GRAPH_LIMIT_EXCEEDED", "pass index overflow") + })?, + executor: g.passes[i].executor.clone(), + parameters: parameters[i].clone(), + reads: g.passes[i] + .reads + .iter() + .map(|b| { + Ok(CompiledRead { + binding: b.binding.clone(), + resource: *resource_remap.get(&b.resource).ok_or_else(|| { + GraphError::new( + "GRAPH_UNKNOWN_RESOURCE", + "compiled read remap missing", + ) + })?, + access: b.access, + }) + }) + .collect::>()?, + writes: g.passes[i] + .writes + .iter() + .map(|b| { + Ok(CompiledWrite { + binding: b.binding.clone(), + resource: *resource_remap.get(&b.resource).ok_or_else(|| { + GraphError::new( + "GRAPH_UNKNOWN_RESOURCE", + "compiled write remap missing", + ) + })?, + access: b.access.clone(), + }) + }) + .collect::>()?, + }) + }) + .collect::>()?, + culled_pass_count: u32::try_from(g.passes.len() - live.iter().filter(|x| **x).count()) + .map_err(|_| GraphError::new("GRAPH_LIMIT_EXCEEDED", "pass count overflow"))?, + culled_resource_count: u32::try_from(g.resources.len() - resources.len()) + .map_err(|_| GraphError::new("GRAPH_LIMIT_EXCEEDED", "resource count overflow"))?, + resources, + outputs: g + .outputs + .into_iter() + .map(|o| { + Ok(CompiledOutput { + name: o.name, + resource: *resource_remap.get(&o.resource).ok_or_else(|| { + GraphError::new("GRAPH_UNKNOWN_RESOURCE", "compiled output remap missing") + })?, + }) + }) + .collect::>()?, + allocation_classes: classes, + transient_slot_count: next, + }) +} diff --git a/renderer/src/render_graph/mod.rs b/renderer/src/render_graph/mod.rs new file mode 100644 index 0000000..dec32eb --- /dev/null +++ b/renderer/src/render_graph/mod.rs @@ -0,0 +1,58 @@ +//! Device-free V1 render graph compiler and compiled graph registry. + +mod compiler; +mod registry; +mod runtime; +mod schema; + +pub use compiler::{ + compile, compile_with, parse_and_compile, AllocationClass, CompiledGraph, CompiledOutput, + CompiledPass, CompiledRead, CompiledResource, CompiledWrite, ExecutorContract, + ExecutorRegistry, ExecutorResolution, Lifetime, NormalizedParameters, SceneForwardExecutors, + TextureAllocationKey, TextureUsage, TransientAllocation, +}; +pub use registry::{CompiledGraphId, Registry}; +pub use runtime::{ + class_offsets, resolve_extent, runtime_texture_key, validate_activatable, ResolvedExtent, + RuntimeTextureKey, +}; +pub use schema::*; + +pub const MAX_JSON_BYTES: usize = 1024 * 1024; +pub const MAX_RESOURCES: usize = 1024; +pub const MAX_PASSES: usize = 1024; +pub const MAX_USES: usize = 8192; +pub const MAX_OUTPUTS: usize = 64; + +#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)] +pub struct GraphError { + pub code: &'static str, + pub message: String, + pub details: serde_json::Value, +} + +impl GraphError { + pub(crate) fn new(code: &'static str, message: impl Into) -> Self { + let message = message.into(); + Self { + code, + details: serde_json::json!({"message": message}), + message, + } + } + pub(crate) fn at( + code: &'static str, + message: impl Into, + path: impl Into, + ) -> Self { + let message = message.into(); + Self { + code, + details: serde_json::json!({"message": message, "path": path.into()}), + message, + } + } +} + +#[cfg(test)] +mod tests; diff --git a/renderer/src/render_graph/registry.rs b/renderer/src/render_graph/registry.rs new file mode 100644 index 0000000..9b38e72 --- /dev/null +++ b/renderer/src/render_graph/registry.rs @@ -0,0 +1,114 @@ +use super::{parse_and_compile, CompiledGraph, GraphError}; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CompiledGraphId { + pub slot: u32, + pub generation: u32, +} +impl From for [u32; 2] { + fn from(x: CompiledGraphId) -> Self { + [x.slot, x.generation] + } +} +#[derive(Debug)] +struct Slot { + generation: u32, + value: Option, + retired: bool, +} +#[derive(Debug)] +pub struct Registry { + slots: Vec, + capacity: u32, +} +impl Default for Registry { + fn default() -> Self { + Self::new(16) + } +} +impl Registry { + pub fn new(capacity: u32) -> Self { + Self { + slots: vec![], + capacity, + } + } + pub fn compile( + &mut self, + bytes: &[u8], + ) -> Result<(CompiledGraphId, serde_json::Value), GraphError> { + let graph = parse_and_compile(bytes)?; + if let Some((i, s)) = self.slots.iter_mut().enumerate().find(|(_, s)| { + s.value + .as_ref() + .is_some_and(|g| g.graph_id == graph.graph_id) + }) { + if graph.revision <= s.value.as_ref().unwrap().revision { + return Err(GraphError::new( + "GRAPH_REVISION_CONFLICT", + "revision must increase", + )); + } + let id = CompiledGraphId { + slot: u32::try_from(i).map_err(|_| { + GraphError::new("GRAPH_LIMIT_EXCEEDED", "registry slot overflow") + })?, + generation: s.generation, + }; + let summary = graph.summary(id.into()); + s.value = Some(graph); + return Ok((id, summary)); + } + let i = if let Some(i) = self + .slots + .iter() + .position(|s| s.value.is_none() && !s.retired) + { + i + } else { + if u32::try_from(self.slots.len()).map_or(true, |len| len >= self.capacity) { + return Err(GraphError::new( + "GRAPH_REGISTRY_FULL", + "compiled graph registry is full", + )); + } + self.slots.push(Slot { + generation: 1, + value: None, + retired: false, + }); + self.slots.len() - 1 + }; + let id = CompiledGraphId { + slot: u32::try_from(i) + .map_err(|_| GraphError::new("GRAPH_LIMIT_EXCEEDED", "registry slot overflow"))?, + generation: self.slots[i].generation, + }; + let summary = graph.summary(id.into()); + self.slots[i].value = Some(graph); + Ok((id, summary)) + } + pub fn get(&self, id: CompiledGraphId) -> Result<&CompiledGraph, GraphError> { + self.slots + .get(id.slot as usize) + .filter(|s| s.generation == id.generation) + .and_then(|s| s.value.as_ref()) + .ok_or_else(|| GraphError::new("STALE_GRAPH_ID", "stale compiled graph id")) + } + pub fn contains(&self, id: CompiledGraphId) -> bool { + self.get(id).is_ok() + } + pub fn drop_graph(&mut self, id: CompiledGraphId) -> Result<(), GraphError> { + let s = self + .slots + .get_mut(id.slot as usize) + .filter(|s| s.generation == id.generation && s.value.is_some()) + .ok_or_else(|| GraphError::new("STALE_GRAPH_ID", "stale compiled graph id"))?; + s.value = None; + if s.generation == u32::MAX { + s.retired = true + } else { + s.generation += 1 + } + Ok(()) + } +} diff --git a/renderer/src/render_graph/runtime.rs b/renderer/src/render_graph/runtime.rs new file mode 100644 index 0000000..8183679 --- /dev/null +++ b/renderer/src/render_graph/runtime.rs @@ -0,0 +1,215 @@ +use std::collections::BTreeMap; + +use super::{ + CompiledGraph, Dimension, Extent, ExternalSource, Format, GraphError, Residency, + TextureAllocationKey, TextureUsage, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ResolvedExtent { + pub width: u32, + pub height: u32, + pub depth_or_array_layers: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct RuntimeTextureKey { + pub dimension: Dimension, + pub format: Format, + pub extent: ResolvedExtent, + pub mip_level_count: u32, + pub sample_count: u32, + pub usage: Vec, + pub view_formats: Vec, +} + +fn scaled(value: u32, numerator: u32, denominator: u32) -> Result { + if denominator == 0 { + return Err(GraphError::new( + "GRAPH_EXECUTION_UNSUPPORTED", + "zero extent denominator", + )); + } + let product = u64::from(value) + .checked_mul(u64::from(numerator)) + .ok_or_else(|| GraphError::new("GRAPH_EXECUTION_UNSUPPORTED", "extent overflow"))?; + let result = product + .checked_add(u64::from(denominator) - 1) + .ok_or_else(|| GraphError::new("GRAPH_EXECUTION_UNSUPPORTED", "extent overflow"))? + / u64::from(denominator); + u32::try_from(result.max(1)) + .map_err(|_| GraphError::new("GRAPH_EXECUTION_UNSUPPORTED", "extent overflow")) +} + +pub fn resolve_extent(extent: &Extent, surface: [u32; 2]) -> Result { + let (width, height, depth_or_array_layers) = match extent { + Extent::Absolute { + width, + height, + depth_or_array_layers, + } => (*width, *height, *depth_or_array_layers), + Extent::SurfaceRelative { + width, + height, + depth_or_array_layers, + } => ( + scaled(surface[0], width.numerator, width.denominator)?, + scaled(surface[1], height.numerator, height.denominator)?, + *depth_or_array_layers, + ), + }; + if width == 0 || height == 0 || depth_or_array_layers == 0 { + return Err(GraphError::new( + "GRAPH_EXECUTION_UNSUPPORTED", + "texture extent must be nonzero", + )); + } + Ok(ResolvedExtent { + width, + height, + depth_or_array_layers, + }) +} + +pub fn runtime_texture_key( + key: &TextureAllocationKey, + surface: [u32; 2], +) -> Result { + Ok(RuntimeTextureKey { + dimension: key.descriptor.dimension, + format: key.descriptor.format, + extent: resolve_extent(&key.descriptor.extent, surface)?, + mip_level_count: key.descriptor.mip_level_count, + sample_count: key.descriptor.sample_count, + usage: key.usage.clone(), + view_formats: key.view_formats.clone(), + }) +} + +/// Assigns disjoint physical ranges after merging symbolic allocation classes that +/// resolve to the same concrete descriptor key. +pub fn class_offsets( + classes: &[(TextureAllocationKey, u32)], + surface: [u32; 2], +) -> Result, GraphError> { + let mut next = BTreeMap::new(); + let mut offsets = Vec::with_capacity(classes.len()); + for (key, count) in classes { + let concrete = runtime_texture_key(key, surface)?; + let offset = next.entry(concrete).or_insert(0u32); + offsets.push(*offset); + *offset = offset.checked_add(*count).ok_or_else(|| { + GraphError::new("GRAPH_EXECUTION_UNSUPPORTED", "transient slot overflow") + })?; + } + Ok(offsets) +} + +pub fn validate_activatable(graph: &CompiledGraph) -> Result<(), GraphError> { + let unsupported = || { + GraphError::new( + "GRAPH_EXECUTION_UNSUPPORTED", + "graph is outside the activatable Phase 6 subset", + ) + }; + if graph.passes.is_empty() || graph.outputs.is_empty() { + return Err(unsupported()); + } + let surface_outputs = graph + .outputs + .iter() + .filter(|o| { + matches!( + graph.resources[o.resource as usize].residency, + Residency::External { + source: ExternalSource::SurfaceColor + } + ) + }) + .count(); + if surface_outputs == 0 { + return Err(unsupported()); + } + for pass in &graph.passes { + if pass.executor.key != "scene_forward" + || pass.executor.version != 1 + || !pass.reads.is_empty() + { + return Err(unsupported()); + } + let color = pass + .writes + .iter() + .find(|w| w.binding == "color") + .ok_or_else(&unsupported)?; + let depth = pass + .writes + .iter() + .find(|w| w.binding == "depth") + .ok_or_else(&unsupported)?; + let c = &graph.resources[color.resource as usize]; + let d = &graph.resources[depth.resource as usize]; + if !matches!( + c.residency, + Residency::External { + source: ExternalSource::SurfaceColor + } + ) || !matches!(d.residency, Residency::Transient) + || d.descriptor.format != Format::Depth32Float + || d.descriptor.dimension != Dimension::D2 + || d.descriptor.mip_level_count != 1 + || d.descriptor.sample_count != 1 + || d.descriptor.extent != c.descriptor.extent + { + return Err(unsupported()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::render_graph::{Dimension, Ratio, TextureDescriptor, TextureUsage}; + fn key(n: u32, d: u32) -> TextureAllocationKey { + TextureAllocationKey { + descriptor: TextureDescriptor { + dimension: Dimension::D2, + format: Format::Depth32Float, + extent: Extent::SurfaceRelative { + width: Ratio { + numerator: n, + denominator: d, + }, + height: Ratio { + numerator: n, + denominator: d, + }, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + }, + usage: vec![TextureUsage::DepthAttachment], + view_formats: vec![], + } + } + #[test] + fn extent_uses_checked_ceil_and_minimum_one() { + assert_eq!( + resolve_extent(&key(1, 2).descriptor.extent, [3, 1]).unwrap(), + ResolvedExtent { + width: 2, + height: 1, + depth_or_array_layers: 1 + } + ); + } + #[test] + fn equivalent_symbolic_classes_are_disjoint() { + assert_eq!( + class_offsets(&[(key(1, 2), 2), (key(2, 4), 3)], [100, 100]).unwrap(), + vec![0, 2] + ); + } +} diff --git a/renderer/src/render_graph/schema.rs b/renderer/src/render_graph/schema.rs new file mode 100644 index 0000000..ed55540 --- /dev/null +++ b/renderer/src/render_graph/schema.rs @@ -0,0 +1,188 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct GraphV1 { + pub schema_version: u32, + pub graph_id: String, + pub revision: u32, + pub resources: Vec, + pub passes: Vec, + pub outputs: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ResourceRef { + pub id: String, + pub version: u32, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Resource { + pub id: String, + pub version: u32, + pub residency: Residency, + pub texture: TextureDescriptor, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum Residency { + External { source: ExternalSource }, + Transient, +} +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum ExternalSource { + SurfaceColor, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct TextureDescriptor { + pub dimension: Dimension, + pub format: Format, + pub extent: Extent, + pub mip_level_count: u32, + pub sample_count: u32, +} +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum Dimension { + D1, + D2, + D3, +} +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum Format { + Surface, + Rgba8Unorm, + Rgba8UnormSrgb, + Bgra8Unorm, + Bgra8UnormSrgb, + Rgba16Float, + R32Float, + Depth32Float, +} +impl Format { + pub(crate) fn depth(self) -> bool { + self == Self::Depth32Float + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum Extent { + Absolute { + width: u32, + height: u32, + #[serde(rename = "depthOrArrayLayers")] + depth_or_array_layers: u32, + }, + SurfaceRelative { + width: Ratio, + height: Ratio, + #[serde(rename = "depthOrArrayLayers")] + depth_or_array_layers: u32, + }, +} +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[serde(deny_unknown_fields)] +pub struct Ratio { + pub numerator: u32, + pub denominator: u32, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Pass { + pub id: String, + pub state: PassState, + pub executor: ExecutorRef, + pub parameters: serde_json::Value, + pub reads: Vec, + pub writes: Vec, +} +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PassState { + Enabled, + Muted, +} +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ExecutorRef { + pub key: String, + pub version: u32, +} +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ReadBinding { + pub binding: String, + pub resource: ResourceRef, + pub access: ReadAccess, +} +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum ReadAccess { + Sampled, + Storage, + CopySrc, +} +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct WriteBinding { + pub binding: String, + pub resource: ResourceRef, + pub access: WriteAccess, +} +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum WriteAccess { + Storage, + CopyDst, + ColorAttachment { + location: u32, + load: ColorLoad, + store: StoreOp, + }, + DepthAttachment { + load: DepthLoad, + store: StoreOp, + }, +} +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] +pub enum ColorLoad { + Clear { value: [f64; 4] }, + Load, +} +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] +pub enum DepthLoad { + Clear { value: f32 }, + Load, +} +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum StoreOp { + Store, + Discard, +} +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Output { + pub name: String, + pub resource: ResourceRef, +} + +pub(crate) fn identifier(s: &str) -> bool { + s.as_bytes() + .first() + .is_some_and(|c| c.is_ascii_alphabetic() || *c == b'_') + && s.bytes() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'/' | b'-')) +} diff --git a/renderer/src/render_graph/tests.rs b/renderer/src/render_graph/tests.rs new file mode 100644 index 0000000..13da4ca --- /dev/null +++ b/renderer/src/render_graph/tests.rs @@ -0,0 +1,702 @@ +use super::*; + +struct TestExecutors; +struct TestExecutor { + observable: bool, +} + +static TEST_EXECUTOR: TestExecutor = TestExecutor { observable: false }; +static OBSERVABLE_EXECUTOR: TestExecutor = TestExecutor { observable: true }; + +impl ExecutorRegistry for TestExecutors { + fn resolve(&self, executor: &ExecutorRef) -> ExecutorResolution<'_> { + if executor.version != 1 { + return ExecutorResolution::UnsupportedVersion; + } + match executor.key.as_str() { + "test" => ExecutorResolution::Found(&TEST_EXECUTOR), + "observable" => ExecutorResolution::Found(&OBSERVABLE_EXECUTOR), + _ => ExecutorResolution::UnknownKey, + } + } +} + +impl ExecutorContract for TestExecutor { + fn inherently_observable(&self) -> bool { + self.observable + } + + fn normalize_parameters( + &self, + parameters: &serde_json::Value, + ) -> Result { + if parameters == &serde_json::json!({}) { + Ok(NormalizedParameters::SceneForward) + } else { + Err("test parameters must be empty".into()) + } + } + + fn validate_bindings( + &self, + _pass: &Pass, + _resources: &std::collections::HashMap, + ) -> Result<(), String> { + Ok(()) + } +} + +fn compile_json(value: serde_json::Value) -> Result { + compile_with(&serde_json::to_vec(&value).unwrap(), &TestExecutors) +} + +fn texture(format: &str) -> serde_json::Value { + serde_json::json!({ + "dimension": "d2", + "format": format, + "extent": {"kind":"absolute", "width":16, "height":16, "depthOrArrayLayers":1}, + "mipLevelCount": 1, + "sampleCount": 1 + }) +} + +fn transient(id: &str, format: &str) -> serde_json::Value { + serde_json::json!({ + "id": id, + "version": 0, + "residency": {"kind":"transient"}, + "texture": texture(format) + }) +} + +fn pass( + id: &str, + executor: &str, + reads: serde_json::Value, + writes: serde_json::Value, +) -> serde_json::Value { + serde_json::json!({ + "id": id, + "state": "enabled", + "executor": {"key":executor, "version":1}, + "parameters": {}, + "reads": reads, + "writes": writes + }) +} + +fn resource_ref(id: &str) -> serde_json::Value { + serde_json::json!({"id":id, "version":0}) +} + +fn sampled(binding: &str, id: &str) -> serde_json::Value { + serde_json::json!({"binding":binding, "resource":resource_ref(id), "access":"sampled"}) +} + +fn copy_write(binding: &str, id: &str) -> serde_json::Value { + serde_json::json!({"binding":binding, "resource":resource_ref(id), "access":{"kind":"copy_dst"}}) +} + +fn color_write(binding: &str, id: &str, location: u32) -> serde_json::Value { + serde_json::json!({ + "binding":binding, + "resource":resource_ref(id), + "access":{ + "kind":"color_attachment", + "location":location, + "load":{"op":"clear", "value":[0.0, 0.0, 0.0, 1.0]}, + "store":"store" + } + }) +} + +fn color_load(binding: &str, id: &str, location: u32) -> serde_json::Value { + serde_json::json!({ + "binding":binding, + "resource":resource_ref(id), + "access":{ + "kind":"color_attachment", + "location":location, + "load":{"op":"load"}, + "store":"store" + } + }) +} + +fn graph( + resources: serde_json::Value, + passes: serde_json::Value, + outputs: serde_json::Value, +) -> serde_json::Value { + serde_json::json!({ + "schemaVersion":1, + "graphId":"test_graph", + "revision":1, + "resources":resources, + "passes":passes, + "outputs":outputs + }) +} + +fn empty(id: &str, revision: u32) -> Vec { + format!(r#"{{"schemaVersion":1,"graphId":"{id}","revision":{revision},"resources":[],"passes":[],"outputs":[]}}"#).into_bytes() +} +fn error(bytes: &[u8]) -> &'static str { + parse_and_compile(bytes).unwrap_err().code +} + +#[test] +fn size_precedes_encoding() { + assert_eq!( + error(&vec![0xff; MAX_JSON_BYTES + 1]), + "GRAPH_PAYLOAD_TOO_LARGE" + ); +} +#[test] +fn encoding_precedes_schema() { + assert_eq!(error(&[0xff]), "GRAPH_ENCODING_INVALID"); +} +#[test] +fn malformed_json() { + assert_eq!(error(b"{"), "GRAPH_JSON_INVALID"); +} +#[test] +fn missing_schema_probe() { + assert_eq!(error(b"{}"), "GRAPH_SCHEMA_UNSUPPORTED"); +} +#[test] +fn unsupported_schema_probe() { + assert_eq!(error(br#"{"schemaVersion":2}"#), "GRAPH_SCHEMA_UNSUPPORTED"); +} +#[test] +fn strict_unknown_field() { + assert_eq!(error(br#"{"schemaVersion":1,"graphId":"g","revision":1,"resources":[],"passes":[],"outputs":[],"extra":0}"#), "GRAPH_JSON_INVALID"); +} +#[test] +fn identifier_first_character() { + assert_eq!(error(&empty("1bad", 1)), "GRAPH_INVALID_ID"); +} +#[test] +fn underscore_identifier() { + assert_eq!(parse_and_compile(&empty("_ok", 1)).unwrap().graph_id, "_ok"); +} +#[test] +fn revision_required() { + assert_eq!(error(&empty("g", 0)), "GRAPH_INVALID_ID"); +} + +#[test] +fn resource_version_zero_and_wire_names() { + let json = br#"{"schemaVersion":1,"graphId":"g","revision":1,"resources":[{"id":"r","version":0,"residency":{"kind":"transient"},"texture":{"dimension":"d2","format":"rgba8_unorm","extent":{"kind":"absolute","width":1,"height":1,"depthOrArrayLayers":1},"mipLevelCount":1,"sampleCount":1}}],"passes":[],"outputs":[]}"#; + assert_eq!(parse_and_compile(json).unwrap().culled_resource_count, 1); +} +#[test] +fn old_mip_wire_rejected() { + let mut s = String::from_utf8(empty("g", 1)).unwrap(); + s=s.replace("\"resources\":[]", "\"resources\":[{\"id\":\"r\",\"version\":0,\"residency\":{\"kind\":\"transient\"},\"texture\":{\"dimension\":\"d2\",\"format\":\"rgba8_unorm\",\"extent\":{\"kind\":\"absolute\",\"width\":1,\"height\":1,\"depthOrArrayLayers\":1},\"mipLevels\":1,\"sampleCount\":1}}]"); + assert_eq!(error(s.as_bytes()), "GRAPH_JSON_INVALID"); +} + +#[test] +fn registry_transaction_on_parse_failure() { + let mut r = Registry::new(1); + assert!(r.compile(b"{").is_err()); + assert!(r.compile(&empty("g", 1)).is_ok()); +} +#[test] +fn registry_capacity() { + let mut r = Registry::new(1); + r.compile(&empty("a", 1)).unwrap(); + assert_eq!( + r.compile(&empty("b", 1)).unwrap_err().code, + "GRAPH_REGISTRY_FULL" + ); +} +#[test] +fn registry_revision_replaces_in_place() { + let mut r = Registry::new(1); + let (a, _) = r.compile(&empty("g", 1)).unwrap(); + let (b, _) = r.compile(&empty("g", 2)).unwrap(); + assert_eq!(a, b); + assert_eq!(r.get(a).unwrap().revision, 2); +} +#[test] +fn registry_revision_conflict() { + let mut r = Registry::new(1); + r.compile(&empty("g", 2)).unwrap(); + assert_eq!( + r.compile(&empty("g", 2)).unwrap_err().code, + "GRAPH_REVISION_CONFLICT" + ); +} +#[test] +fn registry_drop_and_stale() { + let mut r = Registry::new(1); + let (id, _) = r.compile(&empty("g", 1)).unwrap(); + r.drop_graph(id).unwrap(); + assert_eq!(r.get(id).unwrap_err().code, "STALE_GRAPH_ID"); + assert_eq!(r.drop_graph(id).unwrap_err().code, "STALE_GRAPH_ID"); +} +#[test] +fn registry_reuse_increments_generation() { + let mut r = Registry::new(1); + let (a, _) = r.compile(&empty("a", 1)).unwrap(); + r.drop_graph(a).unwrap(); + let (b, _) = r.compile(&empty("b", 1)).unwrap(); + assert_eq!(a.slot, b.slot); + assert_eq!(a.generation + 1, b.generation); +} +#[test] +fn graph_error_details_always_have_message() { + let e = parse_and_compile(b"{}").unwrap_err(); + assert!(e.details["message"].is_string()); +} + +#[test] +fn zero_surface_ratio_is_rejected_without_panicking() { + for field in ["width", "height"] { + let mut resource = transient("r", "rgba8_unorm"); + resource["texture"]["extent"] = serde_json::json!({ + "kind":"surface_relative", + "width":{"numerator":1,"denominator":1}, + "height":{"numerator":1,"denominator":1}, + "depthOrArrayLayers":1 + }); + resource["texture"]["extent"][field] = serde_json::json!({"numerator":0,"denominator":0}); + let error = compile_json(graph( + serde_json::json!([resource]), + serde_json::json!([]), + serde_json::json!([]), + )) + .unwrap_err(); + assert_eq!(error.code, "GRAPH_ILLEGAL_ACCESS"); + } +} + +#[test] +fn depth_texture_accepts_copy_destination_access() { + let compiled = compile_json(graph( + serde_json::json!([transient("depth", "depth32_float")]), + serde_json::json!([pass( + "write_depth", + "observable", + serde_json::json!([]), + serde_json::json!([copy_write("destination", "depth")]) + )]), + serde_json::json!([]), + )) + .unwrap(); + assert_eq!(compiled.passes.len(), 1); +} + +#[test] +fn unknown_resources_precede_executor_and_parameter_errors() { + let mut invalid = pass( + "bad", + "missing_executor", + serde_json::json!([sampled("input", "missing_resource")]), + serde_json::json!([]), + ); + invalid["parameters"] = serde_json::json!({"also":"invalid"}); + let error = compile_json(graph( + serde_json::json!([]), + serde_json::json!([invalid]), + serde_json::json!([]), + )) + .unwrap_err(); + assert_eq!(error.code, "GRAPH_UNKNOWN_RESOURCE"); +} + +#[test] +fn executor_contract_normalizes_parameters_and_reports_invalid_parameters() { + let valid = compile_json(graph( + serde_json::json!([transient("r", "rgba8_unorm")]), + serde_json::json!([pass( + "p", + "observable", + serde_json::json!([]), + serde_json::json!([copy_write("out", "r")]) + )]), + serde_json::json!([]), + )) + .unwrap(); + assert_eq!( + valid.passes[0].parameters, + NormalizedParameters::SceneForward + ); + + let mut invalid = pass( + "p", + "observable", + serde_json::json!([]), + serde_json::json!([copy_write("out", "r")]), + ); + invalid["parameters"] = serde_json::json!({"unexpected":true}); + let error = compile_json(graph( + serde_json::json!([transient("r", "rgba8_unorm")]), + serde_json::json!([invalid]), + serde_json::json!([]), + )) + .unwrap_err(); + assert_eq!(error.code, "GRAPH_PARAMETERS_INVALID"); +} + +#[test] +fn culls_dead_branches_and_orders_live_dependencies_deterministically() { + let compiled = compile_json(graph( + serde_json::json!([ + transient("middle", "rgba8_unorm"), + transient("output", "rgba8_unorm"), + transient("dead", "rgba8_unorm") + ]), + serde_json::json!([ + pass( + "consumer", + "test", + serde_json::json!([sampled("input", "middle")]), + serde_json::json!([copy_write("out", "output")]) + ), + pass( + "producer", + "test", + serde_json::json!([]), + serde_json::json!([copy_write("out", "middle")]) + ), + pass( + "dead", + "test", + serde_json::json!([]), + serde_json::json!([copy_write("out", "dead")]) + ) + ]), + serde_json::json!([{"name":"present", "resource":resource_ref("output")}]), + )) + .unwrap(); + assert_eq!( + compiled + .passes + .iter() + .map(|p| p.id.as_str()) + .collect::>(), + ["producer", "consumer"] + ); + assert_eq!(compiled.culled_pass_count, 1); + assert_eq!(compiled.culled_resource_count, 1); +} + +#[test] +fn output_extends_inclusive_lifetime_to_graph_boundary() { + let compiled = compile_json(graph( + serde_json::json!([transient("out", "rgba8_unorm")]), + serde_json::json!([pass( + "write", + "test", + serde_json::json!([]), + serde_json::json!([copy_write("out", "out")]) + )]), + serde_json::json!([{"name":"present", "resource":resource_ref("out")}]), + )) + .unwrap(); + assert_eq!(compiled.resources[0].lifetime.first_use, 0); + assert_eq!(compiled.resources[0].lifetime.last_use, 1); +} + +#[test] +fn transient_slots_reuse_only_for_non_overlapping_compatible_lifetimes() { + let compiled = compile_json(graph( + serde_json::json!([ + transient("first", "rgba8_unorm"), + transient("second", "rgba8_unorm"), + transient("incompatible", "rgba16_float") + ]), + serde_json::json!([ + pass( + "a", + "observable", + serde_json::json!([]), + serde_json::json!([copy_write("out", "first")]) + ), + pass( + "b", + "observable", + serde_json::json!([]), + serde_json::json!([copy_write("out", "second")]) + ), + pass( + "c", + "observable", + serde_json::json!([]), + serde_json::json!([copy_write("out", "incompatible")]) + ) + ]), + serde_json::json!([]), + )) + .unwrap(); + let allocations: Vec<_> = compiled + .resources + .iter() + .map(|resource| resource.allocation.unwrap()) + .collect(); + assert_eq!(allocations[0], allocations[1]); + assert_ne!(allocations[0].class, allocations[2].class); + assert_eq!(compiled.allocation_classes.len(), 2); +} + +#[test] +fn cycle_details_survive_a_non_cycle_dfs_branch() { + let result = compile_json(graph( + serde_json::json!([ + transient("ab", "rgba8_unorm"), + transient("bc", "rgba8_unorm"), + transient("ca", "rgba8_unorm"), + transient("branch", "rgba8_unorm") + ]), + serde_json::json!([ + pass( + "a", + "observable", + serde_json::json!([sampled("ca", "ca")]), + serde_json::json!([copy_write("ab", "ab"), copy_write("branch", "branch")]) + ), + pass( + "branch", + "observable", + serde_json::json!([sampled("input", "branch")]), + serde_json::json!([]) + ), + pass( + "b", + "observable", + serde_json::json!([sampled("ab", "ab")]), + serde_json::json!([copy_write("bc", "bc")]) + ), + pass( + "c", + "observable", + serde_json::json!([sampled("bc", "bc")]), + serde_json::json!([copy_write("ca", "ca")]) + ) + ]), + serde_json::json!([]), + )); + let error = result.unwrap_err(); + assert_eq!(error.code, "GRAPH_CYCLE"); + assert_eq!(error.details["kind"], "cycle"); + let edges = error.details["edges"].as_array().unwrap(); + assert_eq!(edges.len(), 3); + assert!(edges.iter().all(|edge| edge["from"] != "branch")); +} + +#[test] +fn duplicate_external_source_is_an_identity_error_before_descriptor_validation() { + let external = |id: &str, texture: serde_json::Value| { + serde_json::json!({ + "id":id, + "version":0, + "residency":{"kind":"external", "source":"surface_color"}, + "texture":texture + }) + }; + let surface = serde_json::json!({ + "dimension":"d2", + "format":"surface", + "extent":{ + "kind":"surface_relative", + "width":{"numerator":1,"denominator":1}, + "height":{"numerator":1,"denominator":1}, + "depthOrArrayLayers":1 + }, + "mipLevelCount":1, + "sampleCount":1 + }); + let error = compile_json(graph( + serde_json::json!([ + external("first", surface), + external("second", texture("rgba8_unorm")) + ]), + serde_json::json!([]), + serde_json::json!([]), + )) + .unwrap_err(); + assert_eq!(error.code, "GRAPH_DUPLICATE_ID"); +} + +#[test] +fn duplicate_writer_precedes_illegal_access_on_the_second_writer() { + let illegal_depth_color = color_write("bad", "depth", 0); + let error = compile_json(graph( + serde_json::json!([transient("depth", "depth32_float")]), + serde_json::json!([ + pass( + "first", + "observable", + serde_json::json!([]), + serde_json::json!([copy_write("out", "depth")]) + ), + pass( + "second", + "observable", + serde_json::json!([]), + serde_json::json!([illegal_depth_color]) + ) + ]), + serde_json::json!([]), + )) + .unwrap_err(); + assert_eq!(error.code, "GRAPH_DUPLICATE_WRITER"); +} + +#[test] +fn rejects_device_invalid_dimensions_and_mismatched_attachments() { + let mut d1 = transient("d1", "rgba8_unorm"); + d1["texture"]["dimension"] = serde_json::json!("d1"); + assert_eq!( + compile_json(graph( + serde_json::json!([d1]), + serde_json::json!([]), + serde_json::json!([]) + )) + .unwrap_err() + .code, + "GRAPH_ILLEGAL_ACCESS" + ); + + let mut depth_d3 = transient("depth", "depth32_float"); + depth_d3["texture"]["dimension"] = serde_json::json!("d3"); + assert_eq!( + compile_json(graph( + serde_json::json!([depth_d3]), + serde_json::json!([]), + serde_json::json!([]) + )) + .unwrap_err() + .code, + "GRAPH_ILLEGAL_ACCESS" + ); + + let first = transient("first", "rgba8_unorm"); + let mut second = transient("second", "rgba8_unorm"); + second["texture"]["extent"]["width"] = serde_json::json!(32); + let error = compile_json(graph( + serde_json::json!([first, second]), + serde_json::json!([pass( + "attachments", + "observable", + serde_json::json!([]), + serde_json::json!([ + color_write("first", "first", 0), + color_write("second", "second", 1) + ]) + )]), + serde_json::json!([]), + )) + .unwrap_err(); + assert_eq!(error.code, "GRAPH_ILLEGAL_ACCESS"); +} + +#[test] +fn cycle_tie_breaks_parallel_edges_by_original_resource_index() { + let error = compile_json(graph( + serde_json::json!([ + transient("z_declared_first", "rgba8_unorm"), + transient("a_declared_second", "rgba8_unorm"), + transient("back", "rgba8_unorm") + ]), + serde_json::json!([ + pass( + "a", + "observable", + serde_json::json!([sampled("back", "back")]), + serde_json::json!([ + copy_write("first", "z_declared_first"), + copy_write("second", "a_declared_second") + ]) + ), + pass( + "b", + "observable", + serde_json::json!([ + sampled("first", "z_declared_first"), + sampled("second", "a_declared_second") + ]), + serde_json::json!([copy_write("back", "back")]) + ) + ]), + serde_json::json!([]), + )) + .unwrap_err(); + assert_eq!(error.code, "GRAPH_CYCLE"); + assert_eq!( + error.details["edges"][0]["resource"]["id"], + "z_declared_first" + ); +} + +#[test] +fn identifier_byte_limit_precedes_reference_and_executor_resolution() { + let overlong = "a".repeat(65); + let invalid = pass( + "p", + "unknown_executor", + serde_json::json!([sampled(&overlong, &overlong)]), + serde_json::json!([]), + ); + let error = compile_json(graph( + serde_json::json!([]), + serde_json::json!([invalid]), + serde_json::json!([]), + )) + .unwrap_err(); + assert_eq!(error.code, "GRAPH_LIMIT_EXCEEDED"); +} + +#[test] +fn uninitialized_resource_precedes_transient_attachment_load_legality() { + let error = compile_json(graph( + serde_json::json!([ + transient("loaded", "rgba8_unorm"), + transient("uninitialized", "rgba8_unorm") + ]), + serde_json::json!([pass( + "conflicting_errors", + "observable", + serde_json::json!([sampled("missing_writer", "uninitialized")]), + serde_json::json!([color_load("loaded", "loaded", 0)]) + )]), + serde_json::json!([]), + )) + .unwrap_err(); + assert_eq!(error.code, "GRAPH_UNINITIALIZED_RESOURCE"); +} + +#[test] +fn malformed_resource_reference_ids_precede_resolution() { + for bindings in ["reads", "writes"] { + let mut invalid = pass( + "p", + "unknown_executor", + serde_json::json!([]), + serde_json::json!([]), + ); + invalid[bindings] = if bindings == "reads" { + serde_json::json!([sampled("input", "1bad")]) + } else { + serde_json::json!([copy_write("output", "1bad")]) + }; + let error = compile_json(graph( + serde_json::json!([]), + serde_json::json!([invalid]), + serde_json::json!([]), + )) + .unwrap_err(); + assert_eq!(error.code, "GRAPH_INVALID_ID"); + } + + let error = compile_json(graph( + serde_json::json!([]), + serde_json::json!([]), + serde_json::json!([{"name":"present", "resource":resource_ref("1bad")}]), + )) + .unwrap_err(); + assert_eq!(error.code, "GRAPH_INVALID_ID"); +} diff --git a/renderer/src/renderer/gpu_scene.rs b/renderer/src/renderer/gpu_scene.rs new file mode 100644 index 0000000..1177a13 --- /dev/null +++ b/renderer/src/renderer/gpu_scene.rs @@ -0,0 +1,394 @@ +use std::mem::size_of; + +use crate::render_data::{MeshHandle, PipelineKey, RenderData, RenderFlags}; +use bytemuck::{Pod, Zeroable}; + +#[repr(C)] +#[derive(Clone, Copy, Debug, Pod, Zeroable, PartialEq)] +pub struct GpuInstance { + pub model: [[f32; 4]; 4], + pub normal_0: [f32; 4], + pub normal_1: [f32; 4], + pub normal_2: [f32; 4], +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DrawItem { + pub pipeline: PipelineKey, + pub mesh: MeshHandle, + pub indices: std::ops::Range, + pub base_vertex: i32, + pub instances: std::ops::Range, +} + +#[derive(Default)] +pub struct GpuScenePlan { + pub positions: Vec<[f32; 3]>, + pub normals: Vec<[f32; 3]>, + pub uvs: Vec<[f32; 2]>, + pub indices: Vec, + pub instances: Vec, + pub draws: Vec, +} + +impl GpuScenePlan { + pub fn build(data: &RenderData) -> Result { + let mut plan = Self::default(); + let mut meshes: Vec<_> = data + .meshes() + .filter(|(_, mesh)| mesh.flags.contains(RenderFlags::VISIBLE)) + .collect(); + meshes.sort_by_key(|(handle, mesh)| { + (mesh.pipeline.get(), handle.slot(), handle.generation()) + }); + let streams = data.streams(); + for (handle, mesh) in meshes { + let mut occurrences: Vec<_> = data + .instances() + .filter(|(_, instance)| { + instance.mesh == handle && instance.flags.contains(RenderFlags::VISIBLE) + }) + .collect(); + occurrences.sort_by_key(|(handle, _)| (handle.slot(), handle.generation())); + if occurrences.is_empty() { + continue; + } + let vertex_start = plan.positions.len(); + let source_start = mesh.geometry.vertex_start as usize; + let source_end = source_start + .checked_add(mesh.geometry.vertex_count as usize) + .ok_or("vertex range overflow")?; + plan.positions.extend_from_slice( + streams + .positions + .get(source_start..source_end) + .ok_or("invalid vertex range")?, + ); + plan.normals.extend_from_slice( + streams + .normals + .get(source_start..source_end) + .ok_or("invalid normal range")?, + ); + plan.uvs.extend_from_slice( + streams + .uvs + .get(source_start..source_end) + .ok_or("invalid uv range")?, + ); + let index_start = + u32::try_from(plan.indices.len()).map_err(|_| "index start exceeds u32")?; + let source_index = mesh.geometry.index_start as usize; + let source_index_end = source_index + .checked_add(mesh.geometry.index_count as usize) + .ok_or("index range overflow")?; + plan.indices.extend_from_slice( + data.indices() + .get(source_index..source_index_end) + .ok_or("invalid index range")?, + ); + let instance_start = + u32::try_from(plan.instances.len()).map_err(|_| "instance start exceeds u32")?; + for (_, instance) in occurrences { + plan.instances.push(GpuInstance { + model: instance.model, + normal_0: [ + instance.normal[0][0], + instance.normal[0][1], + instance.normal[0][2], + 0.0, + ], + normal_1: [ + instance.normal[1][0], + instance.normal[1][1], + instance.normal[1][2], + 0.0, + ], + normal_2: [ + instance.normal[2][0], + instance.normal[2][1], + instance.normal[2][2], + 0.0, + ], + }); + } + plan.draws.push(DrawItem { + pipeline: mesh.pipeline, + mesh: handle, + indices: index_start + ..index_start + .checked_add(mesh.geometry.index_count) + .ok_or("draw index range overflow")?, + base_vertex: i32::try_from(vertex_start).map_err(|_| "base vertex exceeds i32")?, + instances: instance_start + ..u32::try_from(plan.instances.len()) + .map_err(|_| "instance end exceeds u32")?, + }); + } + Ok(plan) + } +} + +pub fn required_buffer_capacity( + current: u64, + required: u64, + maximum: u64, +) -> Result { + if required > maximum { + return Err("buffer exceeds device max_buffer_size"); + } + if required == 0 || current >= required { + return Ok(current); + } + let grown = current + .checked_mul(2) + .ok_or("buffer capacity overflow")? + .max(1) + .max(required); + Ok(grown.min(maximum)) +} + +pub fn vertex_layouts() -> [wgpu::VertexBufferLayout<'static>; 4] { + const INSTANCE_ATTRIBUTES: [wgpu::VertexAttribute; 7] = wgpu::vertex_attr_array![3 => Float32x4, 4 => Float32x4, 5 => Float32x4, 6 => Float32x4, 7 => Float32x4, 8 => Float32x4, 9 => Float32x4]; + [ + wgpu::VertexBufferLayout { + array_stride: 12, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &wgpu::vertex_attr_array![0 => Float32x3], + }, + wgpu::VertexBufferLayout { + array_stride: 12, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &wgpu::vertex_attr_array![1 => Float32x3], + }, + wgpu::VertexBufferLayout { + array_stride: 8, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &wgpu::vertex_attr_array![2 => Float32x2], + }, + wgpu::VertexBufferLayout { + array_stride: size_of::() as u64, + step_mode: wgpu::VertexStepMode::Instance, + attributes: &INSTANCE_ATTRIBUTES, + }, + ] +} + +#[derive(Default)] +pub struct BufferSlot { + pub buffer: Option, + capacity: u64, +} + +#[derive(Default)] +pub struct GpuSceneCache { + revision: Option, + pub positions: BufferSlot, + pub normals: BufferSlot, + pub uvs: BufferSlot, + pub indices: BufferSlot, + pub instances: BufferSlot, + pub draws: Vec, +} + +impl GpuSceneCache { + pub fn upload( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + data: &RenderData, + ) -> Result<(), String> { + if self.revision == Some(data.revision()) { + return Ok(()); + } + let plan = GpuScenePlan::build(data).map_err(str::to_owned)?; + if plan.draws.is_empty() { + self.draws.clear(); + self.revision = Some(data.revision()); + return Ok(()); + } + let maximum = device.limits().max_buffer_size; + fn bytes(values: &[T]) -> Result { + u64::try_from(values.len()) + .map_err(|_| "buffer length overflow".to_owned())? + .checked_mul(size_of::() as u64) + .ok_or_else(|| "buffer byte size overflow".to_owned()) + } + let required = [ + bytes(&plan.positions)?, + bytes(&plan.normals)?, + bytes(&plan.uvs)?, + bytes(&plan.indices)?, + bytes(&plan.instances)?, + ]; + let old = [ + self.positions.capacity, + self.normals.capacity, + self.uvs.capacity, + self.indices.capacity, + self.instances.capacity, + ]; + let mut capacities = [0; 5]; + for i in 0..5 { + capacities[i] = + required_buffer_capacity(old[i], required[i], maximum).map_err(str::to_owned)?; + } + let usages = [ + wgpu::BufferUsages::VERTEX, + wgpu::BufferUsages::VERTEX, + wgpu::BufferUsages::VERTEX, + wgpu::BufferUsages::INDEX, + wgpu::BufferUsages::VERTEX, + ]; + let labels = [ + "scene positions", + "scene normals", + "scene uvs", + "scene indices", + "scene instances", + ]; + let mut replacements: [Option; 5] = Default::default(); + for i in 0..5 { + if capacities[i] != old[i] { + replacements[i] = Some(device.create_buffer(&wgpu::BufferDescriptor { + label: Some(labels[i]), + size: capacities[i], + usage: usages[i] | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + })); + } + } + let slots = [ + &mut self.positions, + &mut self.normals, + &mut self.uvs, + &mut self.indices, + &mut self.instances, + ]; + for (i, slot) in slots.into_iter().enumerate() { + if let Some(buffer) = replacements[i].take() { + slot.buffer = Some(buffer); + slot.capacity = capacities[i]; + } + } + let contents = [ + bytemuck::cast_slice(&plan.positions), + bytemuck::cast_slice(&plan.normals), + bytemuck::cast_slice(&plan.uvs), + bytemuck::cast_slice(&plan.indices), + bytemuck::cast_slice(&plan.instances), + ]; + let slots = [ + &self.positions, + &self.normals, + &self.uvs, + &self.indices, + &self.instances, + ]; + for (slot, contents) in slots.into_iter().zip(contents) { + if !contents.is_empty() { + queue.write_buffer( + slot.buffer.as_ref().expect("nonempty slot allocated"), + 0, + contents, + ); + } + } + self.draws = plan.draws; + self.revision = Some(data.revision()); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::render_data::{MeshCreateInfo, RenderDataConfig, IDENTITY_MODEL_TRANSFORM}; + #[test] + fn instance_is_112_bytes_and_padding_is_zero() { + assert_eq!(size_of::(), 112); + let value = GpuInstance { + model: [[1.0; 4]; 4], + normal_0: [1., 2., 3., 0.], + normal_1: [4., 5., 6., 0.], + normal_2: [7., 8., 9., 0.], + }; + assert_eq!(value.normal_2[3], 0.0); + } + #[test] + fn capacity_grows_and_checks_limit() { + assert_eq!(required_buffer_capacity(8, 9, 32), Ok(16)); + assert!(required_buffer_capacity(0, 33, 32).is_err()); + } + #[test] + fn capacity_reuses_and_layout_matches_shader_contract() { + assert_eq!(required_buffer_capacity(16, 12, 32), Ok(16)); + assert_eq!(required_buffer_capacity(0, 1, 32), Ok(1)); + let layouts = vertex_layouts(); + assert_eq!( + layouts + .iter() + .map(|layout| layout.array_stride) + .collect::>(), + [12, 12, 8, 112] + ); + assert_eq!( + layouts[3] + .attributes + .iter() + .map(|attribute| attribute.shader_location) + .collect::>(), + vec![3, 4, 5, 6, 7, 8, 9] + ); + } + #[test] + fn plan_orders_pipelines_skips_hidden_and_uses_local_indices() { + let mut data = RenderData::new(RenderDataConfig { + initial_vertices: 0, + initial_indices: 0, + initial_meshes: 0, + initial_instances: 0, + ..Default::default() + }) + .unwrap(); + let p = [[0., 0., 0.], [1., 0., 0.], [0., 1., 0.]]; + let n = [[0., 0., 1.]; 3]; + let u = [[0., 0.]; 3]; + let i = [0, 1, 2]; + let mut add = |pipeline, visible| { + data.create_mesh(MeshCreateInfo { + positions: &p, + normals: &n, + uvs: &u, + indices: &i, + pipeline: PipelineKey::new(pipeline), + flags: RenderFlags::VISIBLE, + default_instance_flags: if visible { + RenderFlags::VISIBLE + } else { + RenderFlags::NONE + }, + default_transform: IDENTITY_MODEL_TRANSFORM, + }) + .unwrap() + }; + let high = add(9, true); + let _hidden = add(0, false); + let low = add(2, true); + data.create_instance(low.mesh, IDENTITY_MODEL_TRANSFORM, RenderFlags::VISIBLE) + .unwrap(); + let plan = GpuScenePlan::build(&data).unwrap(); + assert_eq!( + plan.draws + .iter() + .map(|d| d.pipeline.get()) + .collect::>(), + vec![2, 9] + ); + assert_eq!(plan.draws[0].base_vertex, 0); + assert_eq!(plan.draws[1].base_vertex, 3); + assert_eq!(plan.draws[0].instances, 0..2); + assert_eq!(plan.indices, [0, 1, 2, 0, 1, 2]); + assert_eq!(high.mesh, plan.draws[1].mesh); + } +} diff --git a/renderer/src/renderer/mod.rs b/renderer/src/renderer/mod.rs index a1dbbce..5abd6ba 100644 --- a/renderer/src/renderer/mod.rs +++ b/renderer/src/renderer/mod.rs @@ -1,111 +1,122 @@ -use std::{ - cell::RefCell, - collections::HashMap, - marker::PhantomData, - rc::Rc, - sync::mpsc::Receiver, -}; +use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::mpsc::Receiver}; use futures::channel::oneshot; use log::info; use ultraviolet::Vec4; -use wasm_bindgen::{prelude::Closure, JsCast}; -use wasm_bindgen_futures::{spawn_local, JsFuture}; -use web_sys::{DedicatedWorkerGlobalScope, File, MessageEvent}; +use wasm_bindgen::{prelude::Closure, JsCast, JsValue}; +use wasm_bindgen_futures::spawn_local; +use web_sys::DedicatedWorkerGlobalScope; use crate::{ - gltf::{load_gltf_model, ImportError, ModelBounds}, + command_ring::CommandRing, + gltf::{decode_gltf, install_imported, ModelBounds}, message::{DrainEventError, MouseMessage, ResizeMessage, WindowEvent}, + render_data::{ + InstanceHandle, MeshHandle, PipelineKey, RenderData, RenderDataConfig, RenderFlags, + }, renderer::scene::Scene, }; +pub mod gpu_scene; pub mod scene; -// Re-export commonly used types -pub use scene::Mesh; - const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float; +struct ActiveCompiled { + id: crate::render_graph::CompiledGraphId, + graph: crate::render_graph::CompiledGraph, + _textures: Vec, + views: Vec, + class_bases: Vec, +} + +struct PooledTransient { + texture: wgpu::Texture, + view: wgpu::TextureView, +} + +enum SwitchTarget { + Immediate, + Compiled(ActiveCompiled), +} + +struct PendingSwitch { + request: u32, + target: SwitchTarget, +} + +struct CommandError { + code: &'static str, + details: JsValue, +} + +impl From<&'static str> for CommandError { + fn from(code: &'static str) -> Self { + Self { + code, + details: JsValue::UNDEFINED, + } + } +} + +impl From for CommandError { + fn from(error: crate::render_graph::GraphError) -> Self { + // GraphError details are JSON by construction. Parsing avoids panicking if that + // invariant is ever accidentally broken at a command boundary. + let details = js_sys::JSON::parse(&error.details.to_string()).unwrap_or(JsValue::NULL); + Self { + code: error.code, + details, + } + } +} + +fn render_data_error_code(error: &crate::render_data::RenderDataError) -> &'static str { + use crate::render_data::RenderDataError::*; + match error { + InvalidMeshHandle | InvalidInstanceHandle | CannotDestroyDefaultInstance => "STALE_HANDLE", + InvalidTransform => "INVALID_TRANSFORM", + EmptyVertices + | MismatchedVertexStreams + | EmptyIndices + | IndexOutOfBounds + | NonFiniteGeometry + | InputTooLarge => "INVALID_GEOMETRY", + InvalidCapacityConfig { .. } + | CapacityOverflow { .. } + | CapacityExceeded { .. } + | AllocationFailed { .. } + | EmptyRange + | RangeOverflow + | RangeOutOfBounds + | RangeOverlap => "RESOURCE_LIMIT", + RevisionOverflow => "REVISION_OVERFLOW", + StaleReplacementStage => "STALE_REPLACEMENT", + } +} + pub struct GpuResources { // Core resources - buffers: Vec, pipelines: Vec, - textures: Vec, // Layout management pipeline_layouts: Vec, bind_group_layouts: Vec, // Simple name-based pipeline lookup - pipeline_registry: HashMap, - - // Shader modules cache - shader_modules: HashMap, + pipeline_registry: HashMap, } impl GpuResources { pub fn new() -> Self { Self { - buffers: Vec::new(), pipelines: Vec::new(), - textures: Vec::new(), pipeline_layouts: Vec::new(), bind_group_layouts: Vec::new(), pipeline_registry: HashMap::new(), - shader_modules: HashMap::new(), } } - pub fn add_position_buffer(&mut self, buffer: wgpu::Buffer) -> BufferIndex { - let index = self.buffers.len() as u32; - self.buffers.push(buffer); - BufferIndex { - index, - _buffer_type: PhantomData, - } - } - - pub fn add_normal_buffer(&mut self, buffer: wgpu::Buffer) -> BufferIndex { - let index = self.buffers.len() as u32; - self.buffers.push(buffer); - BufferIndex { - index, - _buffer_type: PhantomData, - } - } - - pub fn add_uv_buffer(&mut self, buffer: wgpu::Buffer) -> BufferIndex { - let index = self.buffers.len() as u32; - self.buffers.push(buffer); - BufferIndex { - index, - _buffer_type: PhantomData, - } - } - - pub fn add_index_buffer(&mut self, buffer: wgpu::Buffer) -> BufferIndex { - let index = self.buffers.len() as u32; - self.buffers.push(buffer); - BufferIndex { - index, - _buffer_type: PhantomData, - } - } - - pub fn add_model_matrix_buffer(&mut self, buffer: wgpu::Buffer) -> BufferIndex { - let index = self.buffers.len() as u32; - self.buffers.push(buffer); - BufferIndex { - index, - _buffer_type: PhantomData, - } - } - - #[inline(always)] - pub fn get_buffer(&self, id: &BufferIndex) -> &wgpu::Buffer { - &self.buffers[id.index as usize] - } - pub fn create_pipeline( &mut self, device: &wgpu::Device, @@ -113,7 +124,7 @@ impl GpuResources { vertex_layout: &[wgpu::VertexBufferLayout], shader_source: &str, surface_format: wgpu::TextureFormat, - ) -> Result { + ) -> Result { if self.pipeline_registry.contains_key(name) { return Err(format!("Pipeline '{}' already exists", name)); } @@ -144,7 +155,11 @@ impl GpuResources { topology: wgpu::PrimitiveTopology::TriangleList, strip_index_format: None, front_face: wgpu::FrontFace::Ccw, - cull_mode: Some(wgpu::Face::Back), + cull_mode: if name == "gltf_standard_double_sided" { + None + } else { + Some(wgpu::Face::Back) + }, unclipped_depth: false, polygon_mode: wgpu::PolygonMode::Fill, conservative: false, @@ -177,12 +192,12 @@ impl GpuResources { let index = self.pipelines.len(); self.pipelines.push(pipeline); - self.pipeline_registry.insert(name.to_string(), index); - - Ok(index) + let key = PipelineKey::new(index as u32); + self.pipeline_registry.insert(name.to_string(), key); + Ok(key) } - pub fn get_pipeline(&self, name: &str) -> Option { + pub fn find_pipeline(&self, name: &str) -> Option { self.pipeline_registry.get(name).copied() } @@ -193,8 +208,8 @@ impl GpuResources { vertex_layout: &[wgpu::VertexBufferLayout], shader_source: &str, surface_format: wgpu::TextureFormat, - ) -> usize { - if let Some(index) = self.get_pipeline(name) { + ) -> PipelineKey { + if let Some(index) = self.find_pipeline(name) { return index; } @@ -202,8 +217,8 @@ impl GpuResources { .expect(&format!("Failed to create pipeline '{}'", name)) } - pub fn get_pipeline_by_index(&self, index: usize) -> &wgpu::RenderPipeline { - &self.pipelines[index] + pub fn get_pipeline(&self, key: PipelineKey) -> &wgpu::RenderPipeline { + &self.pipelines[key.get() as usize] } pub fn set_bind_group_layouts(&mut self, layouts: &[wgpu::BindGroupLayout; 2]) { @@ -233,29 +248,6 @@ impl Default for GpuResources { } } -#[repr(transparent)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct BufferIndex { - pub index: u32, - _buffer_type: PhantomData, -} - -impl BufferIndex { - pub fn new(index: u32) -> Self { - Self { - index, - _buffer_type: PhantomData, - } - } -} - -// Kinds of buffers supported -pub struct Position; -pub struct Normal; -pub struct UV; -pub struct Index; -pub struct ModelMatrix; - pub struct RendererContext { pub device: wgpu::Device, pub queue: wgpu::Queue, @@ -271,9 +263,274 @@ pub struct Renderer { context: RendererContext, resources: GpuResources, scene: T, + render_data: RenderData, + snapshot: crate::shared_snapshot::SharedSnapshot, + snapshot_init_sent: bool, + gpu_scene: gpu_scene::GpuSceneCache, + pub(crate) command_ring: Option<&'static CommandRing>, + pending_replies: Vec, + gpu_error: std::sync::Arc, + framing_radius: f32, + graph_registry: crate::render_graph::Registry, + active_compiled: Option, + pending_switch: Option, + transient_pool: HashMap>, + halted: bool, } impl Renderer { + fn reply(&mut self, request: u32, result: Result) { + let (ok, code, value, details) = match result { + Ok(value) => (true, "OK", value, JsValue::UNDEFINED), + Err(error) => (false, error.code, JsValue::UNDEFINED, error.details), + }; + let reply = js_sys::Object::new(); + for (key, item) in [ + ("type", "reply".into()), + ("request", request.into()), + ("ok", ok.into()), + ("code", code.into()), + ("result", value), + ("details", details), + ] { + let _ = js_sys::Reflect::set(&reply, &JsValue::from_str(key), &item); + } + self.pending_replies.push(reply.into()); + } + + fn drain_commands(&mut self) -> bool { + let Some(ring) = self.command_ring else { + return true; + }; + let mut commands = Vec::new(); + if let Err(error) = ring.drain(|words| commands.push(words)) { + log::error!("command ring closed: {error:?}"); + self.command_ring = None; + self.post_fatal("RING_CORRUPT", &format!("{error:?}")); + return false; + } + for words in commands { + let (opcode, request) = (words[1], words[2]); + let words = &words[1..]; + if opcode == 7 { + let outcome = crate::take_payload(words[2]) + .ok_or_else(|| crate::render_graph::GraphError { + code: "PAYLOAD_MISSING", + message: "staged payload is missing".into(), + details: serde_json::json!({"message":"staged payload is missing"}), + }) + .and_then(|bytes| self.graph_registry.compile(&bytes)); + match outcome { + Ok((_id, summary)) => self.reply( + request, + Ok(js_sys::JSON::parse(&summary.to_string()).unwrap_or(JsValue::NULL)), + ), + Err(error) => self.reply(request, Err(error.into())), + } + continue; + } else if opcode == 8 { + let id = crate::render_graph::CompiledGraphId { + slot: words[2], + generation: words[3], + }; + let outcome = + if self.active_compiled.as_ref().is_some_and(|a| a.id == id) { + Err(crate::render_graph::GraphError::new( + "GRAPH_ACTIVE", + "compiled graph is active", + )) + } else if self.pending_switch.as_ref().is_some_and( + |p| matches!(&p.target, SwitchTarget::Compiled(a) if a.id == id), + ) { + Err(crate::render_graph::GraphError::new( + "GRAPH_SWITCH_PENDING", + "compiled graph switch is pending", + )) + } else { + self.graph_registry.drop_graph(id) + }; + match outcome { + Ok(()) => self.reply(request, Ok(JsValue::UNDEFINED)), + Err(error) => self.reply(request, Err(error.into())), + } + continue; + } else if opcode == 9 { + let outcome = if self.pending_switch.is_some() { + Err(crate::render_graph::GraphError::new( + "GRAPH_SWITCH_PENDING", + "a graph switch is pending", + )) + } else if words[2] == 0 { + if words[3] != 0 || words[4] != 0 { + Err(crate::render_graph::GraphError::new( + "STALE_GRAPH_ID", + "immediate mode requires a zero id", + )) + } else { + self.pending_switch = Some(PendingSwitch { + request, + target: SwitchTarget::Immediate, + }); + Ok(()) + } + } else if words[2] == 1 { + let id = crate::render_graph::CompiledGraphId { + slot: words[3], + generation: words[4], + }; + self.prepare_compiled(id).map(|active| { + self.pending_switch = Some(PendingSwitch { + request, + target: SwitchTarget::Compiled(active), + }) + }) + } else { + Err(crate::render_graph::GraphError::new( + "GRAPH_EXECUTION_UNSUPPORTED", + "unknown render mode", + )) + }; + if let Err(error) = outcome { + self.reply(request, Err(error.into())); + } + continue; + } + let outcome: Result = (|| match opcode { + 1 => { + if words[3] > 1 { + return Err("INVALID_FRAMING"); + } + let bytes = crate::take_payload(words[2]).ok_or("PAYLOAD_MISSING")?; + let imported = decode_gltf(&bytes).map_err(|_| "GLB_INVALID")?; + let layout = gpu_scene::vertex_layouts(); + let culled_pipeline = self.resources.get_or_create_pipeline( + &self.context.device, + "gltf_standard", + &layout, + include_str!("../gltf.wgsl"), + self.context.surface_config.format, + ); + let double_sided_pipeline = self.resources.get_or_create_pipeline( + &self.context.device, + "gltf_standard_double_sided", + &layout, + include_str!("../gltf.wgsl"), + self.context.surface_config.format, + ); + let installed = install_imported( + &mut self.render_data, + &imported, + [culled_pipeline, double_sided_pipeline], + ) + .map_err(|_| "INSTALL_FAILED")?; + if let Some(ModelBounds { min, max }) = installed.bounds { + let center = ultraviolet::Vec3::new( + (min[0] + max[0]) * 0.5, + (min[1] + max[1]) * 0.5, + (min[2] + max[2]) * 0.5, + ); + let extent = ultraviolet::Vec3::new( + max[0] - min[0], + max[1] - min[1], + max[2] - min[2], + ); + let radius = (0.5 + * (extent.x * extent.x + extent.y * extent.y + extent.z * extent.z) + .sqrt()) + .max(1.0); + self.framing_radius = radius; + log::info!( + "framing imported scene: center={center:?}, extent={extent:?}, radius={radius}" + ); + self.scene.set_camera_depth_range( + (radius * 0.001).max(0.1), + (radius * 6.0).max(1.1), + ); + if words[3] == 1 { + self.scene.set_camera_look_at( + center + ultraviolet::Vec3::new(0.0, radius * 0.05, 0.0), + center + ultraviolet::Vec3::new(radius, 0.0, 0.0), + ); + } else { + self.scene.set_camera_look_at( + center + + ultraviolet::Vec3::new( + radius * 1.8, + radius * 1.4, + radius * 1.8, + ), + center, + ); + } + } + let result = js_sys::Object::new(); + let meshes = js_sys::Array::new(); + for h in installed.meshes { + meshes.push(&js_sys::Array::of2( + &h.slot().into(), + &h.generation().into(), + )); + } + js_sys::Reflect::set(&result, &"meshes".into(), &meshes).unwrap(); + Ok(result.into()) + } + 2 => { + self.render_data + .set_mesh_flags( + MeshHandle::from_parts(words[2], words[3]), + RenderFlags::from_bits_retain(words[4]), + ) + .map_err(|e| render_data_error_code(&e))?; + Ok(JsValue::UNDEFINED) + } + 3 => { + let mesh = MeshHandle::from_parts(words[2], words[3]); + let mut m = [[0.; 4]; 4]; + for i in 0..16 { + m[i / 4][i % 4] = f32::from_bits(words[4 + i]); + } + let h = self + .render_data + .create_instance(mesh, m, RenderFlags::from_bits_retain(words[20])) + .map_err(|e| render_data_error_code(&e))?; + Ok(js_sys::Array::of2(&h.slot().into(), &h.generation().into()).into()) + } + 4 => { + self.render_data + .set_instance_flags( + InstanceHandle::from_parts(words[2], words[3]), + RenderFlags::from_bits_retain(words[4]), + ) + .map_err(|e| render_data_error_code(&e))?; + Ok(JsValue::UNDEFINED) + } + 5 => { + let h = InstanceHandle::from_parts(words[2], words[3]); + let mut m = [[0.; 4]; 4]; + for i in 0..16 { + m[i / 4][i % 4] = f32::from_bits(words[4 + i]); + } + self.render_data + .set_instance_transform(h, m) + .map_err(|e| render_data_error_code(&e))?; + Ok(JsValue::UNDEFINED) + } + 6 => { + self.render_data + .destroy_instance(InstanceHandle::from_parts(words[2], words[3])) + .map_err(|e| render_data_error_code(&e))?; + Ok(JsValue::UNDEFINED) + } + _ => Err("UNKNOWN_OPCODE"), + })(); + match outcome { + Ok(value) => self.reply(request, Ok(value)), + Err(code) => self.reply(request, Err(code.into())), + } + } + true + } + fn create_depth_texture( device: &wgpu::Device, config: &wgpu::SurfaceConfiguration, @@ -307,6 +564,112 @@ impl Renderer { self.context.depth_view = view; } + fn prepare_compiled( + &mut self, + id: crate::render_graph::CompiledGraphId, + ) -> Result { + let graph = self.graph_registry.get(id)?.clone(); + self.prepare_compiled_snapshot(id, graph) + } + + fn prepare_compiled_snapshot( + &mut self, + id: crate::render_graph::CompiledGraphId, + graph: crate::render_graph::CompiledGraph, + ) -> Result { + crate::render_graph::validate_activatable(&graph)?; + let surface = [ + self.context.surface_config.width, + self.context.surface_config.height, + ]; + let classes: Vec<_> = graph + .allocation_classes + .iter() + .map(|class| (class.key.clone(), class.slot_count)) + .collect(); + let offsets = crate::render_graph::class_offsets(&classes, surface)?; + let mut textures = Vec::new(); + let mut views = Vec::new(); + let mut class_bases = Vec::new(); + for (class, offset) in graph.allocation_classes.iter().zip(offsets) { + class_bases.push(views.len()); + let key = crate::render_graph::runtime_texture_key(&class.key, surface)?; + let required = offset.checked_add(class.slot_count).ok_or_else(|| { + crate::render_graph::GraphError::new( + "GRAPH_RESOURCE_LIMIT", + "transient slot count overflow", + ) + })? as usize; + let bucket = self.transient_pool.entry(key.clone()).or_default(); + while bucket.len() < required { + let usage = key + .usage + .iter() + .fold(wgpu::TextureUsages::empty(), |usage, item| { + usage + | match item { + crate::render_graph::TextureUsage::Sampled => { + wgpu::TextureUsages::TEXTURE_BINDING + } + crate::render_graph::TextureUsage::Storage => { + wgpu::TextureUsages::STORAGE_BINDING + } + crate::render_graph::TextureUsage::CopySrc => { + wgpu::TextureUsages::COPY_SRC + } + crate::render_graph::TextureUsage::CopyDst => { + wgpu::TextureUsages::COPY_DST + } + crate::render_graph::TextureUsage::ColorAttachment + | crate::render_graph::TextureUsage::DepthAttachment => { + wgpu::TextureUsages::RENDER_ATTACHMENT + } + } + }); + let format = match key.format { + crate::render_graph::Format::Depth32Float => wgpu::TextureFormat::Depth32Float, + _ => { + return Err(crate::render_graph::GraphError::new( + "GRAPH_EXECUTION_UNSUPPORTED", + "unsupported transient texture format", + )) + } + }; + let texture = self + .context + .device + .create_texture(&wgpu::TextureDescriptor { + label: Some("render graph transient"), + size: wgpu::Extent3d { + width: key.extent.width, + height: key.extent.height, + depth_or_array_layers: key.extent.depth_or_array_layers, + }, + mip_level_count: key.mip_level_count, + sample_count: key.sample_count, + dimension: wgpu::TextureDimension::D2, + format, + usage, + view_formats: &[], + }); + let view = texture.create_view(&Default::default()); + bucket.push(PooledTransient { texture, view }); + } + for slot in offset as usize..required { + textures.push(bucket[slot].texture.clone()); + views.push(bucket[slot].view.clone()); + } + } + Ok(ActiveCompiled { + id, + graph, + _textures: textures, + views, + class_bases, + }) + } + + #[cfg(target_arch = "wasm32")] pub async fn new(canvas: web_sys::OffscreenCanvas, events_chan: Receiver) -> Self { let id = wgpu::InstanceDescriptor { backends: wgpu::Backends::BROWSER_WEBGPU, @@ -339,13 +702,19 @@ impl Renderer { }; let (device, queue) = adapter.request_device(&descriptor).await.unwrap(); + let gpu_error = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let error_flag = gpu_error.clone(); + device.on_uncaptured_error(Box::new(move |error| { + error_flag.store(true, std::sync::atomic::Ordering::Relaxed); + log::error!("Uncaptured GPU error: {error}"); + })); let surface_caps = surface.get_capabilities(&adapter); let surface_config = wgpu::SurfaceConfiguration { usage: wgpu::TextureUsages::RENDER_ATTACHMENT, format: surface_caps.formats[0], - width: canvas.clone().width(), - height: canvas.clone().height(), + width: canvas.clone().width().max(1), + height: canvas.clone().height().max(1), present_mode: surface_caps.present_modes[0], alpha_mode: surface_caps.alpha_modes[0], view_formats: vec![], @@ -369,7 +738,9 @@ impl Renderer { depth_view, }; - let scene = T::setup(&context, &mut resources); + let mut render_data = + RenderData::new(RenderDataConfig::default()).expect("valid render data config"); + let scene = T::setup(&context, &mut resources, &mut render_data); Self { canvas, @@ -377,13 +748,79 @@ impl Renderer { context, scene, resources, + render_data, + snapshot: crate::shared_snapshot::SharedSnapshot::new(), + snapshot_init_sent: false, + gpu_scene: Default::default(), + command_ring: None, + pending_replies: Vec::new(), + gpu_error, + framing_radius: 0.0, + graph_registry: Default::default(), + active_compiled: None, + pending_switch: None, + transient_pool: HashMap::new(), + halted: false, } } - fn render(&mut self, time: f32) { - self.scene.update(&self.context, &mut self.resources); + fn render(&mut self, _time: f32) { + if self.halted { + return; + } + if self + .gpu_error + .swap(false, std::sync::atomic::Ordering::AcqRel) + { + self.halted = true; + self.post_fatal("GPU_VALIDATION_FAILED", "uncaptured WebGPU error"); + return; + } + if !self.drain_commands() { + return; + } + let global = js_sys::global().unchecked_into::(); + if !self.snapshot_init_sent { + let message = js_sys::Object::new(); + let _ = js_sys::Reflect::set(&message, &"type".into(), &"snapshot-init".into()); + let _ = js_sys::Reflect::set( + &message, + &"controlPtr".into(), + &self.snapshot.control_ptr().into(), + ); + let _ = js_sys::Reflect::set(&message, &"controlVersion".into(), &1.into()); + let _ = js_sys::Reflect::set(&message, &"schemaVersion".into(), &1.into()); + let _ = global.post_message(&message); + self.snapshot_init_sent = true; + } + match self.snapshot.publish(&self.render_data) { + Ok(Some(epoch)) => { + let message = js_sys::Object::new(); + let _ = + js_sys::Reflect::set(&message, &"type".into(), &"snapshot-published".into()); + let _ = js_sys::Reflect::set(&message, &"epoch".into(), &epoch.into()); + let _ = global.post_message(&message); + } + Ok(None) => {} + Err(error) => log::error!("picking snapshot failed closed with error {error}"), + } + self.scene.update(&self.context); + if let Err(error) = + self.gpu_scene + .upload(&self.context.device, &self.context.queue, &self.render_data) + { + log::error!("GPU scene upload failed: {error}"); + self.post_fatal("GPU_UPLOAD_FAILED", &error); + return; + } - let surface_texture = self.context.surface.get_current_texture().unwrap(); + let surface_texture = match self.context.surface.get_current_texture() { + Ok(value) => value, + Err(error) => { + self.post_fatal("SURFACE_FRAME_FAILED", &error.to_string()); + return; + } + }; let texture_view = surface_texture.texture.create_view(&Default::default()); let mut encoder = self.context @@ -392,7 +829,86 @@ impl Renderer { label: Some("Render command encoder"), }); - { + let rendering_compiled = match self.pending_switch.as_ref().map(|p| &p.target) { + Some(SwitchTarget::Compiled(active)) => Some(active), + Some(SwitchTarget::Immediate) => None, + None => self.active_compiled.as_ref(), + }; + if let Some(active) = rendering_compiled { + for pass in &active.graph.passes { + let depth_resource = pass + .writes + .iter() + .find(|w| w.binding == "depth") + .unwrap() + .resource as usize; + let allocation = active.graph.resources[depth_resource].allocation.unwrap(); + let depth_view = &active.views + [active.class_bases[allocation.class as usize] + allocation.slot as usize]; + let color = pass.writes.iter().find(|w| w.binding == "color").unwrap(); + let depth = pass.writes.iter().find(|w| w.binding == "depth").unwrap(); + let (color_load, color_store) = match &color.access { + crate::render_graph::WriteAccess::ColorAttachment { load, store, .. } => ( + match load { + crate::render_graph::ColorLoad::Clear { value } => { + wgpu::LoadOp::Clear(wgpu::Color { + r: value[0], + g: value[1], + b: value[2], + a: value[3], + }) + } + crate::render_graph::ColorLoad::Load => wgpu::LoadOp::Load, + }, + if matches!(store, crate::render_graph::StoreOp::Store) { + wgpu::StoreOp::Store + } else { + wgpu::StoreOp::Discard + }, + ), + _ => unreachable!(), + }; + let (depth_load, depth_store) = match &depth.access { + crate::render_graph::WriteAccess::DepthAttachment { load, store } => ( + match load { + crate::render_graph::DepthLoad::Clear { value } => { + wgpu::LoadOp::Clear(*value) + } + crate::render_graph::DepthLoad::Load => wgpu::LoadOp::Load, + }, + if matches!(store, crate::render_graph::StoreOp::Store) { + wgpu::StoreOp::Store + } else { + wgpu::StoreOp::Discard + }, + ), + _ => unreachable!(), + }; + let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some(&pass.id), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + depth_slice: None, + view: &texture_view, + resolve_target: None, + ops: wgpu::Operations { + load: color_load, + store: color_store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: depth_view, + depth_ops: Some(wgpu::Operations { + load: depth_load, + store: depth_store, + }), + stencil_ops: None, + }), + occlusion_query_set: None, + timestamp_writes: None, + }); + self.encode_scene_forward(&mut render_pass); + } + } else { let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("Render pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { @@ -421,48 +937,141 @@ impl Renderer { timestamp_writes: None, }); - for (i, bind_group) in self.scene.bind_groups().iter().enumerate() { - render_pass.set_bind_group(i as u32, bind_group, &[]); - } - - for mesh in self.scene.meshes() { - render_pass.set_pipeline(self.resources.get_pipeline_by_index(mesh.pipeline_index)); - - render_pass.set_vertex_buffer( - 0, - self.resources - .get_buffer(&mesh.position_buffer_index) - .slice(..), - ); - render_pass.set_vertex_buffer( - 1, - self.resources - .get_buffer(&mesh.normal_buffer_index) - .slice(..), - ); - render_pass.set_vertex_buffer( - 2, - self.resources.get_buffer(&mesh.uv_buffer_index).slice(..), - ); - render_pass.set_vertex_buffer( - 3, - self.resources - .get_buffer(&mesh.model_buffer_index) - .slice(..), - ); - - render_pass.set_index_buffer( - self.resources - .get_buffer(&mesh.index_buffer_index) - .slice(..), - mesh.index_format, - ); - - render_pass.draw_indexed(0..mesh.index_count, 0, 0..mesh.instance_count); - } + self.encode_scene_forward(&mut render_pass); } self.context.queue.submit(std::iter::once(encoder.finish())); surface_texture.present(); + if let Some(pending) = self.pending_switch.take() { + let result = match pending.target { + SwitchTarget::Immediate => { + self.active_compiled = None; + js_sys::JSON::parse(r#"{"mode":"immediate"}"#).unwrap_or(JsValue::NULL) + } + SwitchTarget::Compiled(active) => { + let summary = serde_json::json!({ + "mode":"compiled", + "compiledId":[active.id.slot, active.id.generation], + "graphId":active.graph.graph_id, + "revision":active.graph.revision + }); + self.active_compiled = Some(active); + js_sys::JSON::parse(&summary.to_string()).unwrap_or(JsValue::NULL) + } + }; + self.reply(pending.request, Ok(result)); + } + let global = js_sys::global().unchecked_into::(); + for reply in self.pending_replies.drain(..) { + let _ = global.post_message(&reply); + } + let telemetry = js_sys::Object::new(); + let index_count: u32 = self + .gpu_scene + .draws + .iter() + .map(|d| d.indices.end - d.indices.start) + .sum(); + let instance_count: u32 = self + .gpu_scene + .draws + .iter() + .map(|d| d.instances.end - d.instances.start) + .sum(); + let active = self.active_compiled.as_ref(); + let active_id = active + .map(|a| js_sys::Array::of2(&a.id.slot.into(), &a.id.generation.into()).into()) + .unwrap_or(JsValue::NULL); + for (key, value) in [ + ("type", "telemetry".into()), + ("revision", (self.render_data.revision() as f64).into()), + ("draws", (self.gpu_scene.draws.len() as u32).into()), + ("instances", instance_count.into()), + ("indices", index_count.into()), + ("width", self.context.surface_config.width.into()), + ("height", self.context.surface_config.height.into()), + ("framingRadius", self.framing_radius.into()), + ( + "renderMode", + if active.is_some() { + "compiled".into() + } else { + "immediate".into() + }, + ), + ("activeCompiledId", active_id), + ( + "activeCompiledGraph", + active + .map(|a| a.graph.graph_id.as_str()) + .unwrap_or("") + .into(), + ), + ( + "activeCompiledRevision", + active.map(|a| a.graph.revision).unwrap_or(0).into(), + ), + ( + "graphPasses", + active + .map(|a| a.graph.passes.len() as u32) + .unwrap_or(0) + .into(), + ), + ( + "transientPoolTextures", + (self.transient_pool.values().map(Vec::len).sum::() as u32).into(), + ), + ( + "gpuError", + self.gpu_error + .load(std::sync::atomic::Ordering::Relaxed) + .into(), + ), + ] { + let _ = js_sys::Reflect::set(&telemetry, &key.into(), &value); + } + let _ = global.post_message(&telemetry); + } + + fn encode_scene_forward<'a>(&'a self, render_pass: &mut wgpu::RenderPass<'a>) { + for (i, bind_group) in self.scene.bind_groups().iter().enumerate() { + render_pass.set_bind_group(i as u32, bind_group, &[]); + } + if let (Some(p), Some(n), Some(u), Some(i), Some(inst)) = ( + &self.gpu_scene.positions.buffer, + &self.gpu_scene.normals.buffer, + &self.gpu_scene.uvs.buffer, + &self.gpu_scene.indices.buffer, + &self.gpu_scene.instances.buffer, + ) { + render_pass.set_vertex_buffer(0, p.slice(..)); + render_pass.set_vertex_buffer(1, n.slice(..)); + render_pass.set_vertex_buffer(2, u.slice(..)); + render_pass.set_vertex_buffer(3, inst.slice(..)); + render_pass.set_index_buffer(i.slice(..), wgpu::IndexFormat::Uint32); + for draw in &self.gpu_scene.draws { + render_pass.set_pipeline(self.resources.get_pipeline(draw.pipeline)); + render_pass.draw_indexed( + draw.indices.clone(), + draw.base_vertex, + draw.instances.clone(), + ); + } + } + } + + fn post_fatal(&mut self, code: &str, message: &str) { + self.pending_replies.clear(); + let value = js_sys::Object::new(); + for (key, item) in [ + ("type", JsValue::from_str("fatal")), + ("code", JsValue::from_str(code)), + ("message", JsValue::from_str(message)), + ] { + let _ = js_sys::Reflect::set(&value, &key.into(), &item); + } + let global = js_sys::global().unchecked_into::(); + let _ = global.post_message(&value); } pub async fn read_pixel_from_texture(&self, x: u32, y: u32) -> Vec4 { @@ -595,26 +1204,13 @@ impl Renderer { let mut r = renderer.borrow_mut(); r.scene.handle_zoom(msg.delta_y as f32); } - WindowEvent::Keyboard(msg) => { - log::info!("Key event received: {:?}", msg); - - // Check for 'L' key press - if msg.key == "l" || msg.key == "L" { - let renderer_clone = renderer.clone(); - spawn_local(async move { - if let Err(e) = Self::show_file_picker_and_load(renderer_clone).await { - log::error!("Failed to load file: {:?}", e); - } - }); - } - } + WindowEvent::Keyboard(_) => {} } } fn drain_events(renderer: &Rc>) -> Result<(), DrainEventError> { loop { - let event = renderer.try_borrow_mut()? - .events_chan.try_recv()?; + let event = renderer.try_borrow_mut()?.events_chan.try_recv()?; let renderer_clone = renderer.clone(); spawn_local(async move { @@ -660,15 +1256,32 @@ impl Renderer { } fn resize(&mut self, msg: ResizeMessage) { - let new_width = (msg.width * msg.scale_factor) as u32; - let new_height = (msg.height * msg.scale_factor) as u32; - if new_width != self.canvas.width() || new_height != self.canvas.height() { + let new_width = ((msg.width * msg.scale_factor) as u32).max(1); + let new_height = ((msg.height * msg.scale_factor) as u32).max(1); + if new_width != self.context.surface_config.width + || new_height != self.context.surface_config.height + { self.context.surface_config.width = new_width; self.context.surface_config.height = new_height; self.context .surface .configure(&self.context.device, &self.context.surface_config); self.recreate_depth_texture(); + // The executable subset uses surface-relative transients exclusively. + // Dropping old buckets prevents stale-size reuse and bounds resize growth. + self.transient_pool.clear(); + if let Some(old) = self.active_compiled.take() { + let id = old.id; + let graph = old.graph; + // Keep immediate resources live and fall back for this frame if recreation fails. + match self.prepare_compiled_snapshot(id, graph) { + Ok(active) => self.active_compiled = Some(active), + Err(error) => log::error!( + "compiled graph resize preparation failed: {}", + error.message + ), + } + } self.scene.resize( new_width as f64, @@ -691,81 +1304,25 @@ impl Renderer { self.scene.handle_orbit(delta_x, delta_y); } } - - // currently this replaces everything, will need more sophisticated mechanisms later - pub async fn load_assets_async(renderer: Rc>>) -> Result<(), ImportError> { - let (device, surface_format) = { - let r = renderer.borrow(); - (r.context.device.clone(), r.context.surface_config.format) - }; - - let mut meshes = Vec::new(); - - let mut original_resources = { - let mut r = renderer.borrow_mut(); - r.scene.clear(); - std::mem::take(&mut r.resources) - }; - - let bounds = load_gltf_model( - &device, - &mut original_resources, - &mut meshes, - surface_format, - ) - .await?; - - { - let mut r = renderer.borrow_mut(); - r.resources = original_resources; - - for mesh in meshes { - r.scene.add_mesh(mesh); - } - - if let Some(ModelBounds { min, max }) = bounds { - let center = ultraviolet::Vec3::new( - (min[0] + max[0]) * 0.5, - (min[1] + max[1]) * 0.5, - (min[2] + max[2]) * 0.5, - ); - - let extent = - ultraviolet::Vec3::new(max[0] - min[0], max[1] - min[1], max[2] - min[2]); - let radius = - 0.5 * (extent.x * extent.x + extent.y * extent.y + extent.z * extent.z).sqrt(); - let radius = radius.max(1.0); - - // set the camera position after load, so we are not disoriented - let eye_offset = ultraviolet::Vec3::new(0.0, radius * 0.05, radius * 0.25); - - // Keep the near plane proportional to the model size to avoid - // extreme depth ranges when loading very large assets - let near_plane = (radius * 0.001).max(0.1); - - // The far plane must be far enough to cover the entire model. - // Using a fixed upper clamp caused large models to be clipped - // completely; relying on the model radius instead. - let far_plane = (radius * 4.0).max(near_plane + 1.0); - r.scene.set_camera_depth_range(near_plane, far_plane); - r.scene.set_camera_look_at(center + eye_offset, center); - } - } - - Ok(()) - } - - async fn show_file_picker_and_load( - renderer: Rc>>, - ) -> Result<(), ImportError> { - // For now, we'll just call load_assets_async which loads the default model - // In a full implementation, we'd modify load_gltf_model to accept the file data - Self::load_assets_async(renderer).await - } } -impl From> for u32 { - fn from(value: BufferIndex) -> Self { - value.index +#[cfg(test)] +mod error_code_tests { + use super::*; + use crate::render_data::RenderDataError; + #[test] + fn render_data_errors_have_exact_stable_codes() { + assert_eq!( + render_data_error_code(&RenderDataError::InvalidMeshHandle), + "STALE_HANDLE" + ); + assert_eq!( + render_data_error_code(&RenderDataError::InvalidTransform), + "INVALID_TRANSFORM" + ); + assert_eq!( + render_data_error_code(&RenderDataError::StaleReplacementStage), + "STALE_REPLACEMENT" + ); } } diff --git a/renderer/src/renderer/scene.rs b/renderer/src/renderer/scene.rs index 86cc56b..350e92d 100644 --- a/renderer/src/renderer/scene.rs +++ b/renderer/src/renderer/scene.rs @@ -1,9 +1,9 @@ -use ultraviolet::Mat4; use wgpu::util::DeviceExt; use crate::{ camera::Camera, - renderer::{self, BufferIndex, GpuResources, Index, ModelMatrix, Normal, Position, UV}, + render_data::RenderData, + renderer::{self, GpuResources}, }; pub struct UniformResource { @@ -12,7 +12,6 @@ pub struct UniformResource { pub bind_group_layout: wgpu::BindGroupLayout, } -/// Simple uniform data. #[repr(C)] #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable, Debug, Default)] pub struct FrameMetadata { @@ -23,36 +22,30 @@ pub struct FrameMetadata { _padding0: f32, pub camera_position: [f32; 4], } - impl FrameMetadata { pub fn new(dimension: ultraviolet::Vec2) -> Self { - FrameMetadata { + Self { resolution: dimension.into(), - mouse_move: [std::f32::MIN, std::f32::MIN], - mouse_click: [std::f32::MIN, std::f32::MIN], - _padding0: 0.0, - camera_position: [0.0, 0.0, 0.0, 1.0], + mouse_move: [f32::MIN; 2], + mouse_click: [f32::MIN; 2], + camera_position: [0., 0., 0., 1.], ..Default::default() } } - - pub fn set_camera_position(&mut self, position: ultraviolet::Vec3) { - self.camera_position = [position.x, position.y, position.z, 1.0]; + pub fn set_camera_position(&mut self, p: ultraviolet::Vec3) { + self.camera_position = [p.x, p.y, p.z, 1.]; } - - pub fn update_dimension(&mut self, dimension: ultraviolet::Vec2) { - self.resolution = dimension.into(); + pub fn update_dimension(&mut self, d: ultraviolet::Vec2) { + self.resolution = d.into(); } - pub fn create_uniform_resource(self, device: &wgpu::Device) -> UniformResource { let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("frame metadata uniform buffer"), - contents: bytemuck::cast_slice(&[self][..]), + label: Some("frame metadata"), + contents: bytemuck::bytes_of(&self), usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, }); - let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("Uniform bind group layout"), + label: Some("frame layout"), entries: &[wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, @@ -64,325 +57,71 @@ impl FrameMetadata { count: None, }], }); - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Uniform bind group"), + label: Some("frame group"), layout: &bind_group_layout, entries: &[wgpu::BindGroupEntry { binding: 0, resource: buffer.as_entire_binding(), }], }); - UniformResource { buffer, - bind_group_layout, bind_group, - } - } -} - -pub struct Mesh { - pub pipeline_index: usize, - pub position_buffer_index: BufferIndex, - pub normal_buffer_index: BufferIndex, - pub uv_buffer_index: BufferIndex, - pub model_buffer_index: BufferIndex, - pub index_buffer_index: BufferIndex, - pub index_format: wgpu::IndexFormat, - pub index_count: u32, - pub instance_count: u32, -} - -type VertexBufferSet = (BufferIndex, BufferIndex, BufferIndex); -type IndexBufferInfo = (BufferIndex, u32, wgpu::IndexFormat); - -pub fn mesh_vertex_layout() -> [wgpu::VertexBufferLayout<'static>; 4] { - [ - wgpu::VertexBufferLayout { - array_stride: 12, - step_mode: wgpu::VertexStepMode::Vertex, - attributes: &[wgpu::VertexAttribute { - offset: 0, - shader_location: 0, - format: wgpu::VertexFormat::Float32x3, - }], - }, - wgpu::VertexBufferLayout { - array_stride: 12, - step_mode: wgpu::VertexStepMode::Vertex, - attributes: &[wgpu::VertexAttribute { - offset: 0, - shader_location: 1, - format: wgpu::VertexFormat::Float32x3, - }], - }, - wgpu::VertexBufferLayout { - array_stride: 8, - step_mode: wgpu::VertexStepMode::Vertex, - attributes: &[wgpu::VertexAttribute { - offset: 0, - shader_location: 2, - format: wgpu::VertexFormat::Float32x2, - }], - }, - wgpu::VertexBufferLayout { - array_stride: 64, - step_mode: wgpu::VertexStepMode::Instance, - attributes: &[ - wgpu::VertexAttribute { - offset: 0, - shader_location: 3, - format: wgpu::VertexFormat::Float32x4, - }, - wgpu::VertexAttribute { - offset: 16, - shader_location: 4, - format: wgpu::VertexFormat::Float32x4, - }, - wgpu::VertexAttribute { - offset: 32, - shader_location: 5, - format: wgpu::VertexFormat::Float32x4, - }, - wgpu::VertexAttribute { - offset: 48, - shader_location: 6, - format: wgpu::VertexFormat::Float32x4, - }, - ], - }, - ] -} - -pub struct MeshBuilder { - indices: I, - vertices: V, - pipeline: P, - model_matrix: M, - instance_count: u32, -} - -impl Default for MeshBuilder<(), (), (), ()> { - fn default() -> Self { - Self { - indices: (), - vertices: (), - pipeline: (), - model_matrix: (), - instance_count: 1, - } - } -} - -impl MeshBuilder<(), (), P, M> { - pub fn with_vertices( - self, - device: &wgpu::Device, - resources: &mut GpuResources, - positions: &[[f32; 3]], - normals: &[[f32; 3]], - uvs: &[[f32; 2]], - ) -> MeshBuilder<(), VertexBufferSet, P, M> { - let position_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("Mesh Positions"), - contents: bytemuck::cast_slice(positions), - usage: wgpu::BufferUsages::VERTEX, - }); - let normal_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("Mesh Normals"), - contents: bytemuck::cast_slice(normals), - usage: wgpu::BufferUsages::VERTEX, - }); - let uv_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("Mesh UVs"), - contents: bytemuck::cast_slice(uvs), - usage: wgpu::BufferUsages::VERTEX, - }); - - let position_buffer_index = resources.add_position_buffer(position_buffer); - let normal_buffer_index = resources.add_normal_buffer(normal_buffer); - let uv_buffer_index = resources.add_uv_buffer(uv_buffer); - - MeshBuilder { - vertices: (position_buffer_index, normal_buffer_index, uv_buffer_index), - indices: self.indices, - pipeline: self.pipeline, - model_matrix: self.model_matrix, - instance_count: self.instance_count, - } - } -} - -impl MeshBuilder<(), V, P, M> { - pub fn with_indices( - self, - device: &wgpu::Device, - resources: &mut GpuResources, - indices: &[u32], - ) -> MeshBuilder { - let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("Mesh Indices"), - contents: bytemuck::cast_slice(indices), - usage: wgpu::BufferUsages::INDEX, - }); - - let index_buffer_index = resources.add_index_buffer(index_buffer); - - MeshBuilder { - indices: ( - index_buffer_index, - indices.len() as u32, - wgpu::IndexFormat::Uint32, - ), - vertices: self.vertices, - pipeline: self.pipeline, - model_matrix: self.model_matrix, - instance_count: self.instance_count, - } - } -} - -impl MeshBuilder { - pub fn with_pipeline(self, pipeline_index: usize) -> MeshBuilder { - MeshBuilder { - pipeline: pipeline_index, - indices: self.indices, - vertices: self.vertices, - model_matrix: self.model_matrix, - instance_count: self.instance_count, - } - } -} - -impl MeshBuilder { - pub fn with_model_matrix( - self, - device: &wgpu::Device, - resources: &mut GpuResources, - matrix_columns: Mat4, - ) -> MeshBuilder> { - let model_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("Mesh Model Matrix"), - contents: bytemuck::cast_slice(matrix_columns.as_slice()), - usage: wgpu::BufferUsages::VERTEX, - }); - - let model_buffer_index = resources.add_model_matrix_buffer(model_buffer); - - MeshBuilder { - indices: self.indices, - vertices: self.vertices, - pipeline: self.pipeline, - model_matrix: model_buffer_index, - instance_count: self.instance_count, - } - } -} - -impl MeshBuilder> { - pub fn build(self) -> Mesh { - Mesh { - pipeline_index: self.pipeline, - position_buffer_index: (self.vertices).0, - normal_buffer_index: (self.vertices).1, - uv_buffer_index: (self.vertices).2, - model_buffer_index: self.model_matrix, - index_buffer_index: (self.indices).0, - index_count: (self.indices).1, - index_format: (self.indices).2, - instance_count: self.instance_count, + bind_group_layout, } } } pub trait Scene: Sized { - fn setup(renderer_context: &renderer::RendererContext, resources: &mut GpuResources) -> Self; + fn setup( + context: &renderer::RendererContext, + resources: &mut GpuResources, + data: &mut RenderData, + ) -> Self; fn bind_groups(&self) -> &[wgpu::BindGroup]; - fn meshes(&self) -> &[Mesh]; fn handle_mouse_click(&mut self, x: f32, y: f32); fn handle_zoom(&mut self, delta_y: f32); - fn handle_orbit(&mut self, delta_x: f32, delta_y: f32); - fn clear(&mut self); - fn add_mesh(&mut self, mesh: Mesh); + fn handle_orbit(&mut self, dx: f32, dy: f32); fn set_camera_depth_range(&mut self, near: f32, far: f32); fn set_camera_look_at(&mut self, eye: ultraviolet::Vec3, center: ultraviolet::Vec3); - fn frame_metadata_mut(&mut self) -> Option<&mut FrameMetadata> { None } - fn camera_mut(&mut self) -> Option<&mut Camera> { None } - - fn uniform_buffers(&self) -> Option<&[wgpu::Buffer]> { + fn uniform_buffers(&self) -> Option<[&wgpu::Buffer; 2]> { None } - - fn resize(&mut self, width: f64, height: f64, _scale_factor: f64, queue: &wgpu::Queue) { - let fm_copy = if let Some(fm) = self.frame_metadata_mut() { - let dimension = ultraviolet::Vec2::new(width as f32, height as f32); - fm.update_dimension(dimension); - *fm - } else { - return; - }; - - let view_proj_copy = if let Some(cam) = self.camera_mut() { - cam.update_aspect_ratio(width as f32 / height as f32); - cam.view_proj - } else { - return; - }; - - if let Some(buffers) = self.uniform_buffers() { - if buffers.len() >= 2 { - queue.write_buffer(&buffers[0], 0, bytemuck::cast_slice(&[fm_copy])); - queue.write_buffer(&buffers[1], 0, bytemuck::cast_slice(&[view_proj_copy])); - } + fn resize(&mut self, width: f64, height: f64, _: f64, queue: &wgpu::Queue) { + if let Some(f) = self.frame_metadata_mut() { + f.update_dimension(ultraviolet::Vec2::new(width as f32, height as f32)); } + if let Some(c) = self.camera_mut() { + c.update_aspect_ratio(width as f32 / height as f32) + } + self.write_uniforms(queue); } - - fn update( - &mut self, - renderer_context: &renderer::RendererContext, - _resources: &mut GpuResources, - ) { - let camera_position = if let Some(cam) = self.camera_mut() { - cam.position() - } else { - return; + fn update(&mut self, context: &renderer::RendererContext) { + let position = match self.camera_mut() { + Some(c) => c.position(), + None => return, }; - - let fm_copy = if let Some(fm) = self.frame_metadata_mut() { - let time = (js_sys::Date::now() as f32) * 0.001; - fm.time = time; - fm.set_camera_position(camera_position); - *fm - } else { - return; - }; - - let view_proj_copy = if let Some(cam) = self.camera_mut() { - cam.view_proj - } else { - return; - }; - - if let Some(buffers) = self.uniform_buffers() { - if buffers.len() >= 2 { - renderer_context.queue.write_buffer( - &buffers[0], - 0, - bytemuck::cast_slice(&[fm_copy]), - ); - renderer_context.queue.write_buffer( - &buffers[1], - 0, - bytemuck::cast_slice(&[view_proj_copy]), - ); - } + if let Some(f) = self.frame_metadata_mut() { + f.time = js_sys::Date::now() as f32 * 0.001; + f.set_camera_position(position) + } + self.write_uniforms(&context.queue); + } + fn write_uniforms(&mut self, queue: &wgpu::Queue) { + let frame = self.frame_metadata_mut().copied(); + let view = self.camera_mut().map(|c| c.view_proj); + if let (Some(f), Some(v), Some([frame_buffer, camera_buffer])) = + (frame, view, self.uniform_buffers()) + { + queue.write_buffer(frame_buffer, 0, bytemuck::bytes_of(&f)); + queue.write_buffer(camera_buffer, 0, bytemuck::bytes_of(&v)); } } } diff --git a/renderer/src/shared_snapshot.rs b/renderer/src/shared_snapshot.rs new file mode 100644 index 0000000..b48848e --- /dev/null +++ b/renderer/src/shared_snapshot.rs @@ -0,0 +1,420 @@ +//! Triple-buffered, immutable packed scene snapshot shared with JavaScript. +use std::sync::atomic::{AtomicU32, Ordering}; + +use crate::render_data::{affine_world_aabb, RenderData, RenderFlags}; + +pub const MAGIC: u32 = u32::from_le_bytes(*b"YSNP"); +pub const BLOB_MAGIC: u32 = u32::from_le_bytes(*b"RDS1"); +pub const CONTROL_VERSION: u32 = 1; +pub const SCHEMA: u32 = 1; +pub const SLOT_COUNT: usize = 3; +pub const INIT: u32 = 0; +pub const OPEN: u32 = 1; +pub const FAILED: u32 = 2; +pub const CLOSED: u32 = 3; +pub const FREE: u32 = 0; +pub const WRITING: u32 = 1; +pub const READY: u32 = 2; +pub const READING: u32 = 3; +pub const ERROR_NO_SLOT: u32 = 1; +pub const ERROR_OVERFLOW: u32 = 2; +pub const ERROR_INVARIANT: u32 = 3; +pub const ERROR_PUBLICATION: u32 = 4; +const CONTROL_BYTES: u32 = 256; +const SLOT_BYTES: u32 = 64; +const SNAPSHOT_HEADER_BYTES: usize = 64; +const DATA_OFFSET: usize = 512; +const DESCRIPTOR_BYTES: usize = 32; +const STREAMS: usize = 14; +const SCHEMA_FLAGS: u32 = 3; // dense arrays | affine transforms + +#[repr(C, align(64))] +pub struct SnapshotDescriptor(pub [AtomicU32; 16]); + +#[repr(C, align(64))] +pub struct SnapshotControl { + pub header: [AtomicU32; 16], + pub slots: [SnapshotDescriptor; SLOT_COUNT], +} + +#[repr(C, align(16))] +#[derive(Clone, Copy)] +pub struct SnapshotBlock(pub [u8; 16]); + +pub struct SharedSnapshot { + pub control: Box, + blocks: [Vec; SLOT_COUNT], + last_revision: Option, + next_epoch: u32, + layout_epoch: u32, +} + +impl SharedSnapshot { + pub fn new() -> Self { + let control = Box::new(SnapshotControl { + header: std::array::from_fn(|_| AtomicU32::new(0)), + slots: std::array::from_fn(|_| { + SnapshotDescriptor(std::array::from_fn(|_| AtomicU32::new(0))) + }), + }); + let this = Self { + control, + blocks: Default::default(), + last_revision: None, + next_epoch: 1, + layout_epoch: 0, + }; + for (i, value) in [ + MAGIC, + CONTROL_VERSION, + CONTROL_BYTES, + SLOT_COUNT as u32, + SLOT_BYTES, + SCHEMA, + INIT, + ] + .into_iter() + .enumerate() + { + this.control.header[i].store(value, Ordering::Relaxed); + } + // No publication exists yet; zero is a valid slot number. + this.control.header[9].store(u32::MAX, Ordering::Relaxed); + this + } + + pub fn control_ptr(&self) -> u32 { + self.control.as_ref() as *const _ as usize as u32 + } + + /// Permanently fails snapshot publication without affecting rendering or mutations. + pub fn fail(&self, error: u32) { + self.control.header[14].store(error, Ordering::Relaxed); + self.control.header[6].store(FAILED, Ordering::Release); + } + + /// Packs and publishes if `data` changed. Returns the newly published data epoch. + pub fn publish(&mut self, data: &RenderData) -> Result, u32> { + if self.control.header[6].load(Ordering::Acquire) == FAILED { + return Err(self.control.header[14].load(Ordering::Relaxed)); + } + if self.last_revision == Some(data.revision()) { + return Ok(None); + } + let slot = match self.claim_slot() { + Some(slot) => slot, + None => { + self.fail(ERROR_NO_SLOT); + return Err(ERROR_NO_SLOT); + } + }; + let result = self.publish_claimed(slot, data); + if let Err(error) = result { + self.control.slots[slot].0[0].store(FREE, Ordering::Release); + self.fail(error); + } + result + } + + fn publish_claimed(&mut self, slot: usize, data: &RenderData) -> Result, u32> { + let epoch = self.next_epoch; + let next_epoch = epoch.checked_add(1).ok_or(ERROR_OVERFLOW)?; + let bytes = pack(data, epoch)?; + let blocks = bytes.len().checked_add(15).ok_or(ERROR_OVERFLOW)? / 16; + if self.blocks[slot].capacity() < blocks { + self.layout_epoch = self.layout_epoch.checked_add(1).ok_or(ERROR_OVERFLOW)?; + } + self.blocks[slot].resize(blocks, SnapshotBlock([0; 16])); + let allocation_bytes = blocks.checked_mul(16).ok_or(ERROR_OVERFLOW)?; + let target = unsafe { + std::slice::from_raw_parts_mut( + self.blocks[slot].as_mut_ptr().cast::(), + allocation_bytes, + ) + }; + target[..bytes.len()].copy_from_slice(&bytes); + let ptr = self.blocks[slot].as_ptr() as usize; + let ptr32 = u32::try_from(ptr).map_err(|_| ERROR_OVERFLOW)?; + let length = u32::try_from(bytes.len()).map_err(|_| ERROR_OVERFLOW)?; + let revision = data.revision(); + let d = &self.control.slots[slot].0; + let values = [ + epoch, + self.layout_epoch, + ptr32, + length, + revision as u32, + (revision >> 32) as u32, + data.mesh_count(), + data.instance_count(), + SCHEMA, + SNAPSHOT_HEADER_BYTES as u32, + 0, + 0, + 0, + 0, + 0, + ]; + for (i, value) in values.into_iter().enumerate() { + d[i + 1].store(value, Ordering::Relaxed); + } + let end = ptr.checked_add(allocation_bytes).ok_or(ERROR_OVERFLOW)?; + let pages = wasm_pages(end)?; + let seq = self.open_sequence()?; + d[0].store(READY, Ordering::Release); + for (i, value) in [ + epoch, + slot as u32, + revision as u32, + (revision >> 32) as u32, + pages, + self.layout_epoch, + ] + .into_iter() + .enumerate() + { + self.control.header[8 + i].store(value, Ordering::Relaxed); + } + self.control.header[14].store(0, Ordering::Relaxed); + self.control.header[15].store(0, Ordering::Relaxed); + self.control.header[6].store(OPEN, Ordering::Relaxed); + self.control.header[7].fetch_add(1, Ordering::Release); + debug_assert_eq!(seq & 1, 0); + self.next_epoch = next_epoch; + self.last_revision = Some(revision); + Ok(Some(epoch)) + } + + fn open_sequence(&self) -> Result { + loop { + let seq = self.control.header[7].load(Ordering::Acquire); + if seq & 1 != 0 { + std::hint::spin_loop(); + continue; + } + match self.control.header[7].compare_exchange_weak( + seq, + seq.checked_add(1).ok_or(ERROR_OVERFLOW)?, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => return Ok(seq), + Err(_) => continue, + } + } + } + + fn claim_slot(&self) -> Option { + loop { + for slot in 0..SLOT_COUNT { + if self.control.slots[slot].0[0] + .compare_exchange(FREE, WRITING, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + return Some(slot); + } + } + let oldest = (0..SLOT_COUNT) + .filter(|&slot| self.control.slots[slot].0[0].load(Ordering::Acquire) == READY) + .min_by_key(|&slot| self.control.slots[slot].0[1].load(Ordering::Relaxed)); + let slot = oldest?; + if self.control.slots[slot].0[0] + .compare_exchange(READY, WRITING, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + return Some(slot); + } + // A reader or another claimant won. Recompute rather than touching READING. + } + } +} + +fn align16(value: usize) -> Result { + Ok(value.checked_add(15).ok_or(ERROR_OVERFLOW)? & !15) +} + +#[cfg(target_arch = "wasm32")] +fn wasm_pages(_minimum_end: usize) -> Result { + u32::try_from(core::arch::wasm32::memory_size(0)).map_err(|_| ERROR_OVERFLOW) +} + +#[cfg(not(target_arch = "wasm32"))] +fn wasm_pages(minimum_end: usize) -> Result { + u32::try_from(minimum_end.checked_add(65535).ok_or(ERROR_OVERFLOW)? / 65536) + .map_err(|_| ERROR_OVERFLOW) +} + +fn pack(data: &RenderData, epoch: u32) -> Result, u32> { + let meshes: Vec<_> = data.meshes().collect(); + let instances: Vec<_> = data.instances().collect(); + let strides = [4usize, 4, 4, 12, 12, 4, 4, 4, 4, 4, 64, 12, 12, 4]; + let components = [1u32, 1, 1, 3, 3, 1, 1, 1, 1, 1, 16, 3, 3, 1]; + let scalar = [1u32, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, 1]; + let counts = [meshes.len(); 5] + .into_iter() + .chain([instances.len(); 9]) + .collect::>(); + let mut offsets = [0usize; STREAMS]; + let mut cursor = DATA_OFFSET; + for i in 0..STREAMS { + offsets[i] = cursor; + let bytes = strides[i].checked_mul(counts[i]).ok_or(ERROR_OVERFLOW)?; + cursor = align16(cursor.checked_add(bytes).ok_or(ERROR_OVERFLOW)?)?; + } + let total = u32::try_from(cursor).map_err(|_| ERROR_OVERFLOW)?; + let mesh_count = u32::try_from(meshes.len()).map_err(|_| ERROR_OVERFLOW)?; + let instance_count = u32::try_from(instances.len()).map_err(|_| ERROR_OVERFLOW)?; + let mut out = vec![0u8; cursor]; + let put32 = |out: &mut [u8], at: usize, value: u32| { + out[at..at + 4].copy_from_slice(&value.to_le_bytes()) + }; + let revision = data.revision(); + for (i, value) in [ + BLOB_MAGIC, + SCHEMA, + SNAPSHOT_HEADER_BYTES as u32, + total, + epoch, + revision as u32, + (revision >> 32) as u32, + STREAMS as u32, + SNAPSHOT_HEADER_BYTES as u32, + DESCRIPTOR_BYTES as u32, + mesh_count, + instance_count, + 0x0102_0304, + SCHEMA_FLAGS, + 0, + 0, + ] + .into_iter() + .enumerate() + { + put32(&mut out, i * 4, value); + } + for i in 0..STREAMS { + let at = SNAPSHOT_HEADER_BYTES + i * DESCRIPTOR_BYTES; + for (j, value) in [ + i as u32 + 1, + scalar[i], + offsets[i] as u32, + counts[i] as u32, + components[i], + strides[i] as u32, + 4, + 0, + ] + .into_iter() + .enumerate() + { + put32(&mut out, at + j * 4, value); + } + } + for (dense, (handle, mesh)) in meshes.iter().enumerate() { + for (i, value) in [handle.slot(), handle.generation(), mesh.flags.bits()] + .into_iter() + .enumerate() + { + put32(&mut out, offsets[i] + dense * 4, value); + } + for i in 0..3 { + put32( + &mut out, + offsets[3] + dense * 12 + i * 4, + mesh.aabb.min[i].to_bits(), + ); + put32( + &mut out, + offsets[4] + dense * 12 + i * 4, + mesh.aabb.max[i].to_bits(), + ); + } + } + for (dense, (handle, instance)) in instances.iter().enumerate() { + let mesh = data.mesh(instance.mesh).ok_or(ERROR_INVARIANT)?; + let world = affine_world_aabb(mesh.aabb, instance.model).map_err(|_| ERROR_INVARIANT)?; + for (i, value) in [ + handle.slot(), + handle.generation(), + instance.mesh.slot(), + instance.mesh.generation(), + instance.flags.bits(), + ] + .into_iter() + .enumerate() + { + put32(&mut out, offsets[5 + i] + dense * 4, value); + } + for i in 0..16 { + put32( + &mut out, + offsets[10] + dense * 64 + i * 4, + instance.model[i / 4][i % 4].to_bits(), + ); + } + for i in 0..3 { + put32( + &mut out, + offsets[11] + dense * 12 + i * 4, + world.min[i].to_bits(), + ); + put32( + &mut out, + offsets[12] + dense * 12 + i * 4, + world.max[i].to_bits(), + ); + } + put32( + &mut out, + offsets[13] + dense * 4, + (mesh.flags.contains(RenderFlags::VISIBLE) + && instance.flags.contains(RenderFlags::VISIBLE)) as u32, + ); + } + Ok(out) +} + +impl Drop for SharedSnapshot { + fn drop(&mut self) { + if self.control.header[6].load(Ordering::Acquire) != FAILED { + self.control.header[6].store(CLOSED, Ordering::Release); + } + } +} + +const _: [(); 256] = [(); std::mem::size_of::()]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_control_layout_and_initial_values() { + assert_eq!(std::mem::size_of::(), 256); + assert_eq!(std::mem::size_of::(), 64); + let snapshot = SharedSnapshot::new(); + let values: Vec<_> = snapshot + .control + .header + .iter() + .map(|v| v.load(Ordering::Relaxed)) + .collect(); + assert_eq!(&values[..7], &[MAGIC, 1, 256, 3, 64, 1, INIT]); + assert_eq!(values[9], u32::MAX); + } + + #[test] + fn claiming_never_overwrites_reading_and_prefers_free() { + let snapshot = SharedSnapshot::new(); + snapshot.control.slots[0].0[0].store(READING, Ordering::Relaxed); + assert_eq!(snapshot.claim_slot(), Some(1)); + assert_eq!( + snapshot.control.slots[0].0[0].load(Ordering::Relaxed), + READING + ); + assert_eq!( + snapshot.control.slots[1].0[0].load(Ordering::Relaxed), + WRITING + ); + } +} diff --git a/static/bvh-core.js b/static/bvh-core.js new file mode 100644 index 0000000..eebb665 --- /dev/null +++ b/static/bvh-core.js @@ -0,0 +1,25 @@ +export class DerivedBvh { + constructor() { this.count = 0; this.identity = new Uint32Array(); this.meshIdentity = new Uint32Array(); this.pickable = new Uint8Array(); this.bounds = new Float32Array(); this.nodeBounds = new Float32Array(); this.left = this.right = new Int32Array(); this.leafStart = this.leafCount = new Uint32Array(); this.leaves = new Uint32Array(); this.root = -1; this.rebuilds = 0; this.refits = 0; } + update(snapshot) { + const s = snapshot.streams, n = snapshot.instanceCount; + let changed = n !== this.count; + if (!changed) for (let i = 0; i < n; i++) if (this.identity[i * 2] !== s.instanceSlot[i] || this.identity[i * 2 + 1] !== s.instanceGeneration[i] || this.meshIdentity[i * 2] !== s.instanceMeshSlot[i] || this.meshIdentity[i * 2 + 1] !== s.instanceMeshGeneration[i]) { changed = true; break; } + this.count = n; this.identity = new Uint32Array(n * 2); this.meshIdentity = new Uint32Array(n * 2); this.pickable = new Uint8Array(n); this.bounds = new Float32Array(n * 6); + for (let i = 0; i < n; i++) { this.identity.set([s.instanceSlot[i], s.instanceGeneration[i]], i * 2); this.meshIdentity.set([s.instanceMeshSlot[i], s.instanceMeshGeneration[i]], i * 2); this.pickable[i] = !!s.instancePickable[i]; this.bounds.set(s.instanceWorldMin.subarray(i * 3, i * 3 + 3), i * 6); this.bounds.set(s.instanceWorldMax.subarray(i * 3, i * 3 + 3), i * 6 + 3); } + changed ? this.rebuild() : this.refit(); + } + rebuild() { + this.rebuilds++; const nodes = [], leaves = []; + const build = indices => { const at = nodes.length, node = {left: -1, right: -1, start: 0, count: 0, bounds: [Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity]}; nodes.push(node); for (const i of indices) for (let a = 0; a < 3; a++) { node.bounds[a] = Math.min(node.bounds[a], this.bounds[i * 6 + a]); node.bounds[a + 3] = Math.max(node.bounds[a + 3], this.bounds[i * 6 + a + 3]); } if (indices.length <= 2) { node.start = leaves.length; node.count = indices.length; leaves.push(...indices); return at; } let axis = 0, extent = node.bounds[3] - node.bounds[0]; for (let a = 1; a < 3; a++) if (node.bounds[a + 3] - node.bounds[a] > extent) { axis = a; extent = node.bounds[a + 3] - node.bounds[a]; } indices.sort((a, b) => (this.bounds[a * 6 + axis] + this.bounds[a * 6 + axis + 3]) - (this.bounds[b * 6 + axis] + this.bounds[b * 6 + axis + 3]) || a - b); const mid = indices.length >> 1; node.left = build(indices.slice(0, mid)); node.right = build(indices.slice(mid)); return at; }; + this.root = this.count ? build(Array.from({length: this.count}, (_, i) => i)) : -1; const n = nodes.length; + this.nodeBounds = new Float32Array(n * 6); this.left = new Int32Array(n); this.right = new Int32Array(n); this.leafStart = new Uint32Array(n); this.leafCount = new Uint32Array(n); this.leaves = Uint32Array.from(leaves); + nodes.forEach((x, i) => { this.nodeBounds.set(x.bounds, i * 6); this.left[i] = x.left; this.right[i] = x.right; this.leafStart[i] = x.start; this.leafCount[i] = x.count; }); + } + refit() { this.refits++; for (let n = this.left.length - 1; n >= 0; n--) { const at = n * 6; for (let a = 0; a < 3; a++) { let lo = Infinity, hi = -Infinity; if (this.leafCount[n]) for (let j = 0; j < this.leafCount[n]; j++) { const i = this.leaves[this.leafStart[n] + j]; lo = Math.min(lo, this.bounds[i * 6 + a]); hi = Math.max(hi, this.bounds[i * 6 + a + 3]); } else { lo = Math.min(this.nodeBounds[this.left[n] * 6 + a], this.nodeBounds[this.right[n] * 6 + a]); hi = Math.max(this.nodeBounds[this.left[n] * 6 + a + 3], this.nodeBounds[this.right[n] * 6 + a + 3]); } this.nodeBounds[at + a] = lo; this.nodeBounds[at + a + 3] = hi; } } } + pick(origin, direction, maxDistance = Infinity, maxHits = 1) { + if (this.root < 0) return []; let magnitude = Math.hypot(...direction); const dir = direction.map(v => v / magnitude); + const intersect = (array, at) => { let lo = 0, hi = maxDistance; for (let a = 0; a < 3; a++) { const min = array[at + a], max = array[at + a + 3]; if (dir[a] === 0) { if (origin[a] < min || origin[a] > max) return Infinity; } else { let x = (min - origin[a]) / dir[a], y = (max - origin[a]) / dir[a]; if (x > y) [x, y] = [y, x]; lo = Math.max(lo, x); hi = Math.min(hi, y); if (lo > hi) return Infinity; } } return lo; }; + const hits = [], stack = [this.root]; while (stack.length) { const n = stack.pop(); if (intersect(this.nodeBounds, n * 6) === Infinity) continue; if (this.leafCount[n]) for (let j = 0; j < this.leafCount[n]; j++) { const i = this.leaves[this.leafStart[n] + j]; if (!this.pickable[i]) continue; const distance = intersect(this.bounds, i * 6); if (distance !== Infinity) hits.push({slot: this.identity[i * 2], generation: this.identity[i * 2 + 1], distance}); } else stack.push(this.right[n], this.left[n]); } + hits.sort((a, b) => a.distance - b.distance || a.slot - b.slot || a.generation - b.generation); return hits.slice(0, maxHits); + } +} diff --git a/static/bvh-worker.js b/static/bvh-worker.js new file mode 100644 index 0000000..22a5979 --- /dev/null +++ b/static/bvh-worker.js @@ -0,0 +1,27 @@ +import {SnapshotReader} from "./render-data-snapshot.js"; +import {DerivedBvh} from "./bvh-core.js"; + +let reader, bvh = new DerivedBvh(), epoch = 0, updating = false, requestedEpoch = 0; +function ensureEpoch(expected) { + if (!reader) return false; + const latest = reader.latest(); + if (latest.epoch !== expected) return false; + if (epoch === expected) return true; + const result = reader.transaction(snapshot => { bvh.update(snapshot); epoch = snapshot.epoch; }, expected); + return result !== null && epoch === expected; +} +function coalescedUpdate(hint = 0) { + requestedEpoch = Math.max(requestedEpoch, hint >>> 0); + if (updating) return; + updating = true; + queueMicrotask(() => { try { const latest = reader?.latest(); if (latest?.epoch && latest.epoch !== epoch) ensureEpoch(latest.epoch); if (epoch) postMessage({type: "updated", epoch}); } catch (error) { postMessage({type: "fatal", code: "PICK_PROTOCOL_MISMATCH", message: String(error)}); } finally { updating = false; if (requestedEpoch > epoch) coalescedUpdate(); } }); +} +addEventListener("message", event => { + const m = event.data; + try { + if (m.type === "init") { reader = new SnapshotReader(m.memory, m.controlPtr); if (m.controlVersion !== 1 || m.schemaVersion !== 1) throw Object.assign(new Error("version"), {code: "PICK_PROTOCOL_MISMATCH"}); postMessage({type: "ready"}); } + else if (m.type === "update") coalescedUpdate(m.epoch); + else if (m.type === "pick") { if (!ensureEpoch(m.epoch)) { postMessage({type: "pick", request: m.request, stale: true, epoch}); return; } const hits = bvh.pick(m.origin, m.direction, m.maxDistance, m.maxHits); const latest = reader.latest().epoch; postMessage({type: "pick", request: m.request, stale: latest !== m.epoch || epoch !== m.epoch, epoch, hits}); } + else if (m.type === "dispose") close(); + } catch (error) { postMessage({type: "fatal", code: error.name === "SnapshotProtocolError" || error.code === "PICK_PROTOCOL_MISMATCH" ? "PICK_PROTOCOL_MISMATCH" : "PICK_WORKER_ERROR", message: String(error)}); } +}); diff --git a/static/demo-loadouts.js b/static/demo-loadouts.js new file mode 100644 index 0000000..c633b20 --- /dev/null +++ b/static/demo-loadouts.js @@ -0,0 +1,60 @@ +const JSON_CHUNK = 0x4e4f534a; +const BIN_CHUNK = 0x004e4942; +const encoder = new TextEncoder(); + +const align4 = value => (value + 3) & ~3; +const finiteMinMax = (values, width) => { + const min = Array(width).fill(Infinity), max = Array(width).fill(-Infinity); + for (let i=0;i!Number.isFinite(value))||indices.some(value=>!Number.isInteger(value)||value<0))throw new TypeError("Invalid demo geometry"); + const streams=[new Float32Array(positions),new Float32Array(normals),new Float32Array(texcoords),new Uint32Array(indices)]; + if(!streams[0].length||streams[0].length%3||streams[1].length!==streams[0].length||streams[2].length/2!==streams[0].length/3||streams[3].length%3) throw new TypeError("Invalid demo geometry"); + const offsets=[], chunks=[], views=[]; let byteLength=0; + for(const stream of streams){byteLength=align4(byteLength);offsets.push(byteLength);const bytes=new Uint8Array(stream.buffer);chunks.push({offset:byteLength,bytes});views.push({buffer:0,byteOffset:byteLength,byteLength:bytes.length});byteLength+=bytes.length;} + byteLength=align4(byteLength); + const vertexCount=streams[0].length/3, bounds=finiteMinMax(streams[0],3); + if(indices.some(value=>value>=vertexCount))throw new TypeError("Invalid demo geometry"); + const nodes=[]; for(let z=-1;z<=1;z++)for(let x=-1;x<=1;x++)nodes.push({mesh:0,translation:[x*3,0,z*3]}); + const json={asset:{version:"2.0",generator:"yawn-phase8"},scene:0,scenes:[{nodes:nodes.map((_,i)=>i)}],nodes,meshes:[{primitives:[{attributes:{POSITION:0,NORMAL:1,TEXCOORD_0:2},indices:3}]}],buffers:[{byteLength}],bufferViews:views,accessors:[ + {bufferView:0,componentType:5126,count:vertexCount,type:"VEC3",min:bounds.min,max:bounds.max}, + {bufferView:1,componentType:5126,count:vertexCount,type:"VEC3"}, + {bufferView:2,componentType:5126,count:vertexCount,type:"VEC2"}, + {bufferView:3,componentType:5125,count:streams[3].length,type:"SCALAR"}, + ]}; + let jsonBytes=encoder.encode(JSON.stringify(json)); const jsonLength=align4(jsonBytes.length), total=12+8+jsonLength+8+byteLength; + const out=new ArrayBuffer(total), view=new DataView(out), bytes=new Uint8Array(out); view.setUint32(0,0x46546c67,true);view.setUint32(4,2,true);view.setUint32(8,total,true); + view.setUint32(12,jsonLength,true);view.setUint32(16,JSON_CHUNK,true);bytes.fill(0x20,20,20+jsonLength);bytes.set(jsonBytes,20); + const binHeader=20+jsonLength;view.setUint32(binHeader,byteLength,true);view.setUint32(binHeader+4,BIN_CHUNK,true);for(const chunk of chunks)bytes.set(chunk.bytes,binHeader+8+chunk.offset); + return out; +} + +export function createCubeGeometry(){ + const positions=[],normals=[],texcoords=[],indices=[];const faces=[[[1,0,0],[1,-1,-1],[1,-1,1],[1,1,1],[1,1,-1]],[[-1,0,0],[-1,-1,1],[-1,-1,-1],[-1,1,-1],[-1,1,1]],[[0,1,0],[-1,1,1],[1,1,1],[1,1,-1],[-1,1,-1]],[[0,-1,0],[-1,-1,-1],[1,-1,-1],[1,-1,1],[-1,-1,1]],[[0,0,1],[-1,-1,1],[1,-1,1],[1,1,1],[-1,1,1]],[[0,0,-1],[1,-1,-1],[-1,-1,-1],[-1,1,-1],[1,1,-1]]]; + for(const [normal,...corners] of faces){ + const base=positions.length/3;corners.forEach((p,i)=>{positions.push(...p);normals.push(...normal);texcoords.push(...[[0,0],[1,0],[1,1],[0,1]][i]);}); + const a=corners[0],b=corners[1],c=corners[2],ab=b.map((value,i)=>value-a[i]),ac=c.map((value,i)=>value-a[i]); + const cross=[ab[1]*ac[2]-ab[2]*ac[1],ab[2]*ac[0]-ab[0]*ac[2],ab[0]*ac[1]-ab[1]*ac[0]]; + const outward=cross.reduce((sum,value,i)=>sum+value*normal[i],0)>0; + indices.push(...(outward?[base,base+1,base+2,base,base+2,base+3]:[base,base+2,base+1,base,base+3,base+2])); + } + return {positions,normals,texcoords,indices}; +} +export function createUvSphereGeometry(segments=24,rings=12){ + const positions=[],normals=[],texcoords=[],indices=[];for(let y=0;y<=rings;y++){const v=y/rings,phi=v*Math.PI;for(let x=0;x<=segments;x++){const u=x/segments,theta=u*Math.PI*2,nx=Math.sin(phi)*Math.cos(theta),ny=Math.cos(phi),nz=Math.sin(phi)*Math.sin(theta);positions.push(nx,ny,nz);normals.push(nx,ny,nz);texcoords.push(u,v);}} + for(let y=0;y - - - - - - Document - - - -
- -
-
-
- -
-
- - -
-
-
-
- - + +Yawn Render Graph Demo +
YAWNRender Graph Studio
Starting Phase 8…
Authored GraphLoading editor…
diff --git a/static/index.js b/static/index.js index a48e606..fac92ea 100644 --- a/static/index.js +++ b/static/index.js @@ -1,13 +1,60 @@ -import wbg_init, { main } from "../level-editor/pkg/level_editor.js"; +import wbg_init, { main } from "./level-editor/pkg/level_editor.js"; +import { RendererClient, RendererError } from "./renderer-client.js"; +import { loadDemoLoadout } from "./demo-loadouts.js"; +import { adaptFxNodeSnapshot } from "./render-graph/adapter.js"; +import { AuthoringController } from "./render-graph/authoring-controller.js"; +import { createRenderGraphEditor } from "./render-graph/fxnode-editor.js"; +import { renderGraphPresets } from "./render-graph/presets.js"; -const start = async () => { - await wbg_init(); - main(); -}; +let renderer,editor,controller,assetAbort,busy=false,cleaned=false; +let unsubscribeController=()=>{},unsubscribeSnapshots=()=>{}; +const listeners=[]; +const on=(target,type,fn)=>{target.addEventListener(type,fn);listeners.push(()=>target.removeEventListener(type,fn));}; +const status=message=>{const node=document.querySelector("#demo-status");if(node)node.textContent=message;}; +const sameId=(a,b)=>Array.isArray(a)&&Array.isArray(b)&&a[0]===b[0]&&a[1]===b[1]; +const state={loadout:"cubes",graph:"authored",compiled:{},telemetry:null}; -// Wait for DOM to be ready before starting -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", start); -} else { - start(); +function publish(telemetry){ + state.telemetry=telemetry; + document.documentElement.dataset.phase8State=JSON.stringify({activeLoadout:state.loadout,activeGraph:state.graph,renderDataRevision:telemetry.revision,renderMode:telemetry.renderMode,activeCompiledId:telemetry.activeCompiledId,activeCompiledGraph:telemetry.activeCompiledGraph,activeCompiledRevision:telemetry.activeCompiledRevision,graphPasses:telemetry.graphPasses,draws:telemetry.draws,instances:telemetry.instances,indices:telemetry.indices,framingRadius:telemetry.framingRadius,gpuError:telemetry.gpuError}); } +function waitTelemetry(predicate,timeout=30000){ + const current=renderer?.telemetry;if(current&&predicate(current))return Promise.resolve(current); + return new Promise((resolve,reject)=>{let timer;const done=()=>{clearTimeout(timer);removeEventListener("renderer-frame",frame);};const frame=e=>{if(predicate(e.detail)){done();resolve(e.detail);}};timer=setTimeout(()=>{done();reject(new Error("Telemetry confirmation timed out"));},timeout);onAbort=()=>{done();reject(new RendererError("DISPOSED"));};addEventListener("renderer-frame",frame);timer.unref?.();}); +} +let onAbort=()=>{}; +async function transaction(label,operation,rollback){ + if(busy||cleaned)return false;busy=true;document.querySelectorAll("select, #apply-graph").forEach(x=>x.disabled=true);status(label); + try{const telemetry=await operation();if(cleaned)return false;if(telemetry){publish(telemetry);status(`${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`);}else status(`${state.loadout} · ${state.graph} · committed; telemetry pending`);return true;} + catch(error){if(!cleaned){try{await rollback?.();}catch(rollbackError){console.error("Phase 8 rollback failed",rollbackError);}console.error("Phase 8 transaction failed",error);status(`Failed · ${error?.code??error?.message??error}`);}return false;} + finally{busy=false;if(!cleaned){document.querySelectorAll("select").forEach(x=>x.disabled=false);const button=document.querySelector("#apply-graph");if(button)button.disabled=controller?.applying||!controller?.dirty;}} +} +async function selectLoadout(next,select){ + const previous=state.loadout,targetRevision=(renderer.telemetry?.revision??0)+1;assetAbort=new AbortController(); + const ok=await transaction(`Loading ${next}…`,async()=>{const glb=await loadDemoLoadout(next,{signal:assetAbort.signal});await renderer.replaceSceneGlb(glb,{framing:next==="sponza"?"interior":"exterior"});state.loadout=next;return waitTelemetry(x=>x.revision===targetRevision&&x.draws>0&&x.activeCompiledGraph===state.compiled[state.graph].graphId&&x.gpuError===false).catch(()=>null);}); + assetAbort=undefined;if(!ok)select.value=previous; +} +async function selectGraph(next,select){ + const previous=state.graph,compiled=state.compiled[next]; + const ok=await transaction(`Activating ${next}…`,async()=>{await renderer.switchCompiledGraph(compiled.compiledId);state.graph=next;return waitTelemetry(x=>sameId(x.activeCompiledId,compiled.compiledId)&&x.activeCompiledGraph===compiled.graphId&&x.activeCompiledRevision===compiled.revision&&x.gpuError===false).catch(()=>null);},async()=>{state.graph=previous;select.value=previous;}); + if(!ok)select.value=previous; +} +async function cleanup(){if(cleaned)return;cleaned=true;removeEventListener("pagehide",pagehide);assetAbort?.abort();onAbort();listeners.splice(0).forEach(fn=>fn());unsubscribeController();unsubscribeSnapshots();try{await editor?.destroy();}finally{renderer?.dispose();}} +const pagehide=()=>{void cleanup()}; + +async function start(){ + addEventListener("pagehide",pagehide,{once:true});delete document.documentElement.dataset.phase8Ready;await wbg_init();if(cleaned)return; + renderer=new RendererClient(main());await renderer.ready;const nextEditor=await createRenderGraphEditor(document.querySelector("#graph-editor"));if(cleaned){await nextEditor.destroy();return}editor=nextEditor; + controller=new AuthoringController({renderer,getState:editor.getState});const apply=document.querySelector("#apply-graph"),graphStatus=document.querySelector("#graph-status"),loadoutSelect=document.querySelector("#loadout-select"),graphSelect=document.querySelector("#graph-select"); + unsubscribeController=controller.subscribe(s=>{apply.disabled=busy||s.applying||!s.dirty;graphStatus.textContent=s.applying?"Applying…":s.dirty?"Unapplied changes":`Authored revision ${s.revision}`;});unsubscribeSnapshots=editor.onSnapshots(()=>controller.markDirty()); + const authored=await controller.apply(adaptFxNodeSnapshot);state.compiled.authored={...authored,graphId:"demo_forward"}; + for(const [name,preset] of Object.entries(renderGraphPresets)){const compiled=await renderer.compileGraph(preset);state.compiled[name]={...compiled,graphId:preset.graphId,revision:preset.revision};} + on(window,"renderer-frame",event=>{const expected=state.compiled[state.graph],telemetry=event.detail;if(expected&&telemetry.activeCompiledGraph===expected.graphId&&sameId(telemetry.activeCompiledId,expected.compiledId)&&telemetry.gpuError===false){publish(telemetry);if(!busy)status(`${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`);}}); + on(loadoutSelect,"change",()=>void selectLoadout(loadoutSelect.value,loadoutSelect));on(graphSelect,"change",()=>void selectGraph(graphSelect.value,graphSelect)); + on(apply,"click",()=>{const previous=state.graph,previousAuthored=state.compiled.authored;void transaction("Applying authored graph…",async()=>{const compiled=await controller.apply(adaptFxNodeSnapshot);state.compiled.authored={...compiled,graphId:"demo_forward"};state.graph="authored";graphSelect.value="authored";return waitTelemetry(x=>sameId(x.activeCompiledId,compiled.compiledId)&&x.activeCompiledGraph==="demo_forward"&&x.activeCompiledRevision===compiled.revision&&x.gpuError===false).catch(()=>null);},async()=>{state.compiled.authored=previousAuthored;state.graph=previous;graphSelect.value=previous;controller.markDirty();});}); + await editor.whenRendered(); + const initialized=await transaction("Preparing procedural cubes…",async()=>{const targetRevision=(renderer.telemetry?.revision??0)+1;await renderer.replaceSceneGlb(await loadDemoLoadout("cubes"));await renderer.switchCompiledGraph(authored.compiledId);return waitTelemetry(x=>x.revision===targetRevision&&x.draws>0&&x.activeCompiledGraph==="demo_forward"&&x.activeCompiledRevision===authored.revision&&x.gpuError===false);}); + if(!initialized)throw new Error("Initial demo transaction failed");document.documentElement.dataset.phase8Ready="true"; +} +const startupError=error=>{if(cleaned)return;console.error("Phase 8 startup failed",error);status(`Startup failed · ${error?.code??error}`);void cleanup();}; +if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>start().catch(startupError),{once:true});else start().catch(startupError); diff --git a/static/render-data-snapshot.js b/static/render-data-snapshot.js new file mode 100644 index 0000000..0449148 --- /dev/null +++ b/static/render-data-snapshot.js @@ -0,0 +1,95 @@ +export const SNAPSHOT = Object.freeze({ + MAGIC: 0x504e5359, BLOB_MAGIC: 0x31534452, VERSION: 1, BYTES: 256, + SLOTS: 3, SLOT_BYTES: 64, SCHEMA: 1, INIT: 0, OPEN: 1, FAILED: 2, + CLOSED: 3, FREE: 0, WRITING: 1, READY: 2, READING: 3, +}); +export const STREAM_NAMES = ["meshSlot", "meshGeneration", "meshFlags", "meshLocalMin", "meshLocalMax", "instanceSlot", "instanceGeneration", "instanceMeshSlot", "instanceMeshGeneration", "instanceFlags", "instanceModel", "instanceWorldMin", "instanceWorldMax", "instancePickable"]; +const COMPONENTS = [1, 1, 1, 3, 3, 1, 1, 1, 1, 1, 16, 3, 3, 1]; +const SCALARS = [1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, 1]; +const STRIDES = COMPONENTS.map(n => n * 4); + +export class SnapshotProtocolError extends Error { + constructor(code) { super(code); this.code = code; this.name = "SnapshotProtocolError"; } +} + +const bad = code => { throw new SnapshotProtocolError(code); }; +const add = (a, b) => { const n = a + b; if (!Number.isSafeInteger(n) || n > 0xffffffff) bad("BAD_RANGE"); return n; }; +const mul = (a, b) => { const n = a * b; if (!Number.isSafeInteger(n) || n > 0xffffffff) bad("BAD_RANGE"); return n; }; + +export class SnapshotReader { + constructor(memory, controlPtr) { + if (!memory || !(memory.buffer instanceof SharedArrayBuffer)) bad("BAD_MEMORY"); + if (!Number.isInteger(controlPtr) || controlPtr < 0 || controlPtr % 64 || add(controlPtr, 256) > memory.buffer.byteLength) bad("BAD_CONTROL_POINTER"); + this.memory = memory; this.controlPtr = controlPtr; this.buffer = null; + this.refresh(); this.validateControl(); + } + refresh() { + if (this.buffer === this.memory.buffer) return; + this.buffer = this.memory.buffer; + if (add(this.controlPtr, 256) > this.buffer.byteLength) bad("BAD_CONTROL_POINTER"); + this.control = new Int32Array(this.buffer, this.controlPtr, 64); + } + validateControl() { + const h = this.control; + if ((Atomics.load(h, 0) >>> 0) !== SNAPSHOT.MAGIC) bad("BAD_MAGIC"); + if ((Atomics.load(h, 1) >>> 0) !== SNAPSHOT.VERSION) bad("BAD_VERSION"); + if ((Atomics.load(h, 2) >>> 0) !== SNAPSHOT.BYTES || (Atomics.load(h, 3) >>> 0) !== SNAPSHOT.SLOTS || (Atomics.load(h, 4) >>> 0) !== SNAPSHOT.SLOT_BYTES || (Atomics.load(h, 5) >>> 0) !== SNAPSHOT.SCHEMA) bad("BAD_LAYOUT"); + const lifecycle = Atomics.load(h, 6) >>> 0; + if (lifecycle > SNAPSHOT.CLOSED) bad("BAD_LIFECYCLE"); + if ((Atomics.load(h, 15) >>> 0) !== 0) bad("BAD_RESERVED"); + } + latest() { + this.refresh(); this.validateControl(); + for (let tries = 0; tries < 16; tries++) { + const a = Atomics.load(this.control, 7) >>> 0; + if (a & 1) continue; + const lifecycle = Atomics.load(this.control, 6) >>> 0; + const value = { lifecycle, epoch: Atomics.load(this.control, 8) >>> 0, slot: Atomics.load(this.control, 9) >>> 0, revisionLo: Atomics.load(this.control, 10) >>> 0, revisionHi: Atomics.load(this.control, 11) >>> 0, wasmPages: Atomics.load(this.control, 12) >>> 0, layoutEpoch: Atomics.load(this.control, 13) >>> 0, error: Atomics.load(this.control, 14) >>> 0 }; + const b = Atomics.load(this.control, 7) >>> 0; + if (a === b && !(b & 1)) { + if (lifecycle === SNAPSHOT.FAILED) bad("SNAPSHOT_FAILED"); + if (lifecycle === SNAPSHOT.CLOSED) bad("SNAPSHOT_CLOSED"); + if (lifecycle !== SNAPSHOT.INIT && lifecycle !== SNAPSHOT.OPEN) bad("BAD_LIFECYCLE"); + if (value.wasmPages && value.wasmPages > this.buffer.byteLength / 65536) bad("BAD_WASM_PAGES"); + return value; + } + } + bad("UNSTABLE_CONTROL"); + } + transaction(fn, expectedEpoch = 0) { + this.refresh(); + const latest = this.latest(); + if (!latest.epoch || latest.slot >= SNAPSHOT.SLOTS || (expectedEpoch && latest.epoch !== expectedEpoch)) return null; + let control = this.control; + const base = 16 + latest.slot * 16; + if (Atomics.compareExchange(control, base, SNAPSHOT.READY, SNAPSHOT.READING) !== SNAPSHOT.READY) return null; + try { + // memory.grow replaces memory.buffer even after the slot has been pinned. + this.refresh(); control = this.control; + const slot = Array.from({length: 16}, (_, i) => Atomics.load(control, base + i) >>> 0); + if (slot[0] !== SNAPSHOT.READING || slot[1] !== latest.epoch || slot[2] !== latest.layoutEpoch || slot[5] !== latest.revisionLo || slot[6] !== latest.revisionHi || slot[9] !== SNAPSHOT.SCHEMA || slot[10] !== 64) return null; + if (slot.slice(11).some(Boolean)) bad("BAD_SLOT_RESERVED"); + const ptr = slot[3], bytes = slot[4]; + if (ptr % 16 || bytes < 512 || bytes % 16 || add(ptr, bytes) > this.buffer.byteLength) bad("BAD_SLOT"); + const u32 = new Uint32Array(this.buffer, ptr, bytes / 4); + if (u32[0] !== SNAPSHOT.BLOB_MAGIC || u32[1] !== SNAPSHOT.SCHEMA || u32[2] !== 64 || u32[3] !== bytes || u32[4] !== slot[1] || u32[5] !== slot[5] || u32[6] !== slot[6] || u32[7] !== 14 || u32[8] !== 64 || u32[9] !== 32 || u32[10] !== slot[7] || u32[11] !== slot[8] || u32[12] !== 0x01020304 || u32[13] !== 3) bad("BAD_BLOB"); + if (u32[14] || u32[15]) bad("BAD_BLOB_RESERVED"); + const ranges = [], streams = {}; + for (let i = 0; i < 14; i++) { + const d = 16 + i * 8, semantic = u32[d], scalar = u32[d + 1], offset = u32[d + 2], count = u32[d + 3], components = u32[d + 4], stride = u32[d + 5], width = u32[d + 6], reserved = u32[d + 7]; + const want = i < 5 ? slot[7] : slot[8]; + if (semantic !== i + 1 || scalar !== SCALARS[i] || count !== want || components !== COMPONENTS[i] || stride !== STRIDES[i] || width !== 4 || reserved || offset < 512 || offset % 16) bad("BAD_DESCRIPTOR"); + const end = add(offset, mul(stride, count)); + if (end > bytes) bad("BAD_DESCRIPTOR_RANGE"); + if (count) ranges.push([offset, end]); + const Type = scalar === 2 ? Float32Array : Uint32Array; + streams[STREAM_NAMES[i]] = new Type(this.buffer, add(ptr, offset), mul(count, components)); + } + ranges.sort((a, b) => a[0] - b[0]); + for (let i = 1; i < ranges.length; i++) if (ranges[i][0] < ranges[i - 1][1]) bad("OVERLAPPING_STREAMS"); + return fn(Object.freeze({epoch: slot[1], revisionLo: slot[5], revisionHi: slot[6], meshCount: slot[7], instanceCount: slot[8], streams: Object.freeze(streams)})); + } finally { + Atomics.store(control, base, SNAPSHOT.FREE); Atomics.notify(control, base); + } + } +} diff --git a/static/render-graph/adapter.js b/static/render-graph/adapter.js new file mode 100644 index 0000000..53cd470 --- /dev/null +++ b/static/render-graph/adapter.js @@ -0,0 +1,75 @@ +import { CATALOG_VERSION, descriptors, GRAPH_ID } from "./catalog.js"; + +export class AuthoringGraphError extends Error { + constructor(code, details = {}) { super(code); this.name="AuthoringGraphError"; this.code=code; this.details=Object.freeze(details); } +} +const fail=(code,details)=>{throw new AuthoringGraphError(code,details)}; +const object=v=>v !== null && typeof v === "object" && !Array.isArray(v); +const validId=v=>typeof v === "string" && /^[A-Za-z][A-Za-z0-9_.-]*$/.test(v) && new TextEncoder().encode(v).length<=64; +const validSocketId=v=>typeof v === "string" && /^[A-Za-z][A-Za-z0-9_.-]*:[A-Za-z][A-Za-z0-9_.-]*$/.test(v) && new TextEncoder().encode(v).length<=129; +const keysEqual=(a,b)=>a.length===b.length && a.every(x=>b.includes(x)); + +/** Validate hostile fxnode state and return an app-owned, layout-free projection. */ +export function projectAuthoringSnapshot(raw) { + if(!object(raw)||!Array.isArray(raw.nodes)||!Array.isArray(raw.links)) fail("AUTHORING_SHAPE",{field:"snapshot"}); + if(raw.graphId!==GRAPH_ID||raw.catalogVersion!==CATALOG_VERSION) fail("AUTHORING_CATALOG",{graphId:raw.graphId,catalogVersion:raw.catalogVersion}); + const nodeIds=new Set(), socketIds=new Set(), byId=new Map(), byType=new Map(); + for(const n of raw.nodes){ + if(!object(n)||!validId(n.id)) fail("AUTHORING_ID",{kind:"node",id:n?.id}); + if(nodeIds.has(n.id)) fail("AUTHORING_ID_DUPLICATE",{kind:"node",id:n.id}); nodeIds.add(n.id); + const d=descriptors[n.typeId]; + if(!d) fail("AUTHORING_NODE_TYPE",{nodeId:n.id,typeId:n.typeId}); + if(n.known!==true) fail("AUTHORING_NODE_UNKNOWN",{nodeId:n.id}); + if(n.typeVersion!==d.version) fail("AUTHORING_NODE_VERSION",{nodeId:n.id,expected:d.version,actual:n.typeVersion}); + if(typeof n.muted!=="boolean") fail("AUTHORING_NODE_MUTED",{nodeId:n.id}); + if(n.muted&&n.typeId!=="scene_forward") fail("AUTHORING_NODE_MUTED",{nodeId:n.id}); + if(byType.has(n.typeId)) fail("AUTHORING_TOPOLOGY",{reason:"duplicate-type",typeId:n.typeId}); + if(!Array.isArray(n.sockets)||!keysEqual(n.sockets.map(s=>s?.key),Object.keys(d.sockets))) fail("AUTHORING_SOCKET_SET",{nodeId:n.id}); + const sockets={}; + for(const s of n.sockets){ const expected=d.sockets[s.key]; + if(!object(s)||!validSocketId(s.id)) fail("AUTHORING_ID",{kind:"socket",id:s?.id}); + if(socketIds.has(s.id)) fail("AUTHORING_ID_DUPLICATE",{kind:"socket",id:s.id}); socketIds.add(s.id); + if(s.direction!==expected[0]||s.dataType!==expected[1]) fail("AUTHORING_SOCKET",{nodeId:n.id,socket:s.key}); + sockets[s.key]={id:s.id,direction:s.direction,type:s.dataType,nodeId:n.id}; + } + const p=object(n.parameters)?n.parameters:null; + if(!p||!keysEqual(Object.keys(p),d.parameters)) fail("AUTHORING_PARAMETERS",{nodeId:n.id}); + let parameters={}; + if(n.typeId==="scene_forward"){ + const c=p.clearColor, z=p.clearDepth; + if(!object(c)||c.kind!=="color"||!Array.isArray(c.value)||c.value.length!==4||c.value.some(v=>!Number.isFinite(v)||v<0||v>1)) fail("AUTHORING_PARAMETER",{parameter:"clearColor"}); + if(!object(z)||z.kind!=="number"||!Number.isFinite(z.value)||z.value<0||z.value>1) fail("AUTHORING_PARAMETER",{parameter:"clearDepth"}); + parameters={clearColor:[...c.value],clearDepth:z.value}; + } + const projected={type:n.typeId,id:n.id,sockets,parameters,muted:n.muted}; byId.set(n.id,projected); byType.set(n.typeId,projected); + } + if(!keysEqual([...byType.keys()],Object.keys(descriptors))) fail("AUTHORING_TOPOLOGY",{reason:"node-set"}); + const linkIds=new Set(), incoming=new Set(), links=[]; + for(const l of raw.links){ + if(!object(l)||!validId(l.id)) fail("AUTHORING_ID",{kind:"link",id:l?.id}); + if(linkIds.has(l.id)) fail("AUTHORING_ID_DUPLICATE",{kind:"link",id:l.id}); linkIds.add(l.id); + if(typeof l.muted!=="boolean") fail("AUTHORING_LINK",{linkId:l.id,reason:"muted"}); + const from=byId.get(l.fromNodeId),to=byId.get(l.toNodeId),fs=from&&Object.values(from.sockets).find(s=>s.id===l.fromSocketId),ts=to&&Object.values(to.sockets).find(s=>s.id===l.toSocketId); + if(!fs||!ts||fs.direction!=="output"||ts.direction!=="input"||fs.type!==ts.type) fail("AUTHORING_LINK",{linkId:l.id}); + if(incoming.has(ts.id)) fail("AUTHORING_LINK_INCOMING",{socketId:ts.id}); incoming.add(ts.id); + links.push({from:`${from.type}.${Object.keys(from.sockets).find(k=>from.sockets[k]===fs)}`,to:`${to.type}.${Object.keys(to.sockets).find(k=>to.sockets[k]===ts)}`,muted:l.muted}); + } + const required=["surface_color.surface>scene_forward.color","depth32.depth>scene_forward.depth","scene_forward.result>present.surface"]; + const active=links.filter(l=>!l.muted).map(l=>`${l.from}>${l.to}`).sort(); + if(!keysEqual(active,required.sort())||links.length!==3) fail("AUTHORING_TOPOLOGY",{reason:"links"}); + return Object.freeze({graphId:GRAPH_ID,clearColor:byType.get("scene_forward").parameters.clearColor,clearDepth:byType.get("scene_forward").parameters.clearDepth,passState:byType.get("scene_forward").muted?"disabled":"enabled"}); +} + +export function semanticProjectionToV1(p, revision=1){ + if(!Number.isInteger(revision)||revision<1||revision>0xffffffff) fail("AUTHORING_REVISION",{revision}); + const extent={kind:"surface_relative",width:{numerator:1,denominator:1},height:{numerator:1,denominator:1},depthOrArrayLayers:1}; + return {schemaVersion:1,graphId:p.graphId,revision,resources:[ + {id:"surface",version:0,residency:{kind:"external",source:"surface_color"},texture:{dimension:"d2",format:"surface",extent,mipLevelCount:1,sampleCount:1}}, + {id:"depth",version:0,residency:{kind:"transient"},texture:{dimension:"d2",format:"depth32_float",extent,mipLevelCount:1,sampleCount:1}}, + ],passes:[{id:"forward",state:p.passState,executor:{key:"scene_forward",version:1},parameters:{},reads:[],writes:[ + {binding:"color",resource:{id:"surface",version:0},access:{kind:"color_attachment",location:0,load:{op:"clear",value:p.clearColor},store:"store"}}, + {binding:"depth",resource:{id:"depth",version:0},access:{kind:"depth_attachment",load:{op:"clear",value:p.clearDepth},store:"store"}}, + ]}],outputs:[{name:"present",resource:{id:"surface",version:0}}]}; +} +export const adaptFxNodeSnapshot=(snapshot,revision=1)=>semanticProjectionToV1(projectAuthoringSnapshot(snapshot),revision); +export const adaptGraphSnapshot=adaptFxNodeSnapshot; diff --git a/static/render-graph/authoring-controller.js b/static/render-graph/authoring-controller.js new file mode 100644 index 0000000..54dc2cf --- /dev/null +++ b/static/render-graph/authoring-controller.js @@ -0,0 +1,14 @@ +export class AuthoringController { + #renderer; #getState; #revision=0; #nextRevision=1; #dirty=true; #applying=null; #listeners=new Set(); + constructor({renderer,getState}) { this.#renderer=renderer; this.#getState=getState; } + get revision(){return this.#revision} get dirty(){return this.#dirty} get applying(){return !!this.#applying} + subscribe(fn){this.#listeners.add(fn);return()=>this.#listeners.delete(fn)} + markDirty(){this.#dirty=true;this.#emit()} + #emit(){for(const fn of this.#listeners)fn({revision:this.#revision,dirty:this.#dirty,applying:!!this.#applying})} + apply(adapt){ + if(this.#applying)return this.#applying; + const revision=this.#nextRevision++; this.#dirty=false; this.#emit(); + this.#applying=(async()=>{try{const snapshot=await this.#getState();const ir=adapt(snapshot,revision);const compiled=await this.#renderer.compileGraph(ir);await this.#renderer.switchCompiledGraph(compiled.compiledId);this.#revision=revision;return compiled} catch(e){this.#dirty=true;throw e} finally{this.#applying=null;this.#emit()}})(); + return this.#applying; + } +} diff --git a/static/render-graph/browser-host.js b/static/render-graph/browser-host.js new file mode 100644 index 0000000..cc5389d --- /dev/null +++ b/static/render-graph/browser-host.js @@ -0,0 +1,61 @@ +const viewport = (canvas, ownerWindow) => ({ + width: Math.max(1, canvas.clientWidth), + height: Math.max(1, canvas.clientHeight), + dpr: Math.min(4, Math.max(1, ownerWindow.devicePixelRatio || 1)), +}); +const sameViewport = (a, b) => a.width === b.width && a.height === b.height && a.dpr === b.dpr; +const sizeCanvas = (canvas, value) => { + canvas.width = Math.round(value.width * value.dpr); + canvas.height = Math.round(value.height * value.dpr); +}; +const mods = e => ({ alt:e.altKey, control:e.ctrlKey, meta:e.metaKey, shift:e.shiftKey }); + +export function prepareBrowserHost(canvas, { onError=console.error, chooseNodeType }={}) { + const ownerDocument=canvas.ownerDocument, ownerWindow=ownerDocument.defaultView ?? window; + const originalTabIndex=canvas.getAttribute("tabindex"), originalTouchAction=canvas.style.touchAction; + let view, dead=false, generation=0, resizing=false, pending, appliedViewport, menuPending=false, unsubscribeHost=()=>{}; + const captured=new Set(); + const initialViewport=viewport(canvas,ownerWindow); appliedViewport=initialViewport; sizeCanvas(canvas,initialViewport); + canvas.tabIndex=0; canvas.style.touchAction="none"; + const point=e=>{const r=canvas.getBoundingClientRect();return{x:e.clientX-r.left,y:e.clientY-r.top}}; + const input=e=>{ + if(!view)return; + if(e instanceof ownerWindow.PointerEvent){ + const phase=e.type==="pointerdown"?"down":e.type==="pointermove"?"move":e.type==="pointerup"?"up":"cancel"; + if(phase==="down"){menuPending=e.button===2&&!e.ctrlKey&&(e.buttons&1)===0;canvas.focus();try{canvas.setPointerCapture(e.pointerId);captured.add(e.pointerId)}catch{}} + if((phase==="up"||phase==="cancel")&&captured.delete(e.pointerId))try{if(canvas.hasPointerCapture(e.pointerId))canvas.releasePointerCapture(e.pointerId)}catch{} + view.feedInput({kind:"pointer",phase,pointerId:e.pointerId,pointerType:e.pointerType,position:point(e),button:e.button,buttons:e.buttons,modifiers:mods(e)}); + }else if(e instanceof ownerWindow.WheelEvent){ + e.preventDefault(); menuPending=false; + const scale=e.deltaMode===ownerWindow.WheelEvent.DOM_DELTA_LINE?16:e.deltaMode===ownerWindow.WheelEvent.DOM_DELTA_PAGE?Math.max(1,canvas.clientHeight):1; + view.feedInput({kind:"wheel",position:point(e),delta:{x:e.deltaX*scale,y:e.deltaY*scale},modifiers:mods(e)}); + }else if(e instanceof ownerWindow.KeyboardEvent){menuPending=false;view.feedInput({kind:"key",phase:e.type==="keydown"?"down":"up",key:e.key,code:e.code,repeat:e.repeat,modifiers:mods(e)}); + }else view.feedInput({kind:"focus",phase:e.type==="focus"?"focus":"blur"}); + }; + const names=["pointerdown","pointermove","pointerup","pointercancel","wheel","keydown","keyup","focus","blur"]; + const pump=()=>{ + if(!view||resizing||!pending||dead)return; + const next=pending, currentGeneration=generation;pending=undefined; + if(sameViewport(next,appliedViewport)){sizeCanvas(canvas,next);pump();return} + resizing=true; + Promise.resolve(view.setViewport(next)).then(()=>{if(dead||currentGeneration!==generation)return;appliedViewport=next;sizeCanvas(canvas,next)}).catch(error=>{if(!dead&¤tGeneration===generation)onError(error)}).finally(()=>{if(dead||currentGeneration!==generation)return;resizing=false;pump()}); + }; + const resize=()=>{if(dead)return;pending=viewport(canvas,ownerWindow);pump()}; + const outside=e=>{if(view&&e.button===0&&e.target!==canvas&&!canvas.contains(e.target)&&view.getHostSnapshot().colorPickerOpen)view.feedInput({kind:"outside-pointer",button:0})}; + const lost=e=>captured.delete(e.pointerId); + const observer=new ownerWindow.ResizeObserver(resize); + return {initialViewport,attach(_root,next){ + view=next; + for(const n of names)canvas.addEventListener(n,input,{passive:n!=="wheel"}); + canvas.addEventListener("contextmenu",prevent);canvas.addEventListener("lostpointercapture",lost); + ownerDocument.addEventListener("pointerdown",outside,true);ownerWindow.addEventListener("resize",resize); + unsubscribeHost=view.onHostRequests(request=>{if(request.kind!=="add-node-menu"||!menuPending)return;menuPending=false;const typeId=chooseNodeType?.(request);if(typeId)view.addNode({typeId,viewPosition:request.viewPosition}).catch(onError)}); + observer.observe(canvas);resize(); + },destroy(){ + if(dead)return;dead=true;generation++;pending=undefined;observer.disconnect();unsubscribeHost();ownerWindow.removeEventListener("resize",resize);ownerDocument.removeEventListener("pointerdown",outside,true); + for(const n of names)canvas.removeEventListener(n,input);canvas.removeEventListener("contextmenu",prevent);canvas.removeEventListener("lostpointercapture",lost); + for(const id of captured)try{if(canvas.hasPointerCapture(id))canvas.releasePointerCapture(id)}catch{}captured.clear(); + if(originalTabIndex===null)canvas.removeAttribute("tabindex");else canvas.setAttribute("tabindex",originalTabIndex);canvas.style.touchAction=originalTouchAction;view=null; + }}; +} +function prevent(e){e.preventDefault()} diff --git a/static/render-graph/catalog.js b/static/render-graph/catalog.js new file mode 100644 index 0000000..aff394c --- /dev/null +++ b/static/render-graph/catalog.js @@ -0,0 +1,29 @@ +export const GRAPH_ID = "demo_forward"; +export const CATALOG_VERSION = 1; + +export const socketTypes = { + surface: { title: "Surface", color: "#62b0ff", acceptsFrom: ["surface"] }, + depth: { title: "Depth", color: "#b58cff", acceptsFrom: ["depth"] }, +}; +export const theme = { + background:"#151820",grid:"#292e3a",frame:"#30343a80",frameHeader:"#59616c",body:"#292e39",control:"#191d26",controlFill:"#4775b8",controlEditing:"#101218",textSelection:"#4775b8",outline:"#0b0d12",text:"#edf1f7",muted:"#969eaa",shadow:"#00000088",nodeSelected:"#ff9f43",nodeActive:"#ffffff",unknownHeader:"#555b64",unknownSocket:"#999999",linkMuted:"#d94b4b",knifeMuted:"#e85b5b",emphasis:"#ffffff",focus:"#f5a623",editOutline:"#666a70",resize:"#8b8e95",muteOverlay:"#14141459",boxSelectionFill:"#f5a6231f",checkerLight:"#aaaaaa",checkerDark:"#777777",widgetBorder:"#111216",rampBorder:"#111111",resourceBackground:"#202228" +}; +export const styles = { resource:{header:"#3977a8"}, pass:{header:"#426b43"}, output:{header:"#a75d37"} }; +const socket = (title, direction, type) => ({ title,direction,type,maxIncomingLinks:direction === "input" ? 1 : 0,visible:true,value:null,showValue:false }); +const node = (title, style, sockets, parameters = {}) => ({ version:1,title,behavior:"standard",style,parameters,sockets,ui:[...Object.keys(parameters).map(parameter=>({kind:"parameter",parameter})),...Object.keys(sockets).map(socket=>({kind:"socket",socket}))],muteBypass:[],migrations:[] }); +export const nodeDefinitions = { + surface_color: node("Surface Color", "resource", { surface:socket("Surface","output","surface") }), + depth32: node("Depth 32", "resource", { depth:socket("Depth","output","depth") }), + scene_forward: node("Scene Forward", "pass", { color:socket("Color","input","surface"),depth:socket("Depth","input","depth"),result:socket("Result","output","surface") }, { + clearColor:{type:"color",default:{kind:"color",value:[0,0,0,1]},minimum:0,maximum:1}, + clearDepth:{type:"number",default:{kind:"number",value:1},minimum:0,maximum:1,step:0.01}, + }), + present: node("Present", "output", { surface:socket("Surface","input","surface") }), +}; + +export const descriptors = Object.freeze({ + surface_color:{version:1,sockets:{surface:["output","surface"]},parameters:[]}, + depth32:{version:1,sockets:{depth:["output","depth"]},parameters:[]}, + scene_forward:{version:1,sockets:{color:["input","surface"],depth:["input","depth"],result:["output","surface"]},parameters:["clearColor","clearDepth"]}, + present:{version:1,sockets:{surface:["input","surface"]},parameters:[]}, +}); diff --git a/static/render-graph/fxnode-editor.js b/static/render-graph/fxnode-editor.js new file mode 100644 index 0000000..ed683f6 --- /dev/null +++ b/static/render-graph/fxnode-editor.js @@ -0,0 +1,25 @@ +import { createFxNode } from "@fxnode/index.ts"; +import { CATALOG_VERSION, GRAPH_ID, nodeDefinitions, socketTypes, styles, theme } from "./catalog.js"; +import { prepareBrowserHost } from "./browser-host.js"; + +const spec=[ + ["surface","surface_color",{x:40,y:100}], + ["depth","depth32",{x:40,y:330}], + ["forward","scene_forward",{x:360,y:190}], + ["present","present",{x:700,y:220}], +]; +async function seed(root){ + await root.setState({graphId:GRAPH_ID,catalogVersion:CATALOG_VERSION,nodes:[],links:[],metadata:{}}); + for(const [nodeId,nodeType,position] of spec) await root.dispatch({type:"node.add",nodeId,nodeType,position}); + for(const link of [ + {id:"surface_link",fromNodeId:"surface",fromSocketId:"surface:surface",toNodeId:"forward",toSocketId:"forward:color",muted:false,extensions:{}}, + {id:"depth_link",fromNodeId:"depth",fromSocketId:"depth:depth",toNodeId:"forward",toSocketId:"forward:depth",muted:false,extensions:{}}, + {id:"present_link",fromNodeId:"forward",fromSocketId:"forward:result",toNodeId:"present",toSocketId:"present:surface",muted:false,extensions:{}}, + ]) await root.dispatch({type:"link.add",link}); +} +export async function createRenderGraphEditor(canvas){ + const chooseNodeType=()=>{const value=canvas.ownerDocument.defaultView?.prompt(`Node type: ${Object.keys(nodeDefinitions).join(", ")}`,"scene_forward");return Object.hasOwn(nodeDefinitions,value)?value:null}; + const host=prepareBrowserHost(canvas,{chooseNodeType});let root,view,destroying; + const destroy=()=>destroying??=(async()=>{host.destroy();try{await view?.detach()}finally{root?.destroy();view=undefined;root=undefined}})(); + try{root=await createFxNode({applicationId:"yawn.render-graph",applicationVersion:1,resources:{}});await root.setTheme(theme);await root.setHeaderStyles(styles);for(const entry of Object.entries(socketTypes))await root.composeSocket(...entry);for(const entry of Object.entries(nodeDefinitions))await root.composeNode(...entry);await seed(root);view=await root.attachView({canvas,viewport:host.initialViewport,initialCamera:{center:{x:470,y:210},zoom:.5}});host.attach(root,view);await view.whenRendered();return {getState:()=>root.getState(),onSnapshots:fn=>root.onSnapshots(fn),whenRendered:()=>view.whenRendered(),destroy};}catch(e){await destroy().catch(()=>{});throw e} +} diff --git a/static/render-graph/presets.js b/static/render-graph/presets.js new file mode 100644 index 0000000..54ddbe9 --- /dev/null +++ b/static/render-graph/presets.js @@ -0,0 +1,5 @@ +import { semanticProjectionToV1 } from "./adapter.js"; +const make=(graphId,clearColor)=>Object.freeze(semanticProjectionToV1({graphId,clearColor,clearDepth:1,passState:"enabled"},1)); +export const midnight=make("preset_midnight",[0.015,0.06,0.18,1]); +export const ember=make("preset_ember",[0.18,0.035,0.012,1]); +export const renderGraphPresets=Object.freeze({midnight,ember}); diff --git a/static/renderer-client.js b/static/renderer-client.js new file mode 100644 index 0000000..a6bb78f --- /dev/null +++ b/static/renderer-client.js @@ -0,0 +1,287 @@ +import { SnapshotReader } from "./render-data-snapshot.js"; + +export const VISIBLE = 1; +const HEADER_WORDS = 16, SLOT_WORDS = 24, CAPACITY = 1024, SLOT_VERSION = 1; +const OP = { IMPORT_GLB: 1, MESH_FLAGS: 2, CREATE_INSTANCE: 3, INSTANCE_FLAGS: 4, INSTANCE_TRANSFORM: 5, DESTROY_INSTANCE: 6, COMPILE_GRAPH: 7, DROP_GRAPH: 8, SWITCH_GRAPH: 9 }; +const HANDLE_TOKEN = Symbol("renderer handle"); + +export class RendererError extends Error { + constructor(code, details) { super(details?.message ?? code); this.name = "RendererError"; this.code = code; this.details = details; } +} + +export class RendererClient { + #bridge; #worker; #header; #slots; #buffer; #next = 1; #payload = 1; + #pending = new Map(); #payloadPending = new Map(); #payloadActive = new Set(); #ready; #disposed = false; + #telemetry; #stopped = false; + #graphQueue = []; #graphBusy = false; + #bvh; #snapshotReader; #picking = true; #snapshotEpoch = 0; #pickNext = 1; #picks = new Map(); + + constructor(bridge) { + this.#bridge = bridge; + this.#worker = bridge.worker; + this.#refreshViews(); + if (Atomics.load(this.#header, 0) !== 0x4e574159 || Atomics.load(this.#header, 1) !== 1 || Atomics.load(this.#header, 2) !== CAPACITY || Atomics.load(this.#header, 3) !== SLOT_WORDS) { + try { this.#worker?.terminate?.(); } catch { /* best effort */ } + try { bridge?.free?.(); } catch { /* best effort */ } + throw new RendererError("PROTOCOL_MISMATCH"); + } + this.#worker.addEventListener("message", e => this.#message(e.data)); + this.#worker.addEventListener("error", () => this.#fail("WORKER_ERROR")); + this.#worker.addEventListener("messageerror", () => this.#fail("WORKER_MESSAGE_ERROR")); + try { + const factory = bridge.workerFactory || (() => new Worker(new URL("./bvh-worker.js", import.meta.url), { type: "module" })); + this.#bvh = factory(); + this.#bvh.addEventListener("message", e => this.#bvhMessage(e.data)); + this.#bvh.addEventListener("error", () => this.#disablePicking("PICKING_FAILED")); + this.#bvh.addEventListener("messageerror", () => this.#disablePicking("PICKING_FAILED")); + } catch { this.#picking = false; } + this.#ready = Promise.resolve(this); + } + + get ready() { return this.#ready; } + get telemetry() { return this.#telemetry; } + + #refreshViews() { + const buffer = this.#bridge.memory.buffer; + if (buffer === this.#buffer) return; + this.#buffer = buffer; + this.#header = new Int32Array(buffer, this.#bridge.ringPtr, HEADER_WORDS); + this.#slots = new Int32Array(buffer, this.#bridge.ringPtr + 64, CAPACITY * SLOT_WORDS); + } + + #message(message) { + if (message?.type === "reply") { + const pending = this.#pending.get(message.request); + if (!pending) return; + this.#pending.delete(message.request); + message.ok ? pending.resolve(message.result) : pending.reject(new RendererError(message.code, message.details)); + } else if (message?.type === "payload-ready") { + const pending = this.#payloadPending.get(message.id); + if (pending) { this.#payloadPending.delete(message.id); pending.resolve(); } + } else if (message?.type === "telemetry") { + this.#telemetry = message; + dispatchEvent(new CustomEvent("renderer-frame", { detail: message })); + } else if (message?.type === "fatal") { + console.error("renderer worker fatal", message.code, message.message); + this.#fail(message.code || "WORKER_FATAL"); + } else if (message?.type === "snapshot-init") { + try { + if (message.controlVersion !== 1 || message.schemaVersion !== 1) throw new Error("version"); + this.#snapshotReader = new SnapshotReader(this.#bridge.memory, message.controlPtr); + this.#bvh?.postMessage({type:"init",memory:this.#bridge.memory,controlPtr:message.controlPtr,controlVersion:1,schemaVersion:1}); + } catch { this.#disablePicking("PICK_PROTOCOL_MISMATCH"); } + } else if (message?.type === "snapshot-published") { + try { this.#snapshotEpoch=this.#snapshotReader?.latest().epoch||0; this.#bvh?.postMessage({type:"update",epoch:this.#snapshotEpoch}); } catch { this.#disablePicking("PICK_PROTOCOL_MISMATCH"); } + } + } + + #disablePicking(code) { this.#picking=false; const allowed=new Set(["PICK_UNAVAILABLE","PICK_PROTOCOL_MISMATCH","PICK_WORKER_ERROR","PICK_STALE","DISPOSED"]); const error=new RendererError(allowed.has(code)?code:"PICK_WORKER_ERROR"); for(const p of this.#picks.values())p.reject(error); this.#picks.clear(); try{this.#bvh?.terminate?.();}catch{} this.#bvh=null; } + #bvhMessage(message) { + if(message?.type==="fatal"){this.#disablePicking(message.code);return;} + if(message?.type!=="pick")return; + const p=this.#picks.get(message.request);if(!p)return;this.#picks.delete(message.request); + let latest=0;try{latest=this.#snapshotReader.latest().epoch;this.#snapshotEpoch=latest;}catch{this.#disablePicking("PICK_PROTOCOL_MISMATCH");p.reject(new RendererError("PICK_PROTOCOL_MISMATCH"));return;} + if(message.stale||p.epoch!==message.epoch||message.epoch!==latest){if(!p.retried&&latest){this.#sendPick({...p,retried:true},latest);}else p.reject(new RendererError("PICK_STALE"));return;} + const hits=(message.hits||[]).map(hit=>({instance:this.#instance([hit.slot>>>0,hit.generation>>>0]),distance:hit.distance}));p.resolve({epoch:latest,hits}); + } + #sendPick(p,epoch){const request=this.#pickNext++>>>0||this.#pickNext++;p.epoch=epoch;this.#picks.set(request,p);try{this.#bvh.postMessage({type:"pick",request,epoch,origin:p.origin,direction:p.direction,maxDistance:p.maxDistance,maxHits:p.maxHits});}catch{this.#picks.delete(request);p.reject(new RendererError("PICK_WORKER_ERROR"));}} + + pickRay(origin,direction,{maxDistance=Infinity,maxHits=1}={}) { + const vector=(v,name)=>{if(!v||v.length!==3||[...v].some(x=>typeof x!=="number"||!Number.isFinite(x)))throw new TypeError(`${name} must contain 3 finite numbers`);return [...v];}; + origin=vector(origin,"origin");direction=vector(direction,"direction");if(direction.every(x=>x===0))throw new TypeError("direction must be nonzero");if(typeof maxDistance!=="number"||(!(Number.isFinite(maxDistance)&&maxDistance>=0)&&maxDistance!==Infinity)||!Number.isInteger(maxHits)||maxHits<1||maxHits>64)throw new TypeError("invalid pick options"); + if(this.#disposed)return Promise.reject(new RendererError("DISPOSED"));if(!this.#picking||!this.#bvh||!this.#snapshotReader)return Promise.reject(new RendererError("PICK_UNAVAILABLE")); + let epoch;try{epoch=this.#snapshotReader.latest().epoch;this.#snapshotEpoch=epoch;}catch{return Promise.reject(new RendererError("PICK_PROTOCOL_MISMATCH"));}if(!epoch)return Promise.reject(new RendererError("PICK_STALE")); + return new Promise((resolve,reject)=>this.#sendPick({resolve,reject,origin,direction,maxDistance,maxHits,retried:false},epoch)); + } + + #stop() { + if (this.#stopped) return; + this.#stopped = true; + try { this.#worker?.terminate?.(); } catch { /* best effort */ } + try { this.#bvh?.postMessage?.({type:"dispose"}); this.#bvh?.terminate?.(); } catch { /* best effort */ } + try { this.#bridge?.free?.(); } catch { /* best effort */ } + this.#bridge = null; + } + + #fail(code) { + if (this.#disposed) { this.#stop(); return; } + this.#disposed = true; + const error = new RendererError(code); + for (const pending of this.#pending.values()) pending.reject(error); + this.#pending.clear(); + for (const pending of this.#payloadPending.values()) pending.reject(error); + this.#payloadPending.clear(); + for (const pending of this.#graphQueue) pending.reject(error); + this.#graphQueue.length = 0; + this.#disablePicking(code === "DISPOSED" ? "DISPOSED" : "PICK_WORKER_ERROR"); + this.#stop(); + } + + #corrupt(code) { + Atomics.store(this.#header, 6, 1); + this.#fail(code); + return Promise.reject(new RendererError(code)); + } + + #enqueue(opcode, words = []) { + if (this.#disposed) return Promise.reject(new RendererError("DISPOSED")); + this.#refreshViews(); + if (Atomics.load(this.#header, 6) !== 0) return this.#corrupt("RING_CLOSED"); + const read = Atomics.load(this.#header, 4) >>> 0; + const write = Atomics.load(this.#header, 5) >>> 0; + const backlog = (write - read) >>> 0; + if (backlog > CAPACITY) return this.#corrupt("RING_CORRUPT"); + if (backlog === CAPACITY) return Promise.reject(new RendererError("RING_FULL")); + let request = this.#next++ >>> 0; + if (request === 0) { request = 1; this.#next = 2; } + const base = (write % CAPACITY) * SLOT_WORDS; + const promise = new Promise((resolve, reject) => this.#pending.set(request, { resolve, reject })); + try { + for (let i = 0; i < SLOT_WORDS; i++) Atomics.store(this.#slots, base + i, 0); + for (let i = 0; i < words.length; i++) Atomics.store(this.#slots, base + 3 + i, words[i]); + Atomics.store(this.#slots, base + 2, request); + Atomics.store(this.#slots, base + 1, opcode); + // The slot tag is its publication marker; write_index publishes the complete slot. + Atomics.store(this.#slots, base, SLOT_VERSION); + Atomics.store(this.#header, 5, (write + 1) | 0); + Atomics.notify(this.#header, 5); + } catch (error) { + this.#pending.delete(request); + this.#fail("PUBLICATION_FAILED"); + return Promise.reject(error); + } + return promise; + } + + #mesh(handle) { + return new Mesh(HANDLE_TOKEN, + visible => this.#enqueue(OP.MESH_FLAGS, [...handle, visible ? VISIBLE : 0]), + async (transform, visible) => { + const result = await this.#enqueue(OP.CREATE_INSTANCE, [...handle, ...floatWords(transform), visible ? VISIBLE : 0]); + return this.#instance(result); + }); + } + + #instance(handle) { + return new Instance(HANDLE_TOKEN, + visible => this.#enqueue(OP.INSTANCE_FLAGS, [...handle, visible ? VISIBLE : 0]), + transform => this.#enqueue(OP.INSTANCE_TRANSFORM, [...handle, ...floatWords(transform)]), + () => this.#enqueue(OP.DESTROY_INSTANCE, [...handle])); + } + + async replaceSceneGlb(source, { framing = "exterior" } = {}) { + if (this.#disposed) throw new RendererError("DISPOSED"); + if (framing !== "exterior" && framing !== "interior") throw new TypeError("framing must be exterior or interior"); + let buffer; + if (typeof source === "string" || source instanceof URL) buffer = await (await fetch(source)).arrayBuffer(); + else if (typeof File !== "undefined" && source instanceof File) buffer = await source.arrayBuffer(); + else if (source instanceof ArrayBuffer) buffer = source; + else throw new TypeError("GLB source must be URL, File, or ArrayBuffer"); + if (this.#disposed) throw new RendererError("DISPOSED"); + const result = await this.#withPayload(buffer, OP.IMPORT_GLB, [framing === "interior" ? 1 : 0]); + return result.meshes.map(handle => this.#mesh(handle)); + } + + /** Compatibility alias for the original opcode-1 API. */ + importGlb(source, options) { return this.replaceSceneGlb(source, options); } + + async #withPayload(buffer, opcode, words = []) { + if (this.#disposed) throw new RendererError("DISPOSED"); + let id; + do { id = this.#payload++ >>> 0; if (!id) id = this.#payload++ >>> 0; } + while (!id || this.#payloadActive.has(id)); + this.#payloadActive.add(id); + const worker = this.#worker; + const ready = new Promise((resolve, reject) => this.#payloadPending.set(id, { resolve, reject })); + try { + worker.postMessage({ type: "payload", id, buffer }, [buffer]); + await ready; + return await this.#enqueue(opcode, [id, ...words]); + } finally { + this.#payloadPending.delete(id); + this.#payloadActive.delete(id); + try { worker.postMessage({ type: "payload-release", id }); } catch { /* best effort after termination */ } + } + } + + #graphCall(operation) { + const result = new Promise((resolve, reject) => this.#graphQueue.push({operation, resolve, reject})); + this.#pumpGraphQueue(); + return result; + } + + #pumpGraphQueue() { + if (this.#graphBusy || !this.#graphQueue.length) return; + const call = this.#graphQueue.shift(); + if (this.#disposed) { call.reject(new RendererError("DISPOSED")); this.#pumpGraphQueue(); return; } + this.#graphBusy = true; + let outcome; + try { outcome = call.operation(); } catch (error) { outcome = Promise.reject(error); } + Promise.resolve(outcome).then(call.resolve, call.reject).finally(() => { this.#graphBusy = false; this.#pumpGraphQueue(); }); + } + + compileGraph(graph) { + return this.#graphCall(() => this.#compileGraph(graph)); + } + + async #compileGraph(graph) { + if (this.#disposed) throw new RendererError("DISPOSED"); + let json; + try { json = JSON.stringify(graph); } catch (error) { throw new RendererError("GRAPH_JSON_INVALID", { message: error?.message || "GRAPH_JSON_INVALID" }); } + if (json === undefined) throw new RendererError("GRAPH_JSON_INVALID"); + const buffer = new TextEncoder().encode(json).buffer; + if (buffer.byteLength > 1024 * 1024) throw new RendererError("GRAPH_PAYLOAD_TOO_LARGE"); + return this.#withPayload(buffer, OP.COMPILE_GRAPH); + } + + dropCompiledGraph(compiledId) { + validateCompiledId(compiledId); + return this.#graphCall(() => this.#enqueue(OP.DROP_GRAPH, compiledId)); + } + + switchCompiledGraph(compiledId) { + validateCompiledId(compiledId); + if (compiledId[0] === 0 && compiledId[1] === 0) throw new TypeError("compiledId must be nonzero"); + return this.#graphCall(() => this.#enqueue(OP.SWITCH_GRAPH, [1, ...compiledId])); + } + + switchToImmediate() { + return this.#graphCall(() => this.#enqueue(OP.SWITCH_GRAPH, [0, 0, 0])); + } + + dispose() { this.#fail("DISPOSED"); } +} + +function validateCompiledId(compiledId) { + if (!Array.isArray(compiledId) || compiledId.length !== 2 || compiledId.some(word => !Number.isInteger(word) || word < 0 || word > 0xffffffff)) throw new TypeError("compiledId must contain exactly two uint32 values"); +} + +function floatWords(matrix) { + if (!matrix || matrix.length !== 16) throw new TypeError("transform must contain 16 numbers"); + return [...new Int32Array(new Float32Array(matrix).buffer)]; +} + +class Mesh { + #setVisible; #createInstance; + constructor(token, setVisible, createInstance) { + if (token !== HANDLE_TOKEN) throw new TypeError("Mesh cannot be constructed directly"); + this.#setVisible = setVisible; + this.#createInstance = createInstance; + } + setVisible(visible) { return this.#setVisible(visible); } + createInstance(transform, visible = true) { return this.#createInstance(transform, visible); } +} + +class Instance { + #setVisible; #setTransform; #destroy; #dead = false; + constructor(token, setVisible, setTransform, destroy) { + if (token !== HANDLE_TOKEN) throw new TypeError("Instance cannot be constructed directly"); + this.#setVisible = setVisible; + this.#setTransform = setTransform; + this.#destroy = destroy; + } + #live() { if (this.#dead) throw new RendererError("STALE_HANDLE"); } + setVisible(visible) { this.#live(); return this.#setVisible(visible); } + setTransform(transform) { this.#live(); return this.#setTransform(transform); } + async destroy() { this.#live(); await this.#destroy(); this.#dead = true; } +} diff --git a/tests/demo-loadouts.test.js b/tests/demo-loadouts.test.js new file mode 100644 index 0000000..8408e52 --- /dev/null +++ b/tests/demo-loadouts.test.js @@ -0,0 +1,10 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createCubeGeometry, createUvSphereGeometry, encodeGeometryGlb, isGitLfsPointer, loadDemoLoadout, LoadoutError } from "../static/demo-loadouts.js"; + +function parseGlb(buffer){const view=new DataView(buffer);assert.equal(view.getUint32(0,true),0x46546c67);assert.equal(view.getUint32(4,true),2);assert.equal(view.getUint32(8,true),buffer.byteLength);const length=view.getUint32(12,true);assert.equal(view.getUint32(16,true),0x4e4f534a);const json=JSON.parse(new TextDecoder().decode(new Uint8Array(buffer,20,length)).trim());const bin=20+length;assert.equal(view.getUint32(bin+4,true),0x004e4942);assert.equal(view.getUint32(bin,true),json.buffers[0].byteLength);return json;} +test("procedural cube and sphere have complete indexed vertex streams",()=>{const cube=createCubeGeometry(),sphere=createUvSphereGeometry();assert.deepEqual([cube.positions.length/3,cube.indices.length],[24,36]);assert.ok(sphere.positions.length/3>300);for(const geometry of [cube,sphere]){assert.equal(geometry.normals.length,geometry.positions.length);assert.equal(geometry.texcoords.length,geometry.positions.length/3*2);assert.ok(geometry.indices.every(i=>i>=0&&i{const g=createCubeGeometry();for(let i=0;ig.positions.slice(id*3,id*3+3)),a=p[1].map((v,j)=>v-p[0][j]),b=p[2].map((v,j)=>v-p[0][j]),cross=[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]],normal=g.normals.slice(ids[0]*3,ids[0]*3+3);assert.ok(cross.reduce((sum,v,j)=>sum+v*normal[j],0)>0)}}); +test("loadouts return fresh deterministic GLBs with one mesh and nine nodes",async()=>{for(const name of ["cubes","spheres"]){const a=await loadDemoLoadout(name),b=await loadDemoLoadout(name);assert.notStrictEqual(a,b);assert.deepEqual(new Uint8Array(a),new Uint8Array(b));const json=parseGlb(a);assert.equal(json.meshes.length,1);assert.equal(json.nodes.length,9);assert.ok(json.nodes.every(n=>n.mesh===0));assert.deepEqual(json.meshes[0].primitives[0].attributes,{POSITION:0,NORMAL:1,TEXCOORD_0:2});assert.equal(json.accessors[3].componentType,5125);}}); +test("LFS pointers and HTTP failures are explicit and stable",async()=>{const pointer=new TextEncoder().encode("version https://git-lfs.github.com/spec/v1\noid sha256:abc\n").buffer;assert.equal(isGitLfsPointer(pointer),true);await assert.rejects(loadDemoLoadout("manor",{fetchImpl:async()=>({ok:true,arrayBuffer:async()=>pointer})}),e=>e instanceof LoadoutError&&e.code==="LOADOUT_LFS_POINTER");await assert.rejects(loadDemoLoadout("sponza",{fetchImpl:async()=>({ok:false,status:404})}),e=>e.code==="LOADOUT_HTTP"&&/404/.test(e.message));}); +test("GLB encoder rejects non-finite and out-of-range geometry",()=>{const valid=createCubeGeometry();assert.throws(()=>encodeGeometryGlb({...valid,positions:[...valid.positions.slice(0,-1),NaN]}),/Invalid/);assert.throws(()=>encodeGeometryGlb({...valid,indices:[...valid.indices,0,1,999]}),/Invalid/)}); diff --git a/tests/render-graph-authoring.test.js b/tests/render-graph-authoring.test.js new file mode 100644 index 0000000..1b7b7db --- /dev/null +++ b/tests/render-graph-authoring.test.js @@ -0,0 +1,15 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { adaptFxNodeSnapshot, AuthoringGraphError } from "../static/render-graph/adapter.js"; +import { AuthoringController } from "../static/render-graph/authoring-controller.js"; + +const sockets={surface_color:[["surface","surface:surface","output","surface"]],depth32:[["depth","depth:depth","output","depth"]],scene_forward:[["color","forward:color","input","surface"],["depth","forward:depth","input","depth"],["result","forward:result","output","surface"]],present:[["surface","present:surface","input","surface"]]}; +function fixture(){const nodes=Object.entries(sockets).map(([typeId,list],i)=>({id:["surface","depth","forward","present"][i],typeId,typeVersion:1,known:true,muted:false,parameters:typeId==="scene_forward"?{clearColor:{kind:"color",value:[.1,.2,.3,1]},clearDepth:{kind:"number",value:.5}}:{},sockets:list.map(([key,id,direction,dataType])=>({key,id,direction,dataType}))}));return {version:4,graphId:"demo_forward",catalogVersion:1,nodes,links:[{id:"l1",fromNodeId:"surface",fromSocketId:"surface:surface",toNodeId:"forward",toSocketId:"forward:color",muted:false},{id:"l2",fromNodeId:"depth",fromSocketId:"depth:depth",toNodeId:"forward",toSocketId:"forward:depth",muted:false},{id:"l3",fromNodeId:"forward",fromSocketId:"forward:result",toNodeId:"present",toSocketId:"present:surface",muted:false}],metadata:{layout:"ignored"}}} +const rejects=(mutate,code)=>{const x=structuredClone(fixture());mutate(x);assert.throws(()=>adaptFxNodeSnapshot(x),e=>e instanceof AuthoringGraphError&&e.code===code)}; +test("adapter emits exact deterministic V1 without fxnode state",()=>{const a=adaptFxNodeSnapshot(fixture(),7),b=fixture();b.nodes.reverse();b.links.reverse();assert.deepEqual(adaptFxNodeSnapshot(b,7),a);assert.equal(a.revision,7);assert.deepEqual(a.passes[0].writes[0].access.load.value,[.1,.2,.3,1]);for(const token of ["position","socketId","known","fxnode"])assert.equal(JSON.stringify(a).includes(token),false)}); +test("adapter rejects node catalog, identity, sockets, links, topology and parameters",()=>{ + rejects(x=>x.nodes[0].known=false,"AUTHORING_NODE_UNKNOWN");rejects(x=>delete x.nodes[0].muted,"AUTHORING_NODE_MUTED");rejects(x=>x.nodes[0].muted=true,"AUTHORING_NODE_MUTED");rejects(x=>x.nodes[0].typeVersion=2,"AUTHORING_NODE_VERSION");rejects(x=>x.nodes[0].typeId="other","AUTHORING_NODE_TYPE");rejects(x=>x.nodes[0].id="bad id","AUTHORING_ID");rejects(x=>x.nodes[1].id=x.nodes[0].id,"AUTHORING_ID_DUPLICATE");rejects(x=>x.nodes[0].sockets[0].direction="input","AUTHORING_SOCKET");rejects(x=>x.links[0].muted=true,"AUTHORING_TOPOLOGY");rejects(x=>x.links.pop(),"AUTHORING_TOPOLOGY");rejects(x=>x.nodes[2].parameters.clearDepth.value=Infinity,"AUTHORING_PARAMETER"); +}); +test("adapter carries scene mute and bounds revisions",()=>{const x=fixture();x.nodes[2].muted=true;assert.equal(adaptFxNodeSnapshot(x).passes[0].state,"disabled");assert.throws(()=>adaptFxNodeSnapshot(fixture(),0x100000000),e=>e.code==="AUTHORING_REVISION")}); +test("controller orders compile/switch, revisions and shares one in-flight apply",async()=>{let release;const gate=new Promise(r=>release=r),calls=[];const renderer={async compileGraph(ir){calls.push(`compile:${ir.revision}`);await gate;return{compiledId:[0,1]}},async switchCompiledGraph(id){calls.push(`switch:${id}`)}};const c=new AuthoringController({renderer,getState:async()=>({})});const adapt=(_,r)=>({revision:r}),a=c.apply(adapt);assert.strictEqual(c.apply(adapt),a);c.markDirty();release();await a;assert.deepEqual(calls,["compile:1","switch:0,1"]);assert.equal(c.revision,1);assert.equal(c.dirty,true)}); +test("controller reserves revisions across compile and switch failures",async()=>{let failure="compile",revisions=[];const c=new AuthoringController({getState:async()=>({}),renderer:{compileGraph:async ir=>{revisions.push(ir.revision);if(failure==="compile")throw Error("no");return{compiledId:[0,1]}},switchCompiledGraph:async()=>{if(failure==="switch")throw Error("no")}}});await assert.rejects(c.apply((_,r)=>({revision:r})));failure=null;await c.apply((_,r)=>({revision:r}));failure="switch";await assert.rejects(c.apply((_,r)=>({revision:r})));failure=null;await c.apply((_,r)=>({revision:r}));assert.deepEqual(revisions,[1,2,3,4]);assert.equal(c.revision,4)}); diff --git a/tests/render-graph-presets.test.js b/tests/render-graph-presets.test.js new file mode 100644 index 0000000..39780e4 --- /dev/null +++ b/tests/render-graph-presets.test.js @@ -0,0 +1,4 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ember, midnight, renderGraphPresets } from "../static/render-graph/presets.js"; +test("Phase 8 presets are unique activatable V1 scene-forward graphs",()=>{assert.deepEqual(Object.keys(renderGraphPresets),["midnight","ember"]);assert.deepEqual([midnight.graphId,ember.graphId],["preset_midnight","preset_ember"]);assert.notDeepEqual(midnight.passes[0].writes[0].access.load.value,ember.passes[0].writes[0].access.load.value);for(const graph of [midnight,ember]){assert.equal(graph.schemaVersion,1);assert.equal(graph.revision,1);assert.equal(graph.passes.length,1);assert.equal(graph.passes[0].state,"enabled");assert.deepEqual(graph.passes[0].executor,{key:"scene_forward",version:1});assert.equal(graph.outputs[0].name,"present");}}); diff --git a/tests/renderer-client.test.js b/tests/renderer-client.test.js new file mode 100644 index 0000000..b08aba0 --- /dev/null +++ b/tests/renderer-client.test.js @@ -0,0 +1,135 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as rendererModule from "../static/renderer-client.js"; +const { RendererClient, RendererError } = rendererModule; + +class WorkerMock extends EventTarget { + messages=[]; transfers=[]; terminated=false; + postMessage(message, transfer=[]) { this.messages.push(message); this.transfers.push(transfer); } + terminate(){this.terminated=true;} + reply(data) { this.dispatchEvent(new MessageEvent("message", { data })); } +} +function fixture() { + const memory = new WebAssembly.Memory({initial:2, maximum:4, shared:true}); + const header = new Int32Array(memory.buffer, 0, 16); + header.set([0x4e574159,1,1024,24,0,0]); + const worker = new WorkerMock(); + const bridge = {memory,ringPtr:0,worker,freed:false,free(){this.freed=true;}}; + const client = new RendererClient(bridge); + return {memory,header,worker,bridge,client}; +} +async function imported(f) { + const loading=f.client.importGlb(new ArrayBuffer(8)); + f.worker.reply({type:"payload-ready",id:1}); + await Promise.resolve(); + f.worker.reply({type:"reply",request:1,ok:true,result:{meshes:[[7,3]]}}); + return (await loading)[0]; +} +test("replaceSceneGlb is opcode 1 and importGlb remains an alias", async()=>{ + for(const method of ["replaceSceneGlb","importGlb"]){const f=fixture(),pending=f.client[method](new ArrayBuffer(8));f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();assert.equal(new Int32Array(f.memory.buffer,64,24)[1],1);f.worker.reply({type:"reply",request:1,ok:true,result:{meshes:[]}});assert.deepEqual(await pending,[]);} +}); +test("scene replacement carries the framing mode in opcode 1",async()=>{const f=fixture(),pending=f.client.replaceSceneGlb(new ArrayBuffer(8),{framing:"interior"});f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();assert.deepEqual([...new Int32Array(f.memory.buffer,64,24).slice(1,5)],[1,1,1,1]);f.worker.reply({type:"reply",request:1,ok:true,result:{meshes:[]}});await pending;await assert.rejects(f.client.replaceSceneGlb(new ArrayBuffer(8),{framing:"bad"}),TypeError)}); +test("writes tagged fixed-slot protocol and resolves reply", async () => { + const f=fixture(); const mesh=await imported(f); + const pending=mesh.setVisible(true); + const {memory,header,worker}=f; + assert.equal(Atomics.load(header,5),2); + const slot=new Int32Array(memory.buffer,64+96,24); + assert.deepEqual([...slot.slice(0,6)],[1,2,2,7,3,1]); + worker.reply({type:"reply",request:2,ok:true,code:"OK"}); await pending; +}); +test("maps stable errors and gates destroyed instances", async () => { + const f=fixture(), mesh=await imported(f); const {worker}=f; + const creating=mesh.createInstance(new Float32Array([1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]),false); + worker.reply({type:"reply",request:2,ok:true,result:[4,2]}); const instance=await creating; + const destroying=instance.destroy(); worker.reply({type:"reply",request:3,ok:true}); await destroying; + assert.throws(()=>instance.setVisible(true), error=>error instanceof RendererError&&error.code==="STALE_HANDLE"); +}); +test("rejects protocol mismatch", () => { + const {memory,worker}=fixture(); new Int32Array(memory.buffer)[1]=2; + assert.throws(()=>new RendererClient({memory,ringPtr:0,worker}), /PROTOCOL_MISMATCH/); +}); +test("pending reply exists before ring publication", async () => { + const f=fixture(), mesh=await imported(f); const {worker}=f; + const pending=mesh.setVisible(true); + worker.reply({type:"reply",request:2,ok:true}); + await pending; +}); +test("worker failures and dispose reject every pending operation", async () => { + const f=fixture(), mesh=await imported(f); const {worker,client,bridge}=f; + const a=mesh.setVisible(true), b=mesh.setVisible(false); + worker.dispatchEvent(new Event("error")); + await assert.rejects(a,/WORKER_ERROR/); await assert.rejects(b,/WORKER_ERROR/); + client.dispose(); assert.equal(worker.terminated,true); assert.equal(bridge.freed,true); +}); +test("import always releases staged payload when ring is full", async () => { + const {header,worker,client}=fixture(); Atomics.store(header,5,1024); + const loading=client.importGlb(new ArrayBuffer(8)); + worker.reply({type:"payload-ready",id:1}); + await assert.rejects(loading,/RING_FULL/); + assert.equal(worker.messages.at(-1).type,"payload-release"); +}); +test("does not export handle constructors or internal mutation methods", () => { + const {client}=fixture(); + assert.equal(rendererModule.Mesh,undefined); + assert.equal(rendererModule.Instance,undefined); + assert.equal(client._meshFlags,undefined); + assert.equal(client._createInstance,undefined); +}); +test("corrupt backlog closes and terminates the transport", async () => { + const f=fixture(); Atomics.store(f.header,5,1025); + const loading=f.client.importGlb(new ArrayBuffer(8)); + // Payload staging must first acknowledge before enqueue sees corruption. + f.worker.reply({type:"payload-ready",id:1}); + await assert.rejects(loading,/RING_CORRUPT/); + assert.equal(Atomics.load(f.header,6),1); + assert.equal(f.worker.terminated,true); +}); +test("import rejects immediately after disposal", async () => { + const f=fixture(); + f.client.dispose(); + await assert.rejects(f.client.importGlb(new ArrayBuffer(8)),/DISPOSED/); + assert.equal(f.worker.messages.length,0); +}); +test("import rejects when disposed during asynchronous source loading", async () => { + const f=fixture(); + const originalFetch=globalThis.fetch; + let finishFetch; + globalThis.fetch=()=>new Promise(resolve=>{finishFetch=resolve;}); + try { + const loading=f.client.importGlb("model.glb"); + f.client.dispose(); + finishFetch({arrayBuffer:async()=>new ArrayBuffer(8)}); + await assert.rejects(loading,/DISPOSED/); + assert.equal(f.worker.messages.length,0); + } finally { + globalThis.fetch=originalFetch; + } +}); + +test("compile transfers payload and waits for ready before opcode 7", async()=>{ + const f=fixture(), pending=f.client.compileGraph({schemaVersion:1}); + assert.equal(f.worker.transfers[0].length,1); assert.equal(Atomics.load(f.header,5),0); + f.worker.reply({type:"payload-ready",id:1}); await Promise.resolve(); + assert.equal(new Int32Array(f.memory.buffer,64,24)[1],7); + f.worker.reply({type:"reply",request:1,ok:true,result:{compiledId:[2,3]}}); + assert.deepEqual(await pending,{compiledId:[2,3]}); +}); +test("flat error reply preserves structured details", async()=>{ + const f=fixture(), pending=f.client.compileGraph({}); f.worker.reply({type:"payload-ready",id:1}); await Promise.resolve(); + f.worker.reply({type:"reply",request:1,ok:false,code:"GRAPH_INVALID_ID",details:{message:"bad",path:"graphId"}}); + await assert.rejects(pending,e=>e instanceof RendererError&&e.code==="GRAPH_INVALID_ID"&&e.details.path==="graphId"&&e.message==="bad"); +}); +test("compile releases payload after success", async()=>{const f=fixture(),p=f.client.compileGraph({});f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();f.worker.reply({type:"reply",request:1,ok:true,result:{}});await p;assert.equal(f.worker.messages.at(-1).type,"payload-release");}); +test("compile releases payload after backend error", async()=>{const f=fixture(),p=f.client.compileGraph({});f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();f.worker.reply({type:"reply",request:1,ok:false,code:"X",details:{message:"x"}});await assert.rejects(p);assert.equal(f.worker.messages.at(-1).type,"payload-release");}); +test("compile rejects circular and BigInt JSON", async()=>{const f=fixture(),x={};x.x=x;await assert.rejects(f.client.compileGraph(x),/circular/i);await assert.rejects(f.client.compileGraph({x:1n}),e=>e.code==="GRAPH_JSON_INVALID");}); +test("compile rejects oversized encoding", async()=>{const f=fixture();await assert.rejects(f.client.compileGraph({x:"x".repeat(1024*1024)}),e=>e.code==="GRAPH_PAYLOAD_TOO_LARGE");assert.equal(f.worker.messages.length,0);}); +test("drop graph emits opcode 8 and validates id", async()=>{const f=fixture(),p=f.client.dropCompiledGraph([9,4]);const slot=new Int32Array(f.memory.buffer,64,24);assert.deepEqual([...slot.slice(1,5)],[8,1,9,4]);f.worker.reply({type:"reply",request:1,ok:true});await p;assert.throws(()=>f.client.dropCompiledGraph([1]),TypeError);}); +test("ring-full graph compile releases staged payload", async()=>{const f=fixture();Atomics.store(f.header,5,1024);const p=f.client.compileGraph({});f.worker.reply({type:"payload-ready",id:1});await assert.rejects(p,e=>e.code==="RING_FULL");assert.equal(f.worker.messages.at(-1).type,"payload-release");}); +test("disposal while graph payload is pending releases and rejects", async()=>{const f=fixture(),p=f.client.compileGraph({});f.client.dispose();await assert.rejects(p,e=>e.code==="DISPOSED");assert.equal(f.worker.messages.at(-1).type,"payload-release");}); +test("payload transfer uses the exact encoded ArrayBuffer", async()=>{const f=fixture(),graph={schemaVersion:1};const expected=new TextEncoder().encode(JSON.stringify(graph));const p=f.client.compileGraph(graph);const transferred=f.worker.transfers[0][0];assert.strictEqual(f.worker.messages[0].buffer,transferred);assert.deepEqual(new Uint8Array(transferred),expected);f.client.dispose();await assert.rejects(p);}); +test("cycle error details are preserved exactly", async()=>{const f=fixture(),p=f.client.compileGraph({});f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();const details={message:"cycle",kind:"cycle",edges:[{from:"a",resource:{id:"r",version:0},to:"b"}]};f.worker.reply({type:"reply",request:1,ok:false,code:"GRAPH_CYCLE",details});await assert.rejects(p,e=>e.details===details&&e.details.edges[0].from==="a");}); +test("error without details leaves details undefined", async()=>{const f=fixture(),p=f.client.dropCompiledGraph([1,1]);f.worker.reply({type:"reply",request:1,ok:false,code:"STALE_GRAPH_ID"});await assert.rejects(p,e=>e instanceof RendererError&&e.details===undefined&&e.message==="STALE_GRAPH_ID");}); +test("switch methods emit exact opcode 9 words", async()=>{const f=fixture();const a=f.client.switchCompiledGraph([9,4]);assert.deepEqual([...new Int32Array(f.memory.buffer,64,24).slice(1,7)],[9,1,1,9,4,0]);f.worker.reply({type:"reply",request:1,ok:true});await a;await new Promise(queueMicrotask);const b=f.client.switchToImmediate();assert.deepEqual([...new Int32Array(f.memory.buffer,160,24).slice(1,7)],[9,2,0,0,0,0]);f.worker.reply({type:"reply",request:2,ok:true});await b;assert.throws(()=>f.client.switchCompiledGraph([0,0]),TypeError);}); +test("graph lifecycle FIFO recovers after failure", async()=>{const f=fixture();const a=f.client.switchCompiledGraph([1,1]),b=f.client.switchToImmediate();assert.equal(Atomics.load(f.header,5),1);f.worker.reply({type:"reply",request:1,ok:false,code:"X"});await assert.rejects(a);await new Promise(queueMicrotask);assert.equal(Atomics.load(f.header,5),2);f.worker.reply({type:"reply",request:2,ok:true});await b;}); +test("dispose rejects queued graph lifecycle calls", async()=>{const f=fixture();const a=f.client.switchCompiledGraph([1,1]),b=f.client.switchToImmediate();f.client.dispose();await assert.rejects(a,/DISPOSED/);await assert.rejects(b,/DISPOSED/);}); diff --git a/tests/snapshot-bvh.test.js b/tests/snapshot-bvh.test.js new file mode 100644 index 0000000..666f789 --- /dev/null +++ b/tests/snapshot-bvh.test.js @@ -0,0 +1,130 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { SnapshotReader, SnapshotProtocolError } from "../static/render-data-snapshot.js"; +import { DerivedBvh } from "../static/bvh-core.js"; +import { RendererClient } from "../static/renderer-client.js"; + +const align16 = value => (value + 15) & ~15; +const componentCounts = [1, 1, 1, 3, 3, 1, 1, 1, 1, 1, 16, 3, 3, 1]; +const scalarTypes = [1, 1, 1, 2, 2, 1, 1, 1, 1, 1, 2, 2, 2, 1]; + +function snapshotFixture({ instances = 1 } = {}) { + const memory = new WebAssembly.Memory({ initial: 4, maximum: 8, shared: true }); + const words = new Uint32Array(memory.buffer); + const control = new Int32Array(memory.buffer, 0, 64); + const ptr = 256; + const counts = [1, 1, 1, 1, 1, ...Array(9).fill(instances)]; + const offsets = []; + let cursor = 512; + for (let i = 0; i < 14; i++) { + offsets.push(cursor); + cursor = align16(cursor + counts[i] * componentCounts[i] * 4); + } + control.set([0x504e5359, 1, 256, 3, 64, 1, 1, 2, 1, 0, 7, 0, 4, 1, 0, 0]); + control.set([2, 1, 1, ptr, cursor, 7, 0, 1, instances, 1, 64, 0, 0, 0, 0, 0], 16); + const blob = new Uint32Array(memory.buffer, ptr, cursor / 4); + blob.set([0x31534452, 1, 64, cursor, 1, 7, 0, 14, 64, 32, 1, instances, 0x01020304, 3, 0, 0]); + for (let i = 0; i < 14; i++) { + blob.set([i + 1, scalarTypes[i], offsets[i], counts[i], componentCounts[i], componentCounts[i] * 4, 4, 0], 16 + i * 8); + } + const stream = i => scalarTypes[i] === 2 + ? new Float32Array(memory.buffer, ptr + offsets[i], counts[i] * componentCounts[i]) + : new Uint32Array(memory.buffer, ptr + offsets[i], counts[i] * componentCounts[i]); + stream(0)[0] = 4; stream(1)[0] = 2; stream(2)[0] = 1; + stream(3).set([-1, -1, -1]); stream(4).set([1, 1, 1]); + for (let i = 0; i < instances; i++) { + stream(5)[i] = 10 + i; stream(6)[i] = 3; stream(7)[i] = 4; stream(8)[i] = 2; + stream(9)[i] = 1; stream(13)[i] = 1; + stream(11).set([i * 4, -1, -1], i * 3); stream(12).set([i * 4 + 2, 1, 1], i * 3); + } + return { memory, control, ptr, cursor }; +} + +test("snapshot reader validates and pins an exact ABI snapshot", () => { + const f = snapshotFixture(); + const reader = new SnapshotReader(f.memory, 0); + const result = reader.transaction(snapshot => ({ + epoch: snapshot.epoch, + slot: snapshot.streams.instanceSlot[0], + min: [...snapshot.streams.instanceWorldMin], + }), 1); + assert.deepEqual(result, { epoch: 1, slot: 10, min: [0, -1, -1] }); + assert.equal(Atomics.load(f.control, 16), 0); +}); + +test("snapshot reader rejects malformed control and WRITING slots", () => { + const bad = snapshotFixture(); + bad.control[0] = 0; + assert.throws(() => new SnapshotReader(bad.memory, 0), SnapshotProtocolError); + const writing = snapshotFixture(); + Atomics.store(writing.control, 16, 1); + assert.equal(new SnapshotReader(writing.memory, 0).transaction(() => true, 1), null); +}); + +test("snapshot reader refreshes memory after pinning", () => { + const f = snapshotFixture(); + const reader = new SnapshotReader(f.memory, 0); + const original = Atomics.compareExchange; + let grew = false; + Atomics.compareExchange = (...args) => { + const result = original(...args); + if (!grew && args[1] === 16 && result === 2) { grew = true; f.memory.grow(1); } + return result; + }; + try { + assert.equal(reader.transaction(snapshot => snapshot.instanceCount, 1), 1); + assert.equal(grew, true); + } finally { + Atomics.compareExchange = original; + } +}); + +function bvhSnapshot({ pickable = [1, 1], shifted = false } = {}) { + const count = 2; + return { instanceCount: count, streams: { + instanceSlot: Uint32Array.from([5, 6]), instanceGeneration: Uint32Array.from([1, 1]), + instanceMeshSlot: Uint32Array.from([2, 2]), instanceMeshGeneration: Uint32Array.from([4, 4]), + instancePickable: Uint32Array.from(pickable), + instanceWorldMin: Float32Array.from(shifted ? [10, -1, -1, 4, -1, -1] : [2, -1, -1, 4, -1, -1]), + instanceWorldMax: Float32Array.from(shifted ? [12, 1, 1, 6, 1, 1] : [3, 1, 1, 6, 1, 1]), + }}; +} + +test("BVH preserves topology for visibility/refit and reports world distances", () => { + const bvh = new DerivedBvh(); + bvh.update(bvhSnapshot()); + assert.equal(bvh.rebuilds, 1); + assert.equal(bvh.pick([0, 0, 0], [2, 0, 0], Infinity, 2)[0].distance, 2); + bvh.update(bvhSnapshot({ pickable: [0, 1], shifted: true })); + assert.equal(bvh.rebuilds, 1); + assert.equal(bvh.refits, 1); + assert.deepEqual(bvh.pick([0, 0, 0], [1, 0, 0], Infinity, 2).map(hit => hit.slot), [6]); +}); + +class WorkerMock extends EventTarget { + messages = []; terminated = false; + postMessage(message) { this.messages.push(message); } + terminate() { this.terminated = true; } + reply(data) { this.dispatchEvent(new MessageEvent("message", { data })); } +} + +test("renderer pick returns gated instances and exact epoch", async () => { + const scene = snapshotFixture(); + const ring = 8192; + const ringHeader = new Int32Array(scene.memory.buffer, ring, 16); + ringHeader.set([0x4e574159, 1, 1024, 24]); + const rendererWorker = new WorkerMock(), bvhWorker = new WorkerMock(); + const bridge = { memory: scene.memory, ringPtr: ring, worker: rendererWorker, workerFactory: () => bvhWorker, free() {} }; + const client = new RendererClient(bridge); + rendererWorker.reply({ type: "snapshot-init", controlPtr: 0, controlVersion: 1, schemaVersion: 1 }); + rendererWorker.reply({ type: "snapshot-published", epoch: 1 }); + const picking = client.pickRay([0, 0, 0], [1, 0, 0]); + const request = bvhWorker.messages.find(message => message.type === "pick"); + bvhWorker.reply({ type: "pick", request: request.request, epoch: 1, stale: false, hits: [{ slot: 10, generation: 3, distance: 2 }] }); + const result = await picking; + assert.equal(result.epoch, 1); + assert.equal(result.hits[0].distance, 2); + assert.equal(typeof result.hits[0].instance.setVisible, "function"); + client.dispose(); + assert.equal(bvhWorker.terminated, true); +}); diff --git a/vendor/fxnode/.agents/ORB_WORKFLOW.md b/vendor/fxnode/.agents/ORB_WORKFLOW.md new file mode 100644 index 0000000..9fb358e --- /dev/null +++ b/vendor/fxnode/.agents/ORB_WORKFLOW.md @@ -0,0 +1,122 @@ +# fxnode orb workflow + +This repository is a TypeScript node-editor library with a browser client and worker-owned authority. This guide is +the operating checklist for Amp orbs and future implementation threads. + +## Environment and services + +- No `.env` file or secrets are required for development or tests. +- `.agents/setup` installs the exact `package-lock.json` dependency tree, Chromium, Firefox, and their Linux runtime + dependencies. It then typechecks the repository and starts declared services. +- `.agents/resume` only reconciles declared services. Keep it fast and idempotent; do not install dependencies there. +- `.amp/services.yaml` declares the examples gallery. Use `amp orb services ensure` to start it and obtain its portal. + Inspect it with `amp orb service status examples` and `amp orb service logs examples`. +- Run `npm run examples` outside an orb when a local Vite server is sufficient. Vite uses `examples/` as its root. +- Generated `.amp/portals/` manifests and resume logs are local runtime state and must not be committed. + +## Architectural invariants + +Preserve these unless the user explicitly requests an architectural change: + +1. The main-thread root is a message-passing facade. Graph state, composition, history, commands, selection behavior, + and rendering authority live in the worker. +2. A root can be headless or own multiple attached views. Views share graph/history while camera, selection, pointer + lane, host requests, and render barriers remain view-local. +3. The worker lazily owns one `OffscreenCanvas` atlas and one retained worker 2D context. Views occupy atlas slots; + paint and cropped bitmap production are serialized. The final detach releases the atlas. +4. Main-thread HTML canvases necessarily have one presentation context each. Do not describe the design as one + context across the entire browser application. +5. RAF polling remains continuous for responsive shared-pointer-lane input, but layout, atlas paint, bitmap creation, + worker frame messages, and presentation remain dirty/on-demand. +6. `FxNodeView.setViewport()` is acknowledged and transactional. Runtime hosts await it before changing canvas backing + dimensions. Surface generations and dimensions reject stale or incoherent frames. +7. The core library does not register DOM listeners, observers, menus, modals, or file pickers. Those are application + policy. The example browser host demonstrates explicit cleanup and opt-in detach-on-disconnect behavior. +8. Composition definitions are collections installed through `setTheme`, `setHeaderStyles`, `composeSocket`, and + `composeNode`; initial graph layout is applied afterward with `setState`. +9. Durable save/load uses command journals plus save-time composition. `setState`/`getState` are portable layout/state + APIs, not the preferred persistence mechanism. +10. Commands distinguish durable graph mutations from transient UI previews and provide undo/redo semantics. + +The implementation history and Oracle-reviewed atlas/multi-view decisions are in the originating +[Amp thread](https://ampcode.com/threads/T-019f806f-11fe-74ef-a1f1-90933c1dc543). Treat the current code, tests, README, +and authored docs as authoritative when they differ from historical discussion. + +## Design and implementation process + +1. Read the nearest code, applicable `AGENTS.md`, public types, protocol validators, and focused tests before editing. +2. Prefer the smallest change at the existing ownership boundary. Remove obsolete paths rather than layering adapters. +3. For cross-layer architectural work, ask Oracle for a phased plan. Before each phase ask for concrete implementation + details; after each phase ask for a high-confidence blocker review. Address blockers before moving on. +4. Keep protocol boundaries exact and hostile-safe. A public type change normally requires protocol validation, client, + worker, declaration/type tests, docs, and real-worker browser coverage. +5. Keep DOM policy outside `src/browser/client.ts`. Extend application hosts or examples instead of secretly attaching + listeners in `attachView()`. +6. For rendering changes, reason explicitly about device/logical coordinates, DPR, atlas slot clipping, transforms, + `putImageData` ignoring clip/CTM, bitmap ownership/closing, frame ACKs, and context-loss generations. +7. Do not revert unrelated worktree changes. Do not commit generated `.amp` runtime files. Commit/push only when asked. + +## Verification ladder + +Choose the narrowest useful checks while iterating, then broaden according to blast radius. + +### Fast and focused + +```sh +npx prettier --write +npm run typecheck -- --pretty false +node --import tsx --test test/.test.ts +npx playwright test test/browser/.spec.ts --config playwright.config.ts --project chromium +``` + +For multi-view or worker lifecycle changes, run focused tests in both supported engines: + +```sh +npx playwright test \ + test/browser/client-multiview.spec.ts \ + test/browser/worker-multiview.spec.ts \ + --config playwright.config.ts +``` + +### Test groups + +```sh +npm test # Node unit, protocol, layout, persistence, atlas, and scheduler tests +npm run test:browser # Real browser behavior in Chromium and Firefox +npm run test:visual # Core Chromium screenshot baselines +npm run test:examples:visual # Documentation/example screenshots +npm run check:performance # Large-layout performance budgets +npm run check:docs # TypeDoc generation plus VitePress build +npm run check:readme # README structure and link checks +npm run check:package # Packed-package and worker-asset smoke test +npm run build # Vite library bundle and declaration build +``` + +### Full release gate + +Run this after shared contracts, architecture, rendering, examples, persistence, or release-facing docs change: + +```sh +npm run release:check +git diff --check +npm run format:check +``` + +`release:check` includes typecheck, all unit/browser/visual/example tests, fixture and reference checks, performance, +build, docs, README, composition, and package smoke verification. + +## Visual review policy + +- Never update screenshots merely to make a failure green. +- Inspect expected, actual, and diff images under `test-results/`. Confirm whether changes are intended and localized. +- Use `npm run test:visual:update` only for reviewed core baseline changes. +- Use `npm run test:examples:visual:update` only for reviewed example/documentation image changes. +- Re-run the corresponding non-update command after refreshing a baseline. +- Save one-off user-review screenshots under `.amp/in/artifacts/`; keep transient inspection images in `test-results/`. + +## Documentation expectations + +- README and VitePress learning docs explain user workflows and ownership boundaries. +- TypeDoc is the API contract; regenerate it with `npm run docs:api` after public type changes. +- Examples import library source through `@lib/`, never relative `../src` paths. +- Keep the examples gallery linked to every standalone application and relevant focused scene. diff --git a/vendor/fxnode/.agents/resume b/vendor/fxnode/.agents/resume new file mode 100755 index 0000000..adc03bd --- /dev/null +++ b/vendor/fxnode/.agents/resume @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +mkdir -p .amp +echo "[fxnode resume] ensuring declared orb services" +amp orb services ensure >.amp/resume-services.log 2>&1 +echo "[fxnode resume] ready" diff --git a/vendor/fxnode/.agents/setup b/vendor/fxnode/.agents/setup new file mode 100755 index 0000000..a90dc46 --- /dev/null +++ b/vendor/fxnode/.agents/setup @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "[fxnode setup] installing locked npm dependencies" +npm ci + +echo "[fxnode setup] installing Playwright browsers and Linux dependencies" +npx playwright install --with-deps chromium firefox + +echo "[fxnode setup] checking the TypeScript environment" +npm run typecheck -- --pretty false + +echo "[fxnode setup] ensuring declared orb services" +amp orb services ensure + +echo "[fxnode setup] complete; see .agents/ORB_WORKFLOW.md for development and verification guidance" diff --git a/vendor/fxnode/.amp/services.yaml b/vendor/fxnode/.amp/services.yaml new file mode 100644 index 0000000..e1b3a59 --- /dev/null +++ b/vendor/fxnode/.amp/services.yaml @@ -0,0 +1,6 @@ +services: + examples: + command: npm run examples -- --port "$PORT" + portal: + title: fxnode examples + description: Browse the interactive examples gallery and focused Blender-inspired test scenes. diff --git a/vendor/fxnode/.gitattributes b/vendor/fxnode/.gitattributes new file mode 100644 index 0000000..d6e2d2f --- /dev/null +++ b/vendor/fxnode/.gitattributes @@ -0,0 +1,3 @@ +* text=auto eol=lf +*.png binary +*.blend binary diff --git a/vendor/fxnode/.gitignore b/vendor/fxnode/.gitignore new file mode 100644 index 0000000..ccedd6a --- /dev/null +++ b/vendor/fxnode/.gitignore @@ -0,0 +1,16 @@ +node_modules/ +dist/ +docs/reference/generated/ +docs/.vitepress/cache/ +docs/.vitepress/dist/ +*.blend +*.blend1 +.DS_Store +test-results/ +playwright-report/ +blob-report/ +.env +.env.* +!.env.example +.amp/portals/ +.amp/resume-services.log diff --git a/vendor/fxnode/.prettierignore b/vendor/fxnode/.prettierignore new file mode 100644 index 0000000..431087d --- /dev/null +++ b/vendor/fxnode/.prettierignore @@ -0,0 +1,16 @@ +node_modules/ +dist/ +docs/reference/generated/ +test-results/ +playwright-report/ +blob-report/ +.amp/ + +# Canonical generated data is formatted by its generator and byte-checked. +examples/blender/initialLayout.json +examples/blender/**/initialLayout.json + +# Binary and captured reference assets. +**/*.png +**/*.blend +**/*.blend1 diff --git a/vendor/fxnode/.prettierrc b/vendor/fxnode/.prettierrc new file mode 100644 index 0000000..9048e17 --- /dev/null +++ b/vendor/fxnode/.prettierrc @@ -0,0 +1,14 @@ +{ + "arrowParens": "always", + "bracketSpacing": true, + "endOfLine": "lf", + "htmlWhitespaceSensitivity": "css", + "printWidth": 120, + "proseWrap": "preserve", + "quoteProps": "as-needed", + "semi": true, + "singleQuote": false, + "tabWidth": 2, + "trailingComma": "all", + "useTabs": false +} diff --git a/vendor/fxnode/AGENTS.md b/vendor/fxnode/AGENTS.md new file mode 100644 index 0000000..89cd201 --- /dev/null +++ b/vendor/fxnode/AGENTS.md @@ -0,0 +1,5 @@ +# fxnode agent guidance + +Read [`.agents/ORB_WORKFLOW.md`](.agents/ORB_WORKFLOW.md) before changing architecture, browser/worker boundaries, +examples, tests, or documentation. It records the repository's design invariants, phased review process, test ladder, +visual-baseline policy, and orb service workflow. diff --git a/vendor/fxnode/LICENSE b/vendor/fxnode/LICENSE new file mode 100644 index 0000000..4380123 --- /dev/null +++ b/vendor/fxnode/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 fxnode contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/fxnode/NOTICE.md b/vendor/fxnode/NOTICE.md new file mode 100644 index 0000000..027289b --- /dev/null +++ b/vendor/fxnode/NOTICE.md @@ -0,0 +1,7 @@ +# Reference notice + +Blender is free software from the Blender Foundation and contributors, licensed under GNU GPL. “Blender” is used here only to identify the researched application. This project is not affiliated with or endorsed by the Blender Foundation. + +`tools/blender/create-reference-fixtures.py` is project-authored fixture automation and is licensed under **GPL-3.0-or-later** because it executes through Blender's Python API. All other project-authored content is under the package license unless noted. + +No Blender source, bundled assets, icons, fonts, or Blender Manual screenshots are distributed here. Future files in `docs/research/blender-references/4.5.0/` must be whole-window screenshots captured by this project from the verified baseline binary; their hashes and capture metadata must be recorded in the manifest. diff --git a/vendor/fxnode/README.md b/vendor/fxnode/README.md new file mode 100644 index 0000000..159a1da --- /dev/null +++ b/vendor/fxnode/README.md @@ -0,0 +1,326 @@ +# fxnode + +> **Internal private 0.x prerelease.** This package has `private: true`; its API and data formats may change. + +![fxnode Color Balance node showing three grading wheels and image sockets](https://raw.githubusercontent.com/Heaust-ops/fxnode/main/examples/assets/color-balance.png) + +_The Color Balance example rendered by fxnode. It demonstrates editor presentation only; fxnode does not process pixels._ + +fxnode is a typed, worker-owned node editor for application-defined nodes, sockets, styles, resources, and migrations. +One root can run headlessly or attach multiple independent canvas views to the same graph and worker. The application +explicitly owns its DOM integration: canvas sizing, input forwarding, menus, file pickers, accessibility, lifecycle, +and cleanup. The worker owns graph state, validation, history, hit testing, layout, and frame generation; the browser +client presents transferred frames without keeping a shadow graph. + +fxnode edits and presents graphs. It does **not** execute or evaluate graphs and contains no image-processing engine. +It does not read or write Blender files, and makes no Blender compatibility, affiliation, endorsement, or parity claim. + +## Install and platform requirements + +```sh +npm install fxnode +``` + +That command is for a future registry release; this repository is currently private and not publishable. +Consumers need a modern browser with module workers, Canvas 2D, and worker-side `OffscreenCanvas`/`ImageBitmap` support. +See the committed [browser support matrix](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/guides/browser-support.md) and +[Content Security Policy guide](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/guides/csp.md) before integrating. + +## The simplest node + +Definitions are serializable, consumer-facing tuples. This complete definition matches the executable +[minimal definition](https://github.com/Heaust-ops/fxnode/blob/main/examples/minimal/definition.ts): + +```ts +import type { FxNodeDefinition, FxNodeSocketTypeDefinition, FxNodeStyleDefinition } from "fxnode"; + +export const numberSocket = [ + "number", + { title: "Number", color: "#a8a8a8", acceptsFrom: ["number"] }, +] as const satisfies readonly [string, FxNodeSocketTypeDefinition]; + +export const minimalStyles = { + value: { header: "#4c6ef5" }, +} as const satisfies Readonly>; + +export const valueNode = [ + "example.minimal.value", + { + version: 1, + title: "Number Value", + behavior: "standard", + style: "value", + parameters: { + value: { type: "number", default: { kind: "number", value: 42 }, step: 1 }, + }, + sockets: { + value: { + title: "Value", + direction: "output", + type: "number", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { kind: "parameter", parameter: "value" }, + { kind: "socket", socket: "value" }, + ], + muteBypass: [], + migrations: [], + }, +] as const satisfies readonly [string, FxNodeDefinition]; +``` + +Bootstrap the editor after creating an application-local host and theme: + +```ts +import { createFxNode } from "fxnode"; +import { prepareFxNodeBrowserHost } from "./browser-host.js"; +import { exampleTheme } from "./theme.js"; +import { minimalStyles, numberSocket, valueNode } from "./definition.js"; + +const canvas = document.querySelector("#graph")!; +const host = prepareFxNodeBrowserHost({ canvas }); +let cleaned = false; +let api: Awaited> | null = null; +let view: Awaited>["attachView"]>> | null = null; +function cleanup() { + window.removeEventListener("pagehide", cleanup); + cleaned = true; + const root = api; + api = null; + host.destroy(); + const destroyRoot = () => root?.destroy(); + if (view) void view.detach().then(destroyRoot, destroyRoot); + else destroyRoot(); + view = null; +} +window.addEventListener("pagehide", cleanup); +try { + const created = await createFxNode({ + applicationId: "fxnode.example.minimal", + applicationVersion: 1, + resources: {}, + }); + if (cleaned) created.destroy(); + else { + api = created; + await api.setTheme(exampleTheme); + await api.setHeaderStyles(minimalStyles); + await api.composeSocket(...numberSocket); + await api.composeNode(...valueNode); + await api.setState({ graphId: "minimal", catalogVersion: 1, nodes: [], links: [], metadata: {} }); + view = await api.attachView({ canvas, viewport: host.initialViewport }); + host.attach(api, view); + await view.addNode({ nodeId: "value", typeId: valueNode[0], viewPosition: { x: 360, y: 190 } }); + await view.whenRendered(); + } +} catch (error) { + cleanup(); + throw error; +} +``` + +`exampleTheme` and `prepareFxNodeBrowserHost` are application-local examples, not fxnode package exports. +![Minimal Number Value node rendered by fxnode](https://raw.githubusercontent.com/Heaust-ops/fxnode/main/examples/assets/minimal.png) + +Complete sources: [definition](https://github.com/Heaust-ops/fxnode/blob/main/examples/minimal/definition.ts), [bootstrap](https://github.com/Heaust-ops/fxnode/blob/main/examples/minimal/main.ts), and +[first-node tutorial](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/tutorials/first-node.md). + +## One graph, zero or many views + +`createFxNode()` creates the shared graph authority and starts one worker, but creates no canvas. This is valid for +headless browser workflows. Call `attachView()` whenever the application needs a presentation. Each view has its own +canvas, viewport, camera, selection, input stream, host requests, render barriers, and lifecycle; graph state, +composition, versions, events, persistence, and undo/redo remain shared on the root. + +All views render through one worker-owned atlas canvas and one 2D rendering context. Each HTML canvas keeps its own +presentation context. `FXNODE_VIEW_LIMITS.maxViews` is a resource bound, not a count of worker rendering contexts. + +```ts +const left = await api.attachView({ + canvas: leftCanvas, + viewport: leftViewport, + initialCamera: { center: { x: 480, y: -550 }, zoom: 0.5 }, +}); +const right = await api.attachView({ + canvas: rightCanvas, + viewport: rightViewport, + initialCamera: { center: { x: 2080, y: -400 }, zoom: 0.45 }, +}); + +left.feedInput(pointerInputFromLeftCanvas); +await left.addNode({ typeId: "fxnode.shader.noise-texture", viewPosition: { x: 400, y: 260 } }); +await Promise.all([left.whenRendered(), right.whenRendered()]); + +await Promise.all([left.detach(), right.detach()]); +api.destroy(); +``` + +View-local `addNode`, `removeSelected`, and `setSelectedMuted` use that view's camera and selection. Root-level +`dispatch`, `undo`, and `redo` are useful when no view context is required. A canvas can belong to only one live view. +The application must detach its view before reusing that canvas. + +![One shared graph shown through two independent fxnode canvases](https://raw.githubusercontent.com/Heaust-ops/fxnode/main/examples/assets/multi-view.png) + +The [multi-view example](https://github.com/Heaust-ops/fxnode/tree/main/examples/multi-view) uses a single DOM toolbar +to target whichever canvas was most recently activated. Its canvases forward pointer events only; menus, controls, +resize observation, and teardown remain application code. + +## Focused example: Color Balance + +The Color Balance example keeps strict local socket tuples and styles, then composes its larger node definition: + +```ts +import type { FxNode, FxNodeSocketTypeDefinition, FxNodeStyleDefinition } from "fxnode"; +import { colorBalanceNode } from "./color-balance.js"; +import { exampleTheme } from "./theme.js"; + +const floatSocket = [ + "float", + { title: "Float", color: "#a8a8a8", acceptsFrom: ["float"] }, +] as const satisfies readonly [string, FxNodeSocketTypeDefinition]; +const colorSocket = [ + "color", + { title: "Color", color: "#d7ca63", acceptsFrom: ["color"] }, +] as const satisfies readonly [string, FxNodeSocketTypeDefinition]; +const styles = { + compositorColor: { header: "#8c5cc4" }, +} as const satisfies Readonly>; + +async function installColorBalance(api: FxNode) { + await api.setTheme(exampleTheme); + await api.setHeaderStyles(styles); + await api.composeSocket(...floatSocket); + await api.composeSocket(...colorSocket); + await api.composeNode(...colorBalanceNode); +} +``` + +The node's UI schema includes a representative grading-wheels row: + +```ts +import type { FxNodeDefinition } from "fxnode"; + +const gradingWheelsRow = { + kind: "widget", + widget: "grading-wheels", + bindings: [ + { title: "Lift", scalar: "lift", color: "liftColor" }, + { title: "Gamma", scalar: "gamma", color: "gammaColor" }, + { title: "Gain", scalar: "gain", color: "gainColor" }, + ], + visibleWhen: { parameter: "mode", equals: "Lift/Gamma/Gain" }, +} satisfies FxNodeDefinition["ui"][number]; +``` + +See the complete [bootstrap](https://github.com/Heaust-ops/fxnode/blob/main/examples/color-balance/main.ts), +[definition](https://github.com/Heaust-ops/fxnode/blob/main/examples/shared/nodes/color-balance.ts), and +[tutorial](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/tutorials/color-balance.md). This schema renders controls and connections; it does not evaluate +the graph or process pixels. + +## Application-owned browser host + +fxnode deliberately does not install global listeners or create application UI. A compact host should: + +- measure and DPR-size the canvas, observe resize, and call `setViewport`; +- translate pointer, wheel, keyboard, focus, and outside-pointer events into `feedInput`; +- manage pointer capture, focus, context menus, add-node UI, and authorized resource pickers; +- subscribe to bounded host projections/requests and provide an accessible DOM workflow; and +- remove listeners, subscriptions, observers, temporary DOM, and captures during teardown. + +The example host defaults to explicit lifecycle ownership. Its opt-in `lifecycle: "detach-on-disconnect"` policy +uses a document-level observer to tear down host policy and detach the view after a canvas remains disconnected for a +microtask; same-task moves are preserved. `host.destroy()` itself never detaches the view. + +The host receives both the shared `FxNode` root and one `FxNodeView`. Route graph subscriptions and composition calls +to the root; route canvas input, viewport updates, selection actions, host requests, resources, and rendering to the +view. For multiple canvases, create one host (or equivalent listener owner) per view. + +Use the [browser-host guide](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/guides/browser-host.md), +[interaction guide](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/guides/interactions.md), and the repository's +[compact host implementation](https://github.com/Heaust-ops/fxnode/blob/main/examples/shared/browser-host.ts). + +## State, persistence, and events + +The worker is authoritative. All calls are asynchronous unless their signature says otherwise. + +| API | Meaning | +| ----------------- | -------------------------------------------------------------------------------------------- | +| `getState()` | Detached, readonly current graph snapshot with graph version; does not mutate runtime state. | +| `setState(value)` | Validates and atomically replaces graph state; supports optimistic `expectedVersion`. | +| `save()` | Canonical current `GraphLayoutV2`; a compact graph export, not command history. | +| `getSaveData()` | Durable envelope: canonical baseline, applied journal, and effective save-time composition. | +| `load(value)` | Atomically validates/loads durable data; failure preserves current state. | + +Committed graph changes publish mutations **before** snapshots in version order. `onMutations` and `onSnapshots` +do not mutate state; each returns an unsubscribe function, and subscriber failures are isolated. Composition has a +separate revision domain and `onCompositionChanges`; a rebind that changes the graph publishes its composition event +before the corresponding mutation and snapshot. Read [graph state and events](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/concepts/graph-state-and-events.md) +and [state and persistence](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/concepts/state-and-persistence.md). + +## Headless use + +`fxnode/headless` exposes the same composition-bound document and command authority without browser or worker +resources. With the minimal tuples above: + +```ts +import { createFxNodeHeadless } from "fxnode/headless"; +import { exampleTheme } from "./theme.js"; + +const runtime = createFxNodeHeadless({ + schemaVersion: 2, + id: "fxnode.example.minimal", + version: 1, + compatibility: { wildcardInputTypes: [] }, + theme: exampleTheme, + socketTypes: { [numberSocket[0]]: numberSocket[1] }, + nodeStyles: minimalStyles, + resources: {}, + nodes: { [valueNode[0]]: valueNode[1] }, +} as const); + +const empty = runtime.emptyDocument("minimal"); +const document = { + ...empty, + nodes: { value: runtime.materializeNode("value", valueNode[0], { x: 360, y: 190 }) }, +}; +const issues = runtime.validateDocument(document); +if (issues.length) throw new Error(issues.map((issue) => issue.message).join("; ")); +const layout = runtime.save(document); +``` + +Headless operations are explicit and immutable; they still edit graph documents and never evaluate them. + +## Documentation + +- [Learn](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/index.md): tutorials, concepts, integration guides, and examples +- [Concepts](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/concepts/index.md): worker authority, composition, state, and persistence +- [Guides](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/guides/index.md): browser hosting, lifecycle, accessibility, CSP, and support +- [Tutorials](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/tutorials/index.md): minimal, Color Balance, and live composition +- [API reference landing page](https://github.com/Heaust-ops/fxnode/blob/main/docs/reference/index.md) +- [Research notes](https://github.com/Heaust-ops/fxnode/blob/main/docs/research/blender.md) and [architecture decision](https://github.com/Heaust-ops/fxnode/blob/main/docs/decisions/worker-transport-protobuf-benchmark.md) + +## Development + +Requires Node.js 20 or newer. + +```sh +npm install +npm run typecheck # TypeScript checks +npm test # Node test suite +npm run build # Vite library build + declarations +npm run examples # Example gallery development server +npm run test:examples:visual # Example screenshot checks +npm run docs:dev # Generated API + VitePress development server +npm run docs:build # Build generated API and documentation +npm run format:check # Prettier verification +npm run release:check # Full release gate (maintainers) +``` + +For contribution context, start with the executable [examples](https://github.com/Heaust-ops/fxnode/tree/main/examples) and committed +[Learn landing page](https://github.com/Heaust-ops/fxnode/blob/main/docs/learn/index.md). The MIT license is in [LICENSE](LICENSE); notices are in +[NOTICE.md](NOTICE.md). diff --git a/vendor/fxnode/UPSTREAM.md b/vendor/fxnode/UPSTREAM.md new file mode 100644 index 0000000..48d085e --- /dev/null +++ b/vendor/fxnode/UPSTREAM.md @@ -0,0 +1,25 @@ +# fxnode upstream provenance + +- Source: https://github.com/Heaust-ops/fxnode +- Commit: `3f8745717bf4574577be72e9769373475cc300c9` +- Tree: `fb8bf407854b68dad8c0249c9ba63c7fd0bd9332` +- Imported: 2026-07-26 +- License: MIT (see `LICENSE` and `NOTICE.md`) +- Local patches: none + +This directory is a committed source snapshot. Application adaptations belong outside +`vendor/fxnode`; do not modify vendored source for integration convenience. + +## Reimport + +```sh +git clone --filter=blob:none https://github.com/Heaust-ops/fxnode /tmp/fxnode +git -C /tmp/fxnode fetch --depth=1 origin 3f8745717bf4574577be72e9769373475cc300c9 +git -C /tmp/fxnode checkout --detach 3f8745717bf4574577be72e9769373475cc300c9 +rm -rf vendor/fxnode +mkdir -p vendor/fxnode +git -C /tmp/fxnode archive HEAD | tar -x -C vendor/fxnode +``` + +After importing, restore this file with the new commit/tree hashes and document any +unavoidable local patches explicitly. diff --git a/vendor/fxnode/docs/.vitepress/config.mts b/vendor/fxnode/docs/.vitepress/config.mts new file mode 100644 index 0000000..92a7a00 --- /dev/null +++ b/vendor/fxnode/docs/.vitepress/config.mts @@ -0,0 +1,66 @@ +import { defineConfig } from "vitepress"; +import generated from "../reference/generated/typedoc-sidebar.json" with { type: "json" }; + +const learn = [ + { text: "Learn", link: "/learn/" }, + { + text: "Tutorials", + collapsed: false, + items: [ + { text: "Overview", link: "/learn/tutorials/" }, + { text: "Your first node", link: "/learn/tutorials/first-node" }, + { text: "Color Balance", link: "/learn/tutorials/color-balance" }, + { text: "Live composition", link: "/learn/tutorials/live-composition" }, + ], + }, + { + text: "Concepts", + items: [ + { text: "Overview", link: "/learn/concepts/" }, + { text: "Worker authority", link: "/learn/concepts/worker-authority" }, + { text: "Composition", link: "/learn/concepts/composition" }, + { text: "Graph state and events", link: "/learn/concepts/graph-state-and-events" }, + { text: "State and persistence", link: "/learn/concepts/state-and-persistence" }, + ], + }, + { + text: "Guides", + items: [ + { text: "Overview", link: "/learn/guides/" }, + { text: "Browser host", link: "/learn/guides/browser-host" }, + { text: "Interactions", link: "/learn/guides/interactions" }, + { text: "Rendering and lifecycle", link: "/learn/guides/rendering-and-lifecycle" }, + { text: "Browser support", link: "/learn/guides/browser-support" }, + { text: "CSP", link: "/learn/guides/csp" }, + { text: "Accessibility", link: "/learn/guides/accessibility" }, + ], + }, + { text: "Examples", link: "/learn/examples/" }, +]; + +export default defineConfig({ + title: "fxnode", + description: "A typed, worker-owned node editor", + lastUpdated: true, + srcExclude: ["research/**", "decisions/**"], + ignoreDeadLinks: false, + transformPageData(pageData) { + if (pageData.relativePath.startsWith("reference/generated/")) + pageData.frontmatter = { ...pageData.frontmatter, editLink: false, lastUpdated: false }; + }, + themeConfig: { + nav: [ + { text: "Learn", link: "/learn/" }, + { text: "API Reference", link: "/reference/" }, + ], + sidebar: { "/learn/": learn, "/reference/": [{ text: "API Reference", link: "/reference/" }, ...generated] }, + search: { provider: "local" }, + outline: { level: [2, 3] }, + editLink: { + pattern: "https://github.com/Heaust-ops/fxnode/edit/main/docs/:path", + text: "Edit this page on GitHub", + }, + socialLinks: [{ icon: "github", link: "https://github.com/Heaust-ops/fxnode" }], + footer: { message: "Released under the MIT License.", copyright: "Copyright © fxnode contributors" }, + }, +}); diff --git a/vendor/fxnode/docs/decisions/worker-transport-protobuf-benchmark.md b/vendor/fxnode/docs/decisions/worker-transport-protobuf-benchmark.md new file mode 100644 index 0000000..23b7b6e --- /dev/null +++ b/vendor/fxnode/docs/decisions/worker-transport-protobuf-benchmark.md @@ -0,0 +1,55 @@ +# Phase 3 worker transport Protobuf benchmark + +**Status:** NO-GO (measured 2026-07-22). This decision does not authorize a production transport change. + +## Question and method + +The experiment compared the current browser structured-clone shape with a generated Protobuf-ES schema sent as a transferable `ArrayBuffer`. It ran in headless, real Chromium with a dedicated module worker. The timed Protobuf path included the main-thread object-to-schema adapter, encode, transferable `postMessage`, worker decode and schema-to-object adapter. The structured path included `postMessage` and structured clone. Both paths computed the same FNV-1a checksum over a recursively key-sorted serialization of the reconstructed synthetic payload in the worker. Every Protobuf send asserted immediate transfer detachment. + +Fixtures are deterministic multiplications of nodes, links, sockets, parameters, composition definitions and commands. Graph state, snapshot, node, socket, link and envelope fields are generated typed messages (not JSON bytes in Protobuf). Recursive `JsonValue` represents open values and composition/command/save variants. JSON byte counts below are UTF-8 diagnostics, not a measured transport. Pointer SAB, ImageBitmap frames and resource byte payloads are explicitly excluded. + +The orb-constrained run used 10 warmups and 50 samples per payload/path (rather than an exhaustive 3×120). Samples were sequential in one browser, structured clone always ran first, and the harness did not retain raw samples for paired analysis. It therefore cannot establish cross-machine confidence or formal results for every row. The correctly directed `state.set` failures are far outside timer resolution and independently make the wholesale migration fail its acceptance gates. + +The harness always encoded on the page and decoded in the worker. Consequently, response, snapshot, save-data, mutation, and host-projection rows were synthetic reverse-direction estimates rather than production-faithful worker-to-host measurements. Some of those envelopes also approximated, rather than passed, the current protocol validators. They corroborate the result but are not used as decisive evidence. The graph-heavy `state.set` requests did run in the production direction and used typed graph messages; those are the basis of the decision. + +## Environment + +- Orb: Linux 6.1.158+, x86_64, 2 reported logical CPUs +- Chromium: HeadlessChrome 149.0.7827.55, Linux; `crossOriginIsolated=false` +- Node 20.9.0, npm 10.9.8, Vite 6.1.0, Playwright 1.61.1 +- Exact tools: `@bufbuild/buf@1.72.0`, `@bufbuild/protobuf@2.13.0`, `@bufbuild/protoc-gen-es@2.13.0` +- The one-off benchmark harness and generated output were removed after review; this record preserves its result and limitations. + +## Results + +Times are milliseconds. Delta is `(structured p95 - protobuf p95) / structured p95`; positive favors Protobuf. + +| Payload | JSON bytes | clone p50 / p95 | protobuf p50 / p95 | main codec p95 | p95 delta | +| ---------------------------- | ---------: | --------------: | -----------------: | -------------: | --------: | +| tiny command | 143 | 0.00 / 0.20 | 0.30 / 0.70 | 0.20 | -250.0% | +| state.set medium | 129,422 | 3.70 / 5.40 | 28.00 / 43.30 | 31.30 | -701.9% | +| state.set large | 654,168 | 16.80 / 19.50 | 152.40 / 176.70 | 119.20 | -806.2% | +| snapshot medium | 129,413 | 3.40 / 6.00 | 26.60 / 45.20 | 27.80 | -653.3% | +| snapshot large | 654,159 | 17.40 / 20.40 | 149.70 / 183.70 | 113.70 | -800.5% | +| document.replaced large | 654,175 | 18.10 / 25.50 | 149.80 / 175.50 | 106.70 | -588.2% | +| save-data medium | 143,961 | 3.90 / 6.90 | 70.10 / 94.40 | 66.70 | -1,268.1% | +| save-data large | 624,879 | 17.30 / 21.50 | 310.50 / 383.00 | 243.60 | -1,681.4% | +| initial composition + layout | 559,668 | 14.40 / 17.70 | 279.80 / 322.00 | 204.80 | -1,719.2% | +| receipt | 152 | 0.10 / 0.20 | 0.20 / 0.30 | 0.20 | -50.0% | +| error | 139 | 0.10 / 0.20 | 0.20 / 0.30 | 0.10 | -50.0% | +| input | 179 | 0.10 / 0.20 | 0.20 / 0.30 | 0.20 | -50.0% | +| host projection | 10,084 | 0.30 / 0.30 | 3.50 / 9.00 | 3.10 | -2,900.0% | + +The harness build produced 23,989 gzip bytes (417 bytes for its worker entry and 23,572 bytes for its combined codec chunk). This was not a production baseline/delta comparison and did not account for host/worker runtime duplication, so it is only a non-decisive harness-size estimate. + +## Thresholds and decision + +The planned gates were: graph-heavy p95 improvement ≥20% in at least 3/4 of medium/large state and snapshot cases; no >5% regression on any other large payload; a small-payload deadband (do not decide on sub-millisecond noise); main-thread codec p95 ≤4 ms and no codec task >16.7 ms; worker gzip delta ≤30 KiB and total ≤45 KiB. A noisy threshold crossing would have been **INCONCLUSIVE**. Because only request-direction cases were production-faithful and the bundle comparison was approximate, not every planned gate was formally established. + +The result is still **NO-GO**. Both correctly directed medium and large `state.set` requests regressed by multiples, making the planned 3-of-4 graph gate mathematically impossible to pass. Their page-side adapter/encode latency also exceeded both codec limits by wide margins. Tiny-message differences and reverse-direction estimates are not used to strengthen the decision. The generic `JsonValue` portions do not prove every conceivable fully typed schema would be slow, but the typed graph result is sufficient to reject a wholesale migration now. + +Production retains structured clone for object-rich commands, composition, state, and events; the existing `SharedArrayBuffer` pointer lane and naturally binary transferables remain the appropriate narrow optimizations. A future packed typed-array format should be considered only for a measured numeric hot path, not as a generic graph protocol. + +## Repository outcome + +The reviewed harness was intentionally not retained: it approximated several current envelopes, tested only host-to-worker direction, and would have permanently added a generator/runtime dependency tree for a rejected design. This ADR retains the environment, measured table, decisive evidence, and limitations without turning the one-off experiment into a misleading supported benchmark. diff --git a/vendor/fxnode/docs/index.md b/vendor/fxnode/docs/index.md new file mode 100644 index 0000000..51d7c17 --- /dev/null +++ b/vendor/fxnode/docs/index.md @@ -0,0 +1,31 @@ +--- +layout: home + +hero: + name: fxnode + text: A worker-owned node editor + tagline: Compose a typed graph language, present it on Canvas, and persist it safely. + actions: + - theme: brand + text: Start learning + link: /learn/ + - theme: alt + text: API Reference + link: /reference/ + +features: + - title: Worker authority + details: Commands, validation, hit testing, layout, history, and rendering live behind one explicit boundary. + - title: Zero or many views + details: Run headlessly or attach independent canvases with view-local cameras, selections, input, and rendering to one shared graph. + - title: Application composition + details: Your application supplies node definitions, socket compatibility, theme, and resource policies. + - title: Durable documents + details: Canonical saves, bounded decoding, opaque unknown nodes, and declarative migrations protect user data. +--- + +::: warning Prerelease +fxnode is an internal `0.x` prerelease. APIs and persistence contracts can still change. +::: + +fxnode **presents and persists** node graphs. It does not evaluate or execute them. It is not Blender-compatible and makes no Blender feature or visual parity claim. diff --git a/vendor/fxnode/docs/learn/concepts/composition.md b/vendor/fxnode/docs/learn/concepts/composition.md new file mode 100644 index 0000000..8e12886 --- /dev/null +++ b/vendor/fxnode/docs/learn/concepts/composition.md @@ -0,0 +1,11 @@ +# Composition + +Composition is the application-defined graph language, distinct from graph state. It owns themes, header styles, directional socket compatibility, resources, node definitions, UI rows, defaults, bypasses, and migrations. Graph state is a document written in that language. Compatibility is checked from the destination socket's accepted source types; changing it can invalidate existing links. + +There is no built-in registry. Browser applications install definitions through `setTheme`, `setHeaderStyles`, `setCompatibility`, `composeSocket`, and `composeNode`, or atomically with `loadComposition`. Dependencies come first; `setState` comes last. Plain structured-clone-safe data crosses the worker boundary—no callbacks or classes. + +Updates compile, validate, rebind, and publish atomically and return a receipt (`status`, composition `revision`, graph version, and whether rebinding changed the graph). A rejected candidate changes nothing. Distinct definition IDs converge regardless of concurrent installation order once dependencies exist; updates to the same ID are ordered, and references still require their dependency to be installed first. + +Every committed change to a node definition resets definition-bound undo/redo history, even if no current instance uses that definition. Removing a node definition preserves its instances as opaque, read-only nodes. Removing a socket type is rejected while compatibility rules, another socket type, or a node definition references it; update or remove those dependents first. A valid composition rebind may remove graph links that have become incompatible. Reintroducing compatible definitions can promote opaque instances. A migration `rename-socket` rewrites both the node's socket data and every link endpoint that refers to it in the same transaction—there is no observable half-renamed graph. A semantic no-op emits nothing and advances neither revision nor graph version. + +Static/headless authoring can use `compileFxNodeComposition` and immutable helpers to retain literal ID types. Browser handles intentionally accept string IDs because their composition authority can change live. diff --git a/vendor/fxnode/docs/learn/concepts/graph-state-and-events.md b/vendor/fxnode/docs/learn/concepts/graph-state-and-events.md new file mode 100644 index 0000000..74e9df2 --- /dev/null +++ b/vendor/fxnode/docs/learn/concepts/graph-state-and-events.md @@ -0,0 +1,15 @@ +# Graph state and events + +Runtime graph state contains `graphId`, `catalogVersion`, nodes, links, and metadata. It is not the persistence envelope. The worker commits commands atomically and checks optional optimistic `expectedVersion` values. + +Committed graph changes emit mutations before snapshots, in version order. Subscribers are isolated and return an unsubscribe function. Composition changes use a separate revision domain and emit `onCompositionChanges`; when rebinding changes a graph, the composition event precedes the matching mutation and snapshot. + +Command and composition calls resolve with receipts only after authoritative validation and publication. Use their returned versions/revisions for the next compare-and-swap rather than inferring them from event timing. Structured validation/protocol failures reject without partial mutation; a `noop` receipt means no graph publication. + +| Domain | Meaning | Advances on | +| ---------------------- | -------------------------------------- | ---------------------------------- | +| Graph `version` | runtime document concurrency | graph-changing command/load/rebind | +| Composition `revision` | live authority concurrency | committed composition update | +| `catalogVersion` | bound composition version in documents | normalization/binding; persisted | + +Do not compare or substitute these values. Gesture previews remain worker-local until one commit. diff --git a/vendor/fxnode/docs/learn/concepts/index.md b/vendor/fxnode/docs/learn/concepts/index.md new file mode 100644 index 0000000..ad91914 --- /dev/null +++ b/vendor/fxnode/docs/learn/concepts/index.md @@ -0,0 +1,10 @@ +# Concepts + +For integrators deciding where application responsibilities end and fxnode authority begins. Read in this order: + +1. [Worker authority](./worker-authority): locate truth, work, and the asynchronous host boundary. +2. [Composition](./composition): model the application's graph language and live updates. +3. [Graph state and events](./graph-state-and-events): reason about documents, receipts, versions, and observation. +4. [State and persistence](./state-and-persistence): choose runtime replacement, canonical export, or replayable persistence. + +Afterward you should be able to choose the correct API and concurrency domain, predict publication order, and design durable loading without treating fxnode as a graph evaluator. fxnode is an editor and presenter, not an evaluator. diff --git a/vendor/fxnode/docs/learn/concepts/state-and-persistence.md b/vendor/fxnode/docs/learn/concepts/state-and-persistence.md new file mode 100644 index 0000000..e056080 --- /dev/null +++ b/vendor/fxnode/docs/learn/concepts/state-and-persistence.md @@ -0,0 +1,11 @@ +# State and persistence + +`getState()` and `setState()` exchange exact, process-local state for the currently installed composition. `setState()` is useful for bootstrap and controlled replacement, not historical imports. + +`save()` returns the canonical current `GraphLayoutV2`—a compact graph export, not history. For replayable durable storage use `getSaveData()`: its envelope records the canonical baseline, the applied command journal since that baseline, and the effective save-time composition used to establish compatibility. The baseline and journal are composed at save time to verify that they reproduce the exported current graph. + +`load()` accepts historical `GraphLayoutV1`, canonical `GraphLayoutV2`, or the save-data envelope. It stages decode, compatibility checks, declarative migrations, and replay before one atomic publication; structured issues identify paths/codes on failure, and rejected input leaves graph, history, and observable state unchanged. A successful graph change publishes the load mutation/snapshot as one commit. Loading an envelope installs its migrated baseline and command journal (including checkpoint placement); if the resulting graph equals current state, the load is a no-op but the validated journal/baseline is still installed for subsequent undo/redo and saves. + +Durable `GraphLayoutV2` uses `schemaVersion: 2`; its historical `catalogVersion` field stores composition version. Unknown types and future node versions round-trip as opaque read-only records. Declarative migration edges must form a complete valid route; failures preserve the original opaque payload. Canonical ordering and bounded admission make saves deterministic and hostile inputs reject safely. + +In short: **set/get state** for exact current runtime state; **save** for canonical `GraphLayoutV2`; **save data/load** for compatible persistence and replay. Selection, camera, hover, composition revision, and undo/redo internals are not durable graph fields. diff --git a/vendor/fxnode/docs/learn/concepts/worker-authority.md b/vendor/fxnode/docs/learn/concepts/worker-authority.md new file mode 100644 index 0000000..055dbe2 --- /dev/null +++ b/vendor/fxnode/docs/learn/concepts/worker-authority.md @@ -0,0 +1,13 @@ +# Worker authority + +The worker is authoritative for graph state, composition, validation, command history, hit testing, layout, gestures, and rendering. One root owns one worker and one shared graph, whether it has zero, one, or many attached views. The browser client keeps only bounded host projections. It does not keep a graph shadow. + +Graph state, composition, persistence, events, and history belong to the root. Canvas, viewport, camera, selection, gestures, rendering, host requests, and resource authorization belong to a view. A mutation from any view changes the shared graph and schedules every attached view, while cameras and selections remain independent. + +The worker serializes view painting and cropping through one atlas canvas and one 2D context. Each attached HTML canvas has its own presentation context; `maxViews` remains a resource bound rather than a rendering-context count. + +The application owns the DOM: canvas sizing, listeners, focus policy, menus, dialogs, measurement, and teardown. It turns DOM events into `feedInput()` DTOs. fxnode never registers document/window/canvas listeners or creates controls. + +Host requests cross an asynchronous trust boundary. For `resource-open`, the worker emits an immutable descriptor and one-use authorization. The application chooses UI and later calls `provideResource(authorization, data)`. The token is consumed only by a valid accepted submission: failed data validation does **not** consume it, so the application may correct the data and retry. Do not depend on the original pointer's browser activation; ask for a fresh user action when required. Authorizations become stale after relevant graph/composition changes, and transferred `ArrayBuffer`s detach. + +This boundary makes worker ordering definitive: await composition dependencies and treat terminal startup/protocol failures as terminal. diff --git a/vendor/fxnode/docs/learn/examples/index.md b/vendor/fxnode/docs/learn/examples/index.md new file mode 100644 index 0000000..42b30c2 --- /dev/null +++ b/vendor/fxnode/docs/learn/examples/index.md @@ -0,0 +1,33 @@ +# Examples + +The repository has five current experiences. Images below use the examples' existing captured assets—there are no documentation copies. + +## Minimal + +![A single Number Value node on the fxnode canvas](../../../examples/assets/minimal.png) + +_A minimal composition and one node. [Source](https://github.com/Heaust-ops/fxnode/blob/main/examples/minimal/main.ts)._ + +## Color Balance + +![A Color Balance node with lift, gamma, and gain grading wheels](../../../examples/assets/color-balance.png) + +_A focused custom-widget composition. [Source](https://github.com/Heaust-ops/fxnode/blob/main/examples/color-balance/main.ts)._ + +## Live composition + +![A live composition example with a parameter node and upgrade control](../../../examples/assets/live-composition.png) + +_Replacing a node definition and migrating its instance. [Source](https://github.com/Heaust-ops/fxnode/blob/main/examples/live-composition/main.ts)._ + +## Multi-view + +![One shared graph rendered in two independent canvas views](../../../examples/assets/multi-view.png) + +_One worker and graph with independent cameras and selections. The application-owned toolbar targets the active view, and the canvases forward pointer events only. [Source](https://github.com/Heaust-ops/fxnode/blob/main/examples/multi-view/main.ts)._ + +## Blender-shaped gallery + +The [larger gallery source](https://github.com/Heaust-ops/fxnode/blob/main/examples/blender/main.ts) exercises many node and interaction shapes; it is repository application code, not package authority. + +These examples present and persist editable graphs; they do **not evaluate** them. Blender-shaped fixtures and visual regression images do not establish Blender compatibility, behavioral parity, or pixel parity. diff --git a/vendor/fxnode/docs/learn/guides/accessibility.md b/vendor/fxnode/docs/learn/guides/accessibility.md new file mode 100644 index 0000000..0c8e037 --- /dev/null +++ b/vendor/fxnode/docs/learn/guides/accessibility.md @@ -0,0 +1,5 @@ +# Accessibility + +The editor is bitmap Canvas. Its nodes, sockets, labels, controls, and relationships are not semantic accessibility-tree objects. fxnode makes **no WCAG conformance claim** and must not be the only interface when assistive access is required. + +Applications should provide an equivalent semantic DOM workflow, status announcements, instructions, and controls. Keyboard support alone is not accessibility. An application-owned DOM add-node dialog can implement combobox/listbox semantics and focus restoration, but that does not make the Canvas editor accessible. diff --git a/vendor/fxnode/docs/learn/guides/browser-host.md b/vendor/fxnode/docs/learn/guides/browser-host.md new file mode 100644 index 0000000..bec916d --- /dev/null +++ b/vendor/fxnode/docs/learn/guides/browser-host.md @@ -0,0 +1,20 @@ +# Browser host + +`createFxNode` needs application identity/version, resource policies, and optionally a worker URL/history limit. It creates the shared root and worker without a canvas. `root.attachView()` needs a canvas, logical CSS-pixel viewport plus DPR, and optionally an initial camera. The **application owns the DOM and canvas dimensions**. Before attachment, measure the initial layout and set the backing dimensions. For a runtime resize, await `view.setViewport(next)` before updating the backing dimensions. Attach and remove your own listeners. + +fxnode creates one module worker per root and never creates a resize observer, menu, modal, or file picker. A root may have no views or multiple views. Convert pointer/keyboard/wheel events to each view's `feedInput()` values. The worker performs authoritative hit testing and may issue view-scoped `add-node-menu` or `resource-open` host requests; your DOM decides presentation and ordering. + +Use root methods for composition, state, persistence, subscriptions, and context-free commands. Use view methods for input, viewport changes, selection actions, resource responses, and render checkpoints. A canvas can have only one live view. On teardown, remove application listeners and observers first, detach each view, then destroy the root. + +The repository example host defaults to `lifecycle: "explicit"`, so `host.destroy()` removes only host-owned policy +and never detaches its view. Opt in with `lifecycle: "detach-on-disconnect"` for component-style examples. That mode +requires an initially connected canvas and `MutationObserver`; hosts share one observer per document. A removal is +confirmed in a microtask (so a same-task remove/reinsert or reparent survives), then host resources are synchronously +removed before `view.detach()` is requested. Moving the canvas to another document counts as disconnection; hiding it +or giving it zero layout size does not. + +Resize observations are coalesced while a viewport request is in flight. The host updates canvas backing dimensions +only after `setViewport()` acknowledges that request. A rejection preserves the prior backing store, reports through +`onError`, and a later observation can retry. + +Install composition before initial state. Imported/historical data belongs in `load()`, not `setState()`. See [state and persistence](../concepts/state-and-persistence). diff --git a/vendor/fxnode/docs/learn/guides/browser-support.md b/vendor/fxnode/docs/learn/guides/browser-support.md new file mode 100644 index 0000000..4cd5249 --- /dev/null +++ b/vendor/fxnode/docs/learn/guides/browser-support.md @@ -0,0 +1,7 @@ +# Browser support + +The certified functional matrix is Chromium and Firefox from Playwright 1.61.1 on desktop Linux. Chromium image goldens are regression tests, not cross-engine or Blender parity tests. WebKit, Safari-branded builds, and mobile are not certified. + +The main thread requires module `Worker`, `crypto.randomUUID`, and Canvas 2D. The worker requires `OffscreenCanvas`, a 2D context, cropped `createImageBitmap`, and `ImageBitmap.close`; there is no fallback. Named capability errors identify missing features. + +Cross-origin isolation enables an optional `SharedArrayBuffer` pointer lane per view. Without it, normal `postMessage` transport remains functional. Limits include 16 attached views, DPR 4, 8192 logical pixels per dimension, 16,777,216 device pixels per view, 67,108,864 device pixels across all views, and history 1,000. diff --git a/vendor/fxnode/docs/learn/guides/csp.md b/vendor/fxnode/docs/learn/guides/csp.md new file mode 100644 index 0000000..2ff3eac --- /dev/null +++ b/vendor/fxnode/docs/learn/guides/csp.md @@ -0,0 +1,5 @@ +# Content Security Policy + +fxnode starts a same-origin ES module worker with no blob, classic-worker, or main-thread fallback. Permit it with an appropriate `worker-src 'self'` and `script-src`, and serve JavaScript with the correct MIME type. Pass `workerUrl` if assets move independently. + +For optional shared-memory input use `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp`; embedded cross-origin resources must satisfy CORS or CORP. Otherwise fxnode automatically uses messages. Worker construction blocked synchronously reports `worker.construct`; a worker script/network/module load failure reports `worker.load`; failure to complete startup in time reports `worker.timeout`. None silently downgrade to main-thread execution. diff --git a/vendor/fxnode/docs/learn/guides/index.md b/vendor/fxnode/docs/learn/guides/index.md new file mode 100644 index 0000000..8868917 --- /dev/null +++ b/vendor/fxnode/docs/learn/guides/index.md @@ -0,0 +1,5 @@ +# Integration guides + +For browser/platform engineers turning an editor bootstrap into a production integration. Start with the [browser host](./browser-host), then wire [interactions](./interactions) and [rendering and lifecycle](./rendering-and-lifecycle). These establish canvas ownership, input forwarding, render checkpoints, and teardown. + +Before release, review [browser support](./browser-support), [CSP](./csp), and [accessibility](./accessibility), in that order. The outcome is a host with explicit capability fallbacks, deployable worker policy, accessible DOM-owned controls, and no leaked listeners or workers. diff --git a/vendor/fxnode/docs/learn/guides/interactions.md b/vendor/fxnode/docs/learn/guides/interactions.md new file mode 100644 index 0000000..59ae198 --- /dev/null +++ b/vendor/fxnode/docs/learn/guides/interactions.md @@ -0,0 +1,7 @@ +# Interactions + +The host translates DOM input; the worker owns gesture state and commits. Supported editor gestures include movement, box selection, resize, link creation/replacement, Ctrl-right-click cutting, Ctrl-Alt-right-click muting, `M` node mute, `H` collapse, `G` modal move, and undo/redo. + +Plain right-click on eligible empty canvas may request an add-node menu. The host owns its HTML, search, grouping, focus, dismissal, and calls `addNode`. Controls follow composition `ui` order. Scrubbing modifiers, text commit/cancel, and reset all become atomic commands. + +Do not derive behavior from projected pixels or retain a parallel graph. Subscribe to committed events when application UI needs updates. diff --git a/vendor/fxnode/docs/learn/guides/rendering-and-lifecycle.md b/vendor/fxnode/docs/learn/guides/rendering-and-lifecycle.md new file mode 100644 index 0000000..c72c220 --- /dev/null +++ b/vendor/fxnode/docs/learn/guides/rendering-and-lifecycle.md @@ -0,0 +1,7 @@ +# Rendering and lifecycle + +Each view's `whenRendered()` synchronizes a frame for that view. Attach another view when a second canvas needs an independent camera or selection over the same graph; graph mutations schedule all attached views. Set the initial backing dimensions before attachment. At runtime, await that view's `setViewport(next)` first, then update the host canvas backing dimensions. + +Keep every listener, observer, menu, and focus behavior in an application-owned cleanup object. On unmount/page teardown, remove those resources, await `view.detach()` for each view, then call `root.destroy()`. The example browser host keeps this explicit by default; its opt-in disconnect policy can perform host cleanup and request detachment when a connected canvas is removed. Destroying that host alone never detaches. View detachment is idempotent and rejects subsequent view work with `FxNodeViewDetachedError`. Root destruction is idempotent, detaches all remaining views, and makes pending and future work reject with `FxNodeDestroyedError`. Fatal startup/protocol failures also release resources and make future calls reject the stored terminal error. + +Do not use rendering completion as graph execution completion: fxnode never executes graphs. diff --git a/vendor/fxnode/docs/learn/index.md b/vendor/fxnode/docs/learn/index.md new file mode 100644 index 0000000..b3237da --- /dev/null +++ b/vendor/fxnode/docs/learn/index.md @@ -0,0 +1,23 @@ +# Learn fxnode + +Treat this as a trail map rather than a giant manual. + +## Start + +Build [your first node](/learn/tutorials/first-node), then tour [all examples](/learn/examples/). + +## Build + +Use the [browser-host guide](/learn/guides/browser-host) to connect your DOM and the [interaction guide](/learn/guides/interactions) to translate input. + +## Understand + +Read [worker authority](/learn/concepts/worker-authority), [composition](/learn/concepts/composition), and [state and persistence](/learn/concepts/state-and-persistence). + +## Integrate + +Check [browser support](/learn/guides/browser-support), [CSP](/learn/guides/csp), lifecycle, and [accessibility](/learn/guides/accessibility) before shipping. + +## Reference + +When you know the concept and need an exact signature, use the [API Reference](/reference/). diff --git a/vendor/fxnode/docs/learn/tutorials/color-balance.md b/vendor/fxnode/docs/learn/tutorials/color-balance.md new file mode 100644 index 0000000..2e1e80a --- /dev/null +++ b/vendor/fxnode/docs/learn/tutorials/color-balance.md @@ -0,0 +1,57 @@ +# Build a Color Balance editor + +## What you will build + +A focused editor with float/color socket types and the repository's grading-wheel Color Balance definition. + +## Prerequisites and checkpoint + +Complete [your first node](./first-node). Confirm the empty editor renders before adding the two socket definitions. + +## 1. Install dependencies + +After `createFxNode`, install theme and styles, then compose `float`, compose `color`, and compose the node. Make `setState` the final bootstrap state call; attach a view, add the node through that view, attach the host, and await the view's `whenRendered()`. + +```ts +import { createFxNode } from "fxnode"; + +const root = await createFxNode({ + applicationId: "color.balance", + applicationVersion: 1, + resources: {}, +}); +await root.setTheme(theme); +await root.setHeaderStyles(styles); +await root.composeSocket(...floatSocket); +await root.composeSocket(...colorSocket); +await root.composeNode(...colorBalanceNode); +await root.setState({ graphId: "color-balance", catalogVersion: 1, nodes: [], links: [], metadata: {} }); +const view = await root.attachView({ canvas, viewport }); +host.attach(root, view); +await view.addNode({ + nodeId: "color-balance", + typeId: colorBalanceNode[0], + viewPosition: { x: 300, y: 40 }, +}); +await view.whenRendered(); +``` + +This is an **excerpt**: `canvas`, `host`, `viewport`, theme, styles, sockets, and node definition are application-owned setup shown in the working source. + +**Checkpoint:** the Color Balance node and its three grading wheels are visible and interactive. + +### Why? + +Definitions refer to styles and sockets, so dependencies must exist first. The widget edits graph data; fxnode does not perform color correction or execute the graph. + +## 2. Attach, verify, and clean up + +Attachment starts DOM input forwarding; the view's `whenRendered()` establishes a visible-frame checkpoint. On teardown remove listeners, run `host.destroy()`, await `view.detach()`, and call `root.destroy()` (including startup failure and startup/teardown races). + +## Complete example + +See [`examples/color-balance/main.ts`](https://github.com/Heaust-ops/fxnode/blob/main/examples/color-balance/main.ts) and the shared [node definition](https://github.com/Heaust-ops/fxnode/blob/main/examples/shared/nodes/color-balance.ts). + +## Related concepts / relevant API / next + +Read [composition](../concepts/composition), then inspect [`FxNode.composeNode`](/reference/generated/fxnode/interfaces/FxNode#composenode) and continue to [live composition](./live-composition). diff --git a/vendor/fxnode/docs/learn/tutorials/first-node.md b/vendor/fxnode/docs/learn/tutorials/first-node.md new file mode 100644 index 0000000..baf5067 --- /dev/null +++ b/vendor/fxnode/docs/learn/tutorials/first-node.md @@ -0,0 +1,72 @@ +# Your first node + +## What you will build + +A Canvas editor containing one numeric value node, matching the repository's executable minimal example. + +## Prerequisites + +Install `fxnode`. Give the canvas non-zero CSS dimensions (the attributes also provide a useful fallback), then prepare a browser host that measures it and forwards input: + +```html + +``` + +The repository's [small host implementation](https://github.com/Heaust-ops/fxnode/blob/main/examples/shared/browser-host.ts) contains viewport, resize, and input wiring; see [browser host](../guides/browser-host) for its contract. + +## Checkpoint + +Your canvas has non-zero CSS dimensions and your host has produced `initialViewport`. + +## 1. Prepare application-owned definitions + +`theme`, `minimalStyles`, `numberSocket`, and `valueNode` below are **application-owned definitions**, not fxnode globals. The socket and node are exported as `[id, definition]` tuples so they can be passed directly to the composition methods. Define or import them before bootstrap; the executable [definition file](https://github.com/Heaust-ops/fxnode/blob/main/examples/minimal/definition.ts) is the compact reference. + +## 2. Bootstrap in dependency order + +Create the shared root first, then install composition dependencies in order: theme, header styles, sockets, nodes, and finally graph state. Attach the canvas view after that bootstrap. + +```ts +import { createFxNode } from "fxnode"; + +const root = await createFxNode({ + applicationId: "my.first.editor", + applicationVersion: 1, + resources: {}, +}); +await root.setTheme(theme); +await root.setHeaderStyles(minimalStyles); +await root.composeSocket(...numberSocket); +await root.composeNode(...valueNode); +await root.setState({ graphId: "first", catalogVersion: 1, nodes: [], links: [], metadata: {} }); +const view = await root.attachView({ canvas, viewport: host.initialViewport }); +host.attach(root, view); +await view.addNode({ nodeId: "value", typeId: valueNode[0], viewPosition: { x: 360, y: 190 } }); +await view.whenRendered(); +``` + +**Checkpoint:** a “Number Value” node is visible. The host is attached only after setup, and the view's `whenRendered()` confirms the committed node reached a frame. + +### Why this order? + +The worker validates every definition against current authority. `setState` is last so known nodes bind against the complete composition. Host attachment follows bootstrap so input cannot race setup. + +## 3. Clean up + +Remove application listeners, call `host.destroy()`, await `view.detach()`, then call `root.destroy()` on unmount or `pagehide`. Also destroy a late-created root if teardown wins a startup race. The complete source demonstrates that guard. + +## Complete example + +The complete executable source is [`examples/minimal/main.ts`](https://github.com/Heaust-ops/fxnode/blob/main/examples/minimal/main.ts), with its [`definition.ts`](https://github.com/Heaust-ops/fxnode/blob/main/examples/minimal/definition.ts). + +## Related concepts + +[Composition](../concepts/composition) and [worker authority](../concepts/worker-authority). + +## Relevant API + +[`createFxNode`](/reference/generated/fxnode/functions/createFxNode), [`FxNode`](/reference/generated/fxnode/interfaces/FxNode), and [`FxNodeView`](/reference/generated/fxnode/interfaces/FxNodeView). + +## Next + +Build a richer [Color Balance node](./color-balance). diff --git a/vendor/fxnode/docs/learn/tutorials/index.md b/vendor/fxnode/docs/learn/tutorials/index.md new file mode 100644 index 0000000..4d57bc4 --- /dev/null +++ b/vendor/fxnode/docs/learn/tutorials/index.md @@ -0,0 +1,9 @@ +# Tutorials + +For application developers integrating fxnode for the first time. Follow these in order: each tutorial builds on the previous one's host and composition vocabulary. You will finish able to bootstrap a visible editor, install a custom widget, and safely replace a live definition with optimistic concurrency. + +1. [Your first node](./first-node) — size and host a canvas, install definitions, render, and tear down. +2. [Color Balance](./color-balance) — add dependency-ordered socket types and a custom widget. +3. [Live composition](./live-composition) — migrate a visible instance using composition receipts. + +Each page marks excerpts, establishes ordered checkpoints, explains why each concern exists, and links to a complete executable source. diff --git a/vendor/fxnode/docs/learn/tutorials/live-composition.md b/vendor/fxnode/docs/learn/tutorials/live-composition.md new file mode 100644 index 0000000..ba092c7 --- /dev/null +++ b/vendor/fxnode/docs/learn/tutorials/live-composition.md @@ -0,0 +1,47 @@ +# Live composition + +## What you will build + +An editor that replaces a version-1 node definition with version 2 and migrates its graph instance atomically. + +## Prerequisites and checkpoint + +Understand [composition](../concepts/composition). Start with the v1 node visible and retain the receipt's `revision`. + +## 1. Acquire the v1 revision + +```ts +import type { FxNode, FxNodeView } from "fxnode"; + +const v1Receipt = await api.composeNode("example.live.parameter", liveNodeV1); +let revision = v1Receipt.revision; +``` + +This is an **excerpt**: await socket dependencies first, compose v1, call `setState`, attach a view, add its instance through that view, attach the host, and render. **Checkpoint:** v1 is visible and `revision` came from its receipt—not a guessed constant. + +## 2. Replace it using the v2 receipt + +```ts +async function upgrade(root: FxNode, view: FxNodeView) { + const v2Receipt = await root.composeNode("example.live.parameter", liveNodeV2, { + expectedRevision: revision, + }); + revision = v2Receipt.revision; + await view.whenRendered(); + return v2Receipt; +} +``` + +Invoke this on an explicit host action. **Checkpoint:** inspect `v2Receipt.status`, `graphChanged`, `graphVersion`, and updated `revision`; the migrated v2 node is visible. Clean up the button/page listeners, host, and API on teardown. + +### Why? + +Composition revision and graph version are separate concurrency domains. A committed rebind can advance both; a no-op advances neither. Compare-and-swap prevents two writers from assuming the same authority. + +## Complete example + +See the working [`examples/live-composition/main.ts`](https://github.com/Heaust-ops/fxnode/blob/main/examples/live-composition/main.ts) and its [definitions](https://github.com/Heaust-ops/fxnode/blob/main/examples/live-composition/definitions.ts). + +## Related concepts / relevant API / next + +Read [graph state and events](../concepts/graph-state-and-events) and [`CompositionReceipt`](/reference/generated/fxnode/type-aliases/CompositionReceipt), then plan [lifecycle cleanup](../guides/rendering-and-lifecycle). diff --git a/vendor/fxnode/docs/reference/index.md b/vendor/fxnode/docs/reference/index.md new file mode 100644 index 0000000..395527a --- /dev/null +++ b/vendor/fxnode/docs/reference/index.md @@ -0,0 +1,9 @@ +# API Reference + +fxnode exposes exactly three package entrypoints: + +1. [`fxnode`](/reference/generated/fxnode/) — browser client, shared graph types, and composition authoring. +2. [`fxnode/headless`](/reference/generated/fxnode/headless/) — composition-bound decoding and command execution without browser resources. +3. [`fxnode/widgets/color-ramp`](/reference/generated/fxnode/widgets/color-ramp/) — immutable color-ramp model and operations. + +The generated pages describe exact signatures. For intent, ordering, and ownership, return to [Learn](/learn/) or start with [your first node](/learn/tutorials/first-node). Integration guidance lives in [browser host](/learn/guides/browser-host), while [composition](/learn/concepts/composition) and [state and persistence](/learn/concepts/state-and-persistence) explain the two main data boundaries. diff --git a/vendor/fxnode/docs/research/blender-fixtures.md b/vendor/fxnode/docs/research/blender-fixtures.md new file mode 100644 index 0000000..4a2c71d --- /dev/null +++ b/vendor/fxnode/docs/research/blender-fixtures.md @@ -0,0 +1,13 @@ +# Capturing Blender references + +Use the official Linux Blender 4.5.0 binary whose archive SHA-256 is recorded in the manifest. A graphical X11 session (a disposable Xvfb session is suitable) and a whole-window capture utility are required. + +For each of the eight IDs: + +1. Start Blender at a deterministic 1440×900 window size with factory settings. +2. Run `blender --python tools/blender/create-reference-fixtures.py -- --fixture ` (add `--save /tmp/.blend` if desired). +3. Wait for redraw, keep the entire Blender window—including chrome—visible, and capture it to `docs/research/blender-references/4.5.0/.png`. For the hover fixture, move the pointer over the active node title before capture; this interaction cannot honestly be synthesized by Blender's data API. +4. Record UTC capture time, pixel dimensions, and `sha256sum` in `src/research/reference-manifest.ts`, change status to `captured`, and set capture method to `self-captured-blender-window`. +5. Run `npm run check:references:strict`. + +The script validates Blender 4.5.x, rebuilds the current file, creates material or geometry node trees, lays nodes out deterministically, configures editor zoom, and saves only when asked. Generated `.blend` files are ignored and are not reference artifacts. diff --git a/vendor/fxnode/docs/research/blender-references/4.5.0/README.md b/vendor/fxnode/docs/research/blender-references/4.5.0/README.md new file mode 100644 index 0000000..9dfc20a --- /dev/null +++ b/vendor/fxnode/docs/research/blender-references/4.5.0/README.md @@ -0,0 +1,3 @@ +# Blender 4.5.0 self-captures + +This directory intentionally contains no PNGs yet. Eight expected paths and their honest pending reasons are recorded in `src/research/reference-manifest.ts`. Do not substitute downloaded images or screenshots from the Blender Manual. diff --git a/vendor/fxnode/docs/research/blender.md b/vendor/fxnode/docs/research/blender.md new file mode 100644 index 0000000..dbcdf85 --- /dev/null +++ b/vendor/fxnode/docs/research/blender.md @@ -0,0 +1,20 @@ +# Blender 4.5 research freeze + +## Reproducible baseline + +- Blender version: **4.5.0** +- Source commit: [`8cb6b388974a817afedf1317ce26f0c75aa5f181`](https://projects.blender.org/blender/blender/src/commit/8cb6b388974a817afedf1317ce26f0c75aa5f181) +- Official binary SHA-256: `1188b95cc12321c770b631939f7c25a096910b6f884a990bf9c0f62d52b38aec` +- Manual snapshot: [`f72fe39427bf150242dd6cfdd94d902e535d2286`](https://projects.blender.org/blender/blender-manual/src/commit/f72fe39427bf150242dd6cfdd94d902e535d2286) + +Source citations are commit-pinned so later UI changes cannot silently alter the baseline. Relevant implementation entry points used only for behavioral study are [node drawing](https://projects.blender.org/blender/blender/src/commit/8cb6b388974a817afedf1317ce26f0c75aa5f181/source/blender/editors/space_node/node_draw.cc) and [node editor space](https://projects.blender.org/blender/blender/src/commit/8cb6b388974a817afedf1317ce26f0c75aa5f181/source/blender/editors/space_node/space_node.cc). + +## Clean-room observations + +The node editor presents a zoomable canvas. Nodes use title bars, body panels, labeled input/output sockets, links, selection/active emphasis, collapse controls, and inline controls where a socket is not linked. Frames group nodes visually; reroutes reshape link paths. Shader and geometry trees share interaction conventions while exposing domain-specific socket types and node content. + +fxnode derives only functional observations and independently measured self-captures. No Blender source code, assets, icons, fonts, manual prose, or manual screenshots are copied into this repository. Names required for compatibility and factual citations are retained. See `NOTICE.md` and the typed reference manifest. + +## Reference state + +The eight requested captures are represented in `src/research/reference-manifest.ts`. They remain truthfully `pending` because this orb had neither Blender nor Xvfb. The normal Phase 1 check validates this state; the strict check fails until genuine captures and metadata are committed. diff --git a/vendor/fxnode/docs/research/parity-survey.md b/vendor/fxnode/docs/research/parity-survey.md new file mode 100644 index 0000000..3cc2e1c --- /dev/null +++ b/vendor/fxnode/docs/research/parity-survey.md @@ -0,0 +1,60 @@ + + +# Blender parity survey + +## Current parity matrix + +| Area | Status | Evidence and limits | +| ------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Uniform ordinary controls | Implemented | Number, integer, enum, boolean, string, and vector fields share composition-ordered widget-unit rows. Colors use compact swatches backed by a worker-rendered Oklch/RGBA/HSV/hex picker. Compound widgets intentionally span multiple rows. | +| Field reset | Implemented | Backspace resets the focused or hovered editable field; linked/read-only fields are inert; reset is one-step undoable. | +| Color Ramp | Implemented structurally and behaviorally | Browser-tested stop selection/drag, add/remove, modes, interpolation, hue mode, popup color edits, flip, distribute, reset, cancellation, and undo. Eyedropper integration remains unavailable. | +| Noise Texture | Implemented structurally | All Blender 4.5 dimensions/type visibility combinations are exhaustively tested. fxnode does not evaluate noise. | +| Shader Image Texture | Implemented as editor intent | Local image selection, worker decoding/thumbnail rendering, projection/interpolation/extension, and Box-only Blend are browser-tested. Texture evaluation remains outside scope. | +| Compositor Image | Partial by design | Image-user fields are represented and browser-tested. Dynamic multilayer/view/pass sockets require host resource metadata. | +| Color Balance | Implemented structurally | Lift/Gamma/Gain, Offset/Power/Slope, and White Point layouts are browser-tested. “Master Color Grading” is an example label, not a Blender type. | +| Knife and link mute | Implemented | Ctrl-RMB and Ctrl-Alt-RMB freehand gestures are browser-tested as one atomic version/history entry. | +| Node mute | Implemented | `M` grays every ordinary known node; declared compatible pairs additionally receive derived red bypass curves. There is no graph evaluation. | +| Pixel-exact Blender appearance | Not established | Blender captures remain 0/8 pending. Playwright images are fxnode regression and structural-parity evidence only. | + +## Image Texture and Compositor Image + +References: Blender 4.5's [Image Texture manual](https://docs.blender.org/manual/en/4.5/render/shader_nodes/textures/image.html), [Compositor Image manual](https://docs.blender.org/manual/en/4.5/compositing/types/input/image.html), [`ShaderNodeTexImage` RNA](https://docs.blender.org/api/4.5/bpy.types.ShaderNodeTexImage.html), and [`CompositorNodeImage` RNA](https://docs.blender.org/api/4.5/bpy.types.CompositorNodeImage.html). + +These are intentionally distinct example composition definitions. Image Texture persists an image reference plus interpolation, projection (including Box-only Blend), extension, editor color-space intent, alpha mode, Vector input, and Color/Alpha outputs. Compositor Image persists its data-block reference and source; Movie/Sequence expose frame count, start, offset, cyclic, and auto-refresh. It has only static Image/Alpha/Z outputs. Resource controls synchronously open a host file chooser, transfer bytes into the worker, decode there, and render a bounded-cache thumbnail. The graph stores only a serializable local reference—not image bytes—so loading that graph in a fresh editor shows the filename/unavailable state until the user reopens the file. Neither node evaluates image pixels. Dynamic multilayer/render passes require a future host metadata provider and are not fabricated. + +## Color Balance / “Master Color Grading” example + +References: Blender 4.5's [Color Balance manual](https://docs.blender.org/manual/en/4.5/compositing/types/color/adjust/color_balance.html), [`CompositorNodeColorBalance` RNA](https://docs.blender.org/api/4.5/bpy.types.CompositorNodeColorBalance.html), and [commit-pinned Blender compositor node source](https://projects.blender.org/blender/blender/src/commit/8cb6b388974a817afedf1317ce26f0c75aa5f181/source/blender/nodes/composite/nodes/node_composite_colorbalance.cc). + +The example definition remains **Color Balance**. “Master Color Grading” is only the parity fixture's custom instance label; Blender has no core Master node. Lift/Gamma/Gain and Offset/Power/Slope use paired scalar/color rows. White Point exposes Input and Output temperature, tint, and color sections. Eyedroppers are visibly disabled placeholders pending a host bridge. This is presentation and persistence only; fxnode performs no compositing evaluation. + +## Color Ramp + +References: [Blender 4.5 Color Ramp manual](https://docs.blender.org/manual/en/4.5/modeling/geometry_nodes/utilities/color_ramp.html), [Blender 4.5 `ColorRamp` RNA](https://docs.blender.org/api/4.5/bpy.types.ColorRamp.html), and [commit-pinned Blender source](https://projects.blender.org/blender/blender/src/commit/8cb6b388974a817afedf1317ce26f0c75aa5f181/source/blender/editors/interface/templates/interface_template_color_ramp.cc). + +The V2 persisted value records RGB/HSV/HSL mode, all five interpolation modes, hue interpolation, and two to 32 sorted, identified RGBA stops. Legacy arrays receive stable index-derived IDs. Worker-owned interactions cover overlapping selection, sampled insertion, Blender-style midpoint insertion, removal, clamped/reordering movement, RGBA updates, flip, even distribution, descriptor reset, cancellation, and one-step undo. The compound row owns toolbar/menu, checker-gradient, handle, selector, position, and color bounds; stops are not sockets. Active-stop selection remains transient and is not serialized. + +Deferred Blender details: eyedropper, precise Blender cardinal/B-spline kernels and HSL conversion, context popup styling, and keyboard navigation within popup menus. + +## Noise Texture (Blender 4.5) + +References: [Blender 4.5 Noise Texture manual](https://docs.blender.org/manual/en/4.5/render/shader_nodes/textures/noise.html) and [Blender 4.5 ShaderNodeTexNoise RNA](https://docs.blender.org/api/4.5/bpy.types.ShaderNodeTexNoise.html). + +Dimensions and fractal Type drive V2 `in`/`equals` visibility expressions. Vector is shown for 2D/3D/4D, W for 1D/4D, Normalize only for fBM, Offset for Hybrid/Ridged/Hetero, and Gain for Hybrid/Ridged. Links and defaults remain in the document while their rows are hidden. + +## Link knife and mute + +`Ctrl`+RMB draws a captured freehand knife and atomically removes every crossed visible effective link. `Ctrl`+`Alt`+RMB uses the same gesture to toggle authored link mute instead; muted and reroute-propagated links are red and do not suppress input defaults. Escape, pointer cancellation, and blur cancel without history. Each completed gesture is one version/event/history entry. + +`M` toggles mute for selected ordinary known nodes, including generators. Every muted node receives a clipped neutral overlay. Math, Vector Math, Mix, Set Position, and Transform Geometry additionally show explicitly declared, type-compatible red bypass curves; generator and type-incompatible nodes intentionally have no fabricated bypass. Bypasses are layout-only; fxnode still does not evaluate graphs. Collapse state is immediate and undoable while the header chevron rotates between expanded/down and collapsed/right with a short worker-owned animation. + +### Shortcut table + +| Shortcut | Behavior | +| --------------------- | ------------------------------------- | +| RMB on empty canvas | Open searchable add-node dialog | +| `Ctrl`+RMB drag | Knife/remove crossed effective links | +| `Ctrl`+`Alt`+RMB drag | Toggle mute on crossed authored links | +| `M` | Toggle selected node mute | +| `Escape` | Cancel active gesture silently | diff --git a/vendor/fxnode/docs/research/performance-phase7.md b/vendor/fxnode/docs/research/performance-phase7.md new file mode 100644 index 0000000..a177b1e --- /dev/null +++ b/vendor/fxnode/docs/research/performance-phase7.md @@ -0,0 +1,24 @@ + + +# Phase 7 spatial performance + +The deterministic stress workload contains exactly **5,000 node rectangles** and +**10,000 link rectangles** (seed 7). Nodes are arranged on a sparse 100 × 50 +grid and links connect nearby nodes. The query viewport is 1200 × 800 logical +pixels at DPR 2. CI runs `npm run check:performance`; it checks exact totals and +requires at least 90% culling at candidate p95. Timing is reported, never gated. + +Measured in the Amp orb on 2026-07-20: Node 20.9.0, Playwright/Chromium 1.49.1, +generic orb CPU (Intel Xeon 2.60 GHz). Across 100 deterministic viewport queries: + +| metric | result | +| -------------- | ----------: | +| index build | 32.604 ms | +| candidates p50 | 72 / 15,000 | +| candidates p95 | 75 / 15,000 | +| query p50 | 0.295 ms | +| query p95 | 0.431 ms | +| p95 culling | 99.50% | + +These values are one-orb observations, not performance guarantees. Candidate +counts and the 90% ratio are deterministic; elapsed times vary by host load. diff --git a/vendor/fxnode/examples/assets/color-balance.png b/vendor/fxnode/examples/assets/color-balance.png new file mode 100644 index 0000000000000000000000000000000000000000..b8f03042d22792e59168bb1232a8262b714afc79 GIT binary patch literal 42797 zcmd43c|6qn|NmX*oKiV*N^%g6ge05@S?83btRZAi_I0ujW|&H-Y)MSAB{9h|_T4D5 z4hCaiW-P;Cm|+HEnB{tRKHu;6`rWSE_1E>s_51mw_f&50c`vWm^YwV%AJ6CO)qPU~ zzEdKnjvP6{ckk|Piz7#Vt2lDxm!T8C0RO`8DZYQ?$R9`U-M;xCJbPpA*dtMQ(Qn%f z3#zr_L?~tOU87da(U@y-i3=18WurrPqr?60S@&;UzpqgGqjE;wcURErjl{@%Y1fMt zd^w<7VU4p{pBXX;QZF6v_{0zP3HT1+3&qp^bG&r+*3Sl0xs#GVkH201Kj{R~J_NMRf%|HOWq@uBdy=P<_L7rTEv8yaH^VqRtnVGh>Q<zE#i)tR!DXT zz7f?zTPLI1{(X$uet#<42}|Aio$Ei2GM)OE*3;8dF?8R20{Ggw(1{#mlBn#z-{$w_ zkAtgfYLr+Zr0{Rj>xqGoIi+=o3Q;Pt1MP0NHbE}Aw{5_C~lH=4D! z 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 literal 0 HcmV?d00001 diff --git a/vendor/fxnode/examples/assets/live-composition.png b/vendor/fxnode/examples/assets/live-composition.png new file mode 100644 index 0000000000000000000000000000000000000000..cfa58870ec241901bc80bc6d292b94c7e15133aa GIT binary patch literal 32275 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 literal 0 HcmV?d00001 diff --git a/vendor/fxnode/examples/assets/minimal.png b/vendor/fxnode/examples/assets/minimal.png new file mode 100644 index 0000000000000000000000000000000000000000..a7dc175daf46cdfc3a62da30a6a5e7361c6e383d GIT binary patch literal 20084 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 z zSY+QIIG?hvKBYD_Qt+^6R5)*lCEDL*)jzVLq0z3pa9!1fKT7=hP;+9fmvYBvk7!Y> zq9bJCmv!PSyAerv6;%o6upV3&JHyH6C(wYniGasQt0o9mjqa!O&j^e}#mNe&s>H1# zr$3BeW{>O+m;x#*lMGiJn)GSny&i7MRA~Gzn>}rJHF$sa^hhnD%3rbo&DF;Nmw&6w zzms#*8)DaF_;nz$#aQH3BU+VOmExtU56@E1wly18zYyVUHD)hviT|uQWFk~UQeQ14 za&Y~kG?OZE6xco~Sk%{HY6nZneP;i;uf6aU{qGX!pY+no%EAFTvPEtZru&9bTwW&r z0-}S;>#^Uxuer%rwUBv+%d<9_uVdsmlxAHrrcX5=+=jNLiZBo)_O%+U3pueUqh|(n z3g;<)_JeF~Y;2)_4x2Ro{nfdq2l5;?bAU5mV?3y~mRMem0yAROExw0k+A%7iLnkj3 zEIFq%&TD(L!;re=z@kcZ@#CQ})Ne|z4!xhn0Q&F@dnmlvEi zkKQM#@xUH0H;v!hrQLxUGLX>eb;af1m9OuYH#EF%ALcI6(h-GB?btp?Q&jAAmXvuf zj)@3es&lW&HVP->5MU-JkIte_92Uj+kfZNd{tJMR#S#cIyN(oAt-96DqNwD*1;}i> zIaR9w7w6y<;Nw7{CyP}t#`lfXCqKVEKLN)O90a*9A_7UfRC^Ivztm1m0dbWufYDFG z$QTo%R|)wAQsNNFCIKeV)o(FD98vXASno{2L< z?1ImHmc1U|6>5$5tJ_l;>YAF?{~fCF^1Cgbc7~9`rK?FwI>g$oiyApLL2Gq1eR=U6 zP9OV=BC%IiSI<^DCGr$PLfi@$oeutdD2_e=U?d6(n;RJ~hG#JZtRRX+3H>g7tk1)C zWR2rdg!~s{X${y6dPd+aDGwG+&FCP7f@zh^H^UWUZ?9g;RB5Wzr2St#=X5swW3xSS z{hakV&ou6HB9|P#S#h&eMxkv21vRU4%W6?eJNnJmO{e`v*E&?G-Sihrp=Cy~R2wR5 zB6UcrR0@6O-Ncw=z0GJ0bm;Z*OC$^7_~&Rg_k-uNwi|qIuJ|8i>rPmLd4=LCTV7|} zh{}SpLL~J(wSRjxVhjz}{*iq>kL4|gu?$}C0hr}trG7?6Mq)}zdTMH|!Sh=3DC0O0 z`$aKcO@XJ@t1-n;b) zkr-EQ^b*-D$kE7Xq%; zK5a%Z7!=wmCJmiq2tei&z6o^j^#IlvhH?f^?{`55Zol|j}jT*-) z)(`alQc_ZE_D#(XkfRU4c&wtLMt$EM`m5o7gf`pfC{2RU=UXw#tq{oaX{EyLY`NX! zJRjiX={1@-I)N?*b8Pa1*)F#RVu!YhtH08=XwgjyKG=we_)Y~<-4kzY+WS4U@^m}h znLgYQarqX9?d5pgbGvS3*mrJg-&NFfn~t1c=|5++NdFm@Dw&y`K_lXCwjGpn9p>Ed zlb9vob+5|>PxU1-a?5@=xY6^j#He5%%SC2y*a525@}olQ2ERBLuseh~q3eRALMEqOdyzO+rge2Vvu z1o<(Dk=tATBnAMY|Ct4J`ZVmcGE@XO^rb1YEE#BChGo7oKEC|!ZEewnlM84=i08Pn zzHj?5bnmmt3VMBsMTHZ|na19hama_f=)uj-SaSs>p7Y2euhFdjpOsrBrE1kdT zaM44!{z11=xBc{LQO~h$?N=?rTCn=FlF|$=-Z7MbLxnUD?an>{1JiZA{1)GQ4$p#w z@m+mn+>Iw`R=A1CN1 zOa9QQ{&@$eu8`dXo?3-Nxu0@w@sdg@}3H zq^XLx53O&0%n4$`Z7ED^*7scXiBmimaBE10S2>{(88Ynu$ub~$yNi$e1=hvvXBV)8 z!QJxnz>uD0cwC3!yn-=COG_IABT*wt$m&ldqky2cTDjZ%ME!_31E-DYQ(AN?X8wPO z^AE|_OZQpOr@VEPWHS#=p~_5IiyuXa-=%3yh6$PeZW-LTH$=*FzqKrTE4Je@uMyQRG7 zz%!2KEx)E9FR^6PT`N)1aoRUx_+v^aNjt)QJfD`m@%ry|O*Q~;_@g{zZgitG6!~$0 zwZsjKgR6<7y^Am4Rly_e+kuDiI!9LqQD8h@zdAQlM5Ox^&u~A4h;F{sBRX=MAkzda z+P+gCUX6BR%UM%eRfo^5B#tpHMiZYI`h{{NFiFL-Fi1Nt6xR#iGTXy36EZa^HsGjM zah2u%Yx?jF951wXxeQc=c4k!m`q*npWV&YZq3W#b3Ucxx$(vJ34}OUGQ|7dAmF&1> z!ES1NW{AB3kl5G+EE3!C&qJ zTXV<0fmkamt28GMxaffyGbt#|J76Iy^~>GGBVciV$1JC!d}4UGx}0Z_0SCN_rNiFp zF&%K$0-ZW^0WQClmY09p4Umw;Z)A92U0et)Ze~84x+)YclD1m%XEk&b|tyw#~N! zuS6sy;SGsMDMaj`d}8xLeRiIYwX0AkcWeK>qyeom@M?-zdn^U*{yOQ|vogPU!So2U zZN&eIL!#(I?RSJ-{2n!VMOrmL;X0T0GRRej)K2a2rS;(ttHH>*B3N2~RrQ_GgG9d+X7kd;?XwmHUm;%M3cC%k~dC%G!ezdr< zijGVF2-eY9=5j-^Y}UmZGUK}6@i<#FYuXT}hOTe5w!(Wa6YC}?(KH~r-6OFKQf&5X z%`+5@VByf!cmM~01610o6-*mMu(GppWOF-*puQJaGT&<~QYynE+@&%q28U7Q}r^qhd;pjKc~q%(ymgMji+ zX3@H-a~P+7!n%zsl+lWQh{cTD{EX4+!@&)iC7u?+;Aj=ympmd4cERbJW~N2h^L0AA z^K29DFV4$_mi=n|gd!y2l^fhp7A=UsgWt{D_rpOd@2}Yd_zD*o4jm3XX&_=5;{%if z85AEQI%BPHQn>IkRwGAC6=e%lP4cup#E;T^^M199uX)P@DZR{|7L{pSg^HO6i;|9T zbLD0hpbo|>)2Vpq7#5C|9!Y@Yf5U}?i{i8~xQe$@rTf;jA47oL&>aTI}aZn+SJgaiBT z?(S_StMRN3tGL4%QS$urKvq*EMLO%4S4Q8{wflTy&_@Hk^t^Qv!EZwV7yd+2n#^_L z>g0;^Qu zY837KMY7&U3yfoAnyrQs<12S*s?p6d4Gf&BM0EkRcrLkF4BT)@FDUfx-Zcf07X%1! z>MkxJZ{Ls<229PHXHlc5?WSxxaY+@Ws?)K@5cU`)L!vWuuf}mv zt?0z@k7tO=%6Z|Z_bq}fjM@QCXt-4S76k%90ddqb5}#}mfB=<557or;FSCC#Bh7aR zKQ?uIc?U0bm{npSoY&AzRU@N{o@d^rj5Esc$22)~ft;;L31){f|W!*|7RaMJ96BBEj z`{R=ulgrEkOME2_!-FY1g3!E}0!zJn8yDRgpjQqQ-Evd4>h zVyr}Vk5oYGc-ek)Qk0B`i|c>0`qJd;68FNixiBUr744KuELSl-gJ6l66$cLw-;CBS zPFovBLQ8v$5X?kEAZEc+} z!!bSRX~AJx%Ktl|ObHZF-8C;IcLyz1L1c*8lpn20}q@NGceBwA_4sDDu+8&CYAG;YuMrQLUM zN#^QAJ3;0ubvGH97?er(j-OCCZ&rlL{j+L&EK=5KPjw9h0$H_dA5U6h#bmR#v$C=+ zYUYbh-|FE^VuI5Z*Sygy7^`WZS**gdyg~Q!di`)k67R!I*v||P&p-IS+0xnBOs`}* zLnFz6dPBYah5Zw`LS&VAh1B(Et-6|0Qe3JD6HW*fdL9*|{ei4c)LzI`d_VSxJEZpP z(qTP7#tEfwra|1xTW&d}+>2%{2s-A|2h6zNH}v%cNNBCyHOua=GgwPB z*$nU#H}2=%gb0v}_{tDgCQCTqDo*f@f)4Ij&3~5G4}^^95#pdTblnzZt=d#GV2cQ8 z-RDokhgQ&_tEt^Gc6;5Ak*X$_99u0r^_?2o7C``^XPM9VnZ@zbt6nfVv{gQ;OHI#^Ad-pZ94Fwuhdy~P)#ibn zny(5nv3Ia<2Zm3m8J%12@rr$RgQS+F%xF|O-Y0DTPKnJiab4=>ch=$$2s98jQ>z$P z!}^TXRQk2*dT8EM@%sJ9h1?yjWJkvN7f&SkY`8o+crjH}t4FxJFRwk%I&f8rLn+vB zqsuW_ezaWTPu}q&`eM1Uub6MB^r1^ESPckj%~VoNh+U&O>#IH!$dN* zpu$uP4BT!faAm)rb3*NH%Z&R7V_GtKPp!DdAbZq@{LGzB8}D35g(L2eIpO0|F@x5{ z-;|w+l>p#sLnXUkimU`Q$z^^w`}Fc#RQm^aUJ8!dKFlJxQfB(_SrY^hKbhInC1ksBQ^ zNc22gX6aOOt}g1gYEiaqCqFllWoM_q14q?JHU<3IHXVIwHZOTuC!Mt6uxNBaFSBjH zVPDzd!t^y!0UhO)BW28}^7fnD&$@D+321Bmnrk&1X zvpgtp!YQtWV`LcO9pcsdO0wWR`2WM!cSghcw)>`t5H%#ah+uT0cY^4Q=$%CGqxTX$ zdN5Yao)gHc8=GkQ5s-v9faz4zJ2xB0-c)-2C`U)Qe^geRm!(F)Ux3ou_} zw0dHAcEDaw?V?H}L1X79%fJBzp`|{>lyJ{kcJw*as1yGR7cC9^-Vf^Ud?!5r?L2<# zvAiOL7p4vN{q3=BHxqm3_wl1c)KrdUnOQC!HTC%FH-%NvoMoy%&T5ez1%x6ZZ~5^M z^R%6gs-xw}?HCe9##e?)kJ|*kbHDFNdyR~>D;anp=c{H>t=?F7OzXe(m zRu)=YyObMrY#YcFm^~FH)(P(%mZBFFBDS_-L5P!lGibbhzpU(#yezk6eE~L5?2wiy zqlMm{$z%M&ie!Rym~ub+mC#k>OXwjJE8V!qlCwX~wbI)M2^MS41YUXcxb~8^-!~M? zC`-$=`RvfAi(|6N@)~0eoUF_x-ZTs8A28~QOO95|#NeJhoEX@J4Sy9dhKoTK9m;hN z2CJ=4k`5!loy`!wVYQ_zBP|2hX*GX8hjFQ@!(?b2NT1^qr$A4!{Qklpi0FNk6wl83 zAQ!urAh-M$y9I1P1jO3CLBvNP>M?hL**I!&bXh{EKIMP6sUYaxS7h;FAxswq3tLOQ zn%}T}V|R`>yR~jfx7Z}%b8SkvI^VEuG{tur8Sj2mfa);++ z5a)2BI!AKd|65*ESIQ~ZLWl1~ULm~6d*>R$d;h0TNFlQ1JH4sdTxO+FWz*Wvt?V;) zQq?KzcwX&_yv1JKtMQ3JANSaS%}s@8eZH<&FAQRcqge~QRls?v+^cDfYW{^jsEO)z z%X<2b+ns~U$X`4D+Q2{gtfT^)fUbd*=xVx=M`%aV{3D_$HvVFU8zbMk03ch!CnuoZnWRg*~FSR5li#+H1XZW$+Ua>)uYD&aWB74A()cPs71c>1ok8m z3t$OEv)r^`NGMsbkS?$2k-Wp-aS@ydw#JytGOB>1HZ=@3NgH4o9F2T5QlrW^@g=p~ zH`}}oiz|XTJ;?VGc>>h&0iPZlFo)u!qaENJzIJpiMT<%4&7mMzvd0>mezf)J-Bb@! z1bUwJwWCVtf#NOqljm8zw1$~$zKj{dTBxoL5%tFFLe(-MjU~EC@S~;1x)!Pqlwdec zS@*HO*mXfop@4Xmn4xdXjqcQgs>+7OwxI(zk=t9o9X;#Z6_>qVtxwO)K))p6a%^|! zDN4s)zHQ*qH+cQ|i^7n=>aK@SE;*jk*(@8oeFwo3!^GsTUlWtw^Qd2OPaDY%#_#<4 zVC;X3*k%894b6l%b4-fMarAOYg_Rje(Kn7Pz~y9C6HUVUP1ZZRJ_W+bz4AkakJSsV z7`E8KSr>EOkW4%4CX22&rB{6j6x^u(_kO|d_TG{Qd~6P+T<&%v`l7(>W)?^? zg|AG1c9!sl35WyZI+afN{y~d|w8}5;CIFd2rsOj#o26qfv*-3LFChTx%;$H%F;kk7 z5_0(@H9QVB_OFOI7om_k8q#53?F zxsJU*eyz=72z=+n~P;Fy^Il-Kp2`t zAKpz_*t>(b?1IGTAIWBjsBT<7`RrPx`)4X6SYGji7be-03eELQpq{tNPn@dl`k)o) znCV3o3_@kle4zxbZ{dkAs&YC*Wh_V^e1C$UZh~>^Do-p%IlaHU(i)kC5V3JpS5^%} zPU$R^<6bB7EvJ}rXV_V{yw68C@T0{LwwJ|6U5@hX7AMF+I++EXuiHXgT7iU==b&r7 z0$CUAt0N?y#^BbEum^Ze8Pi~8|JySg-BcekMaJ*biLjZv2Gy<@dAY*WA5qw_H9mUc zhk0D8G(n~P$x3PR6&K`@m|@2bm8eg4 zz&m}86Dl2OPwXAvNEYAt9Sb66+;Xwq+uhL-=m3gEUARmITQ|Mq*>(}?of3e1yflcp zD_>DX7H(6(W#_enUY!dDvGLGlh7iFftr{0-8Hb#v#EKOyYO^OSrVuXM$awPCFFwB} z&{L6qjC=v5ZJ|pw2P7k2i$j7cEU={*8JEU9!pU zP&Jp9av88C;hM@w*TapW-v(Gj07ZDU%9jr^e1m0|G@hY9Iulx1eVEmH=mF2Yw%S~k z@o$5yJoHynM?2r$=R*>ise2kW4fQkLbqVk6*fU%P}* zc>yTgaG_p;wov=asyVfv1u&TZO+Sdpq+Gw+=O?n{FP$(qSBpGLE1T_2Uo-N)jdrz} zE#|`798DLtS__S)nx677u9&lH=qV^@YVuf-9*~caj-s%toeN_aV4&PcNh!*0vaW3M zksL?Rs_g`MjY;~(O^b226v`A8ES)Dw%ews|3eZQjt^=~>(oU1k8 z+xMb7`x&t@*kF5`fa8vD#?A8HF=@PG9UeR~k7!b*{KPR2;eFlq+NfXs5XWObFN5ak z3gM6t3NH1oC*)6{lc-mv_4=wLHZpLG#>mHEw+a5LZM{KnYG>;9d>M7rK~>A}X-wlg zK%N@ZIXo1J0{HD}iz?W>-B5-V_tw=6;}AzXYQMR8J%KGV%d2Q0Ev2}2eyMEBoW!-p zfrB&{=Q=?S$GiYm{X0ng1JE+o6TU9@x97m}e{ilmb~{M`|BE=&gA|oqJy-Q@z)kOb zUC?bYMt*WCvx^EHC>>bL$hf!&-b_hZTQerYR$uK6{XBkosVs_$Vq{|zVRhY%Li__b zX;m2sxhZzpRCUhX@55s|MH~OftNs|SUzyhM8A#&PY5KTaZ9*?=&CAO(nl7|}a7al> zX4PpsLZ%uE&XJ|374Ns!Pfu7^o0^-BsmLnfSnX*(Kv$C8-QF3Ckget9*1Pzy{>Fu= z@qo`J$+_o^oahtW&^Qv7ttGQUoEcv9D;#2V`=*1mo*p#UtkM&p5@I6{jh6xGb-SaX zRiI`dN@v!eciA#B2F=rSP#)SQ&|m#M$E*5T0R;POx|8JC zf|+L*$0nEgCpeBjMZC|`hE?9;3R6@2^{+`CW7ahBqz`*GH)9bGiTQ30eFhq4!pVhD zBNshwO&1yEH)Wp7la9!v)%*fXEA!QxW^z71;|M;LF24p;Q;le;i*W?DkRgfH`}fM3zCL|Qs>!36o6F&5QzzUQ|MypfAh$zs-~R&zS8 z)crs1AhfLBm9Mv_JhNs&1zLu;Jg6P=8$MJ*I7PIrWYz1f=g}-4OZ{E|kFi7Itl+1b zW2>HSnyc620Re3QV~jtkHG2rf>m~W|oJOXadVMAxax-oc>!Rl|>WfDu;H8)eu`Gtz z*%NRFuJ|rEPsZH%j9HsY^-h^#uZrEAY^A0;4Hr~bBSN4?T3YsmHBMWLwA*Xi_Y6xX zMh7t<^sA>ln+dqeU_@JPa`Fp0S`aCAC^ea+Y~y%J$`_3NnFW#`Qcy9%Z2rKMh56CU z#ZzT$Bv^grOWy2@UK0!&tdc%#l4r&_8F~x4LgkulvO=UFtK+_CWqOwa$=@uBqnR@&X~E?}K|K4N<(;`euW{%QPKg9A^E5)M zhI-6@BqU7mL3hB=(9rqv{o9=Iy1aRXXq-NuF-l5++den|E(2@98SOO6Y{Rk90#97M z*6nSrz-RCFqwS$W93rV9_*uktx|x%t&3(TRwq*=+YgA@0kPita!+QTRfB9ea>^rC# zIRIS)im&rFs9k1cd#`+`L=EJ7({Em?p{|+fd_Gr)59K%COCq&nwMg5+t)+ffrZ7a= z{)@!IX@9*_S@dkX z`zao!Vf9T-_RSt~snuC|>XUq#bVWz@j+-3=_eJSzzfHyGqo*Aq9d@ON*!L<5=O5;C zAeYL1$nH+j+abHg#Wvrqk$RgM3K6%xARKZHagQaQT|Her<*YLP)U2r;H~N%mn*|Yp zrk^jO0KDG|u<_K)-OLC*9t1UdO>ek7;~0CXmC+?=(x8+tB@mPwWFI~%eEsP0v*RK( z?`ijX3*K{Qw4lY_N4|7dPy3uP5#hP9Xs-bF*Qxc#E2A8XJeF!_hX=+~pS2$bzHRZm zUc$3)x=RzNH3w&K18%>AQ}&KDA#Y8avW<2;(F|cLV?%qex%85>(b<2n0QCByIL_w# z<<`=X%*fbSzHQXT%a_2R#@+^3d7VmAH89Yrn>4N7eKzX(w0mfTHg1?0aKqO3 z-IuSs5B*N7?vIUy;6(4F z3HL=Ro}G_7&)>Joa^L+~4Fl9X4gf~0Yk}PMarM--W~#V6c3qh3yx$v(_697u(IJUv{Xi*?RUa3e@6K@B`{!pNk_E*Gw9j zSfe`Ac5JKEp{8auzNF_O#7F{Y!riBHc`5nGA->hM+{q77qOga7JxG&w=sis(>WZasQ zb7GpoPW{^Fcw_dUWnHs~KT~w^qTly?nX#ppRj;${wjte$5~}bjE-lTu1QN7fw49n% ztBuc1!Ct&RL%V72;%~~h#5O!j>}=DKvQnfF&XQ^-Uq2}?84b!b zMiv$dpbU0qw6Qcj4>n$|Q`_IyYBfoZY$%b7i9hOv8o4giXu=BDr$pUrO3^X3B=oz7 zs&!VV;wuK&Nrj3&F3nHFonJo(mugf^US<>MiQPNBH#{gB);s7pZNOZ|ns)s{NDsEV zI9m5UXt^D3T;U=?HZHn6RucPt^gQyDuK9~n>$u#EhZxjOKfJ<3ddA019)Twq@m@c8 zH}puHWU1|XIrQoZJ!srvsebpD`Xorx zUQTgP$BLeORC7KBL`opa^sirY#xYcK(OcsaGcywtZ#z4^Z|KFYR+oLR>7%IJ_jz{D z50+#&AzPJ8c#HlJYkAJ*3(ZRHyRpaT`@fqbpJ^J%f@w!VR~$7fFNhn>QA|vQx7T`E^FU0`yX%c0ndFWCr8TjkV44M7?|K#D=MOuWLh%`tgjh{ud5`n zt=(zUO8rg=i!$~z7{HgedZAZDM8Xpjs#Wb?l+DhR)h|kOE#@)3 z+m3sx4#WjyIJ-gnJ)Ty;9AIx<0J9slJluH}PXTntPU%4F2$5nVBo<)sxTTQL7q{)x zNsvKhtg^<+2t^{pk-kmqu6kj8<32g+2XY)!2pmk`C_cQbwZL><>+sEE{6~Kx$o}_* zijop*yFsVDovSNPy_lghs>MU?1{kBOKi&!7zx-O>xbIkW(bF^1b_ztGTa{QLzftAY z5xM}e3gkL(a|D;KT(xv`Sy%k-MdXG~D}h)Med}!0J)Iwy0b1^`d2Ck>r+Su_VaJbo z`=wfo`%(?48r|hsE{&(BfI5r%>jn3{7MHnRO`Ge~JmVF9;>g$VYUc4E9nsOQ;s?)NpsD~SmX1Mig{Exi{FMLWnH`N=r(^*)4+g>5L zieK18)*p#wl~E;&vw|2b9t4goxi4|}&Z~Q!We6J79h)eBqiY;28h2$n{XsFQx02O* zJFy64)jG8mH4mG)M_B9%7H!fe&A1IQ$SNv~b=(a%&zTU)6p?rGk2!2)aJMR4d1%>z zOGQ~#v?gt-GD)`x3Z0|fbX5&)m`m*~xx)Ndy7C@VE(ve zL_{PYP@T4W0v?qV=#5lZ=|34+^G_>`lnb)G#n`MqSmL^u1_1 zyGqAK3mo7Qm|Ofk#cemAR6Z2dDSR0)N1X36V4AqVTG@=vi2FCR8lu+q2C72!24=L|M?>*{$qnW&z;Ra8{Q zskBSctN;_2pRp%E!C{h+w-bbE5lO)T6WrtuO8#5PdmU@LWH>JYF_if&ev-4V@8ieM zT0oH{b^0*AoQ!}9laCeOh%u(eZ)xxb6}*~J@2y0)fv!vqoC%-R-;WX*V|<>MGKv^x zB-mURy+7dYa^5S7thK{E|H9oZw4!Cy$Y0jbn>&5Ph*^MFpS@SoYOVsR(9~a2S+X2e z;G5FR;O2U>)Z~zN`Ob0o7q*E+Mp0>(`Pi{;K;wKEZL;rtj%I@Ku<11cVPdiU#3wtvwEVbrK=%fKt-2L{IpGKz;Z@oP-BGxD zvLXtCnH}k`Da63p63clncVn{cn198PXA^gBsXK>ti#P_Cv@l+UmY+-eN_Jn>YXKrx zcVIOII#{*+<_gur#r)Rb)4P>GY2cm1x(xhYMQgk7XA`g?&hkx}nVQzfhGh8yITBY3 zg&y|d zPh5(yh-f`7Ob;8IyYC6vH<57&1)?zOcmB**!mdY6eBGm5c*b>Ix7U9 zAQ+^!#fLk{5e$bT=FIApehp+gC+0*79h&#c@#nuDx1DHfWX%Yg?&c45z;DU`M@uJW zOn6E%Gc)ED$xWzAa)*9skF23yKM;cWuNG=u)taAk1TQ}i<`4>X$YC&$uJ2&WCy^xK z%=5tho4E4e9aY=W+Mr0#fCE|k;P^6?_-{)`KS%uA%BSe{PLAEoTZUrOIb!0x%2WnakCp1h7QX|f%w6M*2%W@b^MuN+*^vL;O}htxG=&`HC-WN^E30G>d|)- z3UrH>KYnO^*Scg(WsvjqS>}12PJ36e`IsX=#mQ@i=Zy)g?-Xp9=`C+WxFmMNf9Ab6 z8IYBWim4-<*(3|Qy?M?~^RUHoJ3&!i-kdsk>q$Pxh`CK{j|#4Sm2a~6yGlLh(?sPP zmHy8-S|95kiDUebGw@}tr^gH&8`YClRZaMQ8ZGbV23Q{Ap{N`dY=PMa2R}CpXbu06 zlW&|*2)wCk%OjA)m+S^!nvpEl!_go3lk@Y&)pa{AUTm@vabN}o8!Rh{mLZ{eK!Ff? zc#iU0W?e9QzWtU2$DU3W9U%$M_YlC(bN&5kjJz;KvY|y}Ty>RJ%UoYxFcN*+7*_O> z$O+p8aW+RK#C_pTx=%rt^IDrFhG8g-!dSCH_x51HDee`nXqP2A@NEVH${EH)deU{p zt5c6fo3}14wSJeU*|%4Gol0PE$Hpf4-ARwyonF)Cj_YB2Cp9aN20k}y z#TF*=9vH0Jua}CK|kWMZ`&_oEgz<{X)Z3VMR4 zrltU84&GViXak@qEiGc#e|xyD%xht8uBn+xzVlYI`>{A-w$`_IW0t-fioG6N`;B9A ze(>#T0BxiaamzboW$AQ5oU*%}S?ad$I0>T7>7;%l4%RHwsG2R~82&13taLs}C3Xe_ z?u6g@c)+h@jBr_ZT{^7ynWl-@n0IZDgVYv*Yr<=mvQcMYVKjsBjYTFT7m}`C#|RCSgr&E$%Df@^K8U3LOo=25V(STA}ZDX6xl8IO>?v(6F|o*%J{fC zoj}w|j}GI*4)PI)cjujAS003(l~K|(nQQ7J003qWIlMS2g8*={r=;Y1<&u*_Ct$9~ zKRuNz18}69Ji5rVv@~FhheFdOL4wl$7n6ziSM2wI8Hp`@_zuOlQvHEiDD{?2-l?_y zcEsENVhM7G61&@Nv8lZ4&b~Xzr@DK`V^C*$yzYN<+J3$$V6RWzcUm_eOXZ!EQii3b zrsg9M=@~G2sNQD0ObZwu`X~ z$qW1cU;)MnU6$|h((Lv9yOj<*&+RqL1nP%9`*NH*Fu$-#FkxJe6g+3MX%v)?$`2-3 z?=?K|3S!=}nk)iVuYR3tMWDxgq47TMDus5n&T3L7c4Xz-r^oHL$l6h$IM~ohuX*v{ z=TDFm^$yNeOUo6pq0i;&(?kE05(}cCY?;6~+&Mi+=b&llKr+2@rhST4v&$Tv^{fow z4PbC`L8d43(@S=4_0Z23Y9YOoNhkDT9yEV=eo*xA{URe)${+Vg4> z$}I+&fv523W~C?k-dyx6r8f#YtSqrXZ3bu9A)Wi?E#s5Ij}2A1w#uyIPz)ByCVk4l zPRll@^%z(B&ZqiBb5MVri9nD zG6$2R9q;-iV3P?fD>rjnaF3g`kff=px@2bddzaIqluaTpA)C#pyftH11CN0xmB(JI zQ;jZVIgz?JJO`)D6GH9aGx!T4q8M^M``s&g3xQB0C{`2Hdr-N zVIb+sUQ>D}Ie(r-zIVyGJ5#~T$T-(LNhl=RZ`2@L2<`bshR zc}zdcfo49z!(JiIo0|h?an0AQrE}h?Sw0|@ zVk0HJ62>VfulVK=hmp{0Wc;*pM;`?MD00!c$r@WTReFhu>Aq|5l4_G@aAkP{u{Md( zkO$+ed>&YVoA8o+z_@nC3gb!y&lYWN0`*-W?-3<9xqj2fSHqJDQdnDDQZyxfUq-A~ zVA>bI`bPkv_T9>g$Q^Lheyi+QZP0_*+giUdQ~2+@yFoIu7K{IO!1b?exlUkp7xmd( z3OMz{@;w?6vd=Fn-ZJt*oGsXUZcKN^TkbW8-$RbCEuqn2g_oi|nIt@c4GRgMQod%r zp1mqhC5&=(tartkD$Qf7ie{xg|$M<{kN(Z_P?&)2{}Ksou_ zwgGUNrOelE4y79Cs9g4H8CA8MoHX$6o>2;othoNFJxnNfpuK!wxRvAX5-LM@b--)7 z&r48KF}(K8_2^NRZMc?80{zpVD`A}N7pSj27(9-z2X%X^I z31bRS7%&sqSUxV_m zNhG4#F4v~nkK3emwndw-v-f$M{jNWgzGzsbQkvrW%K9zvKm8=Z!#pcwEVUxPeMY z{V_O_ohNYKBG^2|r~#mnRu>LCjGBF#CpQYt0y&=K>*?y2oVd&t4$8i4yK0=>8ci>& zbUuyd<8G`QGiTSW**;pO0k-s~RQCGb)7Gxqdk%k*8}-m^`{ji6^jZK;7#NUuPMCWE z$s=tclLLwTXPKQwJ>I0INX-_jbkO(q_I7nuy4&`-`lx2I{`*HY4hlz!m6g%Fw#U(| zu+a7cw$!U1WwTYT&HC^G8zAy={CXCaecRQIcL&f1c{>bCp7zZMbZeWULHJ-*3$kT< z;`!8l%;6T3`U+>&Yvet~l3UbIwz&_wdU^{B6)q}FFBwL-RJ1flJUKoV8s)2B8N}Nr z__r}B+?0lKx}**U#)(eXAN*=_|18__T&fDw6GJ>nyu$JR(*Lg2VNcs6qa?R_uF5ec z5r1C5gWncs#m`5}c!#^RQ@+v(R*@Az{oM*Oe~&Wpn$#B0rwrW~auh$Wm9wT7v$m!R z%)n0&^!Tv$gQsn4s$`u-eN1%6B@{CewtLH+yC_SI=CEE?ANJWV>$7VN28USz3^>+h z<>bI%UOpaA)9!-)M2peX(;eojiz*0jI@UAk#5x7pDE}SA7J$eixawBiS~C}o;BUx< z%%dQbt=-~xTdjv}x5!j3z}vBTqTLi02&o#Uknu|hf z0mQ(gE$l^DMMvdk6c|F{w?P4kfYjDmyS98p5$7jPAsn`GqzPD&>iJisqo-Z+LgIGQ zP9c%RE`s4ZS0d{(!JqN5RA7?_ZZ6+e$!%*3>yzOTKX)U)=~jqNrgFiCm-Ri{zTf|e ze+t-PqDkVl^z|i_39aq5z51mrMqX@fY);q!L1y-)g-jM-Ae4glQ?ZGhZy1{{Ye)S~ zckl03WBmp~iSO^K?wx=Py8cA^)U~yS!{46c3BCP{{Z#%ne@&j2o88e=@k2$IOWyEp zO(cdWP;Fphk}pk|WAqLXVd`h7xV7rA(qfgBmGwoFuPiF!v{sNsH?*~7ND%-e1wcKE zxNQIG%l47q32EyqG_wHS-uE$Tz12feQJb4Y=@s5$8Zo;Q8E}9YUk5^d+!KTn+J1MA zD<%*!U0s1ZX4FEL8Q5bVNAH5ZLOj$lVCf%YKfHcc}|yYeicD&wrBnVF%5uT8Uhko)4!FPt*EEP*&nq+-lPDF+%L zR&+-Lzvl=3L|NH@Hb!o`vv}9$qdApIKDyf<^I68Gbpltc@B>;eW zDAz$Fqr5lg{mvl2qj#iPH%O?!b7qJXppt!1^|Lxd>n{9q_(cV7ATSFvBblDIY+vE9 zwMNFrwnH>Et)}m4HViFMxM>H&f|-vwg}2P1{#mZV{w2q6MqV7=BQ`fd3JQ@F50o_n z+h7ZpN&3mRKN}?tghN@zmc{XLpKo(M3&kU=;H7&qVfGeDtxocQ_?@xOoA%#h_*7J* zq3HLYgf}cCnIiUk=MWM1MnG9XsLHNEB?o_;-M2x731fK{MY87rl)p&{)tPqp}zb;2z9C@T0->nJW zyF^HAR^f_s1KTmM@PGaS>dlz=U<7HL(>9g=Vkw=(PvEz-s4t1b_%x9*0B8+1<#s?O z6^T5G@M;UXqqam7&z~f3!IW6ueTh^HS`rRsv0iQIJYGosY>iLAq5JtY+A1K5XAfR( ziP}MSb2*evB#)DotA(t4ws(zXS4J^xVddU$2!DXzd<0&I576<|Xz4G#PaAW_%}vb) zO&*bA@KO+`ASHC45RoF;Fj`4lzx?>zNIsI7SVhiq_2g4kCwVD^sO_XFVgF0F$j|Bpnw`e%+q!i;!6xJG*LaT~|ThZ)YssTh;$5vm|mtIV=hK$ITrUC*}vjjdQwy*W4xf}4XpL8mQ@5yI~!_Z`3mnCq@VvC z7h#&%P#&ZT;ti=8oS*${g5F|mgMyft&vn2wh~q%tHFMZpjabzn=AaW@5|+LJ6}z9S z@;_aG!{GomyvnXDywMqAQ)zg7D$E;#rCII7o`{XI{%#IBDa{`Y;k z;0;pI0{j2F-CMjRS!&?2hr*J6zKqG_^i&0)m8G;;N}&$G%_r4M*WbUXN+;YXMhJ?c zY4=6`#w7C8uFz~ZJS@Xi&Afn#`9;N?0K=;f@;8u8{|p8qO#lDy=y|8}jbn{U0$eqd z)Dcc~diU~Y|BW!dxjmyi-5kk%<98T@2V^rK^ni6%B-zwTr`}-5eY|-?Ys6&&L$~Ij;PgsDi6o6*5}})WLZ4l%xk87SJ5o>_^_pW;P^gWrWN{(I5u}JzR^&V* z6}Dpdz$y8g#+u}ln4co_WwAuwv>PO6we2Bb>fi|0S2VNIIw$pnhfTeBFL8B&q~%3u zd=2Rn#7?sg{6fYi(6phL+-!7n0}Bs#`WnGohAPxrR_DoWd{U9Pwni`!?QA~E#K_o= zN=Yv+LJjk+EV&;V^wJe3f)sy^j0k!heUqlquQ%72ZgvK0W$T;(DHsBQK<2aB*#YLa zR=2}y?Rnr}zl`j>Mvy}~M0}<{KHzTY>1EEIAm8}52bLM^7wJ^3AE+rL7C{YRDS|hA z*eN3cz#WNHPap2s8Aw*wtgo*Jstab^Yz*z$D52x|rwc;1>US(Z;xe85=&_oEmi@Nc z#cmGx?ysosU6**Y?pKxlFURrxbJG4Fih4xue^Jy<3r`8XH<@1V9|wg|0q)IrcfaDF z;!+ea{kZY09yuOV;lqnQBFTd<{-Tw5j)1q(0pbic|ASR#a^f%g! z!uJ7w=`SJ>Z_;!AJLkUf0*yKmB4k}^$*8~d?Wc0ah{TiqTyWK_+bRB8&3YEOQ%2Wqh-W%Fk+=I$5!>bd`50kuvb>B!gRLN|WisOLmDvUyXBzjNe9u^pd+BMUWN z&>WD8deZCVJA&&C9E@kh`2y zRaWM|+6$%fJG!IK_A>9f@pNLa$nxIZcTe53Z3bK?lf#^>P`1`!(L8&%^Q?BLzRz$8U&zmExdNxjw72tCzPSPW&&_`HzC2Ce5aeB2X1 zYByd!FGkl;Rh!hTY`d8pPS;J_KpUtvA7Nl(o0-7O<<7EdTc$>_?z3dt0);DEhk5Q^ znHk?JzI}68Y;x2STPd}VNPA^IoR*oI+6>U&ir{p{Y}vne*L;~~WX)Ex+QR30K~@AV zlZ6IOjr~UX4Z3xZth~IS-whn#x&fHPo@tcW$Z!A3W>(D^9ns&oa{H$zTy3G@kXD?( z(qX8N3Ign+WNL5I3TQA#gdm+8Hbj0BMkBj3mAOu!vK_Ih{*6W7Adyb9r50Bp^cqm) zsIP!hhoMz%H?5G)ijs;?)By4m5R&|N*CY`#$G*&#y8`mOG}P5guy_!YLo^oFND#`d zNT<_Bx|BgK{cKA4)yqRS1%VQPme6A&x4lbGOUrg>s;hcMCmE(`@!|*fyz_BhNFu$` z;C?Zq8HFJC$|@>$ zo5ee`@@`c_!D0EZ*A#wQW@1Vr?l}J(O>aZ-gEL3G*#ydwUQ0lnv2v!U2;_hw!Sk97 zw<*~|TFPt#IK=aoXK=i3S^1Vcii(SKbH%G>g($#Y*c4)poMQe{fc@4ahuO#%wNHb$ zq@|~2q@%IXDa^`>LZ;?Dr#X?W1M=NIgmeOk1J100l8tmZ2>CxL{!a$J-FA)3icY-# zQ?jKUdyDng0oR90N^(WDW*ZS3cp?r|0xR2$g>1AdH5P=o4h#TMvI~F%bYf>%?Cn02 z(X*X*KlQAvteh=iI*%(^<$!3+@^WDr6yAilK=T~@EjI}^fUv?Cq#rM=(f@dsC=tk*e1O3-B+pID>TfgMS75g-z%^poKfOEach?Kuq^-?)E_w%7(+#kvYz9o9m?qAE zx+RO1or=fF)XQt0>K++fe;$Bbvj5eN=>FET9!~LA-r+ZXcQ|6&1tF_=Xtqr-nfPxs zq7wL$ZoCrL*c=2YQLe7&>qpqr{74rn$>MQ3qqZx$tXT%EC3^S!+9e`Yt^PPw4Gr<= zC&E-iaRe^=t&3~{#P9nn4tY!&1}-HKMgn*VF)?nLF}fiXCnOS8r{9Jrh#WR@0VKp2 z&Ojs@3OI#0o?OP>{rR*a2 zTYmfVc%nB)K(3T>rr?IZ|MdlM9RoRuH-Pz>>b6UAf1h%+^Jrqpec4b)N5fD>eE-*8 zAay+H3Xh|Wx>54$h*eEpTp`b| zIOvkDO3{m>**n8*^x@x|n)q$fVcs$tuBgDv(})O#V%G474~!=zw`YkM!^{hQ?$UAr zYX~!!^X$mJyon|hy#nhTzqI4`=d9n(TTC0Tc>_Zw-~0$z&&CxONkw!n9VjYt;&q+^ zhWEnZiPC9A;B=jQ$kw+76ng&aqbwH;VgKbiLWZq?iF>D4Jt<4}jSLY$AjH1YT)$lL zbg83_;QV8(2ObzS5uaa4v;aEC7f1@p-$nhysjflNR6DX?gFsQQeu)2Z666U4%^ikn z{>$Y1z9s^upw;CbSi-FTr{v{(yj9Os9%pGFN|bQ~dDePZ{qZp#H3sn!!0KDqr=YPF zwSFe#TXa~zzqz_U+nyAIpO+)+uxX2^xE~T0;c1D<85i=g@#1VpF|MkXLktf1NEabEsePNA2z@$!@dwo zrv99cisX+ldpb+ZMr!6y7ZvohK-?T$T=l{^olrC`c9psG9HJc6Hj=jZ;jyuJO%xjx z`d-XiSbdgz*hnqEhNytmCnAhwxNY-p_C+t~`hx|@BK4Pj3ZCOm2LqWw{d9v83O*8n z75O3&rp|oZb2*bcx6X`@@!NY+Uc;O0?II(%;hxYzyLxTK)tFbWh-7g(P&4VnN=nv5 z8bt$LY!UdrKtj@MiSW!Gq|i+h=L9aqLlk-ga80thJ6M}za z?nW&Wu>P2SSsXepPWX*EzZ)@y82^Zgu56=c0|dsDDC5P8 zstD;i&YKyC_sF@tzZ1L9rCpkF$QsGZNOKDeKluEz0ZZW7Ly>4#R6Wu);+fekH6x?k zpUa;`M>4#TXgA(mCpE8)P<t&y-8~MK5EE!S-C=5#H^i1Fwaj9$>F+W9nP|!m0vcdoU+UV}a zqVsMT0%XeovW?JA;p3!xqRM8kCGSIstb)=@G$jR#wTSNk?BTfmpvp#sfh6 z*}faIV7UU&*LkhQ|Dvz?^1Rv@tP#r+06Tr(AM1AkVA9R|De3Ne^YsU%WihXW&hDO4 zmHyXGn%6E4c~H`=AqR62LR@<;GbSa`+pp02 zM-#t^v%(+%Y^k31b>l627ZrPKRf=#~D8};j0upCg9qNMyJ$n{L)u9TH(F+9(}3B@w+M*9@Vkli?>NsA>WgMB*yR3STk4cNHRqiF zQUy*V_cGoqKg*T7$6g|0x*P}v|N;-L92Erlro;UM$yEfXC!rL> zD*0}BASY+1zYZ%v<2qu{iWpKS9!VFH@kQ(5hZ?j*u|)u*lf19OaLIAe#jK&f$KZr%$DqH9<4w#XKD&9O>+VbmMC^92Hkmmk zmW+p!ho`=|xv-^07cw?GJ>3b2cY5jQ;9M@vrsu@B%Z^oXYsZB2d_EV|72Uix zn@C^@07XGZFWVL=4F4=F2>F0de$n#q?EfT($aB({KrDtcRDkU4{Z${e_Adp!+j*Ml zb7)Uoa}DWbSb!h>cCUKIt^&Vcd*NF1;f|95jhzKh;bCLd{Ej4S@@x^9%Ztm#eNE=; ztUA2a6XtvXp4-galoNmBZ>&)y5$jhj@68=%%?s9)ZSiF27UrbWQoUB^$^0%LcnQdQ zG03jx21pR$8L%R>wC3{zAuX29q~5pLfKvDZi1Ql<3LI$*A|oOzwVNdX@6#-xl>Fd4 zl%}CI@yrpVrfW?$OM@G1XO8tBEZ|vRpO+xp1LFviwm(9JG1-xT@vQ`uO@w4B{&b>R z$2NLdXAfILm5OJBuyumGEiCjS3ze#7DMh?)#&Wv>U(+V7v!rB@UcZ{UiVh%kiM349 zEfKHk+$bV1;rLn(_%c=Cjd|R2>qR`cAbNtn`Y6H zJ$?4i3>~i7Dm^YHe^cNPn>%?n&84ZLBV|Vf7^n)!b&}%#OKKguTh+cdQCMJG)o$*4 zR~$d(Aeoi=^q`ZEizZXw2S`x|*07bB%vK+){;DgZP+ekL8%^^cs9mbREnI$woV1obKVYn;QV@A)OnM?F4|mmKZE zNBsXC_wsy!YMQ-9a*Lm(D27yh#jC=WWTO80*cKxy`(hM3aAX@Qe=!IkpGLQXVpn;r zofjv&O2e$&hc1FUyF1v@NEK3wr9S-9`(M{dadA_p2wZfTQQ_b~kk!v#f4FCK&7!Z< zx!8gok8RV3zgrBaF#ESZt4=NY_R#NGdGML1t;)JGGOb{l3JF|K?A%$;-{w|WO(!Sb z;7MdYJ$efE_m6MSK2dTwy}+Wv!9fixMLT``#)jiF$nQtEj3g2aEf@l+S(RRP9kD($uT8#Pkty=E&6Wl zbDLEUlrpo^A5vXDK;-@IFadVdB?;&s01MUaJYz}&lu|NI*{Ui`jI1Ia+utk8A4Z{OjE z27V4l@U)n=Tb1XG|HEG5(3Fm7?9}OI*F1aXQW$B^r@_2rueGvfjbGQ;NRI8*I$Sbs z#l7rTfgh~GiqZG4Sy_8wjP@;YtY4I5(BSHnB{fFSV>D_EoUWI_9OQ}7l`<>cvhBRn zAMzj0nsvulPJAm9d%NvkR#dcUx8O!g2fM&}DIhJ}_m{mJbC7+{?{IU9fA!)MoSv5i zo!=Ja%46Z;)qO;`44lZk3T2uwtQnEdBQmh=Tt1Q#O@z}#xnzk>4OUpgo?C0O-)$h6 zevUFG%hAR-U={l4T2W;js1YC-?!TLbg5w0|_jNN<0zOLO;hwCftErM=5XdUx5fV(w zJikkey9|Fly+7f}foJoSdUre|DMa-l3}*J4<2DDe7LH<)0LecJlXiJ4`&TNa~ifS8?8M2z>GhdQvH=$nqZO;Gkh~RSCHc&1_*eF znx*2ureR1lIUDl|qU#)o4*S09SwJ$@HXB9Fq1peT>#gIe+_wE;M7lwwq@=sMLAq0v z7D2ie-Q8Ux-JKHBty0nr5+aRAcfE7zIp^+kfA4+%aIeqW59`S}=9puAqqly~=JL5+ zK!?yT?K}+FGLaam!V& zd@N)__s4$pY5?c2zg%s=;J(u~l5d*+-9_8+!ZW}`RZio(zrz&S zZSI*+V7cDiv%5=aawf9!plF^tElie3pKkH;uM-yCX=iQgb4GdfKv(Exy63%{W1AYEnyo%3D`hvG3H2U- zjLp)D+hXB-0VckRyc`?Pn3h60b)Qw*q8?3P96>SvtO>2~KnO{PMpzM;^NFI1N}9GK zk{PE``RmNCs&jmT@Zoer(F1B|-QAl@=?8p0?<)E7j{vq2033*(rGtOZJno!lD}K7) z=c|Z#uXeO{-q`|Vy`aUNR!Sdw9S&Mwu1B zPm9UreLgBca5weKmFa~O@0;S~8cE}RQ9Uh?X$x^!-+VWjLMG~Nxfx~ge&&-#u}Whnyr5AUSjwRjT+@Oizmm_um=J;#j zODevK+|}<_Pugwj`P6ah`RP4yLSaXx3kao639NL9F{sgznZGF{%G@T{X7I8MSh`{! zAdsxk1C(rD!bELU)M=?kvGsDTROWo6Z>Jr+&g)`mp^+Qjztr=`)D9;wo+)}y1lei) z^oiwlTTP|X?`%CSXIs$s4hR_EDl6YN=)60XqXDZ3fl4-Cj1bd+5`WHOG#{>>G#Li- zmb0rKznMAWON>{Iw{Cf~SfN8ZyYD}c>hY1vXqmA{3?xQLoE8-vx)7khJgz$1o3&o- zC?7U%UW-TujlK*vTRjjL9#(UMQ8(jZfeQIE?N^sTM!Z_bapP+&&-it;hIFJ51w}u-ViY|T1V7$pO>*6BhYRswm89llT6sxq zd(4kpY&r39)Cqbj^KeZy^6(-PiCa`3a#yn%V&uvy`|qcm3_O6o%8wM$(QipsG||yY ztZla~Es*rMWWg;Oas0l#ptSbtPg=jt)B=Wfs7SJYrNEa}6wp0qA24F0V-Dx&c%rE9 zAE>~N)d|Q)^Qn;74|Pv%Tt!-#cdO(-FUA62+oLuK`H=DV1svo={j{dc!tPF?kA7spS)n!|;N9J#2}^F&U;f4`M21gYs(y^kM-4 zl#S@8H6Q0m^zh@o$UnRZ$;9k#_}iTIbD2O4EOtY zeZ}};Y6;yX`IY>Z6fh3Qs?(oOO%PZJtXfYuF3>@4tW#OEH68l-lnow8TtgFq!_Xdq zj7s@Ur+N_#Y*N}>J7s!vEo?{xjpn)nUweHB&*Ce=h7UScUY4ryfu!Pem~NV$<8UI? zJ$7eLvxv&IVz?LtjR;{NSV?h(`AG?h8ubgxWb;6K6x5J6qh|!ah4WI+m3Nd2*YQWV zXpvEu%I|#2h|7|7HAa95(}JFXi${+@MP)4<)?phVQH#06=E{xCuv9KpArjdW8pWD< z>lLrN;I5NpH$bF{8tOS}$1FFJ7dl0%D-(F%cZC*4<>+?HfQruo{5w_}&3IW4iTB#7 zLfAdpV+_y$d!`0TUt8Qk-m|Y`xWY=}mpemfsOL(>24J>B`tA{42K5i#h~S~-8J-N! z8kRB0m)0MD6YMc~{k1vy@Vfmfj&ii@-Wqv}(@4J74G+T7vYj_stV|+QqY`~S?jGH1 z2gL1-R*le}BZ&~tG8Yt5pt~<`Ky?46VJ}_)4^92gJ=zMI)2qKT1*z3=>m^jkq%}_| z&$C85)IJ)J?BO|x1lE~;VX8OhB7&f033x|TIb~g-KRdr@c?{4%k6{9^&`2^_BpT9s z9QBlj`8zsz2CVU^-Xn9g?@8dmiULKO7l29Ak}$#Xu<;f}y0QM#%q;KedH1hh zOs)CVIp^#WFJEo~e~1a_?b zA_D&C+|Kj62o}fV)UP+(6_+f`hCX}1*D^{PGrWl>@sv#V5w(&QJihd?{-NS z`R2VoL=7U(fh@tmGT{%>#HtE`EhhnBc)0xi`|)qjXHx-p+leaEXVp61Ur> zI$}1)=!Kb*k!B}sRFfy3cDwe>orQ6;xv7LkUgMy+54Qh!}5%(a`XG&#K8ce$B0^yM`hsR+7sYcT4(^U+mMD5qyp z)(WgY26iM*o;+cG&*CFNRTN6!R74JtXCZJ8Z?rRT3Rj-&Oe|i=9qY#KmC=e#{oF@h zm=~YgBR+LCmQ+i|+~VAh&LWjCpcH*Yt?l>QC6Eglc58@ccWAj(PbDH8E_eM&_>%7{ zr=l+D(OIMC;S(eJo{-u)G!JhZ;4R_Zn+JcgtyflBnF(-9^Yh1%tOgZT7L77;GoP_) z?YrAXKY2MEj{N|jf-hAj$v|tGX5P98;>f6tM{m|()Sy#&>t9t|Ih8NUk2BeetEYkW z;~PDpbxnnPK|qmiHY=L>v}G>OTf&u0)zo-e1F{IA@_UUX`m6>50)$IUlwXRx{m$-_ zgoa5{u|_>t#7c(&JgdhW7a2u6GAipI{f)L;AD3~}V_L9g{zUR_8BMeaI1`{qiL`#b zp{;+er-njt=PIDTgfkf!H(obXLiFwZNnyNJ*kPR&9eLv}v=9`#@NB6i>Kdi;rViT= zHRN}q_p3iDu+ZshoG?MnA&stdyt(Q%R$CQbc&@P9202pYkE!=vui)G<6YGbGWG`%oBD`2N-GM{Q!QUtEytTm$2a za^|EO>9*vUMpil36wC7B=y^f=yma2je~(t%XB!rkHvDq(GE)uj zs?ZtLl$DhY*Z$G+DZvBo83uaBsG9h8geYtw{uFtW{WebRqIKT(%ie**Ec7+&pQ1jWc1smc8Z^91rjozGBt14&diBw3{(Yq(f}SX+ zzM$hoKj)WdL=0X!Lvr=f^-WTVVZcSq^wCA&qc~QitjI=RlbX<5R z8weD5V7&@O{k~DKu~{ab#ayZj#t3mb&=dMUCvMH^EO|zIucH9<;GDR`{4PVZ}E3l z^>sd^9+AVyoF#gc)J_37at0)j7tE9Q^E4$wecaDqyaED-cTt|AGR1VpB_m!KGN@UD=f0$a6u;_x$8UEzJ z<{2gc;g|+2WCi&q!YeDe6(*0oSPuxV@Z4F_? zjAOgmk?W;a7bGIc*m0JS$Dl&8Pra&7V4dqKO}3l#|MTNLR9nI+RMiygz~SZg<+=P( zn;6aO4mwL2bt#oDy&qXh-Rtzs)TJJ)wh;!2w4qrtA)d$E^wbAOXxC@>&iwin`1Zz# zTxU#PxVVbl-0;|c-xNuhTB0|DzF*L{#~C6%sZqZ*J5+O`+>f+n*P*6g6<8AKa=*VU zAn&{4nNpfJ;?omTWg_?Zd&y!S)bun+<@)9HsgO!>2Fj7Asc|UM(1QE~3auV_KDfc> z3iQ76yUaryMD%-u2ntnTlZfuy+G-SYM)A02y0(&trTC@E6GtiDw}*a;??<|Xc!tsrh@!<>IepU;j{Zb3_ur3!+??F* zSWHQn`^`aig5yj%r?3s1epBhe&uLvVwTwCN+j#%}Z81KzI3Y!ZGLaOBbeqYu6kgL^~*B1~?O8T7_OH{*mj52PE6jK6+wz^SYj8i8^| zgQi~eAGOiCF#K!uViZ1w?tb^YfV2lltNaHRupnLp1Gfs>Dd5k|KRQ;gm6@XmyN0I?t=Sab$ ze><|(hM>kuqpvpx{c9Ja)Bq%$N$7$I`dI(xiX`>S{`itYkCFT6Pl$kiG{*fqR=~$F zwZHcpd?c6tI}E@_KZ3vU1bl=sPKqnaR6Sv6@f`T)2}FwcJXK`thI^vO20sK=%e&9l z-If#evHunLLMMn=t}nIz?$C|P^#7NN#-EXb8&%>Up#La^^?y9g0=wrzpQ?b>3`}lu zBW-2`v&_7DpBSh>gphxbM$*U0vYZU!XdHi|Lp|8-%)l!5E2& zVO>ws>*pA=5SQiOXpN{TA?Au{g~SE_`I9@ifVfftEEKk;k~2uB`dN5y7Pm95@V4lk zx`+H*IIcg#yXz|4L7YGpMZWzOeoLk@aLBSI^j2di^(U1SV_q$2)};)6#GFP{f-+NJ z#>oD7C@s;@Kin<{_*1$US*k(4-N-!k(_9q+D_u-K|L&{13l}U?b7PqJ$Hz3i(7V@- z3#)+zU50z6NLcM_2DT;a$5-TSyR?2^x(y(v)umQLYcl25psKik`LR5iVNdg3Gm2}2 zUyj~X=Zhy(TUy)}|3;nvQ;Le3vX`PeeaOf3Ur>o!TKHRhJg*<`P|TXI>*|?&cQit8 z;MQ~FKtjC!=R^6|am~+8(B*OZ9P@PR=dJ0}E(uHD+Td%cfWB&sp@UIbMQSu+W zQlJ)g2uo<<)GD_-KJaep?!sbY^xJFK(DU6>k29g&Qe?{CS&yxdDxtS}8&fEf-|M%y3bA72w(>ZQ7&->MX4|^o^zP|E z=}tmGqY8^Oc0hOH`}ji8M9UR#|B!5|FM~ko#RmOrvFy(|Mb>^YcJINU z+xX_)991&@*MyAkS>73aNJ!?DCzROa4=0ED6CDsFzadKujIvXYG9K9EO(v!Bz57A+ zz}?4`W-TwLx^;rDk^vcp&7V)=w~#!q_c{ryuPDu5Q(qfM;;i#~zpdzz##=}`AyoM! zB_U_r%7TWTH~A5K>$X#~UN|yMf=$ADaxhLhr(+sFy_&w+1VH(;J-DPSwqp_vJo$Gm z!2GvjH2LTBH5y#Jl9?eEgOX|U+mt9~f@D#SYNMpS2G8jYfXX-j(V(sGa8WrsVR_2@ zRwj1Bl6%L3{^Xrf{?j5wVwuHQP9pQH3d$3RtQR;PPTOzW15Z#aM1;9bZ|BB6GGqMd z{FNC7($8@qe@9-I2y`nYXxhnFUP6$TwsbGcgnoh{L}mB#g5ZboOK;n_tO{FLPKMHMA> zR3YEXOZxorxa9b(SW#16woPjv#T^) z;lP(+x{(YtL3WcBT=#{YP8ziD7ONln#p+ir=6_Hr%Q*+2{hcoEXrx=CJkH2Z_8R*w zM>R?AoOmrHUZeZemRDWsjbeV9ZXkBIYu(0k6>5K-ZKOz=oF!Q9M;yqN&B4$+|KsvL zmaKu11N1w@EXVeptfK9C-Q)H7DCDZ`KJB;NeJwv|+qfR>yzi)J>oGg1na6ozTFrE| zosgJl&9mrO-J!3$@*$N)P-lRB&Sqeyy3xDwZS29%m4ms~t4KRm9X3x%+w*T0iKia= z?>y2hDP~vd2Y$+#B%H9xT}ALN-;@-60;(<4)bqC^q3O%ZH6T%&H)m5!W1CqR7q@+Z zkOzl6A`TsDe%(0f4Fh8th~=KN}sxDC;@Od~wZK}d?{{*khBfCbmlww9rjP^P_A$J*(+o~7%1m5+#q zaQATgV+SMsF5BnY-m7hAJAATUS!#p-0m%Dh%_==+GGJTGnIsW#QO_U$uomTv9<6RB zBFDo1+~CgR?3on*$4u)b*Teoh*YB@%XAggW9Y)<6uBa&E**;S)j`XMNbrt9mM(RQS zXQ=*nNk#H}-`cx_Er%P{n`wEKdhn84Ds{}ac(R$!dGJf>BtS!*wMgj%+Ma$%Y`njD z#+;0fc6nqooGZLf{j>NR7F={r*HIe|48j0y*DAfRV{P7P{mcAh70Rqc!DvXvLvzpx z1Hw+FxY?C3WOCwf{e_L-rsmvh!lE8>S?)*A{wEtT3ZSPe>d?pM(M5cQ^nBXd+v@w+ zYeABr;~>j;CfTF|0g~)^&C}^OCEZ&@N8YhoVj}I|YxWfVpuB=Jg3ARaMQDm;gu@sXG`a1Z1 zACC@^_8_%xbrBIULZA2b3t(FZ+Wu%Sr!}Y}(1OqgQhq&7!-7M+!oj<&`u%QHNdLy$ zi7>iJ5T`=C;cvS3*;tjZwC`5URjXmCyGwOqwaQiCmvgdd8#!GdqPuB7J_1$%YR^Q} zZ@_#4TOe)7+dT#0cVgyY1F-l~<2z1a_A;1QmV9HD&O&7Q$dEsKg%DBcqIRWf&=Mda zyzNsm3Z$T?FD)z#MyFg@tfWGkSLN_<@{yC<0VP~!5T9C9qfCJ|5sj;z_ubg_cD;_7 zmzM+?V~!jm^w*5W#IO)(1~PiOg2yMm&xJYz1kw7{e7o5w_pDx>_YF|UA@bb=Q> ziz6YZ@-B(F>VcwHN0`13m)YhtytI8gPQVt{6kzg(s@tR$NWE5<0X9T2TconIl%Jb= zV?*2U?c+Xc9>aDY8w-oq>c)A?Id+!T*PygpMo-T!j>YwY8L4#JYo7AY#3E)7(w!gT z>c_5yg+;FxnW7x<4IvJW8g;;YSdfIsfYWllAFRYivqd|rs$Sw2a+;0QnA30>zq>vy z1zqM-B4Gj1=g;p`%?!uPxc~?L`jYIv;@v7$=yKr@%fR3_uj~1(``^W)A|AgkfdH%P z!F>6&WnC?Yg2EskvrdiY1*m2~Rm?5!S{mx(0W~X%OuOlA-w0!M)2(algW6plC{Y92 zW=PQ5&ZHR_rRcMUj@x6{b^K6NfXR!;Q{-V3alC|o6I`qh0Z7uDeurXDZ|gQTBJo&r zOMgi?MuHCT#EugH6K1u0GBB3QwjT=FH7;Vy);tgkH~}hYBdDPbAZ1HGyku5Sttm*kHmHPquIr8fLdQqN(w~8xK3A3fZ{a>5(&05sp^^wo98n! ztQ!{v77*Wuz{BCU8;m++48MB|^BL`*4-hVZZLOrTG?V@9rt6BYOpD$4np8a%C+ErC z#d2}#ZNTkXLV~VcYhgt_6Fq(W=O6R69HB$0iJ5OXQU@6a!5eJOLwoT3qvKSG+B7%! zW%m`PYmjx`^MU8nnRBoS!5nZI=ko9Vy`5%&*j8&`aZ=-yMx{ zk&b0yBSP1y{A$^dbwpE3D^7PP^mjcg_8Hi%&$+VB*}vu&Sp)Q}h;&XfP9I>)V%E7VOgL5~XiTfWSf>1prZTk|BY94^{*lEtUU?3tJpXlo{E zOlN!{b$6%yMin_cFk-C@iE!_W$y8EOV(KB_t*XguTl?PZSgev=)R;)CLbI>=@+A+j zKrbsByuG~zTC!RAOz&snDW!K$TSC5mh10nGv=1!SKjc)+kBxOvgaZzn*WL8&u25zi zXpKqdf2{hXJcd-a^(_}fO)k!4hW6x_Cz0%EAx&Q*@f#andnxEFGNEn`Q>u~pryT+|FTP=YLD{aJ zSDRe<_@~B3U=n%h6uq9hQ|0I+;N8egiI`9^MLPbbu33)-z+4Or#0|z+tkCA|@TAnR zV9Q}!&YPD}8UHyr!BqXn^IR5QsZqp`V61Ff`a6u2Ttb_M9L^4_l4QzcG7tezOWV}l z2TlWWnv@Uw8Mv$0n9xRj#ad#HMo) z;}YA_0x}y*Hc1)-j?$%DNiDF-l?0!mp{e7Rv^n(rUmdQ=W)lBDwTIrUcX%vs*@@FcBK|=z?6ru0F2l{ufne(Qn+Fj4gxPQUI zyg>Y~Xh9B_gE}g7VQg3AW0_nNs1HLP<+k=6&cp3*M1j+&0fH|_^Dx^Ic2&i65V<$_ zFi4av#dVG5y#FYo_=jRiQZKG7u!L!(aw#WFq$c%$2$w@pp5QEcoA7R#BrpXXkWOcX zU6-o9k{Qd(f?9hqQwiD*3&85y?6y4|#D3L3e7JkzfC~+5Fvi445?D%JEk8=6xh$mG z>0*BJdNM@tkZ~g3%+%XEW`u< z&(HYDLzBK`VpPh# za#usMFp1BKTs@`S2P=Q` z3h2SMF`>+L%t*8pXQU_Z0*wxTnMvjG%?uO5E3-lb;wM}9(MORf<jy6EOW2tB{mTFuX&2z-R z^%WgPTn=7_e@%lrMcgeAR% zbn2#NH#MJBN$G07Girxgy9lQ+ju$ABl#p7C<=f{N|E>kZ_d6CM;lytyH!jn6o&-Pe z9cU-wK3AHv8AMPfaLwI1D5ihL*Skvh_nC@-Nin|v?IKZzUQ47TQp&3Fk?qL(yHj;s zB(s8)^!!vlsUSMmO;6L(@a`lhbK-<>V605le67Ac@Tcc^4;D?EV*NzzUn^1N55gYte?!eX$|ql z*MwiRL3{p~d`hex=g zH(^prRb4toF^2Yu^J}U~3a4|O({{cux?cuP1hXHMy0YI~knWp1Y>#FlM7p5OdLmUk`P}IMpce26~~rBAsdxv;s{|u zF9v2cr)j!)&tXT$BimHg->P%uCW^`*AC%6eZiy||x{m3H`u491t6|rn1|FOVpvC-6 zwzhen?}PeyMg~YJIy}*zTC&lhl@QGOUXUlEMG&!b;K{JcMVK8k#>>6qjdUH*$8)wV zdLn%H)Ok?h0DoSc(nb1HQfOnT{*yZo21v(!#QkLEsPOWB9~w)?#`Gd-M20?rUeb8j zLhwBqe244k8xv@T`adb9Du!tHN&LJ0&LLyErk$^QtcJ~NzgvIHDk<%ZUW;Ch=UdNx z+`S=Vf4A**#T9OHTo!MFA4y4{SBA~I#VUI%Q$atE!M98+J4J<9q2Xp)5-vyj-*<>xs=Iy}d&{Sf ze`6$FJcW& z9fqB5AI8mAB4g zX*t(vRMTeb_?=WaWFJnveYFwTJ(T9*<*vS9e?$!glF}Bf;PlAl)jU78Jl=@<(-Z>D z7ieciU@<5WEJOg50Z_PBAd~3nB1;)*X%HaOKt=VLOu&hcR&Xf8#Mazgb@o^9ka&z_ zw6r6TAWD1q-FhHL4Y2q0i5sSmU*V3Q9#KNI7y++%G*g)5zgd$%E27-0D@kpr$E?Y1Sq+B=Jz@Z8}dK zYOnFf3?XOBi!5OvS{jKXxk-y893gq^qIGJSIFeFAC1u~tmm-Z}TslrTSuY`!2&isD z;rPyngcf?Nk=fw!>d7^j>7XVho;OC7trrf~NIDoWS2@s)!0k)~M=+xu1vt+IbM$^Lh_F_+fT zvbu*!Xh!UaNi58sJv!1IE;s$iA3mHyR470a8;vXfN&Trqu>uZNAYHN0*1)RMKe6)2vKwSJa_+)ku#PwFHK@7J>@SzNBDDhB1@yGIUPABYX(+VY z4)x|>kUD%NUOYsYW?t7Fyyaf`ayC1ML%GPAeR+cxshu^vD1c+s)8IIaJDcxgtG;yf z%P|6~(8xp~eJt((FOk*_oV_9}zLmK~7Fm6Kce6}oTAPHRpogTh2GoL5Dpzf#r_1`} z+|&CPo@+glqb#QnbbpE(Dm*P-*L*JV?lJ+~uV(E1+XntF4T8IHCD}sQAPxc~Ha2)X za8$Ik8t?)lA{`aKtjPp^NQ_x#9vvZX=?`~vVSKeBtbRk>4kzV<Mekg1Et! zqm`5pF-d1Exj|8JLN{^1>A~AIn_ok5ZNH9L_qFOytY>kcmqvz|x@z30)Uu&}N$GHd z;)HC1IPO_3(hu1nb1E7-dh9{VUCv?9<;&-JZm^WrP?=xYk(|h9u-3ltVohj-Zq)n} zOANi;w&D!;TzGY7p`Fb2VZKWoZ<2;j@|tdBoW81X(B0J?b}OoglIQXYCiLxvK|qIl zbq($fzlYiWVfPW^WRGZV;?mbIm&PLsWV&UjMtdrK~;2cMzgXkJ`x-G)c|h&2yB zK7O5jmmws1W09E(Ue1DweS}ILInM-#a0g4B93gx%vOvtlL4t+ez(s&QLSpxu$tpzQ zJu%VN%N?EW!89whzw2314-pGJ?As5 z_^`O7nCP+(t5Z2N#3ve*r^xXagP7GcYP~vTchB2T?k}e_wX1PgW?912Yx{8|@zh#Z zA8wvv=3?o$j_qGCo@`<215P>Et}y{#lXKp7weUbSxKDd8N>^c z?pJ#1Wg>BH^ZQhGD^i;~*8TMDnEbIs@ltR$K^Og)?i3D>Lh9u*vWe9CuslBPOj2cZ zH0t*|ILam9vbIi9nBv1)=_rS_=xb z#iz>suy#n1LWbh=_K^&G#f>Q z`J_9|0%@)cs?gB_Lh5!{61OX6T3S`CO<||#16oj%nh2tjHQ!A?c#Dsr2a)p%+Lstz zS#ibuhV(r+t22A^@x5Ee#mC@y_kV`|KM)*j7RJaaH@1ROpxOW8i49LvN%+o9XUBKJ zcuAz<2&@KIiXG(AbnUXecy&*Pvi2v`2RIwC9F(OU^bH4jyFU`qg{ouBGz3@%74C`+ zV81qU?T$2}QY7kI#(zXD3)sg~kj{@S(%;70n?9j@ObV?)=-<=^viJPvuL=1|#PZKN zvn;gevOuHvQZAgFDAk->r!s@n>q9<1e`Us^-()`tuCO@M)08CIeSgGK$FGwb<1)W% z5B^;X;7r#3ArPJQ#@c{8FXG9l5l{0$UOyvnuB)cm`Q}|bp`nz?+fmqtTAO4XUxy0h zE(r21@%zc>>H|BJAYVOewXiT+(pS)5jrUl zRh6!FH`q1$bfxc%q~WDORnnp~+KQ@T_>l|Z0?Hi+v64|G`UKzA{=9rJk6}qAu;`H@ zoK`*E3QcZ-YtKDo+3&r+x5lna5?#u&nw`FvzU8(8zZ&e;%NK^{UHo%jKlINBi(3^# zOC8Wn>;V+$PE&yNV>&vX25~3E;_Gg*JMvNcC-Ga`GiopVi^M+0FU}r$Q-s`J)CY~9 zI~CquJQbKmpCu%a()^?}2_LIBagxIKxcmB%%X6H6RTxjn+>pAvE~iBw?gli9H8c{e z_I6b=gp!j!08)$`3!otbb6-JW;r;UJ&P+BRJvoa~DtE|AU*FNg1({6hla0j6<}X)u zzJjLvaj>?x50$U4S;>4hNJ@&DQUu!7Az}&-A1ymfwlh2EH!?3OSzolQWhLY%lvoDI zEyr7@2-S0~jv^7n2cN|v%?JBozs|G5_d2%A=_LDe&416Xq@?6_J#jc%X&5MOF%V>m zdd@@vfpuDC0>f8)?|Q$+c3z#|oYr&w(c-Y?NxzPF=lltjJU%4W<1@2ee7t+mYfUQs z`K={NeUHaB`ar~EnDfAM=wdBdePWGX0lY}{jVn2u9VgMcdz6n2KK}*-R+RigCx|%M zwv!APJVDPT0AS1sgdoQ-;x8~>q^&eNUI6=OL&#A=%QZr#vP@Pl@V4NgyLVoiU{O;n z$Rfe!FRe?@D-*jU)yHf7c8M8A$z6NfQRsc^*DYldUZUT07{>IUde{lh1WW!OW!&VS z{0P_G8l!DulfW?`xX7;U3PXvJv0QT!=)XK8vlH-c3ER2^of+Ueap`Nng1xstD;GeX zQa42XTlBlj^1*fSnZm=$jp5ZY0|SMa%9jz_hDf!;Zus$;cgzedfI^Bj?8M34*!1ullwD4! z(kt`p1Gu>&+U$L1Pge}vPkonfTJ|`U<=knDJ5hx^lwS7>*?9|m_8qKj*-AL^ajucB zc!MudkJ^qZ(Q>{)74|m`{$EKal-~f#=q%+_5omCMj$37(K!%}NC9@RJrA$mrj@!Df z`&g^uO?wYk?gkq_r)+fzZoGn7NxFW??fg#G&~sLOMl^t+2d=d%#0Ote4cEiM*q;8# z-{Z!}WI5gl{XMrHxgE#fRPs3+Ja*P2tohsZg+hqA{N{jXm7@CtyG2D`+k+90^X9H# z%K5{yLa`cDm{u(0)<3T%j7LEI6LOTxBj+sA5MTEjD-{)~(xGkcY|MU9Ie}q4-4Aw5#rmc$V!oJ{$yUs%@6gqm zf1ABTCu;ZI9n*R%*6F_zEH6N2ELrb<#wu%ls${!N$8&0ge@(;n_hhhs0(3V46{XDj zRp_3qGS6h>-~Iti9wJ~*vAwKrx$V9=$V~;*c+fNQ9_o7bm1%|UPqBpLOZ>^sKI zP5xe$Uq6h#baz+(w|;XwJKzUfYUc~wm6G0Pa;GN(uT(DQ-F5c2D8L^#Jz)Hb>bBpz z32^J{W5$h|ex5)3sdOx}u&|Kjb57mb+YO)%d;$MlMnIqFav`X`k%Dgb%ET!|M0|FWt zxBq_V@z5ogE4|*!PV=i(AtqgyqlcT}16eqc>;A_!HZP0NNZRff&P3Y#*0B;Z$jz0r z1O#k$*H3I~I&x!XX3e>l%1!}u9!SIwymK^`kl2~8WAc*4aTFj1@?-P!&5yLn6KjWH z@k_VVYFyrY4r@C;M!~}vsY7jlum$gZeAWN?-b3#awVFweFEAi0-~E=I?WU7i3RG}` z!Ea0DCz~7PKZ)D!V&2-s(EIQSmg0;UDf5V)%(%b>?q4wTvX6@yKBW%QczqdeA(p< z4D&o60x_qlbH4`%jKgbbP5;NN|+NgmH*LPIA&XZ-Rl@gu^@B>UFre=UZ<+1WGE>!uu|`fHxgt!&=cnmQa-n`q_J*E?pq{lP;BBdY%{ zUvLRMUWMiJ{M05|{+h|g^ZfTphqoEv1U^7;D1p_KZmWy_Xe_aa*LweW{z1n@_YX=L zRy}kaqDpDawr+t;>G z02Kh2kv+EfHOCILt}F3|LZ69>xK^bV6;zvN#`1d0r|kw}@+P>B_$tlVNwlGsIRgza zCrO~&SYw1$@jnG5prXqXuV~x4u?a<$s32nz67DFlHizl63A+4lxjlZGQmrS>*|_3@ z8vYTOfrpYd#V-H;t8+X2O_sLdr|$N}_{nbEi2mj2>?Vp}1E?=(i6(Cys& z2R)JPG+CG%_7AWmMA$oujp7d#{*dbNo1ny31vCQN5d~;Qs3zSkK{3m&e}gX?4v6+V z^*YS%cEm2NIk~Aw(^Ltco0nK19Pr=#wie+H#*FYoVme$O`Gx%#R{%tM`-Jytq*viP z&I0Tl`x2)(`>kCUKF4DXD#b7V&j877+f)%| zK%jR{#^-eVxs>dFwHwHAztlJ8BKX~OC(WM%^A@;nsfgYrEd4Nofr}L3usno(Nc~@x zG$?QnEGGt(5Ci&w!1o={6126dE3-pIMb+e%joY&S8`!}QAgkfRKeuneXgR;J7m@jA zpm0MFQ+?fOtE6K&xWUMx>p9JU@aO%22{SV0D?#haqnh(cI_eII-QMUdC}}#aHKnL_ z`VDDaGK-KW_`4@LY?zezoIbKK(hsb+4WC4Bdde+I2fz*T_RcRHE{k~acQ-M&@csHH z#F2A+H@#?)g~PV4PW8I`7>HBkfB!!=Y}qDpB9;FOel%uam@zUm#KFc&gJuN> z|Ch^QM5L~P6)yMubLj)WbC!H_VPUxc^S~+czK^UTD|zGQNwr%^U9lM#1dRjy}XpaZ*~@_f#-$22^N1llF{s<+qac%hh6$gig)V> zgP}@X3;S!7U7T{~C!Wa39Wm?0M_ufdFY(E;$6U>jJ1*+NL}a&g;h=c`!2ckn#(p0C z+W0{tU*1WP^ftqsUv9*fnP+B}oqsHr4?w~-S*4IDq;DjIDs3yrd&&P-4bxsX{EK7*ZQE+N1<(V4t#xhBd|4KP4v}N zA>k`pQqQ#wBNJx#%#yqu^vHMFC9i#l)mDc7n4P!d+h_g#tJdeI9G1Olcwz@|0Lsw7 z!N}OyEdSo40*ex>*!+5GsotJhYtCer}m)G{;TgyFszDgUt zi~sj>`f|Vdz?827aoOtU$D#P(1i7k7j8;UnQXHv zV3xhMvhv*(7e9RQGW_s4{b>Eyy{?hraT_;n%DTMleAwDuHNX2}H>aPy@VeyY%}C%H zpf4AJSBA-jTTHul^7L6vEv>xF%v4BvZ+IZs+;rjjo=+Z5mgalYPt492?f07^(LHyC zx?|(#7wPBfRhAu8c(G-ZlnBc#?)%$-djwd{d%8G=oLaoAN^RB$9_=R&qn!$l8Jn+N3d!~XExvPLSsW}E`YJiwLBUcNXo^OXK>u29 zp_aA0?wPY@7y*w>W?&HXae9?9Yugq1G-j5a&Oi|t!RCsb*a-?bD<`uip8N4y5~xVv zV9$&aYv1TBb{!5^LC{Vohbsmh$FH$+9@^|4U%Xp(=FjQs>H1)2xlA%ks*JjKR4?8A z=STA!428R6g+B2E4enrb30!nsOj@DoU`A5&(We++e(Yz>z4KA_|N3=*yv-z390fpSm%z;(G3OjjlHw*P0Pj05U)~=fHs3}q{ms>5 zEj6AZbNj?Dnr;YK4pit+aB=pg;7#Ja^ZuRKQMq5m^hU_u_ZPzJkF&LEbg6JyE?Ek! zR0R%RxP0nm3D2Xsa?KXjdRLenEDpk()5aicr78Su?x4ke9ju)SDB z>>eX&tJ$bx + + + + fxnode all supported + + + + + + + diff --git a/vendor/fxnode/examples/blender/all-supported/initialLayout.json b/vendor/fxnode/examples/blender/all-supported/initialLayout.json new file mode 100644 index 0000000..4b1bb77 --- /dev/null +++ b/vendor/fxnode/examples/blender/all-supported/initialLayout.json @@ -0,0 +1,1832 @@ +{ + "graphId": "all-supported", + "catalogVersion": 4, + "nodes": [ + { + "id": "all-fxnode-common-frame", + "typeId": "fxnode.common.frame", + "typeVersion": 1, + "position": { + "x": 40, + "y": 0 + }, + "size": { + "x": 300, + "y": 100 + }, + "label": "Frame", + "parameters": {}, + "sockets": [], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "all-fxnode-common-group-input", + "typeId": "fxnode.common.group-input", + "typeVersion": 1, + "position": { + "x": 520, + "y": 0 + }, + "size": { + "x": 180, + "y": 100 + }, + "label": "Group Input", + "parameters": { + "interfaceName": { + "kind": "string", + "value": "Socket" + } + }, + "sockets": [ + { + "id": "all-fxnode-common-group-input:output", + "key": "output", + "label": "Interface", + "direction": "output", + "dataType": "any", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "all-fxnode-common-group-output", + "typeId": "fxnode.common.group-output", + "typeVersion": 1, + "position": { + "x": 760, + "y": 0 + }, + "size": { + "x": 180, + "y": 100 + }, + "label": "Group Output", + "parameters": { + "interfaceName": { + "kind": "string", + "value": "Socket" + } + }, + "sockets": [ + { + "id": "all-fxnode-common-group-output:input", + "key": "input", + "label": "Interface", + "direction": "input", + "dataType": "any", + "accepts": [ + "any" + ], + "maxIncomingLinks": 9007199254740991, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "all-fxnode-common-reroute", + "typeId": "fxnode.common.reroute", + "typeVersion": 1, + "position": { + "x": 280, + "y": 0 + }, + "size": { + "x": 140, + "y": 100 + }, + "label": "Reroute", + "parameters": {}, + "sockets": [ + { + "id": "all-fxnode-common-reroute:input", + "key": "input", + "label": "Input", + "direction": "input", + "dataType": "any", + "accepts": [ + "any" + ], + "maxIncomingLinks": 1, + "visible": true + }, + { + "id": "all-fxnode-common-reroute:output", + "key": "output", + "label": "Output", + "direction": "output", + "dataType": "any", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "all-fxnode-compositor-color-balance", + "typeId": "fxnode.compositor.color-balance", + "typeVersion": 1, + "position": { + "x": 280, + "y": -840 + }, + "size": { + "x": 400, + "y": 100 + }, + "label": "Color Balance", + "parameters": { + "mode": { + "kind": "string", + "value": "Lift/Gamma/Gain" + }, + "lift": { + "kind": "number", + "value": 0 + }, + "liftColor": { + "kind": "color", + "value": [ + 1, + 1, + 1, + 1 + ] + }, + "gamma": { + "kind": "number", + "value": 1 + }, + "gammaColor": { + "kind": "color", + "value": [ + 1, + 1, + 1, + 1 + ] + }, + "gain": { + "kind": "number", + "value": 1 + }, + "gainColor": { + "kind": "color", + "value": [ + 1, + 1, + 1, + 1 + ] + }, + "offset": { + "kind": "number", + "value": 0 + }, + "offsetColor": { + "kind": "color", + "value": [ + 1, + 1, + 1, + 1 + ] + }, + "power": { + "kind": "number", + "value": 1 + }, + "powerColor": { + "kind": "color", + "value": [ + 1, + 1, + 1, + 1 + ] + }, + "slope": { + "kind": "number", + "value": 1 + }, + "slopeColor": { + "kind": "color", + "value": [ + 1, + 1, + 1, + 1 + ] + }, + "inputTemperature": { + "kind": "number", + "value": 6500 + }, + "inputTint": { + "kind": "number", + "value": 0 + }, + "inputColor": { + "kind": "color", + "value": [ + 1, + 1, + 1, + 1 + ] + }, + "outputTemperature": { + "kind": "number", + "value": 6500 + }, + "outputTint": { + "kind": "number", + "value": 0 + }, + "outputColor": { + "kind": "color", + "value": [ + 1, + 1, + 1, + 1 + ] + } + }, + "sockets": [ + { + "id": "all-fxnode-compositor-color-balance:image", + "key": "image", + "label": "Image", + "direction": "input", + "dataType": "color", + "accepts": [ + "color", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "color", + "value": [ + 0.8, + 0.8, + 0.8, + 1 + ] + } + }, + { + "id": "all-fxnode-compositor-color-balance:factor", + "key": "factor", + "label": "Factor", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 1 + } + }, + { + "id": "all-fxnode-compositor-color-balance:result", + "key": "result", + "label": "Image", + "direction": "output", + "dataType": "color", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "all-fxnode-compositor-image", + "typeId": "fxnode.compositor.image", + "typeVersion": 1, + "position": { + "x": 40, + "y": -840 + }, + "size": { + "x": 240, + "y": 100 + }, + "label": "Image", + "parameters": { + "image": { + "kind": "string", + "value": "" + }, + "source": { + "kind": "string", + "value": "File" + }, + "frames": { + "kind": "number", + "value": 1 + }, + "startFrame": { + "kind": "number", + "value": 1 + }, + "offset": { + "kind": "number", + "value": 0 + }, + "cyclic": { + "kind": "boolean", + "value": false + }, + "autoRefresh": { + "kind": "boolean", + "value": false + } + }, + "sockets": [ + { + "id": "all-fxnode-compositor-image:image", + "key": "image", + "label": "Image", + "direction": "output", + "dataType": "color", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + }, + { + "id": "all-fxnode-compositor-image:alpha", + "key": "alpha", + "label": "Alpha", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + }, + { + "id": "all-fxnode-compositor-image:z", + "key": "z", + "label": "Z", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "all-fxnode-geometry-join-geometry", + "typeId": "fxnode.geometry.join-geometry", + "typeVersion": 1, + "position": { + "x": 1000, + "y": -560 + }, + "size": { + "x": 180, + "y": 100 + }, + "label": "Join Geometry", + "parameters": {}, + "sockets": [ + { + "id": "all-fxnode-geometry-join-geometry:geometry", + "key": "geometry", + "label": "Geometry", + "direction": "input", + "dataType": "geometry", + "accepts": [ + "geometry", + "any" + ], + "maxIncomingLinks": 9007199254740991, + "visible": true + }, + { + "id": "all-fxnode-geometry-join-geometry:result", + "key": "result", + "label": "Geometry", + "direction": "output", + "dataType": "geometry", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "all-fxnode-geometry-mesh-cube", + "typeId": "fxnode.geometry.mesh-cube", + "typeVersion": 1, + "position": { + "x": 280, + "y": -560 + }, + "size": { + "x": 190, + "y": 100 + }, + "label": "Mesh Cube", + "parameters": {}, + "sockets": [ + { + "id": "all-fxnode-geometry-mesh-cube:size", + "key": "size", + "label": "Size", + "direction": "input", + "dataType": "vector", + "accepts": [ + "vector", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "all-fxnode-geometry-mesh-cube:vertices-x", + "key": "vertices-x", + "label": "Vertices X", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 2 + } + }, + { + "id": "all-fxnode-geometry-mesh-cube:vertices-y", + "key": "vertices-y", + "label": "Vertices Y", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 2 + } + }, + { + "id": "all-fxnode-geometry-mesh-cube:vertices-z", + "key": "vertices-z", + "label": "Vertices Z", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 2 + } + }, + { + "id": "all-fxnode-geometry-mesh-cube:mesh", + "key": "mesh", + "label": "Mesh", + "direction": "output", + "dataType": "geometry", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "all-fxnode-geometry-position", + "typeId": "fxnode.geometry.position", + "typeVersion": 1, + "position": { + "x": 40, + "y": -560 + }, + "size": { + "x": 140, + "y": 100 + }, + "label": "Position", + "parameters": {}, + "sockets": [ + { + "id": "all-fxnode-geometry-position:position", + "key": "position", + "label": "Position", + "direction": "output", + "dataType": "vector", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "all-fxnode-geometry-set-position", + "typeId": "fxnode.geometry.set-position", + "typeVersion": 1, + "position": { + "x": 520, + "y": -560 + }, + "size": { + "x": 190, + "y": 100 + }, + "label": "Set Position", + "parameters": {}, + "sockets": [ + { + "id": "all-fxnode-geometry-set-position:geometry", + "key": "geometry", + "label": "Geometry", + "direction": "input", + "dataType": "geometry", + "accepts": [ + "geometry", + "any" + ], + "maxIncomingLinks": 1, + "visible": true + }, + { + "id": "all-fxnode-geometry-set-position:position", + "key": "position", + "label": "Position", + "direction": "input", + "dataType": "vector", + "accepts": [ + "vector", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [ + 0, + 0, + 0 + ] + } + }, + { + "id": "all-fxnode-geometry-set-position:offset", + "key": "offset", + "label": "Offset", + "direction": "input", + "dataType": "vector", + "accepts": [ + "vector", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [ + 0, + 0, + 0 + ] + } + }, + { + "id": "all-fxnode-geometry-set-position:result", + "key": "result", + "label": "Geometry", + "direction": "output", + "dataType": "geometry", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "all-fxnode-geometry-transform-geometry", + "typeId": "fxnode.geometry.transform-geometry", + "typeVersion": 1, + "position": { + "x": 760, + "y": -560 + }, + "size": { + "x": 210, + "y": 100 + }, + "label": "Transform Geometry", + "parameters": {}, + "sockets": [ + { + "id": "all-fxnode-geometry-transform-geometry:geometry", + "key": "geometry", + "label": "Geometry", + "direction": "input", + "dataType": "geometry", + "accepts": [ + "geometry", + "any" + ], + "maxIncomingLinks": 1, + "visible": true + }, + { + "id": "all-fxnode-geometry-transform-geometry:translation", + "key": "translation", + "label": "Translation", + "direction": "input", + "dataType": "vector", + "accepts": [ + "vector", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [ + 0, + 0, + 0 + ] + } + }, + { + "id": "all-fxnode-geometry-transform-geometry:rotation", + "key": "rotation", + "label": "Rotation", + "direction": "input", + "dataType": "vector", + "accepts": [ + "vector", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [ + 0, + 0, + 0 + ] + } + }, + { + "id": "all-fxnode-geometry-transform-geometry:scale", + "key": "scale", + "label": "Scale", + "direction": "input", + "dataType": "vector", + "accepts": [ + "vector", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "all-fxnode-geometry-transform-geometry:result", + "key": "result", + "label": "Geometry", + "direction": "output", + "dataType": "geometry", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "all-fxnode-shader-color", + "typeId": "fxnode.shader.color", + "typeVersion": 1, + "position": { + "x": 280, + "y": -280 + }, + "size": { + "x": 140, + "y": 100 + }, + "label": "Color", + "parameters": { + "color": { + "kind": "color", + "value": [ + 0.8, + 0.8, + 0.8, + 1 + ] + } + }, + "sockets": [ + { + "id": "all-fxnode-shader-color:color", + "key": "color", + "label": "Color", + "direction": "output", + "dataType": "color", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "all-fxnode-shader-color-ramp", + "typeId": "fxnode.shader.color-ramp", + "typeVersion": 2, + "position": { + "x": 1240, + "y": -280 + }, + "size": { + "x": 320, + "y": 100 + }, + "label": "Color Ramp", + "parameters": { + "ramp": { + "kind": "json", + "value": { + "colorMode": "hsv", + "interpolation": "ease", + "hueInterpolation": "far", + "stops": [ + { + "id": "black", + "position": 0, + "color": [ + 0, + 0, + 0, + 1 + ] + }, + { + "id": "accent", + "position": 0.38, + "color": [ + 0.05, + 0.35, + 1, + 1 + ] + }, + { + "id": "white", + "position": 1, + "color": [ + 1, + 1, + 1, + 1 + ] + } + ] + } + } + }, + "sockets": [ + { + "id": "all-fxnode-shader-color-ramp:factor", + "key": "factor", + "label": "Factor", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0.5 + } + }, + { + "id": "all-fxnode-shader-color-ramp:color", + "key": "color", + "label": "Color", + "direction": "output", + "dataType": "color", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + }, + { + "id": "all-fxnode-shader-color-ramp:alpha", + "key": "alpha", + "label": "Alpha", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "all-fxnode-shader-image-texture", + "typeId": "fxnode.shader.image-texture", + "typeVersion": 1, + "position": { + "x": 1960, + "y": -280 + }, + "size": { + "x": 280, + "y": 100 + }, + "label": "Image Texture", + "parameters": { + "image": { + "kind": "string", + "value": "" + }, + "interpolation": { + "kind": "string", + "value": "Linear" + }, + "projection": { + "kind": "string", + "value": "Flat" + }, + "blend": { + "kind": "number", + "value": 0 + }, + "extension": { + "kind": "string", + "value": "Repeat" + }, + "colorSpace": { + "kind": "string", + "value": "sRGB" + }, + "alphaMode": { + "kind": "string", + "value": "Straight" + } + }, + "sockets": [ + { + "id": "all-fxnode-shader-image-texture:vector", + "key": "vector", + "label": "Vector", + "direction": "input", + "dataType": "vector", + "accepts": [ + "vector", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [ + 0, + 0, + 0 + ] + } + }, + { + "id": "all-fxnode-shader-image-texture:color", + "key": "color", + "label": "Color", + "direction": "output", + "dataType": "color", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + }, + { + "id": "all-fxnode-shader-image-texture:alpha", + "key": "alpha", + "label": "Alpha", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "all-fxnode-shader-material-output", + "typeId": "fxnode.shader.material-output", + "typeVersion": 1, + "position": { + "x": 2440, + "y": -280 + }, + "size": { + "x": 190, + "y": 100 + }, + "label": "Material Output", + "parameters": {}, + "sockets": [ + { + "id": "all-fxnode-shader-material-output:surface", + "key": "surface", + "label": "Surface", + "direction": "input", + "dataType": "shader", + "accepts": [ + "shader", + "any" + ], + "maxIncomingLinks": 1, + "visible": true + }, + { + "id": "all-fxnode-shader-material-output:volume", + "key": "volume", + "label": "Volume", + "direction": "input", + "dataType": "shader", + "accepts": [ + "shader", + "any" + ], + "maxIncomingLinks": 1, + "visible": true + }, + { + "id": "all-fxnode-shader-material-output:displacement", + "key": "displacement", + "label": "Displacement", + "direction": "input", + "dataType": "vector", + "accepts": [ + "vector", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [ + 0, + 0, + 0 + ] + } + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "all-fxnode-shader-math", + "typeId": "fxnode.shader.math", + "typeVersion": 1, + "position": { + "x": 520, + "y": -280 + }, + "size": { + "x": 180, + "y": 100 + }, + "label": "Math", + "parameters": { + "operation": { + "kind": "string", + "value": "add" + }, + "clamp": { + "kind": "boolean", + "value": false + } + }, + "sockets": [ + { + "id": "all-fxnode-shader-math:a", + "key": "a", + "label": "A", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "all-fxnode-shader-math:b", + "key": "b", + "label": "B", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "all-fxnode-shader-math:value", + "key": "value", + "label": "Value", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "all-fxnode-shader-mix", + "typeId": "fxnode.shader.mix", + "typeVersion": 1, + "position": { + "x": 1000, + "y": -280 + }, + "size": { + "x": 190, + "y": 100 + }, + "label": "Mix", + "parameters": { + "blend": { + "kind": "string", + "value": "mix" + }, + "clamp": { + "kind": "boolean", + "value": false + } + }, + "sockets": [ + { + "id": "all-fxnode-shader-mix:factor", + "key": "factor", + "label": "Factor", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0.5 + } + }, + { + "id": "all-fxnode-shader-mix:a", + "key": "a", + "label": "A", + "direction": "input", + "dataType": "color", + "accepts": [ + "color", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "color", + "value": [ + 0.8, + 0.8, + 0.8, + 1 + ] + } + }, + { + "id": "all-fxnode-shader-mix:b", + "key": "b", + "label": "B", + "direction": "input", + "dataType": "color", + "accepts": [ + "color", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "color", + "value": [ + 0.8, + 0.8, + 0.8, + 1 + ] + } + }, + { + "id": "all-fxnode-shader-mix:result", + "key": "result", + "label": "Result", + "direction": "output", + "dataType": "color", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "all-fxnode-shader-noise-texture", + "typeId": "fxnode.shader.noise-texture", + "typeVersion": 2, + "position": { + "x": 1720, + "y": -280 + }, + "size": { + "x": 200, + "y": 100 + }, + "label": "Noise Texture", + "parameters": { + "dimensions": { + "kind": "string", + "value": "4d" + }, + "noiseType": { + "kind": "string", + "value": "hybrid-multifractal" + }, + "normalize": { + "kind": "boolean", + "value": false + } + }, + "sockets": [ + { + "id": "all-fxnode-shader-noise-texture:vector", + "key": "vector", + "label": "Vector", + "direction": "input", + "dataType": "vector", + "accepts": [ + "vector", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [ + 0, + 0, + 0 + ] + } + }, + { + "id": "all-fxnode-shader-noise-texture:w", + "key": "w", + "label": "W", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "all-fxnode-shader-noise-texture:scale", + "key": "scale", + "label": "Scale", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 5 + } + }, + { + "id": "all-fxnode-shader-noise-texture:detail", + "key": "detail", + "label": "Detail", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 2 + } + }, + { + "id": "all-fxnode-shader-noise-texture:roughness", + "key": "roughness", + "label": "Roughness", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0.5 + } + }, + { + "id": "all-fxnode-shader-noise-texture:lacunarity", + "key": "lacunarity", + "label": "Lacunarity", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 2 + } + }, + { + "id": "all-fxnode-shader-noise-texture:offset", + "key": "offset", + "label": "Offset", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "all-fxnode-shader-noise-texture:gain", + "key": "gain", + "label": "Gain", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 1 + } + }, + { + "id": "all-fxnode-shader-noise-texture:distortion", + "key": "distortion", + "label": "Distortion", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "all-fxnode-shader-noise-texture:factor", + "key": "factor", + "label": "Factor", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + }, + { + "id": "all-fxnode-shader-noise-texture:color", + "key": "color", + "label": "Color", + "direction": "output", + "dataType": "color", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "all-fxnode-shader-principled-bsdf", + "typeId": "fxnode.shader.principled-bsdf", + "typeVersion": 1, + "position": { + "x": 2200, + "y": -280 + }, + "size": { + "x": 220, + "y": 100 + }, + "label": "Principled BSDF", + "parameters": {}, + "sockets": [ + { + "id": "all-fxnode-shader-principled-bsdf:base-color", + "key": "base-color", + "label": "Base Color", + "direction": "input", + "dataType": "color", + "accepts": [ + "color", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "color", + "value": [ + 0.8, + 0.8, + 0.8, + 1 + ] + } + }, + { + "id": "all-fxnode-shader-principled-bsdf:metallic", + "key": "metallic", + "label": "Metallic", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "all-fxnode-shader-principled-bsdf:roughness", + "key": "roughness", + "label": "Roughness", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0.5 + } + }, + { + "id": "all-fxnode-shader-principled-bsdf:ior", + "key": "ior", + "label": "IOR", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 1.5 + } + }, + { + "id": "all-fxnode-shader-principled-bsdf:alpha", + "key": "alpha", + "label": "Alpha", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 1 + } + }, + { + "id": "all-fxnode-shader-principled-bsdf:normal", + "key": "normal", + "label": "Normal", + "direction": "input", + "dataType": "vector", + "accepts": [ + "vector", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [ + 0, + 0, + 0 + ] + } + }, + { + "id": "all-fxnode-shader-principled-bsdf:bsdf", + "key": "bsdf", + "label": "BSDF", + "direction": "output", + "dataType": "shader", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "all-fxnode-shader-texture-coordinate", + "typeId": "fxnode.shader.texture-coordinate", + "typeVersion": 1, + "position": { + "x": 1480, + "y": -280 + }, + "size": { + "x": 190, + "y": 100 + }, + "label": "Texture Coordinate", + "parameters": {}, + "sockets": [ + { + "id": "all-fxnode-shader-texture-coordinate:generated", + "key": "generated", + "label": "Generated", + "direction": "output", + "dataType": "vector", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + }, + { + "id": "all-fxnode-shader-texture-coordinate:normal", + "key": "normal", + "label": "Normal", + "direction": "output", + "dataType": "vector", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + }, + { + "id": "all-fxnode-shader-texture-coordinate:uv", + "key": "uv", + "label": "UV", + "direction": "output", + "dataType": "vector", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + }, + { + "id": "all-fxnode-shader-texture-coordinate:object", + "key": "object", + "label": "Object", + "direction": "output", + "dataType": "vector", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "all-fxnode-shader-value", + "typeId": "fxnode.shader.value", + "typeVersion": 1, + "position": { + "x": 40, + "y": -280 + }, + "size": { + "x": 140, + "y": 100 + }, + "label": "Value", + "parameters": { + "value": { + "kind": "number", + "value": 0 + } + }, + "sockets": [ + { + "id": "all-fxnode-shader-value:value", + "key": "value", + "label": "Value", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "all-fxnode-shader-vector-math", + "typeId": "fxnode.shader.vector-math", + "typeVersion": 1, + "position": { + "x": 760, + "y": -280 + }, + "size": { + "x": 190, + "y": 100 + }, + "label": "Vector Math", + "parameters": { + "operation": { + "kind": "string", + "value": "add" + } + }, + "sockets": [ + { + "id": "all-fxnode-shader-vector-math:a", + "key": "a", + "label": "A", + "direction": "input", + "dataType": "vector", + "accepts": [ + "vector", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [ + 0, + 0, + 0 + ] + } + }, + { + "id": "all-fxnode-shader-vector-math:b", + "key": "b", + "label": "B", + "direction": "input", + "dataType": "vector", + "accepts": [ + "vector", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [ + 0, + 0, + 0 + ] + } + }, + { + "id": "all-fxnode-shader-vector-math:vector", + "key": "vector", + "label": "Vector", + "direction": "output", + "dataType": "vector", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + }, + { + "id": "all-fxnode-shader-vector-math:value", + "key": "value", + "label": "Value", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + } + ], + "links": [ + { + "id": "all-link-cube", + "fromNodeId": "all-fxnode-geometry-mesh-cube", + "fromSocketId": "all-fxnode-geometry-mesh-cube:mesh", + "toNodeId": "all-fxnode-geometry-set-position", + "toSocketId": "all-fxnode-geometry-set-position:geometry", + "muted": false, + "extensions": {} + }, + { + "id": "all-link-position", + "fromNodeId": "all-fxnode-geometry-position", + "fromSocketId": "all-fxnode-geometry-position:position", + "toNodeId": "all-fxnode-geometry-set-position", + "toSocketId": "all-fxnode-geometry-set-position:position", + "muted": false, + "extensions": {} + }, + { + "id": "all-link-set", + "fromNodeId": "all-fxnode-geometry-set-position", + "fromSocketId": "all-fxnode-geometry-set-position:result", + "toNodeId": "all-fxnode-geometry-join-geometry", + "toSocketId": "all-fxnode-geometry-join-geometry:geometry", + "muted": false, + "extensions": {} + }, + { + "id": "all-link-transform", + "fromNodeId": "all-fxnode-geometry-transform-geometry", + "fromSocketId": "all-fxnode-geometry-transform-geometry:result", + "toNodeId": "all-fxnode-geometry-join-geometry", + "toSocketId": "all-fxnode-geometry-join-geometry:geometry", + "muted": false, + "extensions": {} + } + ], + "metadata": {} +} diff --git a/vendor/fxnode/examples/blender/all-supported/main.ts b/vendor/fxnode/examples/blender/all-supported/main.ts new file mode 100644 index 0000000..e164613 --- /dev/null +++ b/vendor/fxnode/examples/blender/all-supported/main.ts @@ -0,0 +1,20 @@ +import { createApplicationFxNode } from "../application-browser.js"; +import { prepareFxNodeBrowserHost } from "../../shared/browser-host.js"; +import initialLayout from "./initialLayout.json" with { type: "json" }; +const canvas = document.querySelector("canvas")!, + host = prepareFxNodeBrowserHost({ canvas }); +let root: Awaited> | undefined, + view: Awaited["attachView"]>> | undefined; +try { + root = await createApplicationFxNode(); + await root.setState(initialLayout); + view = await root.attachView({ canvas, viewport: host.initialViewport }); + host.attach(root, view); + (window as unknown as { fxnodeExample: unknown }).fxnodeExample = { root, view }; + await view.whenRendered(); +} catch (error) { + host.destroy(); + await view?.detach(); + root?.destroy(); + throw error; +} diff --git a/vendor/fxnode/examples/blender/all-supported/style.css b/vendor/fxnode/examples/blender/all-supported/style.css new file mode 100644 index 0000000..bca77f7 --- /dev/null +++ b/vendor/fxnode/examples/blender/all-supported/style.css @@ -0,0 +1,13 @@ +html, +body { + margin: 0; + width: 100%; + height: 100%; + overflow: hidden; + background: #202124; +} +canvas { + display: block; + width: 100%; + height: 100%; +} diff --git a/vendor/fxnode/examples/blender/application-browser.ts b/vendor/fxnode/examples/blender/application-browser.ts new file mode 100644 index 0000000..63ec41c --- /dev/null +++ b/vendor/fxnode/examples/blender/application-browser.ts @@ -0,0 +1,86 @@ +import { createFxNode, type CreateFxNodeOptions, type FxNode } from "@lib/index.js"; +import { + APPLICATION_HEADER_STYLES, + APPLICATION_ID, + APPLICATION_RESOURCES, + APPLICATION_VERSION, + applicationCompatibility, +} from "./nodes/application.js"; +import { exampleTheme } from "../shared/theme.js"; +import { frameNode } from "./nodes/common/frame.js"; +import { rerouteNode } from "./nodes/common/reroute.js"; +import { + anySocket, + floatSocket, + vectorSocket, + colorSocket, + shaderSocket, + geometrySocket, +} from "./nodes/socket-types.js"; +import { groupInputNode } from "./nodes/common/group-input.js"; +import { groupOutputNode } from "./nodes/common/group-output.js"; +import { valueNode } from "./nodes/shader/value.js"; +import { colorNode } from "./nodes/shader/color.js"; +import { mathNode } from "./nodes/shader/math.js"; +import { vectorMathNode } from "./nodes/shader/vector-math.js"; +import { mixNode } from "./nodes/shader/mix.js"; +import { colorRampNode } from "./nodes/shader/color-ramp.js"; +import { textureCoordinateNode } from "./nodes/shader/texture-coordinate.js"; +import { noiseTextureNode } from "./nodes/shader/noise-texture.js"; +import { imageTextureNode } from "./nodes/shader/image-texture.js"; +import { principledBsdfNode } from "./nodes/shader/principled-bsdf.js"; +import { materialOutputNode } from "./nodes/shader/material-output.js"; +import { positionNode } from "./nodes/geometry/position.js"; +import { meshCubeNode } from "./nodes/geometry/mesh-cube.js"; +import { setPositionNode } from "./nodes/geometry/set-position.js"; +import { transformGeometryNode } from "./nodes/geometry/transform-geometry.js"; +import { joinGeometryNode } from "./nodes/geometry/join-geometry.js"; +import { imageNode } from "./nodes/compositor/image.js"; +import { colorBalanceNode } from "../shared/nodes/color-balance.js"; +export type ApplicationFxNode = FxNode; +type ApplicationBrowserOptions = Omit; +export async function createApplicationFxNode(options: ApplicationBrowserOptions = {}): Promise { + const api = await createFxNode({ + ...options, + applicationId: APPLICATION_ID, + applicationVersion: APPLICATION_VERSION, + resources: APPLICATION_RESOURCES, + }); + try { + await api.setTheme(exampleTheme); + await api.setHeaderStyles(APPLICATION_HEADER_STYLES); + await api.composeSocket(...anySocket); + await api.composeSocket(...floatSocket); + await api.composeSocket(...vectorSocket); + await api.composeSocket(...colorSocket); + await api.composeSocket(...shaderSocket); + await api.composeSocket(...geometrySocket); + await api.setCompatibility(applicationCompatibility); + await api.composeNode(...frameNode); + await api.composeNode(...rerouteNode); + await api.composeNode(...groupInputNode); + await api.composeNode(...groupOutputNode); + await api.composeNode(...valueNode); + await api.composeNode(...colorNode); + await api.composeNode(...mathNode); + await api.composeNode(...vectorMathNode); + await api.composeNode(...mixNode); + await api.composeNode(...colorRampNode); + await api.composeNode(...textureCoordinateNode); + await api.composeNode(...noiseTextureNode); + await api.composeNode(...imageTextureNode); + await api.composeNode(...principledBsdfNode); + await api.composeNode(...materialOutputNode); + await api.composeNode(...positionNode); + await api.composeNode(...meshCubeNode); + await api.composeNode(...setPositionNode); + await api.composeNode(...transformGeometryNode); + await api.composeNode(...joinGeometryNode); + await api.composeNode(...imageNode); + await api.composeNode(...colorBalanceNode); + return api; + } catch (error) { + api.destroy(); + throw error; + } +} diff --git a/vendor/fxnode/examples/blender/control-test/index.html b/vendor/fxnode/examples/blender/control-test/index.html new file mode 100644 index 0000000..88329b1 --- /dev/null +++ b/vendor/fxnode/examples/blender/control-test/index.html @@ -0,0 +1,23 @@ + + + + + FxNode controls + + + + + + + diff --git a/vendor/fxnode/examples/blender/control-test/initialLayout.json b/vendor/fxnode/examples/blender/control-test/initialLayout.json new file mode 100644 index 0000000..101982b --- /dev/null +++ b/vendor/fxnode/examples/blender/control-test/initialLayout.json @@ -0,0 +1,293 @@ +{ + "graphId": "control-test", + "catalogVersion": 4, + "nodes": [ + { + "id": "color", + "typeId": "fxnode.shader.color", + "typeVersion": 1, + "known": true, + "position": { + "x": 300, + "y": 200 + }, + "size": { + "x": 151, + "y": 100 + }, + "label": "Color", + "parameters": { + "color": { + "kind": "color", + "value": [ + 0.8, + 0.8, + 0.8, + 1 + ] + } + }, + "sockets": [ + { + "id": "color:color", + "key": "color", + "label": "Color", + "direction": "output", + "dataType": "color", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {} + }, + { + "id": "group", + "typeId": "fxnode.common.group-input", + "typeVersion": 1, + "known": true, + "position": { + "x": -100, + "y": -100 + }, + "size": { + "x": 257, + "y": 100 + }, + "label": "Group Input", + "parameters": { + "interfaceName": { + "kind": "string", + "value": "Socket" + } + }, + "sockets": [ + { + "id": "group:output", + "key": "output", + "label": "Interface", + "direction": "output", + "dataType": "any", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {} + }, + { + "id": "math", + "typeId": "fxnode.shader.math", + "typeVersion": 1, + "known": true, + "position": { + "x": -250, + "y": 200 + }, + "size": { + "x": 192, + "y": 148 + }, + "label": "Math", + "parameters": { + "operation": { + "kind": "string", + "value": "add" + }, + "clamp": { + "kind": "boolean", + "value": false + } + }, + "sockets": [ + { + "id": "math:a", + "key": "a", + "label": "A", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "math:b", + "key": "b", + "label": "B", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "math:value", + "key": "value", + "label": "Value", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {} + }, + { + "id": "value", + "typeId": "fxnode.shader.value", + "typeVersion": 1, + "known": true, + "position": { + "x": -500, + "y": 200 + }, + "size": { + "x": 151, + "y": 100 + }, + "label": "Value", + "parameters": { + "value": { + "kind": "number", + "value": 0 + } + }, + "sockets": [ + { + "id": "value:value", + "key": "value", + "label": "Value", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {} + }, + { + "id": "vector", + "typeId": "fxnode.shader.vector-math", + "typeVersion": 1, + "known": true, + "position": { + "x": 20, + "y": 200 + }, + "size": { + "x": 340, + "y": 148 + }, + "label": "Vector Math", + "parameters": { + "operation": { + "kind": "string", + "value": "add" + } + }, + "sockets": [ + { + "id": "vector:a", + "key": "a", + "label": "A", + "direction": "input", + "dataType": "vector", + "accepts": [ + "vector", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [ + 0, + 0, + 0 + ] + } + }, + { + "id": "vector:b", + "key": "b", + "label": "B", + "direction": "input", + "dataType": "vector", + "accepts": [ + "vector", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [ + 0, + 0, + 0 + ] + } + }, + { + "id": "vector:vector", + "key": "vector", + "label": "Vector", + "direction": "output", + "dataType": "vector", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + }, + { + "id": "vector:value", + "key": "value", + "label": "Value", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {} + } + ], + "links": [ + { + "id": "value-math", + "fromNodeId": "value", + "fromSocketId": "value:value", + "toNodeId": "math", + "toSocketId": "math:a", + "muted": false, + "extensions": {} + } + ], + "metadata": {} +} diff --git a/vendor/fxnode/examples/blender/control-test/main.ts b/vendor/fxnode/examples/blender/control-test/main.ts new file mode 100644 index 0000000..5c6eb6e --- /dev/null +++ b/vendor/fxnode/examples/blender/control-test/main.ts @@ -0,0 +1,30 @@ +import { createApplicationFxNode, type ApplicationFxNode } from "../application-browser.js"; +import type { FxNodeView } from "@lib/index.js"; +import { prepareFxNodeBrowserHost } from "../../shared/browser-host.js"; +import initialLayout from "./initialLayout.json" with { type: "json" }; +const canvas = document.querySelector("#controls"); +if (!canvas) throw new Error("Control test canvas missing"); +const host = prepareFxNodeBrowserHost({ canvas }); +const handle: { root: ApplicationFxNode | null; view: FxNodeView | null; ready: Promise } = { + root: null, + view: null, + ready: Promise.resolve(), +}; +window.controlTest = handle; +handle.ready = (async () => { + let root: ApplicationFxNode | undefined, view: FxNodeView | undefined; + try { + root = await createApplicationFxNode(); + await root.setState(initialLayout); + view = await root.attachView({ canvas, viewport: host.initialViewport }); + handle.root = root; + handle.view = view; + host.attach(root, view); + await view.whenRendered(); + } catch (error) { + host.destroy(); + await view?.detach(); + root?.destroy(); + throw error; + } +})(); diff --git a/vendor/fxnode/examples/blender/globals.d.ts b/vendor/fxnode/examples/blender/globals.d.ts new file mode 100644 index 0000000..32f996f --- /dev/null +++ b/vendor/fxnode/examples/blender/globals.d.ts @@ -0,0 +1,18 @@ +import type { FxNode, FxNodeView } from "@lib/index.js"; +import type { PreparedFxNodeBrowserHost } from "../shared/browser-host.js"; + +declare global { + interface FxNodeExampleHandle { + root: FxNode | null; + view: FxNodeView | null; + host: PreparedFxNodeBrowserHost; + ready: Promise; + readonly rendered: Promise; + } + interface Window { + fxnodeExample: FxNodeExampleHandle; + linkToolsTest: { root: FxNode | null; view: FxNodeView | null; ready: Promise }; + } +} + +export {}; diff --git a/vendor/fxnode/examples/blender/index.html b/vendor/fxnode/examples/blender/index.html new file mode 100644 index 0000000..28e3534 --- /dev/null +++ b/vendor/fxnode/examples/blender/index.html @@ -0,0 +1,304 @@ + + + + + + fxnode deterministic browser example + + + +

+

fxnode browser baseline

+

Deterministic fxnode regression composition; genuine Blender parity references remain pending.

+ +
+ + + + diff --git a/vendor/fxnode/examples/blender/initialLayout.json b/vendor/fxnode/examples/blender/initialLayout.json new file mode 100644 index 0000000..26ec701 --- /dev/null +++ b/vendor/fxnode/examples/blender/initialLayout.json @@ -0,0 +1,697 @@ +{ + "graphId": "phase-4-example", + "catalogVersion": 4, + "nodes": [ + { + "id": "frame", + "typeId": "fxnode.common.frame", + "typeVersion": 1, + "position": { + "x": -550, + "y": 250 + }, + "size": { + "x": 190, + "y": 270 + }, + "label": "Surface Controls", + "parameters": {}, + "sockets": [], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "math", + "typeId": "fxnode.shader.math", + "typeVersion": 1, + "position": { + "x": -300, + "y": 170 + }, + "size": { + "x": 160, + "y": 110 + }, + "label": "Math", + "parameters": { + "operation": { + "kind": "string", + "value": "add" + }, + "clamp": { + "kind": "boolean", + "value": false + } + }, + "sockets": [ + { + "id": "math:a", + "key": "a", + "label": "A", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "math:b", + "key": "b", + "label": "B", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "math:value", + "key": "value", + "label": "Value", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "noise", + "typeId": "fxnode.shader.noise-texture", + "typeVersion": 2, + "position": { + "x": -100, + "y": 190 + }, + "size": { + "x": 165, + "y": 130 + }, + "label": "Noise Texture", + "parameters": { + "dimensions": { + "kind": "string", + "value": "3d" + }, + "noiseType": { + "kind": "string", + "value": "fbm" + }, + "normalize": { + "kind": "boolean", + "value": false + } + }, + "sockets": [ + { + "id": "noise:vector", + "key": "vector", + "label": "Vector", + "direction": "input", + "dataType": "vector", + "accepts": [ + "vector", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [ + 0, + 0, + 0 + ] + } + }, + { + "id": "noise:w", + "key": "w", + "label": "W", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "noise:scale", + "key": "scale", + "label": "Scale", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 5 + } + }, + { + "id": "noise:detail", + "key": "detail", + "label": "Detail", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 2 + } + }, + { + "id": "noise:roughness", + "key": "roughness", + "label": "Roughness", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0.5 + } + }, + { + "id": "noise:lacunarity", + "key": "lacunarity", + "label": "Lacunarity", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 2 + } + }, + { + "id": "noise:offset", + "key": "offset", + "label": "Offset", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "noise:gain", + "key": "gain", + "label": "Gain", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 1 + } + }, + { + "id": "noise:distortion", + "key": "distortion", + "label": "Distortion", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "noise:factor", + "key": "factor", + "label": "Factor", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + }, + { + "id": "noise:color", + "key": "color", + "label": "Color", + "direction": "output", + "dataType": "color", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "output", + "typeId": "fxnode.shader.material-output", + "typeVersion": 1, + "position": { + "x": 405, + "y": 115 + }, + "size": { + "x": 155, + "y": 100 + }, + "label": "Material Output", + "parameters": {}, + "sockets": [ + { + "id": "output:surface", + "key": "surface", + "label": "Surface", + "direction": "input", + "dataType": "shader", + "accepts": [ + "shader", + "any" + ], + "maxIncomingLinks": 1, + "visible": true + }, + { + "id": "output:volume", + "key": "volume", + "label": "Volume", + "direction": "input", + "dataType": "shader", + "accepts": [ + "shader", + "any" + ], + "maxIncomingLinks": 1, + "visible": true + }, + { + "id": "output:displacement", + "key": "displacement", + "label": "Displacement", + "direction": "input", + "dataType": "vector", + "accepts": [ + "vector", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [ + 0, + 0, + 0 + ] + } + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "principled", + "typeId": "fxnode.shader.principled-bsdf", + "typeVersion": 1, + "position": { + "x": 165, + "y": 175 + }, + "size": { + "x": 185, + "y": 125 + }, + "label": "Principled BSDF", + "parameters": {}, + "sockets": [ + { + "id": "principled:base-color", + "key": "base-color", + "label": "Base Color", + "direction": "input", + "dataType": "color", + "accepts": [ + "color", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "color", + "value": [ + 0.8, + 0.8, + 0.8, + 1 + ] + } + }, + { + "id": "principled:metallic", + "key": "metallic", + "label": "Metallic", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "principled:roughness", + "key": "roughness", + "label": "Roughness", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0.5 + } + }, + { + "id": "principled:ior", + "key": "ior", + "label": "IOR", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 1.5 + } + }, + { + "id": "principled:alpha", + "key": "alpha", + "label": "Alpha", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 1 + } + }, + { + "id": "principled:normal", + "key": "normal", + "label": "Normal", + "direction": "input", + "dataType": "vector", + "accepts": [ + "vector", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [ + 0, + 0, + 0 + ] + } + }, + { + "id": "principled:bsdf", + "key": "bsdf", + "label": "BSDF", + "direction": "output", + "dataType": "shader", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "reroute", + "typeId": "fxnode.common.reroute", + "typeVersion": 1, + "position": { + "x": 105, + "y": 55 + }, + "size": { + "x": 140, + "y": 100 + }, + "label": "Reroute", + "parameters": {}, + "sockets": [ + { + "id": "reroute:input", + "key": "input", + "label": "Input", + "direction": "input", + "dataType": "any", + "accepts": [ + "any" + ], + "maxIncomingLinks": 1, + "visible": true + }, + { + "id": "reroute:output", + "key": "output", + "label": "Output", + "direction": "output", + "dataType": "any", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "value-collapsed", + "typeId": "fxnode.shader.value", + "typeVersion": 1, + "position": { + "x": 30, + "y": -175 + }, + "size": { + "x": 140, + "y": 100 + }, + "label": "Value", + "parameters": { + "value": { + "kind": "number", + "value": 0 + } + }, + "sockets": [ + { + "id": "value-collapsed:value", + "key": "value", + "label": "Value", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": true, + "extensions": {}, + "parentId": "frame", + "known": true + }, + { + "id": "value-expanded", + "typeId": "fxnode.shader.value", + "typeVersion": 1, + "position": { + "x": 30, + "y": -55 + }, + "size": { + "x": 140, + "y": 100 + }, + "label": "Value", + "parameters": { + "value": { + "kind": "number", + "value": 0 + } + }, + "sockets": [ + { + "id": "value-expanded:value", + "key": "value", + "label": "Value", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "parentId": "frame", + "known": true + } + ], + "links": [ + { + "id": "link-1", + "fromNodeId": "value-expanded", + "fromSocketId": "value-expanded:value", + "toNodeId": "math", + "toSocketId": "math:a", + "muted": false, + "extensions": {} + }, + { + "id": "link-2", + "fromNodeId": "value-collapsed", + "fromSocketId": "value-collapsed:value", + "toNodeId": "math", + "toSocketId": "math:b", + "muted": false, + "extensions": {} + }, + { + "id": "link-3", + "fromNodeId": "math", + "fromSocketId": "math:value", + "toNodeId": "noise", + "toSocketId": "noise:scale", + "muted": false, + "extensions": {} + }, + { + "id": "link-4", + "fromNodeId": "noise", + "fromSocketId": "noise:factor", + "toNodeId": "reroute", + "toSocketId": "reroute:input", + "muted": false, + "extensions": {} + }, + { + "id": "link-5", + "fromNodeId": "reroute", + "fromSocketId": "reroute:output", + "toNodeId": "principled", + "toSocketId": "principled:roughness", + "muted": false, + "extensions": {} + }, + { + "id": "link-6", + "fromNodeId": "principled", + "fromSocketId": "principled:bsdf", + "toNodeId": "output", + "toSocketId": "output:surface", + "muted": false, + "extensions": {} + } + ], + "metadata": { + "description": "Deterministic fxnode browser baseline" + } +} diff --git a/vendor/fxnode/examples/blender/link-tools-test/index.html b/vendor/fxnode/examples/blender/link-tools-test/index.html new file mode 100644 index 0000000..75f6cb1 --- /dev/null +++ b/vendor/fxnode/examples/blender/link-tools-test/index.html @@ -0,0 +1,23 @@ + + + + + FxNode link tools test + + + + + + + diff --git a/vendor/fxnode/examples/blender/link-tools-test/initialLayout.json b/vendor/fxnode/examples/blender/link-tools-test/initialLayout.json new file mode 100644 index 0000000..42aa797 --- /dev/null +++ b/vendor/fxnode/examples/blender/link-tools-test/initialLayout.json @@ -0,0 +1,855 @@ +{ + "graphId": "link-tools-test", + "catalogVersion": 4, + "nodes": [ + { + "id": "chain-math", + "typeId": "fxnode.shader.math", + "typeVersion": 1, + "known": true, + "position": { + "x": 260, + "y": -100 + }, + "size": { + "x": 192, + "y": 148 + }, + "label": "Math", + "parameters": { + "operation": { + "kind": "string", + "value": "add" + }, + "clamp": { + "kind": "boolean", + "value": false + } + }, + "sockets": [ + { + "id": "chain-math:a", + "key": "a", + "label": "A", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "chain-math:b", + "key": "b", + "label": "B", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "chain-math:value", + "key": "value", + "label": "Value", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {} + }, + { + "id": "chain-source", + "typeId": "fxnode.shader.value", + "typeVersion": 1, + "known": true, + "position": { + "x": -560, + "y": -100 + }, + "size": { + "x": 151, + "y": 100 + }, + "label": "Value", + "parameters": { + "value": { + "kind": "number", + "value": 0 + } + }, + "sockets": [ + { + "id": "chain-source:value", + "key": "value", + "label": "Value", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {} + }, + { + "id": "math-a", + "typeId": "fxnode.shader.math", + "typeVersion": 1, + "known": true, + "position": { + "x": 180, + "y": 240 + }, + "size": { + "x": 192, + "y": 148 + }, + "label": "Math", + "parameters": { + "operation": { + "kind": "string", + "value": "add" + }, + "clamp": { + "kind": "boolean", + "value": false + } + }, + "sockets": [ + { + "id": "math-a:a", + "key": "a", + "label": "A", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "math-a:b", + "key": "b", + "label": "B", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "math-a:value", + "key": "value", + "label": "Value", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {} + }, + { + "id": "math-b", + "typeId": "fxnode.shader.math", + "typeVersion": 1, + "known": true, + "position": { + "x": 180, + "y": 120 + }, + "size": { + "x": 192, + "y": 148 + }, + "label": "Math", + "parameters": { + "operation": { + "kind": "string", + "value": "add" + }, + "clamp": { + "kind": "boolean", + "value": false + } + }, + "sockets": [ + { + "id": "math-b:a", + "key": "a", + "label": "A", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "math-b:b", + "key": "b", + "label": "B", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "math-b:value", + "key": "value", + "label": "Value", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {} + }, + { + "id": "math-c", + "typeId": "fxnode.shader.math", + "typeVersion": 1, + "known": true, + "position": { + "x": 180, + "y": 0 + }, + "size": { + "x": 192, + "y": 148 + }, + "label": "Math", + "parameters": { + "operation": { + "kind": "string", + "value": "add" + }, + "clamp": { + "kind": "boolean", + "value": false + } + }, + "sockets": [ + { + "id": "math-c:a", + "key": "a", + "label": "A", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "math-c:b", + "key": "b", + "label": "B", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "math-c:value", + "key": "value", + "label": "Value", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {} + }, + { + "id": "noise", + "typeId": "fxnode.shader.noise-texture", + "typeVersion": 2, + "known": true, + "position": { + "x": 180, + "y": -230 + }, + "size": { + "x": 286, + "y": 292 + }, + "label": "Noise Texture", + "parameters": { + "dimensions": { + "kind": "string", + "value": "3d" + }, + "noiseType": { + "kind": "string", + "value": "fbm" + }, + "normalize": { + "kind": "boolean", + "value": false + } + }, + "sockets": [ + { + "id": "noise:vector", + "key": "vector", + "label": "Vector", + "direction": "input", + "dataType": "vector", + "accepts": [ + "vector", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [ + 0, + 0, + 0 + ] + } + }, + { + "id": "noise:w", + "key": "w", + "label": "W", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "noise:scale", + "key": "scale", + "label": "Scale", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 5 + } + }, + { + "id": "noise:detail", + "key": "detail", + "label": "Detail", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 2 + } + }, + { + "id": "noise:roughness", + "key": "roughness", + "label": "Roughness", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0.5 + } + }, + { + "id": "noise:lacunarity", + "key": "lacunarity", + "label": "Lacunarity", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 2 + } + }, + { + "id": "noise:offset", + "key": "offset", + "label": "Offset", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "noise:gain", + "key": "gain", + "label": "Gain", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 1 + } + }, + { + "id": "noise:distortion", + "key": "distortion", + "label": "Distortion", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "noise:factor", + "key": "factor", + "label": "Factor", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + }, + { + "id": "noise:color", + "key": "color", + "label": "Color", + "direction": "output", + "dataType": "color", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {} + }, + { + "id": "reroute", + "typeId": "fxnode.common.reroute", + "typeVersion": 1, + "known": true, + "position": { + "x": 0, + "y": -100 + }, + "size": { + "x": 10, + "y": 10 + }, + "label": "Reroute", + "parameters": {}, + "sockets": [ + { + "id": "reroute:input", + "key": "input", + "label": "Input", + "direction": "input", + "dataType": "any", + "accepts": [ + "any" + ], + "maxIncomingLinks": 1, + "visible": true + }, + { + "id": "reroute:output", + "key": "output", + "label": "Output", + "direction": "output", + "dataType": "any", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {} + }, + { + "id": "source-a", + "typeId": "fxnode.shader.value", + "typeVersion": 1, + "known": true, + "position": { + "x": -560, + "y": 240 + }, + "size": { + "x": 151, + "y": 100 + }, + "label": "Value", + "parameters": { + "value": { + "kind": "number", + "value": 0 + } + }, + "sockets": [ + { + "id": "source-a:value", + "key": "value", + "label": "Value", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {} + }, + { + "id": "source-b", + "typeId": "fxnode.shader.value", + "typeVersion": 1, + "known": true, + "position": { + "x": -560, + "y": 120 + }, + "size": { + "x": 151, + "y": 100 + }, + "label": "Value", + "parameters": { + "value": { + "kind": "number", + "value": 0 + } + }, + "sockets": [ + { + "id": "source-b:value", + "key": "value", + "label": "Value", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {} + }, + { + "id": "source-c", + "typeId": "fxnode.shader.value", + "typeVersion": 1, + "known": true, + "position": { + "x": -560, + "y": 0 + }, + "size": { + "x": 151, + "y": 100 + }, + "label": "Value", + "parameters": { + "value": { + "kind": "number", + "value": 0 + } + }, + "sockets": [ + { + "id": "source-c:value", + "key": "value", + "label": "Value", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {} + }, + { + "id": "transform", + "typeId": "fxnode.geometry.transform-geometry", + "typeVersion": 1, + "known": true, + "position": { + "x": -300, + "y": -230 + }, + "size": { + "x": 340, + "y": 148 + }, + "label": "Transform Geometry", + "parameters": {}, + "sockets": [ + { + "id": "transform:geometry", + "key": "geometry", + "label": "Geometry", + "direction": "input", + "dataType": "geometry", + "accepts": [ + "geometry", + "any" + ], + "maxIncomingLinks": 1, + "visible": true + }, + { + "id": "transform:translation", + "key": "translation", + "label": "Translation", + "direction": "input", + "dataType": "vector", + "accepts": [ + "vector", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [ + 0, + 0, + 0 + ] + } + }, + { + "id": "transform:rotation", + "key": "rotation", + "label": "Rotation", + "direction": "input", + "dataType": "vector", + "accepts": [ + "vector", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [ + 0, + 0, + 0 + ] + } + }, + { + "id": "transform:scale", + "key": "scale", + "label": "Scale", + "direction": "input", + "dataType": "vector", + "accepts": [ + "vector", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [ + 1, + 1, + 1 + ] + } + }, + { + "id": "transform:result", + "key": "result", + "label": "Geometry", + "direction": "output", + "dataType": "geometry", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {} + } + ], + "links": [ + { + "id": "chain-in", + "fromNodeId": "chain-source", + "fromSocketId": "chain-source:value", + "toNodeId": "reroute", + "toSocketId": "reroute:input", + "muted": false, + "extensions": {} + }, + { + "id": "chain-out", + "fromNodeId": "reroute", + "fromSocketId": "reroute:output", + "toNodeId": "chain-math", + "toSocketId": "chain-math:a", + "muted": false, + "extensions": {} + }, + { + "id": "parallel-a", + "fromNodeId": "source-a", + "fromSocketId": "source-a:value", + "toNodeId": "math-a", + "toSocketId": "math-a:a", + "muted": false, + "extensions": {} + }, + { + "id": "parallel-b", + "fromNodeId": "source-b", + "fromSocketId": "source-b:value", + "toNodeId": "math-b", + "toSocketId": "math-b:a", + "muted": false, + "extensions": {} + }, + { + "id": "parallel-c", + "fromNodeId": "source-c", + "fromSocketId": "source-c:value", + "toNodeId": "math-c", + "toSocketId": "math-c:a", + "muted": false, + "extensions": {} + } + ], + "metadata": {} +} diff --git a/vendor/fxnode/examples/blender/link-tools-test/main.ts b/vendor/fxnode/examples/blender/link-tools-test/main.ts new file mode 100644 index 0000000..616c190 --- /dev/null +++ b/vendor/fxnode/examples/blender/link-tools-test/main.ts @@ -0,0 +1,30 @@ +import { createApplicationFxNode, type ApplicationFxNode } from "../application-browser.js"; +import type { FxNodeView } from "@lib/index.js"; +import { prepareFxNodeBrowserHost } from "../../shared/browser-host.js"; +import initialLayout from "./initialLayout.json" with { type: "json" }; +const canvas = document.querySelector("#link-tools"); +if (!canvas) throw new Error("Link tools test canvas missing"); +const host = prepareFxNodeBrowserHost({ canvas }); +const handle: { root: ApplicationFxNode | null; view: FxNodeView | null; ready: Promise } = { + root: null, + view: null, + ready: Promise.resolve(), +}; +window.linkToolsTest = handle; +handle.ready = (async () => { + let root: ApplicationFxNode | undefined, view: FxNodeView | undefined; + try { + root = await createApplicationFxNode(); + await root.setState(initialLayout); + view = await root.attachView({ canvas, viewport: host.initialViewport }); + handle.root = root; + handle.view = view; + host.attach(root, view); + await view.whenRendered(); + } catch (error) { + host.destroy(); + await view?.detach(); + root?.destroy(); + throw error; + } +})(); diff --git a/vendor/fxnode/examples/blender/main.ts b/vendor/fxnode/examples/blender/main.ts new file mode 100644 index 0000000..37fc443 --- /dev/null +++ b/vendor/fxnode/examples/blender/main.ts @@ -0,0 +1,35 @@ +import { createApplicationFxNode } from "./application-browser.js"; +import { prepareFxNodeBrowserHost } from "../shared/browser-host.js"; +import initialLayout from "./initialLayout.json" with { type: "json" }; + +const canvas = document.querySelector("#graph"); +const addNodeMenuTemplate = document.querySelector("#add-node-menu-template"); +if (!canvas || !addNodeMenuTemplate) throw new Error("Example canvas or add-node menu template is missing"); +const host = prepareFxNodeBrowserHost({ canvas, addNodeMenuTemplate }); + +let resolveRendered!: () => void; +const rendered = new Promise((resolve) => { + resolveRendered = resolve; +}); +const handle: FxNodeExampleHandle = { root: null, view: null, host, ready: Promise.resolve(), rendered }; +window.fxnodeExample = handle; + +handle.ready = (async () => { + let root: Awaited> | undefined, + view: Awaited["attachView"]>> | undefined; + try { + root = await createApplicationFxNode(); + await root.setState(initialLayout); + view = await root.attachView({ canvas, viewport: host.initialViewport }); + handle.root = root; + handle.view = view; + host.attach(root, view); + await view.whenRendered(); + resolveRendered(); + } catch (error) { + host.destroy(); + await view?.detach(); + root?.destroy(); + throw error; + } +})(); diff --git a/vendor/fxnode/examples/blender/nodes/application.ts b/vendor/fxnode/examples/blender/nodes/application.ts new file mode 100644 index 0000000..a1d9555 --- /dev/null +++ b/vendor/fxnode/examples/blender/nodes/application.ts @@ -0,0 +1,49 @@ +/** Shared constants for the example application composition. */ +import type { FxNodeCompositionData } from "@lib/composition/index.js"; +export const applicationCompatibility = { + wildcardInputTypes: ["any"], +} as const satisfies FxNodeCompositionData["compatibility"]; +export const APPLICATION_ID = "fxnode.example.application"; +export const APPLICATION_VERSION = 4; +export const APPLICATION_HEADER_STYLES = { + input: { + header: "#8b3f72", + }, + converter: { + header: "#4f5964", + }, + texture: { + header: "#a36b34", + }, + shader: { + header: "#3b7551", + }, + output: { + header: "#963d3d", + }, + geometry: { + header: "#2c7a75", + }, + common: { + header: "#555b64", + }, + compositorInput: { + header: "#8b3f72", + }, + compositorColor: { + header: "#4f5964", + }, +} as const satisfies FxNodeCompositionData["nodeStyles"]; +export const APPLICATION_RESOURCES = { + legacyImage: { + kind: "image", + title: "Image", + openTitle: "Open", + accept: ["image/*"], + referencePrefix: "fxnode-local-image:", + maxBytes: 33554432, + maxWidth: 8192, + maxHeight: 8192, + maxPixels: 16777216, + }, +} as const satisfies FxNodeCompositionData["resources"]; diff --git a/vendor/fxnode/examples/blender/nodes/common/frame.ts b/vendor/fxnode/examples/blender/nodes/common/frame.ts new file mode 100644 index 0000000..96da268 --- /dev/null +++ b/vendor/fxnode/examples/blender/nodes/common/frame.ts @@ -0,0 +1,15 @@ +import type { FxNodeDefinition } from "@lib/composition/index.js"; +export const frameNode = [ + "fxnode.common.frame", + { + version: 1, + title: "Frame", + behavior: "frame", + style: "common", + parameters: {}, + sockets: {}, + ui: [], + muteBypass: [], + migrations: [], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/blender/nodes/common/group-input.ts b/vendor/fxnode/examples/blender/nodes/common/group-input.ts new file mode 100644 index 0000000..7b484a6 --- /dev/null +++ b/vendor/fxnode/examples/blender/nodes/common/group-input.ts @@ -0,0 +1,42 @@ +import type { FxNodeDefinition } from "@lib/composition/index.js"; +export const groupInputNode = [ + "fxnode.common.group-input", + { + version: 1, + title: "Group Input", + behavior: "standard", + style: "input", + parameters: { + interfaceName: { + type: "string", + default: { + kind: "string", + value: "Socket", + }, + }, + }, + sockets: { + output: { + title: "Interface", + direction: "output", + type: "any", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { + kind: "parameter", + parameter: "interfaceName", + }, + { + kind: "socket", + socket: "output", + }, + ], + muteBypass: [], + migrations: [], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/blender/nodes/common/group-output.ts b/vendor/fxnode/examples/blender/nodes/common/group-output.ts new file mode 100644 index 0000000..9215c3a --- /dev/null +++ b/vendor/fxnode/examples/blender/nodes/common/group-output.ts @@ -0,0 +1,42 @@ +import type { FxNodeDefinition } from "@lib/composition/index.js"; +export const groupOutputNode = [ + "fxnode.common.group-output", + { + version: 1, + title: "Group Output", + behavior: "standard", + style: "output", + parameters: { + interfaceName: { + type: "string", + default: { + kind: "string", + value: "Socket", + }, + }, + }, + sockets: { + input: { + title: "Interface", + direction: "input", + type: "any", + maxIncomingLinks: 9007199254740991, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { + kind: "parameter", + parameter: "interfaceName", + }, + { + kind: "socket", + socket: "input", + }, + ], + muteBypass: [], + migrations: [], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/blender/nodes/common/reroute.ts b/vendor/fxnode/examples/blender/nodes/common/reroute.ts new file mode 100644 index 0000000..a794026 --- /dev/null +++ b/vendor/fxnode/examples/blender/nodes/common/reroute.ts @@ -0,0 +1,43 @@ +import type { FxNodeDefinition } from "@lib/composition/index.js"; +export const rerouteNode = [ + "fxnode.common.reroute", + { + version: 1, + title: "Reroute", + behavior: "reroute", + style: "common", + parameters: {}, + sockets: { + input: { + title: "Input", + direction: "input", + type: "any", + maxIncomingLinks: 1, + visible: true, + value: null, + showValue: false, + }, + output: { + title: "Output", + direction: "output", + type: "any", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { + kind: "socket", + socket: "input", + }, + { + kind: "socket", + socket: "output", + }, + ], + muteBypass: [["input", "output"]], + migrations: [], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/blender/nodes/compositor/image.ts b/vendor/fxnode/examples/blender/nodes/compositor/image.ts new file mode 100644 index 0000000..549bab1 --- /dev/null +++ b/vendor/fxnode/examples/blender/nodes/compositor/image.ts @@ -0,0 +1,170 @@ +import type { FxNodeDefinition } from "@lib/composition/index.js"; +export const imageNode = [ + "fxnode.compositor.image", + { + version: 1, + title: "Image", + behavior: "standard", + style: "compositorInput", + parameters: { + image: { + type: "string", + default: { + kind: "string", + value: "", + }, + }, + source: { + type: "string", + default: { + kind: "string", + value: "File", + }, + enum: ["Generated", "File", "Movie", "Sequence", "Multilayer"], + }, + frames: { + type: "number", + default: { + kind: "number", + value: 1, + }, + minimum: 1, + maximum: 1048574, + integer: true, + }, + startFrame: { + type: "number", + default: { + kind: "number", + value: 1, + }, + minimum: -1048574, + maximum: 1048574, + integer: true, + }, + offset: { + type: "number", + default: { + kind: "number", + value: 0, + }, + minimum: -1048574, + maximum: 1048574, + integer: true, + }, + cyclic: { + type: "boolean", + default: { + kind: "boolean", + value: false, + }, + }, + autoRefresh: { + type: "boolean", + default: { + kind: "boolean", + value: false, + }, + }, + }, + sockets: { + image: { + title: "Image", + direction: "output", + type: "color", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + alpha: { + title: "Alpha", + direction: "output", + type: "float", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + z: { + title: "Z", + direction: "output", + type: "float", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { + kind: "resource", + resource: "legacyImage", + parameter: "image", + title: "Image", + openTitle: "Open", + }, + { + kind: "parameter", + parameter: "source", + }, + { + kind: "parameter", + parameter: "frames", + title: "Frames", + visibleWhen: { + parameter: "source", + in: ["Movie", "Sequence"], + }, + }, + { + kind: "parameter", + parameter: "startFrame", + title: "Start Frame", + visibleWhen: { + parameter: "source", + in: ["Movie", "Sequence"], + }, + }, + { + kind: "parameter", + parameter: "offset", + visibleWhen: { + parameter: "source", + in: ["Movie", "Sequence"], + }, + }, + { + kind: "parameter", + parameter: "cyclic", + visibleWhen: { + parameter: "source", + in: ["Movie", "Sequence"], + }, + }, + { + kind: "parameter", + parameter: "autoRefresh", + title: "Auto Refresh", + visibleWhen: { + parameter: "source", + in: ["Movie", "Sequence"], + }, + }, + { + kind: "socket", + socket: "image", + }, + { + kind: "socket", + socket: "alpha", + }, + { + kind: "socket", + socket: "z", + }, + ], + muteBypass: [], + migrations: [], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/blender/nodes/geometry/join-geometry.ts b/vendor/fxnode/examples/blender/nodes/geometry/join-geometry.ts new file mode 100644 index 0000000..9b06e5b --- /dev/null +++ b/vendor/fxnode/examples/blender/nodes/geometry/join-geometry.ts @@ -0,0 +1,43 @@ +import type { FxNodeDefinition } from "@lib/composition/index.js"; +export const joinGeometryNode = [ + "fxnode.geometry.join-geometry", + { + version: 1, + title: "Join Geometry", + behavior: "standard", + style: "geometry", + parameters: {}, + sockets: { + geometry: { + title: "Geometry", + direction: "input", + type: "geometry", + maxIncomingLinks: 9007199254740991, + visible: true, + value: null, + showValue: false, + }, + result: { + title: "Geometry", + direction: "output", + type: "geometry", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { + kind: "socket", + socket: "geometry", + }, + { + kind: "socket", + socket: "result", + }, + ], + muteBypass: [], + migrations: [], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/blender/nodes/geometry/mesh-cube.ts b/vendor/fxnode/examples/blender/nodes/geometry/mesh-cube.ts new file mode 100644 index 0000000..fead389 --- /dev/null +++ b/vendor/fxnode/examples/blender/nodes/geometry/mesh-cube.ts @@ -0,0 +1,116 @@ +import type { FxNodeDefinition } from "@lib/composition/index.js"; +export const meshCubeNode = [ + "fxnode.geometry.mesh-cube", + { + version: 1, + title: "Mesh Cube", + behavior: "standard", + style: "geometry", + parameters: {}, + sockets: { + size: { + title: "Size", + direction: "input", + type: "vector", + maxIncomingLinks: 1, + visible: true, + value: { + type: "vector", + default: { + kind: "vector", + value: [1, 1, 1], + }, + minimum: 0, + }, + showValue: true, + }, + "vertices-x": { + title: "Vertices X", + direction: "input", + type: "float", + maxIncomingLinks: 1, + visible: true, + value: { + type: "number", + default: { + kind: "number", + value: 2, + }, + minimum: 2, + maximum: 1000, + integer: true, + }, + showValue: true, + }, + "vertices-y": { + title: "Vertices Y", + direction: "input", + type: "float", + maxIncomingLinks: 1, + visible: true, + value: { + type: "number", + default: { + kind: "number", + value: 2, + }, + minimum: 2, + maximum: 1000, + integer: true, + }, + showValue: true, + }, + "vertices-z": { + title: "Vertices Z", + direction: "input", + type: "float", + maxIncomingLinks: 1, + visible: true, + value: { + type: "number", + default: { + kind: "number", + value: 2, + }, + minimum: 2, + maximum: 1000, + integer: true, + }, + showValue: true, + }, + mesh: { + title: "Mesh", + direction: "output", + type: "geometry", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { + kind: "socket", + socket: "size", + }, + { + kind: "socket", + socket: "vertices-x", + }, + { + kind: "socket", + socket: "vertices-y", + }, + { + kind: "socket", + socket: "vertices-z", + }, + { + kind: "socket", + socket: "mesh", + }, + ], + muteBypass: [], + migrations: [], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/blender/nodes/geometry/position.ts b/vendor/fxnode/examples/blender/nodes/geometry/position.ts new file mode 100644 index 0000000..cbfa2c4 --- /dev/null +++ b/vendor/fxnode/examples/blender/nodes/geometry/position.ts @@ -0,0 +1,30 @@ +import type { FxNodeDefinition } from "@lib/composition/index.js"; +export const positionNode = [ + "fxnode.geometry.position", + { + version: 1, + title: "Position", + behavior: "standard", + style: "geometry", + parameters: {}, + sockets: { + position: { + title: "Position", + direction: "output", + type: "vector", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { + kind: "socket", + socket: "position", + }, + ], + muteBypass: [], + migrations: [], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/blender/nodes/geometry/set-position.ts b/vendor/fxnode/examples/blender/nodes/geometry/set-position.ts new file mode 100644 index 0000000..fe2c2bb --- /dev/null +++ b/vendor/fxnode/examples/blender/nodes/geometry/set-position.ts @@ -0,0 +1,81 @@ +import type { FxNodeDefinition } from "@lib/composition/index.js"; +export const setPositionNode = [ + "fxnode.geometry.set-position", + { + version: 1, + title: "Set Position", + behavior: "standard", + style: "geometry", + parameters: {}, + sockets: { + geometry: { + title: "Geometry", + direction: "input", + type: "geometry", + maxIncomingLinks: 1, + visible: true, + value: null, + showValue: false, + }, + position: { + title: "Position", + direction: "input", + type: "vector", + maxIncomingLinks: 1, + visible: true, + value: { + type: "vector", + default: { + kind: "vector", + value: [0, 0, 0], + }, + }, + showValue: true, + }, + offset: { + title: "Offset", + direction: "input", + type: "vector", + maxIncomingLinks: 1, + visible: true, + value: { + type: "vector", + default: { + kind: "vector", + value: [0, 0, 0], + }, + }, + showValue: true, + }, + result: { + title: "Geometry", + direction: "output", + type: "geometry", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { + kind: "socket", + socket: "geometry", + }, + { + kind: "socket", + socket: "position", + }, + { + kind: "socket", + socket: "offset", + }, + { + kind: "socket", + socket: "result", + }, + ], + muteBypass: [["geometry", "result"]], + migrations: [], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/blender/nodes/geometry/transform-geometry.ts b/vendor/fxnode/examples/blender/nodes/geometry/transform-geometry.ts new file mode 100644 index 0000000..7dc8f6d --- /dev/null +++ b/vendor/fxnode/examples/blender/nodes/geometry/transform-geometry.ts @@ -0,0 +1,100 @@ +import type { FxNodeDefinition } from "@lib/composition/index.js"; +export const transformGeometryNode = [ + "fxnode.geometry.transform-geometry", + { + version: 1, + title: "Transform Geometry", + behavior: "standard", + style: "geometry", + parameters: {}, + sockets: { + geometry: { + title: "Geometry", + direction: "input", + type: "geometry", + maxIncomingLinks: 1, + visible: true, + value: null, + showValue: false, + }, + translation: { + title: "Translation", + direction: "input", + type: "vector", + maxIncomingLinks: 1, + visible: true, + value: { + type: "vector", + default: { + kind: "vector", + value: [0, 0, 0], + }, + }, + showValue: true, + }, + rotation: { + title: "Rotation", + direction: "input", + type: "vector", + maxIncomingLinks: 1, + visible: true, + value: { + type: "vector", + default: { + kind: "vector", + value: [0, 0, 0], + }, + }, + showValue: true, + }, + scale: { + title: "Scale", + direction: "input", + type: "vector", + maxIncomingLinks: 1, + visible: true, + value: { + type: "vector", + default: { + kind: "vector", + value: [1, 1, 1], + }, + }, + showValue: true, + }, + result: { + title: "Geometry", + direction: "output", + type: "geometry", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { + kind: "socket", + socket: "geometry", + }, + { + kind: "socket", + socket: "translation", + }, + { + kind: "socket", + socket: "rotation", + }, + { + kind: "socket", + socket: "scale", + }, + { + kind: "socket", + socket: "result", + }, + ], + muteBypass: [["geometry", "result"]], + migrations: [], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/blender/nodes/shader/color-ramp.ts b/vendor/fxnode/examples/blender/nodes/shader/color-ramp.ts new file mode 100644 index 0000000..a14a07e --- /dev/null +++ b/vendor/fxnode/examples/blender/nodes/shader/color-ramp.ts @@ -0,0 +1,127 @@ +import type { FxNodeDefinition } from "@lib/composition/index.js"; +export const colorRampNode = [ + "fxnode.shader.color-ramp", + { + version: 2, + title: "Color Ramp", + behavior: "standard", + style: "shader", + parameters: { + ramp: { + type: "json", + codec: "color-ramp/v1", + default: { + kind: "json", + value: { + colorMode: "rgb", + interpolation: "linear", + hueInterpolation: "near", + stops: [ + { + id: "stop-0", + position: 0, + color: [0, 0, 0, 1], + }, + { + id: "stop-1", + position: 1, + color: [1, 1, 1, 1], + }, + ], + }, + }, + }, + }, + sockets: { + factor: { + title: "Factor", + direction: "input", + type: "float", + maxIncomingLinks: 1, + visible: true, + value: { + type: "number", + default: { + kind: "number", + value: 0.5, + }, + minimum: 0, + maximum: 1, + }, + showValue: true, + }, + color: { + title: "Color", + direction: "output", + type: "color", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + alpha: { + title: "Alpha", + direction: "output", + type: "float", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { + kind: "socket", + socket: "color", + }, + { + kind: "socket", + socket: "alpha", + }, + { + kind: "widget", + widget: "color-ramp", + parameter: "ramp", + title: "", + }, + { + kind: "socket", + socket: "factor", + }, + ], + muteBypass: [], + migrations: [ + { + fromVersion: 1, + toVersion: 2, + steps: [ + { + kind: "materialize-missing", + target: "parameter", + key: "ramp", + }, + { + kind: "materialize-missing", + target: "socket", + key: "factor", + }, + { + kind: "materialize-missing", + target: "socket", + key: "color", + }, + { + kind: "materialize-missing", + target: "socket", + key: "alpha", + }, + { + kind: "migrate-parameter", + parameter: "ramp", + codec: "color-ramp/legacy-stops", + }, + ], + }, + ], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/blender/nodes/shader/color.ts b/vendor/fxnode/examples/blender/nodes/shader/color.ts new file mode 100644 index 0000000..a5c7599 --- /dev/null +++ b/vendor/fxnode/examples/blender/nodes/shader/color.ts @@ -0,0 +1,44 @@ +import type { FxNodeDefinition } from "@lib/composition/index.js"; +export const colorNode = [ + "fxnode.shader.color", + { + version: 1, + title: "Color", + behavior: "standard", + style: "shader", + parameters: { + color: { + type: "color", + default: { + kind: "color", + value: [0.8, 0.8, 0.8, 1], + }, + minimum: 0, + maximum: 1, + }, + }, + sockets: { + color: { + title: "Color", + direction: "output", + type: "color", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { + kind: "parameter", + parameter: "color", + }, + { + kind: "socket", + socket: "color", + }, + ], + muteBypass: [], + migrations: [], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/blender/nodes/shader/image-texture.ts b/vendor/fxnode/examples/blender/nodes/shader/image-texture.ts new file mode 100644 index 0000000..5a57a7a --- /dev/null +++ b/vendor/fxnode/examples/blender/nodes/shader/image-texture.ts @@ -0,0 +1,157 @@ +import type { FxNodeDefinition } from "@lib/composition/index.js"; +export const imageTextureNode = [ + "fxnode.shader.image-texture", + { + version: 1, + title: "Image Texture", + behavior: "standard", + style: "input", + parameters: { + image: { + type: "string", + default: { + kind: "string", + value: "", + }, + }, + interpolation: { + type: "string", + default: { + kind: "string", + value: "Linear", + }, + enum: ["Linear", "Closest", "Cubic", "Smart"], + }, + projection: { + type: "string", + default: { + kind: "string", + value: "Flat", + }, + enum: ["Flat", "Box", "Sphere", "Tube"], + }, + blend: { + type: "number", + default: { + kind: "number", + value: 0, + }, + minimum: 0, + maximum: 1, + }, + extension: { + type: "string", + default: { + kind: "string", + value: "Repeat", + }, + enum: ["Repeat", "Extend", "Clip", "Mirror"], + }, + colorSpace: { + type: "string", + default: { + kind: "string", + value: "sRGB", + }, + enum: ["sRGB", "Linear", "Non-Color"], + }, + alphaMode: { + type: "string", + default: { + kind: "string", + value: "Straight", + }, + enum: ["Straight", "Premultiplied", "Channel Packed", "None"], + }, + }, + sockets: { + vector: { + title: "Vector", + direction: "input", + type: "vector", + maxIncomingLinks: 1, + visible: true, + value: { + type: "vector", + default: { + kind: "vector", + value: [0, 0, 0], + }, + }, + showValue: false, + }, + color: { + title: "Color", + direction: "output", + type: "color", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + alpha: { + title: "Alpha", + direction: "output", + type: "float", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { + kind: "resource", + resource: "legacyImage", + parameter: "image", + title: "Image", + openTitle: "Open", + }, + { + kind: "parameter", + parameter: "interpolation", + }, + { + kind: "parameter", + parameter: "projection", + }, + { + kind: "parameter", + parameter: "blend", + title: "Blend", + visibleWhen: { + parameter: "projection", + equals: "Box", + }, + }, + { + kind: "parameter", + parameter: "extension", + }, + { + kind: "parameter", + parameter: "colorSpace", + title: "Color Space", + }, + { + kind: "parameter", + parameter: "alphaMode", + title: "Alpha Mode", + }, + { + kind: "socket", + socket: "vector", + }, + { + kind: "socket", + socket: "color", + }, + { + kind: "socket", + socket: "alpha", + }, + ], + muteBypass: [], + migrations: [], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/blender/nodes/shader/material-output.ts b/vendor/fxnode/examples/blender/nodes/shader/material-output.ts new file mode 100644 index 0000000..86a0332 --- /dev/null +++ b/vendor/fxnode/examples/blender/nodes/shader/material-output.ts @@ -0,0 +1,62 @@ +import type { FxNodeDefinition } from "@lib/composition/index.js"; +export const materialOutputNode = [ + "fxnode.shader.material-output", + { + version: 1, + title: "Material Output", + behavior: "standard", + style: "output", + parameters: {}, + sockets: { + surface: { + title: "Surface", + direction: "input", + type: "shader", + maxIncomingLinks: 1, + visible: true, + value: null, + showValue: false, + }, + volume: { + title: "Volume", + direction: "input", + type: "shader", + maxIncomingLinks: 1, + visible: true, + value: null, + showValue: false, + }, + displacement: { + title: "Displacement", + direction: "input", + type: "vector", + maxIncomingLinks: 1, + visible: true, + value: { + type: "vector", + default: { + kind: "vector", + value: [0, 0, 0], + }, + }, + showValue: true, + }, + }, + ui: [ + { + kind: "socket", + socket: "surface", + }, + { + kind: "socket", + socket: "volume", + }, + { + kind: "socket", + socket: "displacement", + }, + ], + muteBypass: [], + migrations: [], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/blender/nodes/shader/math.ts b/vendor/fxnode/examples/blender/nodes/shader/math.ts new file mode 100644 index 0000000..9681e60 --- /dev/null +++ b/vendor/fxnode/examples/blender/nodes/shader/math.ts @@ -0,0 +1,92 @@ +import type { FxNodeDefinition } from "@lib/composition/index.js"; +export const mathNode = [ + "fxnode.shader.math", + { + version: 1, + title: "Math", + behavior: "standard", + style: "converter", + parameters: { + operation: { + type: "string", + default: { + kind: "string", + value: "add", + }, + enum: ["add", "subtract", "multiply", "divide", "power", "minimum", "maximum"], + }, + clamp: { + type: "boolean", + default: { + kind: "boolean", + value: false, + }, + }, + }, + sockets: { + a: { + title: "A", + direction: "input", + type: "float", + maxIncomingLinks: 1, + visible: true, + value: { + type: "number", + default: { + kind: "number", + value: 0, + }, + }, + showValue: true, + }, + b: { + title: "B", + direction: "input", + type: "float", + maxIncomingLinks: 1, + visible: true, + value: { + type: "number", + default: { + kind: "number", + value: 0, + }, + }, + showValue: true, + }, + value: { + title: "Value", + direction: "output", + type: "float", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { + kind: "parameter", + parameter: "operation", + }, + { + kind: "parameter", + parameter: "clamp", + }, + { + kind: "socket", + socket: "a", + }, + { + kind: "socket", + socket: "b", + }, + { + kind: "socket", + socket: "value", + }, + ], + muteBypass: [["a", "value"]], + migrations: [], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/blender/nodes/shader/mix.ts b/vendor/fxnode/examples/blender/nodes/shader/mix.ts new file mode 100644 index 0000000..541694e --- /dev/null +++ b/vendor/fxnode/examples/blender/nodes/shader/mix.ts @@ -0,0 +1,117 @@ +import type { FxNodeDefinition } from "@lib/composition/index.js"; +export const mixNode = [ + "fxnode.shader.mix", + { + version: 1, + title: "Mix", + behavior: "standard", + style: "shader", + parameters: { + blend: { + type: "string", + default: { + kind: "string", + value: "mix", + }, + enum: ["mix", "add", "multiply", "screen", "overlay"], + }, + clamp: { + type: "boolean", + default: { + kind: "boolean", + value: false, + }, + }, + }, + sockets: { + factor: { + title: "Factor", + direction: "input", + type: "float", + maxIncomingLinks: 1, + visible: true, + value: { + type: "number", + default: { + kind: "number", + value: 0.5, + }, + minimum: 0, + maximum: 1, + }, + showValue: true, + }, + a: { + title: "A", + direction: "input", + type: "color", + maxIncomingLinks: 1, + visible: true, + value: { + type: "color", + default: { + kind: "color", + value: [0.8, 0.8, 0.8, 1], + }, + minimum: 0, + maximum: 1, + }, + showValue: true, + }, + b: { + title: "B", + direction: "input", + type: "color", + maxIncomingLinks: 1, + visible: true, + value: { + type: "color", + default: { + kind: "color", + value: [0.8, 0.8, 0.8, 1], + }, + minimum: 0, + maximum: 1, + }, + showValue: true, + }, + result: { + title: "Result", + direction: "output", + type: "color", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { + kind: "parameter", + parameter: "blend", + }, + { + kind: "parameter", + parameter: "clamp", + }, + { + kind: "socket", + socket: "factor", + }, + { + kind: "socket", + socket: "a", + }, + { + kind: "socket", + socket: "b", + }, + { + kind: "socket", + socket: "result", + }, + ], + muteBypass: [["a", "result"]], + migrations: [], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/blender/nodes/shader/noise-texture.ts b/vendor/fxnode/examples/blender/nodes/shader/noise-texture.ts new file mode 100644 index 0000000..76605f1 --- /dev/null +++ b/vendor/fxnode/examples/blender/nodes/shader/noise-texture.ts @@ -0,0 +1,364 @@ +import type { FxNodeDefinition } from "@lib/composition/index.js"; +export const noiseTextureNode = [ + "fxnode.shader.noise-texture", + { + version: 2, + title: "Noise Texture", + behavior: "standard", + style: "texture", + parameters: { + dimensions: { + type: "string", + default: { + kind: "string", + value: "3d", + }, + enum: ["1d", "2d", "3d", "4d"], + }, + noiseType: { + type: "string", + default: { + kind: "string", + value: "fbm", + }, + enum: ["fbm", "multifractal", "hybrid-multifractal", "ridged-multifractal", "hetero-terrain"], + }, + normalize: { + type: "boolean", + default: { + kind: "boolean", + value: false, + }, + }, + }, + sockets: { + vector: { + title: "Vector", + direction: "input", + type: "vector", + maxIncomingLinks: 1, + visible: true, + value: { + type: "vector", + default: { + kind: "vector", + value: [0, 0, 0], + }, + }, + showValue: false, + }, + w: { + title: "W", + direction: "input", + type: "float", + maxIncomingLinks: 1, + visible: true, + value: { + type: "number", + default: { + kind: "number", + value: 0, + }, + }, + showValue: true, + }, + scale: { + title: "Scale", + direction: "input", + type: "float", + maxIncomingLinks: 1, + visible: true, + value: { + type: "number", + default: { + kind: "number", + value: 5, + }, + minimum: -1000, + maximum: 1000, + }, + showValue: true, + }, + detail: { + title: "Detail", + direction: "input", + type: "float", + maxIncomingLinks: 1, + visible: true, + value: { + type: "number", + default: { + kind: "number", + value: 2, + }, + minimum: 0, + maximum: 15, + }, + showValue: true, + }, + roughness: { + title: "Roughness", + direction: "input", + type: "float", + maxIncomingLinks: 1, + visible: true, + value: { + type: "number", + default: { + kind: "number", + value: 0.5, + }, + minimum: 0, + maximum: 1, + }, + showValue: true, + }, + lacunarity: { + title: "Lacunarity", + direction: "input", + type: "float", + maxIncomingLinks: 1, + visible: true, + value: { + type: "number", + default: { + kind: "number", + value: 2, + }, + minimum: 0, + maximum: 1000, + }, + showValue: true, + }, + offset: { + title: "Offset", + direction: "input", + type: "float", + maxIncomingLinks: 1, + visible: true, + value: { + type: "number", + default: { + kind: "number", + value: 0, + }, + minimum: -1000, + maximum: 1000, + }, + showValue: true, + }, + gain: { + title: "Gain", + direction: "input", + type: "float", + maxIncomingLinks: 1, + visible: true, + value: { + type: "number", + default: { + kind: "number", + value: 1, + }, + minimum: 0, + maximum: 1000, + }, + showValue: true, + }, + distortion: { + title: "Distortion", + direction: "input", + type: "float", + maxIncomingLinks: 1, + visible: true, + value: { + type: "number", + default: { + kind: "number", + value: 0, + }, + minimum: -1000, + maximum: 1000, + }, + showValue: true, + }, + factor: { + title: "Factor", + direction: "output", + type: "float", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + color: { + title: "Color", + direction: "output", + type: "color", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { + kind: "parameter", + parameter: "dimensions", + title: "Dimensions", + }, + { + kind: "parameter", + parameter: "noiseType", + title: "Type", + }, + { + kind: "parameter", + parameter: "normalize", + title: "Normalize", + visibleWhen: { + parameter: "noiseType", + equals: "fbm", + }, + }, + { + kind: "socket", + socket: "vector", + visibleWhen: { + parameter: "dimensions", + in: ["2d", "3d", "4d"], + }, + }, + { + kind: "socket", + socket: "w", + visibleWhen: { + parameter: "dimensions", + in: ["1d", "4d"], + }, + }, + { + kind: "socket", + socket: "scale", + }, + { + kind: "socket", + socket: "detail", + }, + { + kind: "socket", + socket: "roughness", + }, + { + kind: "socket", + socket: "lacunarity", + }, + { + kind: "socket", + socket: "offset", + visibleWhen: { + parameter: "noiseType", + in: ["hybrid-multifractal", "ridged-multifractal", "hetero-terrain"], + }, + }, + { + kind: "socket", + socket: "gain", + visibleWhen: { + parameter: "noiseType", + in: ["hybrid-multifractal", "ridged-multifractal"], + }, + }, + { + kind: "socket", + socket: "distortion", + }, + { + kind: "socket", + socket: "factor", + }, + { + kind: "socket", + socket: "color", + }, + ], + muteBypass: [], + migrations: [ + { + fromVersion: 1, + toVersion: 2, + steps: [ + { + kind: "materialize-missing", + target: "parameter", + key: "dimensions", + }, + { + kind: "materialize-missing", + target: "parameter", + key: "noiseType", + }, + { + kind: "materialize-missing", + target: "parameter", + key: "normalize", + }, + { + kind: "materialize-missing", + target: "socket", + key: "vector", + }, + { + kind: "materialize-missing", + target: "socket", + key: "w", + }, + { + kind: "materialize-missing", + target: "socket", + key: "scale", + }, + { + kind: "materialize-missing", + target: "socket", + key: "detail", + }, + { + kind: "materialize-missing", + target: "socket", + key: "roughness", + }, + { + kind: "materialize-missing", + target: "socket", + key: "lacunarity", + }, + { + kind: "materialize-missing", + target: "socket", + key: "offset", + }, + { + kind: "materialize-missing", + target: "socket", + key: "gain", + }, + { + kind: "materialize-missing", + target: "socket", + key: "distortion", + }, + { + kind: "materialize-missing", + target: "socket", + key: "factor", + }, + { + kind: "materialize-missing", + target: "socket", + key: "color", + }, + ], + }, + ], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/blender/nodes/shader/principled-bsdf.ts b/vendor/fxnode/examples/blender/nodes/shader/principled-bsdf.ts new file mode 100644 index 0000000..d66f7ac --- /dev/null +++ b/vendor/fxnode/examples/blender/nodes/shader/principled-bsdf.ts @@ -0,0 +1,154 @@ +import type { FxNodeDefinition } from "@lib/composition/index.js"; +export const principledBsdfNode = [ + "fxnode.shader.principled-bsdf", + { + version: 1, + title: "Principled BSDF", + behavior: "standard", + style: "shader", + parameters: {}, + sockets: { + "base-color": { + title: "Base Color", + direction: "input", + type: "color", + maxIncomingLinks: 1, + visible: true, + value: { + type: "color", + default: { + kind: "color", + value: [0.8, 0.8, 0.8, 1], + }, + minimum: 0, + maximum: 1, + }, + showValue: true, + }, + metallic: { + title: "Metallic", + direction: "input", + type: "float", + maxIncomingLinks: 1, + visible: true, + value: { + type: "number", + default: { + kind: "number", + value: 0, + }, + minimum: 0, + maximum: 1, + }, + showValue: true, + }, + roughness: { + title: "Roughness", + direction: "input", + type: "float", + maxIncomingLinks: 1, + visible: true, + value: { + type: "number", + default: { + kind: "number", + value: 0.5, + }, + minimum: 0, + maximum: 1, + }, + showValue: true, + }, + ior: { + title: "IOR", + direction: "input", + type: "float", + maxIncomingLinks: 1, + visible: true, + value: { + type: "number", + default: { + kind: "number", + value: 1.5, + }, + minimum: 1, + maximum: 1000, + }, + showValue: true, + }, + alpha: { + title: "Alpha", + direction: "input", + type: "float", + maxIncomingLinks: 1, + visible: true, + value: { + type: "number", + default: { + kind: "number", + value: 1, + }, + minimum: 0, + maximum: 1, + }, + showValue: true, + }, + normal: { + title: "Normal", + direction: "input", + type: "vector", + maxIncomingLinks: 1, + visible: true, + value: { + type: "vector", + default: { + kind: "vector", + value: [0, 0, 0], + }, + }, + showValue: false, + }, + bsdf: { + title: "BSDF", + direction: "output", + type: "shader", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { + kind: "socket", + socket: "base-color", + }, + { + kind: "socket", + socket: "metallic", + }, + { + kind: "socket", + socket: "roughness", + }, + { + kind: "socket", + socket: "ior", + }, + { + kind: "socket", + socket: "alpha", + }, + { + kind: "socket", + socket: "normal", + }, + { + kind: "socket", + socket: "bsdf", + }, + ], + muteBypass: [], + migrations: [], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/blender/nodes/shader/texture-coordinate.ts b/vendor/fxnode/examples/blender/nodes/shader/texture-coordinate.ts new file mode 100644 index 0000000..3f21e49 --- /dev/null +++ b/vendor/fxnode/examples/blender/nodes/shader/texture-coordinate.ts @@ -0,0 +1,69 @@ +import type { FxNodeDefinition } from "@lib/composition/index.js"; +export const textureCoordinateNode = [ + "fxnode.shader.texture-coordinate", + { + version: 1, + title: "Texture Coordinate", + behavior: "standard", + style: "shader", + parameters: {}, + sockets: { + generated: { + title: "Generated", + direction: "output", + type: "vector", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + normal: { + title: "Normal", + direction: "output", + type: "vector", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + uv: { + title: "UV", + direction: "output", + type: "vector", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + object: { + title: "Object", + direction: "output", + type: "vector", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { + kind: "socket", + socket: "generated", + }, + { + kind: "socket", + socket: "normal", + }, + { + kind: "socket", + socket: "uv", + }, + { + kind: "socket", + socket: "object", + }, + ], + muteBypass: [], + migrations: [], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/blender/nodes/shader/value.ts b/vendor/fxnode/examples/blender/nodes/shader/value.ts new file mode 100644 index 0000000..e1061c3 --- /dev/null +++ b/vendor/fxnode/examples/blender/nodes/shader/value.ts @@ -0,0 +1,43 @@ +import type { FxNodeDefinition } from "@lib/composition/index.js"; +export const valueNode = [ + "fxnode.shader.value", + { + version: 1, + title: "Value", + behavior: "standard", + style: "shader", + parameters: { + value: { + type: "number", + default: { + kind: "number", + value: 0, + }, + step: 1, + }, + }, + sockets: { + value: { + title: "Value", + direction: "output", + type: "float", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { + kind: "parameter", + parameter: "value", + }, + { + kind: "socket", + socket: "value", + }, + ], + muteBypass: [], + migrations: [], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/blender/nodes/shader/vector-math.ts b/vendor/fxnode/examples/blender/nodes/shader/vector-math.ts new file mode 100644 index 0000000..91b3229 --- /dev/null +++ b/vendor/fxnode/examples/blender/nodes/shader/vector-math.ts @@ -0,0 +1,98 @@ +import type { FxNodeDefinition } from "@lib/composition/index.js"; +export const vectorMathNode = [ + "fxnode.shader.vector-math", + { + version: 1, + title: "Vector Math", + behavior: "standard", + style: "converter", + parameters: { + operation: { + type: "string", + default: { + kind: "string", + value: "add", + }, + enum: ["add", "subtract", "multiply", "dot-product", "length", "normalize"], + }, + }, + sockets: { + a: { + title: "A", + direction: "input", + type: "vector", + maxIncomingLinks: 1, + visible: true, + value: { + type: "vector", + default: { + kind: "vector", + value: [0, 0, 0], + }, + }, + showValue: true, + }, + b: { + title: "B", + direction: "input", + type: "vector", + maxIncomingLinks: 1, + visible: true, + value: { + type: "vector", + default: { + kind: "vector", + value: [0, 0, 0], + }, + }, + showValue: true, + }, + vector: { + title: "Vector", + direction: "output", + type: "vector", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + value: { + title: "Value", + direction: "output", + type: "float", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { + kind: "parameter", + parameter: "operation", + }, + { + kind: "socket", + socket: "a", + }, + { + kind: "socket", + socket: "b", + visibleWhen: { + parameter: "operation", + equals: "add", + }, + }, + { + kind: "socket", + socket: "vector", + }, + { + kind: "socket", + socket: "value", + }, + ], + muteBypass: [["a", "vector"]], + migrations: [], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/blender/nodes/socket-types.ts b/vendor/fxnode/examples/blender/nodes/socket-types.ts new file mode 100644 index 0000000..bf3906d --- /dev/null +++ b/vendor/fxnode/examples/blender/nodes/socket-types.ts @@ -0,0 +1,25 @@ +import type { FxNodeSocketTypeDefinition } from "@lib/composition/index.js"; +export const anySocket = ["any", { title: "any", color: "#999999", acceptsFrom: ["any"] }] as const satisfies readonly [ + string, + FxNodeSocketTypeDefinition, +]; +export const floatSocket = [ + "float", + { title: "float", color: "#a8a8a8", acceptsFrom: ["float", "any"] }, +] as const satisfies readonly [string, FxNodeSocketTypeDefinition]; +export const vectorSocket = [ + "vector", + { title: "vector", color: "#6476dc", acceptsFrom: ["vector", "any"] }, +] as const satisfies readonly [string, FxNodeSocketTypeDefinition]; +export const colorSocket = [ + "color", + { title: "color", color: "#d7ca63", acceptsFrom: ["color", "any"] }, +] as const satisfies readonly [string, FxNodeSocketTypeDefinition]; +export const shaderSocket = [ + "shader", + { title: "shader", color: "#62b34f", acceptsFrom: ["shader", "any"] }, +] as const satisfies readonly [string, FxNodeSocketTypeDefinition]; +export const geometrySocket = [ + "geometry", + { title: "geometry", color: "#00bfa5", acceptsFrom: ["geometry", "any"] }, +] as const satisfies readonly [string, FxNodeSocketTypeDefinition]; diff --git a/vendor/fxnode/examples/blender/parity/index.html b/vendor/fxnode/examples/blender/parity/index.html new file mode 100644 index 0000000..7d908bd --- /dev/null +++ b/vendor/fxnode/examples/blender/parity/index.html @@ -0,0 +1,12 @@ + + + + + fxnode image and grading parity + + + + + + + diff --git a/vendor/fxnode/examples/blender/parity/initialLayout.json b/vendor/fxnode/examples/blender/parity/initialLayout.json new file mode 100644 index 0000000..711176e --- /dev/null +++ b/vendor/fxnode/examples/blender/parity/initialLayout.json @@ -0,0 +1,754 @@ +{ + "graphId": "parity", + "catalogVersion": 4, + "nodes": [ + { + "id": "color-ramp", + "typeId": "fxnode.shader.color-ramp", + "typeVersion": 2, + "position": { + "x": 350, + "y": 180 + }, + "size": { + "x": 320, + "y": 292 + }, + "label": "Color Ramp", + "parameters": { + "ramp": { + "kind": "json", + "value": { + "colorMode": "rgb", + "interpolation": "linear", + "hueInterpolation": "near", + "stops": [ + { + "id": "stop-0", + "position": 0, + "color": [0, 0, 0, 1] + }, + { + "id": "stop-1", + "position": 1, + "color": [1, 1, 1, 1] + } + ] + } + } + }, + "sockets": [ + { + "id": "color-ramp:factor", + "key": "factor", + "label": "Factor", + "direction": "input", + "dataType": "float", + "accepts": ["float", "any"], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0.5 + } + }, + { + "id": "color-ramp:color", + "key": "color", + "label": "Color", + "direction": "output", + "dataType": "color", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + }, + { + "id": "color-ramp:alpha", + "key": "alpha", + "label": "Alpha", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "compositor-image", + "typeId": "fxnode.compositor.image", + "typeVersion": 1, + "position": { + "x": -340, + "y": -260 + }, + "size": { + "x": 176, + "y": 220 + }, + "label": "Image", + "parameters": { + "image": { + "kind": "string", + "value": "" + }, + "source": { + "kind": "string", + "value": "File" + }, + "frames": { + "kind": "number", + "value": 1 + }, + "startFrame": { + "kind": "number", + "value": 1 + }, + "offset": { + "kind": "number", + "value": 0 + }, + "cyclic": { + "kind": "boolean", + "value": false + }, + "autoRefresh": { + "kind": "boolean", + "value": false + } + }, + "sockets": [ + { + "id": "compositor-image:image", + "key": "image", + "label": "Image", + "direction": "output", + "dataType": "color", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + }, + { + "id": "compositor-image:alpha", + "key": "alpha", + "label": "Alpha", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + }, + { + "id": "compositor-image:z", + "key": "z", + "label": "Z", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "image-texture", + "typeId": "fxnode.shader.image-texture", + "typeVersion": 1, + "position": { + "x": -520, + "y": 180 + }, + "size": { + "x": 257, + "y": 316 + }, + "label": "Image Texture", + "parameters": { + "image": { + "kind": "string", + "value": "" + }, + "interpolation": { + "kind": "string", + "value": "Linear" + }, + "projection": { + "kind": "string", + "value": "Flat" + }, + "blend": { + "kind": "number", + "value": 0 + }, + "extension": { + "kind": "string", + "value": "Repeat" + }, + "colorSpace": { + "kind": "string", + "value": "sRGB" + }, + "alphaMode": { + "kind": "string", + "value": "Straight" + } + }, + "sockets": [ + { + "id": "image-texture:vector", + "key": "vector", + "label": "Vector", + "direction": "input", + "dataType": "vector", + "accepts": ["vector", "any"], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [0, 0, 0] + } + }, + { + "id": "image-texture:color", + "key": "color", + "label": "Color", + "direction": "output", + "dataType": "color", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + }, + { + "id": "image-texture:alpha", + "key": "alpha", + "label": "Alpha", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "master", + "typeId": "fxnode.compositor.color-balance", + "typeVersion": 1, + "position": { + "x": 20, + "y": -260 + }, + "size": { + "x": 400, + "y": 292 + }, + "label": "Master Color Grading", + "parameters": { + "mode": { + "kind": "string", + "value": "Lift/Gamma/Gain" + }, + "lift": { + "kind": "number", + "value": 0 + }, + "liftColor": { + "kind": "color", + "value": [1, 1, 1, 1] + }, + "gamma": { + "kind": "number", + "value": 1 + }, + "gammaColor": { + "kind": "color", + "value": [1, 1, 1, 1] + }, + "gain": { + "kind": "number", + "value": 1 + }, + "gainColor": { + "kind": "color", + "value": [1, 1, 1, 1] + }, + "offset": { + "kind": "number", + "value": 0 + }, + "offsetColor": { + "kind": "color", + "value": [1, 1, 1, 1] + }, + "power": { + "kind": "number", + "value": 1 + }, + "powerColor": { + "kind": "color", + "value": [1, 1, 1, 1] + }, + "slope": { + "kind": "number", + "value": 1 + }, + "slopeColor": { + "kind": "color", + "value": [1, 1, 1, 1] + }, + "inputTemperature": { + "kind": "number", + "value": 6500 + }, + "inputTint": { + "kind": "number", + "value": 0 + }, + "inputColor": { + "kind": "color", + "value": [1, 1, 1, 1] + }, + "outputTemperature": { + "kind": "number", + "value": 6500 + }, + "outputTint": { + "kind": "number", + "value": 0 + }, + "outputColor": { + "kind": "color", + "value": [1, 1, 1, 1] + } + }, + "sockets": [ + { + "id": "master:image", + "key": "image", + "label": "Image", + "direction": "input", + "dataType": "color", + "accepts": ["color", "any"], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "color", + "value": [0.8, 0.8, 0.8, 1] + } + }, + { + "id": "master:factor", + "key": "factor", + "label": "Factor", + "direction": "input", + "dataType": "float", + "accepts": ["float", "any"], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 1 + } + }, + { + "id": "master:result", + "key": "result", + "label": "Image", + "direction": "output", + "dataType": "color", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "noise-3d", + "typeId": "fxnode.shader.noise-texture", + "typeVersion": 2, + "position": { + "x": -220, + "y": 180 + }, + "size": { + "x": 286, + "y": 292 + }, + "label": "Noise Texture", + "parameters": { + "dimensions": { + "kind": "string", + "value": "3d" + }, + "noiseType": { + "kind": "string", + "value": "fbm" + }, + "normalize": { + "kind": "boolean", + "value": false + } + }, + "sockets": [ + { + "id": "noise-3d:vector", + "key": "vector", + "label": "Vector", + "direction": "input", + "dataType": "vector", + "accepts": ["vector", "any"], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [0, 0, 0] + } + }, + { + "id": "noise-3d:w", + "key": "w", + "label": "W", + "direction": "input", + "dataType": "float", + "accepts": ["float", "any"], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "noise-3d:scale", + "key": "scale", + "label": "Scale", + "direction": "input", + "dataType": "float", + "accepts": ["float", "any"], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 5 + } + }, + { + "id": "noise-3d:detail", + "key": "detail", + "label": "Detail", + "direction": "input", + "dataType": "float", + "accepts": ["float", "any"], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 2 + } + }, + { + "id": "noise-3d:roughness", + "key": "roughness", + "label": "Roughness", + "direction": "input", + "dataType": "float", + "accepts": ["float", "any"], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0.5 + } + }, + { + "id": "noise-3d:lacunarity", + "key": "lacunarity", + "label": "Lacunarity", + "direction": "input", + "dataType": "float", + "accepts": ["float", "any"], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 2 + } + }, + { + "id": "noise-3d:offset", + "key": "offset", + "label": "Offset", + "direction": "input", + "dataType": "float", + "accepts": ["float", "any"], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "noise-3d:gain", + "key": "gain", + "label": "Gain", + "direction": "input", + "dataType": "float", + "accepts": ["float", "any"], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 1 + } + }, + { + "id": "noise-3d:distortion", + "key": "distortion", + "label": "Distortion", + "direction": "input", + "dataType": "float", + "accepts": ["float", "any"], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "noise-3d:factor", + "key": "factor", + "label": "Factor", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + }, + { + "id": "noise-3d:color", + "key": "color", + "label": "Color", + "direction": "output", + "dataType": "color", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + }, + { + "id": "noise-4d", + "typeId": "fxnode.shader.noise-texture", + "typeVersion": 2, + "position": { + "x": 60, + "y": 180 + }, + "size": { + "x": 286, + "y": 292 + }, + "label": "Noise Texture", + "parameters": { + "dimensions": { + "kind": "string", + "value": "4d" + }, + "noiseType": { + "kind": "string", + "value": "fbm" + }, + "normalize": { + "kind": "boolean", + "value": false + } + }, + "sockets": [ + { + "id": "noise-4d:vector", + "key": "vector", + "label": "Vector", + "direction": "input", + "dataType": "vector", + "accepts": ["vector", "any"], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "vector", + "value": [0, 0, 0] + } + }, + { + "id": "noise-4d:w", + "key": "w", + "label": "W", + "direction": "input", + "dataType": "float", + "accepts": ["float", "any"], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "noise-4d:scale", + "key": "scale", + "label": "Scale", + "direction": "input", + "dataType": "float", + "accepts": ["float", "any"], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 5 + } + }, + { + "id": "noise-4d:detail", + "key": "detail", + "label": "Detail", + "direction": "input", + "dataType": "float", + "accepts": ["float", "any"], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 2 + } + }, + { + "id": "noise-4d:roughness", + "key": "roughness", + "label": "Roughness", + "direction": "input", + "dataType": "float", + "accepts": ["float", "any"], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0.5 + } + }, + { + "id": "noise-4d:lacunarity", + "key": "lacunarity", + "label": "Lacunarity", + "direction": "input", + "dataType": "float", + "accepts": ["float", "any"], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 2 + } + }, + { + "id": "noise-4d:offset", + "key": "offset", + "label": "Offset", + "direction": "input", + "dataType": "float", + "accepts": ["float", "any"], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "noise-4d:gain", + "key": "gain", + "label": "Gain", + "direction": "input", + "dataType": "float", + "accepts": ["float", "any"], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 1 + } + }, + { + "id": "noise-4d:distortion", + "key": "distortion", + "label": "Distortion", + "direction": "input", + "dataType": "float", + "accepts": ["float", "any"], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0 + } + }, + { + "id": "noise-4d:factor", + "key": "factor", + "label": "Factor", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + }, + { + "id": "noise-4d:color", + "key": "color", + "label": "Color", + "direction": "output", + "dataType": "color", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "known": true + } + ], + "links": [ + { + "id": "compositor-grade", + "fromNodeId": "compositor-image", + "fromSocketId": "compositor-image:image", + "toNodeId": "master", + "toSocketId": "master:image", + "muted": false, + "extensions": {} + } + ], + "metadata": {} +} diff --git a/vendor/fxnode/examples/blender/parity/main.ts b/vendor/fxnode/examples/blender/parity/main.ts new file mode 100644 index 0000000..87eb75c --- /dev/null +++ b/vendor/fxnode/examples/blender/parity/main.ts @@ -0,0 +1,20 @@ +import { createApplicationFxNode } from "../application-browser.js"; +import { prepareFxNodeBrowserHost } from "../../shared/browser-host.js"; +import initialLayout from "./initialLayout.json" with { type: "json" }; +const canvas = document.querySelector("canvas")!; +const host = prepareFxNodeBrowserHost({ canvas }); +let root: Awaited> | undefined, + view: Awaited["attachView"]>> | undefined; +try { + root = await createApplicationFxNode(); + await root.setState(initialLayout); + view = await root.attachView({ canvas, viewport: host.initialViewport }); + host.attach(root, view); + await view.whenRendered(); + window.parityExample = { root, view }; +} catch (error) { + host.destroy(); + await view?.detach(); + root?.destroy(); + throw error; +} diff --git a/vendor/fxnode/examples/blender/parity/style.css b/vendor/fxnode/examples/blender/parity/style.css new file mode 100644 index 0000000..bca77f7 --- /dev/null +++ b/vendor/fxnode/examples/blender/parity/style.css @@ -0,0 +1,13 @@ +html, +body { + margin: 0; + width: 100%; + height: 100%; + overflow: hidden; + background: #202124; +} +canvas { + display: block; + width: 100%; + height: 100%; +} diff --git a/vendor/fxnode/examples/blender/ramp-test/index.html b/vendor/fxnode/examples/blender/ramp-test/index.html new file mode 100644 index 0000000..d28a39e --- /dev/null +++ b/vendor/fxnode/examples/blender/ramp-test/index.html @@ -0,0 +1,23 @@ + + + + + FxNode ramp test + + + + + + + diff --git a/vendor/fxnode/examples/blender/ramp-test/initialLayout.json b/vendor/fxnode/examples/blender/ramp-test/initialLayout.json new file mode 100644 index 0000000..bf54269 --- /dev/null +++ b/vendor/fxnode/examples/blender/ramp-test/initialLayout.json @@ -0,0 +1,128 @@ +{ + "graphId": "ramp-test", + "catalogVersion": 4, + "nodes": [ + { + "id": "frame", + "typeId": "fxnode.common.frame", + "typeVersion": 1, + "known": true, + "position": { + "x": -300, + "y": 250 + }, + "size": { + "x": 380, + "y": 310 + }, + "label": "Frame", + "parameters": {}, + "sockets": [], + "muted": false, + "collapsed": false, + "extensions": {} + }, + { + "id": "ramp", + "typeId": "fxnode.shader.color-ramp", + "typeVersion": 2, + "known": true, + "position": { + "x": 40, + "y": -40 + }, + "size": { + "x": 320, + "y": 292 + }, + "label": "Color Ramp", + "parameters": { + "ramp": { + "kind": "json", + "value": { + "colorMode": "rgb", + "interpolation": "linear", + "hueInterpolation": "near", + "stops": [ + { + "id": "red", + "position": 0.1, + "color": [ + 1, + 0, + 0, + 1 + ] + }, + { + "id": "green", + "position": 0.35, + "color": [ + 0, + 1, + 0, + 0.8 + ] + }, + { + "id": "blue", + "position": 0.9, + "color": [ + 0, + 0, + 1, + 0.6 + ] + } + ] + } + } + }, + "sockets": [ + { + "id": "ramp:factor", + "key": "factor", + "label": "Factor", + "direction": "input", + "dataType": "float", + "accepts": [ + "float", + "any" + ], + "maxIncomingLinks": 1, + "visible": true, + "defaultValue": { + "kind": "number", + "value": 0.5 + } + }, + { + "id": "ramp:color", + "key": "color", + "label": "Color", + "direction": "output", + "dataType": "color", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + }, + { + "id": "ramp:alpha", + "key": "alpha", + "label": "Alpha", + "direction": "output", + "dataType": "float", + "accepts": [], + "maxIncomingLinks": 0, + "visible": true + } + ], + "muted": false, + "collapsed": false, + "extensions": {}, + "parentId": "frame" + } + ], + "links": [], + "metadata": {} +} diff --git a/vendor/fxnode/examples/blender/ramp-test/main.ts b/vendor/fxnode/examples/blender/ramp-test/main.ts new file mode 100644 index 0000000..7ec9edf --- /dev/null +++ b/vendor/fxnode/examples/blender/ramp-test/main.ts @@ -0,0 +1,30 @@ +import { createApplicationFxNode, type ApplicationFxNode } from "../application-browser.js"; +import type { FxNodeView } from "@lib/index.js"; +import { prepareFxNodeBrowserHost } from "../../shared/browser-host.js"; +import initialLayout from "./initialLayout.json" with { type: "json" }; +const canvas = document.querySelector("#ramp"); +if (!canvas) throw new Error("Ramp test canvas missing"); +const host = prepareFxNodeBrowserHost({ canvas }); +const handle: { root: ApplicationFxNode | null; view: FxNodeView | null; ready: Promise } = { + root: null, + view: null, + ready: Promise.resolve(), +}; +(window as typeof window & { rampTest: typeof handle }).rampTest = handle; +handle.ready = (async () => { + let root: ApplicationFxNode | undefined, view: FxNodeView | undefined; + try { + root = await createApplicationFxNode(); + await root.setState(initialLayout); + view = await root.attachView({ canvas, viewport: host.initialViewport }); + handle.root = root; + handle.view = view; + host.attach(root, view); + await view.whenRendered(); + } catch (error) { + host.destroy(); + await view?.detach(); + root?.destroy(); + throw error; + } +})(); diff --git a/vendor/fxnode/examples/blender/style.css b/vendor/fxnode/examples/blender/style.css new file mode 100644 index 0000000..d051347 --- /dev/null +++ b/vendor/fxnode/examples/blender/style.css @@ -0,0 +1,30 @@ +:root { + color-scheme: dark; + font-family: system-ui, sans-serif; + background: #111318; + color: #e5e5e5; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +main { + width: 1200px; + margin: 20px auto; +} +h1 { + margin: 0 0 4px; + font-size: 22px; +} +p { + margin: 0 0 12px; + color: #aeb2ba; + font-size: 14px; +} +canvas { + display: block; + width: 1200px; + height: 640px; +} diff --git a/vendor/fxnode/examples/color-balance/index.html b/vendor/fxnode/examples/color-balance/index.html new file mode 100644 index 0000000..c9cfaeb --- /dev/null +++ b/vendor/fxnode/examples/color-balance/index.html @@ -0,0 +1,20 @@ + + + + + + fxnode Color Balance + + + +
+

Color Balance schema

+

+ Demonstrates schema, controls, persistence, and presentation. It does not process or composite pixels and does + not execute or evaluate the graph. +

+ +
+ + + diff --git a/vendor/fxnode/examples/color-balance/main.ts b/vendor/fxnode/examples/color-balance/main.ts new file mode 100644 index 0000000..b42f8e1 --- /dev/null +++ b/vendor/fxnode/examples/color-balance/main.ts @@ -0,0 +1,67 @@ +import { createFxNode, type FxNodeSocketTypeDefinition, type FxNodeStyleDefinition } from "@lib/index.js"; +import { prepareFxNodeBrowserHost } from "../shared/browser-host.js"; +import { colorBalanceNode } from "../shared/nodes/color-balance.js"; +import { exampleTheme } from "../shared/theme.js"; +const floatSocket = [ + "float", + { title: "Float", color: "#a8a8a8", acceptsFrom: ["float"] }, +] as const satisfies readonly [string, FxNodeSocketTypeDefinition]; +const colorSocket = [ + "color", + { title: "Color", color: "#d7ca63", acceptsFrom: ["color"] }, +] as const satisfies readonly [string, FxNodeSocketTypeDefinition]; +const styles = { compositorColor: { header: "#8c5cc4" } } as const satisfies Readonly< + Record +>; +const canvas = document.querySelector("#graph")!, + host = prepareFxNodeBrowserHost({ canvas }); +let cleanedUp = false; +function cleanup() { + window.removeEventListener("pagehide", cleanup); + cleanedUp = true; + const root = handle.root, + view = handle.view; + handle.root = null; + handle.view = null; + host.destroy(); + const destroyRoot = () => root?.destroy(); + if (view) void view.detach().then(destroyRoot, destroyRoot); + else destroyRoot(); +} +const handle: StandaloneExampleHandle = { + root: null, + view: null, + host, + ready: Promise.resolve(), + cleanup, +}; +window.fxnodeStandalone = handle; +window.addEventListener("pagehide", cleanup); +handle.ready = (async () => { + try { + const root = await createFxNode({ + applicationId: "fxnode.example.color-balance", + applicationVersion: 1, + resources: {}, + }); + if (cleanedUp) { + root.destroy(); + return; + } + handle.root = root; + await root.setTheme(exampleTheme); + await root.setHeaderStyles(styles); + await root.composeSocket(...floatSocket); + await root.composeSocket(...colorSocket); + await root.composeNode(...colorBalanceNode); + await root.setState({ graphId: "color-balance", catalogVersion: 1, nodes: [], links: [], metadata: {} }); + const view = await root.attachView({ canvas, viewport: host.initialViewport }); + handle.view = view; + host.attach(root, view); + await view.addNode({ nodeId: "color-balance", typeId: colorBalanceNode[0], viewPosition: { x: 300, y: 40 } }); + await view.whenRendered(); + } catch (error) { + handle.cleanup(); + throw error; + } +})(); diff --git a/vendor/fxnode/examples/gallery.css b/vendor/fxnode/examples/gallery.css new file mode 100644 index 0000000..4a41594 --- /dev/null +++ b/vendor/fxnode/examples/gallery.css @@ -0,0 +1,255 @@ +:root { + color-scheme: dark; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + background: #0b0d11; + color: #f5f5f4; +} +* { + box-sizing: border-box; +} +body { + max-width: 1180px; + margin: 0 auto; + padding: 64px 28px 40px; + background: + radial-gradient(circle at 8% 0%, #ed570018, transparent 26rem), + radial-gradient(circle at 95% 8%, #6286ff12, transparent 24rem); +} +.hero { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(270px, 0.7fr); + align-items: end; + gap: 48px; + padding: 52px 0 68px; + border-bottom: 1px solid #282b31; +} +.eyebrow { + display: block; + margin-bottom: 14px; + color: #f07834; + font-size: 0.73rem; + font-weight: 750; + letter-spacing: 0.15em; + text-transform: uppercase; +} +h1, +h2, +p { + margin-top: 0; +} +h1 { + max-width: 780px; + margin-bottom: 24px; + font-size: clamp(3rem, 7vw, 6.4rem); + font-weight: 670; + letter-spacing: -0.065em; + line-height: 0.92; +} +h1 em { + color: #f07834; + font-style: normal; +} +.hero p { + max-width: 680px; + margin-bottom: 0; + color: #aaaeb7; + font-size: 1.08rem; + line-height: 1.65; +} +.architecture { + display: grid; + gap: 8px; + padding: 20px; + color: #c7cad0; + background: #14171cbb; + border: 1px solid #30343c; + border-radius: 12px; + font: + 650 0.8rem/1.2 ui-monospace, + SFMono-Regular, + Menlo, + monospace; +} +.architecture span { + padding: 11px 13px; + background: #1d2026; + border: 1px solid #343840; + border-radius: 6px; +} +.architecture i { + padding-left: 14px; + color: #f07834; + font-style: normal; +} +main { + padding: 68px 0; +} +.section-heading { + display: flex; + align-items: end; + justify-content: space-between; + gap: 32px; + margin-bottom: 24px; +} +h2 { + margin-bottom: 0; + font-size: clamp(1.8rem, 3vw, 2.55rem); + letter-spacing: -0.035em; +} +.section-heading > p { + margin-bottom: 4px; + color: #858a94; +} +.gallery { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; +} +.gallery a { + display: block; + padding: 18px; + overflow: hidden; + color: inherit; + text-decoration: none; + background: #15181d; + border: 1px solid #2b2f36; + border-radius: 12px; + transition: 160ms ease; +} +.gallery a:hover { + border-color: #f07834; + box-shadow: 0 18px 48px #0007; + transform: translateY(-3px); +} +.gallery img { + display: block; + width: calc(100% + 36px); + aspect-ratio: 16 / 9; + margin: -18px -18px 18px; + object-fit: cover; + border-bottom: 1px solid #30343b; +} +.gallery strong { + display: block; + margin: 6px 0 7px; + font-size: 1.28rem; +} +.gallery p { + margin-bottom: 0; + color: #9297a1; + line-height: 1.5; +} +.tag { + color: #f07834; + font-size: 0.7rem; + font-weight: 750; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.gallery .featured { + grid-column: 1 / -1; + min-height: 210px; + padding: 34px; + background: + linear-gradient(100deg, #181b21 0%, #181b21e8 54%, #211913c9), + radial-gradient(circle at 90% 50%, #ed570055, transparent 30%); +} +.featured strong { + max-width: 600px; + margin-top: 12px; + font-size: clamp(1.8rem, 4vw, 3rem); + letter-spacing: -0.04em; +} +.featured p { + max-width: 600px; +} +.open { + display: block; + margin-top: 28px; + color: #f5f5f4; + font-weight: 700; +} +.open b { + margin-left: 6px; + color: #f07834; +} +.labs { + margin-top: 72px; + padding-top: 54px; + border-top: 1px solid #282b31; +} +.labs nav { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 10px; +} +.labs a { + min-height: 108px; + padding: 16px; + color: inherit; + text-decoration: none; + background: #13161a; + border: 1px solid #292d33; + border-radius: 8px; +} +.labs a:hover { + background: #1a1d22; + border-color: #f07834; +} +.labs strong, +.labs span { + display: block; +} +.labs strong { + margin-bottom: 9px; + font-size: 0.88rem; +} +.labs span { + color: #858a94; + font-size: 0.76rem; + line-height: 1.4; +} +footer { + padding: 24px 0 4px; + color: #6f747e; + border-top: 1px solid #282b31; + font-size: 0.82rem; +} +@media (max-width: 820px) { + body { + padding: 28px 18px; + } + .hero { + grid-template-columns: 1fr; + padding: 34px 0 48px; + } + .architecture { + display: none; + } + .section-heading { + align-items: start; + flex-direction: column; + gap: 8px; + } + .gallery { + grid-template-columns: 1fr; + } + .gallery .featured { + grid-column: auto; + } + .labs nav { + grid-template-columns: repeat(2, 1fr); + } +} +@media (max-width: 460px) { + .labs nav { + grid-template-columns: 1fr; + } +} diff --git a/vendor/fxnode/examples/index.html b/vendor/fxnode/examples/index.html new file mode 100644 index 0000000..20d1e32 --- /dev/null +++ b/vendor/fxnode/examples/index.html @@ -0,0 +1,80 @@ + + + + + + fxnode examples + + + +
+
+ Interactive TypeScript examples +

Build node editors with fxnode

+

+ Explore composable nodes, live worker-owned state, independent views, and a Blender-inspired editor. Every + example runs locally in your browser. +

+
+
+ Main thread hostWorker authorityShared atlas +
+
+ +
+ +
fxnode keeps graph authority in a worker. Application code owns DOM presentation and lifecycle.
+ + diff --git a/vendor/fxnode/examples/live-composition/definitions.ts b/vendor/fxnode/examples/live-composition/definitions.ts new file mode 100644 index 0000000..39f8e82 --- /dev/null +++ b/vendor/fxnode/examples/live-composition/definitions.ts @@ -0,0 +1,57 @@ +import type { FxNodeDefinition, FxNodeSocketTypeDefinition, FxNodeStyleDefinition } from "@lib/index.js"; +export const liveSocket = [ + "number", + { title: "Number", color: "#74c0fc", acceptsFrom: ["number"] }, +] as const satisfies readonly [string, FxNodeSocketTypeDefinition]; +export const liveStyles = { live: { header: "#0b7285" } } as const satisfies Readonly< + Record +>; +const base = { + title: "Live Parameter", + behavior: "standard", + style: "live", + sockets: { + result: { + title: "Result", + direction: "output", + type: "number", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + muteBypass: [], +} as const; +export const liveNodeV1 = [ + "example.live.parameter", + { + ...base, + version: 1, + parameters: { value: { type: "number", default: { kind: "number", value: 1 } } }, + ui: [ + { kind: "parameter", parameter: "value" }, + { kind: "socket", socket: "result" }, + ], + migrations: [], + }, +] as const satisfies readonly [string, FxNodeDefinition]; +export const liveNodeV2 = [ + "example.live.parameter", + { + ...base, + version: 2, + parameters: { + value: { type: "number", default: { kind: "number", value: 1 } }, + detail: { type: "number", default: { kind: "number", value: 0.5 }, minimum: 0, maximum: 1, step: 0.1 }, + }, + ui: [ + { kind: "parameter", parameter: "value" }, + { kind: "parameter", parameter: "detail" }, + { kind: "socket", socket: "result" }, + ], + migrations: [ + { fromVersion: 1, toVersion: 2, steps: [{ kind: "materialize-missing", target: "parameter", key: "detail" }] }, + ], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/live-composition/index.html b/vendor/fxnode/examples/live-composition/index.html new file mode 100644 index 0000000..87820fa --- /dev/null +++ b/vendor/fxnode/examples/live-composition/index.html @@ -0,0 +1,22 @@ + + + + + + fxnode live composition + + + +
+

Live composition migration

+

+ Updates a version 1 schema to version 2 and materializes a missing detail parameter. This demonstrates + composition, not graph execution or evaluation. +

+ + Version 1 ready +
+ + + diff --git a/vendor/fxnode/examples/live-composition/main.ts b/vendor/fxnode/examples/live-composition/main.ts new file mode 100644 index 0000000..80e891e --- /dev/null +++ b/vendor/fxnode/examples/live-composition/main.ts @@ -0,0 +1,82 @@ +import { createFxNode } from "@lib/index.js"; +import { prepareFxNodeBrowserHost } from "../shared/browser-host.js"; +import { exampleTheme } from "../shared/theme.js"; +import { liveNodeV1, liveNodeV2, liveSocket, liveStyles } from "./definitions.js"; +const canvas = document.querySelector("#graph")!, + button = document.querySelector("#compose")!, + status = document.querySelector("#status")!, + host = prepareFxNodeBrowserHost({ canvas }); +let cleanedUp = false; +function cleanup() { + window.removeEventListener("pagehide", cleanup); + cleanedUp = true; + button.removeEventListener("click", compose); + const root = handle.root, + view = handle.view; + handle.root = null; + handle.view = null; + host.destroy(); + const destroyRoot = () => root?.destroy(); + if (view) void view.detach().then(destroyRoot, destroyRoot); + else destroyRoot(); +} +const handle: StandaloneExampleHandle = { + root: null, + view: null, + host, + ready: Promise.resolve(), + cleanup, +}; +window.fxnodeStandalone = handle; +window.addEventListener("pagehide", cleanup); +let revision = 0; +async function compose() { + if (!handle.root || !handle.view) return; + button.disabled = true; + status.textContent = "Composing version 2…"; + try { + const receipt = await handle.root.composeNode(...liveNodeV2, { expectedRevision: revision }); + revision = receipt.revision; + handle.lastCompositionReceipt = receipt; + status.textContent = `Version 2 ${receipt.status}; revision ${receipt.revision}; graph ${receipt.graphChanged ? "migrated" : "unchanged"}`; + await handle.view.whenRendered(); + } catch (error) { + status.textContent = error instanceof Error ? error.message : String(error); + button.disabled = false; + throw error; + } +} +button.addEventListener("click", compose); +handle.ready = (async () => { + try { + const root = await createFxNode({ + applicationId: "fxnode.example.live-composition", + applicationVersion: 1, + resources: {}, + }); + if (cleanedUp) { + root.destroy(); + return; + } + handle.root = root; + await root.setTheme(exampleTheme); + await root.setHeaderStyles(liveStyles); + await root.composeSocket(...liveSocket); + const receipt = await root.composeNode(...liveNodeV1); + revision = receipt.revision; + await root.setState({ graphId: "live", catalogVersion: 1, nodes: [], links: [], metadata: {} }); + const view = await root.attachView({ canvas, viewport: host.initialViewport }); + handle.view = view; + host.attach(root, view); + const added = await view.addNode({ + nodeId: "live-node", + typeId: liveNodeV1[0], + viewPosition: { x: 340, y: 160 }, + }); + handle.graphVersion = added.version; + await view.whenRendered(); + } catch (error) { + handle.cleanup(); + throw error; + } +})(); diff --git a/vendor/fxnode/examples/minimal/definition.ts b/vendor/fxnode/examples/minimal/definition.ts new file mode 100644 index 0000000..0ac3ffa --- /dev/null +++ b/vendor/fxnode/examples/minimal/definition.ts @@ -0,0 +1,35 @@ +import type { FxNodeDefinition, FxNodeSocketTypeDefinition, FxNodeStyleDefinition } from "@lib/index.js"; +export const numberSocket = [ + "number", + { title: "Number", color: "#a8a8a8", acceptsFrom: ["number"] }, +] as const satisfies readonly [string, FxNodeSocketTypeDefinition]; +export const minimalStyles = { value: { header: "#4c6ef5" } } as const satisfies Readonly< + Record +>; +export const valueNode = [ + "example.minimal.value", + { + version: 1, + title: "Number Value", + behavior: "standard", + style: "value", + parameters: { value: { type: "number", default: { kind: "number", value: 42 }, step: 1 } }, + sockets: { + value: { + title: "Value", + direction: "output", + type: "number", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { kind: "parameter", parameter: "value" }, + { kind: "socket", socket: "value" }, + ], + muteBypass: [], + migrations: [], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/minimal/index.html b/vendor/fxnode/examples/minimal/index.html new file mode 100644 index 0000000..fdf0900 --- /dev/null +++ b/vendor/fxnode/examples/minimal/index.html @@ -0,0 +1,17 @@ + + + + + + fxnode minimal example + + + +
+

Minimal custom node

+

The smallest useful custom number/value node.

+ +
+ + + diff --git a/vendor/fxnode/examples/minimal/main.ts b/vendor/fxnode/examples/minimal/main.ts new file mode 100644 index 0000000..68b94ba --- /dev/null +++ b/vendor/fxnode/examples/minimal/main.ts @@ -0,0 +1,56 @@ +import { createFxNode } from "@lib/index.js"; +import { prepareFxNodeBrowserHost } from "../shared/browser-host.js"; +import { exampleTheme } from "../shared/theme.js"; +import { minimalStyles, numberSocket, valueNode } from "./definition.js"; + +const canvas = document.querySelector("#graph")!; +const host = prepareFxNodeBrowserHost({ canvas }); +let cleanedUp = false; +function cleanup() { + window.removeEventListener("pagehide", cleanup); + cleanedUp = true; + const root = handle.root, + view = handle.view; + handle.root = null; + handle.view = null; + host.destroy(); + const destroyRoot = () => root?.destroy(); + if (view) void view.detach().then(destroyRoot, destroyRoot); + else destroyRoot(); +} +const handle: StandaloneExampleHandle = { + root: null, + view: null, + host, + ready: Promise.resolve(), + cleanup, +}; +window.fxnodeStandalone = handle; +window.addEventListener("pagehide", cleanup); +handle.ready = (async () => { + try { + const root = await createFxNode({ + applicationId: "fxnode.example.minimal", + applicationVersion: 1, + resources: {}, + }); + if (cleanedUp) { + root.destroy(); + return; + } + handle.root = root; + await root.setTheme(exampleTheme); + await root.setHeaderStyles(minimalStyles); + await root.composeSocket(...numberSocket); + await root.composeNode(...valueNode); + await root.setState({ graphId: "minimal", catalogVersion: 1, nodes: [], links: [], metadata: {} }); + const view = await root.attachView({ canvas, viewport: host.initialViewport }); + handle.view = view; + host.attach(root, view); + await view.addNode({ nodeId: "value", typeId: valueNode[0], viewPosition: { x: 360, y: 190 } }); + await view.whenRendered(); + } catch (error) { + handle.cleanup(); + throw error; + } +})(); diff --git a/vendor/fxnode/examples/multi-view/index.html b/vendor/fxnode/examples/multi-view/index.html new file mode 100644 index 0000000..a588115 --- /dev/null +++ b/vendor/fxnode/examples/multi-view/index.html @@ -0,0 +1,45 @@ + + + + + + fxnode multi-view example + + + +
+
+
+

One graph, two views

+

Selection and camera are local to each canvas; graph edits render in both.

+
+ +
+
+
+

Material detail

+ +
+
+

Output overview

+ +
+
+
+ + + diff --git a/vendor/fxnode/examples/multi-view/main.ts b/vendor/fxnode/examples/multi-view/main.ts new file mode 100644 index 0000000..941a13c --- /dev/null +++ b/vendor/fxnode/examples/multi-view/main.ts @@ -0,0 +1,124 @@ +import type { FxNodeView } from "@lib/index.js"; +import { createApplicationFxNode } from "../blender/application-browser.js"; +import initialLayout from "../blender/all-supported/initialLayout.json" with { type: "json" }; +import { prepareFxNodeBrowserHost } from "../shared/browser-host.js"; + +const canvases = [ + document.querySelector("#view-a")!, + document.querySelector("#view-b")!, +]; +const toolbar = document.querySelector("#toolbar")!; +const nodeType = document.querySelector("#node-type")!; +const addButton = toolbar.querySelector("[data-action=add]")!; +const deleteButton = toolbar.querySelector("[data-action=delete]")!; +const muteButton = toolbar.querySelector("[data-action=mute]")!; +const status = toolbar.querySelector("#toolbar-status")!; +let activeIndex = 0, + toolbarPending = false, + cleaned = false, + root: Awaited> | null = null; +const views: FxNodeView[] = [], + unsubscribers: (() => void)[] = []; +const hosts = canvases.map((canvas) => prepareFxNodeBrowserHost({ canvas, lifecycle: "detach-on-disconnect" })); +const renderCounts = [0, 0]; +function updateToolbar() { + const snapshot = views[activeIndex]?.getHostSnapshot(); + const muted = snapshot?.selection.mute.enabled === true && snapshot.selection.mute.state === "all-muted"; + addButton.disabled = toolbarPending || !views[activeIndex]; + deleteButton.disabled = toolbarPending || !snapshot?.selection.canRemove; + muteButton.disabled = toolbarPending || snapshot?.selection.mute.enabled !== true; + muteButton.setAttribute("aria-pressed", String(muted)); + muteButton.textContent = muted ? "Unmute" : "Mute"; +} +function activate(index: number) { + activeIndex = index; + canvases.forEach((canvas, candidate) => canvas.parentElement?.classList.toggle("active", candidate === index)); + updateToolbar(); +} +const pointerListeners = canvases + .map((canvas, index) => { + const listener = () => activate(index); + canvas.addEventListener("pointerdown", listener); + return [{ canvas, listener }]; + }) + .flat(); +const toolbarListener = async (event: Event) => { + const action = (event.target as HTMLElement).closest("button")?.dataset.action, + view = views[activeIndex]; + if (!action || !view || toolbarPending) return; + toolbarPending = true; + status.textContent = ""; + updateToolbar(); + try { + const viewport = hosts[activeIndex]!.currentViewport; + await (action === "add" + ? view.addNode({ + typeId: nodeType.value, + viewPosition: { x: viewport.width / 2, y: viewport.height / 2 }, + }) + : action === "delete" + ? view.removeSelected() + : view.setSelectedMuted(muteButton.getAttribute("aria-pressed") !== "true")); + } catch (error) { + status.textContent = error instanceof Error ? error.message : "Action failed"; + } finally { + toolbarPending = false; + if (!cleaned) updateToolbar(); + } +}; +toolbar.addEventListener("click", toolbarListener); + +async function cleanup() { + if (cleaned) return; + cleaned = true; + window.removeEventListener("pagehide", cleanup); + toolbar.removeEventListener("click", toolbarListener); + for (const { canvas, listener } of pointerListeners) canvas.removeEventListener("pointerdown", listener); + unsubscribers.forEach((unsubscribe) => unsubscribe()); + hosts.forEach((host) => host.destroy()); + await Promise.allSettled(views.map((view) => view.detach())); + root?.destroy(); + root = null; + handle.root = null; + handle.views = []; +} +const handle: MultiViewExampleHandle = { root: null, views, ready: Promise.resolve(), cleanup, renderCounts }; +window.fxnodeMultiView = handle; +window.addEventListener("pagehide", cleanup); +handle.ready = (async () => { + try { + const created = await createApplicationFxNode(); + if (cleaned) { + created.destroy(); + return; + } + root = created; + handle.root = created; + await root.setState(initialLayout); + for (const [index, canvas] of canvases.entries()) { + const host = hosts[index]!, + viewport = host.initialViewport; + const context = canvas.getContext("2d")!, + drawImage = context.drawImage.bind(context); + context.drawImage = ((...args: Parameters) => { + renderCounts[index]!++; + Reflect.apply(drawImage, context, args); + }) as typeof context.drawImage; + const view = await root.attachView({ + canvas, + viewport, + initialCamera: index + ? { center: { x: 2080, y: -400 }, zoom: 0.45 } + : { center: { x: 480, y: -550 }, zoom: 0.5 }, + }); + views.push(view); + host.attach(root, view); + unsubscribers.push(view.subscribeHost(updateToolbar)); + } + await Promise.all(views.map((view) => view.whenRendered())); + updateToolbar(); + } catch (error) { + await cleanup(); + throw error; + } +})(); diff --git a/vendor/fxnode/examples/multi-view/style.css b/vendor/fxnode/examples/multi-view/style.css new file mode 100644 index 0000000..862fe5b --- /dev/null +++ b/vendor/fxnode/examples/multi-view/style.css @@ -0,0 +1,84 @@ +:root { + color-scheme: dark; + font-family: Inter, system-ui, sans-serif; + background: #101216; + color: #f1f3f5; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +main { + width: 1160px; + margin: 24px auto; +} +header { + display: flex; + align-items: end; + justify-content: space-between; + margin-bottom: 16px; +} +h1 { + margin: 0 0 5px; + font-size: 25px; +} +p { + margin: 0; + color: #adb5bd; +} +#toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 9px; + background: #1d1f23; + border: 1px solid #343a40; + border-radius: 7px; +} +select, +button { + padding: 7px 10px; + color: inherit; + background: #292c31; + border: 1px solid #495057; + border-radius: 4px; +} +button:hover { + border-color: #748ffc; +} +button:disabled { + opacity: 0.45; +} +#toolbar-status { + max-width: 180px; + color: #ff8787; + font-size: 12px; +} +.views { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} +article { + padding: 8px; + background: #181a1f; + border: 2px solid transparent; + border-radius: 8px; +} +article.active { + border-color: #5c7cfa; +} +h2 { + margin: 2px 3px 9px; + font-size: 14px; + color: #ced4da; +} +canvas { + display: block; + width: 554px; + height: 520px; + background: #1d1f23; + touch-action: none; +} diff --git a/vendor/fxnode/examples/shared/add-node-menu.ts b/vendor/fxnode/examples/shared/add-node-menu.ts new file mode 100644 index 0000000..f34b13d --- /dev/null +++ b/vendor/fxnode/examples/shared/add-node-menu.ts @@ -0,0 +1,154 @@ +import type { FxNodeView } from "@lib/index.js"; + +/** Application-owned add-node menu used by the standalone examples. */ + +interface Point { + readonly x: number; + readonly y: number; +} + +export interface AddNodeMenu { + open(viewPosition: Point): void; + close(restoreCanvasFocus?: boolean): void; + destroy(): void; +} + +function cloneElement(template: HTMLTemplateElement, selector: string): T { + const element = template.content.firstElementChild?.cloneNode(true); + if (!(element instanceof Element)) throw new Error(`Add-node menu template ${selector} is empty`); + return element as T; +} + +export function createAddNodeMenu( + template: HTMLTemplateElement, + canvas: HTMLCanvasElement, + api: FxNodeView, + onError: (error: unknown) => void, +): AddNodeMenu { + const entries = [...template.content.querySelectorAll("button[data-fxnode-menu-option]")].map( + (option) => { + const typeId = option.dataset.typeId; + const title = option.textContent?.trim(); + const group = option.closest("[data-fxnode-menu-group]"); + const groupTitle = group?.querySelector("[data-fxnode-menu-heading]")?.textContent?.trim(); + if (!typeId || !title || !group || !groupTitle) throw new Error("Add-node menu option is missing HTML context"); + return { + typeId, + searchText: `${title} ${groupTitle} ${typeId} ${option.dataset.keywords ?? ""}`.toLowerCase(), + option, + }; + }, + ); + if (!entries.length) throw new Error("Add-node menu HTML template has no node options"); + let host: HTMLDivElement | undefined; + let removeListeners = () => {}; + + const close = (restore = false) => { + if (!host) return; + removeListeners(); + host.remove(); + host = undefined; + if (restore) canvas.focus(); + }; + + const open = (viewPosition: Point) => { + close(); + host = cloneElement(template, "root"); + const input = host.querySelector("[data-fxnode-menu-search]"); + const results = host.querySelector("[data-fxnode-menu-results]"); + const empty = host.querySelector("[data-fxnode-menu-empty]"); + const options = [...host.querySelectorAll("button[data-fxnode-menu-option]")]; + if (!input || !results || !empty || options.length !== entries.length) { + host.remove(); + host = undefined; + throw new Error("Add-node menu HTML template is incomplete"); + } + + let visible = entries.slice(); + let active = 0; + const choose = (index: number) => { + const item = visible[index]; + if (!item) return; + close(true); + void api.addNode({ typeId: item.typeId, viewPosition }).catch(onError); + }; + const render = () => { + entries.forEach((item, sourceIndex) => { + const button = options[sourceIndex]!; + const index = visible.indexOf(item); + button.hidden = index < 0; + if (index >= 0) { + button.classList.toggle("active", index === active); + button.id = `fxnode-node-option-${index}`; + button.setAttribute("aria-selected", String(index === active)); + button.onpointerenter = () => { + if (active !== index) { + active = index; + render(); + } + }; + button.onclick = () => choose(index); + } + }); + for (const group of results.querySelectorAll("[data-fxnode-menu-group]")) + group.hidden = !group.querySelector("button[data-fxnode-menu-option]:not([hidden])"); + empty.hidden = visible.length > 0; + input.setAttribute("aria-expanded", "true"); + input.setAttribute("aria-activedescendant", visible.length ? `fxnode-node-option-${active}` : ""); + }; + input.oninput = () => { + const query = input.value.trim().toLowerCase(); + visible = entries.filter((item) => !query || item.searchText.includes(query)); + active = 0; + render(); + }; + input.onkeydown = (event) => { + if (event.key === "Escape") { + event.preventDefault(); + close(true); + return; + } + if (!visible.length) return; + if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Home" || event.key === "End") { + event.preventDefault(); + active = + event.key === "Home" + ? 0 + : event.key === "End" + ? visible.length - 1 + : (active + (event.key === "ArrowDown" ? 1 : -1) + visible.length) % visible.length; + render(); + host?.querySelector(`#fxnode-node-option-${active}`)?.scrollIntoView({ block: "nearest" }); + } else if (event.key === "Enter") { + event.preventDefault(); + choose(active); + } + }; + + render(); + document.body.append(host); + const rect = canvas.getBoundingClientRect(); + const menu = host.getBoundingClientRect(); + const margin = 8; + host.style.left = `${Math.max(margin, Math.min(rect.left + viewPosition.x, window.innerWidth - menu.width - margin))}px`; + host.style.top = `${Math.max(margin, Math.min(rect.top + viewPosition.y, window.innerHeight - menu.height - margin))}px`; + host.style.visibility = "visible"; + input.focus(); + const outside = (event: PointerEvent) => { + if (host && !event.composedPath().includes(host)) close(false); + }; + const blur = () => close(false); + const scroll = () => close(false); + document.addEventListener("pointerdown", outside, true); + window.addEventListener("blur", blur); + window.addEventListener("scroll", scroll, true); + removeListeners = () => { + document.removeEventListener("pointerdown", outside, true); + window.removeEventListener("blur", blur); + window.removeEventListener("scroll", scroll, true); + removeListeners = () => {}; + }; + }; + + return { open, close, destroy: () => close(false) }; +} diff --git a/vendor/fxnode/examples/shared/browser-host.ts b/vendor/fxnode/examples/shared/browser-host.ts new file mode 100644 index 0000000..6535a9c --- /dev/null +++ b/vendor/fxnode/examples/shared/browser-host.ts @@ -0,0 +1,439 @@ +import type { FxNode, FxNodeModifiers, FxNodeResourceOpenRequest, FxNodeView, FxNodeViewport } from "@lib/index.js"; +import { createAddNodeMenu, type AddNodeMenu } from "./add-node-menu.js"; + +/** Explicit DOM host adapter; callers own attachment and cleanup. */ + +export interface FxNodeBrowserHostOptions { + readonly canvas: HTMLCanvasElement; + readonly lifecycle?: "explicit" | "detach-on-disconnect"; + readonly addNodeMenuTemplate?: HTMLTemplateElement; + readonly activateResourcePicker?: (request: FxNodeResourceOpenRequest) => void | Promise; + readonly onError?: (error: unknown) => void; +} + +export interface PreparedFxNodeBrowserHost { + readonly initialViewport: FxNodeViewport; + readonly currentViewport: FxNodeViewport; + attach(root: FxNode, view: FxNodeView): void; + syncViewport(): void; + destroy(): void; +} + +const INPUT_EVENTS = [ + "pointerdown", + "pointermove", + "pointerup", + "pointercancel", + "mousedown", + "wheel", + "keydown", + "keyup", + "focus", + "blur", +] as const; +const activeHosts = new WeakMap(); +type DisconnectRegistration = { canvas: HTMLCanvasElement; disconnected(): void }; +const disconnectRegistries = new WeakMap< + Document, + { observer: MutationObserver; registrations: Set } +>(); + +function watchDisconnect(documentValue: Document, registration: DisconnectRegistration): () => void { + let registry = disconnectRegistries.get(documentValue); + if (!registry) { + const registrations = new Set(); + const Observer = documentValue.defaultView?.MutationObserver; + if (!Observer) throw new Error("detach-on-disconnect requires a connected canvas and MutationObserver"); + const observer = new Observer(() => { + for (const candidate of registrations) { + if (candidate.canvas.ownerDocument === documentValue && candidate.canvas.isConnected) continue; + queueMicrotask(() => { + if ( + registrations.has(candidate) && + (candidate.canvas.ownerDocument !== documentValue || !candidate.canvas.isConnected) + ) + candidate.disconnected(); + }); + } + }); + observer.observe(documentValue, { childList: true, subtree: true }); + registry = { observer, registrations }; + disconnectRegistries.set(documentValue, registry); + } + registry.registrations.add(registration); + return () => { + registry!.registrations.delete(registration); + if (registry!.registrations.size === 0) { + registry!.observer.disconnect(); + disconnectRegistries.delete(documentValue); + } + }; +} + +function measureViewport(canvas: HTMLCanvasElement, devicePixelRatio: number): FxNodeViewport { + const dpr = Math.min(4, Math.max(1, devicePixelRatio || 1)), + maxLogicalDimension = Math.floor(8192 / dpr), + maxLogicalPixels = Math.floor(16_777_216 / (dpr * dpr)), + width = Math.min(maxLogicalDimension, Math.max(1, canvas.clientWidth)), + height = Math.min(maxLogicalDimension, Math.floor(maxLogicalPixels / width), Math.max(1, canvas.clientHeight)); + return { width, height, dpr }; +} + +function sameViewport(left: FxNodeViewport, right: FxNodeViewport): boolean { + return left.width === right.width && left.height === right.height && left.dpr === right.dpr; +} +function sizeCanvas(canvas: HTMLCanvasElement, viewport: FxNodeViewport): void { + const width = Math.max(1, Math.round(viewport.width * viewport.dpr)), + height = Math.max(1, Math.round(viewport.height * viewport.dpr)); + if (canvas.width !== width) canvas.width = width; + if (canvas.height !== height) canvas.height = height; +} +function modifiers(event: MouseEvent | KeyboardEvent): FxNodeModifiers { + return { alt: event.altKey, control: event.ctrlKey, meta: event.metaKey, shift: event.shiftKey }; +} +export function prepareFxNodeBrowserHost({ + canvas, + lifecycle = "explicit", + addNodeMenuTemplate, + activateResourcePicker, + onError = console.error, +}: FxNodeBrowserHostOptions): PreparedFxNodeBrowserHost { + if (activeHosts.has(canvas)) throw new Error("Canvas already has an active FxNode browser host"); + if ( + lifecycle === "detach-on-disconnect" && + (!canvas.isConnected || !canvas.ownerDocument.defaultView?.MutationObserver) + ) + throw new Error("detach-on-disconnect requires a connected canvas and MutationObserver"); + const ownerDocument = canvas.ownerDocument, + ownerWindow = ownerDocument.defaultView ?? window, + report = (error: unknown) => { + try { + onError(error); + } catch (reportError) { + console.error("FxNode browser host error callback failed", reportError); + } + }; + const originalTabIndex = canvas.getAttribute("tabindex"), + originalTouchAction = canvas.style.touchAction; + let viewport = measureViewport(canvas, ownerWindow.devicePixelRatio); + sizeCanvas(canvas, viewport); + let view: FxNodeView | undefined, + active = true, + attached = false, + lifecycleGeneration = 0, + authorization: { request: FxNodeResourceOpenRequest; generation: number } | undefined, + observer: ResizeObserver | undefined; + let unwatchDisconnect: (() => void) | undefined, + pendingViewport: FxNodeViewport | undefined, + resizeInFlight = false; + let changedTabIndex = false, + appliedTabIndex: string | null = null, + changedTouchAction = false, + pickerGeneration = 0, + menuPending = false; + const capturedPointers = new Set(), + subscriptions = new Set<() => void>(); + let menu: AddNodeMenu | undefined; + let resourceFile: HTMLInputElement | undefined; + + const defaultPicker = (request: FxNodeResourceOpenRequest) => { + if (!resourceFile) { + resourceFile = ownerDocument.createElement("input"); + resourceFile.type = "file"; + resourceFile.hidden = true; + resourceFile.dataset.fxnodeResourceFile = ""; + ownerDocument.body.append(resourceFile); + resourceFile.addEventListener("change", resourceChanged); + } + const generation = ++pickerGeneration; + authorization = { request, generation }; + resourceFile.accept = request.resource.accept.join(","); + resourceFile.value = ""; + resourceFile.click(); + }; + const resourceChanged = () => { + const pending = authorization, + file = resourceFile?.files?.[0]; + authorization = undefined; + if (!pending || !file || !view) return; + void file + .arrayBuffer() + .then((bytes) => { + if (active && view && pending.generation === pickerGeneration) + return view.provideResource(pending.request.authorization, { name: file.name, mime: file.type, bytes }); + }) + .catch((error) => { + if (active && pending.generation === pickerGeneration) report(error); + }); + }; + const activate = activateResourcePicker ?? defaultPicker; + const position = (event: MouseEvent) => { + const rect = canvas.getBoundingClientRect(); + return { x: event.clientX - rect.left, y: event.clientY - rect.top }; + }; + + const input = (event: Event) => { + if (!active || !view) return; + if (event instanceof PointerEvent) { + const phase = + event.type === "pointerdown" + ? "down" + : event.type === "pointermove" + ? "move" + : event.type === "pointerup" + ? "up" + : "cancel"; + const point = position(event); + if (phase === "down") { + menu?.close(false); + menuPending = event.button === 2 && !event.ctrlKey && (event.buttons & 1) === 0; + canvas.focus(); + try { + canvas.setPointerCapture(event.pointerId); + capturedPointers.add(event.pointerId); + } catch { + /* unsupported or detached */ + } + } + if ((phase === "up" || phase === "cancel") && capturedPointers.delete(event.pointerId)) + try { + if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId); + } catch { + /* detached */ + } + view.feedInput({ + kind: "pointer", + phase, + pointerId: event.pointerId, + pointerType: event.pointerType, + position: point, + button: event.button, + buttons: event.buttons, + modifiers: modifiers(event), + }); + return; + } + if (event instanceof WheelEvent) { + event.preventDefault(); + menu?.close(false); + menuPending = false; + const rect = canvas.getBoundingClientRect(), + scale = + event.deltaMode === WheelEvent.DOM_DELTA_LINE + ? 16 + : event.deltaMode === WheelEvent.DOM_DELTA_PAGE + ? Math.max(1, rect.height) + : 1; + view.feedInput({ + kind: "wheel", + position: { x: event.clientX - rect.left, y: event.clientY - rect.top }, + delta: { x: event.deltaX * scale, y: event.deltaY * scale }, + modifiers: modifiers(event), + }); + return; + } + if (event instanceof MouseEvent) { + if (event.button !== 2 || (event.buttons & 1) === 0) return; + menuPending = false; + view.feedInput({ + kind: "pointer", + phase: "down", + pointerId: 1, + pointerType: "mouse", + position: position(event), + button: event.button, + buttons: event.buttons, + modifiers: modifiers(event), + }); + return; + } + if (event instanceof KeyboardEvent) { + menu?.close(false); + menuPending = false; + view.feedInput({ + kind: "key", + phase: event.type === "keydown" ? "down" : "up", + key: event.key, + code: event.code, + repeat: event.repeat, + modifiers: modifiers(event), + }); + return; + } + view.feedInput({ kind: "focus", phase: event.type === "focus" ? "focus" : "blur" }); + }; + const contextMenu = (event: Event) => event.preventDefault(); + const lostCapture = (event: PointerEvent) => capturedPointers.delete(event.pointerId); + const outsidePointer = (event: PointerEvent) => { + if ( + active && + view && + event.button === 0 && + view.getHostSnapshot().colorPickerOpen && + event.target !== canvas && + !canvas.contains(event.target as Node) + ) { + menuPending = false; + view.feedInput({ kind: "outside-pointer", button: 0 }); + } + }; + const pumpViewport = () => { + if (!active || !view || resizeInFlight || !pendingViewport) return; + const next = pendingViewport, + generation = lifecycleGeneration; + pendingViewport = undefined; + if (sameViewport(viewport, next)) { + pumpViewport(); + return; + } + resizeInFlight = true; + menu?.close(false); + menuPending = false; + void Promise.resolve(view.setViewport(next)) + .then(() => { + if (!active || generation !== lifecycleGeneration) return; + viewport = next; + sizeCanvas(canvas, next); + }) + .catch((error) => { + if (active && generation === lifecycleGeneration) report(error); + }) + .finally(() => { + if (!active || generation !== lifecycleGeneration) return; + resizeInFlight = false; + pumpViewport(); + }); + }; + const syncViewport = () => { + if (!active) return; + const next = measureViewport(canvas, ownerWindow.devicePixelRatio); + if (view) { + pendingViewport = next; + pumpViewport(); + } else { + viewport = next; + sizeCanvas(canvas, next); + } + }; + const resize = () => syncViewport(); + const host: PreparedFxNodeBrowserHost = { + initialViewport: viewport, + get currentViewport() { + return viewport; + }, + attach(rootValue, viewValue) { + if (!active) throw new Error("FxNode browser host has been destroyed"); + if (attached) throw new Error("FxNode browser host is already attached"); + if (lifecycle === "detach-on-disconnect" && (!canvas.isConnected || canvas.ownerDocument !== ownerDocument)) { + host.destroy(); + throw new Error("detach-on-disconnect requires the canvas to remain connected to its original document"); + } + attached = true; + view = viewValue; + try { + if (addNodeMenuTemplate) menu = createAddNodeMenu(addNodeMenuTemplate, canvas, viewValue, report); + if (canvas.tabIndex < 0) { + canvas.tabIndex = 0; + changedTabIndex = true; + appliedTabIndex = canvas.getAttribute("tabindex"); + } + if (canvas.style.touchAction !== "none") { + canvas.style.touchAction = "none"; + changedTouchAction = true; + } + observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(resize); + for (const name of INPUT_EVENTS) canvas.addEventListener(name, input, { passive: name !== "wheel" }); + canvas.addEventListener("contextmenu", contextMenu); + canvas.addEventListener("lostpointercapture", lostCapture); + ownerDocument.addEventListener("pointerdown", outsidePointer, true); + ownerWindow.addEventListener("resize", resize); + observer?.observe(canvas); + if (lifecycle === "detach-on-disconnect") + unwatchDisconnect = watchDisconnect(ownerDocument, { + canvas, + disconnected() { + const detachedView = view; + host.destroy(); + void detachedView?.detach().catch(report); + }, + }); + subscriptions.add( + viewValue.onHostRequests((request) => { + if (request.kind === "add-node-menu") { + if (!menuPending) return; + menuPending = false; + menu?.open(request.viewPosition); + return; + } + menuPending = false; + menu?.close(false); + try { + void Promise.resolve(activate(request)).catch((error) => { + if (active) report(error); + }); + } catch (error) { + if (active) report(error); + } + }), + ); + const closeMenu = () => { + menuPending = false; + menu?.close(false); + }; + const invalidatePicker = () => { + pickerGeneration++; + authorization = undefined; + closeMenu(); + }; + subscriptions.add(rootValue.onCompositionChanges(invalidatePicker)); + subscriptions.add(rootValue.onMutations(invalidatePicker)); + syncViewport(); + } catch (error) { + host.destroy(); + throw error; + } + }, + syncViewport, + destroy() { + if (!active) return; + active = false; + lifecycleGeneration++; + pendingViewport = undefined; + unwatchDisconnect?.(); + unwatchDisconnect = undefined; + for (const unsubscribe of subscriptions) unsubscribe(); + subscriptions.clear(); + pickerGeneration++; + menuPending = false; + observer?.disconnect(); + ownerWindow.removeEventListener("resize", resize); + ownerDocument.removeEventListener("pointerdown", outsidePointer, true); + for (const name of INPUT_EVENTS) canvas.removeEventListener(name, input); + canvas.removeEventListener("contextmenu", contextMenu); + canvas.removeEventListener("lostpointercapture", lostCapture); + for (const pointerId of capturedPointers) + try { + if (canvas.hasPointerCapture(pointerId)) canvas.releasePointerCapture(pointerId); + } catch { + /* detached */ + } + capturedPointers.clear(); + authorization = undefined; + menu?.destroy(); + if (resourceFile) { + resourceFile.removeEventListener("change", resourceChanged); + resourceFile.remove(); + resourceFile = undefined; + } + if (changedTouchAction && canvas.style.touchAction === "none") canvas.style.touchAction = originalTouchAction; + if (changedTabIndex && canvas.getAttribute("tabindex") === appliedTabIndex) { + if (originalTabIndex === null) canvas.removeAttribute("tabindex"); + else canvas.setAttribute("tabindex", originalTabIndex); + } + if (activeHosts.get(canvas) === host) activeHosts.delete(canvas); + view = undefined; + }, + }; + activeHosts.set(canvas, host); + return host; +} diff --git a/vendor/fxnode/examples/shared/example.css b/vendor/fxnode/examples/shared/example.css new file mode 100644 index 0000000..db2acd4 --- /dev/null +++ b/vendor/fxnode/examples/shared/example.css @@ -0,0 +1,38 @@ +:root { + color-scheme: dark; + font-family: Inter, system-ui, sans-serif; + background: #101216; + color: #f1f3f5; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +main { + width: 1000px; + margin: 24px auto; +} +h1 { + margin: 0 0 6px; + font-size: 24px; +} +p { + margin: 0 0 14px; + color: #adb5bd; + line-height: 1.45; +} +canvas { + display: block; + width: 1000px; + height: 560px; + background: #1d1f23; +} +button { + margin: 0 0 14px; + padding: 8px 14px; +} +#status { + color: #ffd8a8; +} diff --git a/vendor/fxnode/examples/shared/globals.d.ts b/vendor/fxnode/examples/shared/globals.d.ts new file mode 100644 index 0000000..1024d6b --- /dev/null +++ b/vendor/fxnode/examples/shared/globals.d.ts @@ -0,0 +1,26 @@ +import type { CompositionReceipt, FxNode, FxNodeView } from "@lib/index.js"; +import type { PreparedFxNodeBrowserHost } from "./browser-host.js"; + +declare global { + interface StandaloneExampleHandle { + root: FxNode | null; + view: FxNodeView | null; + host: PreparedFxNodeBrowserHost; + ready: Promise; + graphVersion?: number; + lastCompositionReceipt?: CompositionReceipt; + cleanup(): void; + } + interface Window { + fxnodeStandalone: StandaloneExampleHandle; + fxnodeMultiView: MultiViewExampleHandle; + } + interface MultiViewExampleHandle { + root: FxNode | null; + views: FxNodeView[]; + ready: Promise; + cleanup(): Promise; + renderCounts: number[]; + } +} +export {}; diff --git a/vendor/fxnode/examples/shared/nodes/color-balance.ts b/vendor/fxnode/examples/shared/nodes/color-balance.ts new file mode 100644 index 0000000..261908a --- /dev/null +++ b/vendor/fxnode/examples/shared/nodes/color-balance.ts @@ -0,0 +1,352 @@ +import type { FxNodeDefinition } from "@lib/index.js"; +/** Shared canonical presentation/schema example; it does not evaluate pixels. */ +export const colorBalanceNode = [ + "fxnode.compositor.color-balance", + { + version: 1, + title: "Color Balance", + behavior: "standard", + style: "compositorColor", + parameters: { + mode: { + type: "string", + default: { + kind: "string", + value: "Lift/Gamma/Gain", + }, + enum: ["Lift/Gamma/Gain", "Offset/Power/Slope", "White Point"], + }, + lift: { + type: "number", + default: { + kind: "number", + value: 0, + }, + }, + liftColor: { + type: "color", + default: { + kind: "color", + value: [1, 1, 1, 1], + }, + }, + gamma: { + type: "number", + default: { + kind: "number", + value: 1, + }, + }, + gammaColor: { + type: "color", + default: { + kind: "color", + value: [1, 1, 1, 1], + }, + }, + gain: { + type: "number", + default: { + kind: "number", + value: 1, + }, + }, + gainColor: { + type: "color", + default: { + kind: "color", + value: [1, 1, 1, 1], + }, + }, + offset: { + type: "number", + default: { + kind: "number", + value: 0, + }, + }, + offsetColor: { + type: "color", + default: { + kind: "color", + value: [1, 1, 1, 1], + }, + }, + power: { + type: "number", + default: { + kind: "number", + value: 1, + }, + }, + powerColor: { + type: "color", + default: { + kind: "color", + value: [1, 1, 1, 1], + }, + }, + slope: { + type: "number", + default: { + kind: "number", + value: 1, + }, + }, + slopeColor: { + type: "color", + default: { + kind: "color", + value: [1, 1, 1, 1], + }, + }, + inputTemperature: { + type: "number", + default: { + kind: "number", + value: 6500, + }, + }, + inputTint: { + type: "number", + default: { + kind: "number", + value: 0, + }, + }, + inputColor: { + type: "color", + default: { + kind: "color", + value: [1, 1, 1, 1], + }, + }, + outputTemperature: { + type: "number", + default: { + kind: "number", + value: 6500, + }, + }, + outputTint: { + type: "number", + default: { + kind: "number", + value: 0, + }, + }, + outputColor: { + type: "color", + default: { + kind: "color", + value: [1, 1, 1, 1], + }, + }, + }, + sockets: { + image: { + title: "Image", + direction: "input", + type: "color", + maxIncomingLinks: 1, + visible: true, + value: { + type: "color", + default: { + kind: "color", + value: [0.8, 0.8, 0.8, 1], + }, + minimum: 0, + maximum: 1, + }, + showValue: true, + }, + factor: { + title: "Factor", + direction: "input", + type: "float", + maxIncomingLinks: 1, + visible: true, + value: { + type: "number", + default: { + kind: "number", + value: 1, + }, + minimum: 0, + maximum: 1, + }, + showValue: true, + }, + result: { + title: "Image", + direction: "output", + type: "color", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { + kind: "parameter", + parameter: "mode", + }, + { + kind: "widget", + widget: "grading-wheels", + bindings: [ + { + title: "Lift", + scalar: "lift", + color: "liftColor", + }, + { + title: "Gamma", + scalar: "gamma", + color: "gammaColor", + }, + { + title: "Gain", + scalar: "gain", + color: "gainColor", + }, + ], + visibleWhen: { + parameter: "mode", + equals: "Lift/Gamma/Gain", + }, + }, + { + kind: "widget", + widget: "grading-wheels", + bindings: [ + { + title: "Offset", + scalar: "offset", + color: "offsetColor", + }, + { + title: "Power", + scalar: "power", + color: "powerColor", + }, + { + title: "Slope", + scalar: "slope", + color: "slopeColor", + }, + ], + visibleWhen: { + parameter: "mode", + equals: "Offset/Power/Slope", + }, + }, + { + kind: "text", + variant: "section", + title: "Input", + visibleWhen: { + parameter: "mode", + equals: "White Point", + }, + }, + { + kind: "parameter", + parameter: "inputTemperature", + title: "Temperature", + visibleWhen: { + parameter: "mode", + equals: "White Point", + }, + }, + { + kind: "parameter", + parameter: "inputTint", + title: "Tint", + visibleWhen: { + parameter: "mode", + equals: "White Point", + }, + }, + { + kind: "parameter", + parameter: "inputColor", + title: "Color", + visibleWhen: { + parameter: "mode", + equals: "White Point", + }, + }, + { + kind: "text", + variant: "placeholder", + title: "Eyedropper (host bridge)", + visibleWhen: { + parameter: "mode", + equals: "White Point", + }, + }, + { + kind: "text", + variant: "section", + title: "Output", + visibleWhen: { + parameter: "mode", + equals: "White Point", + }, + }, + { + kind: "parameter", + parameter: "outputTemperature", + title: "Temperature", + visibleWhen: { + parameter: "mode", + equals: "White Point", + }, + }, + { + kind: "parameter", + parameter: "outputTint", + title: "Tint", + visibleWhen: { + parameter: "mode", + equals: "White Point", + }, + }, + { + kind: "parameter", + parameter: "outputColor", + title: "Color", + visibleWhen: { + parameter: "mode", + equals: "White Point", + }, + }, + { + kind: "text", + variant: "placeholder", + title: "Eyedropper (host bridge)", + visibleWhen: { + parameter: "mode", + equals: "White Point", + }, + }, + { + kind: "socket", + socket: "image", + }, + { + kind: "socket", + socket: "factor", + }, + { + kind: "socket", + socket: "result", + }, + ], + muteBypass: [["image", "result"]], + migrations: [], + }, +] as const satisfies readonly [string, FxNodeDefinition]; diff --git a/vendor/fxnode/examples/shared/theme.ts b/vendor/fxnode/examples/shared/theme.ts new file mode 100644 index 0000000..e29a601 --- /dev/null +++ b/vendor/fxnode/examples/shared/theme.ts @@ -0,0 +1,33 @@ +import type { FxNodeTheme } from "@lib/index.js"; +export const exampleTheme = { + background: "#1d1f23", + grid: "#33363c", + frame: "#30343a80", + frameHeader: "#59616c", + body: "#35383e", + control: "#24272b", + controlFill: "#4775b8", + controlEditing: "#181a1d", + textSelection: "#4775b8", + outline: "#111216", + text: "#e5e5e5", + muted: "#a5a8ad", + shadow: "#00000088", + nodeSelected: "#ed5700", + nodeActive: "#ffffff", + unknownHeader: "#555b64", + unknownSocket: "#999999", + linkMuted: "#d94b4b", + knifeMuted: "#e85b5b", + emphasis: "#ffffff", + focus: "#f5a623", + editOutline: "#666a70", + resize: "#8b8e95", + muteOverlay: "#14141459", + boxSelectionFill: "#f5a6231f", + checkerLight: "#aaaaaa", + checkerDark: "#777777", + widgetBorder: "#111216", + rampBorder: "#111111", + resourceBackground: "#202228", +} as const satisfies FxNodeTheme; diff --git a/vendor/fxnode/package-lock.json b/vendor/fxnode/package-lock.json new file mode 100644 index 0000000..dc8fb0f --- /dev/null +++ b/vendor/fxnode/package-lock.json @@ -0,0 +1,3983 @@ +{ + "name": "fxnode", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fxnode", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@playwright/test": "1.61.1", + "@types/node": "22.14.0", + "prettier": "^3.6.2", + "tsx": "4.19.2", + "typedoc": "0.28.20", + "typedoc-plugin-markdown": "4.12.0", + "typedoc-vitepress-theme": "1.1.3", + "typescript": "5.7.2", + "vite": "6.1.0", + "vitepress": "1.6.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@algolia/abtesting": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.22.0.tgz", + "integrity": "sha512-BFR6zNowNKcY7Ou7TaJc9QWexES4YKPbmf/OTFofpdsdhz4x6q0lbxp3duO0EHnyrN7rE4ba/TSXuY+BDGu4+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/autocomplete-core": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", + "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", + "@algolia/autocomplete-shared": "1.17.7" + } + }, + "node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", + "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@algolia/autocomplete-preset-algolia": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", + "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.17.7" + }, + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/autocomplete-shared": { + "version": "1.17.7", + "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", + "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/client-abtesting": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.56.0.tgz", + "integrity": "sha512-7r4Z3NC7yU1oAQVWJNA2HX7tX481F3pJvCGyLIXiTdBcthz4Q/o21jwcMYDFkuI92UWTNBQQmHYgwHo1zS5dzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.56.0.tgz", + "integrity": "sha512-avmjXQSq+jadFO8Xl2em05/uQdQnEmHsJyOAdVbZkmVgpMfxL12aJwVVfGNwYr9nulcpuJN1X0lTaQ5wxuNGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.56.0.tgz", + "integrity": "sha512-v2TPStUhY//ripPjIVclZ8AWc7DEGooXULZGFlFu37zNatgHjw34oZZ+OSbbc/YHO+xZwPl62I1k8xH1m4S2eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.56.0.tgz", + "integrity": "sha512-P0ehROpM4Sem3Sqo5x2cKPgj67D3G3jy0rh1Amwkcvsfr6tkvIcdCmerieanqTF7NxUMPNFLkpIFeMO8Rpa50w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.56.0.tgz", + "integrity": "sha512-SXK3Vn3WVxyzbm31oePZBJkp1wpOyuWdd4B/Pv7n0aXDxmeSWhC1R1FC1517mMrFAIaPH4Rt0x6RUe7ZNjz8FA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.56.0.tgz", + "integrity": "sha512-5+ZdX8garFnmycnZgKhtXHePEaLj5zqDxI/0lkhhluzCcvTn0/PvvTirTg8hHYetQHvn7GDyeAiqTAieMvMW4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.56.0.tgz", + "integrity": "sha512-+mKUdYvqOi0BcvpAEyCEw49vSBptufIcfibtHz2bdr1pI789M46Yt0uQEk/sxtK3teh71OQvVFHaTDzShUWewQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/ingestion": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.56.0.tgz", + "integrity": "sha512-9g/zj+AZx5moFcdFIrYQoVrueXivjUcc3MQHtCYT8WhIuk1lUh1AyEhvJCS0XBZld09cLvd1AZ3BvDBpVpX2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.56.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.56.0.tgz", + "integrity": "sha512-Qf3Sr6f9A9uxCZUf3MXS0d2b877uYzEB5yxqpVGXAhcJnBCQjrRRon0KvefpGkxy+BshrIJs96OUoMtGqXTFDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.56.0.tgz", + "integrity": "sha512-GXWG1rWc5wu8hY4N33Y3b6ernY6sAdAvmKWN/zHAiACOx40WnpG0TVX5YazCAr/9gOYGInSiM2A0y2jy2xbiDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.56.0.tgz", + "integrity": "sha512-7t24cBxaInS3mZb7ddEaZT/tp6q+/aR4YttsQVyP1/i+LmwPR34atO35KjaLFCcRVrlP7sYOAqkCfg6lIRB+ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.56.0.tgz", + "integrity": "sha512-R7ePHgVYmDFjZpvrsVAfbDz/d4RxKAYZ5/vgLfIsCVRZRryjWl/3INOxpOICzitehQ5FjNtNjcLQTrmHPTcHBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.56.0.tgz", + "integrity": "sha512-PIOUXlSnrqM0S+WOgDRb4RzotydJH7ZoT6tOyL7tAO7qJOfvX5wsEW8Pe+PMKMwvuI4/gIyK9cg2H7lJXqnc4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@docsearch/css": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", + "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@docsearch/js": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", + "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/react": "3.8.2", + "preact": "^10.0.0" + } + }, + "node_modules/@docsearch/react": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", + "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-core": "1.17.7", + "@algolia/autocomplete-preset-algolia": "1.17.7", + "@docsearch/css": "3.8.2", + "algoliasearch": "^5.14.2" + }, + "peerDependencies": { + "@types/react": ">= 16.8.0 < 19.0.0", + "react": ">= 16.8.0 < 19.0.0", + "react-dom": ">= 16.8.0 < 19.0.0", + "search-insights": ">= 1 < 3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "search-insights": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", + "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", + "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", + "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", + "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", + "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", + "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", + "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", + "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", + "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", + "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", + "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", + "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", + "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", + "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", + "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", + "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", + "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", + "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", + "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", + "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", + "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", + "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", + "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", + "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@gerrit0/mini-shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.23.0.tgz", + "integrity": "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-oniguruma": "^3.23.0", + "@shikijs/langs": "^3.23.0", + "@shikijs/themes": "^3.23.0", + "@shikijs/types": "^3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@iconify-json/simple-icons": { + "version": "1.2.91", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.91.tgz", + "integrity": "sha512-plQJHZxzpkIXaeYjJF0Sc9nkzYF32EDX9olx7j1hcKYFaiTrQXbcngZmvzuV0pG6uZPU3ZuA/GIsgxSHOm6jrQ==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shikijs/core": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", + "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.4" + } + }, + "node_modules/@shikijs/core/node_modules/@shikijs/engine-oniguruma": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", + "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/core/node_modules/@shikijs/types": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", + "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", + "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^3.1.0" + } + }, + "node_modules/@shikijs/engine-javascript/node_modules/@shikijs/types": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", + "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/transformers": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", + "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/types": "2.5.0" + } + }, + "node_modules/@shikijs/transformers/node_modules/@shikijs/types": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", + "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.14.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz", + "integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz", + "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.40", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz", + "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz", + "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.40", + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-ssr": "3.5.40", + "@vue/shared": "3.5.40", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz", + "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.10.tgz", + "integrity": "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.10" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.10.tgz", + "integrity": "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.10", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.10", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.10.tgz", + "integrity": "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz", + "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.40.tgz", + "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz", + "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/runtime-core": "3.5.40", + "@vue/shared": "3.5.40", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.40.tgz", + "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.40.tgz", + "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", + "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/integrations": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", + "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vueuse/core": "12.8.2", + "@vueuse/shared": "12.8.2", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", + "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.8.2", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", + "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/algoliasearch": { + "version": "5.56.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.56.0.tgz", + "integrity": "sha512-PrqppUmhT4ENdas2pH9caE7efUcxy6EcSFhWzosiVuQBzu2tQ5yLTI6jwomT/1cuBnivzGfxiJCqDNN9FRRh+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.22.0", + "@algolia/client-abtesting": "5.56.0", + "@algolia/client-analytics": "5.56.0", + "@algolia/client-common": "5.56.0", + "@algolia/client-insights": "5.56.0", + "@algolia/client-personalization": "5.56.0", + "@algolia/client-query-suggestions": "5.56.0", + "@algolia/client-search": "5.56.0", + "@algolia/ingestion": "1.56.0", + "@algolia/monitoring": "1.56.0", + "@algolia/recommend": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/emoji-regex-xs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", + "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", + "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.23.1", + "@esbuild/android-arm": "0.23.1", + "@esbuild/android-arm64": "0.23.1", + "@esbuild/android-x64": "0.23.1", + "@esbuild/darwin-arm64": "0.23.1", + "@esbuild/darwin-x64": "0.23.1", + "@esbuild/freebsd-arm64": "0.23.1", + "@esbuild/freebsd-x64": "0.23.1", + "@esbuild/linux-arm": "0.23.1", + "@esbuild/linux-arm64": "0.23.1", + "@esbuild/linux-ia32": "0.23.1", + "@esbuild/linux-loong64": "0.23.1", + "@esbuild/linux-mips64el": "0.23.1", + "@esbuild/linux-ppc64": "0.23.1", + "@esbuild/linux-riscv64": "0.23.1", + "@esbuild/linux-s390x": "0.23.1", + "@esbuild/linux-x64": "0.23.1", + "@esbuild/netbsd-x64": "0.23.1", + "@esbuild/openbsd-arm64": "0.23.1", + "@esbuild/openbsd-x64": "0.23.1", + "@esbuild/sunos-x64": "0.23.1", + "@esbuild/win32-arm64": "0.23.1", + "@esbuild/win32-ia32": "0.23.1", + "@esbuild/win32-x64": "0.23.1" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/focus-trap": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", + "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tabbable": "^6.4.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/markdown-it": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minisearch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "dev": true, + "license": "MIT" + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oniguruma-to-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", + "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex-xs": "^1.0.0", + "regex": "^6.0.1", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.20", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", + "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/preact": { + "version": "10.29.7", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz", + "integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/search-insights": { + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", + "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/shiki": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", + "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "2.5.0", + "@shikijs/engine-javascript": "2.5.0", + "@shikijs/engine-oniguruma": "2.5.0", + "@shikijs/langs": "2.5.0", + "@shikijs/themes": "2.5.0", + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/shiki/node_modules/@shikijs/engine-oniguruma": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", + "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/shiki/node_modules/@shikijs/langs": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", + "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/shiki/node_modules/@shikijs/themes": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", + "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "2.5.0" + } + }, + "node_modules/shiki/node_modules/@shikijs/types": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", + "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tsx": { + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.2.tgz", + "integrity": "sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.23.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typedoc": { + "version": "0.28.20", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.20.tgz", + "integrity": "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@gerrit0/mini-shiki": "^3.23.0", + "lunr": "^2.3.9", + "markdown-it": "^14.3.0", + "minimatch": "^10.2.5", + "yaml": "^2.9.0" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 18", + "pnpm": ">= 10" + }, + "peerDependencies": { + "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" + } + }, + "node_modules/typedoc-plugin-markdown": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/typedoc-plugin-markdown/-/typedoc-plugin-markdown-4.12.0.tgz", + "integrity": "sha512-eJDEMAfxCmede22c/Jw7d0FA13ggAQv+KkwQYKYCdqI02cin6Rc9QRwbG/7XvvHWinuFejySnZVUWDtvGk3Vbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "typedoc": "0.28.x" + } + }, + "node_modules/typedoc-vitepress-theme": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/typedoc-vitepress-theme/-/typedoc-vitepress-theme-1.1.3.tgz", + "integrity": "sha512-EK9iV7e3+R8lFNigdc0rIPWMxqfmDku0uGac3qYUu9tS4Qf1rhWZnyZJ4zu4G3iXrP5mqNPkv2wpODzRlA7jLw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typedoc-plugin-markdown": ">=4.11.0" + } + }, + "node_modules/typescript": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.0.tgz", + "integrity": "sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.24.2", + "postcss": "^8.5.1", + "rollup": "^4.30.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/vitepress": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", + "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/css": "3.8.2", + "@docsearch/js": "3.8.2", + "@iconify-json/simple-icons": "^1.2.21", + "@shikijs/core": "^2.1.0", + "@shikijs/transformers": "^2.1.0", + "@shikijs/types": "^2.1.0", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-vue": "^5.2.1", + "@vue/devtools-api": "^7.7.0", + "@vue/shared": "^3.5.13", + "@vueuse/core": "^12.4.0", + "@vueuse/integrations": "^12.4.0", + "focus-trap": "^7.6.4", + "mark.js": "8.11.1", + "minisearch": "^7.1.1", + "shiki": "^2.1.0", + "vite": "^5.4.14", + "vue": "^3.5.13" + }, + "bin": { + "vitepress": "bin/vitepress.js" + }, + "peerDependencies": { + "markdown-it-mathjax3": "^4", + "postcss": "^8" + }, + "peerDependenciesMeta": { + "markdown-it-mathjax3": { + "optional": true + }, + "postcss": { + "optional": true + } + } + }, + "node_modules/vitepress/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitepress/node_modules/@shikijs/types": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", + "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/vitepress/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitepress/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz", + "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-sfc": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/server-renderer": "3.5.40", + "@vue/shared": "3.5.40" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/vendor/fxnode/package.json b/vendor/fxnode/package.json new file mode 100644 index 0000000..79cdcbe --- /dev/null +++ b/vendor/fxnode/package.json @@ -0,0 +1,89 @@ +{ + "name": "fxnode", + "version": "0.1.0", + "private": true, + "description": "Internal prerelease of a clean-room, typed, worker-owned node editor", + "license": "MIT", + "type": "module", + "sideEffects": false, + "files": [ + "dist", + "README.md", + "LICENSE", + "NOTICE.md" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/heaust-ops/fxnode.git" + }, + "homepage": "https://github.com/heaust-ops/fxnode#readme", + "bugs": { + "url": "https://github.com/heaust-ops/fxnode/issues" + }, + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./headless": { + "types": "./dist/headless.d.ts", + "import": "./dist/headless.js" + }, + "./widgets/color-ramp": { + "types": "./dist/widgets/color-ramp.d.ts", + "import": "./dist/widgets/color-ramp.js" + } + }, + "scripts": { + "test": "node --import tsx --test test/*.test.ts", + "test:browser": "playwright test --config playwright.config.ts", + "test:visual": "playwright test --config playwright.visual.config.ts", + "test:visual:update": "playwright test --config playwright.visual.config.ts --update-snapshots", + "examples": "vite --config vite.config.ts examples --host 127.0.0.1", + "example": "npm run examples", + "test:examples:visual": "playwright test --config playwright.examples.config.ts", + "test:examples:visual:update": "playwright test --config playwright.examples.config.ts --update-snapshots", + "format": "prettier --write .", + "format:check": "prettier --check .", + "build": "vite build && tsc -p tsconfig.build.json", + "check:readme": "tsx tools/check-readme.ts", + "check:package": "tsx tools/check-package.ts", + "docs:api": "typedoc --options typedoc.json", + "check:docs:api": "npm run docs:api && tsx tools/check-api-docs.ts", + "docs:dev": "npm run docs:api && vitepress dev docs", + "docs:build": "npm run docs:api && vitepress build docs", + "docs:preview": "vitepress preview docs", + "check:docs": "npm run check:docs:api && vitepress build docs", + "release:check": "npm run check && npm run check:readme && npm run check:docs && npm run test:browser && npm run test:visual && npm run test:examples:visual && npm run check:package", + "prepublishOnly": "npm run release:check", + "check": "npm run typecheck && npm test && npm run check:performance && npm run check:all-supported && npm run check:blender-fixture && npm run check:browser-fixtures && npm run build && npm run check:composition && npm run check:references", + "check:performance": "tsx tools/performance-phase7.ts --check", + "benchmark:performance": "tsx tools/performance-phase7.ts", + "typecheck": "tsc --noEmit", + "check:composition": "tsx tools/check-application-composition.ts", + "check:references": "tsx tools/check-references.ts", + "check:references:strict": "tsx tools/check-references.ts --strict", + "generate:all-supported": "tsx tools/all-supported.ts --write", + "check:all-supported": "tsx tools/all-supported.ts", + "generate:blender-fixture": "tsx tools/blender-fixture.ts --write", + "check:blender-fixture": "tsx tools/blender-fixture.ts", + "generate:browser-fixtures": "tsx tools/browser-fixtures.ts --write", + "check:browser-fixtures": "tsx tools/browser-fixtures.ts" + }, + "devDependencies": { + "@playwright/test": "1.61.1", + "@types/node": "22.14.0", + "prettier": "^3.6.2", + "tsx": "4.19.2", + "typedoc": "0.28.20", + "typedoc-plugin-markdown": "4.12.0", + "typedoc-vitepress-theme": "1.1.3", + "typescript": "5.7.2", + "vite": "6.1.0", + "vitepress": "1.6.4" + }, + "engines": { + "node": ">=20" + } +} diff --git a/vendor/fxnode/playwright.config.ts b/vendor/fxnode/playwright.config.ts new file mode 100644 index 0000000..2eed481 --- /dev/null +++ b/vendor/fxnode/playwright.config.ts @@ -0,0 +1,15 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "test/browser", + testIgnore: /.*visual\.spec\.ts/, + grepInvert: /@visual/, + projects: [ + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + { name: "firefox", use: { ...devices["Desktop Firefox"] } }, + ], + use: { baseURL: "http://127.0.0.1:4173" }, + webServer: { command: "npx vite --host 127.0.0.1 --port 4173", port: 4173, reuseExistingServer: false }, + timeout: 20_000, + snapshotPathTemplate: "{testDir}/{testFilePath}-snapshots/{arg}-{platform}{ext}", +}); diff --git a/vendor/fxnode/playwright.examples.config.ts b/vendor/fxnode/playwright.examples.config.ts new file mode 100644 index 0000000..5fc2e9a --- /dev/null +++ b/vendor/fxnode/playwright.examples.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "@playwright/test"; +export default defineConfig({ + testDir: "test/browser", + testMatch: "**/examples.docs.visual.spec.ts", + projects: [ + { + name: "chromium", + use: { browserName: "chromium", viewport: { width: 1200, height: 720 }, deviceScaleFactor: 1 }, + }, + ], + use: { baseURL: "http://127.0.0.1:4173" }, + webServer: { + command: "npx vite --config vite.config.ts --host 127.0.0.1 --port 4173", + port: 4173, + reuseExistingServer: false, + }, + snapshotPathTemplate: "examples/assets/{arg}.png", + timeout: 20_000, +}); diff --git a/vendor/fxnode/playwright.visual.config.ts b/vendor/fxnode/playwright.visual.config.ts new file mode 100644 index 0000000..62f5741 --- /dev/null +++ b/vendor/fxnode/playwright.visual.config.ts @@ -0,0 +1,13 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "test/browser", + // Keep the compact regression, catalog overview, and focused parity evidence + // in one Chromium-only visual run. + testMatch: ["**/visual.spec.ts", "**/all-supported.spec.ts", "**/parity.visual.spec.ts"], + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + use: { baseURL: "http://127.0.0.1:4173", deviceScaleFactor: 1 }, + webServer: { command: "npx vite --host 127.0.0.1 --port 4173", port: 4173, reuseExistingServer: false }, + timeout: 20_000, + snapshotPathTemplate: "{testDir}/{testFilePath}-snapshots/{arg}-{platform}{ext}", +}); diff --git a/vendor/fxnode/src/browser/client.ts b/vendor/fxnode/src/browser/client.ts new file mode 100644 index 0000000..e2aa9ea --- /dev/null +++ b/vendor/fxnode/src/browser/client.ts @@ -0,0 +1,1238 @@ +import type { Command, FxNodeSaveData } from "../commands/types.js"; +import type { GraphLayoutV2, GraphSnapshot } from "../core/types.js"; +import type { MutationEnvelope, SnapshotEnvelope } from "../composition/bound-engine.js"; +import { createInitialFxNodeComposition } from "../composition/compile.js"; +import { + validCommandReceipt, + validCompositionReceipt, + validWorkerMessage, + PROTOCOL_VERSION, + type CompositionChange, + type CompositionChangeEnvelope, + type CompositionReceipt, + type CompositionRevisionExpectation, + type CompositionUpdateWire, + type InputEventWire, + type PointerFence, + type VersionExpectation, + type WorkerRequest, +} from "./protocol.js"; +import { + advancePointerLaneFence, + createPointerLane, + pointerLaneFence, + publishPointerMove, + supportsPointerLane, + type PointerLaneSnapshot, + type PointerMoveWire, +} from "./pointer-lane.js"; +import type { + FxNodeCompositionData, + NodeTypeId, + FxNodeDefinition, + FxNodeSocketTypeDefinition, + FxNodeStyleDefinition, + FxNodeTheme, +} from "../composition/types.js"; +import type { NodeReferenceCheck, ReferenceCheck } from "../composition/references.js"; +import defaultWorkerUrl from "../worker/fxnode.worker.ts?worker&url"; +import type { + AddNodeParams, + FxNodeActionOptions, + FxNodeHostRequest, + FxNodeHostSnapshot, + FxNodeCamera, + FxNodeInput, + FxNodeResourceAuthorization, + FxNodeResourceData, + FxNodeSelectionSnapshot, + FxNodeViewport, +} from "./host-types.js"; +import { + decodeFxNodeActionOptions, + decodeFxNodeAddNodeParams, + decodeFxNodeCamera, + decodeFxNodeInput, + decodeFxNodeResourceAuthorization, + decodeFxNodeResourceData, + decodeFxNodeViewport, +} from "./host-decode.js"; +import { fxNodeDevicePixels, FXNODE_VIEW_LIMITS } from "./view-limits.js"; + +export interface FxNodeIssue { + readonly code: string; + readonly message: string; + readonly path?: string; +} +export class FxNodeCapabilityError extends Error { + override name = "FxNodeCapabilityError"; + constructor( + message: string, + readonly code = "capability.unavailable", + ) { + super(message); + } +} +export class FxNodeProtocolError extends Error { + override name = "FxNodeProtocolError"; + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + } +} +export class FxNodeWorkerError extends Error { + override name = "FxNodeWorkerError"; + constructor( + message: string, + readonly code = "worker.error", + readonly issues?: readonly FxNodeIssue[], + readonly path?: string, + ) { + super(message); + } +} +export class FxNodeDestroyedError extends Error { + override name = "FxNodeDestroyedError"; + constructor() { + super("FxNode has been destroyed"); + } +} +export class FxNodeViewDetachedError extends Error { + override name = "FxNodeViewDetachedError"; + constructor() { + super("FxNode view has been detached"); + } +} + +export type CommandIntent = + | Exclude + | (Omit, "nodeId"> & { + nodeId?: Extract["nodeId"]; + }) + | (Omit, "link"> & { + link: Omit["link"], "id"> & { + id?: Extract["link"]["id"]; + }; + }); +export interface FxNode { + attachView(options: FxNodeViewOptions): Promise; + dispatch(intent: CommandIntent, options?: { expectedVersion?: number }): Promise; + undo(options?: { expectedVersion?: number }): Promise; + redo(options?: { expectedVersion?: number }): Promise; + save(): Promise; + getSaveData(): Promise>; + load(data: unknown, options?: { expectedVersion?: number }): Promise; + getState(): Promise>; + setState(state: unknown, options?: { expectedVersion?: number }): Promise; + onMutations(callback: (event: MutationEnvelope) => void): () => void; + onSnapshots(callback: (event: SnapshotEnvelope) => void): () => void; + setTheme(theme: FxNodeTheme, options?: CompositionUpdateOptions): Promise; + setHeaderStyles( + styles: Readonly>, + options?: CompositionUpdateOptions, + ): Promise; + setCompatibility( + compatibility: FxNodeCompositionData["compatibility"], + options?: CompositionUpdateOptions, + ): Promise; + composeSocket( + id: string, + definition: FxNodeSocketTypeDefinition, + options?: CompositionUpdateOptions, + ): Promise; + removeSocket(id: string, options?: CompositionUpdateOptions): Promise; + composeNode( + id: string, + definition: D & NodeReferenceCheck, + options?: CompositionUpdateOptions, + ): Promise; + removeNode(id: string, options?: CompositionUpdateOptions): Promise; + loadComposition( + composition: C & ReferenceCheck, + options?: CompositionUpdateOptions, + ): Promise; + onCompositionChanges(callback: (event: CompositionChangeEnvelope) => void): () => void; + destroy(): void; +} +export interface FxNodeViewOptions { + canvas: HTMLCanvasElement; + viewport: FxNodeViewport; + initialCamera?: FxNodeCamera; +} +export interface FxNodeView { + readonly id: string; + feedInput: (input: FxNodeInput) => void; + setViewport: (viewport: FxNodeViewport) => Promise; + getHostSnapshot: () => FxNodeHostSnapshot; + subscribeHost: (callback: () => void) => () => void; + addNode(params: AddNodeParams, options?: FxNodeActionOptions): Promise; + removeSelected(options?: FxNodeActionOptions): Promise; + setSelectedMuted(value: boolean, options?: FxNodeActionOptions): Promise; + onHostRequests(callback: (request: FxNodeHostRequest) => void): () => void; + provideResource( + authorization: FxNodeResourceAuthorization, + data: FxNodeResourceData, + options?: FxNodeActionOptions, + ): Promise; + whenRendered(): Promise; + detach(): Promise; +} +export interface CreateFxNodeOptions { + applicationId: string; + applicationVersion: number; + resources: FxNodeCompositionData["resources"]; + historyLimit?: number; + workerUrl?: string | URL; +} +export interface CommandReceipt { + status: "committed" | "noop"; + version: number; +} +export interface CompositionUpdateOptions { + readonly expectedRevision?: number; +} +export type { CompositionChange, CompositionChangeEnvelope, CompositionReceipt } from "./protocol.js"; + +type Pending = { + requestType: RpcRequest["type"]; + owner: "root" | FxNodeViewClient; + resolve: (value: unknown) => void; + reject: (reason: unknown) => void; +}; +type Barrier = { resolve: () => void; reject: (reason: unknown) => void }; +type RpcRequest = WorkerRequest extends infer R + ? R extends { id: string } + ? Omit + : never + : never; +const STARTUP_TIMEOUT_MS = 5_000; +const emptySelection = (): FxNodeSelectionSnapshot => + Object.freeze({ nodeCount: 0, linkCount: 0, canRemove: false, mute: Object.freeze({ enabled: false as const }) }); + +const reservedCanvases = new WeakSet(); + +class FxNodeClient implements FxNode { + private terminalError: Error | undefined; + private readonly pending = new Map(); + private readonly views = new Map(); + private readonly mutations = new Set<(event: MutationEnvelope) => void>(); + private readonly snapshots = new Set<(event: SnapshotEnvelope) => void>(); + private readonly compositionChanges = new Set<(event: CompositionChangeEnvelope) => void>(); + private readonly unclaimedCompositionEvents = new Map(); + private compositionRevision: number | undefined; + private aggregateDevicePixels = 0; + private attachingViewCount = 0; + private readonly usedViewIds = new Set(); + private rpcCounter = 0; + constructor(private readonly worker: Worker) { + worker.onmessage = this.onMessage; + worker.onerror = () => + this.shutdown( + new FxNodeCapabilityError( + "The FxNode module worker failed to load. Check workerUrl, CSP worker-src, URL accessibility, and JavaScript MIME type.", + "worker.load", + ), + ); + worker.onmessageerror = () => + this.shutdown(new FxNodeProtocolError("The FxNode worker sent an uncloneable message")); + } + + async initialize( + applicationId: string, + applicationVersion: number, + resources: FxNodeCompositionData["resources"], + historyLimit: number, + ): Promise { + const timeout = setTimeout( + () => + this.shutdown( + new FxNodeCapabilityError( + "FxNode worker startup timed out. Check workerUrl, CSP worker-src, URL accessibility, and JavaScript MIME type.", + "worker.timeout", + ), + ), + STARTUP_TIMEOUT_MS, + ); + try { + await this.post({ + type: "init", + applicationId, + applicationVersion, + resources, + historyLimit, + }); + if (this.compositionRevision !== 0) this.protocolFailure("FxNode worker did not publish its initial state"); + } finally { + clearTimeout(timeout); + } + } + + private readonly onMessage = (event: MessageEvent): void => { + let recognizableBitmap: ImageBitmap | undefined; + try { + recognizableBitmap = this.bitmapFrom(event.data); + if (!validWorkerMessage(event.data)) { + this.shutdown(new FxNodeProtocolError("Invalid message from FxNode worker")); + return; + } + const data = event.data; + if (data.type === "response") { + const pending = this.pending.get(data.id); + if (!pending) return; + if ( + (pending.requestType === "init" || + pending.requestType === "view.attach" || + pending.requestType === "view.detach" || + pending.requestType === "view.viewport") && + data.ok && + Object.hasOwn(data, "value") + ) { + this.shutdown(new FxNodeProtocolError("FxNode worker returned a value for a valueless response")); + return; + } + this.pending.delete(data.id); + if (data.ok) { + if (pending.requestType === "init") this.compositionRevision = 0; + pending.resolve(Object.hasOwn(data, "value") ? structuredClone(data.value) : undefined); + } else { + const capabilityCodes = new Set([ + "atlas.create", + "atlas.context", + "atlas.crop", + "atlas.context-lost", + "view.render.unavailable", + ]); + pending.reject( + capabilityCodes.has(data.error.code) + ? new FxNodeCapabilityError(data.error.message, data.error.code) + : new FxNodeWorkerError( + data.error.message, + data.error.code, + data.error.issues as readonly FxNodeIssue[] | undefined, + data.error.path, + ), + ); + } + } else if (data.type === "composition.event") { + const envelope = data.envelope; + if ( + this.compositionRevision === undefined || + envelope.baseRevision !== this.compositionRevision || + envelope.revision !== this.compositionRevision + 1 || + this.unclaimedCompositionEvents.has(envelope.revision) + ) { + this.shutdown(new FxNodeProtocolError("FxNode worker published an invalid composition revision sequence")); + return; + } + this.compositionRevision = envelope.revision; + for (const view of this.views.values()) view.clearDocumentDependentHostState(envelope.revision); + this.unclaimedCompositionEvents.set(envelope.revision, envelope); + this.notify(this.compositionChanges, envelope); + } else if (data.type === "mutation") { + for (const view of this.views.values()) view.clearDocumentDependentHostState(); + this.notify(this.mutations, data.envelope); + } else if (data.type === "snapshot.event") this.notify(this.snapshots, data.envelope); + else if (data.type === "fatal") this.shutdown(new FxNodeWorkerError(data.error.message, data.error.code)); + else this.views.get(data.viewId)?.consumeMessage(data); + } finally { + try { + recognizableBitmap?.close(); + } catch {} + } + }; + + private bitmapFrom(data: unknown): ImageBitmap | undefined { + if (typeof ImageBitmap === "undefined" || typeof data !== "object" || data === null) return undefined; + try { + const bitmap = (data as { bitmap?: unknown }).bitmap; + return bitmap instanceof ImageBitmap ? bitmap : undefined; + } catch { + return undefined; + } + } + private notify(callbacks: ReadonlySet<(event: T) => void>, event: T): void { + for (const callback of callbacks) + try { + callback(event); + } catch (error) { + console.error("FxNode subscriber failed", error); + } + } + safePost(message: WorkerRequest, transfer: Transferable[] = []): boolean { + try { + this.worker.postMessage(message, transfer); + return true; + } catch { + return false; + } + } + requiredPost(message: WorkerRequest): boolean { + if (this.safePost(message)) return true; + this.shutdown(new FxNodeProtocolError("Unable to send a message to the FxNode worker")); + return false; + } + post( + message: RpcRequest, + transfer: Transferable[] = [], + onPosted?: () => void, + owner: "root" | FxNodeViewClient = "root", + ): Promise { + if (this.terminalError) return Promise.reject(this.terminalError); + if ( + (message.type === "command" || + message.type === "load" || + message.type === "state.set" || + message.type === "composition.update") && + !this.flushViews() + ) + return Promise.reject(this.terminalError!); + if (this.rpcCounter >= Number.MAX_SAFE_INTEGER) { + const error = new FxNodeProtocolError("FxNode RPC identifier counter exhausted"); + this.shutdown(error); + return Promise.reject(error); + } + let id: string; + do id = `${++this.rpcCounter}:${crypto.randomUUID()}`; + while (this.pending.has(id)); + return new Promise((resolve, reject) => { + this.pending.set(id, { requestType: message.type, owner, resolve: (value) => resolve(value as T), reject }); + try { + this.worker.postMessage({ protocol: PROTOCOL_VERSION, id, ...message }, transfer); + onPosted?.(); + } catch (error) { + this.pending.delete(id); + const failure = new FxNodeProtocolError("Unable to send a message to the FxNode worker", { + cause: error, + }); + this.shutdown(failure); + reject(failure); + } + }); + } + private flushViews(): boolean { + for (const view of this.views.values()) if (view.isAttached() && !view.flushPointerLane()) return false; + return true; + } + private expected(version?: number): VersionExpectation { + return version === undefined ? { kind: "current" } : { kind: "exact", version }; + } + private compositionExpected(revision?: number): CompositionRevisionExpectation { + return revision === undefined ? { kind: "current" } : { kind: "exact", revision }; + } + private compositionChange(update: CompositionUpdateWire): CompositionChange { + return update.kind === "theme.set" || + update.kind === "header-styles.set" || + update.kind === "composition.load" || + update.kind === "compatibility.set" + ? { kind: update.kind } + : { kind: update.kind, id: update.id }; + } + private sameCompositionChange(left: CompositionChange, right: CompositionChange): boolean { + return ( + left.kind === right.kind && + (left.kind === "theme.set" || + left.kind === "header-styles.set" || + left.kind === "composition.load" || + left.kind === "compatibility.set" || + (right.kind !== "theme.set" && + right.kind !== "header-styles.set" && + right.kind !== "composition.load" && + right.kind !== "compatibility.set" && + left.id === right.id)) + ); + } + private protocolFailure(message: string): never { + const error = new FxNodeProtocolError(message); + this.shutdown(error); + throw error; + } + private async updateComposition( + update: CompositionUpdateWire, + options?: CompositionUpdateOptions, + ): Promise { + const receipt = await this.post({ + type: "composition.update", + expected: this.compositionExpected(options?.expectedRevision), + update, + }); + if (!validCompositionReceipt(receipt)) + return this.protocolFailure("FxNode worker returned an invalid composition receipt"); + if (receipt.revision !== this.compositionRevision) + return this.protocolFailure("FxNode composition receipt does not match the published revision"); + if (options?.expectedRevision !== undefined) { + const expected = receipt.status === "committed" ? options.expectedRevision + 1 : options.expectedRevision; + if (receipt.revision !== expected) + return this.protocolFailure("FxNode composition receipt violates its revision expectation"); + } + if (receipt.status === "committed") { + const envelope = this.unclaimedCompositionEvents.get(receipt.revision); + if ( + !envelope || + !this.sameCompositionChange(envelope.change, this.compositionChange(update)) || + envelope.graphVersion !== receipt.graphVersion || + envelope.graphChanged !== receipt.graphChanged || + !envelope.historyReset + ) + return this.protocolFailure("FxNode composition receipt does not match its composition event"); + this.unclaimedCompositionEvents.delete(receipt.revision); + } + return receipt; + } + private commandReceipt(value: unknown, expected: VersionExpectation): CommandReceipt { + if (!validCommandReceipt(value)) return this.protocolFailure("FxNode worker returned an invalid command receipt"); + if ( + expected.kind === "exact" && + value.version !== (value.status === "committed" ? expected.version + 1 : expected.version) + ) + return this.protocolFailure("FxNode worker returned an incoherent command receipt"); + return value; + } + private async postCommand(request: RpcRequest, expected: VersionExpectation): Promise { + return this.commandReceipt(await this.post(request), expected); + } + dispatch(intent: CommandIntent, options?: { expectedVersion?: number }): Promise { + let command = intent as Command; + if (intent.type === "node.add" && !intent.nodeId) + command = { ...intent, nodeId: crypto.randomUUID() } as unknown as Command; + if (intent.type === "link.add" && !intent.link.id) + command = { ...intent, link: { ...intent.link, id: crypto.randomUUID() } } as Command; + const expected = this.expected(options?.expectedVersion); + return this.postCommand({ type: "command", command, expected }, expected); + } + undo(options?: { expectedVersion?: number }) { + return this.dispatch({ type: "undo" }, options); + } + redo(options?: { expectedVersion?: number }) { + return this.dispatch({ type: "redo" }, options); + } + save() { + return this.post({ type: "save" }); + } + getSaveData() { + return this.post>({ type: "save.data" }); + } + load(layout: unknown, options?: { expectedVersion?: number }) { + const expected = this.expected(options?.expectedVersion); + return this.postCommand({ type: "load", data: layout, expected }, expected); + } + getState() { + return this.post>({ type: "state.get" }); + } + setState(state: unknown, options?: { expectedVersion?: number }) { + const expected = this.expected(options?.expectedVersion); + return this.postCommand({ type: "state.set", state, expected }, expected); + } + onMutations(callback: (event: MutationEnvelope) => void) { + if (this.terminalError) throw this.terminalError; + if (typeof callback !== "function") throw new TypeError("Mutation callback must be a function"); + this.mutations.add(callback); + return () => this.mutations.delete(callback); + } + onSnapshots(callback: (event: SnapshotEnvelope) => void) { + if (this.terminalError) throw this.terminalError; + if (typeof callback !== "function") throw new TypeError("Snapshot callback must be a function"); + this.snapshots.add(callback); + return () => this.snapshots.delete(callback); + } + setTheme(theme: FxNodeTheme, options?: CompositionUpdateOptions) { + return this.updateComposition({ kind: "theme.set", theme }, options); + } + setHeaderStyles(styles: Readonly>, options?: CompositionUpdateOptions) { + return this.updateComposition({ kind: "header-styles.set", styles }, options); + } + setCompatibility(compatibility: FxNodeCompositionData["compatibility"], options?: CompositionUpdateOptions) { + return this.updateComposition({ kind: "compatibility.set", compatibility }, options); + } + composeSocket(id: string, definition: FxNodeSocketTypeDefinition, options?: CompositionUpdateOptions) { + return this.updateComposition({ kind: "socket.compose", id, definition }, options); + } + removeSocket(id: string, options?: CompositionUpdateOptions) { + return this.updateComposition({ kind: "socket.remove", id }, options); + } + composeNode( + id: string, + definition: D & NodeReferenceCheck, + options?: CompositionUpdateOptions, + ) { + return this.updateComposition({ kind: "node.compose", id, definition }, options); + } + removeNode(id: string, options?: CompositionUpdateOptions) { + return this.updateComposition({ kind: "node.remove", id }, options); + } + loadComposition( + composition: T & ReferenceCheck, + options?: CompositionUpdateOptions, + ) { + return this.updateComposition({ kind: "composition.load", composition }, options); + } + onCompositionChanges(callback: (event: CompositionChangeEnvelope) => void) { + if (this.terminalError) throw this.terminalError; + if (typeof callback !== "function") throw new TypeError("Composition callback must be a function"); + this.compositionChanges.add(callback); + return () => this.compositionChanges.delete(callback); + } + async attachView(options: FxNodeViewOptions): Promise { + if (this.terminalError) throw this.terminalError; + const canvas = options.canvas, + viewport = decodeFxNodeViewport(options.viewport), + camera = decodeFxNodeCamera(options.initialCamera ?? { center: { x: 0, y: 0 }, zoom: 1 }), + area = fxNodeDevicePixels(viewport.width, viewport.height, viewport.dpr); + if (this.views.size + this.attachingViewCount >= FXNODE_VIEW_LIMITS.maxViews) + throw new RangeError("FxNode view count limit exceeded"); + const pixels = area ? area.width * area.height : 0; + if (!area || this.aggregateDevicePixels + pixels > FXNODE_VIEW_LIMITS.maxActiveDevicePixels) + throw new RangeError("FxNode view exceeds the aggregate pixel budget"); + if (reservedCanvases.has(canvas)) throw new FxNodeCapabilityError("Canvas is already attached", "canvas.in-use"); + + reservedCanvases.add(canvas); + this.attachingViewCount++; + this.aggregateDevicePixels += pixels; + let view: FxNodeViewClient | undefined; + try { + const context = canvas.getContext("2d"); + if (!context) throw new FxNodeCapabilityError("Canvas 2D context is unavailable", "canvas.2d"); + if (this.terminalError) throw this.terminalError; + + let id: string; + do id = crypto.randomUUID(); + while (this.usedViewIds.has(id)); + this.usedViewIds.add(id); + view = new FxNodeViewClient(this, id, canvas, context, viewport, pixels); + this.views.set(view.id, view); + this.attachingViewCount--; + await view.attach(camera); + return view; + } catch (error) { + if (view) view.close(error instanceof Error ? error : new FxNodeProtocolError("Unable to attach view")); + else { + if (!this.terminalError) { + this.attachingViewCount--; + this.aggregateDevicePixels -= pixels; + } + reservedCanvases.delete(canvas); + } + throw error; + } + } + releaseView(view: FxNodeViewClient): void { + if (this.views.get(view.id) === view) { + this.views.delete(view.id); + this.aggregateDevicePixels -= view.devicePixels; + } + } + preflightResizeView(view: FxNodeViewClient, pixels: number): void { + const next = this.aggregateDevicePixels - view.devicePixels + pixels; + if (next > FXNODE_VIEW_LIMITS.maxActiveDevicePixels) + throw new RangeError("FxNode view exceeds the aggregate pixel budget"); + } + commitResizeView(view: FxNodeViewClient, pixels: number): void { + const next = this.aggregateDevicePixels - view.devicePixels + pixels; + if (next > FXNODE_VIEW_LIMITS.maxActiveDevicePixels) { + const error = new FxNodeProtocolError("FxNode worker acknowledged a resize beyond the aggregate pixel budget"); + this.fail(error); + throw error; + } + this.aggregateDevicePixels = next; + view.devicePixels = pixels; + } + rejectViewPending(view: FxNodeViewClient, error: Error): void { + for (const [id, item] of this.pending) + if (item.owner === view && item.requestType !== "view.detach") { + this.pending.delete(id); + item.reject(error); + } + } + rootError(): Error | undefined { + return this.terminalError; + } + fail(error: Error): void { + this.shutdown(error); + } + get revision(): number { + return this.compositionRevision ?? 0; + } + receipt(value: unknown, expected: VersionExpectation): CommandReceipt { + return this.commandReceipt(value, expected); + } + destroy() { + this.shutdown(new FxNodeDestroyedError()); + } + private shutdown(error: Error): void { + if (this.terminalError) return; + this.terminalError = error; + for (const view of [...this.views.values()]) view.close(error); + this.views.clear(); + this.aggregateDevicePixels = 0; + this.attachingViewCount = 0; + for (const item of this.pending.values()) item.reject(error); + this.pending.clear(); + this.safePost({ protocol: PROTOCOL_VERSION, type: "dispose" }); + this.worker.terminate(); + this.worker.onmessage = null; + this.worker.onerror = null; + this.worker.onmessageerror = null; + this.mutations.clear(); + this.snapshots.clear(); + this.compositionChanges.clear(); + this.unclaimedCompositionEvents.clear(); + } +} + +type ViewMessage = Extract; +interface ViewControlBatch { + kind: "viewport" | "render"; + viewport: FxNodeViewport | undefined; + resolves: Array<() => void>; + rejects: Array<(error: Error) => void>; +} + +class FxNodeViewClient implements FxNodeView { + private state: "attaching" | "attached" | "detaching" | "detached" | "root-closed" = "attaching"; + devicePixels: number; + private renderId = 1; + private surfaceGeneration = 0; + private deviceWidth: number; + private deviceHeight: number; + private hostGeneration = 0; + private pointerLane = supportsPointerLane() ? createPointerLane() : undefined; + private latestPointerMove: PointerLaneSnapshot | undefined; + private lanePointerId: number | undefined; + private knifePointerId: number | undefined; + private pendingNodeMenuRequestId: string | undefined; + private pendingResourceOpenRequestId: string | undefined; + private selection = emptySelection(); + private hostSnapshot: FxNodeHostSnapshot; + private readonly hostSubscribers = new Set<() => void>(); + private readonly hostRequests = new Set<(request: FxNodeHostRequest) => void>(); + private readonly barriers = new Map(); + private detachedError: Error | undefined; + private detachPromise: Promise | undefined; + private selectionReady!: () => void; + private readonly firstSelection = new Promise((resolve) => (this.selectionReady = resolve)); + private activeControl: ViewControlBatch | undefined; + private readonly queuedControls: ViewControlBatch[] = []; + + constructor( + private readonly owner: FxNodeClient, + readonly id: string, + private readonly canvas: HTMLCanvasElement, + private readonly context: CanvasRenderingContext2D, + private viewport: FxNodeViewport, + devicePixels: number, + ) { + this.devicePixels = devicePixels; + const device = fxNodeDevicePixels(viewport.width, viewport.height, viewport.dpr)!; + this.deviceWidth = device.width; + this.deviceHeight = device.height; + this.hostSnapshot = Object.freeze({ + compositionRevision: owner.revision, + colorPickerOpen: false, + selection: this.selection, + }); + } + async attach(camera: FxNodeCamera): Promise { + await this.owner.post( + { + type: "view.attach", + viewId: this.id, + viewport: this.viewport, + camera, + ...(this.pointerLane ? { pointerLane: this.pointerLane } : {}), + }, + [], + undefined, + this, + ); + await this.firstSelection; + this.check(); + this.state = "attached"; + } + consumeMessage(data: ViewMessage): void { + if (this.detachedError) return; + if (data.type === "view.frame") this.consumeFrame(data); + else if (data.type === "view.selection.host") { + this.selection = Object.freeze({ ...data.projection, mute: Object.freeze({ ...data.projection.mute }) }); + this.selectionReady(); + this.publishHost(this.hostSnapshot.colorPickerOpen); + } else if (data.type === "view.node-menu.result") { + if (data.requestId !== this.pendingNodeMenuRequestId) return; + this.pendingNodeMenuRequestId = undefined; + if (data.open && data.compositionRevision === this.owner.revision) + this.notify( + this.hostRequests, + Object.freeze({ + kind: "add-node-menu", + viewPosition: Object.freeze({ ...data.viewPosition }), + compositionRevision: data.compositionRevision, + }), + ); + } else if (data.type === "view.resource.open") { + if (data.authorization.viewId !== data.viewId) { + this.owner.fail(new FxNodeProtocolError("FxNode worker returned cross-view resource authorization")); + return; + } + if (data.requestId !== this.pendingResourceOpenRequestId) return; + this.pendingResourceOpenRequestId = undefined; + if (data.authorization.compositionRevision === this.owner.revision) + this.notify( + this.hostRequests, + Object.freeze({ + kind: "resource-open", + authorization: Object.freeze({ ...data.authorization }), + resource: Object.freeze({ ...data.resource, accept: Object.freeze([...data.resource.accept]) }), + }), + ); + } + } + private consumeFrame(data: Extract): void { + if (data.hostGeneration > this.hostGeneration) { + this.owner.fail(new FxNodeProtocolError("FxNode worker used a future host generation")); + return; + } + if (data.surfaceGeneration < this.surfaceGeneration) { + this.owner.requiredPost({ + protocol: PROTOCOL_VERSION, + type: "view.frame.consumed", + viewId: this.id, + frameId: data.frameId, + }); + return; + } + if ( + data.surfaceGeneration > this.surfaceGeneration || + data.deviceWidth !== this.deviceWidth || + data.deviceHeight !== this.deviceHeight + ) { + this.owner.fail(new FxNodeProtocolError("FxNode worker returned an incoherent view surface")); + return; + } + let error: unknown; + try { + this.context.drawImage(data.bitmap, 0, 0, this.canvas.width, this.canvas.height); + } catch (cause) { + error = cause; + } + if (data.hostGeneration === this.hostGeneration) this.publishHost(data.host.colorPickerOpen); + this.owner.requiredPost({ + protocol: PROTOCOL_VERSION, + type: "view.frame.consumed", + viewId: this.id, + frameId: data.frameId, + }); + for (const [id, list] of [...this.barriers]) + if (id <= data.renderId) { + for (const barrier of list) error ? barrier.reject(error) : barrier.resolve(); + this.barriers.delete(id); + } + } + private notify(callbacks: ReadonlySet<(event: T) => void>, event: T): void { + for (const callback of callbacks) + try { + callback(event); + } catch (error) { + console.error("FxNode subscriber failed", error); + } + } + private publishHost(colorPickerOpen: boolean): void { + this.hostSnapshot = Object.freeze({ + compositionRevision: this.owner.revision, + colorPickerOpen, + selection: this.selection, + }); + this.notify(this.hostSubscribers, undefined); + } + clearDocumentDependentHostState(revision = this.owner.revision): void { + if (this.state !== "attaching" && this.state !== "attached") return; + this.invalidateHostInteractions(); + this.pendingNodeMenuRequestId = this.pendingResourceOpenRequestId = undefined; + this.hostSnapshot = Object.freeze({ + compositionRevision: revision, + colorPickerOpen: false, + selection: this.selection, + }); + this.notify(this.hostSubscribers, undefined); + } + private invalidateHostInteractions(): number { + if (this.hostGeneration >= Number.MAX_SAFE_INTEGER) { + const error = new FxNodeProtocolError("FxNode host generation counter exhausted"); + this.owner.fail(error); + throw error; + } + return ++this.hostGeneration; + } + private nextRenderId(): number { + if (this.renderId >= Number.MAX_SAFE_INTEGER) { + const error = new FxNodeProtocolError("FxNode render counter exhausted"); + this.owner.fail(error); + throw error; + } + return ++this.renderId; + } + private followingRenderId(): number { + if (this.renderId >= Number.MAX_SAFE_INTEGER) { + const error = new FxNodeProtocolError("FxNode render counter exhausted"); + this.owner.fail(error); + throw error; + } + return this.renderId + 1; + } + private followingHostGeneration(): number { + if (this.hostGeneration >= Number.MAX_SAFE_INTEGER) { + const error = new FxNodeProtocolError("FxNode host generation counter exhausted"); + this.owner.fail(error); + throw error; + } + return this.hostGeneration + 1; + } + private check(): void { + const rootError = this.owner.rootError(); + if (rootError) throw rootError; + if (this.detachedError) throw this.detachedError; + } + isAttached(): boolean { + return this.state === "attached"; + } + private nextPointerFence(): PointerFence | undefined { + if (!this.pointerLane) return; + const generation = (pointerLaneFence(this.pointerLane) + 1) | 0; + return this.latestPointerMove ? { generation, before: this.latestPointerMove } : { generation }; + } + flushPointerLane(): boolean { + if (!this.isAttached()) return true; + const pointerFence = this.nextPointerFence(); + if (!pointerFence) return true; + const sent = this.owner.requiredPost({ + protocol: PROTOCOL_VERSION, + type: "view.pointer.flush", + viewId: this.id, + pointerFence, + }); + if (sent) advancePointerLaneFence(this.pointerLane!); + return sent; + } + private sendInput(event: InputEventWire, nodeMenuRequestId?: string, resourceOpenRequestId?: string): void { + const pointerFence = this.nextPointerFence(); + if ( + !this.owner.requiredPost({ + protocol: PROTOCOL_VERSION, + type: "view.input", + viewId: this.id, + event, + hostGeneration: this.hostGeneration, + ...(pointerFence ? { pointerFence } : {}), + ...(nodeMenuRequestId ? { nodeMenuRequestId } : {}), + ...(resourceOpenRequestId ? { resourceOpenRequestId } : {}), + }) + ) + throw this.owner.rootError()!; + if (pointerFence) advancePointerLaneFence(this.pointerLane!); + } + readonly feedInput = (input: FxNodeInput): void => { + this.check(); + const wire = decodeFxNodeInput(input); + if (wire.kind !== "pointer" || wire.phase !== "move" || wire.buttons !== 0) this.invalidateHostInteractions(); + if ( + wire.kind === "pointer" && + wire.phase === "move" && + this.pointerLane && + this.knifePointerId !== wire.pointerId && + (this.lanePointerId === undefined || this.lanePointerId === wire.pointerId) + ) { + const sequence = publishPointerMove(this.pointerLane, wire as PointerMoveWire, this.hostGeneration); + if (sequence !== undefined) { + this.latestPointerMove = { sequence, hostGeneration: this.hostGeneration, event: wire as PointerMoveWire }; + return; + } + } + const menu = + wire.kind === "pointer" && + wire.phase === "down" && + wire.button === 2 && + (wire.modifiers & 2) === 0 && + (wire.buttons & 1) === 0 + ? crypto.randomUUID() + : undefined; + const resource = + wire.kind === "pointer" && wire.phase === "down" && wire.button === 0 ? crypto.randomUUID() : undefined; + this.sendInput(wire, menu, resource); + if ((wire.kind === "pointer" && wire.phase === "down") || wire.kind === "wheel" || wire.kind === "key") { + this.pendingNodeMenuRequestId = undefined; + this.pendingResourceOpenRequestId = undefined; + } + if (menu) this.pendingNodeMenuRequestId = menu; + if (resource) this.pendingResourceOpenRequestId = resource; + if (wire.kind === "pointer" && wire.phase === "down") { + this.lanePointerId ??= wire.pointerId; + if (wire.button === 2 && (wire.modifiers & 2) !== 0) this.knifePointerId = wire.pointerId; + } + if (wire.kind === "pointer" && (wire.phase === "up" || wire.phase === "cancel")) { + if (this.lanePointerId === wire.pointerId) this.lanePointerId = undefined; + if (this.knifePointerId === wire.pointerId) this.knifePointerId = undefined; + } + }; + private enqueueControl(kind: ViewControlBatch["kind"], viewport?: FxNodeViewport): Promise { + return new Promise((resolve, reject) => { + const last = this.queuedControls.at(-1); + if (kind === "viewport" && last?.kind === "viewport") { + last.viewport = viewport; + last.resolves.push(resolve); + last.rejects.push(reject); + } else this.queuedControls.push({ kind, viewport, resolves: [resolve], rejects: [reject] }); + this.pumpControls(); + }); + } + private pumpControls(): void { + if (this.activeControl || this.detachedError) return; + const batch = this.queuedControls.shift(); + if (!batch) return; + this.activeControl = batch; + void this.executeControl(batch) + .then(() => batch.resolves.forEach((resolve) => resolve())) + .catch((cause) => { + const error = cause instanceof Error ? cause : new FxNodeProtocolError("FxNode view control failed"); + batch.rejects.forEach((reject) => reject(error)); + }) + .finally(() => { + this.activeControl = undefined; + this.pumpControls(); + }); + } + private async executeControl(batch: ViewControlBatch): Promise { + this.check(); + if (batch.kind === "render") return this.postRenderBarrier(); + const viewport = batch.viewport!, + area = fxNodeDevicePixels(viewport.width, viewport.height, viewport.dpr)!; + if ( + viewport.width === this.viewport.width && + viewport.height === this.viewport.height && + viewport.dpr === this.viewport.dpr + ) + return; + const pixels = area.width * area.height; + this.owner.preflightResizeView(this, pixels); + const expectedSurfaceGeneration = this.surfaceGeneration + 1; + if (!Number.isSafeInteger(expectedSurfaceGeneration)) { + const error = new FxNodeProtocolError("FxNode surface generation counter exhausted"); + this.owner.fail(error); + throw error; + } + const pointerFence = this.nextPointerFence(), + oldNodeMenuRequestId = this.pendingNodeMenuRequestId, + oldResourceOpenRequestId = this.pendingResourceOpenRequestId, + hostGeneration = this.followingHostGeneration(); + await this.owner.post( + { + type: "view.viewport", + viewId: this.id, + viewport, + expectedSurfaceGeneration, + hostGeneration, + ...(pointerFence ? { pointerFence } : {}), + }, + [], + () => { + this.hostGeneration = hostGeneration; + if (pointerFence) advancePointerLaneFence(this.pointerLane!); + if (this.pendingNodeMenuRequestId === oldNodeMenuRequestId) this.pendingNodeMenuRequestId = undefined; + if (this.pendingResourceOpenRequestId === oldResourceOpenRequestId) + this.pendingResourceOpenRequestId = undefined; + }, + this, + ); + this.check(); + this.viewport = viewport; + this.deviceWidth = area.width; + this.deviceHeight = area.height; + this.surfaceGeneration = expectedSurfaceGeneration; + this.owner.commitResizeView(this, pixels); + } + readonly setViewport = async (value: FxNodeViewport): Promise => { + this.check(); + const viewport = decodeFxNodeViewport(value), + area = fxNodeDevicePixels(viewport.width, viewport.height, viewport.dpr); + if (!area) throw new RangeError("FxNode view exceeds the aggregate pixel budget"); + await this.enqueueControl("viewport", viewport); + }; + readonly getHostSnapshot = (): FxNodeHostSnapshot => { + this.check(); + return this.hostSnapshot; + }; + readonly subscribeHost = (callback: () => void): (() => void) => { + this.check(); + if (typeof callback !== "function") throw new TypeError("Host subscriber must be a function"); + this.hostSubscribers.add(callback); + return () => this.hostSubscribers.delete(callback); + }; + readonly onHostRequests = (callback: (request: FxNodeHostRequest) => void): (() => void) => { + this.check(); + if (typeof callback !== "function") throw new TypeError("Host request callback must be a function"); + this.hostRequests.add(callback); + return () => this.hostRequests.delete(callback); + }; + private action( + request: RpcRequest, + expected: VersionExpectation, + transfer: Transferable[] = [], + ): Promise { + const fenced = this.nextPointerFence(); + return this.owner + .post( + { ...request, ...(fenced ? { pointerFence: fenced } : {}) } as RpcRequest, + transfer, + fenced ? () => advancePointerLaneFence(this.pointerLane!) : undefined, + this, + ) + .then((value) => this.owner.receipt(value, expected)); + } + async addNode(params: AddNodeParams, options?: FxNodeActionOptions): Promise { + this.check(); + const p = decodeFxNodeAddNodeParams(params), + expected = decodeFxNodeActionOptions(options); + return this.action( + { + type: "view.node.add", + viewId: this.id, + nodeId: p.nodeId ?? crypto.randomUUID(), + typeId: p.typeId, + viewPosition: p.viewPosition, + expected, + }, + expected, + ); + } + async removeSelected(options?: FxNodeActionOptions): Promise { + this.check(); + const expected = decodeFxNodeActionOptions(options); + return this.action({ type: "view.selection.remove", viewId: this.id, expected }, expected); + } + async setSelectedMuted(value: boolean, options?: FxNodeActionOptions): Promise { + this.check(); + if (typeof value !== "boolean") throw new TypeError("Muted state must be boolean"); + const expected = decodeFxNodeActionOptions(options); + return this.action({ type: "view.selection.mute", viewId: this.id, value, expected }, expected); + } + async provideResource( + authorization: FxNodeResourceAuthorization, + data: FxNodeResourceData, + options?: FxNodeActionOptions, + ): Promise { + this.check(); + const auth = decodeFxNodeResourceAuthorization(authorization); + if (auth.viewId !== this.id) + throw new FxNodeCapabilityError("Resource authorization belongs to another view", "resource.view-mismatch"); + const resource = decodeFxNodeResourceData(data), + expected = decodeFxNodeActionOptions(options); + return this.action( + { type: "view.resource.set", viewId: this.id, authorization: auth, resource, expected }, + expected, + [resource.bytes], + ); + } + async whenRendered(): Promise { + this.check(); + return this.enqueueControl("render"); + } + private async postRenderBarrier(): Promise { + const id = this.nextRenderId(), + pointerFence = this.nextPointerFence(), + barrier = new Promise((resolve, reject) => + this.barriers.set(id, [...(this.barriers.get(id) ?? []), { resolve, reject }]), + ); + if ( + !this.owner.requiredPost({ + protocol: PROTOCOL_VERSION, + type: "view.render", + viewId: this.id, + renderId: id, + hostGeneration: this.hostGeneration, + ...(pointerFence ? { pointerFence } : {}), + }) + ) + throw this.owner.rootError()!; + if (pointerFence) advancePointerLaneFence(this.pointerLane!); + await barrier; + } + detach(): Promise { + if (!this.detachPromise) { + const rootError = this.owner.rootError(); + if (rootError) return (this.detachPromise = Promise.reject(rootError)); + this.state = "detaching"; + const detached = new FxNodeViewDetachedError(); + this.detachedError = detached; + this.hostSubscribers.clear(); + this.hostRequests.clear(); + this.pendingNodeMenuRequestId = this.pendingResourceOpenRequestId = undefined; + this.owner.rejectViewPending(this, detached); + const controls = [...(this.activeControl ? [this.activeControl] : []), ...this.queuedControls]; + this.queuedControls.length = 0; + for (const batch of controls) batch.rejects.forEach((reject) => reject(detached)); + for (const list of this.barriers.values()) for (const barrier of list) barrier.reject(detached); + this.barriers.clear(); + this.detachPromise = this.owner + .post({ type: "view.detach", viewId: this.id }, [], undefined, this) + .then(() => { + this.state = "detached"; + this.release(); + }) + .catch((cause) => { + const rootError = this.owner.rootError(); + if (rootError) throw rootError; + const error = new FxNodeProtocolError("FxNode worker failed to detach a view", { cause }); + this.owner.fail(error); + throw error; + }); + } + return this.detachPromise; + } + private release(): void { + reservedCanvases.delete(this.canvas); + this.owner.releaseView(this); + this.hostSubscribers.clear(); + this.hostRequests.clear(); + this.selectionReady(); + } + close(error: Error): void { + if (this.state === "root-closed" || this.state === "detached") return; + this.state = "root-closed"; + this.detachedError = error; + this.release(); + const controls = [...(this.activeControl ? [this.activeControl] : []), ...this.queuedControls]; + this.queuedControls.length = 0; + for (const batch of controls) batch.rejects.forEach((reject) => reject(error)); + for (const list of this.barriers.values()) for (const barrier of list) barrier.reject(error); + this.barriers.clear(); + } +} + +export async function createFxNode(options: CreateFxNodeOptions): Promise { + const { applicationId, applicationVersion, resources, historyLimit = 100, workerUrl } = options; + if (!Number.isSafeInteger(historyLimit) || historyLimit < 0 || historyLimit > 1000) + throw new RangeError("historyLimit must be an integer from 0 to 1000"); + if (typeof Worker === "undefined") + throw new FxNodeCapabilityError("FxNode requires module Worker support", "worker.missing"); + if (typeof crypto?.randomUUID !== "function") + throw new FxNodeCapabilityError("FxNode requires crypto.randomUUID", "crypto.random-uuid.missing"); + const bootstrap = createInitialFxNodeComposition(applicationId, applicationVersion, resources).source; + const url = + workerUrl === undefined + ? new URL(defaultWorkerUrl, import.meta.url) + : workerUrl instanceof URL + ? workerUrl + : new URL(workerUrl, import.meta.url); + let worker: Worker; + try { + worker = new Worker(url, { type: "module" }); + } catch { + throw new FxNodeCapabilityError( + "Unable to construct the FxNode module worker. Check workerUrl and CSP worker-src.", + "worker.construct", + ); + } + const client = new FxNodeClient(worker); + try { + await client.initialize(bootstrap.id, bootstrap.version, bootstrap.resources, historyLimit); + return client; + } catch (error) { + client.destroy(); + throw error; + } +} diff --git a/vendor/fxnode/src/browser/host-decode.ts b/vendor/fxnode/src/browser/host-decode.ts new file mode 100644 index 0000000..bd099db --- /dev/null +++ b/vendor/fxnode/src/browser/host-decode.ts @@ -0,0 +1,229 @@ +import type { + AddNodeParams, + FxNodeInput, + FxNodeResourceAuthorization, + FxNodeResourceData, + FxNodeCamera, + FxNodeViewport, +} from "./host-types.js"; +import { fxNodeDevicePixels, FXNODE_VIEW_LIMITS } from "./view-limits.js"; +import type { InputEventWire, VersionExpectation } from "./protocol.js"; + +type Values = Record; +function inspect(value: unknown): { values: Values; keys: readonly string[] } | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return; + const own = Reflect.ownKeys(value); + if (own.some((key) => typeof key !== "string")) return; + const result: Values = {}; + for (const key of own as string[]) { + const descriptor = Reflect.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return; + result[key] = descriptor.value; + } + return { values: result, keys: own as string[] }; +} +const exact = ( + item: { values: Values; keys: readonly string[] } | undefined, + keys: readonly string[], +): Values | undefined => + item && item.keys.length === keys.length && item.keys.every((key) => keys.includes(key)) ? item.values : undefined; +const data = (value: unknown, keys: readonly string[]) => exact(inspect(value), keys); +const point = (value: unknown): { x: number; y: number } | undefined => { + const v = data(value, ["x", "y"]); + return v && typeof v.x === "number" && Number.isFinite(v.x) && typeof v.y === "number" && Number.isFinite(v.y) + ? { x: v.x, y: v.y } + : undefined; +}; +const modifiers = (value: unknown): number | undefined => { + const v = data(value, ["alt", "control", "meta", "shift"]); + if ( + !v || + typeof v.alt !== "boolean" || + typeof v.control !== "boolean" || + typeof v.meta !== "boolean" || + typeof v.shift !== "boolean" + ) + return; + return (v.alt ? 1 : 0) | (v.control ? 2 : 0) | (v.meta ? 4 : 0) | (v.shift ? 8 : 0); +}; +export function decodeFxNodeInput(value: FxNodeInput): InputEventWire { + try { + const item = inspect(value as unknown), + kind = item?.values.kind; + const base = kind === "focus" ? exact(item, ["kind", "phase"]) : undefined; + if (base && (base.phase === "focus" || base.phase === "blur")) return { kind: "focus", phase: base.phase }; + const outside = kind === "outside-pointer" ? exact(item, ["kind", "button"]) : undefined; + if (outside && Number.isInteger(outside.button)) + return { kind: "outside-pointer", button: outside.button as number }; + const pointer = + kind === "pointer" + ? exact(item, ["kind", "phase", "pointerId", "pointerType", "position", "button", "buttons", "modifiers"]) + : undefined, + p = pointer && point(pointer.position), + m = pointer && modifiers(pointer.modifiers); + if ( + pointer && + ["down", "move", "up", "cancel"].includes(String(pointer.phase)) && + Number.isSafeInteger(pointer.pointerId) && + typeof pointer.pointerType === "string" && + pointer.pointerType.length <= 64 && + p && + Number.isInteger(pointer.button) && + Number.isInteger(pointer.buttons) && + m !== undefined + ) + return { + kind: "pointer", + phase: pointer.phase as "down" | "move" | "up" | "cancel", + pointerId: pointer.pointerId as number, + pointerType: pointer.pointerType, + position: p, + button: pointer.button as number, + buttons: pointer.buttons as number, + modifiers: m, + }; + const wheel = kind === "wheel" ? exact(item, ["kind", "position", "delta", "modifiers"]) : undefined, + wp = wheel && point(wheel.position), + delta = wheel && point(wheel.delta), + wm = wheel && modifiers(wheel.modifiers); + if (wheel && wp && delta && wm !== undefined) return { kind: "wheel", position: wp, delta, modifiers: wm }; + const key = kind === "key" ? exact(item, ["kind", "phase", "key", "code", "repeat", "modifiers"]) : undefined, + km = key && modifiers(key.modifiers); + if ( + key && + (key.phase === "down" || key.phase === "up") && + typeof key.key === "string" && + key.key.length <= 256 && + typeof key.code === "string" && + key.code.length <= 256 && + typeof key.repeat === "boolean" && + km !== undefined + ) + return { kind: "key", phase: key.phase, key: key.key, code: key.code, repeat: key.repeat, modifiers: km }; + } catch {} + throw new TypeError("Invalid FxNode input"); +} +export function decodeFxNodeViewport(value: FxNodeViewport): FxNodeViewport { + let v: Values | undefined; + try { + v = data(value as unknown, ["width", "height", "dpr"]); + } catch {} + if ( + !v || + typeof v.width !== "number" || + !Number.isFinite(v.width) || + typeof v.height !== "number" || + !Number.isFinite(v.height) || + typeof v.dpr !== "number" || + !Number.isFinite(v.dpr) + ) + throw new TypeError("Invalid FxNode viewport"); + if ( + v.width < 0 || + v.width > FXNODE_VIEW_LIMITS.maxLogicalDimension || + v.height < 0 || + v.height > FXNODE_VIEW_LIMITS.maxLogicalDimension || + v.dpr <= 0 || + v.dpr > FXNODE_VIEW_LIMITS.maxDpr || + !fxNodeDevicePixels(v.width, v.height, v.dpr) + ) + throw new RangeError("FxNode viewport is outside supported bounds"); + return Object.freeze({ width: v.width, height: v.height, dpr: v.dpr }); +} + +/** Decodes and detaches a camera from an exact, data-only hostile object. */ +export function decodeFxNodeCamera(value: FxNodeCamera): FxNodeCamera { + let v: Values | undefined, center: { x: number; y: number } | undefined; + try { + v = data(value as unknown, ["center", "zoom"]); + center = v && point(v.center); + } catch {} + if (!v || !center || typeof v.zoom !== "number" || !Number.isFinite(v.zoom)) + throw new TypeError("Invalid FxNode camera"); + if (v.zoom < FXNODE_VIEW_LIMITS.minZoom || v.zoom > FXNODE_VIEW_LIMITS.maxZoom) + throw new RangeError("FxNode camera is outside supported bounds"); + return Object.freeze({ center: Object.freeze(center), zoom: v.zoom }); +} + +export function decodeFxNodeActionOptions(value: unknown): VersionExpectation { + if (value === undefined) return { kind: "current" }; + let item: ReturnType | undefined; + try { + item = inspect(value); + } catch {} + if (item?.keys.length === 0) return { kind: "current" }; + const v = exact(item, ["expectedVersion"]); + if (!v || !Number.isSafeInteger(v.expectedVersion) || (v.expectedVersion as number) < 0) + throw new TypeError("Invalid action options"); + return { kind: "exact", version: v.expectedVersion as number }; +} + +export function decodeFxNodeAddNodeParams(value: unknown): AddNodeParams { + try { + const item = inspect(value); + if ( + item && + item.keys.every((key) => ["typeId", "viewPosition", "nodeId"].includes(key)) && + ["typeId", "viewPosition"].every((key) => item.keys.includes(key)) + ) { + const v = item.values, + p = point(v.viewPosition); + if ( + typeof v.typeId === "string" && + v.typeId && + v.typeId.length <= 128 && + p && + (!item.keys.includes("nodeId") || + v.nodeId === undefined || + (typeof v.nodeId === "string" && !!v.nodeId && v.nodeId.length <= 512)) + ) + return { typeId: v.typeId, viewPosition: p, ...(v.nodeId === undefined ? {} : { nodeId: v.nodeId as string }) }; + } + } catch {} + throw new TypeError("Invalid add-node parameters"); +} +export function decodeFxNodeResourceAuthorization(value: unknown): FxNodeResourceAuthorization { + let v: Values | undefined; + try { + v = data(value, ["viewId", "token", "graphVersion", "compositionRevision"]); + } catch {} + if ( + !v || + typeof v.viewId !== "string" || + !v.viewId || + v.viewId.length > 512 || + typeof v.token !== "string" || + !v.token || + v.token.length > 512 || + !Number.isSafeInteger(v.graphVersion) || + (v.graphVersion as number) < 0 || + !Number.isSafeInteger(v.compositionRevision) || + (v.compositionRevision as number) < 0 + ) + throw new TypeError("Invalid resource authorization"); + return Object.freeze({ + viewId: v.viewId, + token: v.token, + graphVersion: v.graphVersion as number, + compositionRevision: v.compositionRevision as number, + }); +} +export function decodeFxNodeResourceData(value: unknown): FxNodeResourceData { + let v: Values | undefined, length: number | undefined; + try { + v = data(value, ["name", "mime", "bytes"]); + if ( + v && + typeof v.name === "string" && + v.name && + v.name.length <= 255 && + !/[\u0000-\u001f\u007f]/.test(v.name) && + typeof v.mime === "string" && + v.mime.length <= 128 + ) + length = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, "byteLength")!.get!.call(v.bytes) as number; + } catch {} + if (!v || length === undefined) throw new TypeError("Invalid resource data"); + if (length === 0 || length > 32 * 1024 * 1024) throw new RangeError("Resource data is outside supported bounds"); + return Object.freeze({ name: v.name as string, mime: v.mime as string, bytes: v.bytes as ArrayBuffer }); +} diff --git a/vendor/fxnode/src/browser/host-types.ts b/vendor/fxnode/src/browser/host-types.ts new file mode 100644 index 0000000..37ba89a --- /dev/null +++ b/vendor/fxnode/src/browser/host-types.ts @@ -0,0 +1,100 @@ +/** DOM-independent keyboard modifiers. */ +export interface FxNodeModifiers { + readonly alt: boolean; + readonly control: boolean; + readonly meta: boolean; + readonly shift: boolean; +} + +export type FxNodeInput = + | { + readonly kind: "pointer"; + readonly phase: "down" | "move" | "up" | "cancel"; + readonly pointerId: number; + readonly pointerType: string; + readonly position: { readonly x: number; readonly y: number }; + readonly button: number; + readonly buttons: number; + readonly modifiers: FxNodeModifiers; + } + | { + readonly kind: "wheel"; + readonly position: { readonly x: number; readonly y: number }; + readonly delta: { readonly x: number; readonly y: number }; + readonly modifiers: FxNodeModifiers; + } + | { + readonly kind: "key"; + readonly phase: "down" | "up"; + readonly key: string; + readonly code: string; + readonly repeat: boolean; + readonly modifiers: FxNodeModifiers; + } + | { readonly kind: "focus"; readonly phase: "focus" | "blur" } + | { readonly kind: "outside-pointer"; readonly button: number }; + +export interface FxNodeViewport { + readonly width: number; + readonly height: number; + readonly dpr: number; +} +/** DOM-independent camera state in graph coordinates. */ +export interface FxNodeCamera { + readonly center: { readonly x: number; readonly y: number }; + readonly zoom: number; +} +export interface AddNodeParams { + readonly typeId: string; + readonly viewPosition: { readonly x: number; readonly y: number }; + readonly nodeId?: string; +} +export interface FxNodeActionOptions { + readonly expectedVersion?: number; +} +export interface FxNodeSelectionSnapshot { + readonly nodeCount: number; + readonly linkCount: number; + readonly canRemove: boolean; + readonly mute: + | { readonly enabled: false } + | { readonly enabled: true; readonly state: "all-muted" | "all-unmuted" | "mixed" }; +} +export interface FxNodeAddNodeMenuRequest { + readonly kind: "add-node-menu"; + readonly viewPosition: { readonly x: number; readonly y: number }; + readonly compositionRevision: number; +} +export interface FxNodeResourceAuthorization { + readonly viewId: string; + readonly token: string; + readonly graphVersion: number; + readonly compositionRevision: number; +} +export interface FxNodeImageResourceDescriptor { + readonly id: string; + readonly kind: "image"; + readonly title: string; + readonly openTitle: string; + readonly accept: readonly string[]; + readonly maxBytes: number; + readonly maxWidth: number; + readonly maxHeight: number; + readonly maxPixels: number; +} +export interface FxNodeResourceOpenRequest { + readonly kind: "resource-open"; + readonly authorization: FxNodeResourceAuthorization; + readonly resource: FxNodeImageResourceDescriptor; +} +export interface FxNodeResourceData { + readonly name: string; + readonly mime: string; + readonly bytes: ArrayBuffer; +} +export type FxNodeHostRequest = FxNodeAddNodeMenuRequest | FxNodeResourceOpenRequest; +export interface FxNodeHostSnapshot { + readonly compositionRevision: number; + readonly colorPickerOpen: boolean; + readonly selection: FxNodeSelectionSnapshot; +} diff --git a/vendor/fxnode/src/browser/pointer-lane.ts b/vendor/fxnode/src/browser/pointer-lane.ts new file mode 100644 index 0000000..cf3287e --- /dev/null +++ b/vendor/fxnode/src/browser/pointer-lane.ts @@ -0,0 +1,114 @@ +import type { InputEventWire } from "./protocol.js"; + +export type PointerMoveWire = Extract & { phase: "move" }; +export interface PointerLaneSnapshot { + readonly sequence: number; + readonly hostGeneration: number; + readonly event: PointerMoveWire; +} + +const WORDS = 16; +const STAMP = 0; +const FENCE = 1; +const SEQUENCE = 2; +const X_LOW = 3; +const X_HIGH = 4; +const Y_LOW = 5; +const Y_HIGH = 6; +const POINTER_ID = 7; +const POINTER_TYPE = 8; +const BUTTON = 9; +const BUTTONS = 10; +const MODIFIERS = 11; +const HOST_GENERATION_LOW = 12; +const HOST_GENERATION_HIGH = 13; +const UINT32 = 0x1_0000_0000; +const scratch = new DataView(new ArrayBuffer(8)); +const views = new WeakMap(); +const wordsFor = (buffer: SharedArrayBuffer): Int32Array => { + const existing = views.get(buffer); + if (existing) return existing; + const words = new Int32Array(buffer); + views.set(buffer, words); + return words; +}; + +const pointerTypes = ["", "mouse", "pen", "touch"] as const; +const pointerTypeCode = (value: string): number => pointerTypes.indexOf(value as (typeof pointerTypes)[number]); +const writeFloat = (words: Int32Array, low: number, high: number, value: number): void => { + scratch.setFloat64(0, value, true); + Atomics.store(words, low, scratch.getInt32(0, true)); + Atomics.store(words, high, scratch.getInt32(4, true)); +}; +const readFloat = (words: Int32Array, low: number, high: number): number => { + scratch.setInt32(0, Atomics.load(words, low), true); + scratch.setInt32(4, Atomics.load(words, high), true); + return scratch.getFloat64(0, true); +}; + +export function supportsPointerLane(): boolean { + return ( + globalThis.crossOriginIsolated === true && typeof SharedArrayBuffer === "function" && typeof Atomics === "object" + ); +} + +export function createPointerLane(): SharedArrayBuffer { + return new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * WORDS); +} +export function pointerLaneFence(buffer: SharedArrayBuffer): number { + return Atomics.load(wordsFor(buffer), FENCE); +} +export function advancePointerLaneFence(buffer: SharedArrayBuffer): number { + return (Atomics.add(wordsFor(buffer), FENCE, 1) + 1) | 0; +} + +export function publishPointerMove( + buffer: SharedArrayBuffer, + event: PointerMoveWire, + hostGeneration: number, +): number | undefined { + const type = pointerTypeCode(event.pointerType); + if (type < 1 || !Number.isSafeInteger(hostGeneration) || hostGeneration < 0) return undefined; + const words = wordsFor(buffer); + const writing = (Atomics.load(words, STAMP) + 1) | 1; + Atomics.store(words, STAMP, writing); + const sequence = (Atomics.load(words, SEQUENCE) + 1) | 0; + Atomics.store(words, SEQUENCE, sequence); + writeFloat(words, X_LOW, X_HIGH, event.position.x); + writeFloat(words, Y_LOW, Y_HIGH, event.position.y); + Atomics.store(words, POINTER_ID, event.pointerId); + Atomics.store(words, POINTER_TYPE, type); + Atomics.store(words, BUTTON, event.button); + Atomics.store(words, BUTTONS, event.buttons); + Atomics.store(words, MODIFIERS, event.modifiers); + Atomics.store(words, HOST_GENERATION_LOW, hostGeneration % UINT32); + Atomics.store(words, HOST_GENERATION_HIGH, Math.floor(hostGeneration / UINT32)); + Atomics.store(words, STAMP, (writing + 1) & ~1); + return sequence; +} + +export function readPointerMove(buffer: SharedArrayBuffer, consumedSequence?: number): PointerLaneSnapshot | undefined { + const words = wordsFor(buffer); + const before = Atomics.load(words, STAMP); + if ((before & 1) !== 0) return undefined; + const sequence = Atomics.load(words, SEQUENCE); + if (sequence === 0 || sequence === consumedSequence) return undefined; + const x = readFloat(words, X_LOW, X_HIGH); + const y = readFloat(words, Y_LOW, Y_HIGH); + const pointerId = Atomics.load(words, POINTER_ID); + const pointerType = pointerTypes[Atomics.load(words, POINTER_TYPE)]; + const button = Atomics.load(words, BUTTON); + const buttons = Atomics.load(words, BUTTONS); + const modifiers = Atomics.load(words, MODIFIERS); + const hostGeneration = + (Atomics.load(words, HOST_GENERATION_LOW) >>> 0) + Atomics.load(words, HOST_GENERATION_HIGH) * UINT32; + const after = Atomics.load(words, STAMP); + if (before !== after || (after & 1) !== 0 || !pointerType || !Number.isFinite(x) || !Number.isFinite(y)) + return undefined; + if (!Number.isSafeInteger(hostGeneration) || hostGeneration < 0) return undefined; + return { + sequence, + hostGeneration, + event: { kind: "pointer", phase: "move", pointerId, pointerType, position: { x, y }, button, buttons, modifiers }, + }; +} diff --git a/vendor/fxnode/src/browser/protocol.ts b/vendor/fxnode/src/browser/protocol.ts new file mode 100644 index 0000000..fbc23e7 --- /dev/null +++ b/vendor/fxnode/src/browser/protocol.ts @@ -0,0 +1,833 @@ +import type { Command, FxNodeSaveData } from "../commands/types.js"; +import type { GraphLayoutV2, GraphSnapshot } from "../core/types.js"; +import type { + MutationEnvelope as BoundMutationEnvelope, + SnapshotEnvelope as BoundSnapshotEnvelope, +} from "../composition/bound-engine.js"; +import type { FxNodeCompositionData } from "../composition/types.js"; +import type { PointerLaneSnapshot } from "./pointer-lane.js"; +import { FXNODE_COMPOSITION_LIMITS } from "../composition/validate.js"; +import { validCommand } from "../commands/validate.js"; +import type { FxNodeCamera, FxNodeSelectionSnapshot, FxNodeViewport } from "./host-types.js"; +import { fxNodeDevicePixels, FXNODE_VIEW_LIMITS } from "./view-limits.js"; + +export const PROTOCOL_VERSION = 3 as const; +export type ViewId = string; +export type Camera = FxNodeCamera; +export type VersionExpectation = { readonly kind: "current" } | { readonly kind: "exact"; readonly version: number }; +export type CompositionRevisionExpectation = + | { readonly kind: "current" } + | { readonly kind: "exact"; readonly revision: number }; +export type CompositionUpdateWire = + | { readonly kind: "composition.load"; readonly composition: unknown } + | { readonly kind: "theme.set"; readonly theme: unknown } + | { readonly kind: "header-styles.set"; readonly styles: unknown } + | { readonly kind: "compatibility.set"; readonly compatibility: unknown } + | { readonly kind: "socket.compose"; readonly id: string; readonly definition: unknown } + | { readonly kind: "socket.remove"; readonly id: string } + | { readonly kind: "node.compose"; readonly id: string; readonly definition: unknown } + | { readonly kind: "node.remove"; readonly id: string }; +export type CompositionChange = + | { readonly kind: "composition.load" } + | { readonly kind: "theme.set" } + | { readonly kind: "header-styles.set" } + | { readonly kind: "compatibility.set" } + | { readonly kind: "socket.compose" | "socket.remove" | "node.compose" | "node.remove"; readonly id: string }; +export type CompositionReceipt = + | { + readonly status: "committed"; + readonly revision: number; + readonly graphVersion: number; + readonly graphChanged: boolean; + readonly historyReset: true; + } + | { + readonly status: "noop"; + readonly revision: number; + readonly graphVersion: number; + readonly graphChanged: false; + readonly historyReset: false; + }; +export interface CompositionChangeEnvelope { + readonly baseRevision: number; + readonly revision: number; + readonly change: CompositionChange; + readonly baseGraphVersion: number; + readonly graphVersion: number; + readonly graphChanged: boolean; + readonly historyReset: true; +} +export type WorkerRequest = + | { + protocol: typeof PROTOCOL_VERSION; + type: "init"; + id: string; + applicationId: unknown; + applicationVersion: unknown; + resources: unknown; + historyLimit: number; + } + | { + protocol: typeof PROTOCOL_VERSION; + type: "view.attach"; + id: string; + viewId: ViewId; + viewport: Viewport; + camera: Camera; + pointerLane?: SharedArrayBuffer; + } + | { protocol: typeof PROTOCOL_VERSION; type: "view.detach"; id: string; viewId: string } + | { protocol: typeof PROTOCOL_VERSION; type: "command"; id: string; command: Command; expected: VersionExpectation } + | { protocol: typeof PROTOCOL_VERSION; type: "load"; id: string; data: unknown; expected: VersionExpectation } + | { protocol: typeof PROTOCOL_VERSION; type: "state.get" | "save" | "save.data"; id: string } + | { protocol: typeof PROTOCOL_VERSION; type: "state.set"; id: string; state: unknown; expected: VersionExpectation } + | { + protocol: typeof PROTOCOL_VERSION; + type: "composition.update"; + id: string; + expected: CompositionRevisionExpectation; + update: CompositionUpdateWire; + } + | { + protocol: typeof PROTOCOL_VERSION; + type: "view.viewport"; + id: string; + viewId: string; + viewport: Viewport; + expectedSurfaceGeneration: number; + hostGeneration: number; + pointerFence?: PointerFence; + } + | { + protocol: typeof PROTOCOL_VERSION; + type: "view.render"; + viewId: string; + renderId: number; + hostGeneration: number; + pointerFence?: PointerFence; + } + | { + protocol: typeof PROTOCOL_VERSION; + type: "view.input"; + viewId: string; + event: InputEventWire; + hostGeneration: number; + pointerFence?: PointerFence; + nodeMenuRequestId?: string; + resourceOpenRequestId?: string; + } + | { + protocol: typeof PROTOCOL_VERSION; + type: "view.node.add"; + viewId: string; + id: string; + nodeId: string; + typeId: string; + viewPosition: { x: number; y: number }; + expected: VersionExpectation; + pointerFence?: PointerFence; + } + | { + protocol: typeof PROTOCOL_VERSION; + type: "view.selection.remove"; + viewId: string; + id: string; + expected: VersionExpectation; + pointerFence?: PointerFence; + } + | { + protocol: typeof PROTOCOL_VERSION; + type: "view.selection.mute"; + viewId: string; + id: string; + value: boolean; + expected: VersionExpectation; + pointerFence?: PointerFence; + } + | { + protocol: typeof PROTOCOL_VERSION; + type: "view.resource.set"; + viewId: string; + id: string; + authorization: { viewId: string; token: string; graphVersion: number; compositionRevision: number }; + resource: { name: string; mime: string; bytes: ArrayBuffer }; + expected: VersionExpectation; + pointerFence?: PointerFence; + } + | { protocol: typeof PROTOCOL_VERSION; type: "view.pointer.flush"; viewId: string; pointerFence: PointerFence } + | { protocol: typeof PROTOCOL_VERSION; type: "view.frame.consumed"; viewId: string; frameId: number } + | { protocol: typeof PROTOCOL_VERSION; type: "dispose" }; +export type Viewport = FxNodeViewport; +export interface PointerFence { + readonly generation: number; + readonly before?: PointerLaneSnapshot; +} +export type InputEventWire = + | { + kind: "pointer"; + phase: "down" | "move" | "up" | "cancel"; + pointerId: number; + pointerType: string; + position: { x: number; y: number }; + button: number; + buttons: number; + modifiers: number; + } + | { kind: "wheel"; position: { x: number; y: number }; delta: { x: number; y: number }; modifiers: number } + | { kind: "key"; phase: "down" | "up"; key: string; code: string; repeat: boolean; modifiers: number } + | { kind: "focus"; phase: "focus" | "blur" } + | { kind: "outside-pointer"; button: number }; +export interface HostInteractionSnapshot { + readonly colorPickerOpen: boolean; +} +export type WorkerMessage = + | { + protocol: typeof PROTOCOL_VERSION; + type: "response"; + id: string; + ok: true; + value?: + | GraphSnapshot + | GraphLayoutV2 + | FxNodeSaveData + | { status: "committed" | "noop"; version: number } + | CompositionReceipt; + } + | { + protocol: typeof PROTOCOL_VERSION; + type: "response"; + id: string; + ok: false; + error: { code: string; message: string; path?: string; issues?: unknown }; + } + | { protocol: typeof PROTOCOL_VERSION; type: "mutation"; envelope: BoundMutationEnvelope } + | { + protocol: typeof PROTOCOL_VERSION; + type: "snapshot.event"; + envelope: BoundSnapshotEnvelope; + } + | { protocol: typeof PROTOCOL_VERSION; type: "composition.event"; envelope: CompositionChangeEnvelope } + | { + protocol: typeof PROTOCOL_VERSION; + type: "view.selection.host"; + viewId: string; + projection: FxNodeSelectionSnapshot; + } + | { + protocol: typeof PROTOCOL_VERSION; + type: "view.frame"; + viewId: string; + bitmap: ImageBitmap; + renderId: number; + frameId: number; + hostGeneration: number; + surfaceGeneration: number; + deviceWidth: number; + deviceHeight: number; + host: HostInteractionSnapshot; + } + | { protocol: typeof PROTOCOL_VERSION; type: "view.node-menu.result"; viewId: string; requestId: string; open: false } + | { + protocol: typeof PROTOCOL_VERSION; + type: "view.node-menu.result"; + viewId: string; + requestId: string; + open: true; + compositionRevision: number; + viewPosition: { x: number; y: number }; + } + | { + protocol: typeof PROTOCOL_VERSION; + type: "view.resource.open"; + viewId: string; + requestId: string; + authorization: { viewId: string; token: string; graphVersion: number; compositionRevision: number }; + resource: { + id: string; + kind: "image"; + title: string; + openTitle: string; + accept: readonly string[]; + maxBytes: number; + maxWidth: number; + maxHeight: number; + maxPixels: number; + }; + } + | { protocol: typeof PROTOCOL_VERSION; type: "fatal"; error: { code: string; message: string } }; + +const record = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); +const exact = (v: Record, keys: readonly string[]): boolean => + Object.keys(v).length === keys.length && keys.every((k) => Object.hasOwn(v, k)); +const nonnegativeSafeInteger = (value: unknown): value is number => + Number.isSafeInteger(value) && (value as number) >= 0; +const forbiddenCompositionIds = new Set(["__proto__", "prototype", "constructor"]); +const compositionId = (value: unknown): value is string => + typeof value === "string" && + value.length > 0 && + value.length <= FXNODE_COMPOSITION_LIMITS.maxIdLength && + !/[\u0000-\u001f\u007f]/.test(value) && + !forbiddenCompositionIds.has(value); +function validCompositionExpectation(value: unknown): value is CompositionRevisionExpectation { + return ( + record(value) && + ((value.kind === "current" && exact(value, ["kind"])) || + (value.kind === "exact" && exact(value, ["kind", "revision"]) && nonnegativeSafeInteger(value.revision))) + ); +} +function validCompositionUpdate(value: unknown): value is CompositionUpdateWire { + if (!record(value)) return false; + if (value.kind === "composition.load") return exact(value, ["kind", "composition"]); + if (value.kind === "theme.set") return exact(value, ["kind", "theme"]); + if (value.kind === "header-styles.set") return exact(value, ["kind", "styles"]); + if (value.kind === "compatibility.set") return exact(value, ["kind", "compatibility"]); + if (value.kind === "socket.compose" || value.kind === "node.compose") + return exact(value, ["kind", "id", "definition"]) && compositionId(value.id); + if (value.kind === "socket.remove" || value.kind === "node.remove") + return exact(value, ["kind", "id"]) && compositionId(value.id); + return false; +} +function validCompositionChange(value: unknown): value is CompositionChange { + if (!record(value)) return false; + if ( + value.kind === "theme.set" || + value.kind === "header-styles.set" || + value.kind === "composition.load" || + value.kind === "compatibility.set" + ) + return exact(value, ["kind"]); + return ( + (value.kind === "socket.compose" || + value.kind === "socket.remove" || + value.kind === "node.compose" || + value.kind === "node.remove") && + exact(value, ["kind", "id"]) && + compositionId(value.id) + ); +} +function validCompositionReceiptUnsafe(value: unknown): value is CompositionReceipt { + if ( + !record(value) || + !exact(value, ["status", "revision", "graphVersion", "graphChanged", "historyReset"]) || + !nonnegativeSafeInteger(value.revision) || + !nonnegativeSafeInteger(value.graphVersion) + ) + return false; + if (value.status === "committed") + return value.revision > 0 && typeof value.graphChanged === "boolean" && value.historyReset === true; + return value.status === "noop" && value.graphChanged === false && value.historyReset === false; +} +export function validCompositionReceipt(value: unknown): value is CompositionReceipt { + try { + return validCompositionReceiptUnsafe(value); + } catch { + return false; + } +} +export function validCommandReceipt( + value: unknown, +): value is { readonly status: "committed" | "noop"; readonly version: number } { + try { + return ( + record(value) && + exact(value, ["status", "version"]) && + (value.status === "committed" || value.status === "noop") && + nonnegativeSafeInteger(value.version) + ); + } catch { + return false; + } +} +function validCompositionEnvelope(value: unknown): value is CompositionChangeEnvelope { + return ( + record(value) && + exact(value, [ + "baseRevision", + "revision", + "change", + "baseGraphVersion", + "graphVersion", + "graphChanged", + "historyReset", + ]) && + nonnegativeSafeInteger(value.baseRevision) && + nonnegativeSafeInteger(value.revision) && + value.revision === value.baseRevision + 1 && + nonnegativeSafeInteger(value.baseGraphVersion) && + nonnegativeSafeInteger(value.graphVersion) && + typeof value.graphChanged === "boolean" && + value.historyReset === true && + validCompositionChange(value.change) && + value.graphVersion === value.baseGraphVersion + (value.graphChanged ? 1 : 0) + ); +} +const boundedString = (value: unknown, max: number, nonempty = false): value is string => + typeof value === "string" && (!nonempty || value.length > 0) && value.length <= max; +function validHostResourcePolicy( + value: unknown, +): value is Extract["resource"] { + if ( + !record(value) || + !exact(value, ["id", "kind", "title", "openTitle", "accept", "maxBytes", "maxWidth", "maxHeight", "maxPixels"]) || + !compositionId(value.id) || + value.kind !== "image" || + !boundedString(value.title, FXNODE_COMPOSITION_LIMITS.maxTitleLength) || + !boundedString(value.openTitle, FXNODE_COMPOSITION_LIMITS.maxTitleLength) || + !Array.isArray(value.accept) + ) + return false; + if ( + !nonnegativeSafeInteger(value.maxBytes) || + value.maxBytes === 0 || + value.maxBytes > FXNODE_COMPOSITION_LIMITS.maxImageBytes || + !nonnegativeSafeInteger(value.maxWidth) || + value.maxWidth === 0 || + value.maxWidth > FXNODE_COMPOSITION_LIMITS.maxImageDimension || + !nonnegativeSafeInteger(value.maxHeight) || + value.maxHeight === 0 || + value.maxHeight > FXNODE_COMPOSITION_LIMITS.maxImageDimension || + !nonnegativeSafeInteger(value.maxPixels) || + value.maxPixels === 0 || + value.maxPixels > FXNODE_COMPOSITION_LIMITS.maxImagePixels || + value.maxPixels > value.maxWidth * value.maxHeight + ) + return false; + const accepted = new Set(); + for (const item of value.accept) { + if (!boundedString(item, FXNODE_COMPOSITION_LIMITS.maxKeywordLength, true) || accepted.has(item)) return false; + accepted.add(item); + } + return value.accept.length > 0; +} +function validViewportUnsafe(v: unknown): v is Viewport { + return ( + record(v) && + exact(v, ["width", "height", "dpr"]) && + typeof v.width === "number" && + Number.isFinite(v.width) && + v.width >= 0 && + v.width <= FXNODE_VIEW_LIMITS.maxLogicalDimension && + typeof v.height === "number" && + Number.isFinite(v.height) && + v.height >= 0 && + v.height <= FXNODE_VIEW_LIMITS.maxLogicalDimension && + typeof v.dpr === "number" && + Number.isFinite(v.dpr) && + v.dpr > 0 && + v.dpr <= FXNODE_VIEW_LIMITS.maxDpr && + !!fxNodeDevicePixels(v.width, v.height, v.dpr) + ); +} +export function validViewport(v: unknown): v is Viewport { + try { + return validViewportUnsafe(v); + } catch { + return false; + } +} +function validCameraUnsafe(v: unknown): v is FxNodeCamera { + return ( + record(v) && + exact(v, ["center", "zoom"]) && + finitePoint(v.center) && + typeof v.zoom === "number" && + Number.isFinite(v.zoom) && + v.zoom >= FXNODE_VIEW_LIMITS.minZoom && + v.zoom <= FXNODE_VIEW_LIMITS.maxZoom + ); +} +/** Total camera validator for worker-bound hostile values. */ +export function validCamera(v: unknown): v is FxNodeCamera { + try { + return validCameraUnsafe(v); + } catch { + return false; + } +} +function validRequestUnsafe(v: unknown): v is WorkerRequest { + if (!record(v) || v.protocol !== PROTOCOL_VERSION || typeof v.type !== "string") return false; + if (v.type.startsWith("view.") && !id(v.viewId)) return false; + if (v.type === "init") + return ( + Object.keys(v).every((k) => + ["protocol", "type", "id", "applicationId", "applicationVersion", "resources", "historyLimit"].includes(k), + ) && + ["protocol", "type", "id", "applicationId", "applicationVersion", "resources", "historyLimit"].every((k) => + Object.hasOwn(v, k), + ) && + id(v.id) && + typeof v.applicationId === "string" && + Number.isSafeInteger(v.applicationVersion) && + Number.isSafeInteger(v.historyLimit) && + (v.historyLimit as number) >= 0 && + (v.historyLimit as number) <= 1000 + ); + if (v.type === "view.attach") + return ( + Object.keys(v).every((key) => + ["protocol", "type", "id", "viewId", "viewport", "camera", "pointerLane"].includes(key), + ) && + ["protocol", "type", "id", "viewId", "viewport", "camera"].every((key) => Object.hasOwn(v, key)) && + id(v.id) && + validViewport(v.viewport) && + validCamera(v.camera) && + (v.pointerLane === undefined || + (typeof SharedArrayBuffer === "function" && v.pointerLane instanceof SharedArrayBuffer)) + ); + if (v.type === "view.detach") return exact(v, ["protocol", "type", "id", "viewId"]) && id(v.id); + if (v.type === "command") + return ( + exact(v, ["protocol", "type", "id", "command", "expected"]) && + id(v.id) && + validCommand(v.command) && + validExpectation(v.expected) + ); + if (v.type === "load") + return exact(v, ["protocol", "type", "id", "data", "expected"]) && id(v.id) && validExpectation(v.expected); + if (v.type === "state.get" || v.type === "save" || v.type === "save.data") + return exact(v, ["protocol", "type", "id"]) && id(v.id); + // State is deliberately opaque here. The composition-bound worker authority decodes it. + if (v.type === "state.set") + return exact(v, ["protocol", "type", "id", "state", "expected"]) && id(v.id) && validExpectation(v.expected); + if (v.type === "composition.update") + return ( + exact(v, ["protocol", "type", "id", "expected", "update"]) && + id(v.id) && + validCompositionExpectation(v.expected) && + validCompositionUpdate(v.update) + ); + if (v.type === "view.viewport") + return ( + Object.keys(v).every((key) => + [ + "protocol", + "type", + "id", + "viewId", + "viewport", + "expectedSurfaceGeneration", + "hostGeneration", + "pointerFence", + ].includes(key), + ) && + ["protocol", "type", "id", "viewId", "viewport", "expectedSurfaceGeneration", "hostGeneration"].every((key) => + Object.hasOwn(v, key), + ) && + id(v.id) && + validViewport(v.viewport) && + nonnegativeSafeInteger(v.expectedSurfaceGeneration) && + nonnegativeSafeInteger(v.hostGeneration) && + (v.pointerFence === undefined || validPointerFence(v.pointerFence)) + ); + if (v.type === "view.render") + return ( + Object.keys(v).every((key) => + ["protocol", "type", "viewId", "renderId", "hostGeneration", "pointerFence"].includes(key), + ) && + ["protocol", "type", "viewId", "renderId", "hostGeneration"].every((key) => Object.hasOwn(v, key)) && + nonnegativeSafeInteger(v.renderId) && + nonnegativeSafeInteger(v.hostGeneration) && + (v.pointerFence === undefined || validPointerFence(v.pointerFence)) + ); + if (v.type === "view.frame.consumed") + return exact(v, ["protocol", "type", "viewId", "frameId"]) && nonnegativeSafeInteger(v.frameId); + if (v.type === "dispose") return exact(v, ["protocol", "type"]); + if (v.type === "view.pointer.flush") + return exact(v, ["protocol", "type", "viewId", "pointerFence"]) && validPointerFence(v.pointerFence); + if (v.type === "view.node.add") + return ( + Object.keys(v).every((k) => + ["protocol", "type", "viewId", "id", "nodeId", "typeId", "viewPosition", "expected", "pointerFence"].includes( + k, + ), + ) && + ["protocol", "type", "viewId", "id", "nodeId", "typeId", "viewPosition", "expected"].every((k) => + Object.hasOwn(v, k), + ) && + id(v.id) && + id(v.nodeId) && + nodeTypeId(v.typeId) && + finitePoint(v.viewPosition) && + validExpectation(v.expected) && + (v.pointerFence === undefined || validPointerFence(v.pointerFence)) + ); + if (v.type === "view.selection.remove") + return ( + Object.keys(v).every((k) => ["protocol", "type", "viewId", "id", "expected", "pointerFence"].includes(k)) && + ["protocol", "type", "viewId", "id", "expected"].every((k) => Object.hasOwn(v, k)) && + id(v.id) && + validExpectation(v.expected) && + (v.pointerFence === undefined || validPointerFence(v.pointerFence)) + ); + if (v.type === "view.selection.mute") + return ( + Object.keys(v).every((k) => + ["protocol", "type", "viewId", "id", "value", "expected", "pointerFence"].includes(k), + ) && + ["protocol", "type", "viewId", "id", "value", "expected"].every((k) => Object.hasOwn(v, k)) && + id(v.id) && + typeof v.value === "boolean" && + validExpectation(v.expected) && + (v.pointerFence === undefined || validPointerFence(v.pointerFence)) + ); + if (v.type === "view.resource.set") + return ( + Object.keys(v).every((k) => + ["protocol", "type", "viewId", "id", "authorization", "resource", "expected", "pointerFence"].includes(k), + ) && + ["protocol", "type", "viewId", "id", "authorization", "resource", "expected"].every((k) => Object.hasOwn(v, k)) && + id(v.id) && + record(v.authorization) && + exact(v.authorization, ["viewId", "token", "graphVersion", "compositionRevision"]) && + id(v.authorization.viewId) && + id(v.authorization.token) && + nonnegativeSafeInteger(v.authorization.graphVersion) && + nonnegativeSafeInteger(v.authorization.compositionRevision) && + record(v.resource) && + exact(v.resource, ["name", "mime", "bytes"]) && + typeof v.resource.name === "string" && + v.resource.name.length > 0 && + v.resource.name.length <= 255 && + !/[\u0000-\u001f\u007f]/.test(v.resource.name) && + typeof v.resource.mime === "string" && + v.resource.mime.length <= 128 && + v.resource.bytes instanceof ArrayBuffer && + v.resource.bytes.byteLength > 0 && + v.resource.bytes.byteLength <= 32 * 1024 * 1024 && + validExpectation(v.expected) && + (v.pointerFence === undefined || validPointerFence(v.pointerFence)) + ); + return ( + v.type === "view.input" && + Object.keys(v).every((k) => + [ + "protocol", + "type", + "viewId", + "event", + "hostGeneration", + "pointerFence", + "nodeMenuRequestId", + "resourceOpenRequestId", + ].includes(k), + ) && + ["protocol", "type", "viewId", "event", "hostGeneration"].every((k) => Object.hasOwn(v, k)) && + validInput(v.event) && + nonnegativeSafeInteger(v.hostGeneration) && + (v.pointerFence === undefined || validPointerFence(v.pointerFence)) && + (v.nodeMenuRequestId === undefined || id(v.nodeMenuRequestId)) && + (v.resourceOpenRequestId === undefined || id(v.resourceOpenRequestId)) + ); +} +/** Total across hostile objects (including proxies with throwing traps). */ +export function validRequest(v: unknown): v is WorkerRequest { + try { + return validRequestUnsafe(v); + } catch { + return false; + } +} +const finitePoint = (v: unknown): boolean => + record(v) && + exact(v, ["x", "y"]) && + typeof v.x === "number" && + Number.isFinite(v.x) && + typeof v.y === "number" && + Number.isFinite(v.y); +function validInput(v: unknown): v is InputEventWire { + if (!record(v)) return false; + if (v.kind === "outside-pointer") return exact(v, ["kind", "button"]) && Number.isInteger(v.button); + if (v.kind === "pointer") + return ( + exact(v, ["kind", "phase", "pointerId", "pointerType", "position", "button", "buttons", "modifiers"]) && + ["down", "move", "up", "cancel"].includes(String(v.phase)) && + Number.isSafeInteger(v.pointerId) && + typeof v.pointerType === "string" && + finitePoint(v.position) && + Number.isInteger(v.button) && + Number.isInteger(v.buttons) && + Number.isInteger(v.modifiers) + ); + if (v.kind === "wheel") + return ( + exact(v, ["kind", "position", "delta", "modifiers"]) && + finitePoint(v.position) && + finitePoint(v.delta) && + Number.isInteger(v.modifiers) + ); + if (v.kind === "key") + return ( + exact(v, ["kind", "phase", "key", "code", "repeat", "modifiers"]) && + ["down", "up"].includes(String(v.phase)) && + typeof v.key === "string" && + typeof v.code === "string" && + typeof v.repeat === "boolean" && + Number.isInteger(v.modifiers) + ); + return v.kind === "focus" && exact(v, ["kind", "phase"]) && (v.phase === "focus" || v.phase === "blur"); +} +function validPointerFence(v: unknown): v is PointerFence { + return ( + record(v) && + Object.keys(v).every((k) => ["generation", "before"].includes(k)) && + Object.hasOwn(v, "generation") && + Number.isInteger(v.generation) && + (v.before === undefined || + (record(v.before) && + exact(v.before, ["sequence", "hostGeneration", "event"]) && + Number.isInteger(v.before.sequence) && + nonnegativeSafeInteger(v.before.hostGeneration) && + validInput(v.before.event) && + record(v.before.event) && + v.before.event.kind === "pointer" && + v.before.event.phase === "move")) + ); +} +const id = (v: unknown): boolean => typeof v === "string" && v.length > 0 && v.length <= 512; +const nodeTypeId = (v: unknown): boolean => + typeof v === "string" && v.length > 0 && v.length <= FXNODE_COMPOSITION_LIMITS.maxIdLength; +function validExpectation(v: unknown): v is VersionExpectation { + return ( + record(v) && + ((v.kind === "current" && exact(v, ["kind"])) || + (v.kind === "exact" && + exact(v, ["kind", "version"]) && + Number.isSafeInteger(v.version) && + (v.version as number) >= 0)) + ); +} +function validWorkerMessageUnsafe(v: unknown): v is WorkerMessage { + if (!record(v) || v.protocol !== PROTOCOL_VERSION || typeof v.type !== "string") return false; + if (v.type.startsWith("view.") && !id(v.viewId)) return false; + if (v.type === "view.frame") + return ( + exact(v, [ + "protocol", + "type", + "viewId", + "bitmap", + "renderId", + "frameId", + "hostGeneration", + "surfaceGeneration", + "deviceWidth", + "deviceHeight", + "host", + ]) && + typeof ImageBitmap !== "undefined" && + v.bitmap instanceof ImageBitmap && + nonnegativeSafeInteger(v.renderId) && + nonnegativeSafeInteger(v.frameId) && + nonnegativeSafeInteger(v.hostGeneration) && + nonnegativeSafeInteger(v.surfaceGeneration) && + Number.isSafeInteger(v.deviceWidth) && + (v.deviceWidth as number) > 0 && + (v.deviceWidth as number) <= FXNODE_VIEW_LIMITS.maxDeviceDimension && + Number.isSafeInteger(v.deviceHeight) && + (v.deviceHeight as number) > 0 && + (v.deviceHeight as number) <= FXNODE_VIEW_LIMITS.maxDeviceDimension && + (v.deviceWidth as number) * (v.deviceHeight as number) <= FXNODE_VIEW_LIMITS.maxDevicePixelsPerView && + v.bitmap.width === v.deviceWidth && + v.bitmap.height === v.deviceHeight && + validHostSnapshot(v.host) + ); + if (v.type === "mutation") + return exact(v, ["protocol", "type", "envelope"]) && record(v.envelope) && Number.isSafeInteger(v.envelope.version); + if (v.type === "snapshot.event") + return exact(v, ["protocol", "type", "envelope"]) && record(v.envelope) && Number.isSafeInteger(v.envelope.version); + if (v.type === "composition.event") + return exact(v, ["protocol", "type", "envelope"]) && validCompositionEnvelope(v.envelope); + if (v.type === "view.selection.host") + return exact(v, ["protocol", "type", "viewId", "projection"]) && validSelectionProjection(v.projection); + if (v.type === "view.node-menu.result") + return v.open === false + ? exact(v, ["protocol", "type", "viewId", "requestId", "open"]) && id(v.requestId) + : v.open === true && + exact(v, ["protocol", "type", "viewId", "requestId", "open", "compositionRevision", "viewPosition"]) && + id(v.requestId) && + nonnegativeSafeInteger(v.compositionRevision) && + finitePoint(v.viewPosition); + if (v.type === "view.resource.open") + return ( + exact(v, ["protocol", "type", "viewId", "requestId", "authorization", "resource"]) && + id(v.requestId) && + record(v.authorization) && + exact(v.authorization, ["viewId", "token", "graphVersion", "compositionRevision"]) && + id(v.authorization.viewId) && + id(v.authorization.token) && + nonnegativeSafeInteger(v.authorization.graphVersion) && + nonnegativeSafeInteger(v.authorization.compositionRevision) && + validHostResourcePolicy(v.resource) + ); + if (v.type === "fatal") + return ( + exact(v, ["protocol", "type", "error"]) && + record(v.error) && + exact(v.error, ["code", "message"]) && + typeof v.error.code === "string" && + typeof v.error.message === "string" + ); + return ( + v.type === "response" && + id(v.id) && + typeof v.ok === "boolean" && + (v.ok + ? exact(v, ["protocol", "type", "id", "ok"]) || exact(v, ["protocol", "type", "id", "ok", "value"]) + : exact(v, ["protocol", "type", "id", "ok", "error"]) && validError(v.error)) + ); +} +/** Total across hostile objects (including proxies with throwing traps). */ +export function validWorkerMessage(v: unknown): v is WorkerMessage { + try { + return validWorkerMessageUnsafe(v); + } catch { + return false; + } +} +function validError(v: unknown): boolean { + return ( + record(v) && + Object.hasOwn(v, "code") && + Object.hasOwn(v, "message") && + Object.keys(v).every((k) => ["code", "message", "path", "issues"].includes(k)) && + typeof v.code === "string" && + v.code.length <= 128 && + typeof v.message === "string" && + v.message.length <= 2048 && + (v.path === undefined || (Object.hasOwn(v, "path") && typeof v.path === "string" && v.path.length <= 512)) && + (v.issues === undefined || + (Object.hasOwn(v, "issues") && + Array.isArray(v.issues) && + v.issues.length <= 100 && + v.issues.every( + (issue) => + record(issue) && + exact(issue, ["code", "path", "message"]) && + typeof issue.code === "string" && + issue.code.length <= 128 && + typeof issue.path === "string" && + issue.path.length <= 512 && + typeof issue.message === "string" && + issue.message.length <= 1024, + ))) + ); +} +function validHostSnapshot(v: unknown): v is HostInteractionSnapshot { + return record(v) && exact(v, ["colorPickerOpen"]) && typeof v.colorPickerOpen === "boolean"; +} +function validSelectionProjection(v: unknown): v is FxNodeSelectionSnapshot { + if ( + !record(v) || + !exact(v, ["nodeCount", "linkCount", "canRemove", "mute"]) || + !nonnegativeSafeInteger(v.nodeCount) || + !nonnegativeSafeInteger(v.linkCount) || + v.canRemove !== (v.nodeCount > 0 || v.linkCount > 0) || + !record(v.mute) + ) + return false; + return v.mute.enabled === false + ? exact(v.mute, ["enabled"]) + : v.mute.enabled === true && + exact(v.mute, ["enabled", "state"]) && + ["all-muted", "all-unmuted", "mixed"].includes(String(v.mute.state)); +} diff --git a/vendor/fxnode/src/browser/view-limits.ts b/vendor/fxnode/src/browser/view-limits.ts new file mode 100644 index 0000000..d9abba8 --- /dev/null +++ b/vendor/fxnode/src/browser/view-limits.ts @@ -0,0 +1,44 @@ +export const FXNODE_VIEW_LIMITS = Object.freeze({ + maxViews: 16, + maxLogicalDimension: 8192, + maxDpr: 4, + maxDeviceDimension: 8192, + maxDevicePixelsPerView: 16_777_216, + maxActiveDevicePixels: 16_777_216, + maxAtlasDimension: 8192, + maxAtlasPixels: 16_777_216, + maxInFlightDevicePixels: 16_777_216, + minZoom: 0.1, + maxZoom: 4, +}); + +/** Returns the actual rounded backing-store size, or undefined when unsupported. */ +export function fxNodeDevicePixels( + width: number, + height: number, + dpr: number, +): Readonly<{ width: number; height: number }> | undefined { + if ( + !Number.isFinite(width) || + !Number.isFinite(height) || + !Number.isFinite(dpr) || + width < 0 || + height < 0 || + dpr <= 0 || + width > FXNODE_VIEW_LIMITS.maxLogicalDimension || + height > FXNODE_VIEW_LIMITS.maxLogicalDimension || + dpr > FXNODE_VIEW_LIMITS.maxDpr + ) + return; + const deviceWidth = Math.max(1, Math.round(width * dpr)), + deviceHeight = Math.max(1, Math.round(height * dpr)); + if ( + !Number.isSafeInteger(deviceWidth) || + !Number.isSafeInteger(deviceHeight) || + deviceWidth > FXNODE_VIEW_LIMITS.maxDeviceDimension || + deviceHeight > FXNODE_VIEW_LIMITS.maxDeviceDimension || + deviceWidth * deviceHeight > FXNODE_VIEW_LIMITS.maxDevicePixelsPerView + ) + return; + return Object.freeze({ width: deviceWidth, height: deviceHeight }); +} diff --git a/vendor/fxnode/src/color/oklab.ts b/vendor/fxnode/src/color/oklab.ts new file mode 100644 index 0000000..406a42f --- /dev/null +++ b/vendor/fxnode/src/color/oklab.ts @@ -0,0 +1,67 @@ +export type Rgb = readonly [number, number, number]; +export type Rgba = readonly [number, number, number, number]; +export interface Oklab { + readonly l: number; + readonly a: number; + readonly b: number; +} +export interface Oklch { + readonly l: number; + readonly c: number; + readonly h: number; +} +const tau = Math.PI * 2; +const decode = (value: number) => + Math.abs(value) <= 0.04045 ? value / 12.92 : Math.sign(value) * ((Math.abs(value) + 0.055) / 1.055) ** 2.4; +const encode = (value: number) => + Math.abs(value) <= 0.0031308 ? 12.92 * value : Math.sign(value) * (1.055 * Math.abs(value) ** (1 / 2.4) - 0.055); +export const normalizeHue = (value: number) => ((value % tau) + tau) % tau; +export function srgbToOklab(rgb: Rgb): Oklab { + const r = decode(rgb[0]), + g = decode(rgb[1]), + b = decode(rgb[2]), + l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b), + m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b), + s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + return { + l: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, + a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, + b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, + }; +} +export function oklabToLinearSrgb({ l, a, b }: Oklab): Rgb { + const x = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3, + y = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3, + z = (l - 0.0894841775 * a - 1.291485548 * b) ** 3; + return [ + 4.0767416621 * x - 3.3077115913 * y + 0.2309699292 * z, + -1.2684380046 * x + 2.6097574011 * y - 0.3413193965 * z, + -0.0041960863 * x - 0.7034186147 * y + 1.707614701 * z, + ]; +} +export function oklabToOklch(value: Oklab, fallbackHue = 0): Oklch { + const c = Math.hypot(value.a, value.b); + return { l: value.l, c, h: c <= 4e-6 ? normalizeHue(fallbackHue) : normalizeHue(Math.atan2(value.b, value.a)) }; +} +export const oklchToOklab = ({ l, c, h }: Oklch): Oklab => ({ l, a: c * Math.cos(h), b: c * Math.sin(h) }); +const inLinearGamut = (rgb: Rgb) => rgb.every((value) => Number.isFinite(value) && value >= -1e-9 && value <= 1 + 1e-9); +export const isInSrgbGamut = (color: Oklch) => inLinearGamut(oklabToLinearSrgb(oklchToOklab(color))); +export function maxSrgbChroma(l: number, h: number): number { + if (l <= 0 || l >= 1) return 0; + let low = 0, + high = 0.4; + while (high < 2 && isInSrgbGamut({ l, c: high, h })) high *= 2; + for (let i = 0; i < 20; i++) { + const middle = (low + high) / 2; + if (isInSrgbGamut({ l, c: middle, h })) low = middle; + else high = middle; + } + return low; +} +export function mapOklchToSrgb(color: Oklch): Rgb { + if (color.l <= 0) return [0, 0, 0]; + if (color.l >= 1) return [1, 1, 1]; + const mapped = { ...color, c: Math.min(Math.max(0, color.c), maxSrgbChroma(color.l, color.h)) }, + linear = oklabToLinearSrgb(oklchToOklab(mapped)); + return linear.map((value) => Math.min(1, Math.max(0, encode(value)))) as unknown as Rgb; +} diff --git a/vendor/fxnode/src/commands/limits.ts b/vendor/fxnode/src/commands/limits.ts new file mode 100644 index 0000000..b771d1d --- /dev/null +++ b/vendor/fxnode/src/commands/limits.ts @@ -0,0 +1,2 @@ +/** Maximum number of commands in one atomic batch. */ +export const FXNODE_BATCH_LIMIT = 256; diff --git a/vendor/fxnode/src/commands/save-data.ts b/vendor/fxnode/src/commands/save-data.ts new file mode 100644 index 0000000..e12f3c5 --- /dev/null +++ b/vendor/fxnode/src/commands/save-data.ts @@ -0,0 +1,201 @@ +import type { FxNodeCompositionData, NodeTypeId } from "../composition/types.js"; +import { + PERSISTENCE_LIMITS, + type DecodeResult as BoundDecodeResult, + type ValidationIssue as BoundValidationIssue, +} from "../composition/bound-document.js"; +import { saveCompositionCompatibility } from "../composition/save-compatibility.js"; +import { FXNODE_COMPOSITION_LIMITS, validateFxNodeComposition } from "../composition/validate.js"; +import { admitStructuredData, deepFreeze, isRecord } from "../core/json.js"; +import type { GraphDocument, GraphLayoutV2 } from "../core/types.js"; +import type { CompatibleFxNodeSaveData } from "./types.js"; +import { validFxNodeReplayCommand } from "./validate.js"; + +export const FXNODE_SAVE_DATA_LIMITS = Object.freeze({ + maxCommands: 1_000, + maxAtomicCommands: 10_000, + maxValues: 100_000, + maxStringCodeUnits: 1_048_576, + maxDepth: 50, + maxIssues: 100, +}); +const SAVE_ENVELOPE_OVERHEAD = 256; +export type FxNodeSaveDataDecodeResult = + | { readonly ok: true; readonly value: CompatibleFxNodeSaveData; readonly baseline: GraphDocument } + | { readonly ok: false; readonly issues: readonly BoundValidationIssue[] }; +const exact = (value: Record, keys: readonly string[]) => + Object.keys(value).length === keys.length && keys.every((k) => Object.hasOwn(value, k)); +const issue = (code: string, path: string, message: string): readonly BoundValidationIssue[] => + deepFreeze([{ code, path, message }]); +const prefix = (path: string) => `/baseline${path === "/" ? "" : path.startsWith("/") ? path : `/${path}`}`; + +/** Save-decoder-only bridge for durable command logs that embedded the schema-1 menu catalog. */ +const normalizeSavedComposition = (input: unknown): unknown => { + if ( + !isRecord(input) || + input.schemaVersion !== 1 || + !Object.hasOwn(input, "menuGroups") || + !isRecord(input.menuGroups) || + !isRecord(input.nodes) + ) + return input; + for (const group of Object.values(input.menuGroups)) + if ( + !isRecord(group) || + !exact(group, ["title", "order"]) || + typeof group.title !== "string" || + group.title.length === 0 || + group.title.length > FXNODE_COMPOSITION_LIMITS.maxTitleLength || + !Number.isSafeInteger(group.order) + ) + return input; + for (const definition of Object.values(input.nodes)) { + if (!isRecord(definition) || !Object.hasOwn(definition, "menu") || !isRecord(definition.menu)) return input; + const menu = definition.menu; + if (menu.kind === "hidden") { + if (!exact(menu, ["kind"])) return input; + continue; + } + if ( + menu.kind !== "entry" || + !exact(menu, ["kind", "group", "order", "keywords"]) || + typeof menu.group !== "string" || + !Object.hasOwn(input.menuGroups, menu.group) || + !Number.isSafeInteger(menu.order) || + !Array.isArray(menu.keywords) || + menu.keywords.some( + (keyword) => typeof keyword !== "string" || keyword.length > FXNODE_COMPOSITION_LIMITS.maxKeywordLength, + ) + ) + return input; + } + const { menuGroups: _menuGroups, ...composition } = input; + return { + ...composition, + schemaVersion: 2, + nodes: Object.fromEntries( + Object.entries(input.nodes).map(([id, definition]) => { + if (!isRecord(definition)) return [id, definition]; + const { menu: _menu, ...node } = definition; + return [id, node]; + }), + ), + }; +}; + +/** Bounded, total decoder for an admitted command-log save envelope. */ +export function decodeFxNodeSaveData( + source: unknown, + composition: { readonly source: C }, + decodeBaseline: (value: unknown) => BoundDecodeResult, +): FxNodeSaveDataDecodeResult { + try { + const admitted = admitStructuredData(source, { + maxValues: + PERSISTENCE_LIMITS.maxValues + + FXNODE_SAVE_DATA_LIMITS.maxValues + + FXNODE_COMPOSITION_LIMITS.maxValues + + SAVE_ENVELOPE_OVERHEAD, + maxStringCodeUnits: + PERSISTENCE_LIMITS.maxStringCodeUnits + + FXNODE_SAVE_DATA_LIMITS.maxStringCodeUnits + + FXNODE_COMPOSITION_LIMITS.maxStringCodeUnits + + SAVE_ENVELOPE_OVERHEAD, + maxDepth: + Math.max(PERSISTENCE_LIMITS.maxDepth, FXNODE_SAVE_DATA_LIMITS.maxDepth, FXNODE_COMPOSITION_LIMITS.maxDepth) + 2, + maxIssues: FXNODE_SAVE_DATA_LIMITS.maxIssues, + }); + if (!admitted.ok) return { ok: false, issues: deepFreeze(admitted.issues) }; + const value = admitted.value; + if (!isRecord(value) || value.kind !== "fxnode.command-log") + return { ok: false, issues: issue("save.shape", "/", "Invalid FxNodeSaveData envelope") }; + if (value.schemaVersion === 1) + return { + ok: false, + issues: issue( + "save.schema.unsupported", + "/schemaVersion", + "FxNodeSaveData schema version 1 is no longer supported", + ), + }; + if (Number.isSafeInteger(value.schemaVersion) && Number(value.schemaVersion) > 2) + return { + ok: false, + issues: issue( + "save.schema.future", + "/schemaVersion", + "FxNodeSaveData schema version is newer than this runtime", + ), + }; + if (!exact(value, ["kind", "schemaVersion", "composition", "baseline", "commands"]) || value.schemaVersion !== 2) + return { ok: false, issues: issue("save.shape", "/", "Invalid FxNodeSaveData envelope") }; + const normalizedComposition = normalizeSavedComposition(value.composition); + const savedComposition = validateFxNodeComposition(normalizedComposition); + if (!savedComposition.ok) + return { + ok: false, + issues: deepFreeze([ + { code: "save.composition.invalid", path: "/composition", message: "Saved composition is invalid" }, + ...savedComposition.issues.slice(0, 99).map((x) => ({ ...x, path: `/composition${x.path}` })), + ]), + }; + if (!Array.isArray(value.commands)) + return { ok: false, issues: issue("save.commands", "/commands", "Commands must be an array") }; + if (value.commands.length > FXNODE_SAVE_DATA_LIMITS.maxCommands) + return { ok: false, issues: issue("limit.commands", "/commands", "Command limit exceeded") }; + const admittedCommands = admitStructuredData(value.commands, FXNODE_SAVE_DATA_LIMITS); + if (!admittedCommands.ok) + return { + ok: false, + issues: deepFreeze(admittedCommands.issues.map((x) => ({ ...x, path: `/commands${x.path}` }))), + }; + if (!isRecord(value.baseline) || value.baseline.schemaVersion !== 2) + return { + ok: false, + issues: issue("baseline.schema", "/baseline/schemaVersion", "Baseline must be a GraphLayoutV2 record"), + }; + const admittedBaseline = admitStructuredData(value.baseline, PERSISTENCE_LIMITS); + if (!admittedBaseline.ok) + return { + ok: false, + issues: deepFreeze(admittedBaseline.issues.map((x) => ({ ...x, path: `/baseline${x.path}` }))), + }; + const compatibility = saveCompositionCompatibility( + savedComposition.value, + composition.source, + value.baseline as unknown as GraphLayoutV2, + ); + if (compatibility.length) return { ok: false, issues: compatibility }; + let atomic = 0; + for (let i = 0; i < value.commands.length; i++) { + const command = value.commands[i]; + if (!validFxNodeReplayCommand(command, (id) => Object.hasOwn(savedComposition.value.nodes, id))) + return { + ok: false, + issues: issue( + "command.invalid", + `/commands/${i}`, + "Invalid replay command or node type is absent from saved composition", + ), + }; + atomic += command.type === "batch" ? command.commands.length : 1; + if (atomic > FXNODE_SAVE_DATA_LIMITS.maxAtomicCommands) + return { ok: false, issues: issue("limit.atomic-commands", `/commands/${i}`, "Atomic command limit exceeded") }; + } + const decoded = decodeBaseline(value.baseline); + if (!decoded.ok) + return { + ok: false, + issues: deepFreeze( + decoded.issues.slice(0, FXNODE_SAVE_DATA_LIMITS.maxIssues).map((x) => ({ ...x, path: prefix(x.path) })), + ), + }; + return { + ok: true, + value: deepFreeze({ ...value, composition: savedComposition.value } as unknown as CompatibleFxNodeSaveData), + baseline: decoded.value, + }; + } catch { + return { ok: false, issues: issue("save.decode", "/", "Save data could not be decoded") }; + } +} diff --git a/vendor/fxnode/src/commands/types.ts b/vendor/fxnode/src/commands/types.ts new file mode 100644 index 0000000..8f29a03 --- /dev/null +++ b/vendor/fxnode/src/commands/types.ts @@ -0,0 +1,85 @@ +import type { FxNodeCompositionData, NodeTypeId } from "../composition/types.js"; +import type { + CommandId, + GraphLayoutV2, + GraphLink, + LinkId, + NodeId, + ParameterValue, + SocketId, + Vec2, +} from "../core/types.js"; + +export type Command = + | { readonly type: "batch"; readonly commands: readonly BatchCommand[] } + | { + readonly type: "node.add"; + readonly nodeId: NodeId; + readonly nodeType: NodeTypeId; + readonly position: Vec2; + readonly parentId?: NodeId; + } + | { readonly type: "node.remove"; readonly id: NodeId } + | { readonly type: "node.move"; readonly id: NodeId; readonly position: Vec2 } + | { readonly type: "node.resize"; readonly id: NodeId; readonly size: Vec2 } + | { readonly type: "node.label"; readonly id: NodeId; readonly label: string } + | { readonly type: "node.parameter"; readonly id: NodeId; readonly key: string; readonly value: ParameterValue } + | { readonly type: "node.parameter-reset"; readonly id: NodeId; readonly key: string } + | { + readonly type: "node.socket-default"; + readonly id: NodeId; + readonly socketId: SocketId; + readonly value: ParameterValue; + } + | { readonly type: "node.socket-default-reset"; readonly id: NodeId; readonly socketId: SocketId } + | { readonly type: "node.mute" | "node.collapse"; readonly id: NodeId; readonly value: boolean } + | { readonly type: "node.parent"; readonly id: NodeId; readonly parentId: NodeId | null } + | { readonly type: "link.add"; readonly link: GraphLink } + | { readonly type: "link.remove"; readonly id: LinkId } + | { readonly type: "link.mute"; readonly id: LinkId; readonly value: boolean } + | { readonly type: "link.replace"; readonly removeId: LinkId; readonly link: GraphLink } + | { readonly type: "undo" } + | { readonly type: "redo" }; +/** Atomic, deliberately non-recursive operations used by completed gestures. */ +export type BatchCommand = Extract< + Command, + { + readonly type: + | "node.move" + | "node.resize" + | "node.remove" + | "node.mute" + | "node.collapse" + | "node.parent" + | "link.remove" + | "link.mute"; + } +>; +/** A forward-only command suitable for durable replay. */ +export type FxNodeReplayCommand = Exclude< + Command, + { readonly type: "undo" | "redo" } +>; +export interface FxNodeSaveData { + readonly kind: "fxnode.command-log"; + readonly schemaVersion: 2; + readonly composition: C; + readonly baseline: GraphLayoutV2; + readonly commands: readonly FxNodeReplayCommand[]; +} +/** A decoded save whose embedded historical composition was compatibility-checked against C. */ +export interface CompatibleFxNodeSaveData + extends Omit, "composition"> { + readonly composition: FxNodeCompositionData; +} +export interface CommandRequest { + readonly commandId: CommandId; + readonly expectedVersion: number; + readonly source: "api" | "gesture"; + readonly command: Command; +} +export interface CommandError { + readonly code: string; + readonly message: string; + readonly path?: string; +} diff --git a/vendor/fxnode/src/commands/validate.ts b/vendor/fxnode/src/commands/validate.ts new file mode 100644 index 0000000..8e64195 --- /dev/null +++ b/vendor/fxnode/src/commands/validate.ts @@ -0,0 +1,143 @@ +import { FXNODE_COMPOSITION_LIMITS } from "../composition/validate.js"; +import { isJson } from "../core/json.js"; +import type { FxNodeCompositionData, NodeTypeId } from "../composition/types.js"; +import type { GraphLink, ParameterValue } from "../core/types.js"; +import type { Command, FxNodeReplayCommand } from "./types.js"; +import { FXNODE_BATCH_LIMIT } from "./limits.js"; + +const record = (v: unknown): v is Record => typeof v === "object" && v !== null && !Array.isArray(v); +const exact = (v: Record, keys: readonly string[]) => + Object.keys(v).length === keys.length && keys.every((k) => Object.hasOwn(v, k)); +const finitePoint = (v: unknown) => + record(v) && + exact(v, ["x", "y"]) && + typeof v.x === "number" && + Number.isFinite(v.x) && + typeof v.y === "number" && + Number.isFinite(v.y); +const id = (v: unknown) => typeof v === "string" && v.length > 0 && v.length <= 512; +const nodeTypeId = (v: unknown) => + typeof v === "string" && v.length > 0 && v.length <= FXNODE_COMPOSITION_LIMITS.maxIdLength; +const parameter = (v: unknown): v is ParameterValue => + record(v) && + exact(v, ["kind", "value"]) && + (v.kind === "number" + ? typeof v.value === "number" && Number.isFinite(v.value) + : v.kind === "boolean" + ? typeof v.value === "boolean" + : v.kind === "string" + ? typeof v.value === "string" + : v.kind === "vector" + ? Array.isArray(v.value) && + v.value.length === 3 && + v.value.every((x) => typeof x === "number" && Number.isFinite(x)) + : v.kind === "color" + ? Array.isArray(v.value) && + v.value.length === 4 && + v.value.every((x) => typeof x === "number" && Number.isFinite(x)) + : v.kind === "json" && isJson(v.value)); +const graphLink = (v: unknown): v is GraphLink => + record(v) && + exact(v, ["id", "fromNodeId", "fromSocketId", "toNodeId", "toSocketId", "muted", "extensions"]) && + id(v.id) && + id(v.fromNodeId) && + id(v.fromSocketId) && + id(v.toNodeId) && + id(v.toSocketId) && + typeof v.muted === "boolean" && + record(v.extensions) && + isJson(v.extensions); +function validCommandUnsafe(v: unknown, nested = false): v is Command { + if (!record(v) || typeof v.type !== "string") return false; + if (v.type === "batch") + return ( + !nested && + exact(v, ["type", "commands"]) && + Array.isArray(v.commands) && + v.commands.length <= FXNODE_BATCH_LIMIT && + v.commands.every( + (item) => + validCommandUnsafe(item, true) && + [ + "node.move", + "node.resize", + "node.remove", + "node.mute", + "node.collapse", + "node.parent", + "link.remove", + "link.mute", + ].includes((item as { type: string }).type), + ) + ); + if (v.type === "undo" || v.type === "redo") return !nested && exact(v, ["type"]); + if (v.type === "node.remove" || v.type === "link.remove") return exact(v, ["type", "id"]) && id(v.id); + if (v.type === "node.move" || v.type === "node.resize") + return ( + exact(v, ["type", "id", v.type === "node.move" ? "position" : "size"]) && + id(v.id) && + finitePoint(v[v.type === "node.move" ? "position" : "size"]) + ); + if (v.type === "node.mute" || v.type === "node.collapse" || v.type === "link.mute") + return exact(v, ["type", "id", "value"]) && id(v.id) && typeof v.value === "boolean"; + if (v.type === "node.parent") + return exact(v, ["type", "id", "parentId"]) && id(v.id) && (v.parentId === null || id(v.parentId)); + if (nested) return false; + if (v.type === "node.label") return exact(v, ["type", "id", "label"]) && id(v.id) && typeof v.label === "string"; + if (v.type === "node.add") + return ( + Object.keys(v).every((k) => ["type", "nodeId", "nodeType", "position", "parentId"].includes(k)) && + ["type", "nodeId", "nodeType", "position"].every((k) => Object.hasOwn(v, k)) && + id(v.nodeId) && + id(v.nodeType) && + finitePoint(v.position) && + (v.parentId === undefined || id(v.parentId)) + ); + if (v.type === "link.add") return exact(v, ["type", "link"]) && record(v.link); + if (v.type === "link.replace") return exact(v, ["type", "removeId", "link"]) && id(v.removeId) && record(v.link); + if (v.type === "node.parameter") + return exact(v, ["type", "id", "key", "value"]) && id(v.id) && typeof v.key === "string" && record(v.value); + if (v.type === "node.parameter-reset") + return exact(v, ["type", "id", "key"]) && id(v.id) && typeof v.key === "string"; + if (v.type === "node.socket-default-reset") return exact(v, ["type", "id", "socketId"]) && id(v.id) && id(v.socketId); + return ( + v.type === "node.socket-default" && + exact(v, ["type", "id", "socketId", "value"]) && + id(v.id) && + id(v.socketId) && + record(v.value) + ); +} +/** Total across hostile objects (including proxies with throwing traps). */ +export function validCommand(v: unknown): v is Command { + try { + return validCommandUnsafe(v); + } catch { + return false; + } +} +export type ReplayNodeTypeAuthority = + | { readonly has: (id: NodeTypeId) => boolean } + | ((id: NodeTypeId) => boolean); +/** Total, strict forward-only persisted-command validator. Live wire validation remains deliberately compatible. */ +export function validFxNodeReplayCommand( + v: unknown, + nodeTypes?: ReplayNodeTypeAuthority, +): v is FxNodeReplayCommand { + try { + if (!validCommandUnsafe(v) || v.type === "undo" || v.type === "redo") return false; + if (v.type === "node.add") + return ( + nodeTypeId(v.nodeType) && + (!nodeTypes || + (typeof nodeTypes === "function" + ? nodeTypes(v.nodeType as NodeTypeId) + : nodeTypes.has(v.nodeType as NodeTypeId))) + ); + if (v.type === "link.add" || v.type === "link.replace") return graphLink(v.link); + if (v.type === "node.parameter" || v.type === "node.socket-default") return parameter(v.value); + return true; + } catch { + return false; + } +} diff --git a/vendor/fxnode/src/composition/bound-document.ts b/vendor/fxnode/src/composition/bound-document.ts new file mode 100644 index 0000000..09921b6 --- /dev/null +++ b/vendor/fxnode/src/composition/bound-document.ts @@ -0,0 +1,845 @@ +import { + admitStructuredData, + canonicalJsonEqual, + canonicalize, + cloneJson, + deepFreeze, + isJson, + isRecord, + nullRecord, +} from "../core/json.js"; +import { + graphId, + linkId, + nodeId, + socketId, + type GraphDocument, + type GraphLayoutV2, + type GraphLink, + type GraphNode, + type GraphState, + type JsonValue, + type ParameterValue, + type Socket, +} from "../core/types.js"; +import { migrateColorRamp } from "../widgets/color-ramp.js"; +import type { + CompiledFxNodeComposition, + FxNodeCompositionData, + FxNodeDefinition, + FxNodeMigrationStep, + NodeTypeId, +} from "./types.js"; +import { matchesFxNodeValueSchema } from "./value-matcher.js"; +import { initialNodeSize } from "../layout/node-dimensions.js"; + +/** A persistence or graph validation problem, with a stable code and JSON-pointer path. */ +export interface ValidationIssue { + readonly code: string; + readonly path: string; + readonly message: string; +} +/** Result of decoding durable graph data under a compiled composition. */ +export type DecodeResult = + | { readonly ok: true; readonly value: GraphDocument } + | { readonly ok: false; readonly issues: readonly ValidationIssue[] }; + +type BoundValidationIssue = ValidationIssue; +type BoundDecodeResult = DecodeResult; + +export function graphStateFromDocument(document: GraphDocument): GraphState { + return deepFreeze({ + graphId: document.graphId, + catalogVersion: document.catalogVersion, + nodes: Object.values(document.nodes) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)), + links: Object.values(document.links) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)), + metadata: document.metadata, + }); +} +export const PERSISTENCE_LIMITS = Object.freeze({ + maxIssues: 100, + maxNodes: 10_000, + maxLinks: 20_000, + maxSocketsPerNode: 256, + maxParametersPerNode: 512, + maxValues: 2_000_000, + maxStringCodeUnits: 8_388_608, + maxDepth: 50, + maxMigrationExecutions: 1_000_000, +}); +const finite = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); +const validString = (v: unknown, max = 512): v is string => + typeof v === "string" && v.length > 0 && v.length <= max && !/[\u0000-\u001f\u007f]/u.test(v); +const forbidden = new Set([ + "selection", + "selected", + "camera", + "hover", + "hovered", + "runtimeVersion", + "history", + "undo", + "redo", + "session", + "layout", +]); +const pointer = (...p: string[]) => `/${p.map((x) => x.replaceAll("~", "~0").replaceAll("/", "~1")).join("/")}`; +const parameter = (v: unknown): v is ParameterValue => + isRecord(v) && + Object.keys(v).every((k) => k === "kind" || k === "value") && + (v.kind === "number" + ? finite(v.value) + : v.kind === "boolean" + ? typeof v.value === "boolean" + : v.kind === "string" + ? typeof v.value === "string" + : v.kind === "vector" + ? Array.isArray(v.value) && v.value.length === 3 && v.value.every(finite) + : v.kind === "color" + ? Array.isArray(v.value) && v.value.length === 4 && v.value.every(finite) + : v.kind === "json" && isJson(v.value)); + +const admit = (input: unknown) => admitStructuredData(input, PERSISTENCE_LIMITS); +const STATE_ADMISSION_LIMITS = { + ...PERSISTENCE_LIMITS, + maxValues: PERSISTENCE_LIMITS.maxValues + PERSISTENCE_LIMITS.maxNodes + 1, + maxStringCodeUnits: + PERSISTENCE_LIMITS.maxStringCodeUnits + PERSISTENCE_LIMITS.maxNodes * "known".length + "version".length, +}; +function transient(v: unknown, path: string, out: BoundValidationIssue[], depth = 0): void { + if (depth > 50 || out.length >= 100) return; + if (Array.isArray(v)) { + v.forEach((x, i) => transient(x, `${path}/${i}`, out, depth + 1)); + return; + } + if (!isRecord(v)) return; + for (const [k, x] of Object.entries(v)) { + if (forbidden.has(k)) + out.push({ + code: "field.transient", + path: `${path}/${k}`, + message: "Transient field is forbidden", + }); + transient(x, `${path}/${k}`, out, depth + 1); + } +} + +/** Creates document operations whose sole definition authority is one compiled composition. */ +export function bindDocument(compiled: CompiledFxNodeComposition) { + const wildcards = new Set(compiled.compatibility.wildcardInputTypes), + definition = (id: string) => + (compiled.nodes as { get(key: string): unknown }).get(id) as + | (FxNodeDefinition & { readonly typeId: NodeTypeId }) + | undefined; + const accepts = (s: { direction: string; type: string }) => + s.direction === "input" + ? (( + compiled.socketTypes as { + get(key: string): { acceptsFrom: readonly string[] } | undefined; + } + ).get(s.type)?.acceptsFrom ?? []) + : []; + const socketFor = (id: string, key: string, s: FxNodeDefinition["sockets"][string]): Socket => + deepFreeze({ + id: socketId(`${id}:${key}`), + key, + label: s.title, + direction: s.direction, + dataType: s.type, + accepts: cloneJson(accepts(s)), + maxIncomingLinks: s.maxIncomingLinks, + visible: s.visible, + ...(s.value ? { defaultValue: cloneJson(s.value.default) } : {}), + }); + const materializeNode = ( + id: string, + typeId: NodeTypeId, + position = { x: 0, y: 0 }, + parentId?: string, + ): GraphNode => { + const d = definition(typeId); + if (!d) throw new TypeError(`Unknown composition type: ${typeId}`); + const parameters = nullRecord(Object.entries(d.parameters).map(([k, s]) => [k, cloneJson(s.default)])); + const sockets = Object.entries(d.sockets).map(([k, s]) => socketFor(id, k, s)); + const materialized: Pick = { label: d.title, parameters, sockets }; + return deepFreeze({ + id: nodeId(id), + typeId, + typeVersion: d.version, + known: true, + position: cloneJson(position), + size: initialNodeSize(d, materialized), + label: d.title, + parameters, + sockets: deepFreeze(sockets), + muted: false, + collapsed: false, + ...(parentId === undefined ? {} : { parentId: nodeId(parentId) }), + extensions: nullRecord(), + }) as GraphNode; + }; + const exact = (node: GraphNode, d: FxNodeDefinition): boolean => { + const exactKeys = (value: object, allowed: readonly string[]) => { + const keys = Object.keys(value); + return keys.length === allowed.length && keys.every((key) => allowed.includes(key)); + }; + if ( + !exactKeys(node, [ + "id", + "typeId", + "typeVersion", + "known", + "position", + "size", + "label", + "parameters", + "sockets", + "muted", + "collapsed", + ...(Object.hasOwn(node, "parentId") ? ["parentId"] : []), + "extensions", + ]) || + !exactKeys(node.position, ["x", "y"]) || + !exactKeys(node.size, ["x", "y"]) + ) + return false; + const ps = Object.entries(d.parameters); + if ( + Object.keys(node.parameters).length !== ps.length || + !ps.every(([k, s]) => { + const value = node.parameters[k]; + return isRecord(value) && exactKeys(value, ["kind", "value"]) && matchesFxNodeValueSchema(s, value); + }) + ) + return false; + const ss = Object.entries(d.sockets); + return ( + node.sockets.length === ss.length && + ss.every(([key, s], i) => { + const a = node.sockets[i]; + return ( + !!a && + a.id === `${node.id}:${key}` && + a.key === key && + a.label === s.title && + a.direction === s.direction && + a.dataType === s.type && + a.maxIncomingLinks === s.maxIncomingLinks && + a.visible === s.visible && + JSON.stringify(a.accepts) === JSON.stringify(accepts(s)) && + exactKeys(a, [ + "id", + "key", + "label", + "direction", + "dataType", + "accepts", + "maxIncomingLinks", + ...(s.value ? ["defaultValue"] : []), + "visible", + ]) && + (s.value + ? isRecord(a.defaultValue) && + exactKeys(a.defaultValue, ["kind", "value"]) && + matchesFxNodeValueSchema(s.value, a.defaultValue) + : a.defaultValue === undefined) + ); + }) + ); + }; + const compatible = ( + from: Pick, + to: Pick, + ) => + from.direction === "output" && + to.direction === "input" && + (wildcards.has(to.dataType) || to.accepts.includes(from.dataType)); + const validateDocument = (document: GraphDocument): readonly BoundValidationIssue[] => { + const issues: BoundValidationIssue[] = [], + incoming = new Map(); + for (const l of Object.values(document.links)) + if (!l.muted) incoming.set(l.toSocketId, (incoming.get(l.toSocketId) ?? 0) + 1); + if (document.catalogVersion !== compiled.version) + issues.push({ + code: "catalog.version", + path: "/catalogVersion", + message: "Document version does not match composition", + }); + for (const [key, n] of Object.entries(document.nodes)) { + if (key !== n.id) + issues.push({ + code: "id.mismatch", + path: pointer("nodes", key), + message: "Node key and id differ", + }); + if (![n.position.x, n.position.y, n.size.x, n.size.y].every(finite) || n.size.x <= 0 || n.size.y <= 0) + issues.push({ + code: "number.invalid", + path: pointer("nodes", key), + message: "Invalid geometry", + }); + if (n.known) { + const d = definition(n.typeId); + if (!d || n.typeVersion !== d.version) + issues.push({ + code: "catalog.version", + path: pointer("nodes", key), + message: "Unsupported definition version", + }); + else if (!exact(n, d)) + issues.push({ + code: "catalog.invalid", + path: pointer("nodes", key), + message: "Known node does not exactly match definition", + }); + } + if (n.parentId) { + const p = document.nodes[n.parentId]; + if (!p || (p.known && definition(p.typeId)?.behavior !== "frame")) + issues.push({ + code: "parent.frame", + path: pointer("nodes", key, "parentId"), + message: "Parent must be a frame", + }); + const seen = new Set([n.id]); + let q = p; + while (q) { + if (seen.has(q.id)) { + issues.push({ + code: "parent.cycle", + path: pointer("nodes", key, "parentId"), + message: "Parent cycle", + }); + break; + } + seen.add(q.id); + q = q.parentId ? document.nodes[q.parentId] : undefined; + } + } + } + for (const [key, l] of Object.entries(document.links)) { + if (key !== l.id) + issues.push({ + code: "id.mismatch", + path: pointer("links", key), + message: "Link key and id differ", + }); + const from = document.nodes[l.fromNodeId]?.sockets.find((s) => s.id === l.fromSocketId), + to = document.nodes[l.toNodeId]?.sockets.find((s) => s.id === l.toSocketId); + if (!from || !to) { + issues.push({ + code: "link.endpoint", + path: pointer("links", key), + message: "Endpoint does not exist", + }); + continue; + } + if (!compatible(from, to)) + issues.push({ + code: "link.type", + path: pointer("links", key), + message: "Incompatible socket endpoints", + }); + if ((incoming.get(to.id) ?? 0) > to.maxIncomingLinks) + issues.push({ + code: "link.limit", + path: pointer("links", key), + message: "Too many incoming links", + }); + } + return deepFreeze(issues.slice(0, 100)); + }; + const decodeAdmittedGraphDocumentUnsafe = (source: unknown): BoundDecodeResult => { + let input = source; + const issues: BoundValidationIssue[] = []; + if (isRecord(input) && Number.isSafeInteger(input.schemaVersion) && Number(input.schemaVersion) > 2) + return { + ok: false, + issues: [ + { + code: "schema.future", + path: "/schemaVersion", + message: "Unsupported future schema version", + }, + ], + }; + if (isRecord(input) && input.schemaVersion === 1) + input = { + ...input, + schemaVersion: 2, + links: Array.isArray(input.links) + ? input.links.map((x) => (isRecord(x) ? { ...x, muted: false } : x)) + : input.links, + }; + if (isRecord(input)) for (const [k, v] of Object.entries(input)) if (k !== "nodes") transient(v, `/${k}`, issues); + if ( + !isRecord(input) || + input.schemaVersion !== 2 || + !validString(input.graphId) || + !Number.isSafeInteger(input.catalogVersion) || + Number(input.catalogVersion) <= 0 || + !Array.isArray(input.nodes) || + !Array.isArray(input.links) || + !isRecord(input.metadata) || + !isJson(input.metadata) + ) + return { + ok: false, + issues: [...issues, { code: "decode.shape", path: "/", message: "Invalid GraphLayoutV2" }], + }; + if (input.nodes.length > PERSISTENCE_LIMITS.maxNodes || input.links.length > PERSISTENCE_LIMITS.maxLinks) + return { + ok: false, + issues: [ + { + code: "limit.collection", + path: input.nodes.length > PERSISTENCE_LIMITS.maxNodes ? "/nodes" : "/links", + message: "Document collection limit exceeded", + }, + ], + }; + const rawLinks = input.links as unknown[], + nodes = new Map>(), + staged = new Map(); + let executions = 0; + for (let i = 0; i < input.nodes.length; i++) { + const original = input.nodes[i]; + if ( + !isRecord(original) || + !validString(original.id) || + !validString(original.typeId, 128) || + !Number.isSafeInteger(original.typeVersion) || + Number(original.typeVersion) <= 0 || + !isRecord(original.position) || + !finite(original.position.x) || + !finite(original.position.y) || + !isRecord(original.size) || + !finite(original.size.x) || + !finite(original.size.y) || + Number(original.size.x) <= 0 || + Number(original.size.y) <= 0 || + typeof original.label !== "string" || + original.label.length > 512 || + !isRecord(original.parameters) || + !isJson(original.parameters) || + Object.keys(original.parameters).length > PERSISTENCE_LIMITS.maxParametersPerNode || + !Array.isArray(original.sockets) || + original.sockets.length > PERSISTENCE_LIMITS.maxSocketsPerNode || + typeof original.muted !== "boolean" || + typeof original.collapsed !== "boolean" || + (original.parentId !== undefined && !validString(original.parentId)) || + !isRecord(original.extensions) || + !isJson(original.extensions) || + Object.hasOwn(original, "known") + ) { + issues.push({ + code: "decode.node", + path: pointer("nodes", String(i)), + message: "Invalid node", + }); + continue; + } + if (nodes.has(original.id)) { + issues.push({ + code: "id.duplicate", + path: pointer("nodes", String(i), "id"), + message: "Duplicate node id", + }); + continue; + } + const parseSockets = (r: Record) => { + const out: Socket[] = [], + ids = new Set(); + for (const x of r.sockets as unknown[]) { + if ( + !isRecord(x) || + !validString(x.id) || + !validString(x.key, 128) || + typeof x.label !== "string" || + x.label.length > 512 || + (x.direction !== "input" && x.direction !== "output") || + !validString(x.dataType, 128) || + !Array.isArray(x.accepts) || + !x.accepts.every((a) => validString(a, 128)) || + new Set(x.accepts).size !== x.accepts.length || + !Number.isSafeInteger(x.maxIncomingLinks) || + Number(x.maxIncomingLinks) < 0 || + typeof x.visible !== "boolean" || + (x.defaultValue !== undefined && x.defaultValue !== null && !parameter(x.defaultValue)) || + (x.metadata !== undefined && (!isRecord(x.metadata) || !isJson(x.metadata))) || + ids.has(x.id) + ) + return; + ids.add(x.id); + out.push(x as unknown as Socket); + } + return out; + }; + const originalSockets = parseSockets(original); + if (!originalSockets) { + issues.push({ + code: "decode.socket", + path: pointer("nodes", String(i), "sockets"), + message: "Invalid socket", + }); + continue; + } + let draft = structuredClone(original), + rewrites: { oldId: string; newId: string }[] = [], + soft = false; + const d = definition(original.typeId); + if (d && Number(original.typeVersion) < d.version) { + const byFrom = new Map(d.migrations.map((m) => [m.fromVersion, m])); + while (Number(draft.typeVersion) < d.version) { + const edge = byFrom.get(Number(draft.typeVersion)); + if (!edge) { + soft = true; + break; + } + const next = structuredClone(draft), + edgeRewrites: { oldId: string; newId: string }[] = []; + for (const step of edge.steps) { + if (++executions > PERSISTENCE_LIMITS.maxMigrationExecutions) + return { + ok: false, + issues: [ + { + code: "limit.migrations", + path: "/nodes", + message: "Migration execution limit exceeded", + }, + ], + }; + if (!run(step, next, d, edgeRewrites)) { + soft = true; + break; + } + } + if (soft) break; + next.typeVersion = edge.toVersion; + draft = next; + rewrites.push(...edgeRewrites); + } + } + if (soft) { + draft = structuredClone(original); + rewrites = []; + } + const sockets = parseSockets(draft); + if (!sockets) { + issues.push({ + code: "decode.socket", + path: pointer("nodes", String(i), "sockets"), + message: "Invalid socket", + }); + continue; + } + const base = { + ...draft, + id: nodeId(String(draft.id)), + typeId: String(draft.typeId), + typeVersion: Number(draft.typeVersion), + sockets, + ...(typeof draft.parentId === "string" ? { parentId: nodeId(draft.parentId) } : {}), + }; + let n = cloneJson({ ...base, known: false }) as unknown as GraphNode; + if (d && !soft && Number(draft.typeVersion) === d.version) { + transient(draft, pointer("nodes", String(i)), issues); + const candidate = cloneJson({ + ...base, + known: true, + typeId: d.typeId, + }) as unknown as GraphNode; + if (exact(candidate, d)) n = candidate; + else if (Number(original.typeVersion) === d.version) { + issues.push({ + code: "catalog.invalid", + path: pointer("nodes", String(i)), + message: "Known node does not exactly match definition", + }); + continue; + } else { + n = cloneJson({ + ...original, + sockets: originalSockets, + known: false, + }) as unknown as GraphNode; + rewrites = []; + } + } else rewrites = []; + nodes.set(String(original.id), n); + if (n.known && rewrites.length) staged.set(n.id, rewrites); + } + const linkCandidate = structuredClone(rawLinks); + for (const r of linkCandidate) + if (isRecord(r)) { + for (const rw of staged.get(String(r.fromNodeId)) ?? []) + if (r.fromSocketId === rw.oldId) r.fromSocketId = rw.newId; + for (const rw of staged.get(String(r.toNodeId)) ?? []) if (r.toSocketId === rw.oldId) r.toSocketId = rw.newId; + } + const links = new Map(); + for (const r of linkCandidate) { + if ( + !isRecord(r) || + !validString(r.id) || + !validString(r.fromNodeId) || + !validString(r.fromSocketId) || + !validString(r.toNodeId) || + !validString(r.toSocketId) || + typeof r.muted !== "boolean" || + !isRecord(r.extensions) || + !isJson(r.extensions) || + links.has(r.id) + ) { + issues.push({ + code: "decode.link", + path: "/links", + message: "Invalid link", + }); + continue; + } + links.set( + r.id, + cloneJson({ + ...r, + id: linkId(r.id), + fromNodeId: nodeId(r.fromNodeId), + fromSocketId: socketId(r.fromSocketId), + toNodeId: nodeId(r.toNodeId), + toSocketId: socketId(r.toSocketId), + }) as GraphLink, + ); + } + if (issues.length) return { ok: false, issues: deepFreeze(issues.slice(0, 100)) }; + const document = deepFreeze({ + schemaVersion: 2 as const, + graphId: graphId(input.graphId), + catalogVersion: compiled.version, + nodes: nullRecord(nodes), + links: nullRecord(links), + metadata: cloneJson(input.metadata) as Readonly>, + }); + const semantic = validateDocument(document); + return semantic.length ? { ok: false, issues: semantic } : { ok: true, value: document }; + function run( + step: FxNodeMigrationStep, + draft: Record, + d: FxNodeDefinition, + rewrites: { oldId: string; newId: string }[], + ): boolean { + if (!isRecord(draft.parameters) || !Array.isArray(draft.sockets) || !draft.sockets.every(isRecord)) return false; + const params = draft.parameters, + sockets = draft.sockets; + if (step.kind === "materialize-missing" && step.target === "parameter") { + const def = d.parameters[step.key]; + if (!def) return false; + if (!Object.hasOwn(params, step.key)) params[step.key] = structuredClone(def.default); + return true; + } + if (step.kind === "materialize-missing") { + if (sockets.some((s) => s.key === step.key)) return true; + const id = `${String(draft.id)}:${step.key}`; + if (sockets.some((s) => s.id === id)) return false; + const def = d.sockets[step.key]; + if (!def) return false; + const ordinal = Object.keys(d.sockets).indexOf(step.key), + created = structuredClone(socketFor(String(draft.id), step.key, def)); + sockets.splice(Math.min(ordinal, sockets.length), 0, created as unknown as Record); + return true; + } + if (step.kind === "migrate-parameter") { + const migrated = migrateColorRamp(params[step.parameter]); + if (!migrated) return false; + params[step.parameter] = { + kind: "json", + value: structuredClone(migrated), + }; + return true; + } + if (step.kind === "rename-parameter") { + if (!Object.hasOwn(params, step.from) || Object.hasOwn(params, step.to)) return false; + params[step.to] = params[step.from]; + delete params[step.from]; + return true; + } + const matches = sockets.filter((s) => s.key === step.from); + if (matches.length !== 1 || sockets.some((s) => s.key === step.to)) return false; + const old = matches[0]!, + newId = `${String(draft.id)}:${step.to}`; + if (sockets.some((s) => s !== old && s.id === newId)) return false; + for (const l of rawLinks) + if ( + isRecord(l) && + ((l.fromNodeId === draft.id && l.fromSocketId === newId) || + (l.toNodeId === draft.id && l.toSocketId === newId)) + ) + return false; + if (!validString(old.id)) return false; + const oldId = old.id; + old.key = step.to; + old.id = newId; + rewrites.push({ oldId, newId }); + return true; + } + }; + const decodeGraphDocumentUnsafe = (source: unknown): BoundDecodeResult => { + const admitted = admit(source); + return admitted.ok + ? decodeAdmittedGraphDocumentUnsafe(admitted.value) + : { ok: false, issues: deepFreeze(admitted.issues) }; + }; + const decodeGraphDocument = (source: unknown): BoundDecodeResult => { + try { + return decodeGraphDocumentUnsafe(source); + } catch { + return { + ok: false, + issues: [ + { + code: "decode.failure", + path: "/", + message: "Document could not be decoded", + }, + ], + }; + } + }; + const decodeGraphState = (source: unknown): BoundDecodeResult => { + try { + const admitted = admitStructuredData(source, STATE_ADMISSION_LIMITS); + if (!admitted.ok) return { ok: false, issues: deepFreeze(admitted.issues) }; + const input = admitted.value; + const shape = () => ({ + ok: false as const, + issues: deepFreeze([{ code: "state.shape", path: "/", message: "Invalid GraphState" }]), + }); + if (!isRecord(input)) return shape(); + const keys = Object.keys(input), + allowed = ["version", "graphId", "catalogVersion", "nodes", "links", "metadata"]; + if ( + !["graphId", "catalogVersion", "nodes", "links", "metadata"].every((k) => Object.hasOwn(input, k)) || + keys.some((k) => !allowed.includes(k)) || + !validString(input.graphId) || + !Number.isSafeInteger(input.catalogVersion) || + Number(input.catalogVersion) <= 0 || + !Array.isArray(input.nodes) || + !Array.isArray(input.links) || + !isRecord(input.metadata) || + !isJson(input.metadata) + ) + return shape(); + if (input.nodes.length > PERSISTENCE_LIMITS.maxNodes) + return { + ok: false, + issues: deepFreeze([{ code: "limit.nodes", path: "/nodes", message: "Node limit exceeded" }]), + }; + if (input.links.length > PERSISTENCE_LIMITS.maxLinks) + return { + ok: false, + issues: deepFreeze([{ code: "limit.links", path: "/links", message: "Link limit exceeded" }]), + }; + const hasVersion = Object.hasOwn(input, "version"), + durableValues = admitted.metrics.values - input.nodes.length - (hasVersion ? 1 : 0) + 1, + durableStrings = + admitted.metrics.stringCodeUnits - + input.nodes.length * "known".length - + (hasVersion ? "version".length : 0) + + "schemaVersion".length; + if (durableValues > PERSISTENCE_LIMITS.maxValues) + return { + ok: false, + issues: deepFreeze([{ code: "limit.values", path: "/", message: "Document exceeds inspected value limit" }]), + }; + if (durableStrings > PERSISTENCE_LIMITS.maxStringCodeUnits) + return { + ok: false, + issues: deepFreeze([{ code: "limit.strings", path: "/", message: "Document exceeds string limit" }]), + }; + const nodes: unknown[] = []; + for (const node of input.nodes) { + if (!isRecord(node) || !Object.hasOwn(node, "known") || typeof node.known !== "boolean") + return { + ok: false, + issues: deepFreeze([ + { code: "state.inexact", path: "/nodes", message: "GraphState is not exact for this composition" }, + ]), + }; + const { known: _, ...durable } = node; + nodes.push(durable); + } + const requested = { + graphId: input.graphId, + catalogVersion: input.catalogVersion, + nodes: input.nodes, + links: input.links, + metadata: input.metadata, + }; + const decoded = decodeAdmittedGraphDocumentUnsafe({ + schemaVersion: 2, + graphId: input.graphId, + catalogVersion: input.catalogVersion, + nodes, + links: input.links, + metadata: input.metadata, + }); + if (!decoded.ok) return decoded; + return canonicalJsonEqual(requested, graphStateFromDocument(decoded.value)) + ? decoded + : { + ok: false, + issues: deepFreeze([ + { code: "state.inexact", path: "/", message: "GraphState is not exact for this composition" }, + ]), + }; + } catch { + return { ok: false, issues: [{ code: "state.shape", path: "/", message: "Invalid GraphState" }] }; + } + }; + const save = (document: GraphDocument): GraphLayoutV2 => + deepFreeze({ + schemaVersion: 2, + graphId: document.graphId, + catalogVersion: document.catalogVersion, + nodes: Object.values(document.nodes) + .map(({ known: _, parentId, ...node }) => ({ ...node, ...(parentId === undefined ? {} : { parentId }) })) + .sort((a, b) => a.id.localeCompare(b.id)), + links: Object.values(document.links) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)), + metadata: document.metadata, + }); + const serializeGraphDocument = (d: GraphDocument) => JSON.stringify(canonicalize(save(d))); + const parseGraphDocument = (text: string): BoundDecodeResult => { + try { + return decodeGraphDocument(JSON.parse(text)); + } catch { + return { + ok: false, + issues: [{ code: "decode.json", path: "/", message: "Invalid JSON" }], + }; + } + }; + return Object.freeze({ + emptyDocument: (id = "graph"): GraphDocument => + deepFreeze({ + schemaVersion: 2, + graphId: graphId(id), + catalogVersion: compiled.version, + nodes: nullRecord(), + links: nullRecord(), + metadata: nullRecord(), + }), + materializeNode, + validateDocument, + decodeGraphDocument, + decodeGraphState, + parseGraphDocument, + save, + serializeGraphDocument, + socketsCompatible: compatible, + }); +} diff --git a/vendor/fxnode/src/composition/bound-engine.ts b/vendor/fxnode/src/composition/bound-engine.ts new file mode 100644 index 0000000..572e05e --- /dev/null +++ b/vendor/fxnode/src/composition/bound-engine.ts @@ -0,0 +1,597 @@ +import type { + Command, + CommandError, + CommandRequest, + CompatibleFxNodeSaveData, + FxNodeReplayCommand, +} from "../commands/types.js"; +import { decodeFxNodeSaveData } from "../commands/save-data.js"; +import { validFxNodeReplayCommand } from "../commands/validate.js"; +import { FXNODE_BATCH_LIMIT } from "../commands/limits.js"; +import { canonicalJsonEqual, cloneJson, deepFreeze } from "../core/json.js"; +import type { CommandId, GraphDocument, GraphLayoutV2, GraphNode, GraphSnapshot, GraphState } from "../core/types.js"; +import { invert, type Mutation } from "../engine/mutations.js"; +import { reduceMutations } from "../engine/reducer.js"; +import { + bindDocument, + graphStateFromDocument, + type DecodeResult as BoundDecodeResult, + type ValidationIssue as BoundValidationIssue, +} from "./bound-document.js"; +import type { CompiledFxNodeComposition, FxNodeCompositionData, FxNodeDefinition } from "./types.js"; +import { matchesFxNodeValueSchema } from "./value-matcher.js"; + +/** Immutable engine state owned by one composition-bound headless runtime. */ +export interface EngineState { + readonly version: number; + readonly document: GraphDocument; + readonly undo: readonly { readonly forward: readonly Mutation[]; readonly inverse: readonly Mutation[] }[]; + readonly redo: readonly { readonly forward: readonly Mutation[]; readonly inverse: readonly Mutation[] }[]; + readonly historyLimit: number; +} +/** A committed, versioned mutation batch. */ +export interface MutationEnvelope { + readonly baseVersion: number; + readonly version: number; + readonly commandId: CommandId; + readonly cause: "api" | "gesture" | "undo" | "redo" | "load" | "composition"; + readonly mutations: readonly Mutation[]; +} +/** A versioned immutable graph snapshot. */ +export interface SnapshotEnvelope { + readonly version: number; + readonly snapshot: GraphSnapshot; +} +/** Result of applying a command or state replacement. */ +export type TransitionResult = + | { + readonly status: "committed"; + readonly state: EngineState; + readonly mutationEnvelope: MutationEnvelope; + readonly snapshotEnvelope: SnapshotEnvelope; + } + | { readonly status: "noop"; readonly state: EngineState } + | { readonly status: "rejected"; readonly state: EngineState; readonly error: CommandError }; +export interface StateReplacementRequest { + readonly commandId: CommandId; + readonly expectedVersion: number; + readonly target: GraphState; +} +/** Result of loading durable graph data. */ +export type LoadResult = + | { + readonly ok: true; + readonly state: EngineState; + readonly mutationEnvelope: MutationEnvelope; + readonly snapshotEnvelope: SnapshotEnvelope; + } + | { readonly ok: false; readonly state: EngineState; readonly issues: readonly BoundValidationIssue[] }; +export type ReplayResult = + | { + readonly ok: true; + readonly status: "committed"; + readonly state: EngineState; + readonly mutationEnvelope: MutationEnvelope; + readonly snapshotEnvelope: SnapshotEnvelope; + readonly saveData: CompatibleFxNodeSaveData; + } + | { + readonly ok: true; + readonly status: "noop"; + readonly state: EngineState; + readonly saveData: CompatibleFxNodeSaveData; + } + | { readonly ok: false; readonly state: EngineState; readonly issues: readonly BoundValidationIssue[] }; +type BoundEngineState = EngineState; +type BoundMutationEnvelope = MutationEnvelope; +type BoundSnapshotEnvelope = SnapshotEnvelope; +type BoundTransitionResult = TransitionResult; +type BoundStateReplacementRequest = StateReplacementRequest; +type BoundLoadResult = LoadResult; +type BoundReplayResult = ReplayResult; +const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b), + reject = (code: string, message: string): CommandError => ({ code, message }); +function snapshotState(state: BoundEngineState): GraphSnapshot { + return deepFreeze({ version: state.version, ...graphStateFromDocument(state.document) }); +} +export interface BoundRebindCodec { + readonly save: (document: GraphDocument) => GraphLayoutV2; + readonly decodeGraphDocument: (value: unknown) => BoundDecodeResult; +} +export interface BoundAuthorityRebindOptions { + readonly commandId: CommandId; + readonly removedNodeTypes?: ReadonlySet; +} +export type BoundAuthorityRebindResult = + | { readonly ok: false; readonly state: BoundEngineState; readonly issues: readonly BoundValidationIssue[] } + | { readonly ok: true; readonly graphChanged: false; readonly state: BoundEngineState } + | { + readonly ok: true; + readonly graphChanged: true; + readonly state: BoundEngineState; + readonly mutationEnvelope: BoundMutationEnvelope; + readonly snapshotEnvelope: BoundSnapshotEnvelope; + }; +const pointerToken = (value: string) => value.replaceAll("~", "~0").replaceAll("/", "~1"); +export function rebindBoundEngineAuthority( + state: BoundEngineState, + current: BoundRebindCodec, + candidate: BoundRebindCodec, + options: BoundAuthorityRebindOptions, +): BoundAuthorityRebindResult { + const decoded = candidate.decodeGraphDocument(current.save(state.document)); + if (!decoded.ok) return { ok: false, state, issues: decoded.issues }; + const document = decoded.value, + demotions: BoundValidationIssue[] = []; + for (const before of Object.values(state.document.nodes)) { + if (!before.known) continue; + const after = document.nodes[before.id]; + const explicitlyRemoved = + options.removedNodeTypes?.has(before.typeId) === true && after?.known === false && after.typeId === before.typeId; + if (!after?.known && !explicitlyRemoved && demotions.length < 100) + demotions.push({ + code: "composition.node-demotion", + path: `/nodes/${pointerToken(before.id)}/known`, + message: `Known node type "${before.typeId}" would become unknown`, + }); + } + if (demotions.length) return { ok: false, state, issues: deepFreeze(demotions) }; + const graphChanged = !canonicalJsonEqual(state.document, document); + if (graphChanged && state.version >= Number.MAX_SAFE_INTEGER) + return { + ok: false, + state, + issues: deepFreeze([{ code: "version.overflow", path: "/", message: "Version exhausted" }]), + }; + const next: BoundEngineState = deepFreeze({ + version: graphChanged ? state.version + 1 : state.version, + document, + undo: [], + redo: [], + historyLimit: state.historyLimit, + }); + if (!graphChanged) return { ok: true, graphChanged: false, state: next }; + const mutationEnvelope: BoundMutationEnvelope = deepFreeze({ + baseVersion: state.version, + version: next.version, + commandId: options.commandId, + cause: "composition", + mutations: [{ kind: "document.replaced", before: state.document, after: document }], + }); + return { + ok: true, + graphChanged: true, + state: next, + mutationEnvelope, + snapshotEnvelope: deepFreeze({ version: next.version, snapshot: snapshotState(next) }), + }; +} + +/** Creates engine operations permanently closed over one compiled composition. */ +export function bindEngine(compiled: CompiledFxNodeComposition) { + const docs = bindDocument(compiled), + definition = (id: string) => + (compiled.nodes as { get(key: string): unknown }).get(id) as FxNodeDefinition | undefined; + const snapshot = (s: BoundEngineState): GraphSnapshot => snapshotState(s); + const persisted = (document: GraphDocument) => docs.decodeGraphDocument(docs.save(document)); + const createEngine = (document: GraphDocument, historyLimit = 100): BoundEngineState => { + if (!Number.isSafeInteger(historyLimit) || historyLimit < 0) + throw new RangeError("historyLimit must be a finite nonnegative integer"); + const issue = docs.validateDocument(document)[0]; + if (issue) throw new TypeError(`Invalid initial document: ${issue.code}`); + const closed = persisted(document); + if (!closed.ok) + throw new TypeError(`Initial document is not persistable: ${closed.issues[0]?.code ?? "persistence.invalid"}`); + return deepFreeze({ version: 0, document: closed.value, undo: [], redo: [], historyLimit }); + }; + const plan = (document: GraphDocument, command: Command): readonly Mutation[] | CommandError | null => { + if (command.type === "batch") { + if (!command.commands.length) return null; + if (command.commands.length > FXNODE_BATCH_LIMIT) + return reject("batch.limit", `Batch exceeds ${FXNODE_BATCH_LIMIT} commands`); + let draft = document, + all: Mutation[] = []; + for (const c of command.commands) { + const r = plan(draft, c); + if (r === null) continue; + if (!Array.isArray(r)) return r as CommandError; + all.push(...r); + draft = reduceMutations(draft, r); + } + return all.length ? all : null; + } + if (command.type === "node.add") { + if (document.nodes[command.nodeId]) return reject("node.duplicate", "Node id already exists"); + const d = definition(command.nodeType); + if (!d) return reject("node.type-unknown", "Node type is not declared"); + if (command.parentId) { + const p = document.nodes[command.parentId]; + if (!p || !p.known || definition(p.typeId)?.behavior !== "frame") + return reject("parent.frame", "Parent must be an existing known frame"); + } + return [ + { + kind: "node.set", + id: command.nodeId, + before: null, + after: docs.materializeNode(command.nodeId, command.nodeType, command.position, command.parentId), + }, + ]; + } + if (command.type === "node.remove") { + const n = document.nodes[command.id]; + if (!n) return reject("node.missing", "Node does not exist"); + return [ + { kind: "node.set", id: n.id, before: n, after: null }, + ...Object.values(document.links) + .filter((l) => l.fromNodeId === n.id || l.toNodeId === n.id) + .map((l) => ({ kind: "link.set" as const, id: l.id, before: l, after: null })), + ...Object.values(document.nodes) + .filter((x) => x.parentId === n.id) + .map((x) => { + const { parentId: _, ...unparented } = x; + return { kind: "node.set" as const, id: x.id, before: x, after: deepFreeze(unparented) }; + }), + ]; + } + if (command.type === "link.add") { + if (document.links[command.link.id]) return reject("link.duplicate", "Link id already exists"); + if ( + Object.values(document.links).some( + (l) => l.fromSocketId === command.link.fromSocketId && l.toSocketId === command.link.toSocketId, + ) + ) + return reject("link.endpoint-duplicate", "Endpoints are already linked"); + return [{ kind: "link.set", id: command.link.id, before: null, after: cloneJson(command.link) }]; + } + if (command.type === "link.remove") { + const l = document.links[command.id]; + return l + ? [{ kind: "link.set", id: l.id, before: l, after: null }] + : reject("link.missing", "Link does not exist"); + } + if (command.type === "link.mute") { + const l = document.links[command.id]; + if (!l) return reject("link.missing", "Link does not exist"); + return l.muted === command.value + ? null + : [{ kind: "link.set", id: l.id, before: l, after: deepFreeze({ ...l, muted: command.value }) }]; + } + if (command.type === "link.replace") { + const old = document.links[command.removeId]; + if (!old) return reject("link.missing", "Replacement target does not exist"); + if (command.link.toSocketId !== old.toSocketId) + return reject("link.replace-target", "Replacement must keep target socket"); + if (command.link.id !== old.id && document.links[command.link.id]) + return reject("link.duplicate", "Replacement id collides"); + return [ + { kind: "link.set", id: old.id, before: old, after: null }, + { kind: "link.set", id: command.link.id, before: null, after: cloneJson(command.link) }, + ]; + } + if (command.type === "undo" || command.type === "redo") return null; + const node = document.nodes[command.id]; + if (!node) return reject("node.missing", "Node does not exist"); + let after: GraphNode; + switch (command.type) { + case "node.move": + if (same(node.position, command.position)) return null; + after = { ...node, position: cloneJson(command.position) }; + break; + case "node.resize": + if (command.size.x <= 0 || command.size.y <= 0) return reject("size.nonpositive", "Node size must be positive"); + if (same(node.size, command.size)) return null; + after = { ...node, size: cloneJson(command.size) }; + break; + case "node.label": + if (node.label === command.label) return null; + after = { ...node, label: command.label }; + break; + case "node.mute": + if (node.muted === command.value) return null; + after = { ...node, muted: command.value }; + break; + case "node.collapse": + if (node.collapsed === command.value) return null; + after = { ...node, collapsed: command.value }; + break; + case "node.parent": { + if (node.parentId === (command.parentId ?? undefined)) return null; + const world = (n: GraphNode) => { + let x = n.position.x, + y = n.position.y, + p = n.parentId ? document.nodes[n.parentId] : undefined; + while (p) { + x += p.position.x; + y += p.position.y; + p = p.parentId ? document.nodes[p.parentId] : undefined; + } + return { x, y }; + }; + const p = command.parentId ? document.nodes[command.parentId] : undefined; + if (command.parentId && (!p || !p.known || definition(p.typeId)?.behavior !== "frame")) + return reject("parent.frame", "Parent must be an existing known frame"); + const origin = p ? world(p) : { x: 0, y: 0 }, + current = world(node), + position = { x: current.x - origin.x, y: current.y - origin.y }; + if (command.parentId) after = { ...node, position, parentId: command.parentId }; + else { + const { parentId: _, ...unparented } = node; + after = { ...unparented, position }; + } + break; + } + case "node.parameter": + case "node.parameter-reset": { + if (!node.known) return reject("node.unknown-readonly", "Unknown node parameters are read-only"); + const schema = definition(node.typeId)?.parameters[command.key]; + if (!schema) return reject("parameter.unknown", "Parameter is not declared"); + const value = command.type === "node.parameter-reset" ? schema.default : command.value; + if (!matchesFxNodeValueSchema(schema, value)) + return reject("parameter.invalid", "Parameter does not match its schema"); + if (same(node.parameters[command.key], value)) return null; + after = { ...node, parameters: deepFreeze({ ...node.parameters, [command.key]: cloneJson(value) }) }; + break; + } + case "node.socket-default": + case "node.socket-default-reset": { + if (!node.known) return reject("node.unknown-readonly", "Unknown node defaults are read-only"); + const index = node.sockets.findIndex((s) => s.id === command.socketId); + if (index < 0) return reject("socket.missing", "Socket does not exist"); + const key = node.sockets[index]!.key, + schema = definition(node.typeId)?.sockets[key]?.value, + value = schema && (command.type === "node.socket-default-reset" ? schema.default : command.value); + if (!schema || !matchesFxNodeValueSchema(schema, value)) + return reject("socket.default-invalid", "Socket has no editable value schema or value is invalid"); + if (same(node.sockets[index]!.defaultValue, value)) return null; + after = { + ...node, + sockets: node.sockets.map((s, i) => (i === index ? deepFreeze({ ...s, defaultValue: cloneJson(value) }) : s)), + }; + break; + } + } + return [{ kind: "node.set", id: node.id, before: node, after: deepFreeze(after) }]; + }; + const transition = (state: BoundEngineState, request: CommandRequest): BoundTransitionResult => { + if (request.expectedVersion !== state.version) + return { status: "rejected", state, error: reject("version.stale", "Expected version does not match") }; + if (state.version >= Number.MAX_SAFE_INTEGER) + return { status: "rejected", state, error: reject("version.overflow", "Version exhausted") }; + const cause: BoundMutationEnvelope["cause"] = + request.command.type === "undo" ? "undo" : request.command.type === "redo" ? "redo" : request.source; + let mutations: readonly Mutation[], + undo = state.undo, + redo = state.redo; + if (cause === "undo" || cause === "redo") { + const entry = (cause === "undo" ? state.undo : state.redo).at(-1); + if (!entry) return { status: "noop", state }; + mutations = cause === "undo" ? entry.inverse : entry.forward; + undo = cause === "undo" ? state.undo.slice(0, -1) : [...state.undo, entry]; + redo = cause === "redo" ? state.redo.slice(0, -1) : [...state.redo, entry]; + } else { + const r = plan(state.document, request.command); + if (r === null) return { status: "noop", state }; + if (!Array.isArray(r)) return { status: "rejected", state, error: r as CommandError }; + mutations = r; + } + const document = reduceMutations(state.document, mutations) as GraphDocument; + const issue = docs.validateDocument(document)[0]; + if (issue) + return { status: "rejected", state, error: { code: issue.code, message: issue.message, path: issue.path } }; + const closed = persisted(document); + if (!closed.ok) { + const persistenceIssue = closed.issues[0]; + return { + status: "rejected", + state, + error: { + code: persistenceIssue?.code ?? "persistence.invalid", + message: persistenceIssue?.message ?? "Candidate state is not persistable", + ...(persistenceIssue?.path === undefined ? {} : { path: persistenceIssue.path }), + }, + }; + } + const persistedDocument = canonicalJsonEqual(document, closed.value) ? document : closed.value; + if (cause === "api" || cause === "gesture") { + const entry = deepFreeze({ forward: mutations, inverse: invert(mutations) }); + undo = state.historyLimit === 0 ? [] : [...state.undo, entry].slice(-state.historyLimit); + redo = []; + } + const next = deepFreeze({ ...state, version: state.version + 1, document: persistedDocument, undo, redo }), + mutationEnvelope = deepFreeze({ + baseVersion: state.version, + version: next.version, + commandId: request.commandId, + cause, + mutations, + }); + return { + status: "committed", + state: next, + mutationEnvelope, + snapshotEnvelope: deepFreeze({ version: next.version, snapshot: snapshot(next) }), + }; + }; + const replaceState = ( + state: BoundEngineState, + request: BoundStateReplacementRequest, + ): BoundTransitionResult => { + if (request.expectedVersion !== state.version) + return { status: "rejected", state, error: reject("version.stale", "Expected version does not match") }; + const decoded = docs.decodeGraphState(request.target); + if (!decoded.ok) { + const issue = decoded.issues[0]; + return { + status: "rejected", + state, + error: { + code: issue?.code ?? "state.shape", + message: issue?.message ?? "Invalid GraphState", + ...(issue?.path === undefined ? {} : { path: issue.path }), + }, + }; + } + if (canonicalJsonEqual(state.document, decoded.value)) return { status: "noop", state }; + if (state.version >= Number.MAX_SAFE_INTEGER) + return { status: "rejected", state, error: reject("version.overflow", "Version exhausted") }; + const mutations = deepFreeze([ + { kind: "document.replaced" as const, before: state.document, after: decoded.value }, + ]), + entry = deepFreeze({ forward: mutations, inverse: invert(mutations) }), + undo = state.historyLimit === 0 ? [] : [...state.undo, entry].slice(-state.historyLimit), + next = deepFreeze({ ...state, version: state.version + 1, document: decoded.value, undo, redo: [] }), + mutationEnvelope = deepFreeze({ + baseVersion: state.version, + version: next.version, + commandId: request.commandId, + cause: "api" as const, + mutations, + }); + return { + status: "committed", + state: next, + mutationEnvelope, + snapshotEnvelope: deepFreeze({ version: next.version, snapshot: snapshot(next) }), + }; + }; + const load = ( + state: BoundEngineState, + value: unknown, + expectedVersion = state.version, + id = "load" as CommandId, + ): BoundLoadResult => { + if (expectedVersion !== state.version) + return { + ok: false, + state, + issues: [{ code: "version.stale", path: "/", message: "Expected version does not match" }], + }; + const decoded = docs.decodeGraphDocument(value); + if (!decoded.ok) return { ok: false, state, issues: decoded.issues }; + if (state.version >= Number.MAX_SAFE_INTEGER) + return { ok: false, state, issues: [{ code: "version.overflow", path: "/", message: "Version exhausted" }] }; + const next = deepFreeze({ ...state, version: state.version + 1, document: decoded.value, undo: [], redo: [] }), + mutationEnvelope = deepFreeze({ + baseVersion: state.version, + version: next.version, + commandId: id, + cause: "load" as const, + mutations: [{ kind: "document.replaced" as const, before: state.document, after: decoded.value }], + }); + return { + ok: true, + state: next, + mutationEnvelope, + snapshotEnvelope: deepFreeze({ version: next.version, snapshot: snapshot(next) }), + }; + }; + const replaySaveData = ( + state: BoundEngineState, + value: unknown, + expectedVersion = state.version, + id = "load" as CommandId, + ): BoundReplayResult => { + if (expectedVersion !== state.version) + return { + ok: false, + state, + issues: [{ code: "version.stale", path: "/", message: "Expected version does not match" }], + }; + const decoded = decodeFxNodeSaveData(value, compiled, docs.decodeGraphDocument); + if (!decoded.ok) return { ok: false, state, issues: decoded.issues }; + let staged: BoundEngineState = deepFreeze({ + version: 0, + document: decoded.baseline, + undo: [], + redo: [], + historyLimit: state.historyLimit, + }); + for (let index = 0; index < decoded.value.commands.length; index++) { + const result = transition(staged, { + commandId: id, + expectedVersion: staged.version, + source: "api", + command: decoded.value.commands[index]!, + }); + if (result.status !== "committed") + return { + ok: false, + state, + issues: [ + result.status === "noop" + ? { code: "replay.noop", path: `/commands/${index}`, message: "Replay command did not commit" } + : { + code: result.error.code, + path: `/commands/${index}${result.error.path ?? ""}`, + message: result.error.message, + }, + ], + }; + staged = result.state; + } + const graphChanged = !canonicalJsonEqual(state.document, staged.document); + if (graphChanged && state.version >= Number.MAX_SAFE_INTEGER) + return { ok: false, state, issues: [{ code: "version.overflow", path: "/", message: "Version exhausted" }] }; + const next: BoundEngineState = deepFreeze({ + version: graphChanged ? state.version + 1 : state.version, + document: graphChanged ? staged.document : state.document, + undo: staged.undo, + redo: [], + historyLimit: state.historyLimit, + }); + if (!graphChanged) return { ok: true, status: "noop", state: next, saveData: decoded.value }; + const mutationEnvelope: BoundMutationEnvelope = deepFreeze({ + baseVersion: state.version, + version: next.version, + commandId: id, + cause: "load", + mutations: [{ kind: "document.replaced", before: state.document, after: next.document }], + }); + return { + ok: true, + status: "committed", + state: next, + mutationEnvelope, + snapshotEnvelope: deepFreeze({ version: next.version, snapshot: snapshot(next) }), + saveData: decoded.value, + }; + }; + /** Trusted worker-only journal check under this already compiled authority. */ + const validateReplayJournal = ( + baseline: GraphLayoutV2, + commands: readonly FxNodeReplayCommand[], + historyLimit = 100, + ): boolean => { + const decoded = docs.decodeGraphDocument(baseline); + if (!decoded.ok) return false; + let staged: BoundEngineState = deepFreeze({ + version: 0, + document: decoded.value, + undo: [], + redo: [], + historyLimit, + }); + for (const command of commands) { + if (!validFxNodeReplayCommand(command, compiled.nodes)) return false; + const result = transition(staged, { + commandId: "journal" as CommandId, + expectedVersion: staged.version, + source: "api", + command, + }); + if (result.status !== "committed") return false; + staged = result.state; + } + return persisted(staged.document).ok; + }; + return Object.freeze({ + createEngine, + transition, + replaceState, + load, + replaySaveData, + validateReplayJournal, + getState: snapshot, + }); +} diff --git a/vendor/fxnode/src/composition/compile.ts b/vendor/fxnode/src/composition/compile.ts new file mode 100644 index 0000000..ba08016 --- /dev/null +++ b/vendor/fxnode/src/composition/compile.ts @@ -0,0 +1,110 @@ +import { deepFreeze } from "../core/json.js"; +import { validateFxNodeComposition, type FxNodeCompositionIssue } from "./validate.js"; +import { effectiveImageResourcePolicy } from "./resource-policy.js"; +import type { CompiledFxNodeComposition, FxNodeCompositionData, FxNodeReadonlyMap, FxNodeTheme } from "./types.js"; +import type { ReferenceCheck } from "./references.js"; + +export class FxNodeCompositionError extends TypeError { + constructor(readonly issues: readonly FxNodeCompositionIssue[]) { + super(`Invalid fxnode composition (${issues.length} issue${issues.length === 1 ? "" : "s"})`); + this.name = "FxNodeCompositionError"; + } +} +export const DEFAULT_FXNODE_THEME = { + background: "#000000", + grid: "#33363c", + frame: "#30343a80", + frameHeader: "#59616c", + body: "#35383e", + control: "#24272b", + controlFill: "#4775b8", + controlEditing: "#181a1d", + textSelection: "#4775b8", + outline: "#111216", + text: "#e5e5e5", + muted: "#a5a8ad", + shadow: "#00000088", + nodeSelected: "#ed5700", + nodeActive: "#ffffff", + unknownHeader: "#555b64", + unknownSocket: "#999999", + linkMuted: "#d94b4b", + knifeMuted: "#e85b5b", + emphasis: "#ffffff", + focus: "#f5a623", + editOutline: "#666a70", + resize: "#8b8e95", + muteOverlay: "#14141459", + boxSelectionFill: "#f5a6231f", + checkerLight: "#aaaaaa", + checkerDark: "#777777", + widgetBorder: "#111216", + rampBorder: "#111111", + resourceBackground: "#202228", +} as const satisfies FxNodeTheme; +function facade(entries: readonly (readonly [K, V])[]): FxNodeReadonlyMap { + const map = new Map(entries); + return Object.freeze({ + get size() { + return map.size; + }, + get: (key: K) => map.get(key), + has: (key: K) => map.has(key), + keys: () => map.keys(), + values: () => map.values(), + entries: () => map.entries(), + forEach: (callback: (value: V, key: K) => void) => map.forEach((value, key) => callback(value, key)), + [Symbol.iterator]: () => map[Symbol.iterator](), + }); +} +export function compileFxNodeComposition( + composition: C & ReferenceCheck, +): CompiledFxNodeComposition { + const checked = validateFxNodeComposition(composition); + if (!checked.ok) throw new FxNodeCompositionError(checked.issues); + let source: C; + try { + source = deepFreeze(structuredClone(checked.value)) as unknown as C; + } catch { + throw new FxNodeCompositionError( + Object.freeze([Object.freeze({ code: "data.clone", path: "", message: "composition could not be cloned" })]), + ); + } + const raw = source as C & { id: string; version: number; theme: unknown }; + const withIds = (record: Record) => + Object.entries(record).map(([id, value]) => [id, deepFreeze({ ...(value as object), id })] as const); + const nodeEntries = Object.entries(raw.nodes as Record).map( + ([typeId, value]) => [typeId, deepFreeze({ ...(value as object), typeId })] as const, + ); + const resourceEntries = Object.entries( + raw.resources as Record, + ).map(([id, value]) => [id, deepFreeze({ ...effectiveImageResourcePolicy(value), id })] as const); + return Object.freeze({ + source, + id: raw.id, + version: raw.version, + compatibility: source.compatibility, + theme: raw.theme, + nodes: facade(nodeEntries), + socketTypes: facade(withIds(raw.socketTypes as Record)), + styles: facade(withIds(raw.nodeStyles as Record)), + resources: facade(resourceEntries), + }) as unknown as CompiledFxNodeComposition; +} +export function createInitialFxNodeComposition( + applicationId: string, + applicationVersion: number, + resources: FxNodeCompositionData["resources"], +): CompiledFxNodeComposition { + return compileFxNodeComposition({ + schemaVersion: 2, + id: applicationId, + version: applicationVersion, + resources, + nodeStyles: {}, + compatibility: { wildcardInputTypes: [] as readonly never[] }, + socketTypes: {}, + nodes: {}, + theme: DEFAULT_FXNODE_THEME, + }) as unknown as CompiledFxNodeComposition; +} diff --git a/vendor/fxnode/src/composition/compose.ts b/vendor/fxnode/src/composition/compose.ts new file mode 100644 index 0000000..cd2ef20 --- /dev/null +++ b/vendor/fxnode/src/composition/compose.ts @@ -0,0 +1,127 @@ +import type { NodeReferenceCheck } from "./references.js"; +import type { + FxNodeCompositionData, + FxNodeDefinition, + FxNodeSocketTypeDefinition, + FxNodeStyleDefinition, + FxNodeTheme, +} from "./types.js"; + +/** @inline */ +type ReplaceProperty = { readonly [P in keyof T]: P extends K ? V : T[P] }; +/** @inline */ +type PutProperty = { + readonly [P in keyof T | K]: P extends K ? V : P extends keyof T ? T[P] : never; +}; +type EntryValue = E extends readonly [K, infer V] ? V : never; +type PutEntries = { + readonly [P in keyof T | E[0]]: P extends E[0] ? EntryValue : P extends keyof T ? T[P] : never; +}; +export type NodeCompositionEntry = readonly [id: string, definition: FxNodeDefinition]; +export type SocketCompositionEntry = readonly [id: string, definition: FxNodeSocketTypeDefinition]; +export type ComposedNode = { + readonly [P in keyof C]: P extends "nodes" + ? { readonly [N in keyof C["nodes"] | I]: N extends I ? D : N extends keyof C["nodes"] ? C["nodes"][N] : never } + : C[P]; +}; +export type ComposedNodes = ReplaceProperty< + C, + "nodes", + PutEntries +>; +export type ComposedSocket< + C extends Pick, + I extends string, + D extends FxNodeSocketTypeDefinition, +> = { + readonly [P in keyof C]: P extends "socketTypes" + ? { + readonly [S in keyof C["socketTypes"] | I]: S extends I + ? D + : S extends keyof C["socketTypes"] + ? C["socketTypes"][S] + : never; + } + : C[P]; +}; +export type ComposedSockets< + C extends Pick, + E extends SocketCompositionEntry, +> = ReplaceProperty>; +/** @inline */ +type ThemeTarget = Omit & { readonly theme?: FxNodeTheme }; +export type Themed = { + readonly [P in keyof C | "theme"]: P extends "theme" ? T : P extends keyof C ? C[P] : never; +}; +export type RemovedSocket< + C extends Pick, + I extends Extract, +> = { readonly [P in keyof C]: P extends "socketTypes" ? Omit : C[P] }; +export type RemovedNode, I extends Extract> = { + readonly [P in keyof C]: P extends "nodes" ? Omit : C[P]; +}; + +export function setTheme( + composition: C, + theme: T, +): Themed; +export function setTheme(composition: ThemeTarget, theme: FxNodeTheme): FxNodeCompositionData { + return { ...composition, theme }; +} +export type HeaderStyled = { readonly [P in keyof C]: P extends "nodeStyles" ? S : C[P] }; +export function setHeaderStyles< + const C extends Pick, + const S extends Readonly>, +>(composition: C, styles: S): HeaderStyled; +export function setHeaderStyles( + composition: Pick, + styles: Readonly>, +): Pick { + return { ...composition, nodeStyles: styles }; +} +export function composeSocket< + const C extends Pick, + const I extends string, + const D extends FxNodeSocketTypeDefinition["socketTypes"], string> | I>, +>(composition: C, id: I, definition: D): ComposedSocket; +export function composeSocket( + composition: Pick, + id: string, + definition: FxNodeSocketTypeDefinition, +): Pick { + return { ...composition, socketTypes: { ...composition.socketTypes, [id]: definition } }; +} +export function removeSocket< + const C extends Pick, + const I extends Extract, +>(composition: C, id: I): RemovedSocket; +export function removeSocket( + composition: Pick, + id: string, +): Pick { + const { [id]: _, ...socketTypes } = composition.socketTypes; + return { ...composition, socketTypes }; +} +export function composeNode< + const D extends FxNodeDefinition, + const C extends FxNodeCompositionData, + const I extends string, +>(composition: C, id: I, definition: D & NodeReferenceCheck, D>): ComposedNode; +export function composeNode( + composition: FxNodeCompositionData, + id: string, + definition: FxNodeDefinition, +): FxNodeCompositionData { + return { ...composition, nodes: { ...composition.nodes, [id]: definition } }; +} +export function removeNode< + const C extends Pick, + const I extends Extract, +>(composition: C, id: I): RemovedNode; +export function removeNode( + composition: Pick, + id: string, +): Pick { + const { [id]: _, ...nodes } = composition.nodes; + return { ...composition, nodes }; +} diff --git a/vendor/fxnode/src/composition/index.ts b/vendor/fxnode/src/composition/index.ts new file mode 100644 index 0000000..b0977de --- /dev/null +++ b/vendor/fxnode/src/composition/index.ts @@ -0,0 +1,12 @@ +/** Static composition authoring, validation, and compilation. */ +export * from "./types.js"; +export { setTheme, setHeaderStyles, composeSocket, removeSocket, composeNode, removeNode } from "./compose.js"; +export type { ComposedNode, ComposedSocket, Themed, RemovedSocket, RemovedNode, HeaderStyled } from "./compose.js"; +export { FXNODE_COMPOSITION_LIMITS, validateFxNodeComposition } from "./validate.js"; +export type { FxNodeCompositionIssue, FxNodeCompositionValidation } from "./validate.js"; +export { + compileFxNodeComposition, + createInitialFxNodeComposition, + DEFAULT_FXNODE_THEME, + FxNodeCompositionError, +} from "./compile.js"; diff --git a/vendor/fxnode/src/composition/references.ts b/vendor/fxnode/src/composition/references.ts new file mode 100644 index 0000000..477f72f --- /dev/null +++ b/vendor/fxnode/src/composition/references.ts @@ -0,0 +1,88 @@ +import type { FxNodeComposition, FxNodeCompositionData, FxNodeMigrationStep, FxNodeUiRow } from "./types.js"; + +type K = Extract; +type Visibility

= + | { parameter: P; equals: string | number | boolean } + | { parameter: P; in: readonly (string | number | boolean)[] } + | { all: readonly Visibility

[] } + | { any: readonly Visibility

[] }; +type Vis = R extends { visibleWhen: infer V } ? Omit & { visibleWhen: V & Visibility

} : R; +type Row = R extends { kind: "parameter" | "widget" | "resource"; parameter: unknown } + ? Vis< + Omit & { parameter: P } & (R extends { kind: "resource" } + ? { resource: Resources } + : unknown), + P + > + : R extends { kind: "socket"; socket: unknown } + ? Vis & { socket: S }, P> + : R extends { kind: "hidden"; target: "parameter" } + ? Omit & { parameter: P } + : R extends { kind: "hidden"; target: "socket" } + ? Omit & { socket: S } + : R extends { widget: "grading-wheels"; bindings: infer B extends readonly unknown[] } + ? Vis & { bindings: { [I in keyof B]: B[I] & { scalar: P; color: P } } }, P> + : Vis; +/** @inline */ +type Migration = M extends { steps: infer Steps extends readonly unknown[] } + ? M & { + readonly fromVersion: number; + readonly toVersion: number; + readonly steps: readonly (Steps[number] & FxNodeMigrationStep)[]; + } + : never; +export type NodeReferenceCheck< + C extends FxNodeCompositionData, + D extends { + parameters: object; + sockets: object; + ui: readonly unknown[]; + muteBypass: readonly unknown[]; + migrations: readonly { steps: readonly unknown[] }[]; + }, +> = D & + Omit & { + readonly defaultSize?: never; + readonly style: K; + readonly parameters: { + readonly [P in keyof D["parameters"]]: D["parameters"][P] & + FxNodeComposition["nodes"][string]["parameters"][string]; + }; + readonly sockets: { + readonly [S in keyof D["sockets"]]: D["sockets"][S] & + FxNodeComposition["nodes"][string]["sockets"][string] & { readonly type: K }; + }; + readonly ui: readonly (D["ui"][number] & + FxNodeUiRow, K, K> & + Row, K, K>)[]; + readonly muteBypass: readonly (readonly [K, K])[]; + readonly migrations: readonly Migration, K>[]; + }; +export type ReferenceCheck = { + readonly schemaVersion: 2; + readonly id: string; + readonly version: number; + readonly compatibility: { readonly wildcardInputTypes: readonly K[] }; + readonly theme: FxNodeComposition["theme"]; + readonly socketTypes: { + readonly [T in keyof C["socketTypes"]]: C["socketTypes"][T] & + FxNodeComposition["socketTypes"][string] & { readonly acceptsFrom: readonly K[] }; + }; + readonly nodeStyles: { + readonly [T in keyof C["nodeStyles"]]: C["nodeStyles"][T] & FxNodeComposition["nodeStyles"][string]; + }; + readonly resources: { + readonly [T in keyof C["resources"]]: C["resources"][T] & FxNodeComposition["resources"][string]; + }; + readonly nodes: { + readonly [N in keyof C["nodes"]]: C["nodes"][N] extends infer D extends { + parameters: object; + sockets: object; + ui: readonly unknown[]; + muteBypass: readonly unknown[]; + migrations: readonly { steps: readonly unknown[] }[]; + } + ? NodeReferenceCheck + : never; + }; +}; diff --git a/vendor/fxnode/src/composition/resource-policy.ts b/vendor/fxnode/src/composition/resource-policy.ts new file mode 100644 index 0000000..a77fec4 --- /dev/null +++ b/vendor/fxnode/src/composition/resource-policy.ts @@ -0,0 +1,20 @@ +import type { FxNodeImageResourceDefinition } from "./types.js"; + +export const FXNODE_IMAGE_HARD_LIMITS = Object.freeze({ + maxBytes: 33_554_432, + maxWidth: 8192, + maxHeight: 8192, + maxPixels: 16_777_216, +}); + +export function effectiveImageResourcePolicy(policy: FxNodeImageResourceDefinition): FxNodeImageResourceDefinition { + const maxWidth = Math.min(policy.maxWidth, FXNODE_IMAGE_HARD_LIMITS.maxWidth), + maxHeight = Math.min(policy.maxHeight, FXNODE_IMAGE_HARD_LIMITS.maxHeight); + return Object.freeze({ + ...policy, + maxBytes: Math.min(policy.maxBytes, FXNODE_IMAGE_HARD_LIMITS.maxBytes), + maxWidth, + maxHeight, + maxPixels: Math.min(policy.maxPixels, FXNODE_IMAGE_HARD_LIMITS.maxPixels, maxWidth * maxHeight), + }); +} diff --git a/vendor/fxnode/src/composition/save-compatibility.ts b/vendor/fxnode/src/composition/save-compatibility.ts new file mode 100644 index 0000000..c5bd35b --- /dev/null +++ b/vendor/fxnode/src/composition/save-compatibility.ts @@ -0,0 +1,125 @@ +import { canonicalJsonEqual, deepFreeze, isRecord } from "../core/json.js"; +import type { GraphLayoutV2 } from "../core/types.js"; +import { bindDocument, type ValidationIssue as BoundValidationIssue } from "./bound-document.js"; +import { compileFxNodeComposition } from "./compile.js"; +import type { FxNodeCompositionData, FxNodeDefinition, FxNodeValueSchema } from "./types.js"; + +const token = (s: string) => s.replaceAll("~", "~0").replaceAll("/", "~1"); +const equal = (a: unknown, b: unknown) => canonicalJsonEqual(a, b); +const hardSchema = (s: FxNodeValueSchema) => + Object.fromEntries(Object.entries(s).filter(([k]) => !["softMin", "softMax", "step", "precision"].includes(k))); +const semanticSocket = (s: FxNodeDefinition["sockets"][string]) => ({ + title: s.title, + direction: s.direction, + type: s.type, + maxIncomingLinks: s.maxIncomingLinks, + visible: s.visible, + value: s.value === null ? null : hardSchema(s.value), +}); +const semanticNode = (n: FxNodeDefinition) => ({ + version: n.version, + behavior: n.behavior, + parameters: Object.fromEntries(Object.entries(n.parameters).map(([k, v]) => [k, hardSchema(v)])), + sockets: Object.fromEntries(Object.entries(n.sockets).map(([k, v]) => [k, semanticSocket(v)])), + muteBypass: n.muteBypass, + migrations: n.migrations, +}); +const push = (issues: BoundValidationIssue[], value: BoundValidationIssue) => { + if (issues.length < 99) issues.push(value); +}; +const missing = ( + saved: Record, + current: Record, + base: string, + issues: BoundValidationIssue[], +) => { + for (const id of Object.keys(saved)) + if (!Object.hasOwn(current, id)) + push(issues, { + code: "composition.definition-missing", + path: `/composition/${base}/${token(id)}`, + message: `Current composition is missing saved ${base} definition "${id}"`, + }); +}; + +/** Checks that current authority conservatively preserves every replay-relevant saved meaning. */ +export function saveCompositionCompatibility( + saved: FxNodeCompositionData, + current: FxNodeCompositionData, + baseline: GraphLayoutV2, +): readonly BoundValidationIssue[] { + const leaves: BoundValidationIssue[] = []; + if (saved.id !== current.id) + push(leaves, { + code: "composition.id", + path: "/composition/id", + message: `Saved composition id "${saved.id}" does not match current id "${current.id}"`, + }); + for (const key of ["socketTypes", "nodeStyles", "resources", "nodes"] as const) + missing(saved[key], current[key], key, leaves); + if (!equal(saved.compatibility.wildcardInputTypes, current.compatibility.wildcardInputTypes)) + push(leaves, { + code: "composition.wildcard", + path: "/composition/compatibility/wildcardInputTypes", + message: "Wildcard input types changed", + }); + for (const id of Object.keys(saved.socketTypes)) { + const a = saved.socketTypes[id], + b = current.socketTypes[id]; + if (a && b && !equal(a.acceptsFrom, b.acceptsFrom)) + push(leaves, { + code: "composition.socket-semantic", + path: `/composition/socketTypes/${token(id)}/acceptsFrom`, + message: `Socket type "${id}" compatibility changed`, + }); + } + let aDocs: ReturnType | undefined, bDocs: ReturnType | undefined; + try { + aDocs = bindDocument(compileFxNodeComposition(saved)); + bDocs = bindDocument(compileFxNodeComposition(current)); + } catch { + /* saved/current are normally compiled/validated by callers */ + } + for (const id of Object.keys(saved.nodes)) { + const a = saved.nodes[id], + b = current.nodes[id]; + if (!a || !b) continue; + const path = `/composition/nodes/${token(id)}`; + if (!equal(semanticNode(a), semanticNode(b))) + push(leaves, { code: "composition.node-semantic", path, message: `Node type "${id}" replay semantics changed` }); + else if ( + aDocs && + bDocs && + !equal(aDocs.materializeNode("compat-probe", id), bDocs.materializeNode("compat-probe", id)) + ) + push(leaves, { + code: "composition.node-materialization", + path, + message: `Node type "${id}" materializes differently`, + }); + } + if (Array.isArray(baseline.nodes)) + for (let i = 0; i < baseline.nodes.length; i++) { + const node = baseline.nodes[i]; + if ( + isRecord(node) && + typeof node.typeId === "string" && + !Object.hasOwn(saved.nodes, node.typeId) && + Object.hasOwn(current.nodes, node.typeId) + ) + push(leaves, { + code: "composition.opaque-promotion", + path: `/baseline/nodes/${i}/typeId`, + message: `Opaque baseline node type "${node.typeId}" would be promoted by the current composition`, + }); + } + if (!leaves.length) return deepFreeze([]); + return deepFreeze([ + { + code: "composition.incompatible", + path: "/composition", + message: "Current composition is not a conservative semantic superset of the saved composition", + }, + ...leaves, + ]); +} diff --git a/vendor/fxnode/src/composition/types.ts b/vendor/fxnode/src/composition/types.ts new file mode 100644 index 0000000..c9fffdb --- /dev/null +++ b/vendor/fxnode/src/composition/types.ts @@ -0,0 +1,259 @@ +export interface FxNodeCompositionData { + readonly schemaVersion: 2; + readonly id: string; + readonly version: number; + readonly compatibility: { readonly wildcardInputTypes: readonly string[] }; + readonly socketTypes: Readonly>; + readonly theme: FxNodeTheme; + readonly nodeStyles: Readonly>; + readonly resources: Readonly>; + readonly nodes: Readonly>; +} +/** An authoring seed which may omit the theme until passed through setTheme. */ +export type FxNodeCompositionSeed = Omit & { readonly theme?: never }; + +export type NodeTypeId = + string extends Extract ? string : Extract; +export type SocketTypeId = + string extends Extract ? string : Extract; +export type NodeStyleId = + string extends Extract ? string : Extract; +export type ResourceId = + string extends Extract ? string : Extract; +export type NodeParameterId> = N extends keyof C["nodes"] + ? C["nodes"][N] extends { readonly parameters: infer P } + ? Extract + : string + : string; +export type NodeSocketId> = N extends keyof C["nodes"] + ? C["nodes"][N] extends { readonly sockets: infer S } + ? Extract + : string + : string; +export type AnyNodeParameterId = + NodeTypeId extends infer N ? (N extends NodeTypeId ? NodeParameterId : never) : never; +export type AnyNodeSocketId = + NodeTypeId extends infer N ? (N extends NodeTypeId ? NodeSocketId : never) : never; + +export type FxNodeHexColor = `#${string}`; +export type FxNodeJsonValue = + | null + | boolean + | number + | string + | readonly FxNodeJsonValue[] + | { readonly [key: string]: FxNodeJsonValue }; +export type FxNodeParameterValue = + | { readonly kind: "number"; readonly value: number } + | { readonly kind: "string"; readonly value: string } + | { readonly kind: "boolean"; readonly value: boolean } + | { readonly kind: "vector"; readonly value: readonly [number, number, number] } + | { readonly kind: "color"; readonly value: readonly [number, number, number, number] } + | { readonly kind: "json"; readonly value: FxNodeJsonValue }; +/** @inline */ +/** @inline */ +type Bounds = { + readonly minimum?: number; + readonly maximum?: number; + readonly softMin?: number; + readonly softMax?: number; + readonly step?: number; +}; +export type FxNodeValueSchema = + | (Bounds & { + readonly type: "number"; + readonly default: Extract; + readonly integer?: boolean; + readonly precision?: number; + }) + | { + readonly type: "string"; + readonly default: Extract; + readonly enum?: readonly string[]; + } + | { readonly type: "boolean"; readonly default: Extract } + | (Bounds & { readonly type: "vector"; readonly default: Extract }) + | (Bounds & { readonly type: "color"; readonly default: Extract }) + | { + readonly type: "json"; + readonly codec?: "color-ramp/v1"; + readonly default: Extract; + }; + +export type FxNodeVisibility

= + | { readonly parameter: P; readonly equals: string | number | boolean } + | { readonly parameter: P; readonly in: readonly (string | number | boolean)[] } + | { readonly all: readonly FxNodeVisibility

[] } + | { readonly any: readonly FxNodeVisibility

[] }; +export interface FxNodeSocketTypeDefinition { + readonly title: string; + readonly color: FxNodeHexColor; + readonly acceptsFrom: readonly S[]; +} +export interface FxNodeStyleDefinition { + readonly header: FxNodeHexColor; +} +export interface FxNodeImageResourceDefinition { + readonly kind: "image"; + readonly title: string; + readonly openTitle: string; + readonly accept: readonly string[]; + readonly referencePrefix: string; + readonly maxBytes: number; + readonly maxWidth: number; + readonly maxHeight: number; + readonly maxPixels: number; +} +export type FxNodeResourceDefinition = FxNodeImageResourceDefinition; +export interface FxNodeSocketDefinition { + readonly title: string; + readonly direction: "input" | "output"; + readonly type: S; + readonly maxIncomingLinks: number; + readonly visible: boolean; + readonly value: FxNodeValueSchema | null; + readonly showValue: boolean; +} +export interface FxNodeGradingBinding

{ + readonly title: string; + readonly scalar: P; + readonly color: P; +} +export type FxNodeUiRow

= + | { + readonly kind: "parameter"; + readonly parameter: P; + readonly title?: string; + readonly visibleWhen?: FxNodeVisibility

; + } + | { readonly kind: "socket"; readonly socket: S; readonly title?: string; readonly visibleWhen?: FxNodeVisibility

} + | { + readonly kind: "widget"; + readonly widget: "color-ramp"; + readonly parameter: P; + readonly title?: string; + readonly visibleWhen?: FxNodeVisibility

; + } + | { + readonly kind: "widget"; + readonly widget: "grading-wheels"; + readonly bindings: readonly [FxNodeGradingBinding

, FxNodeGradingBinding

, FxNodeGradingBinding

]; + readonly visibleWhen?: FxNodeVisibility

; + } + | { + readonly kind: "resource"; + readonly resource: R; + readonly parameter: P; + readonly title?: string; + readonly openTitle?: string; + readonly visibleWhen?: FxNodeVisibility

; + } + | { + readonly kind: "text"; + readonly variant: "header" | "category" | "section" | "panel" | "placeholder"; + readonly title: string; + readonly visibleWhen?: FxNodeVisibility

; + } + | { readonly kind: "hidden"; readonly target: "parameter"; readonly parameter: P } + | { readonly kind: "hidden"; readonly target: "socket"; readonly socket: S }; +export type FxNodeMigrationStep

= + | { readonly kind: "materialize-missing"; readonly target: "parameter"; readonly key: P } + | { readonly kind: "materialize-missing"; readonly target: "socket"; readonly key: S } + | { readonly kind: "migrate-parameter"; readonly parameter: P; readonly codec: "color-ramp/legacy-stops" } + | { readonly kind: "rename-parameter"; readonly from: string; readonly to: P } + | { readonly kind: "rename-socket"; readonly from: string; readonly to: S }; +export interface FxNodeMigration

{ + readonly fromVersion: number; + readonly toVersion: number; + readonly steps: readonly FxNodeMigrationStep[]; +} +export interface FxNodeDefinition { + readonly version: number; + readonly title: string; + readonly behavior: "standard" | "frame" | "reroute"; + readonly style: string; + readonly parameters: Readonly>; + readonly sockets: Readonly>; + readonly ui: readonly FxNodeUiRow[]; + readonly muteBypass: readonly (readonly [string, string])[]; + readonly migrations: readonly FxNodeMigration[]; +} +export interface FxNodeTheme { + readonly background: FxNodeHexColor; + readonly grid: FxNodeHexColor; + readonly frame: FxNodeHexColor; + readonly frameHeader: FxNodeHexColor; + readonly body: FxNodeHexColor; + readonly control: FxNodeHexColor; + readonly controlFill: FxNodeHexColor; + readonly controlEditing: FxNodeHexColor; + readonly textSelection: FxNodeHexColor; + readonly outline: FxNodeHexColor; + readonly text: FxNodeHexColor; + readonly muted: FxNodeHexColor; + readonly shadow: FxNodeHexColor; + readonly nodeSelected: FxNodeHexColor; + readonly nodeActive: FxNodeHexColor; + readonly unknownHeader: FxNodeHexColor; + readonly unknownSocket: FxNodeHexColor; + readonly linkMuted: FxNodeHexColor; + readonly knifeMuted: FxNodeHexColor; + readonly emphasis: FxNodeHexColor; + readonly focus: FxNodeHexColor; + readonly editOutline: FxNodeHexColor; + readonly resize: FxNodeHexColor; + readonly muteOverlay: FxNodeHexColor; + readonly boxSelectionFill: FxNodeHexColor; + readonly checkerLight: FxNodeHexColor; + readonly checkerDark: FxNodeHexColor; + readonly widgetBorder: FxNodeHexColor; + readonly rampBorder: FxNodeHexColor; + readonly resourceBackground: FxNodeHexColor; +} +export interface FxNodeComposition extends FxNodeCompositionData { + readonly schemaVersion: 2; + readonly compatibility: { readonly wildcardInputTypes: readonly string[] }; + readonly theme: FxNodeTheme; + readonly socketTypes: Readonly>; + readonly nodeStyles: Readonly>; + readonly resources: Readonly>; + readonly nodes: Readonly>; +} +export interface FxNodeReadonlyMap extends Iterable { + readonly size: number; + get(key: K): V | undefined; + has(key: K): boolean; + keys(): IterableIterator; + values(): IterableIterator; + entries(): IterableIterator<[K, V]>; + forEach(callback: (value: V, key: K) => void): void; + [Symbol.iterator](): IterableIterator<[K, V]>; +} +/** @inline */ +/** @inline */ +type Values = T[keyof T]; +export type CompiledNode> = (N extends keyof C["nodes"] + ? C["nodes"][N] + : never) & { readonly typeId: N }; +export type CompiledResource> = Omit< + C["resources"][R], + "maxBytes" | "maxWidth" | "maxHeight" | "maxPixels" +> & + Pick & { readonly id: R }; +export interface CompiledFxNodeComposition { + readonly source: C; + readonly id: C["id"]; + readonly version: C["version"]; + readonly compatibility: C["compatibility"]; + readonly theme: C["theme"]; + readonly nodes: FxNodeReadonlyMap, Values<{ [N in NodeTypeId]: CompiledNode }>>; + readonly socketTypes: FxNodeReadonlyMap< + SocketTypeId, + Values<{ [S in SocketTypeId]: C["socketTypes"][S] & { readonly id: S } }> + >; + readonly styles: FxNodeReadonlyMap< + NodeStyleId, + Values<{ [S in NodeStyleId]: C["nodeStyles"][S] & { readonly id: S } }> + >; + readonly resources: FxNodeReadonlyMap, Values<{ [R in ResourceId]: CompiledResource }>>; +} diff --git a/vendor/fxnode/src/composition/validate.ts b/vendor/fxnode/src/composition/validate.ts new file mode 100644 index 0000000..60d264d --- /dev/null +++ b/vendor/fxnode/src/composition/validate.ts @@ -0,0 +1,751 @@ +import { isColorRamp } from "../widgets/color-ramp.js"; +import type { FxNodeComposition, FxNodeValueSchema, FxNodeVisibility } from "./types.js"; + +export const FXNODE_COMPOSITION_LIMITS = Object.freeze({ + maxIssues: 100, + maxDepth: 64, + maxValues: 100_000, + maxStringCodeUnits: 1_048_576, + maxIdLength: 128, + maxTitleLength: 256, + maxKeywordLength: 64, + maxSearchLength: 4096, + maxNodes: 512, + maxSocketTypes: 128, + maxStyles: 128, + maxResources: 64, + maxParametersPerNode: 128, + maxSocketsPerNode: 64, + maxUiRowsPerNode: 256, + maxEnumValues: 256, + maxMigrationsPerNode: 64, + maxMigrationSteps: 128, + maxVisibilityDepth: 16, + maxVisibilityNodes: 256, + maxImageBytes: 33_554_432, + maxImageDimension: 8192, + maxImagePixels: 16_777_216, +}); +export interface FxNodeCompositionIssue { + readonly code: string; + readonly path: string; + readonly message: string; +} +export type FxNodeCompositionValidation = + | { readonly ok: true; readonly value: FxNodeComposition } + | { readonly ok: false; readonly issues: readonly FxNodeCompositionIssue[] }; +const forbidden = new Set(["__proto__", "prototype", "constructor"]); +const esc = (s: string) => s.replaceAll("~", "~0").replaceAll("/", "~1"); +const plain = (v: unknown): v is Record => + v !== null && + typeof v === "object" && + !Array.isArray(v) && + (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null); + +export function validateFxNodeComposition(input: unknown): FxNodeCompositionValidation { + const issues: FxNodeCompositionIssue[] = []; + const add = (path: string, message: string, code = "shape.invalid") => { + if (issues.length < FXNODE_COMPOSITION_LIMITS.maxIssues) issues.push(Object.freeze({ code, path, message })); + }; + const seen = new WeakSet(); + let count = 0, + bytes = 0; + let terminal = false; + const stringBudget = (value: string, path: string) => { + bytes += value.length; + if (bytes <= FXNODE_COMPOSITION_LIMITS.maxStringCodeUnits) return true; + add(path, "composition exceeds string limit", "limit.strings"); + terminal = true; + return false; + }; + const inspect = (v: unknown, p: string, d: number): boolean => { + try { + if (terminal) return false; + if (++count > FXNODE_COMPOSITION_LIMITS.maxValues) { + add(p, "composition exceeds value limit", "limit.values"); + terminal = true; + return false; + } + if (d > FXNODE_COMPOSITION_LIMITS.maxDepth) { + add(p, "composition exceeds depth limit", "limit.depth"); + terminal = true; + return false; + } + if (typeof v === "string") { + return stringBudget(v, p); + } + if (v === null || typeof v === "boolean") return true; + if (typeof v === "number") { + if (!Number.isFinite(v)) add(p, "must be finite", "value.finite"); + return true; + } + if (typeof v !== "object") { + add(p, "unsupported value", "data.type"); + return false; + } + if (seen.has(v)) { + add(p, "shared or cyclic value", "data.identity"); + return false; + } + seen.add(v); + if (Array.isArray(v)) { + if (Object.getPrototypeOf(v) !== Array.prototype) add(p, "array must be plain", "data.array"); + const ds = Object.getOwnPropertyDescriptors(v); + const ld = Object.getOwnPropertyDescriptor(v, "length"); + const length = ld && "value" in ld ? ld.value : undefined; + if (!Number.isSafeInteger(length)) { + add(p, "invalid array length", "data.array"); + return false; + } + if (Number(length) > FXNODE_COMPOSITION_LIMITS.maxValues) { + add(p, "array exceeds value limit", "limit.values"); + terminal = true; + return false; + } + for (const key of Reflect.ownKeys(ds)) { + if (key === "length") continue; + if (typeof key === "string" && !stringBudget(key, p)) return false; + if (typeof key !== "string" || !/^(0|[1-9]\d*)$/.test(key) || Number(key) >= Number(length)) { + add(p, "extra array own property", "data.array"); + continue; + } + const descriptor = ds[key]!; + if (!descriptor.enumerable || !("value" in descriptor)) { + add(`${p}/${esc(key)}`, "array elements must be enumerable data properties", "data.inspect"); + continue; + } + inspect(descriptor.value, `${p}/${key}`, d + 1); + } + for (let i = 0; i < Number(length) && !terminal; i++) + if (!Object.hasOwn(ds, String(i))) add(`${p}/${i}`, "sparse arrays are not allowed", "data.array"); + return true; + } + const proto = Object.getPrototypeOf(v); + if (proto !== Object.prototype && proto !== null) { + add(p, "object must be an ordinary record", "data.type"); + return false; + } + for (const k of Reflect.ownKeys(v)) { + if (typeof k !== "string") { + add(p, "symbol properties are not allowed", "data.symbol"); + continue; + } + if (!stringBudget(k, p)) return false; + const q = `${p}/${esc(k)}`, + descriptor = Object.getOwnPropertyDescriptor(v, k); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) { + add(q, "record properties must be enumerable data properties", "data.inspect"); + continue; + } + inspect(descriptor.value, q, d + 1); + } + return true; + } catch { + add(p, "value could not be inspected", "data.inspect"); + terminal = true; + return false; + } + }; + inspect(input, "", 0); + if (issues.length) return Object.freeze({ ok: false, issues: Object.freeze(issues) }); + let cloned: unknown; + try { + cloned = structuredClone(input); + } catch { + add("", "value could not be cloned", "data.clone"); + return Object.freeze({ ok: false, issues: Object.freeze(issues) }); + } + input = cloned; + if (!plain(input)) { + add("", "must be a plain object"); + return Object.freeze({ ok: false, issues: Object.freeze(issues) }); + } + const known = (o: Record, keys: readonly string[], p: string) => { + for (const k of Object.keys(o)) { + if (issues.length >= FXNODE_COMPOSITION_LIMITS.maxIssues) return; + if (!keys.includes(k)) add(`${p}/${esc(k)}`, "unknown property", "shape.unknown"); + } + }; + const exact = (o: Record, keys: readonly string[], p: string) => { + known(o, keys, p); + for (const k of keys) { + if (issues.length >= FXNODE_COMPOSITION_LIMITS.maxIssues) return; + if (!Object.hasOwn(o, k)) add(`${p}/${k}`, "missing property", "shape.missing"); + } + }; + const id = (v: unknown, p: string) => { + if (typeof v !== "string" || !v || v.length > 128 || /[\x00-\x1f\x7f]/.test(v) || forbidden.has(v)) + add(p, "invalid ID"); + }; + const str = (v: unknown, p: string, max: number = FXNODE_COMPOSITION_LIMITS.maxTitleLength) => { + if (typeof v !== "string" || v.length > max) add(p, "invalid string", "shape.string"); + }; + const integer = (v: unknown, p: string, min = 0) => { + if (!Number.isSafeInteger(v) || Number(v) < min) add(p, "invalid integer", "shape.integer"); + }; + const record = ( + v: unknown, + p: string, + max: number, + visit: (x: Record, q: string, key: string) => void, + ) => { + if (!plain(v)) { + add(p, "must be a record", "shape.record"); + return; + } + const es = Object.entries(v); + if (es.length > max) add(p, "too many entries", "limit.collection"); + for (const [k, x] of es) { + if (issues.length >= FXNODE_COMPOSITION_LIMITS.maxIssues) return; + id(k, `${p}/${esc(k)}`); + if (plain(x)) visit(x, `${p}/${esc(k)}`, k); + else add(`${p}/${esc(k)}`, "must be an object", "shape.object"); + } + }; + exact( + input, + ["schemaVersion", "id", "version", "compatibility", "socketTypes", "nodeStyles", "resources", "theme", "nodes"], + "", + ); + if (input.schemaVersion !== 2) add("/schemaVersion", "must equal 2"); + id(input.id, "/id"); + integer(input.version, "/version", 1); + const socketIds = new Set(Object.keys(plain(input.socketTypes) ? input.socketTypes : {})), + styleIds = new Set(Object.keys(plain(input.nodeStyles) ? input.nodeStyles : {})), + resourceIds = new Set(Object.keys(plain(input.resources) ? input.resources : {})); + if (!plain(input.compatibility)) add("/compatibility", "must be an object"); + else { + exact(input.compatibility, ["wildcardInputTypes"], "/compatibility"); + const wildcards = input.compatibility.wildcardInputTypes; + if (!Array.isArray(wildcards)) add("/compatibility/wildcardInputTypes", "must be an array"); + else { + const seen = new Set(); + wildcards.forEach((value, index) => { + id(value, `/compatibility/wildcardInputTypes/${index}`); + if (typeof value === "string" && !socketIds.has(value)) + add(`/compatibility/wildcardInputTypes/${index}`, "unknown socket type", "reference.socketType"); + if (seen.has(value)) + add(`/compatibility/wildcardInputTypes/${index}`, "duplicate socket type", "value.duplicate"); + seen.add(value); + }); + } + } + const color = (v: unknown, p: string) => { + if (typeof v !== "string" || !/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(v)) add(p, "invalid color"); + }; + record(input.socketTypes, "/socketTypes", 128, (x, p) => { + exact(x, ["title", "color", "acceptsFrom"], p); + str(x.title, p + "/title"); + color(x.color, p + "/color"); + if (!Array.isArray(x.acceptsFrom)) add(p + "/acceptsFrom", "must be an array"); + else { + const seen = new Set(); + x.acceptsFrom.forEach((v, i) => { + id(v, `${p}/acceptsFrom/${i}`); + if (seen.has(v)) add(`${p}/acceptsFrom/${i}`, "duplicate socket type", "value.duplicate"); + seen.add(v); + if (typeof v === "string" && !socketIds.has(v)) + add(`${p}/acceptsFrom/${i}`, "unknown socket type", "reference.socketType"); + }); + } + }); + record(input.nodeStyles, "/nodeStyles", 128, (x, p) => { + exact(x, ["header"], p); + color(x.header, p + "/header"); + }); + record(input.resources, "/resources", FXNODE_COMPOSITION_LIMITS.maxResources, (x, p) => { + exact( + x, + ["kind", "title", "openTitle", "accept", "referencePrefix", "maxBytes", "maxWidth", "maxHeight", "maxPixels"], + p, + ); + if (x.kind !== "image") add(p + "/kind", "must equal image", "shape.literal"); + str(x.title, p + "/title"); + str(x.openTitle, p + "/openTitle"); + if ( + typeof x.referencePrefix !== "string" || + !x.referencePrefix || + x.referencePrefix.length > 128 || + /[\x00-\x1f\x7f]/.test(x.referencePrefix) || + !x.referencePrefix.endsWith(":") + ) + add(p + "/referencePrefix", "invalid reference prefix", "shape.string"); + if ( + !Array.isArray(x.accept) || + !x.accept.length || + x.accept.some((v) => typeof v !== "string" || !v || v.length > FXNODE_COMPOSITION_LIMITS.maxKeywordLength) + ) + add(p + "/accept", "invalid accepted media array", "shape.array"); + else { + const s = new Set(x.accept); + if (s.size !== x.accept.length) add(p + "/accept", "duplicate media type", "value.duplicate"); + } + for (const k of ["maxBytes", "maxWidth", "maxHeight", "maxPixels"] as const) + if (!Number.isSafeInteger(x[k]) || Number(x[k]) < 1) + add(`${p}/${k}`, "must be a positive safe integer", "limit.resource"); + }); + const themeKeys = [ + "background", + "grid", + "frame", + "frameHeader", + "body", + "control", + "controlFill", + "controlEditing", + "textSelection", + "outline", + "text", + "muted", + "shadow", + "nodeSelected", + "nodeActive", + "unknownHeader", + "unknownSocket", + "linkMuted", + "knifeMuted", + "emphasis", + "focus", + "editOutline", + "resize", + "muteOverlay", + "boxSelectionFill", + "checkerLight", + "checkerDark", + "widgetBorder", + "rampBorder", + "resourceBackground", + ] as const; + if (!plain(input.theme)) add("/theme", "must be an object"); + else { + exact(input.theme, themeKeys, "/theme"); + for (const k of themeKeys) color(input.theme[k], `/theme/${k}`); + } + const schema = (x: unknown, p: string) => { + if (!plain(x)) { + add(p, "must be a schema", "schema.shape"); + return; + } + const t = x.type; + if (typeof t !== "string" || !["number", "string", "boolean", "vector", "color", "json"].includes(t)) + add(p + "/type", "unknown schema type", "schema.type"); + const allowed = + t === "number" + ? ["type", "default", "integer", "minimum", "maximum", "softMin", "softMax", "step", "precision"] + : t === "string" + ? ["type", "default", "enum"] + : t === "vector" || t === "color" + ? ["type", "default", "minimum", "maximum", "softMin", "softMax", "step"] + : t === "json" + ? ["type", "default", "codec"] + : ["type", "default"]; + exact( + x, + allowed.filter((k) => k === "type" || k === "default" || Object.hasOwn(x, k)), + p, + ); + if (t === "number" && x.integer !== undefined && typeof x.integer !== "boolean") + add(p + "/integer", "integer must be boolean", "schema.integer"); + const d = x.default; + if (!plain(d) || d.kind !== t) { + add(p + "/default", "tagged default does not match schema", "schema.default"); + } else { + exact(d, ["kind", "value"], p + "/default"); + const val = d.value; + if (t === "number" && (!Number.isFinite(val) || (x.integer === true && !Number.isSafeInteger(val)))) + add(p + "/default/value", "invalid number", "schema.default"); + if (t === "string" && typeof val !== "string") add(p + "/default/value", "invalid string", "schema.default"); + if (t === "boolean" && typeof val !== "boolean") add(p + "/default/value", "invalid boolean", "schema.default"); + if (t === "vector" || t === "color") { + const n = t === "vector" ? 3 : 4; + if (!Array.isArray(val) || val.length !== n || val.some((v) => !Number.isFinite(v))) + add(p + "/default/value", "invalid components", "schema.default"); + else if ( + val.some( + (v) => (typeof x.minimum === "number" && v < x.minimum) || (typeof x.maximum === "number" && v > x.maximum), + ) + ) + add(p + "/default/value", "component outside bounds", "schema.bounds"); + } + if (t === "json" && x.codec === "color-ramp/v1" && !isColorRamp(val)) + add(p + "/default/value", "invalid color ramp", "schema.codec"); + if ( + typeof val === "number" && + ((typeof x.minimum === "number" && val < x.minimum) || (typeof x.maximum === "number" && val > x.maximum)) + ) + add(p + "/default/value", "default outside bounds", "schema.bounds"); + } + for (const k of ["minimum", "maximum", "softMin", "softMax"] as const) + if (x[k] !== undefined && !Number.isFinite(x[k])) add(`${p}/${k}`, "must be finite", "schema.bounds"); + if (typeof x.minimum === "number" && typeof x.maximum === "number" && x.minimum > x.maximum) + add(p + "/minimum", "minimum exceeds maximum", "schema.bounds"); + if (typeof x.softMin === "number" && typeof x.softMax === "number" && x.softMin > x.softMax) + add(p + "/softMin", "soft minimum exceeds soft maximum", "schema.bounds"); + if (typeof x.softMin === "number" && typeof x.minimum === "number" && x.softMin < x.minimum) + add(p + "/softMin", "soft minimum is outside hard bounds", "schema.bounds"); + if (typeof x.softMax === "number" && typeof x.maximum === "number" && x.softMax > x.maximum) + add(p + "/softMax", "soft maximum is outside hard bounds", "schema.bounds"); + if (x.step !== undefined && (!Number.isFinite(x.step) || Number(x.step) <= 0)) + add(p + "/step", "step must be positive", "schema.step"); + if ( + x.precision !== undefined && + (!Number.isSafeInteger(x.precision) || Number(x.precision) < 0 || Number(x.precision) > 20) + ) + add(p + "/precision", "invalid precision", "schema.precision"); + if (t === "json" && x.codec !== undefined && x.codec !== "color-ramp/v1") + add(p + "/codec", "unknown codec", "schema.codec"); + if (x.enum !== undefined) { + if (!Array.isArray(x.enum) || !x.enum.length || x.enum.some((v) => typeof v !== "string")) + add(p + "/enum", "invalid enum", "schema.enum"); + else { + if (x.enum.length > FXNODE_COMPOSITION_LIMITS.maxEnumValues) + add(p + "/enum", "too many enum values", "limit.enum"); + if (new Set(x.enum).size !== x.enum.length) add(p + "/enum", "duplicate enum value", "value.duplicate"); + if (plain(d) && typeof d.value === "string" && !x.enum.includes(d.value)) + add(p + "/default/value", "default is not an enum member", "schema.enum"); + } + } + }; + const primitive = (s: Record | undefined, v: unknown) => + s?.type === "number" + ? typeof v === "number" && Number.isFinite(v) + : s?.type === "string" + ? typeof v === "string" && (!Array.isArray(s.enum) || s.enum.includes(v)) + : s?.type === "boolean" + ? typeof v === "boolean" + : false; + const visibility = ( + v: unknown, + p: string, + paramDefs: Record, + depth: number, + state: { n: number }, + ) => { + if ( + ++state.n > FXNODE_COMPOSITION_LIMITS.maxVisibilityNodes || + depth > FXNODE_COMPOSITION_LIMITS.maxVisibilityDepth + ) { + add(p, "visibility expression too complex", "limit.visibility"); + return; + } + if (!plain(v)) { + add(p, "invalid visibility expression", "ui.visibility"); + return; + } + if ("parameter" in v) { + const hasEquals = Object.hasOwn(v, "equals"), + hasIn = Object.hasOwn(v, "in"); + if (hasEquals === hasIn) { + add(p, "visibility requires exactly equals or in", "ui.visibility"); + return; + } + exact(v, ["parameter", hasEquals ? "equals" : "in"], p); + const def = typeof v.parameter === "string" && plain(paramDefs[v.parameter]) ? paramDefs[v.parameter] : undefined; + if (!def) add(p + "/parameter", "unknown parameter", "reference.parameter"); + const values = hasEquals ? [v.equals] : v.in; + if (!hasEquals && (!Array.isArray(values) || !values.length)) { + add(p + "/in", "must be a nonempty array", "ui.visibility"); + return; + } + if (Array.isArray(values)) + values.forEach((z, i) => { + if (!primitive(def as Record | undefined, z)) + add(`${p}/${hasEquals ? "equals" : `in/${i}`}`, "comparison does not match schema", "ui.visibility"); + }); + return; + } + const hasAll = Object.hasOwn(v, "all"), + hasAny = Object.hasOwn(v, "any"); + if (hasAll === hasAny) { + add(p, "visibility requires exactly all or any", "ui.visibility"); + return; + } + const op = hasAll ? "all" : "any"; + exact(v, [op], p); + if (!Array.isArray(v[op]) || v[op].length === 0) add(p + `/${op}`, "must be a nonempty array", "ui.visibility"); + else + for (let i = 0; i < v[op].length && issues.length < FXNODE_COMPOSITION_LIMITS.maxIssues; i++) + visibility(v[op][i], `${p}/${op}/${i}`, paramDefs, depth + 1, state); + }; + record(input.nodes, "/nodes", FXNODE_COMPOSITION_LIMITS.maxNodes, (node, p) => { + exact( + node, + ["version", "title", "behavior", "style", "parameters", "sockets", "ui", "muteBypass", "migrations"], + p, + ); + integer(node.version, p + "/version", 1); + str(node.title, p + "/title"); + if (typeof node.behavior !== "string" || !["standard", "frame", "reroute"].includes(node.behavior)) + add(p + "/behavior", "invalid behavior", "shape.literal"); + if (typeof node.style !== "string" || !styleIds.has(node.style)) + add(p + "/style", "unknown node style", "reference.style"); + const paramDefs = plain(node.parameters) ? node.parameters : {}; + const socketDefs = plain(node.sockets) ? node.sockets : {}; + const params = new Set(Object.keys(paramDefs)), + sockets = new Set(Object.keys(socketDefs)); + record(node.parameters, p + "/parameters", FXNODE_COMPOSITION_LIMITS.maxParametersPerNode, (x, q) => schema(x, q)); + record(node.sockets, p + "/sockets", FXNODE_COMPOSITION_LIMITS.maxSocketsPerNode, (x, q) => { + exact(x, ["title", "direction", "type", "maxIncomingLinks", "visible", "value", "showValue"], q); + str(x.title, q + "/title"); + if (typeof x.direction !== "string" || !["input", "output"].includes(x.direction)) + add(q + "/direction", "invalid direction", "socket.direction"); + if (typeof x.type !== "string" || !socketIds.has(x.type)) + add(q + "/type", "unknown socket type", "reference.socketType"); + integer(x.maxIncomingLinks, q + "/maxIncomingLinks"); + if (typeof x.visible !== "boolean" || typeof x.showValue !== "boolean") + add(q, "visibility flags must be boolean", "socket.flags"); + if (x.value !== null) schema(x.value, q + "/value"); + if (x.direction === "input" && Number(x.maxIncomingLinks) <= 0) + add(q + "/maxIncomingLinks", "input requires a positive limit", "socket.links"); + if (x.direction === "output" && (x.maxIncomingLinks !== 0 || x.value !== null || x.showValue !== false)) + add(q, "output payload must be null and hidden", "socket.output"); + if (x.showValue === true && x.value === null) add(q + "/value", "shown sockets require a value", "socket.value"); + }); + const placements = new Map(), + visState = { n: 0 }; + const place = (key: string, q: string) => { + const old = placements.get(key); + if (old) add(q, "duplicate or conflicting UI placement", "ui.placement"); + else placements.set(key, q); + }; + if (!Array.isArray(node.ui)) add(p + "/ui", "must be an array", "ui.shape"); + else { + if (node.ui.length > FXNODE_COMPOSITION_LIMITS.maxUiRowsPerNode) add(p + "/ui", "too many rows", "limit.ui"); + for (let i = 0; i < node.ui.length && issues.length < FXNODE_COMPOSITION_LIMITS.maxIssues; i++) { + const r = node.ui[i], + q = `${p}/ui/${i}`; + if (!plain(r)) { + add(q, "invalid UI row", "ui.shape"); + continue; + } + const common = r.visibleWhen !== undefined ? ["visibleWhen"] : []; + let req: string[] = []; + if (r.kind === "parameter") req = ["kind", "parameter", ...("title" in r ? ["title"] : []), ...common]; + else if (r.kind === "socket") req = ["kind", "socket", ...("title" in r ? ["title"] : []), ...common]; + else if (r.kind === "widget" && r.widget === "color-ramp") + req = ["kind", "widget", "parameter", ...("title" in r ? ["title"] : []), ...common]; + else if (r.kind === "widget" && r.widget === "grading-wheels") req = ["kind", "widget", "bindings", ...common]; + else if (r.kind === "widget") { + add(q + "/widget", "unknown widget", "ui.widget"); + continue; + } else if (r.kind === "resource") + req = [ + "kind", + "resource", + "parameter", + ...("title" in r ? ["title"] : []), + ...("openTitle" in r ? ["openTitle"] : []), + ...common, + ]; + else if (r.kind === "text") req = ["kind", "variant", "title", ...common]; + else if (r.kind === "hidden" && (r.target === "parameter" || r.target === "socket")) + req = ["kind", "target", r.target === "socket" ? "socket" : "parameter"]; + else { + if (r.kind === "hidden") add(q + "/target", "invalid hidden target", "ui.target"); + else add(q + "/kind", "unknown UI variant", "ui.kind"); + continue; + } + exact(r, req, q); + if ("title" in r) str(r.title, q + "/title"); + if ("openTitle" in r) str(r.openTitle, q + "/openTitle"); + if ( + r.kind === "text" && + (typeof r.variant !== "string" || + !["header", "category", "section", "panel", "placeholder"].includes(r.variant)) + ) + add(q + "/variant", "invalid text variant", "ui.text"); + for (const field of ["parameter", "socket", "resource"]) + if (req.includes(field) && typeof r[field] !== "string") + add(q + `/${field}`, "must be a string", "shape.string"); + if (typeof r.parameter === "string") { + if (!params.has(r.parameter)) add(q + "/parameter", "unknown parameter", "reference.parameter"); + else if (r.kind !== "hidden" || r.target === "parameter") place(`p:${r.parameter}`, q); + } + if (typeof r.socket === "string") { + if (!sockets.has(r.socket)) add(q + "/socket", "unknown socket", "reference.socket"); + else place(`s:${r.socket}`, q); + } + if (typeof r.resource === "string" && !resourceIds.has(r.resource)) + add(q + "/resource", "unknown resource", "reference.resource"); + if (r.kind === "widget" && r.widget === "color-ramp") { + const d = plain(paramDefs[String(r.parameter)]) + ? (paramDefs[String(r.parameter)] as Record) + : undefined; + if (d?.type !== "json" || d.codec !== "color-ramp/v1") + add(q + "/parameter", "color ramp requires matching codec", "ui.widget"); + } + if (r.kind === "resource") { + const d = plain(paramDefs[String(r.parameter)]) + ? (paramDefs[String(r.parameter)] as Record) + : undefined; + if (d?.type !== "string") add(q + "/parameter", "resource requires string schema", "ui.resource"); + } + if (r.widget === "grading-wheels") { + if (!Array.isArray(r.bindings) || r.bindings.length !== 3) + add(q + "/bindings", "grading wheels require exactly 3 bindings", "ui.widget"); + if (Array.isArray(r.bindings)) + r.bindings.forEach((b, j) => { + const z = `${q}/bindings/${j}`; + if (!plain(b)) { + add(z, "invalid binding", "ui.widget"); + return; + } + exact(b, ["title", "scalar", "color"], z); + str(b.title, z + "/title"); + const sd = plain(paramDefs[String(b.scalar)]) + ? (paramDefs[String(b.scalar)] as Record) + : undefined, + cd = plain(paramDefs[String(b.color)]) + ? (paramDefs[String(b.color)] as Record) + : undefined; + if (sd?.type !== "number") add(z + "/scalar", "scalar requires number schema", "ui.widget"); + if (cd?.type !== "color") add(z + "/color", "color requires color schema", "ui.widget"); + if (b.scalar === b.color) add(z, "bindings must differ", "ui.widget"); + if (typeof b.scalar === "string" && params.has(b.scalar)) place(`p:${b.scalar}`, z); + if (typeof b.color === "string" && params.has(b.color)) place(`p:${b.color}`, z); + }); + } + if (r.visibleWhen !== undefined) visibility(r.visibleWhen, q + "/visibleWhen", paramDefs, 0, visState); + } + } + for (const key of params) + if (!placements.has(`p:${key}`)) + add(`${p}/parameters/${esc(key)}`, "parameter has no UI placement", "ui.placement"); + for (const key of sockets) + if (!placements.has(`s:${key}`)) add(`${p}/sockets/${esc(key)}`, "socket has no UI placement", "ui.placement"); + if (!Array.isArray(node.muteBypass)) add(p + "/muteBypass", "must be an array", "shape.array"); + else { + const pairs = new Set(); + node.muteBypass.forEach((b, i) => { + const q = `${p}/muteBypass/${i}`; + if (!Array.isArray(b) || b.length !== 2 || b.some((x) => typeof x !== "string" || !sockets.has(x))) + add(q, "invalid socket bypass", "reference.socket"); + else { + const pair = `${b[0]}\0${b[1]}`; + if (pairs.has(pair)) add(q, "duplicate bypass", "value.duplicate"); + pairs.add(pair); + const a = socketDefs[b[0]] as Record, + z = socketDefs[b[1]] as Record, + dest = + plain(input.socketTypes) && plain(input.socketTypes[String(a?.type)]) + ? (input.socketTypes[String(a?.type)] as Record) + : undefined; + if (a?.direction !== "input" || z?.direction !== "output") + add(q, "bypass direction is invalid", "socket.bypass"); + else if ( + !Array.isArray(dest?.acceptsFrom) || + (!( + plain(input.compatibility) && + Array.isArray(input.compatibility.wildcardInputTypes) && + input.compatibility.wildcardInputTypes.includes(a.type) + ) && + !dest.acceptsFrom.includes(z.type)) + ) + add(q, "incompatible bypass", "socket.compatibility"); + } + }); + } + if (!Array.isArray(node.migrations)) add(p + "/migrations", "must be an array", "migration.shape"); + else { + if (node.migrations.length > FXNODE_COMPOSITION_LIMITS.maxMigrationsPerNode) + add(p + "/migrations", "too many migrations", "limit.migrations"); + const outgoing = new Set(); + node.migrations.forEach((m, i) => { + const q = `${p}/migrations/${i}`; + if (!plain(m)) { + add(q, "invalid migration", "migration.shape"); + return; + } + exact(m, ["fromVersion", "toVersion", "steps"], q); + integer(m.fromVersion, q + "/fromVersion", 1); + integer(m.toVersion, q + "/toVersion", 1); + if (outgoing.has(m.fromVersion)) add(q + "/fromVersion", "duplicate outgoing migration", "migration.outgoing"); + outgoing.add(m.fromVersion); + if (Number(m.fromVersion) >= Number(m.toVersion) || Number(m.toVersion) > Number(node.version)) + add(q, "invalid migration version edge", "migration.edge"); + if (!Array.isArray(m.steps)) add(q + "/steps", "must be array", "migration.shape"); + else { + const renameSources = new Set(); + const renameDestinations = new Set(); + const parameterWriters = new Set(); + const socketWriters = new Set(); + const materializedParameters = new Set(); + if (m.steps.length > FXNODE_COMPOSITION_LIMITS.maxMigrationSteps) + add(q + "/steps", "too many steps", "limit.migrations"); + m.steps.forEach((s, j) => { + if (!plain(s)) { + add(`${q}/steps/${j}`, "invalid step", "migration.shape"); + return; + } + const props = + s.kind === "rename-parameter" || s.kind === "rename-socket" + ? ["kind", "from", "to"] + : s.kind === "materialize-missing" + ? ["kind", "target", "key"] + : s.kind === "migrate-parameter" + ? ["kind", "parameter", "codec"] + : []; + const z = `${q}/steps/${j}`; + if (!props.length) { + add(z + "/kind", "unknown migration step", "migration.kind"); + return; + } + exact(s, props, z); + if (s.kind === "rename-parameter" || s.kind === "rename-socket") { + const domain = s.kind === "rename-parameter" ? "parameter" : "socket"; + const source = `${domain}:${String(s.from)}`; + const destination = `${domain}:${String(s.to)}`; + if (s.from === s.to || renameSources.has(source) || renameDestinations.has(destination)) + add(z + "/from", "conflicting rename source or destination", "migration.rename"); + renameSources.add(source); + renameDestinations.add(destination); + const writers = domain === "parameter" ? parameterWriters : socketWriters; + if (writers.has(String(s.to))) + add(z + "/to", "multiple migration steps write this target", "migration.write"); + writers.add(String(s.to)); + id(s.from, z + "/from"); + const targets = s.kind === "rename-parameter" ? params : sockets; + if (typeof s.to !== "string" || !targets.has(s.to)) + add(z + "/to", "unknown current target", "reference.migrationTarget"); + } else if (s.kind === "materialize-missing") { + const targets = s.target === "parameter" ? params : s.target === "socket" ? sockets : null; + if (!targets) add(z + "/target", "invalid target", "migration.target"); + else if (typeof s.key !== "string" || !targets.has(s.key)) + add(z + "/key", "unknown current target", "reference.migrationTarget"); + else { + const writers = s.target === "parameter" ? parameterWriters : socketWriters; + if (writers.has(s.key)) + add(z + "/key", "multiple migration steps write this target", "migration.write"); + writers.add(s.key); + if (s.target === "parameter") materializedParameters.add(s.key); + } + } else { + if (typeof s.parameter === "string") { + if (parameterWriters.has(s.parameter) && !materializedParameters.has(s.parameter)) + add(z + "/parameter", "multiple migration steps write this target", "migration.write"); + parameterWriters.add(s.parameter); + } + if (typeof s.parameter !== "string" || !params.has(s.parameter)) + add(z + "/parameter", "unknown parameter", "reference.parameter"); + else { + const target = plain(paramDefs[s.parameter]) + ? (paramDefs[s.parameter] as Record) + : undefined; + if (target?.type !== "json" || target.codec !== "color-ramp/v1") + add(z + "/parameter", "migration requires a color-ramp/v1 json target", "migration.codec"); + } + if (s.codec !== "color-ramp/legacy-stops") + add(z + "/codec", "unknown migration codec", "migration.codec"); + } + }); + } + }); + } + }); + return issues.length + ? Object.freeze({ ok: false as const, issues: Object.freeze(issues) }) + : Object.freeze({ + ok: true as const, + value: input as unknown as FxNodeComposition, + }); +} diff --git a/vendor/fxnode/src/composition/value-matcher.ts b/vendor/fxnode/src/composition/value-matcher.ts new file mode 100644 index 0000000..6ee6a6e --- /dev/null +++ b/vendor/fxnode/src/composition/value-matcher.ts @@ -0,0 +1,33 @@ +import { isColorRamp } from "../widgets/color-ramp.js"; +import { isJson, isRecord } from "../core/json.js"; +import type { FxNodeValueSchema } from "./types.js"; + +const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value); +/** Matches a persisted tagged value against a composition-owned value schema. */ +export function matchesFxNodeValueSchema(schema: FxNodeValueSchema, value: unknown): boolean { + if (!isRecord(value) || value.kind !== schema.type || !("value" in value)) return false; + const v = value.value; + if (schema.type === "number") + return ( + finite(v) && + (!schema.integer || Number.isSafeInteger(v)) && + (schema.minimum === undefined || v >= schema.minimum) && + (schema.maximum === undefined || v <= schema.maximum) + ); + if (schema.type === "string") return typeof v === "string" && (!schema.enum || schema.enum.includes(v)); + if (schema.type === "boolean") return typeof v === "boolean"; + if (schema.type === "vector" || schema.type === "color") { + const n = schema.type === "vector" ? 3 : 4; + return ( + Array.isArray(v) && + v.length === n && + v.every( + (x) => + finite(x) && + (schema.minimum === undefined || x >= schema.minimum) && + (schema.maximum === undefined || x <= schema.maximum), + ) + ); + } + return schema.codec === "color-ramp/v1" ? isColorRamp(v) : isJson(v); +} diff --git a/vendor/fxnode/src/core/json.ts b/vendor/fxnode/src/core/json.ts new file mode 100644 index 0000000..3f4b809 --- /dev/null +++ b/vendor/fxnode/src/core/json.ts @@ -0,0 +1,188 @@ +import type { JsonValue } from "./types.js"; + +export interface StructuredDataLimits { + readonly maxValues: number; + readonly maxStringCodeUnits: number; + readonly maxDepth: number; + readonly maxIssues: number; +} +export interface StructuredDataIssue { + readonly code: string; + readonly path: string; + readonly message: string; +} +export interface StructuredDataMetrics { + readonly values: number; + readonly stringCodeUnits: number; + readonly depth: number; +} +export type StructuredDataAdmissionResult = + | { readonly ok: true; readonly value: unknown; readonly metrics: StructuredDataMetrics } + | { readonly ok: false; readonly issues: readonly StructuredDataIssue[] }; + +/** Inspects hostile input without invoking accessors and returns a detached structured clone. */ +export function admitStructuredData(input: unknown, limits: StructuredDataLimits): StructuredDataAdmissionResult { + const issues: StructuredDataIssue[] = [], + seen = new WeakSet(); + let values = 0, + strings = 0, + maxDepth = 0, + terminal = false; + const add = (code: string, path: string, message: string) => { + if (issues.length < limits.maxIssues) issues.push({ code, path, message }); + }; + const walk = (v: unknown, path: string, depth: number): void => { + try { + maxDepth = Math.max(maxDepth, depth); + if (terminal) return; + if (++values > limits.maxValues) { + add("limit.values", path, "Document exceeds inspected value limit"); + terminal = true; + return; + } + if (depth > limits.maxDepth) { + add("limit.depth", path, "Document exceeds JSON depth limit"); + terminal = true; + return; + } + if (typeof v === "string") { + strings += v.length; + if (strings > limits.maxStringCodeUnits) { + add("limit.strings", path, "Document exceeds string limit"); + terminal = true; + } + return; + } + if (v === null || typeof v === "boolean") return; + if (typeof v === "number") { + if (!Number.isFinite(v)) add("value.finite", path, "Numbers must be finite"); + return; + } + if (typeof v !== "object") { + add("data.type", path, "Unsupported value"); + return; + } + if (seen.has(v)) { + add("data.identity", path, "Shared or cyclic identity is unsupported"); + return; + } + seen.add(v); + if (Array.isArray(v)) { + if (Object.getPrototypeOf(v) !== Array.prototype) add("data.array", path, "Array must be ordinary"); + const ds = Object.getOwnPropertyDescriptors(v), + ld = Object.getOwnPropertyDescriptor(v, "length"), + length = ld && "value" in ld ? ld.value : undefined; + if (!Number.isSafeInteger(length)) { + add("data.array", path, "Invalid array length"); + return; + } + if (Number(length) > limits.maxValues - values) { + add("limit.values", path, "Document exceeds inspected value limit"); + terminal = true; + return; + } + for (let i = 0; i < Number(length); i++) { + if (terminal) break; + const d = ds[String(i)]; + if (!d) { + add("data.array", `${path}/${i}`, "Sparse arrays are unsupported"); + continue; + } + if (!d.enumerable || !("value" in d)) { + add("data.inspect", `${path}/${i}`, "Element must be an enumerable data property"); + continue; + } + walk(d.value, `${path}/${i}`, depth + 1); + } + for (const k of Reflect.ownKeys(ds)) { + if (terminal) break; + if (k !== "length" && !(typeof k === "string" && /^(0|[1-9]\d*)$/.test(k) && Number(k) < Number(length))) + add(typeof k === "symbol" ? "data.symbol" : "data.array", path, "Extra array properties are unsupported"); + } + return; + } + const proto = Object.getPrototypeOf(v); + if (proto !== Object.prototype && proto !== null) { + add("data.type", path, "Object must be an ordinary record"); + return; + } + for (const k of Reflect.ownKeys(v)) { + if (typeof k !== "string") { + add("data.symbol", path, "Symbol properties are unsupported"); + continue; + } + strings += k.length; + if (strings > limits.maxStringCodeUnits) { + add("limit.strings", path, "Document exceeds string limit"); + terminal = true; + return; + } + const d = Object.getOwnPropertyDescriptor(v, k); + if (!d || !d.enumerable || !("value" in d)) { + add("data.inspect", `${path}/${k}`, "Property must be enumerable data"); + continue; + } + walk(d.value, `${path}/${k}`, depth + 1); + } + } catch { + add("data.inspect", path, "Value could not be inspected"); + terminal = true; + } + }; + walk(input, "", 0); + if (issues.length) return { ok: false, issues }; + try { + return { + ok: true, + value: structuredClone(input), + metrics: Object.freeze({ values, stringCodeUnits: strings, depth: maxDepth }), + }; + } catch { + return { ok: false, issues: [{ code: "data.clone", path: "/", message: "Document could not be cloned" }] }; + } +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isJson(value: unknown, depth = 0): value is JsonValue { + if (depth > 50) return false; + if (value === null || typeof value === "string" || typeof value === "boolean") return true; + if (typeof value === "number") return Number.isFinite(value); + if (Array.isArray(value)) return value.every((item) => isJson(item, depth + 1)); + return isRecord(value) && Object.values(value).every((item) => isJson(item, depth + 1)); +} + +export function deepFreeze(value: T): T { + if (value !== null && typeof value === "object" && !Object.isFrozen(value)) { + for (const child of Object.values(value)) deepFreeze(child); + Object.freeze(value); + } + return value; +} + +export function cloneJson(value: T): T { + return deepFreeze(structuredClone(value)); +} + +export function nullRecord(entries: Iterable = []): Readonly> { + const result = Object.create(null) as Record; + for (const [key, value] of entries) result[key] = value; + return Object.freeze(result); +} + +export function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalize); + if (isRecord(value)) { + const result = Object.create(null) as Record; + for (const key of Object.keys(value).sort()) result[key] = canonicalize(value[key]); + return result; + } + return value; +} + +/** Canonical structural equality for admitted JSON-shaped values. */ +export function canonicalJsonEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right)); +} diff --git a/vendor/fxnode/src/core/types.ts b/vendor/fxnode/src/core/types.ts new file mode 100644 index 0000000..67dda5a --- /dev/null +++ b/vendor/fxnode/src/core/types.ts @@ -0,0 +1,113 @@ +import type { FxNodeCompositionData, NodeTypeId } from "../composition/types.js"; + +declare const brand: unique symbol; +export type NodeId = string & { readonly [brand]: "NodeId" }; +export type LinkId = string & { readonly [brand]: "LinkId" }; +export type SocketId = string & { readonly [brand]: "SocketId" }; +export type CommandId = string & { readonly [brand]: "CommandId" }; +export type GraphId = string & { readonly [brand]: "GraphId" }; +export type JsonValue = null | boolean | number | string | readonly JsonValue[] | { readonly [key: string]: JsonValue }; +export interface Vec2 { + readonly x: number; + readonly y: number; +} +export type SocketDataType = string; +export type ParameterValue = + | { readonly kind: "number"; readonly value: number } + | { readonly kind: "boolean"; readonly value: boolean } + | { readonly kind: "string"; readonly value: string } + | { readonly kind: "vector"; readonly value: readonly [number, number, number] } + | { readonly kind: "color"; readonly value: readonly [number, number, number, number] } + | { readonly kind: "json"; readonly value: JsonValue }; + +export interface Socket { + readonly id: SocketId; + readonly key: string; + readonly label: string; + readonly direction: "input" | "output"; + readonly dataType: SocketDataType; + readonly accepts: readonly SocketDataType[]; + readonly maxIncomingLinks: number; + readonly defaultValue?: ParameterValue | null | undefined; + readonly visible: boolean; + readonly metadata?: Readonly>; +} +export interface NodeBase { + readonly id: NodeId; + readonly typeId: string; + readonly typeVersion: number; + /** Upper-left origin. Child positions are local to their parent frame. +Y is up. */ + readonly position: Vec2; + /** Positive logical dimensions. */ + readonly size: Vec2; + readonly label: string; + readonly parameters: Readonly>; + readonly sockets: readonly Socket[]; + readonly muted: boolean; + readonly collapsed: boolean; + readonly parentId?: NodeId | undefined; + readonly extensions: Readonly>; +} +export interface KnownNode extends NodeBase { + readonly known: true; + readonly typeId: NodeTypeId; + readonly parameters: Readonly>; +} +export interface UnknownNode extends NodeBase { + readonly known: false; +} +export type GraphNode = KnownNode | UnknownNode; +export interface GraphLink { + readonly id: LinkId; + readonly fromNodeId: NodeId; + readonly fromSocketId: SocketId; + readonly toNodeId: NodeId; + readonly toSocketId: SocketId; + readonly muted: boolean; + readonly extensions: Readonly>; +} +export interface GraphDocument { + readonly schemaVersion: 2; + readonly graphId: GraphId; + readonly catalogVersion: number; + readonly nodes: Readonly>>; + readonly links: Readonly>; + readonly metadata: Readonly>; +} + +export interface GraphLayoutNodeV1 extends Omit { + readonly parameters: Readonly>; +} +export interface GraphLinkV1 extends Omit {} +export interface GraphLayoutV1 { + readonly schemaVersion: 1; + readonly graphId: GraphId; + readonly catalogVersion: number; + readonly nodes: readonly GraphLayoutNodeV1[]; + readonly links: readonly GraphLinkV1[]; + readonly metadata: Readonly>; +} +export interface GraphLayoutV2 { + readonly schemaVersion: 2; + readonly graphId: GraphId; + readonly catalogVersion: number; + readonly nodes: readonly GraphLayoutNodeV1[]; + readonly links: readonly GraphLink[]; + readonly metadata: Readonly>; +} +export interface GraphState { + readonly graphId: GraphId; + readonly catalogVersion: number; + readonly nodes: readonly GraphNode[]; + readonly links: readonly GraphLink[]; + readonly metadata: Readonly>; +} +export interface GraphSnapshot extends GraphState { + readonly version: number; +} + +export const nodeId = (value: string): NodeId => value as NodeId; +export const linkId = (value: string): LinkId => value as LinkId; +export const socketId = (value: string): SocketId => value as SocketId; +export const commandId = (value: string): CommandId => value as CommandId; +export const graphId = (value: string): GraphId => value as GraphId; diff --git a/vendor/fxnode/src/engine/mutations.ts b/vendor/fxnode/src/engine/mutations.ts new file mode 100644 index 0000000..c5f6c74 --- /dev/null +++ b/vendor/fxnode/src/engine/mutations.ts @@ -0,0 +1,29 @@ +import type { FxNodeCompositionData } from "../composition/types.js"; +import type { GraphDocument, GraphLink, GraphNode, LinkId, NodeId } from "../core/types.js"; + +export type Mutation = + | { + readonly kind: "node.set"; + readonly id: NodeId; + readonly before: GraphNode | null; + readonly after: GraphNode | null; + } + | { + readonly kind: "link.set"; + readonly id: LinkId; + readonly before: GraphLink | null; + readonly after: GraphLink | null; + } + | { readonly kind: "document.replaced"; readonly before: GraphDocument; readonly after: GraphDocument }; +export function invert(mutations: readonly Mutation[]): readonly Mutation[] { + return mutations + .slice() + .reverse() + .map((mutation) => + mutation.kind === "node.set" + ? { kind: "node.set", id: mutation.id, before: mutation.after, after: mutation.before } + : mutation.kind === "link.set" + ? { kind: "link.set", id: mutation.id, before: mutation.after, after: mutation.before } + : { kind: "document.replaced", before: mutation.after, after: mutation.before }, + ); +} diff --git a/vendor/fxnode/src/engine/reducer.ts b/vendor/fxnode/src/engine/reducer.ts new file mode 100644 index 0000000..249d846 --- /dev/null +++ b/vendor/fxnode/src/engine/reducer.ts @@ -0,0 +1,28 @@ +import { deepFreeze, nullRecord } from "../core/json.js"; +import type { FxNodeCompositionData } from "../composition/types.js"; +import type { GraphDocument } from "../core/types.js"; +import type { Mutation } from "./mutations.js"; + +export function reduceMutations( + document: GraphDocument, + mutations: readonly Mutation[], +): GraphDocument { + let nodes = document.nodes; + let links = document.links; + for (const mutation of mutations) { + if (mutation.kind === "document.replaced") return mutation.after; + if (mutation.kind === "node.set") + nodes = nullRecord( + Object.entries(nodes) + .filter(([key]) => key !== mutation.id) + .concat(mutation.after ? [[mutation.id, mutation.after]] : []), + ); + else + links = nullRecord( + Object.entries(links) + .filter(([key]) => key !== mutation.id) + .concat(mutation.after ? [[mutation.id, mutation.after]] : []), + ); + } + return deepFreeze({ ...document, nodes, links }); +} diff --git a/vendor/fxnode/src/headless-runtime.ts b/vendor/fxnode/src/headless-runtime.ts new file mode 100644 index 0000000..11a1403 --- /dev/null +++ b/vendor/fxnode/src/headless-runtime.ts @@ -0,0 +1,70 @@ +import { compileFxNodeComposition } from "./composition/compile.js"; +import { bindDocument } from "./composition/bound-document.js"; +import { bindEngine } from "./composition/bound-engine.js"; +import type { CompiledFxNodeComposition, FxNodeCompositionData, NodeTypeId } from "./composition/types.js"; +import type { ReferenceCheck } from "./composition/references.js"; +import type { CommandId, GraphDocument, GraphLayoutV2, GraphNode, GraphSnapshot, Socket, Vec2 } from "./core/types.js"; +import type { CommandRequest, FxNodeReplayCommand } from "./commands/types.js"; +import type { DecodeResult, ValidationIssue } from "./composition/bound-document.js"; +import type { + EngineState, + LoadResult, + ReplayResult, + StateReplacementRequest, + TransitionResult, +} from "./composition/bound-engine.js"; + +/** Explicit immutable document and engine operations bound to one composition authority. */ +export interface FxNodeHeadless { + /** Creates an empty immutable document for this composition. */ + emptyDocument(id?: string): GraphDocument; + /** Materializes a known node using its definition's defaults. */ + materializeNode(id: string, typeId: NodeTypeId, position?: Vec2, parentId?: string): GraphNode; + /** Validates an immutable document without changing it. */ + validateDocument(document: GraphDocument): readonly ValidationIssue[]; + /** Decodes and validates durable graph data under this composition. */ + decodeGraphDocument(source: unknown): DecodeResult; + /** Decodes a graph-state shaped value into a validated document. */ + decodeGraphState(source: unknown): DecodeResult; + /** Parses JSON text and decodes it as a graph document. */ + parseGraphDocument(text: string): DecodeResult; + /** Converts a validated document to its stable, durable layout form. */ + save(document: GraphDocument): GraphLayoutV2; + /** Serializes the stable durable layout as canonical JSON. */ + serializeGraphDocument(document: GraphDocument): string; + socketsCompatible( + from: Pick, + to: Pick, + ): boolean; + /** Creates version-zero engine state; history retains 100 entries by default, or the supplied nonnegative limit. */ + createEngine(document: GraphDocument, historyLimit?: number): EngineState; + /** Immutably applies a command. Committed results increment version; no-op and rejected results retain state/version. */ + transition(state: EngineState, request: CommandRequest): TransitionResult; + /** Atomically replaces graph state when `expectedVersion` matches; invalid or stale requests are rejected unchanged. */ + replaceState(state: EngineState, request: StateReplacementRequest): TransitionResult; + /** Atomically decodes compatible durable data and replaces state; failures preserve the original state. */ + load(state: EngineState, value: unknown, expectedVersion?: number, id?: CommandId): LoadResult; + /** Validates compatibility and atomically replays save data; any invalid command preserves the original state. */ + replaySaveData(state: EngineState, value: unknown, expectedVersion?: number, id?: CommandId): ReplayResult; + validateReplayJournal( + baseline: GraphLayoutV2, + commands: readonly FxNodeReplayCommand[], + historyLimit?: number, + ): boolean; + /** Returns a deeply immutable snapshot carrying the engine state's current version. */ + getState(state: EngineState): GraphSnapshot; +} +/** @internal Binds an already compiled authority without recompiling it. */ +export function bindFxNodeHeadless( + compiled: CompiledFxNodeComposition, +): FxNodeHeadless { + return Object.freeze({ ...bindDocument(compiled), ...bindEngine(compiled) }); +} + +/** Creates an isolated composition-bound document and engine runtime. The composition is compiled exactly once. */ +export function createFxNodeHeadless( + composition: C & ReferenceCheck, +): FxNodeHeadless { + const compiled = compileFxNodeComposition(composition); + return bindFxNodeHeadless(compiled); +} diff --git a/vendor/fxnode/src/headless.ts b/vendor/fxnode/src/headless.ts new file mode 100644 index 0000000..18e2e35 --- /dev/null +++ b/vendor/fxnode/src/headless.ts @@ -0,0 +1,29 @@ +/** + * Composition-bound graph and command execution without browser or worker resources. + * @module fxnode/headless + */ +export * from "./core/types.js"; +export type { + Command, + BatchCommand, + FxNodeReplayCommand, + FxNodeSaveData, + CompatibleFxNodeSaveData, + CommandRequest, + CommandError, +} from "./commands/types.js"; +export { FXNODE_SAVE_DATA_LIMITS } from "./commands/save-data.js"; +export type { Mutation } from "./engine/mutations.js"; +export type { DecodeResult, ValidationIssue } from "./composition/bound-document.js"; +export type { + EngineState, + LoadResult, + ReplayResult, + MutationEnvelope, + SnapshotEnvelope, + StateReplacementRequest, + TransitionResult, +} from "./composition/bound-engine.js"; +export * from "./composition/index.js"; +export type { FxNodeHeadless } from "./headless-runtime.js"; +export { createFxNodeHeadless } from "./headless-runtime.js"; diff --git a/vendor/fxnode/src/index.ts b/vendor/fxnode/src/index.ts new file mode 100644 index 0000000..14e1f5c --- /dev/null +++ b/vendor/fxnode/src/index.ts @@ -0,0 +1,30 @@ +/** + * Browser client and static composition API for fxnode. + * + * The root client owns graph/composition authority and its worker until {@link FxNode.destroy}. + * Canvases and host interaction belong to independently attached {@link FxNodeView} handles. + * @module fxnode + */ +export * from "./browser/client.js"; +export type { + FxNodeModifiers, + FxNodeInput, + FxNodeViewport, + FxNodeCamera, + FxNodeHostSnapshot, + AddNodeParams, + FxNodeActionOptions, + FxNodeSelectionSnapshot, + FxNodeAddNodeMenuRequest, + FxNodeResourceAuthorization, + FxNodeImageResourceDescriptor, + FxNodeResourceOpenRequest, + FxNodeResourceData, + FxNodeHostRequest, +} from "./browser/host-types.js"; +export { FXNODE_VIEW_LIMITS, fxNodeDevicePixels } from "./browser/view-limits.js"; +export * from "./core/types.js"; +export type { Command, BatchCommand, FxNodeReplayCommand, FxNodeSaveData } from "./commands/types.js"; +export type { Mutation } from "./engine/mutations.js"; +export type { MutationEnvelope, SnapshotEnvelope } from "./composition/bound-engine.js"; +export * from "./composition/index.js"; diff --git a/vendor/fxnode/src/layout/color-picker-layout.ts b/vendor/fxnode/src/layout/color-picker-layout.ts new file mode 100644 index 0000000..f2a8469 --- /dev/null +++ b/vendor/fxnode/src/layout/color-picker-layout.ts @@ -0,0 +1,33 @@ +import type { ColorPickerLayout, Rect } from "./types.js"; +import type { Vec2 } from "../core/types.js"; +export function layoutColorPicker(anchor: Rect, viewport: Vec2): ColorPickerLayout { + const padding = 10, + gap = 8, + planeSize = 176, + strip = 18, + width = 250, + height = 326; + let x = anchor.x + anchor.width + 8, + y = anchor.y; + if (x + width > viewport.x - 8) x = anchor.x - width - 8; + if (y + height > viewport.y - 8) y = viewport.y - height - 8; + x = Math.max(8, x); + y = Math.max(8, y); + const fields = (count: number, yy: number) => + Array.from({ length: count }, (_, i) => ({ + x: x + padding + (i * (width - padding * 2 + 4)) / count, + y: yy, + width: (width - padding * 2 - (count - 1) * 4) / count, + height: 22, + })); + return { + bounds: { x, y, width, height }, + confirm: { x: x + 6, y: y + 4, width: 24, height: 24 }, + plane: { x: x + padding, y: y + 32, width: planeSize, height: planeSize }, + lightness: { x: x + padding + planeSize + gap, y: y + 32, width: strip, height: planeSize }, + alpha: { x: x + padding + planeSize + gap + strip + gap, y: y + 32, width: strip, height: planeSize }, + rgba: fields(4, y + 220) as unknown as ColorPickerLayout["rgba"], + hsv: fields(3, y + 250) as unknown as ColorPickerLayout["hsv"], + hex: { x: x + padding, y: y + 280, width: width - padding * 2, height: 24 }, + }; +} diff --git a/vendor/fxnode/src/layout/constants.ts b/vendor/fxnode/src/layout/constants.ts new file mode 100644 index 0000000..2150ab8 --- /dev/null +++ b/vendor/fxnode/src/layout/constants.ts @@ -0,0 +1,22 @@ +export const GEOMETRY = Object.freeze({ + unit: 20, + row: 24, + parameterRow: 26, + header: 24, + half: 12, + gap: 4, + corner: 7, + socket: 5, + margin: 24, + resize: 4, + grid: 20, + linkSamples: 12, + initialNodeWidth: 140, + initialNodeHeight: 100, + initialFrameWidth: 300, + minWidth: 100, + frameMinimum: 100, + maxWidth: 700, + frameMargin: 30, + reroute: 5, +}); diff --git a/vendor/fxnode/src/layout/geometry.ts b/vendor/fxnode/src/layout/geometry.ts new file mode 100644 index 0000000..b8ff2bd --- /dev/null +++ b/vendor/fxnode/src/layout/geometry.ts @@ -0,0 +1,27 @@ +import type { Vec2 } from "../core/types.js"; +import type { Rect, ViewTransform } from "./types.js"; + +export const worldToView = (p: Vec2, t: ViewTransform): Vec2 => ({ + x: (p.x - t.center.x) * t.zoom + t.viewport.x / 2, + y: (t.center.y - p.y) * t.zoom + t.viewport.y / 2, +}); +export const viewToWorld = (p: Vec2, t: ViewTransform): Vec2 => ({ + x: (p.x - t.viewport.x / 2) / t.zoom + t.center.x, + y: t.center.y - (p.y - t.viewport.y / 2) / t.zoom, +}); +export const viewToDevice = (p: Vec2, t: ViewTransform): Vec2 => ({ x: p.x * t.dpr, y: p.y * t.dpr }); +export const deviceToView = (p: Vec2, t: ViewTransform): Vec2 => ({ x: p.x / t.dpr, y: p.y / t.dpr }); +export const intersects = (a: Rect, b: Rect, overscan = 0): boolean => + a.x + a.width >= b.x - overscan && + a.x <= b.x + b.width + overscan && + a.y - a.height <= b.y + overscan && + a.y >= b.y - b.height - overscan; +export function bounds(points: readonly Vec2[]): Rect { + const xs = points.map((p) => p.x); + const ys = points.map((p) => p.y); + const minX = Math.min(...xs), + maxX = Math.max(...xs), + minY = Math.min(...ys), + maxY = Math.max(...ys); + return { x: minX, y: maxY, width: maxX - minX, height: maxY - minY }; +} diff --git a/vendor/fxnode/src/layout/indexed-layout-store.ts b/vendor/fxnode/src/layout/indexed-layout-store.ts new file mode 100644 index 0000000..1509fd1 --- /dev/null +++ b/vendor/fxnode/src/layout/indexed-layout-store.ts @@ -0,0 +1,53 @@ +import type { GraphDocument, LinkId, NodeId } from "../core/types.js"; +import type { CompiledFxNodeComposition, FxNodeCompositionData } from "../composition/types.js"; +import { GEOMETRY as G } from "./constants.js"; +import { buildLayoutScene, createLayoutView } from "./layout-graph.js"; +import { LooseQuadtree } from "./spatial-index.js"; +import type { LayoutScene, LayoutView, Rect, ViewTransform } from "./types.js"; + +const box = (r: Rect) => ({ minX: r.x, minY: r.y - r.height, maxX: r.x + r.width, maxY: r.y }); +export interface LayoutStoreMetrics { + persistentRebuilds: number; + indexBuildMs: number; + indexQueries: number; + indexQueryMs: number; +} +export class IndexedLayoutStore { + scene: LayoutScene; + readonly nodeIndex = new LooseQuadtree(); + readonly linkIndex = new LooseQuadtree(); + readonly metrics: LayoutStoreMetrics = { persistentRebuilds: 0, indexBuildMs: 0, indexQueries: 0, indexQueryMs: 0 }; + constructor( + readonly compiled: CompiledFxNodeComposition, + document: GraphDocument, + ) { + this.scene = buildLayoutScene(compiled, document); + this.rebuild(document); + } + rebuild(document: GraphDocument): void { + const start = performance.now(); + this.scene = buildLayoutScene(this.compiled, document); + this.nodeIndex.clear(); + this.linkIndex.clear(); + for (const [id, n] of this.scene.nodes) this.nodeIndex.insert(id, id, box(n.bounds)); + for (const [id, l] of this.scene.links) this.linkIndex.insert(id, id, box(l.bounds)); + this.metrics.persistentRebuilds++; + this.metrics.indexBuildMs += performance.now() - start; + } + view(transform: ViewTransform): LayoutView { + const start = performance.now(), + w = transform.viewport.x / transform.zoom / 2 + G.margin, + h = transform.viewport.y / transform.zoom / 2 + G.margin, + q = { + minX: transform.center.x - w, + minY: transform.center.y - h, + maxX: transform.center.x + w, + maxY: transform.center.y + h, + }; + const nodes = this.nodeIndex.query(q), + links = this.linkIndex.query(q); + this.metrics.indexQueries++; + this.metrics.indexQueryMs += performance.now() - start; + return createLayoutView(this.scene, transform, nodes, links); + } +} diff --git a/vendor/fxnode/src/layout/layout-graph.ts b/vendor/fxnode/src/layout/layout-graph.ts new file mode 100644 index 0000000..1c5fa37 --- /dev/null +++ b/vendor/fxnode/src/layout/layout-graph.ts @@ -0,0 +1,588 @@ +import type { GraphDocument, GraphNode, LinkId, NodeId, SocketId, Vec2 } from "../core/types.js"; +import type { + CompiledFxNodeComposition, + FxNodeCompositionData, + FxNodeDefinition, + FxNodeUiRow, + FxNodeValueSchema, +} from "../composition/types.js"; +import { GEOMETRY as G } from "./constants.js"; +import { bounds, intersects } from "./geometry.js"; +import type { + LayoutControl, + LayoutLink, + LayoutNode, + LayoutNumericField, + LayoutRow, + LayoutScene, + LayoutSnapshot, + LayoutSocket, + LayoutSubfield, + LayoutView, + Rect, + ViewTransform, +} from "./types.js"; +import { effectivelyMutedLinks } from "./link-mute.js"; +import { minimumNodeSize, nodeRowUnits, visibleNodeItems } from "./node-dimensions.js"; + +const title = (value: string): string => value.replace(/-/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); +function controlKind(schema: FxNodeValueSchema | undefined): LayoutControl["kind"] { + if (!schema) return "readonly-json"; + if (schema.type === "string" && schema.enum) return "enum"; + return schema.type === "json" ? "readonly-json" : schema.type; +} +function makeSubfields(bounds: Rect, type: FxNodeValueSchema["type"] | undefined): readonly LayoutSubfield[] { + const labels = type === "vector" ? (["X", "Y", "Z"] as const) : []; + const gutter = 3; + const width = labels.length ? (bounds.width - gutter * (labels.length - 1)) / labels.length : 0; + return labels.map((label, index) => ({ + index, + label, + bounds: { x: bounds.x + index * (width + gutter), y: bounds.y, width, height: bounds.height }, + })); +} +function makeNumericFields( + bounds: Rect, + schema: FxNodeValueSchema | undefined, + subfields: readonly LayoutSubfield[], +): readonly LayoutNumericField[] { + const fields = schema?.type === "number" ? [{ index: 0, bounds }] : schema?.type === "vector" ? subfields : []; + const minimum = + schema?.type === "number" + ? (schema.softMin ?? schema.minimum) + : schema?.type === "vector" + ? schema.minimum + : undefined; + const maximum = + schema?.type === "number" + ? (schema.softMax ?? schema.maximum) + : schema?.type === "vector" + ? schema.maximum + : undefined; + const range = + Number.isFinite(minimum) && Number.isFinite(maximum) && maximum! > minimum! + ? { minimum: minimum!, maximum: maximum! } + : undefined; + return fields.map((field) => { + const arrow = Math.min(7, field.bounds.width * 0.14); + return { + component: field.index, + bounds: field.bounds, + decrement: { ...field.bounds, width: arrow }, + value: { ...field.bounds, x: field.bounds.x + arrow, width: Math.max(0, field.bounds.width - arrow * 2) }, + increment: { ...field.bounds, x: field.bounds.x + field.bounds.width - arrow, width: arrow }, + ...(range ? { range } : {}), + }; + }); +} +function cubic(a: Vec2, b: Vec2): readonly Vec2[] { + const dx = Math.max(40, Math.abs(b.x - a.x) * 0.5); + return Array.from({ length: G.linkSamples + 1 }, (_, index) => { + const t = index / G.linkSamples, + u = 1 - t; + return { + x: u ** 3 * a.x + 3 * u ** 2 * t * (a.x + dx) + 3 * u * t ** 2 * (b.x - dx) + t ** 3 * b.x, + y: u ** 3 * a.y + 3 * u ** 2 * t * a.y + 3 * u * t ** 2 * b.y + t ** 3 * b.y, + }; + }); +} +function cubicBounds(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2) { + const points = [p0, p3]; + for (const axis of ["x", "y"] as const) { + const a = -p0[axis] + 3 * p1[axis] - 3 * p2[axis] + p3[axis], + b = 2 * (p0[axis] - 2 * p1[axis] + p2[axis]), + c = p1[axis] - p0[axis], + d = b * b - 4 * a * c; + const roots = + Math.abs(a) < 1e-12 + ? Math.abs(b) < 1e-12 + ? [] + : [-c / b] + : d < 0 + ? [] + : [(-b + Math.sqrt(d)) / (2 * a), (-b - Math.sqrt(d)) / (2 * a)]; + for (const t of roots) + if (t > 0 && t < 1) { + const u = 1 - t; + points.push({ + x: u ** 3 * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t ** 3 * p3.x, + y: u ** 3 * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t ** 3 * p3.y, + }); + } + } + return bounds(points); +} +export function buildLayoutScene( + compiled: CompiledFxNodeComposition, + document: GraphDocument, +): LayoutScene { + const nodes = new Map(); + const sockets = new Map(); + const links = new Map(); + const controls = new Map(); + const sorted = Object.values(document.nodes).sort((a, b) => a.id.localeCompare(b.id)); + const effectiveMuted = effectivelyMutedLinks(compiled, document); + const linksBySocket = new Map(); + for (const link of Object.values(document.links)) + if (!effectiveMuted.has(link.id)) + for (const id of [link.fromSocketId, link.toSocketId]) { + const list = linksBySocket.get(id) ?? []; + list.push(link.id); + linksBySocket.set(id, list); + } + const childrenByParent = new Map(); + for (const node of sorted) + if (node.parentId) { + const list = childrenByParent.get(node.parentId) ?? []; + list.push(node); + childrenByParent.set(node.parentId, list); + } + const origins = new Map(), + depths = new Map(); + const resolve = (node: GraphNode): Vec2 => { + const known = origins.get(node.id); + if (known) return known; + const parent = node.parentId ? document.nodes[node.parentId] : undefined, + p = parent ? resolve(parent) : { x: 0, y: 0 }; + depths.set(node.id, parent ? depths.get(parent.id)! + 1 : 0); + const at = { x: p.x + node.position.x, y: p.y + node.position.y }; + origins.set(node.id, at); + return at; + }; + for (const node of sorted) { + const at = resolve(node), + descriptor = node.known ? (compiled.nodes.get(node.typeId) as FxNodeDefinition | undefined) : undefined; + if (node.known && !descriptor) throw new Error(`Missing compiled node definition: ${node.typeId}`); + const kind = descriptor?.behavior === "frame" ? "frame" : descriptor?.behavior === "reroute" ? "reroute" : "node"; + const descriptorSockets = new Map(Object.entries(descriptor?.sockets ?? {})); + const visibleSockets = node.sockets.filter((socket) => { + const row = descriptor?.ui.find( + (row) => + (row.kind === "socket" || (row.kind === "hidden" && row.target === "socket")) && row.socket === socket.key, + ); + return ( + socket.visible && (!descriptor || (row?.kind === "socket" && visibleNodeItems(descriptor, node).includes(row))) + ); + }); + const ui: readonly FxNodeUiRow[] = descriptor?.ui ?? [ + ...Object.keys(node.parameters) + .sort() + .map((parameter) => ({ kind: "parameter" as const, parameter })), + ...node.sockets.map((socket) => ({ kind: "socket" as const, socket: socket.key })), + ]; + const expandedItems = kind === "node" ? (descriptor ? visibleNodeItems(descriptor, node) : ui) : []; + const visibleItems = node.collapsed ? [] : expandedItems; + const contentHeight = + kind === "frame" + ? Math.max(G.frameMinimum, node.size.y) + : kind === "reroute" + ? G.reroute * 2 + : node.collapsed + ? G.header + : G.header + visibleItems.reduce((sum, item) => sum + nodeRowUnits(item), 0) * G.row + G.gap; + const calculated = descriptor ? minimumNodeSize(descriptor, node) : { x: G.minWidth, y: contentHeight }; + const minimumSize = { x: calculated.x, y: kind === "node" && node.collapsed ? G.header : calculated.y }; + const width = + kind === "reroute" + ? G.reroute * 2 + : kind === "frame" + ? Math.max(minimumSize.x, node.size.x) + : Math.min(G.maxWidth, Math.max(minimumSize.x, node.size.x)); + const height = kind === "node" && !node.collapsed ? Math.max(contentHeight, node.size.y) : contentHeight; + const nodeBounds = { x: at.x, y: at.y, width, height }; + const rowBySocket = new Map(); + let socketRowOffset = 0; + for (const item of visibleItems) { + if (item.kind === "socket") rowBySocket.set(item.socket, socketRowOffset); + socketRowOffset += nodeRowUnits(item); + } + const layoutSockets: LayoutSocket[] = visibleSockets.map((socket) => { + const linkIds = linksBySocket.get(socket.id) ?? []; + const linked = linkIds.length > 0; + const row = rowBySocket.get(socket.key) ?? 0; + const placement = descriptor?.ui.find((item) => item.kind === "socket" && item.socket === socket.key); + const socketType = descriptor ? compiled.socketTypes.get(socket.dataType as never) : undefined; + if (descriptor && !socketType) throw new Error(`Missing compiled socket type: ${socket.dataType}`); + return { + id: socket.id, + nodeId: node.id, + label: placement?.kind === "socket" ? (placement.title ?? title(socket.label)) : title(socket.label), + dataType: socket.dataType as LayoutSocket["dataType"], + color: socketType?.color ?? compiled.theme.unknownSocket, + wildcardInput: + socket.direction === "input" && compiled.compatibility.wildcardInputTypes.includes(socket.dataType), + direction: socket.direction, + accepts: socket.accepts, + capacity: socket.maxIncomingLinks, + linkIds, + linked, + anchor: + kind === "reroute" + ? { x: at.x + G.reroute, y: at.y - G.reroute } + : { + x: at.x + (socket.direction === "output" ? width : 0), + y: at.y - (node.collapsed ? G.half : G.header + G.half + row * G.row), + }, + }; + }); + for (const socket of layoutSockets) sockets.set(socket.id, socket); + const rows: LayoutRow[] = []; + let rowOffset = 0; + for (const item of visibleItems) { + const units = nodeRowUnits(item); + const rowBounds: Rect = { x: at.x, y: at.y - G.header - rowOffset * G.row, width, height: units * G.row }; + if (item.kind === "text") { + rows.push({ kind: item.variant, label: item.title, units, bounds: rowBounds }); + rowOffset += units; + continue; + } + if ( + item.kind === "parameter" || + item.kind === "resource" || + (item.kind === "widget" && item.widget === "color-ramp") + ) { + const key = item.parameter, + schema = descriptor?.parameters[key], + ramp = item.kind === "widget"; + const id = `${node.id}:parameter:${key}`; + const controlBounds = + ramp || schema?.type === "number" + ? { x: at.x + 10, y: rowBounds.y - 3, width: width - 20, height: ramp ? units * G.row - 6 : G.row - 6 } + : { x: at.x + width * 0.42, y: rowBounds.y - 3, width: width * 0.53, height: G.row - 6 }; + const subfields = makeSubfields(controlBounds, schema?.type); + const rampBounds = ramp + ? { + toolbar: { x: controlBounds.x, y: controlBounds.y, width: controlBounds.width, height: 20 }, + mode: { x: controlBounds.x, y: controlBounds.y - 22, width: controlBounds.width * 0.3, height: 20 }, + interpolation: { + x: controlBounds.x + controlBounds.width * 0.31, + y: controlBounds.y - 22, + width: controlBounds.width * 0.4, + height: 20, + }, + hue: { + x: controlBounds.x + controlBounds.width * 0.72, + y: controlBounds.y - 22, + width: controlBounds.width * 0.28, + height: 20, + }, + gradient: { + x: controlBounds.x + 8, + y: controlBounds.y - 46, + width: controlBounds.width - 16, + height: 28, + }, + handles: { x: controlBounds.x + 8, y: controlBounds.y - 74, width: controlBounds.width - 16, height: 28 }, + selector: { x: controlBounds.x, y: controlBounds.y - 104, width: controlBounds.width * 0.25, height: 20 }, + position: { + x: controlBounds.x + controlBounds.width * 0.27, + y: controlBounds.y - 104, + width: controlBounds.width * 0.35, + height: 20, + }, + color: { x: controlBounds.x, y: controlBounds.y - 126, width: controlBounds.width, height: 20 }, + } + : undefined; + const resourceBounds = + item.kind === "resource" + ? { + preview: { x: at.x + 10, y: rowBounds.y - 4, width: width - 20, height: units * G.row - 34 }, + open: { x: at.x + 10, y: rowBounds.y - units * G.row + 26, width: width - 20, height: 22 }, + } + : undefined; + const resource = item.kind === "resource" ? compiled.resources.get(item.resource as never) : undefined; + if (item.kind === "resource" && !resource) throw new Error(`Missing compiled resource: ${item.resource}`); + const control: LayoutControl = { + id, + nodeId: node.id, + source: descriptor ? "parameter" : "unknown", + key, + label: ("title" in item ? item.title : undefined) ?? resource?.title ?? title(key), + kind: item.kind === "resource" ? "resource" : ramp ? "color-ramp" : controlKind(schema), + value: node.parameters[key], + ...(schema ? { schema } : {}), + ...(resource && item.kind === "resource" + ? { + resourceId: item.resource, + resourceReferencePrefix: resource.referencePrefix, + openTitle: item.openTitle ?? resource.openTitle, + } + : {}), + linked: false, + bounds: item.kind === "resource" ? resourceBounds!.open : controlBounds, + subfields, + numericFields: makeNumericFields(controlBounds, schema, subfields), + ...(rampBounds ? { rampBounds } : {}), + ...(resourceBounds ? { resourceBounds } : {}), + }; + controls.set(id, control); + rows.push({ kind: "control", controlId: id, units, bounds: rowBounds }); + } else if (item.kind === "widget" && item.widget === "grading-wheels") { + const gap = 10, + padding = 10, + columnWidth = (width - padding * 2 - gap * 2) / 3; + const wheels = item.bindings.map((wheel, index) => { + const x = at.x + padding + index * (columnWidth + gap), + scalarSchema = descriptor?.parameters[wheel.scalar], + colorSchema = descriptor?.parameters[wheel.color], + scalarId = `${node.id}:parameter:${wheel.scalar}`, + colorId = `${node.id}:parameter:${wheel.color}`, + labelBounds = { x, y: rowBounds.y - 4, width: columnWidth, height: 18 }, + plane = { x: x + 2, y: rowBounds.y - 25, width: columnWidth - 24, height: columnWidth - 24 }, + lightness = { x: x + columnWidth - 18, y: rowBounds.y - 25, width: 14, height: columnWidth - 24 }, + scalarBounds = { + x: x + 2, + y: rowBounds.y - 25 - (columnWidth - 24) - 10, + width: columnWidth - 6, + height: 18, + }, + colorBounds = { + x: plane.x, + y: plane.y, + width: lightness.x + lightness.width - plane.x, + height: plane.height, + }; + controls.set(scalarId, { + id: scalarId, + nodeId: node.id, + source: "parameter", + key: wheel.scalar, + label: wheel.title, + kind: controlKind(scalarSchema), + value: node.parameters[wheel.scalar], + ...(scalarSchema ? { schema: scalarSchema } : {}), + linked: false, + bounds: scalarBounds, + subfields: [], + numericFields: makeNumericFields(scalarBounds, scalarSchema, []), + }); + controls.set(colorId, { + id: colorId, + nodeId: node.id, + source: "parameter", + key: wheel.color, + label: wheel.title, + kind: controlKind(colorSchema), + value: node.parameters[wheel.color], + ...(colorSchema ? { schema: colorSchema } : {}), + linked: false, + bounds: colorBounds, + subfields: [], + numericFields: [], + colorWheelBounds: { plane, lightness }, + }); + return { label: wheel.title, labelBounds, scalarControlId: scalarId, colorControlId: colorId }; + }); + rows.push({ + kind: "grading-wheels", + wheels: wheels as unknown as Extract["wheels"], + units, + bounds: rowBounds, + }); + } else if (item.kind === "socket") { + const raw = node.sockets.find((socket) => socket.key === item.socket); + const socket = raw && sockets.get(raw.id); + if (!raw || !socket) continue; + const socketDescriptor = descriptorSockets.get(item.socket), + schema = socketDescriptor?.showValue ? (socketDescriptor.value ?? undefined) : undefined; + let controlId: string | undefined; + if (schema && socket.direction === "input") { + controlId = `${node.id}:socket:${socket.id}`; + const controlBounds = + schema.type === "number" + ? { x: at.x + 12, y: rowBounds.y - 3, width: width - 24, height: G.row - 6 } + : { x: at.x + width * 0.42, y: rowBounds.y - 3, width: width * 0.53, height: G.row - 6 }; + const subfields = makeSubfields(controlBounds, schema.type); + controls.set(controlId, { + id: controlId, + nodeId: node.id, + source: "socket-default", + key: socket.id, + label: item.title ?? socket.label, + kind: controlKind(schema), + value: raw.defaultValue, + schema, + linked: socket.linked, + bounds: controlBounds, + subfields, + numericFields: makeNumericFields(controlBounds, schema, subfields), + }); + } + rows.push({ + kind: "socket", + socketId: socket.id, + ...(controlId ? { controlId } : {}), + units: 1, + bounds: rowBounds, + }); + } + rowOffset += units; + } + if (kind === "reroute" && layoutSockets[0]) { + rows.push({ kind: "socket", socketId: layoutSockets[0].id, units: 1, bounds: nodeBounds }); + } + const byKey = new Map(node.sockets.map((s) => [s.key, s.id])); + const bypasses = node.muted + ? (descriptor?.muteBypass ?? []).flatMap(([a, b]) => { + const from = byKey.get(a), + to = byKey.get(b), + aa = from && sockets.get(from)?.anchor, + bb = to && sockets.get(to)?.anchor; + return aa && bb ? [{ from: aa, to: bb }] : []; + }) + : []; + const style = descriptor && compiled.styles.get(descriptor.style as never); + if (descriptor && !style) throw new Error(`Missing compiled style: ${descriptor.style}`); + nodes.set(node.id, { + id: node.id, + ...(node.parentId ? { parentId: node.parentId } : {}), + typeId: node.typeId, + label: node.label, + ...(descriptor ? { styleId: descriptor.style } : {}), + headerColor: style?.header ?? compiled.theme.unknownHeader, + kind, + localPosition: node.position, + worldPosition: at, + authoredSize: node.size, + minimumSize, + bounds: nodeBounds, + header: { x: at.x, y: at.y, width, height: kind === "reroute" ? 0 : G.header }, + collapseHitRect: { x: at.x, y: at.y, width: 14, height: G.header }, + resizeHitRect: { + x: at.x + width - G.resize, + y: at.y - height + G.resize, + width: G.resize * 2, + height: G.resize * 2, + }, + collapsed: node.collapsed, + muted: node.muted, + visible: true, + rows, + bypasses, + } satisfies LayoutNode); + } + // Frames are behind all regular nodes. Their authored size is expanded to contain direct children. + for (const frame of sorted + .filter((node) => nodes.get(node.id)?.kind === "frame") + .sort((a, b) => depths.get(b.id)! - depths.get(a.id)!)) { + const children = (childrenByParent.get(frame.id) ?? []).map((node) => nodes.get(node.id) as LayoutNode); + if (children.length) { + const childBounds = bounds( + children.flatMap((child) => [ + { x: child.bounds.x, y: child.bounds.y }, + { x: child.bounds.x + child.bounds.width, y: child.bounds.y - child.bounds.height }, + ]), + ); + const current = nodes.get(frame.id) as LayoutNode; + const fitted = { + x: Math.min(current.bounds.x, childBounds.x - G.frameMargin), + y: Math.max(current.bounds.y, childBounds.y + G.frameMargin), + width: + Math.max(current.bounds.x + current.bounds.width, childBounds.x + childBounds.width + G.frameMargin) - + Math.min(current.bounds.x, childBounds.x - G.frameMargin), + height: + Math.max(current.bounds.y, childBounds.y + G.frameMargin) - + Math.min(current.bounds.y - current.bounds.height, childBounds.y - childBounds.height - G.frameMargin), + }; + nodes.set(frame.id, { ...current, bounds: fitted, header: { ...fitted, height: G.header } }); + } + } + for (const link of Object.values(document.links).sort((a, b) => a.id.localeCompare(b.id))) { + const from = sockets.get(link.fromSocketId) as LayoutSocket | undefined, + to = sockets.get(link.toSocketId) as LayoutSocket | undefined; + if (!from || !to) continue; + const points = cubic(from.anchor, to.anchor); + const dx = Math.max(40, Math.abs(to.anchor.x - from.anchor.x) * 0.5); + const cs = [ + { x: from.anchor.x + dx, y: from.anchor.y }, + { x: to.anchor.x - dx, y: to.anchor.y }, + ] as const, + linkBounds = cubicBounds(from.anchor, cs[0], cs[1], to.anchor); + links.set(link.id, { + id: link.id, + fromNodeId: link.fromNodeId, + fromSocketId: link.fromSocketId, + toNodeId: link.toNodeId, + toSocketId: link.toSocketId, + dataType: from.dataType, + color: from.color, + points, + controls: cs, + bounds: linkBounds, + visible: true, + muted: effectiveMuted.has(link.id), + } satisfies LayoutLink); + } + const allBounds = [...nodes.values()].map((node: LayoutNode) => node.bounds); + const graphBounds = allBounds.length + ? bounds( + allBounds.flatMap((rect) => [ + { x: rect.x, y: rect.y }, + { x: rect.x + rect.width, y: rect.y - rect.height }, + ]), + ) + : { x: 0, y: 0, width: 0, height: 0 }; + const drawOrder = sorted + .filter((node) => nodes.get(node.id)?.kind === "frame") + .concat(sorted.filter((node) => nodes.get(node.id)?.kind !== "frame")) + .map((node) => node.id); + return { + nodes, + sockets, + controls, + links, + drawOrder, + graphBounds, + nodeRanks: new Map(drawOrder.map((id, i) => [id, i])), + linkRanks: new Map([...links.keys()].map((id, i) => [id, i])), + }; +} +export function createLayoutView( + scene: LayoutScene, + transform: ViewTransform, + nodeIds: readonly NodeId[] = scene.drawOrder, + linkIds: readonly LinkId[] = [...scene.links.keys()], +): LayoutView { + const viewport = { + x: transform.center.x - transform.viewport.x / transform.zoom / 2, + y: transform.center.y + transform.viewport.y / transform.zoom / 2, + width: transform.viewport.x / transform.zoom, + height: transform.viewport.y / transform.zoom, + }; + const ns = nodeIds + .filter((id) => { + const n = scene.nodes.get(id); + return n && intersects(n.bounds, viewport, G.margin); + }) + .sort((a, b) => (scene.nodeRanks.get(a) ?? 0) - (scene.nodeRanks.get(b) ?? 0)), + ls = linkIds + .filter((id) => { + const l = scene.links.get(id); + return l && intersects(l.bounds, viewport, G.margin); + }) + .sort((a, b) => (scene.linkRanks.get(a) ?? 0) - (scene.linkRanks.get(b) ?? 0)); + return { + ...scene, + drawOrder: ns, + transform, + candidateNodeIds: ns, + candidateLinkIds: ls, + totalNodes: scene.nodes.size, + totalLinks: scene.links.size, + }; +} +export function layoutGraph( + compiled: CompiledFxNodeComposition, + document: GraphDocument, + transform: ViewTransform, +): LayoutSnapshot { + return createLayoutView(buildLayoutScene(compiled, document), transform); +} +export function applyNodeOrder(layout: T, order: readonly NodeId[]): T { + const frames = layout.drawOrder.filter((id) => layout.nodes.get(id)?.kind === "frame"), + ordinary = layout.drawOrder.filter((id) => layout.nodes.get(id)?.kind !== "frame"), + available = new Set(ordinary), + promoted = order.filter((id) => available.has(id)), + raised = new Set(promoted); + return { ...layout, drawOrder: [...frames, ...ordinary.filter((id) => !raised.has(id)), ...promoted] }; +} diff --git a/vendor/fxnode/src/layout/link-mute.ts b/vendor/fxnode/src/layout/link-mute.ts new file mode 100644 index 0000000..ce33fe0 --- /dev/null +++ b/vendor/fxnode/src/layout/link-mute.ts @@ -0,0 +1,32 @@ +import type { GraphDocument, LinkId } from "../core/types.js"; +import type { CompiledFxNodeComposition, FxNodeCompositionData } from "../composition/types.js"; + +/** Derived reroute mute state. Authored flags are never changed. */ +export function effectivelyMutedLinks( + compiled: CompiledFxNodeComposition, + document: GraphDocument, +): ReadonlySet { + const muted = new Set( + Object.values(document.links) + .filter((l) => l.muted) + .map((l) => l.id), + ); + const reroutes = new Set( + Object.values(document.nodes) + .filter((n) => n.known && compiled.nodes.get(n.typeId as never)?.behavior === "reroute") + .map((n) => n.id), + ); + let changed = true; + while (changed) { + changed = false; + for (const link of Object.values(document.links)) { + if (muted.has(link.id) || !reroutes.has(link.fromNodeId)) continue; + const incoming = Object.values(document.links).filter((l) => l.toNodeId === link.fromNodeId); + if (incoming.length && incoming.every((l) => muted.has(l.id))) { + muted.add(link.id); + changed = true; + } + } + } + return muted; +} diff --git a/vendor/fxnode/src/layout/node-dimensions.ts b/vendor/fxnode/src/layout/node-dimensions.ts new file mode 100644 index 0000000..e79473a --- /dev/null +++ b/vendor/fxnode/src/layout/node-dimensions.ts @@ -0,0 +1,96 @@ +import type { GraphNode, ParameterValue, Vec2 } from "../core/types.js"; +import type { FxNodeDefinition, FxNodeUiRow, FxNodeValueSchema, FxNodeVisibility } from "../composition/types.js"; +import { GEOMETRY as G } from "./constants.js"; + +const title = (value: string) => value.replace(/-/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); +const textWidth = (value: string) => value.length * 6.5; +export const nodeRowUnits = (item: FxNodeUiRow): number => + item.kind === "text" && item.variant === "header" + ? 2 + : item.kind === "widget" + ? item.widget === "grading-wheels" + ? 7 + : 8 + : item.kind === "resource" + ? 4 + : 1; +const controlWidth = (schema: FxNodeValueSchema | undefined, ramp = false) => + !schema + ? 80 + : schema.type === "vector" + ? 180 + : schema.type === "color" + ? 80 + : ramp + ? 300 + : schema.type === "string" + ? Math.max(80, ...(schema.enum ?? []).map((value) => textWidth(value) + 28)) + : 80; +export const visibleWhen = (expression: FxNodeVisibility | undefined, values: GraphNode["parameters"]): boolean => + !expression + ? true + : "all" in expression + ? expression.all.every((item) => visibleWhen(item, values)) + : "any" in expression + ? expression.any.some((item) => visibleWhen(item, values)) + : "equals" in expression + ? (values[expression.parameter] as ParameterValue | undefined)?.value === expression.equals + : expression.in.includes((values[expression.parameter] as ParameterValue | undefined)?.value as never); +export const visibleNodeItems = ( + definition: FxNodeDefinition, + node: Pick, +): readonly FxNodeUiRow[] => + definition.ui + .filter((item) => item.kind !== "hidden" && visibleWhen(item.visibleWhen, node.parameters)) + .filter( + (item) => item.kind !== "socket" || node.sockets.some((socket) => socket.key === item.socket && socket.visible), + ); +export function minimumNodeSize( + definition: FxNodeDefinition, + node: Pick, +): Vec2 { + if (definition.behavior === "reroute") return { x: 10, y: 10 }; + if (definition.behavior === "frame") return { x: G.frameMinimum, y: G.frameMinimum }; + const items = visibleNodeItems(definition, node); + let width = Math.max(G.minWidth, textWidth(node.label) + 42); + for (const item of items) { + const label = + "title" in item + ? item.title + : item.kind === "parameter" || item.kind === "resource" + ? title(item.parameter) + : item.kind === "socket" + ? title(item.socket) + : ""; + if (label) width = Math.max(width, (textWidth(label) + 18) / 0.4); + if (item.kind === "parameter" || item.kind === "resource") { + const schema = definition.parameters[item.parameter]; + width = Math.max( + width, + controlWidth( + schema, + item.kind === "parameter" && + definition.ui.some( + (r) => r.kind === "widget" && r.widget === "color-ramp" && r.parameter === item.parameter, + ), + ) / 0.53, + ); + } else if (item.kind === "socket") { + const socket = definition.sockets[item.socket]; + if (socket?.value && socket.showValue) width = Math.max(width, controlWidth(socket.value) / 0.53); + } else if (item.kind === "widget") width = Math.max(width, item.widget === "grading-wheels" ? 400 : 320); + } + return { + x: Math.min(G.maxWidth, Math.ceil(width)), + y: G.header + items.reduce((sum, item) => sum + nodeRowUnits(item), 0) * G.row + G.gap, + }; +} +export function initialNodeSize( + definition: FxNodeDefinition, + node: Pick, +): Vec2 { + if (definition.behavior === "frame") return { x: G.initialFrameWidth, y: G.initialNodeHeight }; + if (definition.behavior === "reroute") return { x: G.reroute * 2, y: G.reroute * 2 }; + const minimum = minimumNodeSize(definition, node); + return { x: Math.max(G.initialNodeWidth, minimum.x), y: Math.max(G.initialNodeHeight, minimum.y) }; +} diff --git a/vendor/fxnode/src/layout/spatial-index.ts b/vendor/fxnode/src/layout/spatial-index.ts new file mode 100644 index 0000000..5edecc7 --- /dev/null +++ b/vendor/fxnode/src/layout/spatial-index.ts @@ -0,0 +1,166 @@ +export interface SpatialBox { + readonly minX: number; + readonly minY: number; + readonly maxX: number; + readonly maxY: number; +} +export type SpatialRect = + | SpatialBox + | { readonly x: number; readonly y: number; readonly width: number; readonly height: number }; + +export function canonicalBox(rect: SpatialRect): SpatialBox { + const values = + "minX" in rect + ? [rect.minX, rect.minY, rect.maxX, rect.maxY] + : [rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]; + if (!values.every(Number.isFinite)) throw new TypeError("Spatial bounds must be finite"); + return { + minX: Math.min(values[0]!, values[2]!), + minY: Math.min(values[1]!, values[3]!), + maxX: Math.max(values[0]!, values[2]!), + maxY: Math.max(values[1]!, values[3]!), + }; +} +export const boxesIntersect = (a: SpatialBox, b: SpatialBox): boolean => + a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY; +const contains = (a: SpatialBox, b: SpatialBox): boolean => + a.minX <= b.minX && a.maxX >= b.maxX && a.minY <= b.minY && a.maxY >= b.maxY; + +export interface SpatialIndex { + readonly size: number; + insert(id: string, value: T, bounds: SpatialRect): void; + update(id: string, bounds: SpatialRect, value?: T): void; + remove(id: string): T; + query(bounds: SpatialRect): readonly T[]; + clear(): void; +} +interface Entry { + id: string; + value: T; + box: SpatialBox; +} +interface Cell { + box: SpatialBox; + entries: Entry[]; + children?: [Cell, Cell, Cell, Cell]; +} + +/** Rectangle-capable loose quadtree. Items live in exactly one cell. */ +export class LooseQuadtree implements SpatialIndex { + private root: Cell = { box: { minX: -1, minY: -1, maxX: 1, maxY: 1 }, entries: [] }; + private readonly locator = new Map>(); + constructor( + readonly maxEntries = 16, + readonly maxDepth = 16, + readonly minCellSize = 1, + readonly looseness = 2, + ) { + if ( + !Number.isInteger(maxEntries) || + maxEntries < 1 || + !Number.isInteger(maxDepth) || + maxDepth < 0 || + !(minCellSize > 0) || + looseness < 1 + ) + throw new RangeError("Invalid quadtree options"); + } + get size(): number { + return this.locator.size; + } + insert(id: string, value: T, bounds: SpatialRect): void { + if (this.locator.has(id)) throw new Error(`Duplicate spatial id: ${id}`); + const entry = { id, value, box: canonicalBox(bounds) }; + this.locator.set(id, entry); + this.ensureRoot(entry.box); + this.place(this.root, entry, 0); + } + update(id: string, bounds: SpatialRect, value?: T): void { + const old = this.locator.get(id); + if (!old) throw new Error(`Unknown spatial id: ${id}`); + const nextValue = value === undefined ? old.value : value; + this.remove(id); + this.insert(id, nextValue, bounds); + } + remove(id: string): T { + const entry = this.locator.get(id); + if (!entry) throw new Error(`Unknown spatial id: ${id}`); + this.removeCell(this.root, id); + this.locator.delete(id); + return entry.value; + } + query(bounds: SpatialRect): readonly T[] { + const query = canonicalBox(bounds), + found: T[] = [], + seen = new Set(); + const visit = (cell: Cell, root = false): void => { + if (!boxesIntersect(root ? cell.box : this.loose(cell.box), query)) return; + for (const e of cell.entries) + if (!seen.has(e.id) && boxesIntersect(e.box, query)) { + seen.add(e.id); + found.push(e.value); + } + cell.children?.forEach((child) => visit(child)); + }; + visit(this.root, true); + return found; + } + clear(): void { + this.locator.clear(); + this.root = { box: { minX: -1, minY: -1, maxX: 1, maxY: 1 }, entries: [] }; + } + private ensureRoot(box: SpatialBox): void { + if (contains(this.root.box, box)) return; + const all = [...this.locator.values()]; + let minX = Math.min(-1, ...all.map((e) => e.box.minX)), + minY = Math.min(-1, ...all.map((e) => e.box.minY)), + maxX = Math.max(1, ...all.map((e) => e.box.maxX)), + maxY = Math.max(1, ...all.map((e) => e.box.maxY)); + let size = 2; + while (size < Math.max(maxX - minX, maxY - minY)) size *= 2; + const cx = (minX + maxX) / 2, + cy = (minY + maxY) / 2; + this.root = { + box: { minX: cx - size / 2, minY: cy - size / 2, maxX: cx + size / 2, maxY: cy + size / 2 }, + entries: [], + }; + for (const e of all) this.place(this.root, e, 0); + } + private place(cell: Cell, entry: Entry, depth: number): void { + if (depth < this.maxDepth && cell.box.maxX - cell.box.minX > this.minCellSize) { + if (!cell.children && cell.entries.length >= this.maxEntries) this.split(cell); + const child = cell.children?.find((c) => contains(this.loose(c.box), entry.box)); + if (child) { + this.place(child, entry, depth + 1); + return; + } + } + cell.entries.push(entry); + } + private split(cell: Cell): void { + const { minX, minY, maxX, maxY } = cell.box, + mx = (minX + maxX) / 2, + my = (minY + maxY) / 2; + cell.children = [ + { box: { minX, minY, maxX: mx, maxY: my }, entries: [] }, + { box: { minX: mx, minY, maxX, maxY: my }, entries: [] }, + { box: { minX, minY: my, maxX: mx, maxY }, entries: [] }, + { box: { minX: mx, minY: my, maxX, maxY }, entries: [] }, + ]; + } + private loose(box: SpatialBox): SpatialBox { + const x = (box.minX + box.maxX) / 2, + y = (box.minY + box.maxY) / 2, + w = ((box.maxX - box.minX) * this.looseness) / 2, + h = ((box.maxY - box.minY) * this.looseness) / 2; + return { minX: x - w, minY: y - h, maxX: x + w, maxY: y + h }; + } + private removeCell(cell: Cell, id: string): boolean { + const i = cell.entries.findIndex((e) => e.id === id); + if (i >= 0) { + cell.entries.splice(i, 1); + return true; + } + return cell.children?.some((c) => this.removeCell(c, id)) ?? false; + } +} diff --git a/vendor/fxnode/src/layout/types.ts b/vendor/fxnode/src/layout/types.ts new file mode 100644 index 0000000..e8833d9 --- /dev/null +++ b/vendor/fxnode/src/layout/types.ts @@ -0,0 +1,200 @@ +import type { LinkId, NodeId, ParameterValue, SocketId, Vec2 } from "../core/types.js"; +import type { FxNodeHexColor, FxNodeValueSchema } from "../composition/types.js"; + +export interface Rect { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} +export interface ViewTransform { + readonly center: Vec2; + readonly zoom: number; + readonly viewport: Vec2; + readonly dpr: number; +} +/** View-space (+Y down) modal color-picker geometry. */ +export interface ColorPickerLayout { + readonly bounds: Rect; + readonly confirm: Rect; + readonly plane: Rect; + readonly lightness: Rect; + readonly alpha: Rect; + readonly rgba: readonly [Rect, Rect, Rect, Rect]; + readonly hsv: readonly [Rect, Rect, Rect]; + readonly hex: Rect; +} +export interface LayoutSocket { + readonly id: SocketId; + readonly nodeId: NodeId; + readonly label: string; + readonly dataType: string; + readonly color: FxNodeHexColor; + readonly wildcardInput: boolean; + readonly direction: "input" | "output"; + readonly accepts: readonly string[]; + readonly capacity: number; + readonly linkIds: readonly LinkId[]; + readonly anchor: Vec2; + readonly linked: boolean; +} +export type LayoutControlKind = + | "number" + | "boolean" + | "enum" + | "string" + | "resource" + | "vector" + | "color" + | "color-ramp" + | "readonly-json"; +export interface LayoutSubfield { + readonly index: number; + readonly label: "X" | "Y" | "Z" | "R" | "G" | "B" | "A"; + readonly bounds: Rect; +} +export interface LayoutNumericField { + readonly component: number; + readonly bounds: Rect; + readonly value: Rect; + readonly decrement: Rect; + readonly increment: Rect; + /** Preferred display range for Blender-style proportional fill. */ + readonly range?: { readonly minimum: number; readonly maximum: number }; +} +export interface LayoutControl { + readonly id: string; + readonly nodeId: NodeId; + readonly source: "parameter" | "socket-default" | "unknown"; + readonly key: string; + readonly label: string; + readonly kind: LayoutControlKind; + readonly value: ParameterValue | unknown; + readonly schema?: FxNodeValueSchema; + readonly resourceId?: string; + readonly resourceReferencePrefix?: string; + readonly openTitle?: string; + readonly linked: boolean; + readonly bounds: Rect; + readonly subfields: readonly LayoutSubfield[]; + /** Authoritative numeric value and step-button geometry, in world coordinates. */ + readonly numericFields: readonly LayoutNumericField[]; + /** Authoritative Color Ramp sub-control geometry, in world coordinates. */ + readonly rampBounds?: { + readonly toolbar: Rect; + readonly mode: Rect; + readonly interpolation: Rect; + readonly hue: Rect; + readonly gradient: Rect; + readonly handles: Rect; + readonly selector: Rect; + readonly position: Rect; + readonly color: Rect; + }; + /** Authoritative inline Oklch grading-wheel geometry, in world coordinates. */ + readonly colorWheelBounds?: { readonly plane: Rect; readonly lightness: Rect }; + /** Resource thumbnail and chooser button geometry, in world coordinates. */ + readonly resourceBounds?: { readonly preview: Rect; readonly open: Rect }; +} +export type LayoutRow = + | { readonly kind: "control"; readonly controlId: string; readonly units: number; readonly bounds: Rect } + | { + readonly kind: "grading-wheels"; + readonly wheels: readonly [ + { + readonly label: string; + readonly labelBounds: Rect; + readonly scalarControlId: string; + readonly colorControlId: string; + }, + { + readonly label: string; + readonly labelBounds: Rect; + readonly scalarControlId: string; + readonly colorControlId: string; + }, + { + readonly label: string; + readonly labelBounds: Rect; + readonly scalarControlId: string; + readonly colorControlId: string; + }, + ]; + readonly units: number; + readonly bounds: Rect; + } + | { + readonly kind: "socket"; + readonly socketId: SocketId; + readonly controlId?: string; + readonly units: number; + readonly bounds: Rect; + } + | { + readonly kind: "header" | "category" | "section" | "panel" | "placeholder"; + readonly label: string; + readonly units: number; + readonly bounds: Rect; + }; +export interface LayoutNode { + readonly id: NodeId; + readonly parentId?: NodeId; + readonly typeId: string; + readonly label: string; + readonly styleId?: string; + readonly headerColor: FxNodeHexColor; + readonly kind: "node" | "frame" | "reroute"; + readonly localPosition: Vec2; + readonly worldPosition: Vec2; + readonly authoredSize: Vec2; + /** Smallest effective expanded size that can display this node's built-in controls at full scale. */ + readonly minimumSize: Vec2; + readonly bounds: Rect; + readonly header: Rect; + readonly collapseHitRect: Rect; + readonly resizeHitRect: Rect; + readonly collapsed: boolean; + readonly muted: boolean; + readonly visible: boolean; + readonly rows: readonly LayoutRow[]; + readonly bypasses: readonly { readonly from: Vec2; readonly to: Vec2 }[]; +} +export interface LayoutLink { + readonly id: LinkId; + readonly fromNodeId: NodeId; + readonly fromSocketId: SocketId; + readonly toNodeId: NodeId; + readonly toSocketId: SocketId; + readonly dataType: string; + readonly color: FxNodeHexColor; + readonly points: readonly Vec2[]; + readonly controls: readonly [Vec2, Vec2]; + readonly bounds: Rect; + readonly visible: boolean; + readonly muted: boolean; +} +export interface LayoutSnapshot { + readonly nodes: ReadonlyMap; + readonly sockets: ReadonlyMap; + readonly controls: ReadonlyMap; + readonly links: ReadonlyMap; + readonly drawOrder: readonly NodeId[]; + readonly graphBounds: Rect; + readonly transform: ViewTransform; +} +export interface LayoutScene extends Omit { + readonly nodeRanks: ReadonlyMap; + readonly linkRanks: ReadonlyMap; +} +export interface LayoutView extends LayoutSnapshot { + readonly candidateNodeIds: readonly NodeId[]; + readonly candidateLinkIds: readonly LinkId[]; + readonly totalNodes: number; + readonly totalLinks: number; +} + +export function layoutSocketsCompatible(from: LayoutSocket, to: LayoutSocket): boolean { + return ( + from.direction === "output" && to.direction === "input" && (to.wildcardInput || to.accepts.includes(from.dataType)) + ); +} diff --git a/vendor/fxnode/src/render/canvas-renderer.ts b/vendor/fxnode/src/render/canvas-renderer.ts new file mode 100644 index 0000000..e43019d --- /dev/null +++ b/vendor/fxnode/src/render/canvas-renderer.ts @@ -0,0 +1,804 @@ +import { GEOMETRY as G } from "../layout/constants.js"; +import { worldToView } from "../layout/geometry.js"; +import type { LinkId, NodeId, ParameterValue, SocketId } from "../core/types.js"; +import { + layoutSocketsCompatible, + type LayoutControl, + type LayoutSnapshot, + type LayoutSocket, + type Rect, + type ViewTransform, +} from "../layout/types.js"; +import type { FxNodeTheme } from "../composition/types.js"; +import { isColorRamp, sampleColorRamp } from "../widgets/color-ramp.js"; +import { oklabToOklch, srgbToOklab } from "../color/oklab.js"; +import { paintOklchWheel, type DevicePaintTarget } from "./color-picker-renderer.js"; + +export interface InteractionRenderState { + readonly knife?: { + points: readonly { x: number; y: number }[]; + crossed: ReadonlySet; + mode: "remove" | "mute"; + }; +} +export interface InteractionRenderState { + readonly selectedNodes: ReadonlySet; + readonly selectedLinks?: ReadonlySet; + readonly activeNode?: NodeId; + readonly hoverNode?: NodeId; + readonly focusedControl?: string; + readonly hoveredControl?: string; + readonly focusedRampTarget?: string; + readonly hoveredRampTarget?: string; + readonly activeRampStopByControl?: ReadonlyMap; + readonly collapseAnimations?: ReadonlyMap; + readonly controlEdit?: + | { readonly kind: "string"; readonly controlId: string; readonly buffer: string } + | { + readonly kind: "number"; + readonly controlId: string; + readonly component: number; + readonly buffer: string; + readonly selectAll: boolean; + }; + readonly box?: { start: { x: number; y: number }; current: { x: number; y: number } }; + readonly linkDrag?: { from: SocketId; current: { x: number; y: number }; candidate?: SocketId }; + readonly parentHighlight?: NodeId; +} +export interface RenderStats { + readonly candidateNodes: number; + readonly totalNodes: number; + readonly paintedNodes: number; + readonly candidateLinks: number; + readonly totalLinks: number; + readonly paintedLinks: number; + readonly paintMs: number; +} +export interface ResourceImage { + readonly bitmap: ImageBitmap; + readonly name: string; +} + +function valueText(value: unknown): string { + const typed = value as ParameterValue | undefined; + if (!typed || typeof typed !== "object" || !("kind" in typed)) return "—"; + if (typed.kind === "number") return typed.value.toFixed(3); + if (typed.kind === "vector" || typed.kind === "color") + return typed.value.map((component) => component.toFixed(3)).join(" "); + if (typed.kind === "json") return "…"; + return String(typed.value); +} +function resourceLabel(reference: string, prefix: string | undefined): string { + if (!prefix || !reference.startsWith(prefix)) return reference; + try { + return decodeURIComponent(reference.slice(prefix.length).split(":").at(-1)!); + } catch { + return reference; + } +} + +/** Canvas maxWidth scales glyphs but does not constrain them. Clip every label to its owning cell. */ +function clippedText( + context: OffscreenCanvasRenderingContext2D, + text: string, + rect: Rect, + x: number, + y: number, + padding = 3, +): void { + context.save(); + context.beginPath(); + context.rect(rect.x + padding, rect.y, Math.max(0, rect.width - padding * 2), rect.height); + context.clip(); + context.fillText(text, x, y); + context.restore(); +} + +function paintColorSwatch( + context: OffscreenCanvasRenderingContext2D, + rect: Rect, + rgba: readonly number[], + theme: FxNodeTheme, + zoom: number, +): void { + context.save(); + context.beginPath(); + context.roundRect(rect.x, rect.y, rect.width, rect.height, 4 * zoom); + context.clip(); + const cell = Math.max(3, 5 * zoom); + for (let y = rect.y; y < rect.y + rect.height; y += cell) + for (let x = rect.x; x < rect.x + rect.width; x += cell) { + context.fillStyle = + (Math.floor((x - rect.x) / cell) + Math.floor((y - rect.y) / cell)) % 2 + ? theme.checkerDark + : theme.checkerLight; + context.fillRect(x, y, cell, cell); + } + context.fillStyle = `rgba(${(rgba[0] ?? 0) * 255},${(rgba[1] ?? 0) * 255},${(rgba[2] ?? 0) * 255},${rgba[3] ?? 1})`; + context.fillRect(rect.x, rect.y, rect.width, rect.height); + context.restore(); + context.beginPath(); + context.roundRect( + rect.x + 0.5 * zoom, + rect.y + 0.5 * zoom, + Math.max(0, rect.width - zoom), + Math.max(0, rect.height - zoom), + 4 * zoom, + ); + context.strokeStyle = theme.widgetBorder; + context.lineWidth = zoom; + context.stroke(); +} + +function paintControl( + context: OffscreenCanvasRenderingContext2D, + control: LayoutControl, + rect: Rect, + theme: FxNodeTheme, + zoom: number, + interaction?: InteractionRenderState, + resourceImages?: ReadonlyMap, +): void { + const viewRect = (bounds: Rect): Rect => ({ + x: rect.x + (bounds.x - control.bounds.x) * zoom, + y: rect.y + (control.bounds.y - bounds.y) * zoom, + width: bounds.width * zoom, + height: bounds.height * zoom, + }); + const typedRamp = control.value as { kind?: unknown; value?: unknown }; + if (control.kind === "color-ramp" && typedRamp?.kind === "json" && isColorRamp(typedRamp.value)) { + const bounds = control.rampBounds!, + ramp = typedRamp.value, + active = ramp.stops.find((s) => s.id === interaction?.activeRampStopByControl?.get(control.id)) ?? ramp.stops[0]!, + toolbar = viewRect(bounds.toolbar), + menusY = viewRect(bounds.mode).y, + gradient = viewRect(bounds.gradient); + context.textAlign = "center"; + const toolbarButtons = [ + [0, 0.12, "+"], + [0.12, 0.24, "−"], + [0.24, 0.62, "Flip"], + [0.62, 1, "Distribute"], + ] as const; + for (const [start, end, label] of toolbarButtons) { + const button = { + x: toolbar.x + toolbar.width * start, + y: toolbar.y, + width: toolbar.width * (end - start) - 1 * zoom, + height: toolbar.height, + }; + context.fillStyle = theme.control; + context.fillRect(button.x, button.y, button.width, button.height); + context.fillStyle = theme.text; + clippedText(context, label, button, button.x + button.width / 2, button.y + button.height / 2); + } + const labels = [ramp.colorMode.toUpperCase(), ramp.interpolation.replace("-", " "), ramp.hueInterpolation]; + const menuWidths = [0.3, 0.41, 0.29]; + let x = rect.x; + for (let i = 0; i < 3; i++) { + const w = rect.width * menuWidths[i]!; + context.fillStyle = theme.control; + context.fillRect(x, menusY, w - 2 * zoom, 20 * zoom); + context.fillStyle = theme.text; + context.fillText(labels[i]!, x + w / 2, menusY + 10 * zoom); + x += w; + } + context.save(); + context.beginPath(); + context.rect(gradient.x, gradient.y, gradient.width, gradient.height); + context.clip(); + const cell = 6 * zoom; + for (let yy = gradient.y; yy < gradient.y + gradient.height; yy += cell) + for (let xx = gradient.x; xx < gradient.x + gradient.width; xx += cell) { + context.fillStyle = + (Math.floor((xx - gradient.x) / cell) + Math.floor((yy - gradient.y) / cell)) % 2 + ? theme.checkerDark + : theme.checkerLight; + context.fillRect( + xx, + yy, + Math.min(cell, gradient.x + gradient.width - xx), + Math.min(cell, gradient.y + gradient.height - yy), + ); + } + const steps = Math.max(2, Math.ceil(gradient.width)); + for (let i = 0; i < steps; i++) { + const c = sampleColorRamp(ramp, i / (steps - 1)); + context.fillStyle = `rgba(${c[0] * 255},${c[1] * 255},${c[2] * 255},${c[3]})`; + context.fillRect( + gradient.x + (i * gradient.width) / steps, + gradient.y, + gradient.width / steps + 1, + gradient.height, + ); + } + context.restore(); + context.strokeStyle = theme.rampBorder; + context.lineWidth = 1; + context.strokeRect( + gradient.x + 0.5, + gradient.y + 0.5, + Math.max(0, gradient.width - 1), + Math.max(0, gradient.height - 1), + ); + for (const stop of ramp.stops) { + const sx = gradient.x + stop.position * gradient.width, + sy = gradient.y + gradient.height; + context.beginPath(); + context.moveTo(sx, sy); + context.lineTo(sx - 6 * zoom, sy + 12 * zoom); + context.lineTo(sx + 6 * zoom, sy + 12 * zoom); + context.closePath(); + context.fillStyle = `rgb(${stop.color[0] * 255},${stop.color[1] * 255},${stop.color[2] * 255})`; + context.fill(); + context.strokeStyle = stop.id === active.id ? theme.focus : theme.emphasis; + context.lineWidth = stop.id === active.id ? 3 : 2; + context.stroke(); + } + const detailsY = viewRect(bounds.selector).y, + widths = [0.25, 0.35, 0.4], + details = [active.id, `Pos ${active.position.toFixed(3)}`, ""]; + let dx = rect.x; + for (let i = 0; i < 3; i++) { + const w = rect.width * widths[i]!; + const cellRect = { x: dx, y: detailsY, width: w - 2 * zoom, height: 20 * zoom }; + context.fillStyle = theme.control; + context.fillRect(cellRect.x, cellRect.y, cellRect.width, cellRect.height); + context.fillStyle = theme.text; + clippedText(context, details[i]!, cellRect, cellRect.x + cellRect.width / 2, cellRect.y + cellRect.height / 2); + dx += w; + } + const color = viewRect(bounds.color); + paintColorSwatch(context, color, active.color, theme, zoom); + if (interaction?.focusedControl === control.id) { + context.strokeStyle = theme.focus; + context.strokeRect(rect.x, detailsY, rect.width, 20 * zoom); + } + context.textAlign = "left"; + return; + } + context.beginPath(); + context.roundRect(rect.x, rect.y, rect.width, rect.height, 4 * zoom); + context.fillStyle = control.linked ? theme.body : theme.control; + context.fill(); + if (interaction?.focusedControl === control.id && !control.numericFields.length) { + context.strokeStyle = theme.focus; + context.stroke(); + } + context.fillStyle = theme.text; + context.textAlign = "center"; + if (control.kind === "resource") { + const typed = control.value as { kind?: unknown; value?: unknown }, + reference = typed?.kind === "string" && typeof typed.value === "string" ? typed.value : "", + image = resourceImages?.get(reference), + preview = control.resourceBounds ? viewRect(control.resourceBounds.preview) : undefined; + if (preview) { + context.save(); + context.beginPath(); + context.roundRect(preview.x, preview.y, preview.width, preview.height, 4 * zoom); + context.clip(); + context.fillStyle = theme.resourceBackground; + context.fillRect(preview.x, preview.y, preview.width, preview.height); + if (image) { + const scale = Math.min(preview.width / image.bitmap.width, preview.height / image.bitmap.height), + width = image.bitmap.width * scale, + height = image.bitmap.height * scale; + context.drawImage( + image.bitmap, + preview.x + (preview.width - width) / 2, + preview.y + (preview.height - height) / 2, + width, + height, + ); + } else { + context.fillStyle = theme.muted; + clippedText( + context, + reference ? "Image unavailable — reopen" : "No image", + preview, + preview.x + preview.width / 2, + preview.y + preview.height / 2, + 4 * zoom, + ); + } + context.restore(); + context.strokeStyle = theme.widgetBorder; + context.strokeRect( + preview.x + 0.5, + preview.y + 0.5, + Math.max(0, preview.width - 1), + Math.max(0, preview.height - 1), + ); + } + const label = image?.name || resourceLabel(reference, control.resourceReferencePrefix), + open = control.openTitle ?? "Open"; + clippedText( + context, + label ? `${label} ${open}…` : `${open} Image…`, + rect, + rect.x + rect.width / 2, + rect.y + rect.height / 2, + 3 * zoom, + ); + context.textAlign = "left"; + return; + } + if (control.kind === "color") { + const value = control.value as Extract; + paintColorSwatch(context, rect, value.value, theme, zoom); + if (interaction?.focusedControl === control.id) { + context.beginPath(); + context.roundRect( + rect.x + 0.5 * zoom, + rect.y + 0.5 * zoom, + Math.max(0, rect.width - zoom), + Math.max(0, rect.height - zoom), + 4 * zoom, + ); + context.strokeStyle = theme.focus; + context.stroke(); + } + context.textAlign = "left"; + return; + } + const edit = interaction?.controlEdit?.controlId === control.id ? interaction.controlEdit : undefined; + const text = edit?.kind === "string" ? `${edit.buffer}|` : valueText(control.value); + if (control.numericFields.length) { + const typed = control.value as Extract; + for (const field of control.numericFields) { + const fieldRect = viewRect(field.bounds), + valueRect = viewRect(field.value), + decrement = viewRect(field.decrement), + increment = viewRect(field.increment), + component = typed.kind === "number" ? typed.value : (typed.value[field.component] ?? 0), + subfield = control.subfields.find((item) => item.index === field.component), + activeEdit = edit?.kind === "number" && edit.component === field.component ? edit : undefined; + context.beginPath(); + context.roundRect(fieldRect.x, fieldRect.y, fieldRect.width, fieldRect.height, 4 * zoom); + context.fillStyle = activeEdit ? theme.controlEditing : control.linked ? theme.body : theme.control; + context.fill(); + if (!activeEdit && field.range) { + const ratio = Math.max( + 0, + Math.min(1, (component - field.range.minimum) / (field.range.maximum - field.range.minimum)), + ); + context.save(); + context.beginPath(); + context.roundRect(fieldRect.x, fieldRect.y, fieldRect.width, fieldRect.height, 4 * zoom); + context.clip(); + context.fillStyle = theme.controlFill; + context.fillRect(fieldRect.x, fieldRect.y, fieldRect.width * ratio, fieldRect.height); + context.restore(); + } + if (activeEdit) { + const x = fieldRect.x + 7 * zoom, + y = fieldRect.y + fieldRect.height / 2, + textWidth = context.measureText(activeEdit.buffer).width; + context.textAlign = "left"; + if (activeEdit.selectAll) { + context.fillStyle = theme.textSelection; + context.fillRect( + x - 2 * zoom, + fieldRect.y + 2 * zoom, + Math.min(textWidth + 4 * zoom, fieldRect.width - 10 * zoom), + fieldRect.height - 4 * zoom, + ); + } + context.fillStyle = theme.text; + clippedText(context, activeEdit.buffer, fieldRect, x, y, 5 * zoom); + if (!activeEdit.selectAll) { + const caretX = Math.min(fieldRect.x + fieldRect.width - 5 * zoom, x + textWidth + 1 * zoom); + context.fillRect(caretX, fieldRect.y + 4 * zoom, 1 * zoom, fieldRect.height - 8 * zoom); + } + } else { + context.fillStyle = theme.muted; + for (const [button, direction] of [ + [decrement, -1], + [increment, 1], + ] as const) { + const cx = button.x + button.width / 2, + cy = button.y + button.height / 2, + size = Math.min(3 * zoom, button.width * 0.28); + context.beginPath(); + context.moveTo(cx + direction * size, cy); + context.lineTo(cx - direction * size, cy - size); + context.lineTo(cx - direction * size, cy + size); + context.closePath(); + context.fill(); + } + context.fillStyle = theme.text; + context.textAlign = "left"; + clippedText( + context, + subfield?.label ?? control.label, + valueRect, + valueRect.x + 3 * zoom, + fieldRect.y + fieldRect.height / 2, + 2 * zoom, + ); + context.textAlign = "right"; + clippedText( + context, + component.toFixed(3), + valueRect, + valueRect.x + valueRect.width - 3 * zoom, + fieldRect.y + fieldRect.height / 2, + 2 * zoom, + ); + } + context.beginPath(); + context.roundRect( + fieldRect.x + 0.5 * zoom, + fieldRect.y + 0.5 * zoom, + Math.max(0, fieldRect.width - zoom), + Math.max(0, fieldRect.height - zoom), + 4 * zoom, + ); + context.strokeStyle = activeEdit ? theme.editOutline : theme.outline; + context.lineWidth = zoom; + context.stroke(); + } + } else if (control.subfields.length) { + const typed = control.value as Extract; + for (const field of control.subfields) { + const fieldRect = { + x: rect.x + (field.bounds.x - control.bounds.x) * zoom, + y: rect.y, + width: field.bounds.width * zoom, + height: rect.height, + }; + context.beginPath(); + context.roundRect(fieldRect.x, fieldRect.y, fieldRect.width, fieldRect.height, 3 * zoom); + context.fillStyle = control.linked ? theme.body : theme.control; + context.fill(); + const component = typed.value[field.index] ?? 0; + context.fillStyle = theme.text; + clippedText( + context, + `${field.label} ${component.toFixed(3)}`, + fieldRect, + fieldRect.x + fieldRect.width / 2, + rect.y + rect.height / 2, + 2 * zoom, + ); + } + } else { + clippedText(context, text, rect, rect.x + rect.width / 2, rect.y + rect.height / 2, 3 * zoom); + } + context.textAlign = "left"; +} + +function paintSocket( + context: OffscreenCanvasRenderingContext2D, + socket: LayoutSocket, + theme: FxNodeTheme, + zoom: number, + transform: ViewTransform, + showLabel = true, +): void { + const point = worldToView(socket.anchor, transform); + context.fillStyle = socket.color; + context.beginPath(); + context.arc(point.x, point.y, G.socket * zoom, 0, Math.PI * 2); + context.fill(); + if (showLabel && zoom >= 0.35) { + context.fillStyle = theme.text; + context.textAlign = socket.direction === "input" ? "left" : "right"; + context.fillText(socket.label, point.x + (socket.direction === "input" ? 10 : -10) * zoom, point.y); + } + context.textAlign = "left"; +} +export function renderCanvas( + context: OffscreenCanvasRenderingContext2D, + snapshot: LayoutSnapshot, + theme: FxNodeTheme, + interaction?: InteractionRenderState, + resourceImages?: ReadonlyMap, + deviceTarget: DevicePaintTarget = { + x: 0, + y: 0, + width: Math.round(snapshot.transform.viewport.x * snapshot.transform.dpr), + height: Math.round(snapshot.transform.viewport.y * snapshot.transform.dpr), + }, +): RenderStats { + const started = performance.now(); + let paintedNodes = 0, + paintedLinks = 0; + const planned = snapshot as LayoutSnapshot & { + candidateLinkIds?: readonly LinkId[]; + candidateNodeIds?: readonly NodeId[]; + totalNodes?: number; + totalLinks?: number; + }; + const { transform: t } = snapshot; + context.setTransform(t.dpr, 0, 0, t.dpr, deviceTarget.x, deviceTarget.y); + context.fillStyle = theme.background; + context.fillRect(0, 0, t.viewport.x, t.viewport.y); + const spacing = G.grid * t.zoom; + context.fillStyle = theme.grid; + const phase = worldToView({ x: 0, y: 0 }, t); + for (let x = ((phase.x % spacing) + spacing) % spacing; x < t.viewport.x; x += spacing) + for (let y = ((phase.y % spacing) + spacing) % spacing; y < t.viewport.y; y += spacing) { + context.beginPath(); + context.arc(x, y, t.zoom < 0.7 ? 0.5 : 1, 0, Math.PI * 2); + context.fill(); + } + const viewRect = (rect: Rect) => { + const p = worldToView({ x: rect.x, y: rect.y }, t); + return { x: p.x, y: p.y, width: rect.width * t.zoom, height: rect.height * t.zoom }; + }; + for (const id of snapshot.drawOrder) { + const node = snapshot.nodes.get(id); + if (!node?.visible || node.kind !== "frame") continue; + const r = viewRect(node.bounds), + radius = 10 * t.zoom, + h = G.header * t.zoom; + context.beginPath(); + context.roundRect(r.x, r.y, r.width, r.height, radius); + context.fillStyle = theme.frame; + context.fill(); + context.strokeStyle = theme.frameHeader; + context.lineWidth = 1; + context.stroke(); + context.fillStyle = theme.frameHeader; + context.font = `600 ${13 * t.zoom}px sans-serif`; + context.textBaseline = "middle"; + if (t.zoom >= 0.25) context.fillText(node.label, r.x + 10 * t.zoom, r.y + h / 2); + context.beginPath(); + context.moveTo(r.x + 8 * t.zoom, r.y + h); + context.lineTo(r.x + r.width - 8 * t.zoom, r.y + h); + context.stroke(); + } + context.lineWidth = 2; + for (const id of planned.candidateLinkIds ?? snapshot.links.keys()) { + const link = snapshot.links.get(id); + if (!link?.visible) continue; + paintedLinks++; + const color = link.muted ? theme.linkMuted : link.color, + start = worldToView(link.points[0]!, t), + end = worldToView(link.points.at(-1)!, t), + c1 = worldToView(link.controls[0], t), + c2 = worldToView(link.controls[1], t); + const emphasized = interaction?.selectedLinks?.has(link.id) || interaction?.knife?.crossed.has(link.id); + context.strokeStyle = emphasized ? theme.emphasis : color; + context.lineWidth = emphasized ? 4 : 2; + context.beginPath(); + context.moveTo(start.x, start.y); + context.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, end.x, end.y); + context.stroke(); + } + for (const id of snapshot.drawOrder) { + const node = snapshot.nodes.get(id); + if (!node?.visible || node.kind === "frame") continue; + if (node.kind === "reroute") { + paintedNodes++; + const row = node.rows.find((item) => item.kind === "socket"), + socket = row?.kind === "socket" ? snapshot.sockets.get(row.socketId) : undefined; + if (socket) { + const p = worldToView(socket.anchor, t), + selected = interaction?.selectedNodes.has(node.id); + if (selected) { + context.beginPath(); + context.arc(p.x, p.y, (G.reroute + 5) * t.zoom, 0, Math.PI * 2); + context.strokeStyle = node.id === interaction?.activeNode ? theme.nodeActive : theme.nodeSelected; + context.lineWidth = 2; + context.stroke(); + } + context.fillStyle = socket.color; + context.beginPath(); + context.arc(p.x, p.y, G.reroute * t.zoom, 0, Math.PI * 2); + context.fill(); + } + continue; + } + paintedNodes++; + const r = viewRect(node.bounds), + h = G.header * t.zoom; + const radius = G.corner * t.zoom; + context.shadowColor = theme.shadow; + context.shadowBlur = 8; + context.shadowOffsetY = 3; + context.beginPath(); + context.roundRect(r.x, r.y, r.width, r.height, radius); + context.fillStyle = theme.body; + context.fill(); + context.shadowColor = "transparent"; + context.save(); + context.beginPath(); + context.roundRect(r.x, r.y, r.width, r.height, radius); + context.clip(); + context.fillStyle = node.headerColor; + context.fillRect(r.x, r.y, r.width, h); + context.restore(); + context.beginPath(); + context.roundRect(r.x, r.y, r.width, r.height, radius); + context.strokeStyle = theme.outline; + context.lineWidth = 1; + context.stroke(); + context.fillStyle = theme.text; + context.font = `600 ${12 * t.zoom}px sans-serif`; + context.textBaseline = "middle"; + if (t.zoom >= 0.25) context.fillText(node.label, r.x + 22 * t.zoom, r.y + h / 2); + const cx = r.x + 10 * t.zoom, + cy = r.y + h / 2, + collapseAmount = Math.max( + 0, + Math.min(1, interaction?.collapseAnimations?.get(node.id)?.value ?? (node.collapsed ? 1 : 0)), + ); + context.save(); + context.translate(cx, cy); + context.rotate((-Math.PI / 2) * collapseAmount); + context.beginPath(); + context.moveTo(-5 * t.zoom, -2.5 * t.zoom); + context.lineTo(0, 2.5 * t.zoom); + context.lineTo(5 * t.zoom, -2.5 * t.zoom); + context.strokeStyle = "rgba(255,255,255,.75)"; + context.lineWidth = 2 * t.zoom; + context.lineCap = "round"; + context.lineJoin = "round"; + context.stroke(); + context.restore(); + if (interaction?.selectedNodes.has(node.id)) { + context.beginPath(); + context.roundRect(r.x, r.y, r.width, r.height, radius); + context.strokeStyle = node.id === interaction.activeNode ? theme.nodeActive : theme.nodeSelected; + context.lineWidth = 2; + context.stroke(); + } + context.font = `${11 * t.zoom}px sans-serif`; + for (const row of node.rows) { + if ( + row.kind === "header" || + row.kind === "category" || + row.kind === "section" || + row.kind === "panel" || + row.kind === "placeholder" + ) { + const rowRect = viewRect(row.bounds); + context.fillStyle = theme.muted; + context.fillText(row.label, rowRect.x + 8 * t.zoom, rowRect.y + rowRect.height / 2); + } else if (row.kind === "control") { + const control = snapshot.controls.get(row.controlId); + if (control) { + const rowRect = viewRect(row.bounds); + if (control.kind !== "resource" && control.numericFields.length !== 1) { + context.fillStyle = theme.muted; + context.fillText(control.label, rowRect.x + 8 * t.zoom, rowRect.y + rowRect.height / 2); + } + paintControl(context, control, viewRect(control.bounds), theme, t.zoom, interaction, resourceImages); + } + } else if (row.kind === "grading-wheels") { + for (const wheel of row.wheels) { + const scalar = snapshot.controls.get(wheel.scalarControlId), + color = snapshot.controls.get(wheel.colorControlId), + value = color?.value as { kind?: unknown; value?: readonly number[] }; + if (!scalar || !color || value?.kind !== "color" || !value.value || !color.colorWheelBounds) continue; + const label = viewRect(wheel.labelBounds), + plane = viewRect(color.colorWheelBounds.plane), + lightness = viewRect(color.colorWheelBounds.lightness), + model = oklabToOklch(srgbToOklab([value.value[0] ?? 1, value.value[1] ?? 1, value.value[2] ?? 1])); + context.fillStyle = theme.muted; + context.textAlign = "center"; + context.fillText(wheel.label, label.x + label.width / 2, label.y + label.height / 2); + paintOklchWheel( + context, + { plane, lightness }, + model, + t.dpr, + Math.min(0.9, Math.max(0.1, model.l)), + deviceTarget, + ); + paintControl(context, scalar, viewRect(scalar.bounds), theme, t.zoom, interaction); + context.textAlign = "left"; + } + } else if (row.kind === "socket") { + const control = row.controlId ? snapshot.controls.get(row.controlId) : undefined; + const socket = snapshot.sockets.get(row.socketId), + embedsLabel = !!control && !control.linked && control.numericFields.length === 1; + if (socket) paintSocket(context, socket, theme, t.zoom, t, !embedsLabel); + if (control && !control.linked) + paintControl(context, control, viewRect(control.bounds), theme, t.zoom, interaction); + } + } + if (node.muted) { + context.save(); + context.beginPath(); + context.roundRect(r.x, r.y, r.width, r.height, radius); + context.clip(); + context.fillStyle = theme.muteOverlay; + context.fillRect(r.x, r.y, r.width, r.height); + context.restore(); + for (const bypass of node.bypasses) { + const a = worldToView(bypass.from, t), + b = worldToView(bypass.to, t), + dx = Math.max(20, Math.abs(b.x - a.x) * 0.4); + context.strokeStyle = theme.linkMuted; + context.lineWidth = 3; + context.beginPath(); + context.moveTo(a.x, a.y); + context.bezierCurveTo(a.x + dx, a.y, b.x - dx, b.y, b.x, b.y); + context.stroke(); + } + } + if (!node.collapsed) { + context.beginPath(); + context.moveTo(r.x + r.width - 9 * t.zoom, r.y + r.height); + context.lineTo(r.x + r.width, r.y + r.height - 9 * t.zoom); + context.strokeStyle = theme.resize; + context.lineWidth = 1; + context.stroke(); + } + context.textAlign = "left"; + } + if (interaction?.parentHighlight) { + const n = snapshot.nodes.get(interaction.parentHighlight); + if (n) { + const r = viewRect(n.bounds); + context.strokeStyle = theme.focus; + context.lineWidth = 3; + context.strokeRect(r.x, r.y, r.width, r.height); + } + } + if (interaction?.linkDrag) { + const from = [...snapshot.sockets.values()].find((socket) => socket.id === interaction.linkDrag?.from); + if (from) { + const a = worldToView(from.anchor, t), + b = interaction.linkDrag.current, + dx = Math.max(40, Math.abs(b.x - a.x) * 0.5); + context.strokeStyle = from.color; + context.lineWidth = 2; + context.beginPath(); + context.moveTo(a.x, a.y); + context.bezierCurveTo(a.x + dx, a.y, b.x - dx, b.y, b.x, b.y); + context.stroke(); + for (const socket of snapshot.sockets.values()) { + if (layoutSocketsCompatible(from, socket)) { + const p = worldToView(socket.anchor, t); + context.beginPath(); + context.arc(p.x, p.y, socket.id === interaction.linkDrag.candidate ? 10 : 8, 0, Math.PI * 2); + context.strokeStyle = socket.id === interaction.linkDrag.candidate ? theme.emphasis : socket.color; + context.stroke(); + } + } + } + } + if (interaction?.knife) { + const points = interaction.knife.points; + context.strokeStyle = interaction.knife.mode === "mute" ? theme.knifeMuted : theme.emphasis; + context.lineWidth = 2; + context.beginPath(); + points.forEach((p, i) => (i ? context.lineTo(p.x, p.y) : context.moveTo(p.x, p.y))); + context.stroke(); + const p = points.at(-1); + if (p) { + context.beginPath(); + context.moveTo(p.x - 7, p.y - 7); + context.lineTo(p.x + 7, p.y + 7); + context.moveTo(p.x + 7, p.y - 7); + context.lineTo(p.x - 7, p.y + 7); + context.stroke(); + } + } + if (interaction?.box) { + const a = interaction.box.start, + b = interaction.box.current, + x = Math.min(a.x, b.x), + y = Math.min(a.y, b.y), + w = Math.abs(a.x - b.x), + h = Math.abs(a.y - b.y); + context.fillStyle = theme.boxSelectionFill; + context.fillRect(x, y, w, h); + context.strokeStyle = theme.focus; + context.lineWidth = 1; + context.strokeRect(x + 0.5, y + 0.5, w, h); + } + return { + candidateNodes: planned.candidateNodeIds?.length ?? snapshot.drawOrder.length, + totalNodes: planned.totalNodes ?? snapshot.nodes.size, + paintedNodes, + candidateLinks: planned.candidateLinkIds?.length ?? snapshot.links.size, + totalLinks: planned.totalLinks ?? snapshot.links.size, + paintedLinks, + paintMs: performance.now() - started, + }; +} diff --git a/vendor/fxnode/src/render/color-picker-renderer.ts b/vendor/fxnode/src/render/color-picker-renderer.ts new file mode 100644 index 0000000..bf3c935 --- /dev/null +++ b/vendor/fxnode/src/render/color-picker-renderer.ts @@ -0,0 +1,159 @@ +import type { ColorPickerLayout, Rect } from "../layout/types.js"; +import { mapOklchToSrgb, maxSrgbChroma, type Oklch, type Rgba } from "../color/oklab.js"; + +const cache = new Map(); +export interface DevicePaintTarget { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +function wheel(size: number, l: number): ImageData { + const bin = Math.round(l * 32), + key = `${size}:${bin}`, + known = cache.get(key); + if (known) return known; + const image = new ImageData(size, size), + radius = size / 2, + lightness = bin / 32; + for (let y = 0; y < size; y++) + for (let x = 0; x < size; x++) { + const dx = x + 0.5 - radius, + dy = radius - y - 0.5, + r = Math.hypot(dx, dy) / radius, + index = (y * size + x) * 4; + if (r > 1) continue; + const h = Math.atan2(dy, dx), + rgb = mapOklchToSrgb({ l: lightness, c: r * maxSrgbChroma(lightness, h), h }); + image.data[index] = rgb[0] * 255; + image.data[index + 1] = rgb[1] * 255; + image.data[index + 2] = rgb[2] * 255; + image.data[index + 3] = 255; + } + cache.set(key, image); + while (cache.size > 66) cache.delete(cache.keys().next().value!); + return image; +} + +export function paintOklchWheel( + context: OffscreenCanvasRenderingContext2D, + bounds: { plane: Rect; lightness: Rect }, + model: Oklch, + dpr = 1, + planeLightness = model.l, + target: DevicePaintTarget = { x: 0, y: 0, width: Number.MAX_SAFE_INTEGER, height: Number.MAX_SAFE_INTEGER }, +): void { + const size = Math.max(1, Math.round(bounds.plane.width * dpr)), + image = wheel(size, planeLightness), + destinationX = target.x + Math.round(bounds.plane.x * dpr), + destinationY = target.y + Math.round(bounds.plane.y * dpr), + targetRight = target.x + target.width, + targetBottom = target.y + target.height; + // putImageData ignores transforms and clipping. Copy only each opaque wheel + // scanline so transparent corners preserve the picker background and no row + // can write into an adjacent atlas slot. + for (let y = 0; y < size; y++) { + const deviceY = destinationY + y; + if (deviceY < target.y || deviceY >= targetBottom) continue; + let first = 0, + last = size - 1; + while (first < size && image.data[(y * size + first) * 4 + 3] === 0) first++; + while (last >= first && image.data[(y * size + last) * 4 + 3] === 0) last--; + first = Math.max(first, target.x - destinationX); + last = Math.min(last, targetRight - destinationX - 1); + if (first <= last) context.putImageData(image, destinationX, destinationY, first, y, last - first + 1, 1); + } + const light = context.createLinearGradient(0, bounds.lightness.y, 0, bounds.lightness.y + bounds.lightness.height); + for (let i = 0; i <= 8; i++) { + const l = 1 - i / 8, + rgb = mapOklchToSrgb({ l, c: model.c, h: model.h }); + light.addColorStop(i / 8, `rgb(${rgb[0] * 255} ${rgb[1] * 255} ${rgb[2] * 255})`); + } + context.fillStyle = light; + context.fillRect(bounds.lightness.x, bounds.lightness.y, bounds.lightness.width, bounds.lightness.height); + const radius = bounds.plane.width / 2, + cmax = maxSrgbChroma(planeLightness, model.h), + fraction = cmax ? Math.min(1, model.c / cmax) : 0, + cx = bounds.plane.x + radius + radius * fraction * Math.cos(model.h), + cy = bounds.plane.y + radius - radius * fraction * Math.sin(model.h); + context.strokeStyle = "#fff"; + context.lineWidth = 2; + context.beginPath(); + context.arc(cx, cy, 4, 0, Math.PI * 2); + context.stroke(); + const y = bounds.lightness.y + bounds.lightness.height * (1 - model.l); + context.strokeStyle = "#fff"; + context.strokeRect(bounds.lightness.x - 2, y - 2, bounds.lightness.width + 4, 4); +} + +export function paintColorPicker( + context: OffscreenCanvasRenderingContext2D, + layout: ColorPickerLayout, + model: Oklch, + rgba: Rgba, + hsv: readonly number[], + edit?: { field: string; index: number; buffer: string; invalid: boolean }, + dpr = 1, + target: DevicePaintTarget = { x: 0, y: 0, width: Number.MAX_SAFE_INTEGER, height: Number.MAX_SAFE_INTEGER }, +): void { + context.save(); + context.fillStyle = "#181a1f"; + context.strokeStyle = "#f5a623"; + context.lineWidth = 1; + context.beginPath(); + context.roundRect(layout.bounds.x, layout.bounds.y, layout.bounds.width, layout.bounds.height, 7); + context.fill(); + context.stroke(); + paintOklchWheel(context, layout, model, dpr, model.l, target); + const alpha = context.createLinearGradient(0, layout.alpha.y, 0, layout.alpha.y + layout.alpha.height); + alpha.addColorStop(0, `rgba(${rgba[0] * 255},${rgba[1] * 255},${rgba[2] * 255},1)`); + alpha.addColorStop(1, `rgba(${rgba[0] * 255},${rgba[1] * 255},${rgba[2] * 255},0)`); + context.fillStyle = alpha; + context.fillRect(layout.alpha.x, layout.alpha.y, layout.alpha.width, layout.alpha.height); + const y = layout.alpha.y + layout.alpha.height * (1 - rgba[3]); + context.strokeStyle = "#fff"; + context.strokeRect(layout.alpha.x - 2, y - 2, layout.alpha.width + 4, 4); + context.font = "12px sans-serif"; + context.textBaseline = "middle"; + context.textAlign = "center"; + context.fillStyle = "#eee"; + context.fillText("✓", layout.confirm.x + 12, layout.confirm.y + 12); + const hex = + "#" + + rgba + .map((v) => + Math.round(Math.max(0, Math.min(1, v)) * 255) + .toString(16) + .padStart(2, "0"), + ) + .join("") + .toUpperCase(); + for (const [rects, values, name, labels] of [ + [layout.rgba, rgba.map((v) => v.toFixed(3)), "rgba", "RGBA"], + [layout.hsv, [hsv[0]!.toFixed(1), hsv[1]!.toFixed(3), hsv[2]!.toFixed(3)], "hsv", "HSV"], + ] as const) + rects.forEach((r, i) => { + context.fillStyle = "#292c33"; + context.fillRect(r.x, r.y, r.width, r.height); + context.strokeStyle = edit?.field === name && edit.index === i ? (edit.invalid ? "#e55" : "#f5a623") : "#555"; + context.strokeRect(r.x + 0.5, r.y + 0.5, r.width - 1, r.height - 1); + context.fillStyle = "#eee"; + context.fillText( + edit?.field === name && edit.index === i ? edit.buffer : `${labels[i]} ${values[i]}`, + r.x + r.width / 2, + r.y + r.height / 2, + ); + }); + context.fillStyle = "#292c33"; + context.fillRect(layout.hex.x, layout.hex.y, layout.hex.width, layout.hex.height); + context.strokeStyle = edit?.field === "hex" ? (edit.invalid ? "#e55" : "#f5a623") : "#555"; + context.strokeRect(layout.hex.x + 0.5, layout.hex.y + 0.5, layout.hex.width - 1, layout.hex.height - 1); + context.fillStyle = "#eee"; + context.fillText( + edit?.field === "hex" ? edit.buffer : `HEX ${hex}`, + layout.hex.x + layout.hex.width / 2, + layout.hex.y + layout.hex.height / 2, + ); + context.restore(); +} diff --git a/vendor/fxnode/src/research/reference-manifest.ts b/vendor/fxnode/src/research/reference-manifest.ts new file mode 100644 index 0000000..e93b0e0 --- /dev/null +++ b/vendor/fxnode/src/research/reference-manifest.ts @@ -0,0 +1,21 @@ +import { REFERENCE_IDS } from "./reference-types.js"; +import type { ReferenceManifest, Sha256 } from "./reference-types.js"; + +const pendingReason = "Capture environment has no Blender 4.5.0 binary or Xvfb display server."; +const root = "docs/research/blender-references/4.5.0"; + +export const referenceManifest: ReferenceManifest = { + schemaVersion: 1, + baseline: { + blenderVersion: "4.5.0", + sourceCommit: "8cb6b388974a817afedf1317ce26f0c75aa5f181", + binarySha256: "1188b95cc12321c770b631939f7c25a096910b6f884a990bf9c0f62d52b38aec" as Sha256, + manualSnapshot: "f72fe39427bf150242dd6cfdd94d902e535d2286", + }, + references: REFERENCE_IDS.map((id) => ({ + id, + status: "pending", + relativePath: `${root}/${id}.png`, + reason: pendingReason, + })), +}; diff --git a/vendor/fxnode/src/research/reference-types.ts b/vendor/fxnode/src/research/reference-types.ts new file mode 100644 index 0000000..f4011e2 --- /dev/null +++ b/vendor/fxnode/src/research/reference-types.ts @@ -0,0 +1,48 @@ +export type Sha256 = string & { readonly __sha256: unique symbol }; +export type ReferenceStatus = "pending" | "captured"; + +export interface ResearchBaseline { + readonly blenderVersion: "4.5.0"; + readonly sourceCommit: string; + readonly binarySha256: Sha256; + readonly manualSnapshot: string; +} + +export interface PendingReference { + readonly id: ReferenceId; + readonly status: "pending"; + readonly relativePath: string; + readonly reason: string; +} + +export interface CapturedReference { + readonly id: ReferenceId; + readonly status: "captured"; + readonly relativePath: string; + readonly sha256: Sha256; + readonly capturedAt: string; + readonly captureMethod: "self-captured-blender-window"; + readonly width: number; + readonly height: number; +} + +export type ReferenceRecord = PendingReference | CapturedReference; + +export const REFERENCE_IDS = [ + "shader-basic-linked-near", + "shader-basic-linked-far", + "shader-selected-active-hover-near", + "shader-collapsed-near", + "common-frame-reroute-near", + "shader-widget-rich-near", + "shader-socket-gallery-near", + "geometry-socket-gallery-near", +] as const; + +export type ReferenceId = (typeof REFERENCE_IDS)[number]; + +export interface ReferenceManifest { + readonly schemaVersion: 1; + readonly baseline: ResearchBaseline; + readonly references: readonly ReferenceRecord[]; +} diff --git a/vendor/fxnode/src/widgets/color-ramp.ts b/vendor/fxnode/src/widgets/color-ramp.ts new file mode 100644 index 0000000..9c988b3 --- /dev/null +++ b/vendor/fxnode/src/widgets/color-ramp.ts @@ -0,0 +1,188 @@ +/** + * Immutable color-ramp model, mutations, migration, and sampling. + * + * Valid ramps contain 2–32 uniquely identified, position-sorted stops. Mutations + * preserve those invariants and clamp finite positions/channels to [0, 1]. `hsl` + * is currently identical to `hsv` (it is not HSL). `b-spline` uses the same easing + * as `ease`; `cardinal` is a two-stop approximation without neighbourhood spline + * evaluation. + * @module fxnode/widgets/color-ramp + */ +export type ColorRampMode = "rgb" | "hsv" | "hsl"; +export type ColorRampInterpolation = "linear" | "ease" | "constant" | "cardinal" | "b-spline"; +export type HueInterpolation = "near" | "far" | "clockwise" | "counter-clockwise"; +export interface ColorRampStop { + readonly id: string; + readonly position: number; + readonly color: readonly [number, number, number, number]; +} +export interface ColorRamp { + readonly colorMode: ColorRampMode; + readonly interpolation: ColorRampInterpolation; + readonly hueInterpolation: HueInterpolation; + readonly stops: readonly ColorRampStop[]; +} +const clamp = (n: number) => Math.max(0, Math.min(1, n)); +export function isColorRamp(value: unknown): value is ColorRamp { + if (!value || typeof value !== "object") return false; + const r = value as ColorRamp; + if ( + !(["rgb", "hsv", "hsl"] as unknown[]).includes(r.colorMode) || + !(["linear", "ease", "constant", "cardinal", "b-spline"] as unknown[]).includes(r.interpolation) || + !(["near", "far", "clockwise", "counter-clockwise"] as unknown[]).includes(r.hueInterpolation) || + !Array.isArray(r.stops) || + r.stops.length < 2 || + r.stops.length > 32 + ) + return false; + let previous = -Infinity; + const ids = new Set(); + return r.stops.every( + (s) => + typeof s?.id === "string" && + s.id.length > 0 && + !ids.has(s.id) && + !!ids.add(s.id) && + Number.isFinite(s.position) && + s.position >= 0 && + s.position <= 1 && + s.position >= previous && + (previous = s.position) >= 0 && + Array.isArray(s.color) && + s.color.length === 4 && + s.color.every((c: number) => Number.isFinite(c) && c >= 0 && c <= 1), + ); +} +export function migrateColorRamp(value: unknown): ColorRamp | undefined { + const raw = + value && + typeof value === "object" && + "kind" in value && + (value as { kind?: unknown }).kind === "json" && + "value" in value + ? (value as unknown as { value: unknown }).value + : value; + if (isColorRamp(raw)) return raw; + if (!Array.isArray(raw) || raw.length < 2 || raw.length > 32) return; + const stops = raw.map((s, i) => { + const x = s as { id?: unknown; position?: unknown; color?: unknown }; + return { id: typeof x?.id === "string" && x.id ? x.id : `stop-${i}`, position: x?.position, color: x?.color }; + }); + const candidate = { colorMode: "rgb", interpolation: "linear", hueInterpolation: "near", stops }; + return isColorRamp(candidate) ? candidate : undefined; +} +const sorted = (r: ColorRamp, stops: readonly ColorRampStop[]): ColorRamp => ({ + ...r, + stops: [...stops].sort((a, b) => a.position - b.position), +}); +/** Selects the indexed stop at an exact position, or returns `undefined`. */ +export const selectRampStop = (r: ColorRamp, position: number, index = 0): string | undefined => + r.stops.filter((s) => s.position === position)[index]?.id; +/** Adds a sampled stop, preserving ramp validity; invalid IDs/positions are no-ops. */ +export function addRampStop(r: ColorRamp, position: number, id: string): ColorRamp { + if (!id || !Number.isFinite(position) || r.stops.length >= 32 || r.stops.some((s) => s.id === id)) return r; + return sorted(r, [...r.stops, { id, position: clamp(position), color: sampleColorRamp(r, position) }]); +} +/** Blender's plus inserts halfway between active and its left neighbour (or right neighbour for the first stop). */ +export function addRampMidpoint(r: ColorRamp, activeId: string, id: string): ColorRamp { + const i = r.stops.findIndex((s) => s.id === activeId), + a = r.stops[i]; + if (!a) return r; + const other = r.stops[i > 0 ? i - 1 : i + 1]; + return other ? addRampStop(r, (a.position + other.position) / 2, id) : r; +} +export function removeRampStop(r: ColorRamp, id: string): ColorRamp { + return r.stops.length <= 2 ? r : { ...r, stops: r.stops.filter((s) => s.id !== id) }; +} +export function moveRampStop(r: ColorRamp, id: string, position: number): ColorRamp { + if (!Number.isFinite(position)) return r; + return sorted( + r, + r.stops.map((s) => (s.id === id ? { ...s, position: clamp(position) } : s)), + ); +} +export function setRampColor(r: ColorRamp, id: string, color: readonly [number, number, number, number]): ColorRamp { + if (!color.every(Number.isFinite)) return r; + return { + ...r, + stops: r.stops.map((s) => + s.id === id ? { ...s, color: color.map(clamp) as unknown as readonly [number, number, number, number] } : s, + ), + }; +} +export function flipColorRamp(r: ColorRamp): ColorRamp { + return sorted( + r, + r.stops.map((s) => ({ ...s, position: 1 - s.position })), + ); +} +export function distributeColorRamp(r: ColorRamp): ColorRamp { + const first = r.stops[0]!.position, + last = r.stops.at(-1)!.position, + n = r.stops.length - 1; + return { ...r, stops: r.stops.map((s, i) => ({ ...s, position: first + ((last - first) * i) / n })) }; +} +const rgbToHsv = (c: readonly number[]) => { + const [r, g, b] = c, + m = Math.max(r!, g!, b!), + n = Math.min(r!, g!, b!), + d = m - n; + let h = 0; + if (d) h = m === r ? ((g! - b!) / d + 6) % 6 : m === g ? (b! - r!) / d + 2 : (r! - g!) / d + 4; + return [h / 6, m ? d / m : 0, m]; +}; +const hsvToRgb = ([h, s, v]: readonly number[]) => { + const wrapped = ((h! % 1) + 1) % 1, + i = Math.floor(wrapped * 6), + f = wrapped * 6 - i, + p = v! * (1 - s!), + q = v! * (1 - f * s!), + t = v! * (1 - (1 - f) * s!); + return ( + [ + [v, t, p], + [q, v, p], + [p, v, t], + [p, q, v], + [t, p, v], + [v, p, q], + ] as number[][] + )[i % 6]!; +}; +function hueDelta(a: number, b: number, mode: HueInterpolation) { + let d = (((b - a) % 1) + 1) % 1; + if (mode === "near" && d > 0.5) d -= 1; + if (mode === "far" && d < 0.5) d -= 1; + if (mode === "counter-clockwise" && d > 0) d -= 1; + return d; +} +/** Samples a valid ramp; non-finite positions safely sample its first stop. */ +export function sampleColorRamp(r: ColorRamp, position: number): readonly [number, number, number, number] { + if (!Number.isFinite(position)) return r.stops[0]!.color; + const p = clamp(position), + right = r.stops.findIndex((s) => s.position >= p); + if (right <= 0) return r.stops[Math.max(0, right)]!.color; + const b = r.stops[right] ?? r.stops.at(-1)!, + a = r.stops[right - 1]!, + span = b.position - a.position; + let t = span ? (p - a.position) / span : 1; + if (r.interpolation === "constant") t = 0; + else if (r.interpolation === "ease") t = t * t * (3 - 2 * t); + else if (r.interpolation === "cardinal") t = t * t * (2 - t); + else if (r.interpolation === "b-spline") t = t * t * (3 - 2 * t); + let av = [...a.color.slice(0, 3)], + bv = [...b.color.slice(0, 3)]; + if (r.colorMode !== "rgb") { + av = rgbToHsv(av); + bv = rgbToHsv(bv); + av[0] = av[0]! + hueDelta(av[0]!, bv[0]!, r.hueInterpolation) * t; + const rgb = hsvToRgb([av[0], av[1]! + (bv[1]! - av[1]!) * t, av[2]! + (bv[2]! - av[2]!) * t]); + return [clamp(rgb[0]!), clamp(rgb[1]!), clamp(rgb[2]!), a.color[3] + (b.color[3] - a.color[3]) * t]; + } + return [ + clamp(av[0]! + (bv[0]! - av[0]!) * t), + clamp(av[1]! + (bv[1]! - av[1]!) * t), + clamp(av[2]! + (bv[2]! - av[2]!) * t), + clamp(a.color[3] + (b.color[3] - a.color[3]) * t), + ]; +} diff --git a/vendor/fxnode/src/worker/atlas-allocator.ts b/vendor/fxnode/src/worker/atlas-allocator.ts new file mode 100644 index 0000000..8c09238 --- /dev/null +++ b/vendor/fxnode/src/worker/atlas-allocator.ts @@ -0,0 +1,260 @@ +import { FXNODE_VIEW_LIMITS } from "../browser/view-limits.js"; + +export interface AtlasSize { + readonly width: number; + readonly height: number; +} +export interface AtlasRect extends AtlasSize { + readonly x: number; + readonly y: number; +} +export interface AtlasItem extends AtlasSize { + readonly id: string; +} +export interface AtlasLayout extends AtlasSize { + readonly items: ReadonlyMap; + readonly regions: ReadonlyMap; + readonly free: readonly AtlasRect[]; +} +export type AtlasPlanKind = "allocated" | "resized-in-place" | "relocated" | "repacked" | "grown"; +export type AtlasPlan = + | { + readonly ok: true; + readonly kind: AtlasPlanKind; + readonly layout: AtlasLayout; + readonly movedIds: readonly string[]; + } + | { readonly ok: false; readonly code: "atlas.dimension" | "atlas.capacity" }; + +const BLOCK = 256; +const area = ({ width, height }: AtlasSize) => width * height; +const freeOrder = (left: AtlasRect, right: AtlasRect) => + left.y - right.y || left.x - right.x || left.height - right.height || left.width - right.width; +const validItem = ({ width, height }: AtlasSize) => + Number.isSafeInteger(width) && + Number.isSafeInteger(height) && + width > 0 && + height > 0 && + width <= FXNODE_VIEW_LIMITS.maxDeviceDimension && + height <= FXNODE_VIEW_LIMITS.maxDeviceDimension && + width * height <= FXNODE_VIEW_LIMITS.maxDevicePixelsPerView; +const freezeRect = (rect: AtlasRect): AtlasRect => Object.freeze(rect); +const makeLayout = ( + width: number, + height: number, + items: ReadonlyMap, + regions: ReadonlyMap, + free: readonly AtlasRect[], +): AtlasLayout => + Object.freeze({ + width, + height, + items: new Map(items), + regions: new Map(regions), + free: Object.freeze(free.map(freezeRect).sort(freeOrder)), + }); + +function coalesce(rectangles: readonly AtlasRect[]): AtlasRect[] { + const result = rectangles.map((rect) => ({ ...rect })); + for (;;) { + let merged = false; + outer: for (let i = 0; i < result.length; i++) + for (let j = i + 1; j < result.length; j++) { + const a = result[i]!, + b = result[j]!; + let next: AtlasRect | undefined; + if (a.y === b.y && a.height === b.height && (a.x + a.width === b.x || b.x + b.width === a.x)) + next = { x: Math.min(a.x, b.x), y: a.y, width: a.width + b.width, height: a.height }; + else if (a.x === b.x && a.width === b.width && (a.y + a.height === b.y || b.y + b.height === a.y)) + next = { x: a.x, y: Math.min(a.y, b.y), width: a.width, height: a.height + b.height }; + if (next) { + result.splice(j, 1); + result.splice(i, 1, next); + merged = true; + break outer; + } + } + if (!merged) return result.sort(freeOrder); + } +} + +function insert(layout: AtlasLayout, item: AtlasItem): AtlasLayout | undefined { + const candidates = layout.free + .map((rect, index) => ({ rect, index })) + .filter(({ rect }) => item.width <= rect.width && item.height <= rect.height) + .sort(({ rect: left }, { rect: right }) => { + const ldw = left.width - item.width, + ldh = left.height - item.height, + rdw = right.width - item.width, + rdh = right.height - item.height; + return ( + area(left) - area(item) - (area(right) - area(item)) || + Math.min(ldw, ldh) - Math.min(rdw, rdh) || + Math.max(ldw, ldh) - Math.max(rdw, rdh) || + freeOrder(left, right) + ); + }); + const selected = candidates[0]; + if (!selected) return; + const { rect, index } = selected, + region = { x: rect.x, y: rect.y, width: item.width, height: item.height }, + dw = rect.width - item.width, + dh = rect.height - item.height, + remainder: AtlasRect[] = []; + if (dw > dh) { + if (dw) remainder.push({ x: rect.x + item.width, y: rect.y, width: dw, height: rect.height }); + if (dh) remainder.push({ x: rect.x, y: rect.y + item.height, width: item.width, height: dh }); + } else { + if (dh) remainder.push({ x: rect.x, y: rect.y + item.height, width: rect.width, height: dh }); + if (dw) remainder.push({ x: rect.x + item.width, y: rect.y, width: dw, height: item.height }); + } + const items = new Map(layout.items), + regions = new Map(layout.regions), + free = layout.free.slice(); + items.set(item.id, Object.freeze({ width: item.width, height: item.height })); + regions.set(item.id, freezeRect(region)); + free.splice(index, 1, ...remainder); + return makeLayout(layout.width, layout.height, items, regions, free); +} + +function emptyLayout(width: number, height: number): AtlasLayout { + return makeLayout(width, height, new Map(), new Map(), [{ x: 0, y: 0, width, height }]); +} +function sortedItems(items: ReadonlyMap): AtlasItem[] { + return [...items] + .map(([id, size]) => ({ id, ...size })) + .sort((left, right) => { + const side = Math.max(right.width, right.height) - Math.max(left.width, left.height); + return ( + side || + area(right) - area(left) || + right.height - left.height || + right.width - left.width || + (left.id < right.id ? -1 : left.id > right.id ? 1 : 0) + ); + }); +} +function pack(items: readonly AtlasItem[], width: number, height: number): AtlasLayout | undefined { + let layout = emptyLayout(width, height); + for (const item of items) { + const next = insert(layout, item); + if (!next) return; + layout = next; + } + return layout; +} +function candidates(items: readonly AtlasItem[], minimumArea: number): AtlasSize[] { + const largestWidth = Math.max(...items.map((item) => item.width)), + largestHeight = Math.max(...items.map((item) => item.height)), + result: AtlasSize[] = []; + for (let width = BLOCK; width <= FXNODE_VIEW_LIMITS.maxAtlasDimension; width += BLOCK) + for (let height = BLOCK; height <= FXNODE_VIEW_LIMITS.maxAtlasDimension; height += BLOCK) { + const pixels = width * height; + if ( + width >= largestWidth && + height >= largestHeight && + pixels >= minimumArea && + pixels <= FXNODE_VIEW_LIMITS.maxAtlasPixels + ) + result.push({ width, height }); + } + return result.sort( + (left, right) => + area(left) - area(right) || + Math.abs(left.width - left.height) - Math.abs(right.width - right.height) || + left.width - right.width || + left.height - right.height, + ); +} +function repack( + itemsMap: ReadonlyMap, + current?: AtlasLayout, + compact = false, +): AtlasLayout | undefined { + const items = sortedItems(itemsMap), + activeArea = items.reduce((sum, item) => sum + area(item), 0); + if (current && !compact) { + const same = pack(items, current.width, current.height); + if (same) return same; + } + const floor = + current && !compact + ? Math.min(FXNODE_VIEW_LIMITS.maxAtlasPixels, Math.max(activeArea, area(current) * 2)) + : activeArea; + for (const size of candidates(items, floor)) { + const layout = pack(items, size.width, size.height); + if (layout) return layout; + } +} +function movedIds(before: AtlasLayout | undefined, after: AtlasLayout): string[] { + return [...after.regions] + .filter(([id, rect]) => { + const previous = before?.regions.get(id); + return !previous || previous.x !== rect.x || previous.y !== rect.y; + }) + .map(([id]) => id) + .sort(); +} + +export function planAtlasUpsert(current: AtlasLayout | undefined, item: AtlasItem): AtlasPlan { + if (!validItem(item)) return { ok: false, code: "atlas.dimension" }; + const items = new Map(current?.items); + items.set(item.id, { width: item.width, height: item.height }); + if ([...items.values()].reduce((sum, value) => sum + area(value), 0) > FXNODE_VIEW_LIMITS.maxActiveDevicePixels) + return { ok: false, code: "atlas.capacity" }; + const previousSize = current?.items.get(item.id), + previousRegion = current?.regions.get(item.id); + if ( + current && + previousSize && + previousRegion && + item.width <= previousRegion.width && + item.height <= previousRegion.height + ) { + const nextItems = new Map(current.items); + nextItems.set(item.id, Object.freeze({ width: item.width, height: item.height })); + return { + ok: true, + kind: "resized-in-place", + layout: makeLayout(current.width, current.height, nextItems, current.regions, current.free), + movedIds: [], + }; + } + let base = current; + if (current && previousRegion) base = removeAtlasItem(current, item.id); + const incremental = base && insert(base, item); + if (incremental) + return { + ok: true, + kind: previousSize ? "relocated" : "allocated", + layout: incremental, + movedIds: movedIds(current, incremental), + }; + const layout = repack(items, current); + if (!layout) return { ok: false, code: "atlas.capacity" }; + return { + ok: true, + kind: current && layout.width === current.width && layout.height === current.height ? "repacked" : "grown", + layout, + movedIds: movedIds(current, layout), + }; +} + +export function removeAtlasItem(current: AtlasLayout, id: string): AtlasLayout | undefined { + const region = current.regions.get(id); + if (!region) return current; + const items = new Map(current.items), + regions = new Map(current.regions); + items.delete(id); + regions.delete(id); + if (!items.size) return; + return makeLayout(current.width, current.height, items, regions, coalesce([...current.free, region])); +} + +export function planAtlasCompaction(current: AtlasLayout): AtlasPlan | undefined { + const active = [...current.items.values()].reduce((sum, item) => sum + area(item), 0); + if (active / area(current) > 0.25) return; + const layout = repack(current.items, current, true); + if (!layout || area(layout) > area(current) / 2) return; + return { ok: true, kind: "repacked", layout, movedIds: movedIds(current, layout) }; +} diff --git a/vendor/fxnode/src/worker/control-edit.ts b/vendor/fxnode/src/worker/control-edit.ts new file mode 100644 index 0000000..b4af2b7 --- /dev/null +++ b/vendor/fxnode/src/worker/control-edit.ts @@ -0,0 +1,80 @@ +import type { FxNodeValueSchema } from "../composition/types.js"; +import type { ParameterValue } from "../core/types.js"; +import type { LayoutControl } from "../layout/types.js"; + +export function clampNumber(value: number, schema: FxNodeValueSchema | undefined): number { + if (!schema) return value; + if (schema.type !== "number" && schema.type !== "vector" && schema.type !== "color") return value; + const minimum = schema.type === "color" ? 0 : schema.minimum; + const maximum = schema.type === "color" ? 1 : schema.maximum; + return Math.min(maximum ?? Infinity, Math.max(minimum ?? -Infinity, value)); +} + +export function snapNumber(value: number, schema: FxNodeValueSchema | undefined): number { + if (schema?.type !== "number") return value; + const step = schema.step ?? (schema.integer ? 1 : undefined); + if (!step) return value; + const origin = schema.minimum ?? 0; + return origin + Math.round((value - origin) / step) * step; +} + +export function scrubValue( + control: LayoutControl, + original: ParameterValue, + component: number, + deltaPixels: number, + fine: boolean, + snapping: boolean, +): ParameterValue { + const scale = fine ? 0.01 : 0.1; + const next = (value: number): number => { + const changed = value + deltaPixels * scale; + const snapped = snapping ? snapNumber(changed, control.schema) : changed; + const clamped = clampNumber(snapped, control.schema); + return control.schema?.type === "number" && control.schema.integer ? Math.round(clamped) : clamped; + }; + if (original.kind === "number") return { kind: "number", value: next(original.value) }; + if (original.kind === "vector") { + const value: [number, number, number] = [...original.value]; + value[component] = next(value[component] ?? 0); + return { kind: "vector", value }; + } + if (original.kind === "color") { + const value: [number, number, number, number] = [...original.value]; + value[component] = Math.min(1, Math.max(0, next(value[component] ?? 0))); + return { kind: "color", value }; + } + return original; +} + +export function setNumericComponent( + control: LayoutControl, + original: ParameterValue, + component: number, + input: number, +): ParameterValue { + const clamped = clampNumber(input, control.schema); + const next = control.schema?.type === "number" && control.schema.integer ? Math.round(clamped) : clamped; + if (original.kind === "number") return { kind: "number", value: next }; + if (original.kind === "vector") { + const value: [number, number, number] = [...original.value]; + value[component] = next; + return { kind: "vector", value }; + } + if (original.kind === "color") { + const value: [number, number, number, number] = [...original.value]; + value[component] = Math.min(1, Math.max(0, next)); + return { kind: "color", value }; + } + return original; +} + +export function numericStep(control: LayoutControl, fine: boolean): number { + const base = control.schema?.type === "number" ? (control.schema.step ?? (control.schema.integer ? 1 : 0.1)) : 0.1; + return fine && control.schema?.type === "number" && !control.schema.integer ? base / 10 : fine ? base / 10 : base; +} + +export function cycleEnum(values: readonly string[], current: string, direction: 1 | -1): string { + const index = values.indexOf(current); + return values[(Math.max(0, index) + direction + values.length) % values.length] ?? current; +} diff --git a/vendor/fxnode/src/worker/fxnode.worker.ts b/vendor/fxnode/src/worker/fxnode.worker.ts new file mode 100644 index 0000000..258e87a --- /dev/null +++ b/vendor/fxnode/src/worker/fxnode.worker.ts @@ -0,0 +1,2689 @@ +/// +import { + validRequest, + PROTOCOL_VERSION, + type CompositionChange, + type CompositionChangeEnvelope, + type CompositionReceipt, + type CompositionUpdateWire, + type Viewport, + type WorkerMessage, + type WorkerRequest, +} from "../browser/protocol.js"; +import { commandId, linkId, nodeId } from "../core/types.js"; +import { + createInitialFxNodeComposition, + compileFxNodeComposition, + FxNodeCompositionError, +} from "../composition/compile.js"; +import { bindFxNodeHeadless } from "../headless-runtime.js"; +import type { CompiledFxNodeComposition, FxNodeCompositionData } from "../composition/types.js"; +import { + rebindBoundEngineAuthority, + type EngineState as BoundEngineState, + type LoadResult as BoundLoadResult, + type MutationEnvelope as BoundMutationEnvelope, + type ReplayResult as BoundReplayResult, + type SnapshotEnvelope as BoundSnapshotEnvelope, + type TransitionResult as BoundTransitionResult, +} from "../composition/bound-engine.js"; +import { applyNodeOrder, layoutGraph } from "../layout/layout-graph.js"; +import { IndexedLayoutStore } from "../layout/indexed-layout-store.js"; +import { viewToWorld, worldToView } from "../layout/geometry.js"; +import type { LayoutSnapshot } from "../layout/types.js"; +import { layoutColorPicker } from "../layout/color-picker-layout.js"; +import { renderCanvas } from "../render/canvas-renderer.js"; +import { paintColorPicker } from "../render/color-picker-renderer.js"; +import { DirtyReason, RenderScheduler, requestViewInvalidations } from "./render-scheduler.js"; +import { createSession, resetSessionForCompositionRebind, resetSessionForGraphReplacement } from "./session.js"; +import { cycleEnum, numericStep, scrubValue, setNumericComponent } from "./control-edit.js"; +import { + boxNodes, + clampResize, + compatibleTargets, + frameDropCandidate, + groupRoots, + hitTest, + planLink, + zoomAt, +} from "./interaction.js"; +import { + addRampMidpoint, + addRampStop, + distributeColorRamp, + flipColorRamp, + isColorRamp, + moveRampStop, + removeRampStop, + setRampColor, + type ColorRamp, +} from "../widgets/color-ramp.js"; +import { appendKnifePoint, crossedLinks } from "./knife-path.js"; +import { pointerLaneFence, readPointerMove } from "../browser/pointer-lane.js"; +import type { PointerFence } from "../browser/protocol.js"; +import { mapOklchToSrgb, maxSrgbChroma, oklabToOklch, srgbToOklab, type Rgba } from "../color/oklab.js"; +import { canonicalJsonEqual, nullRecord } from "../core/json.js"; +import { advanceJournal, checkpointJournal, importJournal, journalSaveData, type WorkerJournal } from "./journal.js"; +import type { Command } from "../commands/types.js"; +import { planSelectionMute, planSelectionRemoval } from "./selection-actions.js"; +import { FXNODE_VIEW_LIMITS, fxNodeDevicePixels } from "../browser/view-limits.js"; +import type { WorkerSession } from "./session.js"; +import { ViewAtlasError, ViewAtlasManager } from "./view-atlas.js"; + +const scope = self as unknown as DedicatedWorkerGlobalScope; +let state: BoundEngineState | undefined; +let journal: WorkerJournal | undefined; +let application: + | { + compiled: CompiledFxNodeComposition; + runtime: ReturnType>; + } + | undefined; +let layoutStore: IndexedLayoutStore | undefined; +interface WorkerView { + readonly id: string; + readonly session: WorkerSession; + viewport: Viewport; + deviceWidth: number; + deviceHeight: number; + surfaceGeneration: number; + resizePending: boolean; + currentLayout: LayoutSnapshot | undefined; + gestureLayout: LayoutSnapshot | undefined; + readonly scheduler: RenderScheduler; + readonly pointerLane: SharedArrayBuffer | undefined; + handledPointerFence: number; + consumedPointerSequence: number; + hostGeneration: number; + selectionSignature: string; + inFlightPixels: { readonly frameId: number; readonly pixels: number } | undefined; +} +const views = new Map(); +let inFlightDevicePixels = 0; +const atlas = new ViewAtlasManager(undefined, (error) => fatal(error, error.code)); +const usedViewIds = new Set(); +const resourceImages = new Map(); +const resourceOperations = new Map(); +let resourceSerial = 0; +let compositionRevision = 0; +const post = (message: WorkerMessage, transfer: Transferable[] = []): void => scope.postMessage(message, transfer); +function releaseFramePixels(view: WorkerView, frameId?: number): boolean { + const ticket = view.inFlightPixels; + if (!ticket || (frameId !== undefined && ticket.frameId !== frameId)) return false; + view.inFlightPixels = undefined; + inFlightDevicePixels -= ticket.pixels; + return true; +} +type WireIssue = { readonly code: string; readonly path: string; readonly message: string }; +type WireError = { + readonly code: string; + readonly message: string; + readonly path?: string; + readonly issues?: readonly WireIssue[]; +}; +const bounded = (value: unknown, limit: number, fallback: string) => String(value ?? fallback).slice(0, limit); +function toWireIssues(value: unknown): readonly WireIssue[] | undefined { + if (!Array.isArray(value)) return; + return value.slice(0, 100).map((issue) => { + const item = + typeof issue === "object" && issue !== null + ? (issue as { code?: unknown; path?: unknown; message?: unknown }) + : {}; + return { + code: bounded(item.code, 128, "error"), + path: bounded(item.path, 512, "/"), + message: bounded(item.message, 1024, "Request failed"), + }; + }); +} +function toWireError( + value: { readonly code?: unknown; readonly message?: unknown; readonly path?: unknown; readonly issues?: unknown }, + fallbackCode = "worker.error", + fallbackMessage = "Request failed", +): WireError { + const path = value.path === undefined ? undefined : bounded(value.path, 512, "/"); + const issues = toWireIssues(value.issues); + return { + code: bounded(value.code, 128, fallbackCode), + message: bounded(value.message, 2048, fallbackMessage), + ...(path === undefined ? {} : { path }), + ...(issues === undefined ? {} : { issues }), + }; +} +function reject( + id: string, + error: { readonly code?: unknown; readonly message?: unknown; readonly path?: unknown; readonly issues?: unknown }, +): void { + post({ protocol: PROTOCOL_VERSION, type: "response", id, ok: false, error: toWireError(error) }); +} + +async function drawView(view: WorkerView, frameId: number, renderId: number, reasons: number): Promise { + if (!state || views.get(view.id) !== view || view.resizePending) { + if (views.get(view.id) === view) view.scheduler.defer(frameId, reasons); + return; + } + const pixels = view.deviceWidth * view.deviceHeight; + if (view.inFlightPixels || inFlightDevicePixels + pixels > FXNODE_VIEW_LIMITS.maxInFlightDevicePixels) { + view.scheduler.defer(frameId, reasons); + return; + } + view.inFlightPixels = { frameId, pixels }; + inFlightDevicePixels += pixels; + advanceCollapseAnimations(view, performance.now()); + const expectedWidth = view.deviceWidth, + expectedHeight = view.deviceHeight; + const preview = + view.session.previewPositions.size || view.session.previewSizes.size || view.session.previewValues.size; + const nodes = preview + ? Object.fromEntries( + Object.entries(state.document.nodes).map(([id, node]) => { + const values = [...view.session.previewValues].filter(([controlId]) => controlId.startsWith(`${id}:`)); + let next = { + ...node, + ...(view.session.previewPositions.has(node.id) + ? { position: view.session.previewPositions.get(node.id)! } + : {}), + ...(view.session.previewSizes.has(node.id) ? { size: view.session.previewSizes.get(node.id)! } : {}), + }; + for (const [controlId, value] of values) { + const control = view.currentLayout?.controls.get(controlId); + if (control?.source === "parameter" && next.known) + next = { ...next, parameters: { ...next.parameters, [control.key]: value } }; + else if (control?.source === "socket-default") + next = { + ...next, + sockets: next.sockets.map((socket) => + socket.id === control.key ? { ...socket, defaultValue: value } : socket, + ), + }; + } + return [id, next]; + }), + ) + : state.document.nodes; + const transform = currentTransform(view); + const layout = applyNodeOrder( + nodes === state.document.nodes && layoutStore + ? layoutStore.view(transform) + : layoutGraph(application!.compiled, { ...state.document, nodes }, transform), + view.session.uiOrder, + ); + view.currentLayout = layout; + publishSelection(view); + if (view.session.collapseAnimations.size) view.scheduler.request(renderId, DirtyReason.Preview); + let frameHostGeneration = 0, + frameSurfaceGeneration = 0, + frameDeviceWidth = 0, + frameDeviceHeight = 0, + frameHost = { colorPickerOpen: false }; + const bitmap = await atlas.renderAndCrop(view.id, { width: expectedWidth, height: expectedHeight }, (target) => { + frameHostGeneration = view.hostGeneration; + frameSurfaceGeneration = view.surfaceGeneration; + frameDeviceWidth = view.deviceWidth; + frameDeviceHeight = view.deviceHeight; + frameHost = { colorPickerOpen: !!view.session.colorPicker }; + const deviceTarget = { + x: target.deviceX, + y: target.deviceY, + width: target.deviceWidth, + height: target.deviceHeight, + }; + renderCanvas(target.context, layout, application!.compiled.theme, view.session, resourceImages, deviceTarget); + if (view.session.colorPicker) + paintColorPicker( + target.context, + view.session.colorPicker.layout, + view.session.colorPicker.model, + view.session.colorPicker.rgba, + view.session.colorPicker.hsv, + view.session.colorPicker.edit, + view.viewport.dpr, + deviceTarget, + ); + }); + if (!bitmap) { + releaseFramePixels(view, frameId); + if (views.get(view.id) === view) view.scheduler.defer(frameId, reasons); + return; + } + if (views.get(view.id) !== view) { + bitmap.close(); + releaseFramePixels(view, frameId); + return; + } + try { + post( + { + protocol: PROTOCOL_VERSION, + type: "view.frame", + viewId: view.id, + bitmap, + renderId, + frameId, + hostGeneration: frameHostGeneration, + surfaceGeneration: frameSurfaceGeneration, + deviceWidth: frameDeviceWidth, + deviceHeight: frameDeviceHeight, + host: frameHost, + }, + [bitmap], + ); + } catch (error) { + bitmap.close(); + releaseFramePixels(view, frameId); + throw error; + } +} +function fatal(error: unknown, code = "worker.fatal"): void { + if (fataled) return; + fataled = true; + for (const view of views.values()) { + view.scheduler.stop(); + releaseFramePixels(view); + } + views.clear(); + atlas.dispose(); + resourceOperations.clear(); + closeResourceImages(); + post({ + protocol: PROTOCOL_VERSION, + type: "fatal", + error: { code, message: error instanceof Error ? error.message : "Worker failure" }, + }); + scope.close(); +} +let fataled = false; +type CommitPresentationPolicy = { readonly kind: "none" } | { readonly kind: "select-added"; readonly nodeId: string }; +const noPresentation: CommitPresentationPolicy = { kind: "none" }; +function commit( + result: Extract, + command: Command, + sourceView?: WorkerView, + presentation: CommitPresentationPolicy = noPresentation, +): void { + for (const view of views.values()) cancelGesture(view); + const nextJournal = advanceJournal( + journal!, + command, + application!.runtime.save(result.state.document), + (baseline, commands) => application!.runtime.validateReplayJournal(baseline, commands, result.state.historyLimit), + ); + if (result.mutationEnvelope.mutations.some((mutation) => mutation.kind === "document.replaced")) { + installDocumentReplacement(result, nextJournal); + return; + } + state = result.state; + journal = nextJournal; + const collapseTimestamp = performance.now(); + for (const view of views.values()) seedCollapseAnimations(view, result.mutationEnvelope.mutations, collapseTimestamp); + layoutStore?.rebuild(result.state.document); + for (const view of views.values()) pruneSession(view); + if ( + presentation.kind === "select-added" && + sourceView && + views.get(sourceView.id) === sourceView && + state.document.nodes[presentation.nodeId] + ) { + const id = nodeId(presentation.nodeId); + sourceView.session.selectedNodes = new Set([id]); + sourceView.session.selectedLinks.clear(); + sourceView.session.activeNode = id; + raiseNode(sourceView, id); + } + post({ protocol: PROTOCOL_VERSION, type: "mutation", envelope: result.mutationEnvelope }); + post({ protocol: PROTOCOL_VERSION, type: "snapshot.event", envelope: result.snapshotEnvelope }); + for (const view of views.values()) { + refreshLayout(view); + publishSelection(view); + view.scheduler.request(); + } +} +function installDocumentReplacement( + result: + | Extract + | Extract + | Extract, + nextJournal: WorkerJournal, +): void { + const nextStore = new IndexedLayoutStore(application!.compiled, result.state.document); + for (const view of views.values()) cancelGesture(view); + state = result.state; + journal = nextJournal; + layoutStore = nextStore; + for (const view of views.values()) { + view.gestureLayout = undefined; + resetSessionForGraphReplacement(view.session); + view.currentLayout = applyNodeOrder(nextStore.view(currentTransform(view)), []); + } + closeResourceImages(); + post({ protocol: PROTOCOL_VERSION, type: "mutation", envelope: result.mutationEnvelope }); + post({ protocol: PROTOCOL_VERSION, type: "snapshot.event", envelope: result.snapshotEnvelope }); + for (const view of views.values()) { + publishSelection(view); + view.scheduler.request(); + } +} +function collapseValue(animation: import("./session.js").CollapseAnimation, now: number): number { + const t = Math.max(0, Math.min(1, (now - animation.startedAt) / animation.durationMs)), + eased = 1 - (1 - t) ** 3; + return animation.from + (animation.to - animation.from) * eased; +} +function advanceCollapseAnimations(view: WorkerView, now: number): void { + for (const [id, animation] of view.session.collapseAnimations) { + animation.value = collapseValue(animation, now); + if (now - animation.startedAt >= animation.durationMs) view.session.collapseAnimations.delete(id); + } +} +function seedCollapseAnimations( + view: WorkerView, + mutations: readonly import("../engine/mutations.js").Mutation[], + now: number, +): void { + const changes = new Map< + import("../core/types.js").NodeId, + { before: import("../core/types.js").GraphNode | null; after: import("../core/types.js").GraphNode | null } + >(); + for (const mutation of mutations) + if (mutation.kind === "node.set") { + const prior = changes.get(mutation.id); + changes.set(mutation.id, { before: prior ? prior.before : mutation.before, after: mutation.after }); + } + for (const [id, change] of changes) { + if (!change.after) { + view.session.collapseAnimations.delete(id); + continue; + } + if (!change.before || change.before.collapsed === change.after.collapsed) continue; + const existing = view.session.collapseAnimations.get(id), + from = existing ? collapseValue(existing, now) : change.before.collapsed ? 1 : 0, + to = change.after.collapsed ? 1 : 0, + distance = Math.abs(to - from); + if (distance < 0.001) { + view.session.collapseAnimations.delete(id); + continue; + } + view.session.collapseAnimations.set(id, { from, to, value: from, startedAt: now, durationMs: 120 * distance }); + } +} +function cancelGesture(view: WorkerView): boolean { + const active = + !!view.session.knife || + !!view.session.drag || + !!view.session.scrub || + !!view.session.rampDrag || + !!view.session.reroutePress || + !!view.session.modalMove || + !!view.session.box || + !!view.session.linkDrag || + !!view.session.resize || + !!view.session.pan || + !!view.session.controlEdit || + !!view.session.colorPicker || + !!view.session.colorWheel || + !!view.gestureLayout || + view.session.previewValues.size > 0 || + view.session.previewPositions.size > 0 || + view.session.previewSizes.size > 0; + if (view.session.box) view.session.selectedNodes = new Set(view.session.box.checkpoint); + delete view.session.knife; + delete view.session.drag; + delete view.session.scrub; + delete view.session.rampDrag; + delete view.session.reroutePress; + delete view.session.modalMove; + delete view.session.box; + delete view.session.linkDrag; + delete view.session.resize; + delete view.session.parentHighlight; + delete view.session.pan; + delete view.session.controlEdit; + delete view.session.colorPicker; + delete view.session.colorWheel; + view.session.previewValues.clear(); + view.gestureLayout = undefined; + view.session.previewPositions.clear(); + view.session.previewSizes.clear(); + return active; +} +const currentTransform = (view: WorkerView) => ({ + center: view.session.cameraCenter, + zoom: view.session.zoom, + viewport: { x: view.viewport.width, y: view.viewport.height }, + dpr: view.viewport.dpr, +}); +function raiseNode(view: WorkerView, id: import("../core/types.js").NodeId): void { + view.session.uiOrder = [...view.session.uiOrder.filter((value) => value !== id), id]; +} +function selectNode(view: WorkerView, id: import("../core/types.js").NodeId, add = false): void { + if (add) { + view.session.selectedNodes.has(id) ? view.session.selectedNodes.delete(id) : view.session.selectedNodes.add(id); + } else { + view.session.selectedNodes.clear(); + view.session.selectedNodes.add(id); + } + view.session.selectedLinks.clear(); + view.session.activeNode = id; + raiseNode(view, id); +} +function refreshLayout(view: WorkerView): void { + if (layoutStore) view.currentLayout = applyNodeOrder(layoutStore.view(currentTransform(view)), view.session.uiOrder); +} +function pruneSession(view: WorkerView): void { + if (!state) return; + const ids = new Set(Object.keys(state.document.nodes)), + links = new Set(Object.keys(state.document.links)); + view.session.selectedNodes = new Set([...view.session.selectedNodes].filter((id) => ids.has(id))); + view.session.selectedLinks = new Set([...view.session.selectedLinks].filter((id) => links.has(id))); + view.session.uiOrder = view.session.uiOrder.filter((id) => ids.has(id)); + if (view.session.activeNode && !ids.has(view.session.activeNode)) delete view.session.activeNode; +} +function isStandardNode(id: import("../core/types.js").NodeId): boolean { + const node = state?.document.nodes[id]; + return !!node?.known && application?.compiled.nodes.get(node.typeId)?.behavior === "standard"; +} +function publishSelection(view: WorkerView, force = false): void { + if (!state || !application) return; + const nodes = [...view.session.selectedNodes] + .map((id) => state!.document.nodes[id]) + .filter((node): node is NonNullable => !!node), + linkCount = [...view.session.selectedLinks].filter((id) => state!.document.links[id]).length, + eligible = nodes.filter((node) => isStandardNode(node.id)), + mute = eligible.length + ? { + enabled: true as const, + state: (eligible.every((node) => node.muted) + ? "all-muted" + : eligible.every((node) => !node.muted) + ? "all-unmuted" + : "mixed") as "all-muted" | "all-unmuted" | "mixed", + } + : { enabled: false as const }, + projection = { nodeCount: nodes.length, linkCount, canRemove: nodes.length + linkCount > 0, mute }, + signature = `${projection.nodeCount}|${projection.linkCount}|${+projection.canRemove}|${mute.enabled ? mute.state : "disabled"}`; + if (!force && signature === view.selectionSignature) return; + view.selectionSignature = signature; + post({ protocol: PROTOCOL_VERSION, type: "view.selection.host", viewId: view.id, projection }); +} +function controlCommand( + view: WorkerView, + id: string, + value?: import("../core/types.js").ParameterValue, + reset = false, +): import("../commands/types.js").Command | undefined { + const control = view.currentLayout?.controls.get(id); + if (!control || control.source === "unknown") return; + if (control.source === "parameter") + return reset + ? { type: "node.parameter-reset", id: control.nodeId, key: control.key } + : { type: "node.parameter", id: control.nodeId, key: control.key, value: value! }; + return reset + ? { + type: "node.socket-default-reset", + id: control.nodeId, + socketId: control.key as import("../core/types.js").SocketId, + } + : { + type: "node.socket-default", + id: control.nodeId, + socketId: control.key as import("../core/types.js").SocketId, + value: value!, + }; +} +function gestureCommand(view: WorkerView, command: import("../commands/types.js").Command): void { + if (!state) return; + const result = application!.runtime.transition(state, { + commandId: commandId(`gesture-${state.version + 1}`), + expectedVersion: state.version, + source: "gesture", + command, + }); + if (result.status === "committed") commit(result, command, view); +} +const rampValue = (view: WorkerView, id: string, layout = view.currentLayout) => { + const c = layout?.controls.get(id), + v = c?.value as { kind?: unknown; value?: unknown }; + return c?.kind === "color-ramp" && c.rampBounds && v?.kind === "json" && isColorRamp(v.value) + ? { control: c, ramp: v.value } + : undefined; +}; +const activeStop = (view: WorkerView, id: string, ramp: ColorRamp) => { + const stored = view.session.activeRampStopByControl.get(id); + return ramp.stops.find((s) => s.id === stored) ?? ramp.stops[0]!; +}; +function commitRamp(view: WorkerView, id: string, ramp: ColorRamp, active?: string) { + const c = view.currentLayout?.controls.get(id); + if (!c) return; + if (active) view.session.activeRampStopByControl.set(id, active); + const command = controlCommand(view, id, { + kind: "json", + value: ramp as unknown as import("../core/types.js").JsonValue, + }); + if (command) gestureCommand(view, command); +} +function rampAction(view: WorkerView, id: string, target: string, position?: number): void { + const found = rampValue(view, id); + if (!found) return; + let { ramp } = found; + const active = activeStop(view, id, ramp); + if (target === "add") { + const newId = `stop-${state!.version + 1}-${ramp.stops.length}`, + next = addRampMidpoint(ramp, active.id, newId); + commitRamp(view, id, next, next === ramp ? active.id : newId); + return; + } + if (target === "remove") { + const next = removeRampStop(ramp, active.id), + survivor = next.stops.reduce((a, b) => + Math.abs(b.position - active.position) < Math.abs(a.position - active.position) ? b : a, + ); + commitRamp(view, id, next, survivor.id); + return; + } + if (target === "flip") ramp = flipColorRamp(ramp); + else if (target === "distribute") ramp = distributeColorRamp(ramp); + else if (target === "mode") { + const options = ["rgb", "hsv", "hsl"] as const; + ramp = { ...ramp, colorMode: options[(options.indexOf(ramp.colorMode) + 1) % options.length]! }; + } else if (target === "interpolation") { + const options = ["linear", "ease", "constant", "cardinal", "b-spline"] as const; + ramp = { ...ramp, interpolation: options[(options.indexOf(ramp.interpolation) + 1) % options.length]! }; + } else if (target === "hue") { + const options = ["near", "far", "clockwise", "counter-clockwise"] as const; + ramp = { ...ramp, hueInterpolation: options[(options.indexOf(ramp.hueInterpolation) + 1) % options.length]! }; + } else if (target === "gradient" && position !== undefined) { + const newId = `stop-${state!.version + 1}-${ramp.stops.length}`, + next = addRampStop(ramp, position, newId); + commitRamp(view, id, next, next === ramp ? active.id : newId); + return; + } + commitRamp(view, id, ramp, active.id); +} +const containsView = (point: { x: number; y: number }, rect: { x: number; y: number; width: number; height: number }) => + point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height; +function rgbToHsv(rgba: Rgba, oldHue = 0): readonly [number, number, number] { + const [r, g, b] = rgba, + max = Math.max(r, g, b), + min = Math.min(r, g, b), + d = max - min, + s = max ? d / max : 0; + let h = oldHue; + if (d) h = 60 * (max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4); + return [((h % 360) + 360) % 360, s, max]; +} +function hsvToRgb(h: number, s: number, v: number, a: number): Rgba { + h = ((h % 360) + 360) % 360; + s = Math.max(0, Math.min(1, s)); + v = Math.max(0, Math.min(1, v)); + const c = v * s, + x = c * (1 - Math.abs(((h / 60) % 2) - 1)), + m = v - c, + [r, g, b] = + h < 60 + ? [c, x, 0] + : h < 120 + ? [x, c, 0] + : h < 180 + ? [0, c, x] + : h < 240 + ? [0, x, c] + : h < 300 + ? [x, 0, c] + : [c, 0, x]; + return [r + m, g + m, b + m, a]; +} +function beginColorPicker( + view: WorkerView, + id: string, + anchor: { x: number; y: number; width: number; height: number }, + rgba: Rgba, + target: { kind: "control" } | { kind: "ramp-stop"; stopId: string; original: ColorRamp }, +): void { + const model = oklabToOklch(srgbToOklab([rgba[0], rgba[1], rgba[2]])); + view.session.colorPicker = { + layout: layoutColorPicker(anchor, { x: view.viewport.width, y: view.viewport.height }), + controlId: id, + target, + model, + rgba, + hsv: rgbToHsv(rgba), + }; + publishPicker(view); + view.scheduler.request(); +} +function publishPicker(view: WorkerView): void { + const p = view.session.colorPicker; + if (!p) return; + p.model = oklabToOklch(srgbToOklab([p.rgba[0], p.rgba[1], p.rgba[2]]), p.model.h); + if (p.target.kind === "control") view.session.previewValues.set(p.controlId, { kind: "color", value: p.rgba }); + else + view.session.previewValues.set(p.controlId, { + kind: "json", + value: setRampColor( + p.target.original, + p.target.stopId, + p.rgba, + ) as unknown as import("../core/types.js").JsonValue, + }); + view.scheduler.request(); +} +function applyPickerEdit(view: WorkerView, keepInvalid = true): boolean { + const p = view.session.colorPicker, + e = p?.edit; + if (!p || !e) return true; + let rgba: Rgba | undefined; + if (e.field === "hex") { + const match = e.buffer.trim().match(/^#?([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i), + raw = match?.[1]; + if (raw) { + const expanded = raw.length < 5 ? [...raw].map((value) => value + value).join("") : raw, + full = expanded.length === 6 ? expanded + "ff" : expanded; + rgba = [0, 2, 4, 6].map((index) => parseInt(full.slice(index, index + 2), 16) / 255) as unknown as Rgba; + } + } else { + const value = Number(e.buffer); + if (Number.isFinite(value)) { + if (e.field === "rgba") { + const next = [...p.rgba] as number[]; + next[e.index] = Math.max(0, Math.min(1, value)); + rgba = next as unknown as Rgba; + } else { + const next = [...p.hsv] as number[]; + next[e.index] = e.index ? Math.max(0, Math.min(1, value)) : ((value % 360) + 360) % 360; + p.hsv = next as unknown as typeof p.hsv; + rgba = hsvToRgb(next[0]!, next[1]!, next[2]!, p.rgba[3]); + } + } + } + if (!rgba) { + if (keepInvalid) { + p.edit = { ...e, invalid: true }; + view.scheduler.request(); + } else delete p.edit; + return false; + } + p.rgba = rgba; + p.hsv = rgbToHsv(rgba, p.hsv[0]); + delete p.edit; + publishPicker(view); + return true; +} +function openRampPicker(view: WorkerView, id: string, ramp: ColorRamp): void { + const control = view.currentLayout?.controls.get(id), + bounds = control?.rampBounds; + if (!control || !bounds) return; + const stop = activeStop(view, id, ramp), + anchor = viewRectFromWorld(bounds.color, view.currentLayout!); + beginColorPicker(view, id, anchor, stop.color as Rgba, { kind: "ramp-stop", stopId: stop.id, original: ramp }); +} +function openControlPicker(view: WorkerView, id: string): void { + const control = view.currentLayout?.controls.get(id), + value = control?.value as import("../core/types.js").ParameterValue | undefined; + if (!control || control.kind !== "color" || control.linked || control.source === "unknown" || value?.kind !== "color") + return; + beginColorPicker(view, id, viewRectFromWorld(control.bounds, view.currentLayout!), value.value as Rgba, { + kind: "control", + }); +} +function updatePicker(view: WorkerView, position: { x: number; y: number }): void { + const picker = view.session.colorPicker; + if (!picker?.drag) return; + const rect = picker.layout[picker.drag.region]; + if (picker.drag.region === "plane") { + const radius = rect.width / 2, + dx = (position.x - (rect.x + radius)) / radius, + dy = (rect.y + radius - position.y) / radius, + length = Math.min(1, Math.hypot(dx, dy)), + h = Math.atan2(dy, dx), + c = length * maxSrgbChroma(picker.model.l, h); + picker.model = { ...picker.model, c, h }; + } else if (picker.drag.region === "lightness") { + const l = 1 - Math.max(0, Math.min(1, (position.y - rect.y) / rect.height)); + picker.model = { ...picker.model, l, c: Math.min(picker.model.c, maxSrgbChroma(l, picker.model.h)) }; + } else + picker.rgba = [ + picker.rgba[0], + picker.rgba[1], + picker.rgba[2], + 1 - Math.max(0, Math.min(1, (position.y - rect.y) / rect.height)), + ]; + const rgb = mapOklchToSrgb(picker.model); + picker.rgba = [rgb[0], rgb[1], rgb[2], picker.rgba[3]]; + picker.hsv = rgbToHsv(picker.rgba, picker.hsv[0]); + publishPicker(view); +} +function finishPicker(view: WorkerView, commitValue: boolean): void { + const picker = view.session.colorPicker; + if (!picker) return; + if (commitValue) applyPickerEdit(view, false); + const preview = view.session.previewValues.get(picker.controlId); + view.session.previewValues.delete(picker.controlId); + delete view.session.colorPicker; + if (commitValue && preview) { + if (picker.target.kind === "control") { + const command = controlCommand(view, picker.controlId, preview); + if (command) gestureCommand(view, command); + } else { + const value = preview as { kind?: unknown; value?: unknown }; + if (value.kind === "json" && isColorRamp(value.value)) + commitRamp(view, picker.controlId, value.value, picker.target.stopId); + } + } + view.scheduler.request(); +} +const viewRectFromWorld = (rect: { x: number; y: number; width: number; height: number }, layout: LayoutSnapshot) => { + const p = worldToView({ x: rect.x, y: rect.y }, layout.transform); + return { x: p.x, y: p.y, width: rect.width * layout.transform.zoom, height: rect.height * layout.transform.zoom }; +}; +function updateInlineWheel(view: WorkerView, position: { x: number; y: number }): void { + const wheel = view.session.colorWheel; + if (!wheel) return; + const { bounds } = wheel; + if (wheel.region === "plane") { + const radius = bounds.width / 2, + dx = (position.x - (bounds.x + radius)) / radius, + dy = (bounds.y + radius - position.y) / radius, + length = Math.min(1, Math.hypot(dx, dy)), + h = Math.atan2(dy, dx), + l = Math.min(0.9, Math.max(0.1, wheel.model.l)); + wheel.model = { l, h, c: length * maxSrgbChroma(l, h) }; + } else { + const l = 1 - Math.max(0, Math.min(1, (position.y - bounds.y) / bounds.height)); + wheel.model = { ...wheel.model, l, c: Math.min(wheel.model.c, maxSrgbChroma(l, wheel.model.h)) }; + } + const rgb = mapOklchToSrgb(wheel.model); + wheel.rgba = [rgb[0], rgb[1], rgb[2], wheel.rgba[3]]; + view.session.previewValues.set(wheel.controlId, { kind: "color", value: wheel.rgba }); + view.scheduler.request(); +} +function finishInlineWheel(view: WorkerView, commitValue: boolean): void { + const wheel = view.session.colorWheel; + if (!wheel) return; + const preview = view.session.previewValues.get(wheel.controlId); + view.session.previewValues.delete(wheel.controlId); + delete view.session.colorWheel; + view.gestureLayout = undefined; + if (commitValue && preview) { + const command = controlCommand(view, wheel.controlId, preview); + if (command) gestureCommand(view, command); + } else view.scheduler.request(); +} +function processInput( + view: WorkerView, + event: Extract["event"], + nodeMenuRequestId?: string, + resourceOpenRequestId?: string, +): void { + if (!state) return; + if (!view.currentLayout && layoutStore) view.currentLayout = layoutStore.view(currentTransform(view)); + if (!view.currentLayout) return; + if (event.kind === "outside-pointer") { + if (event.button === 0 && view.session.colorPicker) finishPicker(view, true); + return; + } + if (event.kind === "focus" && event.phase === "blur") delete view.session.knife; + if (event.kind === "key" && event.phase === "down" && event.key === "Escape") delete view.session.knife; + if (event.kind === "focus") { + if (event.phase === "blur") { + cancelGesture(view); + view.scheduler.request(); + } + return; + } + if (event.kind === "wheel") { + if (view.session.colorPicker) return; + const next = zoomAt(currentTransform(view), event.position, event.delta.y); + view.session.zoom = next.zoom; + view.session.cameraCenter = next.center; + refreshLayout(view); + view.scheduler.request(); + return; + } + if (event.kind === "key" && event.phase === "down") { + const modifier = (event.modifiers & 6) !== 0; + if (event.key === "Escape") { + cancelGesture(view); + view.scheduler.request(); + return; + } + if (view.session.colorPicker) { + const p = view.session.colorPicker, + e = p.edit; + if (e) { + if (event.key === "Enter") { + applyPickerEdit(view); + return; + } + if (event.key === "Backspace") { + p.edit = { ...e, buffer: e.selectAll ? "" : e.buffer.slice(0, -1), selectAll: false, invalid: false }; + view.scheduler.request(); + return; + } + if (event.key.length === 1 && !modifier) { + p.edit = { ...e, buffer: e.selectAll ? event.key : e.buffer + event.key, selectAll: false, invalid: false }; + view.scheduler.request(); + } + return; + } + return; + } + if (event.key === "Backspace" && view.session.controlEdit) { + view.session.controlEdit = { + ...view.session.controlEdit, + buffer: + view.session.controlEdit.kind === "number" && view.session.controlEdit.selectAll + ? "" + : view.session.controlEdit.buffer.slice(0, -1), + ...(view.session.controlEdit.kind === "number" ? { selectAll: false } : {}), + }; + view.scheduler.request(); + return; + } + if (event.key === "Backspace") { + if (view.session.colorPicker) finishPicker(view, false); + const id = view.session.focusedControl ?? view.session.hoveredControl; + if (id) { + const rv = rampValue(view, id); + if (rv) { + const stop = activeStop(view, id, rv.ramp), + target = view.session.focusedRampTarget; + if (target === "swatch") commitRamp(view, id, setRampColor(rv.ramp, stop.id, [0, 0, 0, 1]), stop.id); + else { + const command = controlCommand(view, id, undefined, true); + if (command) gestureCommand(view, command); + } + return; + } + const command = controlCommand(view, id, undefined, true); + if (command) gestureCommand(view, command); + } + return; + } + if (view.session.focusedControl && view.session.focusedRampTarget) { + const rv = rampValue(view, view.session.focusedControl); + if (rv) { + const stop = activeStop(view, view.session.focusedControl, rv.ramp); + if (event.key === "Delete" || event.key.toLowerCase() === "x") { + rampAction(view, view.session.focusedControl, "remove"); + return; + } + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + const step = (event.modifiers & 8) !== 0 ? 0.001 : 0.01; + commitRamp( + view, + view.session.focusedControl, + moveRampStop(rv.ramp, stop.id, stop.position + (event.key === "ArrowRight" ? step : -step)), + stop.id, + ); + return; + } + return; + } + } + if (view.session.focusedControl && (event.key === "ArrowUp" || event.key === "ArrowDown")) { + const control = view.currentLayout.controls.get(view.session.focusedControl); + const value = control?.value; + if ( + control?.kind === "enum" && + control.schema?.type === "string" && + control.schema.enum && + value && + typeof value === "object" && + "kind" in value && + value.kind === "string" && + "value" in value && + typeof value.value === "string" + ) { + const next = cycleEnum(control.schema.enum, value.value, event.key === "ArrowDown" ? 1 : -1); + const command = controlCommand(view, control.id, { kind: "string", value: next }); + if (command) gestureCommand(view, command); + } + return; + } + if (view.session.controlEdit?.kind === "string") { + if (event.key === "Enter") { + const edit = view.session.controlEdit; + delete view.session.controlEdit; + const command = controlCommand(view, edit.controlId, { kind: "string", value: edit.buffer }); + if (command) gestureCommand(view, command); + return; + } + if (event.key.length === 1 && !modifier) { + view.session.controlEdit = { ...view.session.controlEdit, buffer: view.session.controlEdit.buffer + event.key }; + view.scheduler.request(); + return; + } + } + if (view.session.controlEdit?.kind === "number") { + if (event.key === "Enter") { + const edit = view.session.controlEdit, + value = Number(edit.buffer), + control = view.currentLayout.controls.get(edit.controlId), + original = control?.value as import("../core/types.js").ParameterValue | undefined; + if (Number.isFinite(value) && control && original) { + delete view.session.controlEdit; + const command = controlCommand( + view, + edit.controlId, + setNumericComponent(control, original, edit.component, value), + ); + if (command) gestureCommand(view, command); + } + return; + } + if (event.key.length === 1 && !modifier && /[0-9eE+.-]/.test(event.key)) { + view.session.controlEdit = { + ...view.session.controlEdit, + buffer: view.session.controlEdit.selectAll ? event.key : view.session.controlEdit.buffer + event.key, + selectAll: false, + }; + view.scheduler.request(); + return; + } + } + if (modifier && event.key.toLowerCase() === "z") { + gestureCommand(view, { type: (event.modifiers & 8) !== 0 ? "redo" : "undo" }); + return; + } + if (event.key === "Home") { + const b = view.currentLayout.graphBounds; + if (b.width || b.height) { + view.session.cameraCenter = { x: b.x + b.width / 2, y: b.y - b.height / 2 }; + view.session.zoom = Math.min( + 2, + Math.max(0.1, Math.min(view.viewport.width / (b.width + 80), view.viewport.height / (b.height + 80))), + ); + view.scheduler.request(); + } + return; + } + const ids = [...view.session.selectedNodes]; + if (event.key.toLowerCase() === "g" && ids.length && !view.session.modalMove) { + const startView = view.session.pointer ?? { x: view.viewport.width / 2, y: view.viewport.height / 2 }, + roots = groupRoots(view.session.selectedNodes, view.currentLayout); + view.gestureLayout = view.currentLayout; + view.session.modalMove = { + startView, + startWorld: viewToWorld(startView, view.currentLayout.transform), + origins: new Map(roots.map((id) => [id, state!.document.nodes[id]!.position])), + moved: true, + }; + return; + } + if (event.key === "Enter" && view.session.modalMove) { + finishMove(view); + return; + } + if (event.key === "Delete" || event.key.toLowerCase() === "x") { + const command = planSelectionRemoval(state.document, view.session.selectedNodes, view.session.selectedLinks); + if (command) gestureCommand(view, command); + } else if (event.key.toLowerCase() === "m" && ids.length) { + const command = planSelectionMute(state.document, view.session.selectedNodes, isStandardNode); + if (command) gestureCommand(view, command); + } else if (event.key.toLowerCase() === "h" && ids.length) + gestureCommand(view, { + type: "batch", + commands: ids.map((id) => ({ type: "node.collapse", id, value: !state!.document.nodes[id]?.collapsed })), + }); + return; + } + if (event.kind !== "pointer") return; + view.session.pointer = event.position; + if (view.session.colorPicker) { + const picker = view.session.colorPicker; + if (event.phase === "down" && event.button === 2) { + finishPicker(view, false); + return; + } + if (picker.drag?.pointerId === event.pointerId) { + if (event.phase === "move") updatePicker(view, event.position); + else { + delete picker.drag; + view.scheduler.request(); + } + return; + } + if (event.phase === "down" && event.button === 0) { + if (containsView(event.position, picker.layout.confirm)) { + finishPicker(view, true); + return; + } + if (picker.edit) applyPickerEdit(view, false); + const region = (["plane", "lightness", "alpha"] as const).find((name) => + containsView(event.position, picker.layout[name]), + ); + if (region) { + picker.drag = { pointerId: event.pointerId, region }; + updatePicker(view, event.position); + return; + } + for (const name of ["rgba", "hsv"] as const) { + const index = picker.layout[name].findIndex((r) => containsView(event.position, r)); + if (index >= 0) { + const values = name === "rgba" ? picker.rgba : picker.hsv; + picker.edit = { + field: name, + index, + buffer: name === "hsv" && index === 0 ? values[index]!.toFixed(1) : values[index]!.toFixed(3), + selectAll: true, + invalid: false, + }; + view.scheduler.request(); + return; + } + } + if (containsView(event.position, picker.layout.hex)) { + picker.edit = { + field: "hex", + index: 0, + buffer: + "#" + + picker.rgba + .map((v) => + Math.round(v * 255) + .toString(16) + .padStart(2, "0"), + ) + .join("") + .toUpperCase(), + selectAll: true, + invalid: false, + }; + view.scheduler.request(); + return; + } + finishPicker(view, true); + return; + } + return; + } + if (view.session.colorWheel?.pointerId === event.pointerId) { + if (event.phase === "move") updateInlineWheel(view, event.position); + else finishInlineWheel(view, event.phase === "up"); + return; + } + if (event.phase === "cancel") delete view.session.knife; + if (event.phase === "down" && event.button === 2 && (event.modifiers & 2) !== 0) { + cancelGesture(view); + view.gestureLayout = view.currentLayout; + view.session.knife = { + pointerId: event.pointerId, + points: [event.position], + crossed: new Set(), + mode: (event.modifiers & 1) !== 0 ? "mute" : "remove", + }; + view.scheduler.request(); + return; + } + if (view.session.knife?.pointerId === event.pointerId) { + if (event.phase === "move") { + view.session.knife.points = appendKnifePoint(view.session.knife.points, event.position); + view.session.knife.crossed = crossedLinks( + view.gestureLayout!, + view.session.knife.points, + view.session.knife.mode === "mute", + ); + view.scheduler.request(); + return; + } + if (event.phase === "up") { + const knife = view.session.knife; + delete view.session.knife; + view.gestureLayout = undefined; + const commands = [...knife.crossed].map((id) => + knife.mode === "remove" + ? { type: "link.remove" as const, id } + : { type: "link.mute" as const, id, value: !state!.document.links[id]!.muted }, + ); + if (commands.length) gestureCommand(view, { type: "batch", commands }); + else view.scheduler.request(); + return; + } + } + if (event.phase === "down" && event.button === 2) { + const canceled = cancelGesture(view), + menuLayout = layoutStore?.view(currentTransform(view)), + open = !!nodeMenuRequestId && !canceled && !!menuLayout && hitTest(menuLayout, event.position).kind === "canvas"; + if (nodeMenuRequestId) + post( + open + ? { + protocol: PROTOCOL_VERSION, + type: "view.node-menu.result", + viewId: view.id, + requestId: nodeMenuRequestId, + open: true, + compositionRevision, + viewPosition: event.position, + } + : { + protocol: PROTOCOL_VERSION, + type: "view.node-menu.result", + viewId: view.id, + requestId: nodeMenuRequestId, + open: false, + }, + ); + view.scheduler.request(); + return; + } + if (event.phase === "move") { + const hover = hitTest(view.currentLayout, event.position); + if (hover.kind === "control" || hover.kind === "control-step" || hover.kind === "ramp") { + view.session.hoveredControl = hover.id; + if (hover.kind === "ramp") view.session.hoveredRampTarget = hover.target; + else delete view.session.hoveredRampTarget; + } else { + delete view.session.hoveredControl; + delete view.session.hoveredRampTarget; + if (!view.session.controlEdit) delete view.session.focusedControl; + } + } + if (event.phase === "cancel") { + cancelGesture(view); + view.scheduler.request(); + return; + } + if (view.session.reroutePress?.pointerId === event.pointerId) { + const press = view.session.reroutePress; + if (event.phase === "move" && Math.hypot(event.position.x - press.start.x, event.position.y - press.start.y) >= 4) { + delete view.session.reroutePress; + view.session.linkDrag = { pointerId: event.pointerId, from: press.socketId, current: event.position }; + const hit = hitTest(view.gestureLayout!, event.position, "input"); + if ( + hit.kind === "socket" && + compatibleTargets(view.gestureLayout!, press.socketId).some((socket) => socket.id === hit.id) + ) + view.session.linkDrag.candidate = hit.id; + view.scheduler.request(); + return; + } + if (event.phase === "up") { + delete view.session.reroutePress; + view.gestureLayout = undefined; + selectNode(view, press.nodeId, (event.modifiers & 8) !== 0); + refreshLayout(view); + view.scheduler.request(); + return; + } + return; + } + if (view.session.scrub?.pointerId === event.pointerId) { + if (event.phase === "down" && event.button === 2) { + cancelGesture(view); + view.scheduler.request(); + return; + } + if (event.phase === "move") { + const control = view.gestureLayout?.controls.get(view.session.scrub.controlId); + if (Math.abs(event.position.x - view.session.scrub.startX) >= 2) view.session.scrub.moved = true; + if (!view.session.scrub.moved) return; + const raw = view.session.scrub.original as { kind?: unknown; value?: unknown }; + if (control && raw.kind === "json" && isColorRamp(raw.value)) { + const stop = activeStop(view, control.id, raw.value), + delta = (event.position.x - view.session.scrub.startX) * ((event.modifiers & 8) !== 0 ? 0.001 : 0.01); + const ramp = + view.session.scrub.component < 0 + ? moveRampStop(raw.value, stop.id, stop.position + delta) + : setRampColor( + raw.value, + stop.id, + stop.color.map((c, i) => (i === view.session.scrub!.component ? c + delta : c)) as unknown as readonly [ + number, + number, + number, + number, + ], + ); + view.session.previewValues.set(control.id, { + kind: "json", + value: ramp as unknown as import("../core/types.js").JsonValue, + }); + view.scheduler.request(); + } else if (control) { + const value = scrubValue( + control, + view.session.scrub.original, + view.session.scrub.component, + event.position.x - view.session.scrub.startX, + (event.modifiers & 8) !== 0, + (event.modifiers & 2) !== 0, + ); + view.session.previewValues.set(control.id, value); + view.scheduler.request(); + } + } else { + const scrub = view.session.scrub, + id = scrub.controlId, + value = view.session.previewValues.get(id), + moved = scrub.moved; + cancelGesture(view); + if (!moved) { + view.session.focusedControl = id; + if (scrub.original.kind !== "json") { + const original = scrub.original, + component = + original.kind === "number" + ? original.value + : original.kind === "vector" || original.kind === "color" + ? (original.value[scrub.component] ?? 0) + : 0; + view.session.controlEdit = { + kind: "number", + controlId: id, + component: scrub.component, + buffer: component.toFixed(3), + selectAll: true, + }; + } + view.scheduler.request(); + return; + } + const command = value ? controlCommand(view, id, value) : undefined; + if (command) gestureCommand(view, command); + else view.scheduler.request(); + } + return; + } + if (view.session.rampDrag?.pointerId === event.pointerId) { + if (event.phase === "move") { + const found = rampValue(view, view.session.rampDrag.controlId, view.gestureLayout), + b = found?.control.rampBounds; + if (found && b) { + const world = viewToWorld(event.position, view.gestureLayout!.transform), + p = Math.max(0, Math.min(1, (world.x - b.handles.x) / b.handles.width)); + view.session.previewValues.set(found.control.id, { + kind: "json", + value: moveRampStop( + found.ramp, + view.session.rampDrag.stopId, + p, + ) as unknown as import("../core/types.js").JsonValue, + }); + view.scheduler.request(); + } + } else { + const drag = view.session.rampDrag, + value = view.session.previewValues.get(drag.controlId) as { kind?: unknown; value?: unknown } | undefined; + cancelGesture(view); + if (value?.kind === "json" && isColorRamp(value.value)) + commitRamp(view, drag.controlId, value.value, drag.stopId); + else view.scheduler.request(); + } + return; + } + if (event.phase === "down" && event.button === 1) { + view.session.pan = { pointerId: event.pointerId, last: event.position }; + return; + } + if (view.session.pan?.pointerId === event.pointerId) { + if (event.phase === "move") { + view.session.cameraCenter = { + x: view.session.cameraCenter.x - (event.position.x - view.session.pan.last.x) / view.session.zoom, + y: view.session.cameraCenter.y + (event.position.y - view.session.pan.last.y) / view.session.zoom, + }; + view.session.pan = { ...view.session.pan, last: event.position }; + view.scheduler.request(); + } else delete view.session.pan; + return; + } + if (view.session.modalMove) { + if (event.phase === "move") { + previewMove(view, view.session.modalMove, event.position); + return; + } + if (event.phase === "down" && event.button === 0) { + finishMove(view); + return; + } + } + if (event.phase === "down" && event.button === 0) { + const previousEdit = view.session.controlEdit; + if (previousEdit) { + delete view.session.controlEdit; + view.scheduler.request(); + } + const interactionLayout = layoutStore + ? applyNodeOrder(layoutStore.view(currentTransform(view)), view.session.uiOrder) + : view.currentLayout; + let hit = hitTest(interactionLayout, event.position, "output"); + if ( + previousEdit?.kind === "number" && + hit.kind === "control-step" && + hit.id === previousEdit.controlId && + hit.component === previousEdit.component + ) + hit = { kind: "control", id: hit.id, component: hit.component }; + if (hit.kind === "resource") { + const control = interactionLayout.controls.get(hit.id), + policy = control?.resourceId && application?.compiled.resources.get(control.resourceId as never); + if (resourceOpenRequestId && control?.kind === "resource" && control.source === "parameter" && policy) + post({ + protocol: PROTOCOL_VERSION, + type: "view.resource.open", + viewId: view.id, + requestId: resourceOpenRequestId, + authorization: { + viewId: view.id, + token: control.id, + graphVersion: state.version, + compositionRevision, + }, + resource: { + id: policy.id, + kind: "image", + title: control.label, + openTitle: control.openTitle ?? policy.openTitle ?? "Open", + accept: [...policy.accept], + maxBytes: policy.maxBytes, + maxWidth: policy.maxWidth, + maxHeight: policy.maxHeight, + maxPixels: policy.maxPixels, + }, + }); + return; + } + view.gestureLayout = interactionLayout; + if (hit.kind === "ramp") { + const found = rampValue(view, hit.id); + if (!found) return; + view.session.focusedControl = hit.id; + view.session.focusedRampTarget = hit.target; + const active = activeStop(view, hit.id, found.ramp); + if (hit.target === "handle" && hit.stopId) { + const same = found.ramp.stops.filter( + (s) => + Math.abs(s.position - (hit.position ?? s.position)) < + 7 / view.currentLayout!.transform.zoom / found.control.rampBounds!.handles.width, + ); + let id = hit.stopId; + if (same.length > 1) { + const old = view.session.activeRampStopByControl.get(found.control.id), + i = same.findIndex((s) => s.id === old); + id = same[(i + 1) % same.length]!.id; + } + view.session.activeRampStopByControl.set(found.control.id, id); + view.session.rampDrag = { + pointerId: event.pointerId, + controlId: hit.id, + stopId: id, + original: found.control.value as import("../core/types.js").ParameterValue, + }; + view.scheduler.request(); + return; + } + if (hit.target === "gradient") { + rampAction(view, hit.id, "gradient", hit.position); + return; + } + if (hit.target === "position") { + view.session.scrub = { + pointerId: event.pointerId, + controlId: hit.id, + component: -1, + startX: event.position.x, + original: found.control.value as import("../core/types.js").ParameterValue, + moved: false, + }; + return; + } + if (hit.target === "selector") { + const i = found.ramp.stops.findIndex((s) => s.id === active.id); + view.session.activeRampStopByControl.set( + found.control.id, + found.ramp.stops[(i + 1) % found.ramp.stops.length]!.id, + ); + view.scheduler.request(); + return; + } + if (hit.target === "swatch") { + openRampPicker(view, hit.id, found.ramp); + return; + } + rampAction(view, hit.id, hit.target); + view.scheduler.request(); + return; + } + if (hit.kind === "color-wheel") { + const control = view.currentLayout.controls.get(hit.id), + value = control?.value as import("../core/types.js").ParameterValue | undefined, + bounds = control?.colorWheelBounds?.[hit.region]; + if (!control || value?.kind !== "color" || !bounds) return; + const rgba = value.value as Rgba; + view.gestureLayout = view.currentLayout; + view.session.colorWheel = { + controlId: hit.id, + original: value, + model: oklabToOklch(srgbToOklab([rgba[0], rgba[1], rgba[2]])), + rgba, + pointerId: event.pointerId, + region: hit.region, + bounds: viewRectFromWorld(bounds, view.currentLayout), + }; + updateInlineWheel(view, event.position); + return; + } + if (hit.kind === "control-step") { + const control = view.currentLayout.controls.get(hit.id), + original = control?.value as import("../core/types.js").ParameterValue | undefined; + if (control && original) { + view.session.focusedControl = hit.id; + const current = + original.kind === "number" + ? original.value + : original.kind === "vector" || original.kind === "color" + ? (original.value[hit.component] ?? 0) + : 0, + next = setNumericComponent( + control, + original, + hit.component, + current + hit.direction * numericStep(control, (event.modifiers & 8) !== 0), + ), + command = controlCommand(view, hit.id, next); + if (command) gestureCommand(view, command); + } + return; + } + if (hit.kind === "control") { + const control = view.currentLayout.controls.get(hit.id); + view.session.focusedControl = hit.id; + if (control && !control.linked && control.source !== "unknown") { + const current = control.value as import("../core/types.js").ParameterValue; + if (control.kind === "boolean" && current.kind === "boolean") { + const command = controlCommand(view, hit.id, { kind: "boolean", value: !current.value }); + if (command) gestureCommand(view, command); + } else if ( + control.kind === "enum" && + control.schema?.type === "string" && + control.schema.enum && + current.kind === "string" + ) { + const command = controlCommand(view, hit.id, { + kind: "string", + value: cycleEnum(control.schema.enum, current.value, 1), + }); + if (command) gestureCommand(view, command); + } else if ((control.kind === "string" || control.kind === "resource") && current.kind === "string") + view.session.controlEdit = { kind: "string", controlId: hit.id, buffer: current.value }; + else if (control.kind === "color" && current.kind === "color") openControlPicker(view, hit.id); + else if (control.kind === "number" || control.kind === "vector") + view.session.scrub = { + pointerId: event.pointerId, + controlId: hit.id, + component: hit.component, + startX: event.position.x, + original: current, + moved: false, + }; + } + view.scheduler.request(); + return; + } + if (hit.kind === "socket") { + const socket = view.currentLayout.sockets.get(hit.id), + node = socket && view.currentLayout.nodes.get(socket.nodeId); + if (socket?.direction === "output" && node?.kind === "reroute") + view.session.reroutePress = { + pointerId: event.pointerId, + nodeId: node.id, + socketId: hit.id, + start: event.position, + }; + else if (socket?.direction === "output") + view.session.linkDrag = { pointerId: event.pointerId, from: hit.id, current: event.position }; + view.scheduler.request(); + return; + } + if (hit.kind === "collapse") { + const n = state.document.nodes[hit.id]; + if (n) gestureCommand(view, { type: "node.collapse", id: hit.id, value: !n.collapsed }); + return; + } + if (hit.kind === "resize") { + view.session.resize = { pointerId: event.pointerId, id: hit.id }; + return; + } + if (hit.kind === "link") { + view.session.selectedNodes.clear(); + if ((event.modifiers & 8) === 0) view.session.selectedLinks.clear(); + view.session.selectedLinks.add(hit.id); + view.scheduler.request(); + return; + } + if (hit.kind === "canvas") { + view.session.box = { + pointerId: event.pointerId, + start: event.position, + current: event.position, + checkpoint: new Set(view.session.selectedNodes), + add: (event.modifiers & 8) !== 0, + }; + if (!view.session.box.add) view.session.selectedNodes.clear(); + view.scheduler.request(); + return; + } + const id = hit.id; + if ((event.modifiers & 8) !== 0) selectNode(view, id, true); + else if (!view.session.selectedNodes.has(id)) selectNode(view, id); + else { + view.session.activeNode = id; + raiseNode(view, id); + } + refreshLayout(view); + view.gestureLayout = view.currentLayout; + const roots = groupRoots(view.session.selectedNodes, view.currentLayout); + view.session.drag = { + pointerId: event.pointerId, + startView: event.position, + startWorld: viewToWorld(event.position, view.currentLayout.transform), + origins: new Map(roots.map((root) => [root, state!.document.nodes[root]!.position])), + moved: false, + }; + view.scheduler.request(); + return; + } + if (view.session.box?.pointerId === event.pointerId) { + if (event.phase === "move") { + view.session.box.current = event.position; + const found = boxNodes(view.gestureLayout!, view.session.box.start, event.position); + view.session.selectedNodes = new Set(view.session.box.add ? [...view.session.box.checkpoint, ...found] : found); + view.scheduler.request(); + } else { + for (const id of view.gestureLayout!.drawOrder) if (view.session.selectedNodes.has(id)) raiseNode(view, id); + delete view.session.box; + view.gestureLayout = undefined; + refreshLayout(view); + view.scheduler.request(); + } + return; + } + if (view.session.linkDrag?.pointerId === event.pointerId) { + if (event.phase === "move") { + view.session.linkDrag.current = event.position; + const hit = hitTest(view.gestureLayout!, event.position, "input"); + if ( + hit.kind === "socket" && + compatibleTargets(view.gestureLayout!, view.session.linkDrag.from).some((s) => s.id === hit.id) + ) + view.session.linkDrag.candidate = hit.id; + else delete view.session.linkDrag.candidate; + view.scheduler.request(); + } else { + const command = view.session.linkDrag.candidate + ? planLink( + view.gestureLayout!, + view.session.linkDrag.from, + view.session.linkDrag.candidate, + linkId(`gesture-link-${state.version + 1}`), + ) + : undefined; + cancelGesture(view); + if (command) gestureCommand(view, command); + else view.scheduler.request(); + } + return; + } + if (view.session.resize?.pointerId === event.pointerId) { + if (event.phase === "move") { + const size = clampResize( + view.gestureLayout!, + view.session.resize.id, + viewToWorld(event.position, view.gestureLayout!.transform), + ); + if (size) view.session.previewSizes.set(view.session.resize.id, size); + view.scheduler.request(); + } else { + const id = view.session.resize.id, + size = view.session.previewSizes.get(id); + cancelGesture(view); + if (size) gestureCommand(view, { type: "node.resize", id, size }); + else view.scheduler.request(); + } + return; + } + if (view.session.drag?.pointerId === event.pointerId) { + if (event.phase === "move") { + const distance = Math.hypot( + event.position.x - view.session.drag.startView.x, + event.position.y - view.session.drag.startView.y, + ); + if (distance >= 4) view.session.drag.moved = true; + if (view.session.drag.moved) previewMove(view, view.session.drag, event.position); + } else if (event.phase === "up") finishMove(view); + return; + } +} +function input( + view: WorkerView, + event: Extract["event"], + nodeMenuRequestId?: string, + resourceOpenRequestId?: string, +): void { + try { + processInput(view, event, nodeMenuRequestId, resourceOpenRequestId); + } finally { + publishSelection(view); + } +} + +function previewMove( + view: WorkerView, + move: { + startWorld: { x: number; y: number }; + origins: ReadonlyMap; + }, + position: { x: number; y: number }, +): void { + const layout = view.gestureLayout ?? view.currentLayout!; + const now = viewToWorld(position, layout.transform); + for (const [id, p] of move.origins) + view.session.previewPositions.set(id, { x: p.x + now.x - move.startWorld.x, y: p.y + now.y - move.startWorld.y }); + const first = [...move.origins][0]; + if (first) { + const n = layout.nodes.get(first[0]); + const preview = view.session.previewPositions.get(first[0]); + const target = + n && preview + ? frameDropCandidate(layout, first[0], { + x: n.worldPosition.x + preview.x - n.localPosition.x + n.bounds.width / 2, + y: n.worldPosition.y + preview.y - n.localPosition.y - n.bounds.height / 2, + }) + : undefined; + if (target) view.session.parentHighlight = target; + else delete view.session.parentHighlight; + } + view.scheduler.request(); +} +function finishMove(view: WorkerView): void { + const layout = view.gestureLayout ?? view.currentLayout!; + const commands: import("../commands/types.js").BatchCommand[] = [...view.session.previewPositions].map( + ([id, position]) => ({ type: "node.move", id, position }), + ); + for (const [id] of view.session.previewPositions) { + const n = layout.nodes.get(id); + if (!n) continue; + const target = view.session.parentHighlight; + if ((target ?? undefined) !== (n.parentId ?? undefined)) + commands.push({ type: "node.parent", id, parentId: target ?? null }); + } + cancelGesture(view); + if (commands.length) gestureCommand(view, { type: "batch", commands }); + else view.scheduler.request(); +} + +function resourceTarget(view: WorkerView, token: string, fresh = false) { + const control = (fresh && layoutStore ? layoutStore.view(currentTransform(view)) : view.currentLayout)?.controls.get( + token, + ); + return control?.kind === "resource" && control.source === "parameter" ? control : undefined; +} +function closeResourceImages(): void { + for (const image of resourceImages.values()) image.bitmap.close(); + resourceImages.clear(); + resourceOperations.clear(); +} +function trimResourceImages(): void { + let bytes = [...resourceImages.values()].reduce((sum, image) => sum + image.bytes, 0); + while (resourceImages.size > 16 || bytes > 128 * 1024 * 1024) { + const oldest = [...resourceImages].sort((a, b) => a[1].lastUsed - b[1].lastUsed)[0]; + if (!oldest) break; + oldest[1].bitmap.close(); + bytes -= oldest[1].bytes; + resourceImages.delete(oldest[0]); + } +} +async function setResource( + view: WorkerView, + data: Extract, +): Promise { + applyPointerFence(view, data.pointerFence); + const { authorization, resource } = data; + if (authorization.viewId !== view.id) { + reject(data.id, { code: "resource.view-mismatch", message: "Resource authorization belongs to another view" }); + return; + } + if ( + !state || + authorization.graphVersion !== state.version || + authorization.compositionRevision !== compositionRevision + ) { + reject(data.id, { code: "resource.stale", message: "The image authorization is stale" }); + return; + } + if (data.expected.kind === "exact" && data.expected.version !== state.version) { + reject(data.id, { code: "version.stale", message: "Expected version does not match" }); + return; + } + const decodeRevision = compositionRevision, + decodeGraphVersion = state.version, + target = resourceTarget(view, authorization.token, true), + targetNode = target && state.document.nodes[target.nodeId]; + if (!target || !targetNode) { + reject(data.id, { code: "resource.stale", message: "The image target is no longer available" }); + return; + } + const policy = target.resourceId && application?.compiled.resources.get(target.resourceId as never); + if (!policy) { + reject(data.id, { code: "resource.policy", message: "The image resource policy is unavailable" }); + return; + } + if (resource.bytes.byteLength > Math.min(policy.maxBytes, 32 * 1024 * 1024)) { + reject(data.id, { code: "resource.bytes", message: "The selected file is too large" }); + return; + } + const operation = { serial: ++resourceSerial, view }; + resourceOperations.set(authorization.token, operation); + try { + await setResourceRegistered( + view, + data, + operation, + target, + targetNode, + policy, + state, + decodeRevision, + decodeGraphVersion, + ); + } finally { + if (resourceOperations.get(authorization.token) === operation) resourceOperations.delete(authorization.token); + } +} +async function setResourceRegistered( + view: WorkerView, + data: Extract, + operation: { serial: number; view: WorkerView }, + target: NonNullable>, + targetNode: BoundEngineState["document"]["nodes"][string], + policy: { + readonly maxWidth: number; + readonly maxHeight: number; + readonly maxPixels: number; + readonly referencePrefix: string; + }, + decodeState: BoundEngineState, + decodeRevision: number, + decodeGraphVersion: number, +): Promise { + const { authorization, resource } = data, + sourceView = view; + let bitmap: ImageBitmap; + try { + bitmap = await createImageBitmap(new Blob([resource.bytes], { type: resource.mime || "application/octet-stream" })); + } catch { + reject(data.id, { code: "resource.decode", message: "The selected file is not a supported image" }); + return; + } + const current = resourceTarget(view, authorization.token, true); + if ( + views.get(sourceView.id) !== sourceView || + state !== decodeState || + decodeState.version !== decodeGraphVersion || + decodeState.document.nodes[target.nodeId] !== targetNode || + compositionRevision !== decodeRevision || + resourceOperations.get(authorization.token) !== operation || + !current || + current.nodeId !== target.nodeId || + current.key !== target.key || + current.resourceId !== target.resourceId + ) { + bitmap.close(); + reject(data.id, { code: "resource.stale", message: "The image target changed while decoding" }); + return; + } + if ( + bitmap.width > Math.min(policy.maxWidth, 8192) || + bitmap.height > Math.min(policy.maxHeight, 8192) || + bitmap.width * bitmap.height > Math.min(policy.maxPixels, 16_777_216) + ) { + bitmap.close(); + reject(data.id, { code: "resource.dimensions", message: "The decoded image is too large" }); + return; + } + const cancelled = cancelGesture(view); + publishSelection(view); + if (cancelled) view.scheduler.request(); + const finalTarget = resourceTarget(view, authorization.token, true); + if ( + !finalTarget || + finalTarget.nodeId !== target.nodeId || + finalTarget.key !== target.key || + finalTarget.resourceId !== target.resourceId + ) { + bitmap.close(); + reject(data.id, { code: "resource.stale", message: "The image target changed before commit" }); + return; + } + const reference = `${policy.referencePrefix}${decodeState.document.graphId}:${++resourceSerial}:${encodeURIComponent(resource.name)}`, + result = application!.runtime.transition(decodeState, { + commandId: commandId(data.id), + expectedVersion: decodeState.version, + source: "gesture", + command: { + type: "node.parameter", + id: target.nodeId, + key: target.key, + value: { kind: "string", value: reference }, + }, + }); + if (result.status === "rejected") { + bitmap.close(); + reject(data.id, result.error); + return; + } + if (result.status === "noop") { + bitmap.close(); + post({ + protocol: PROTOCOL_VERSION, + type: "response", + id: data.id, + ok: true, + value: { status: "noop", version: decodeState.version }, + }); + return; + } + commit(result, { + type: "node.parameter", + id: target.nodeId, + key: target.key, + value: { kind: "string", value: reference }, + }); + resourceImages.set(reference, { + bitmap, + name: resource.name, + bytes: bitmap.width * bitmap.height * 4, + lastUsed: performance.now(), + }); + trimResourceImages(); + post({ + protocol: PROTOCOL_VERSION, + type: "response", + id: data.id, + ok: true, + value: { status: "committed", version: state.version }, + }); +} + +function adoptHostGeneration(view: WorkerView, next: number): boolean { + if (next < view.hostGeneration) return false; + const advanced = next > view.hostGeneration; + view.hostGeneration = next; + if (advanced) view.scheduler.request(undefined, DirtyReason.HostInteraction); + return true; +} +function applyPointerSnapshot( + view: WorkerView, + snapshot: import("../browser/pointer-lane.js").PointerLaneSnapshot | undefined, +): void { + if (snapshot && snapshot.sequence !== view.consumedPointerSequence) { + view.consumedPointerSequence = snapshot.sequence; + if (!adoptHostGeneration(view, snapshot.hostGeneration)) return; + input(view, snapshot.event); + } +} +function applyPointerFence(view: WorkerView, fence: PointerFence | undefined): void { + if (!fence) return; + applyPointerSnapshot(view, fence.before); + view.handledPointerFence = fence.generation; +} +function pollPointerLane(view: WorkerView): void { + if (!view.pointerLane) return; + const generation = pointerLaneFence(view.pointerLane); + if (generation !== view.handledPointerFence) return; + const move = readPointerMove(view.pointerLane, view.consumedPointerSequence); + if (pointerLaneFence(view.pointerLane) !== generation) return; + applyPointerSnapshot(view, move); +} + +type ApplicationAuthority = NonNullable; +interface StagedCompositionUpdate { + readonly baseRevision: number; + readonly revision: number; + readonly change: CompositionChange; + readonly baseGraphVersion: number; + readonly graphVersion: number; + readonly graphChanged: boolean; + readonly application: ApplicationAuthority; + readonly state: BoundEngineState; + readonly layoutStore: IndexedLayoutStore; + readonly mutationEnvelope?: BoundMutationEnvelope; + readonly snapshotEnvelope?: BoundSnapshotEnvelope; +} +function putCompositionEntry( + record: Readonly>, + id: string, + value: unknown, +): Readonly> { + const entries: Array = []; + let replaced = false; + for (const [key, current] of Object.entries(record)) { + if (key === id) { + entries.push([key, value]); + replaced = true; + } else entries.push([key, current]); + } + if (!replaced) entries.push([id, value]); + return nullRecord(entries); +} +function removeCompositionEntry( + record: Readonly>, + id: string, +): Readonly> { + return nullRecord(Object.entries(record).filter(([key]) => key !== id)); +} +function patchCompositionSource(source: FxNodeCompositionData, update: CompositionUpdateWire): unknown { + switch (update.kind) { + case "composition.load": + return update.composition; + case "theme.set": + return { ...source, theme: update.theme }; + case "header-styles.set": + return { ...source, nodeStyles: update.styles }; + case "compatibility.set": + return { ...source, compatibility: update.compatibility }; + case "socket.compose": + return { ...source, socketTypes: putCompositionEntry(source.socketTypes, update.id, update.definition) }; + case "socket.remove": + return { ...source, socketTypes: removeCompositionEntry(source.socketTypes, update.id) }; + case "node.compose": + return { ...source, nodes: putCompositionEntry(source.nodes, update.id, update.definition) }; + case "node.remove": + return { ...source, nodes: removeCompositionEntry(source.nodes, update.id) }; + } +} +const compositionChange = (update: CompositionUpdateWire): CompositionChange => + update.kind === "theme.set" || + update.kind === "header-styles.set" || + update.kind === "composition.load" || + update.kind === "compatibility.set" + ? { kind: update.kind } + : { kind: update.kind, id: update.id }; +type StageResult = + | { readonly status: "noop"; readonly receipt: CompositionReceipt } + | { readonly status: "rejected"; readonly error: WireError } + | { readonly status: "committed"; readonly staged: StagedCompositionUpdate }; +function stageCompositionUpdate(data: Extract): StageResult { + const currentApplication = application!, + currentState = state!; + if (data.expected.kind === "exact" && data.expected.revision !== compositionRevision) + return { + status: "rejected", + error: toWireError({ + code: "composition.revision.stale", + message: "Expected composition revision does not match", + }), + }; + let candidateCompiled: CompiledFxNodeComposition; + try { + candidateCompiled = compileFxNodeComposition( + patchCompositionSource(currentApplication.compiled.source, data.update) as FxNodeCompositionData, + ); + } catch (error) { + if (error instanceof FxNodeCompositionError) + return { + status: "rejected", + error: toWireError({ code: "composition.invalid", message: error.message, issues: error.issues }), + }; + throw error; + } + if (canonicalJsonEqual(candidateCompiled.source, currentApplication.compiled.source)) + return { + status: "noop", + receipt: { + status: "noop", + revision: compositionRevision, + graphVersion: currentState.version, + graphChanged: false, + historyReset: false, + }, + }; + if (compositionRevision >= Number.MAX_SAFE_INTEGER) + return { + status: "rejected", + error: toWireError({ code: "composition.revision.overflow", message: "Composition revision exhausted" }), + }; + const removedNodeTypes = + data.update.kind === "composition.load" + ? new Set( + Object.keys(currentApplication.compiled.source.nodes).filter( + (id) => !Object.hasOwn(candidateCompiled.source.nodes, id), + ), + ) + : data.update.kind === "node.remove" + ? new Set([data.update.id]) + : undefined; + const candidateRuntime = bindFxNodeHeadless(candidateCompiled), + rebound = rebindBoundEngineAuthority(currentState, currentApplication.runtime, candidateRuntime, { + commandId: commandId(data.id), + ...(removedNodeTypes ? { removedNodeTypes } : {}), + }); + if (!rebound.ok) { + const demotion = rebound.issues.some((issue) => issue.code === "composition.node-demotion"), + overflow = rebound.issues.some((issue) => issue.code === "version.overflow"); + return { + status: "rejected", + error: toWireError({ + code: demotion ? "composition.node-demotion" : overflow ? "version.overflow" : "composition.rebind.invalid", + message: demotion + ? "Composition update would demote a known node" + : overflow + ? "Graph version exhausted" + : "Current graph is incompatible with the composition update", + issues: rebound.issues, + }), + }; + } + const candidateStore = new IndexedLayoutStore(candidateCompiled, rebound.state.document); + const revision = compositionRevision + 1; + return { + status: "committed", + staged: { + baseRevision: compositionRevision, + revision, + change: compositionChange(data.update), + baseGraphVersion: currentState.version, + graphVersion: rebound.state.version, + graphChanged: rebound.graphChanged, + application: { compiled: candidateCompiled, runtime: candidateRuntime }, + state: rebound.state, + layoutStore: candidateStore, + ...(rebound.graphChanged + ? { mutationEnvelope: rebound.mutationEnvelope, snapshotEnvelope: rebound.snapshotEnvelope } + : {}), + }, + }; +} +function publishCompositionUpdate(id: string, staged: StagedCompositionUpdate): void { + for (const view of views.values()) cancelGesture(view); + application = staged.application; + state = staged.state; + layoutStore = staged.layoutStore; + compositionRevision = staged.revision; + const nodeIds = new Set(Object.keys(state.document.nodes) as import("../core/types.js").NodeId[]), + linkIds = new Set(Object.keys(state.document.links) as import("../core/types.js").LinkId[]); + for (const view of views.values()) { + const order = view.session.uiOrder.filter((nodeId) => nodeIds.has(nodeId)); + resetSessionForCompositionRebind(view.session, nodeIds, linkIds, order); + view.gestureLayout = undefined; + view.currentLayout = applyNodeOrder(layoutStore.view(currentTransform(view)), order); + } + journal = checkpointJournal(application.runtime.save(state.document)); + const envelope: CompositionChangeEnvelope = { + baseRevision: staged.baseRevision, + revision: staged.revision, + change: staged.change, + baseGraphVersion: staged.baseGraphVersion, + graphVersion: staged.graphVersion, + graphChanged: staged.graphChanged, + historyReset: true, + }; + post({ protocol: PROTOCOL_VERSION, type: "composition.event", envelope }); + if (staged.graphChanged) { + post({ protocol: PROTOCOL_VERSION, type: "mutation", envelope: staged.mutationEnvelope! }); + post({ protocol: PROTOCOL_VERSION, type: "snapshot.event", envelope: staged.snapshotEnvelope! }); + } + for (const view of views.values()) publishSelection(view); + for (const view of views.values()) view.scheduler.request(); + post({ + protocol: PROTOCOL_VERSION, + type: "response", + id, + ok: true, + value: { + status: "committed", + revision: staged.revision, + graphVersion: staged.graphVersion, + graphChanged: staged.graphChanged, + historyReset: true, + }, + }); +} +function recognizableInvalidRpc(value: unknown): + | { + readonly type: + | "init" + | "command" + | "load" + | "save.data" + | "state.set" + | "composition.update" + | "view.attach" + | "view.detach" + | "view.viewport" + | "view.node.add" + | "view.selection.remove" + | "view.selection.mute" + | "view.resource.set"; + readonly id: string; + } + | undefined { + try { + if (typeof value !== "object" || value === null || (value as { protocol?: unknown }).protocol !== PROTOCOL_VERSION) + return; + const type = (value as { type?: unknown }).type, + id = (value as { id?: unknown }).id; + if ( + (type !== "init" && + type !== "command" && + type !== "load" && + type !== "save.data" && + type !== "state.set" && + type !== "composition.update" && + type !== "view.attach" && + type !== "view.detach" && + type !== "view.viewport" && + type !== "view.node.add" && + type !== "view.selection.remove" && + type !== "view.selection.mute" && + type !== "view.resource.set") || + typeof id !== "string" || + !id.length || + id.length > 512 + ) + return; + return { type, id }; + } catch { + return; + } +} + +async function handleMessage({ data }: MessageEvent): Promise { + if (!validRequest(data)) { + const recognizable = recognizableInvalidRpc(data); + if (recognizable) { + const candidate = data as { viewId?: unknown }; + if ( + recognizable.type.startsWith("view.") && + recognizable.type !== "view.attach" && + (typeof candidate.viewId !== "string" || !views.has(candidate.viewId)) + ) { + reject(recognizable.id, { code: "view.missing", message: "View is not attached" }); + return; + } + reject(recognizable.id, { + code: + recognizable.type === "init" + ? "init.invalid" + : recognizable.type === "command" + ? "command.invalid" + : recognizable.type === "load" + ? "layout.request.invalid" + : recognizable.type === "save.data" + ? "save-data.request.invalid" + : recognizable.type === "state.set" + ? "state.request.invalid" + : recognizable.type === "composition.update" + ? "composition.request.invalid" + : recognizable.type === "view.viewport" + ? "viewport.request.invalid" + : recognizable.type === "view.resource.set" + ? "resource.request.invalid" + : "action.request.invalid", + message: `Invalid ${recognizable.type} request`, + }); + return; + } + fatal(new Error("Invalid worker protocol message"), "protocol.invalid"); + return; + } + try { + if (data.type === "init") { + if (application) { + reject(data.id, { code: "init.duplicate", message: "Worker is already initialized" }); + return; + } + const compiled = createInitialFxNodeComposition( + data.applicationId as string, + data.applicationVersion as number, + data.resources as never, + ), + runtime = bindFxNodeHeadless(compiled); + const nextState = runtime.createEngine(runtime.emptyDocument(), data.historyLimit), + nextStore = new IndexedLayoutStore(compiled, nextState.document); + application = { compiled, runtime }; + state = nextState; + journal = checkpointJournal(runtime.save(nextState.document)); + layoutStore = nextStore; + compositionRevision = 0; + post({ protocol: PROTOCOL_VERSION, type: "response", id: data.id, ok: true }); + return; + } + if (data.type === "view.attach") { + if (!state) { + reject(data.id, { code: "init.required", message: "Worker is not initialized" }); + return; + } + if (usedViewIds.has(data.viewId)) + return reject(data.id, { code: "view.id.used", message: "View ID has already been used" }); + usedViewIds.add(data.viewId); + let allocated = false; + try { + const device = fxNodeDevicePixels(data.viewport.width, data.viewport.height, data.viewport.dpr), + aggregate = [...views.values()].reduce((sum, item) => sum + item.deviceWidth * item.deviceHeight, 0); + if ( + !device || + views.size >= FXNODE_VIEW_LIMITS.maxViews || + aggregate + device.width * device.height > FXNODE_VIEW_LIMITS.maxActiveDevicePixels + ) + return reject(data.id, { code: "view.limit", message: "View rendering limits exceeded" }); + if ( + typeof OffscreenCanvas !== "function" || + typeof ImageBitmap !== "function" || + typeof ImageBitmap.prototype.close !== "function" || + (data.pointerLane !== undefined && data.pointerLane.byteLength !== Int32Array.BYTES_PER_ELEMENT * 16) + ) + return reject(data.id, { + code: "view.render.unavailable", + message: "Required view rendering capabilities are unavailable", + }); + const mutation = await atlas.attach(data.viewId, device); + allocated = true; + requestViewInvalidations(mutation.invalidatedViewIds, views); + const session = createSession(data.camera); + let view!: WorkerView; + const scheduler = new RenderScheduler((frameId, renderId, reasons) => { + void drawView(view, frameId, renderId, reasons).catch(fatal); + }); + view = { + id: data.viewId, + session, + viewport: data.viewport, + deviceWidth: device.width, + deviceHeight: device.height, + surfaceGeneration: 0, + resizePending: false, + currentLayout: undefined, + gestureLayout: undefined, + scheduler, + pointerLane: data.pointerLane, + handledPointerFence: 0, + consumedPointerSequence: 0, + hostGeneration: 0, + selectionSignature: "", + inFlightPixels: undefined, + }; + view.currentLayout = layoutStore!.view(currentTransform(view)); + views.set(view.id, view); + post({ protocol: PROTOCOL_VERSION, type: "response", id: data.id, ok: true }); + publishSelection(view, true); + scheduler.request(1); + scheduler.start(() => pollPointerLane(view)); + } catch (error) { + if (error instanceof ViewAtlasError) { + if (error.fatal) { + fatal(error, error.code); + return; + } + for (const item of views.values()) item.scheduler.request(0, DirtyReason.Viewport); + reject(data.id, { code: error.code, message: error.message }); + return; + } + if (allocated) + try { + const cleanup = await atlas.detach(data.viewId); + requestViewInvalidations(cleanup.invalidatedViewIds, views); + } catch (cleanupError) { + fatal(cleanupError); + return; + } + reject(data.id, { + code: "view.attach.rejected", + message: error instanceof Error ? error.message : "Unable to attach view", + }); + } + return; + } + if (!state) { + fatal(new Error("Worker is not initialized")); + return; + } + if (data.type === "view.detach") { + const view = views.get(data.viewId); + if (!view) return reject(data.id, { code: "view.missing", message: "View is not attached" }); + cancelGesture(view); + view.scheduler.stop(); + views.delete(data.viewId); + for (const [token, operation] of resourceOperations) + if (operation.view === view && resourceOperations.get(token) === operation) resourceOperations.delete(token); + let detach: Awaited>; + try { + detach = await atlas.detach(data.viewId); + } finally { + releaseFramePixels(view); + } + for (const id of detach.invalidatedViewIds) views.get(id)?.scheduler.request(0, DirtyReason.Viewport); + post({ protocol: PROTOCOL_VERSION, type: "response", id: data.id, ok: true }); + return; + } + const view = "viewId" in data ? views.get(data.viewId) : undefined; + if ("viewId" in data && !view) { + if ("id" in data) reject(data.id, { code: "view.missing", message: "View is not attached" }); + return; + } + if (data.type === "composition.update") { + const result = stageCompositionUpdate(data); + if (result.status === "rejected") + post({ protocol: PROTOCOL_VERSION, type: "response", id: data.id, ok: false, error: result.error }); + else if (result.status === "noop") + post({ protocol: PROTOCOL_VERSION, type: "response", id: data.id, ok: true, value: result.receipt }); + else publishCompositionUpdate(data.id, result.staged); + return; + } + if (data.type === "command") { + for (const item of views.values()) { + if (cancelGesture(item)) item.scheduler.request(); + publishSelection(item); + } + const result = application!.runtime.transition(state, { + commandId: commandId(data.id), + expectedVersion: data.expected.kind === "current" ? state.version : data.expected.version, + source: "api", + command: data.command, + }); + if (result.status === "rejected") reject(data.id, result.error); + else { + if (result.status === "committed") commit(result, data.command); + post({ + protocol: PROTOCOL_VERSION, + type: "response", + id: data.id, + ok: true, + value: { status: result.status, version: state.version }, + }); + } + return; + } + if (data.type === "state.set") { + const result = application!.runtime.replaceState(state, { + commandId: commandId(data.id), + expectedVersion: data.expected.kind === "current" ? state.version : data.expected.version, + target: data.state as never, + }); + if (result.status === "rejected") reject(data.id, result.error); + else { + if (result.status === "committed") + installDocumentReplacement(result, checkpointJournal(application!.runtime.save(result.state.document))); + post({ + protocol: PROTOCOL_VERSION, + type: "response", + id: data.id, + ok: true, + value: { status: result.status, version: state.version }, + }); + } + return; + } + if (data.type === "view.node.add") { + applyPointerFence(view!, data.pointerFence); + const cancelled = cancelGesture(view!); + publishSelection(view!); + if (cancelled) view!.scheduler.request(); + const command: Command = { + type: "node.add", + nodeId: nodeId(data.nodeId), + nodeType: data.typeId, + position: viewToWorld(data.viewPosition, currentTransform(view!)), + }, + result = application!.runtime.transition(state, { + commandId: commandId(data.id), + expectedVersion: data.expected.kind === "current" ? state.version : data.expected.version, + source: "api", + command, + }); + if (result.status === "rejected") reject(data.id, result.error); + else { + if (result.status === "committed") + commit(result, command, view!, { kind: "select-added", nodeId: data.nodeId }); + post({ + protocol: PROTOCOL_VERSION, + type: "response", + id: data.id, + ok: true, + value: { status: result.status, version: state.version }, + }); + } + return; + } + if (data.type === "view.selection.remove" || data.type === "view.selection.mute") { + applyPointerFence(view!, data.pointerFence); + const cancelled = cancelGesture(view!); + publishSelection(view!); + if (cancelled) view!.scheduler.request(); + const command = + data.type === "view.selection.remove" + ? planSelectionRemoval(state.document, view!.session.selectedNodes, view!.session.selectedLinks) + : planSelectionMute(state.document, view!.session.selectedNodes, isStandardNode, data.value); + if (!command) { + post({ + protocol: PROTOCOL_VERSION, + type: "response", + id: data.id, + ok: true, + value: { status: "noop", version: state.version }, + }); + return; + } + const result = application!.runtime.transition(state, { + commandId: commandId(data.id), + expectedVersion: data.expected.kind === "current" ? state.version : data.expected.version, + source: "api", + command, + }); + if (result.status === "rejected") reject(data.id, result.error); + else { + if (result.status === "committed") commit(result, command); + post({ + protocol: PROTOCOL_VERSION, + type: "response", + id: data.id, + ok: true, + value: { status: result.status, version: state.version }, + }); + } + return; + } + if (data.type === "view.resource.set") { + void setResource(view!, data).catch((error) => + reject(data.id, { + code: "resource.error", + message: error instanceof Error ? error.message : "Unable to load image", + }), + ); + return; + } + if (data.type === "load") { + const expected = data.expected.kind === "current" ? state.version : data.expected.version, + isSaveData = + typeof data.data === "object" && + data.data !== null && + (data.data as { kind?: unknown }).kind === "fxnode.command-log", + result = isSaveData + ? application!.runtime.replaySaveData(state, data.data, expected, commandId(data.id)) + : application!.runtime.load(state, data.data, expected, commandId(data.id)); + if (!result.ok) + reject(data.id, { + code: result.issues[0]?.code ?? "layout.invalid", + message: result.issues[0]?.message ?? "Invalid layout", + path: result.issues[0]?.path, + issues: result.issues, + }); + else if (isSaveData && "status" in result && result.status === "noop") { + let cancelled = false; + for (const item of views.values()) cancelled = cancelGesture(item) || cancelled; + state = result.state; + journal = importJournal(result.saveData); + for (const item of views.values()) { + publishSelection(item); + if (cancelled) item.scheduler.request(); + } + post({ + protocol: PROTOCOL_VERSION, + type: "response", + id: data.id, + ok: true, + value: { status: "noop", version: state.version }, + }); + } else { + const committed = result as + | Extract + | Extract; + installDocumentReplacement( + committed, + "saveData" in committed + ? importJournal(committed.saveData) + : checkpointJournal(application!.runtime.save(committed.state.document)), + ); + post({ + protocol: PROTOCOL_VERSION, + type: "response", + id: data.id, + ok: true, + value: { status: "committed", version: state.version }, + }); + } + return; + } + if (data.type === "state.get") { + post({ + protocol: PROTOCOL_VERSION, + type: "response", + id: data.id, + ok: true, + value: application!.runtime.getState(state), + }); + return; + } + if (data.type === "save") { + post({ + protocol: PROTOCOL_VERSION, + type: "response", + id: data.id, + ok: true, + value: application!.runtime.save(state.document), + }); + return; + } + if (data.type === "save.data") { + post({ + protocol: PROTOCOL_VERSION, + type: "response", + id: data.id, + ok: true, + value: journalSaveData(journal!, application!.compiled.source), + }); + return; + } + if (data.type === "view.viewport") { + const device = fxNodeDevicePixels(data.viewport.width, data.viewport.height, data.viewport.dpr), + aggregate = [...views.values()].reduce( + (sum, item) => sum + (item === view ? 0 : item.deviceWidth * item.deviceHeight), + 0, + ); + if (!device || aggregate + device.width * device.height > FXNODE_VIEW_LIMITS.maxActiveDevicePixels) { + reject(data.id, { code: "view.limit", message: "View rendering limits exceeded" }); + return; + } + if (data.expectedSurfaceGeneration !== view!.surfaceGeneration + 1) { + reject(data.id, { code: "view.surface-generation", message: "Unexpected view surface generation" }); + return; + } + if ( + data.hostGeneration < view!.hostGeneration || + (data.pointerFence?.before && data.pointerFence.before.hostGeneration > data.hostGeneration) + ) { + reject(data.id, { code: "view.host-generation", message: "Stale view host generation" }); + return; + } + applyPointerFence(view!, data.pointerFence); + if (!adoptHostGeneration(view!, data.hostGeneration)) { + reject(data.id, { code: "view.host-generation", message: "Stale view host generation" }); + return; + } + view!.resizePending = true; + try { + const mutation = await atlas.resize(view!.id, device); + view!.viewport = data.viewport; + view!.deviceWidth = device.width; + view!.deviceHeight = device.height; + view!.surfaceGeneration = data.expectedSurfaceGeneration; + const invalidated = new Set(mutation.invalidatedViewIds); + invalidated.add(view!.id); + for (const id of invalidated) views.get(id)?.scheduler.request(0, DirtyReason.Viewport); + refreshLayout(view!); + post({ protocol: PROTOCOL_VERSION, type: "response", id: data.id, ok: true }); + } catch (error) { + if (error instanceof ViewAtlasError && !error.fatal) { + for (const item of views.values()) item.scheduler.request(0, DirtyReason.Viewport); + reject(data.id, { code: error.code, message: error.message }); + } else throw error; + } finally { + view!.resizePending = false; + } + return; + } + if (data.type === "view.render") { + applyPointerFence(view!, data.pointerFence); + if (!adoptHostGeneration(view!, data.hostGeneration)) return; + view!.scheduler.request(data.renderId, DirtyReason.Barrier); + return; + } + if (data.type === "view.frame.consumed") { + releaseFramePixels(view!, data.frameId); + view!.scheduler.consumed(data.frameId); + return; + } + if (data.type === "view.pointer.flush") { + applyPointerFence(view!, data.pointerFence); + return; + } + if (data.type === "dispose") { + for (const item of views.values()) { + item.scheduler.stop(); + releaseFramePixels(item); + } + views.clear(); + atlas.dispose(); + closeResourceImages(); + scope.close(); + return; + } + if (data.type === "view.input") { + applyPointerFence(view!, data.pointerFence); + if (!adoptHostGeneration(view!, data.hostGeneration)) return; + input(view!, data.event, data.nodeMenuRequestId, data.resourceOpenRequestId); + } + } catch (error) { + if ((data.type === "init" || data.type === "composition.update") && error instanceof FxNodeCompositionError) { + reject(data.id, { code: "composition.invalid", message: error.message, issues: error.issues }); + return; + } + fatal(error); + } +} + +let viewLifecycleQueue: Promise = Promise.resolve(); +const pendingViewLifecycle = new Map(); +scope.onmessage = (event) => { + const type = + typeof event.data === "object" && event.data !== null ? (event.data as { type?: unknown }).type : undefined, + viewId = + typeof event.data === "object" && + event.data !== null && + typeof (event.data as { viewId?: unknown }).viewId === "string" + ? (event.data as { viewId: string }).viewId + : undefined; + if (type === "view.attach" || type === "view.viewport" || type === "view.detach") { + if (viewId) pendingViewLifecycle.set(viewId, (pendingViewLifecycle.get(viewId) ?? 0) + 1); + viewLifecycleQueue = viewLifecycleQueue + .then(() => handleMessage(event)) + .catch(fatal) + .finally(() => { + if (!viewId) return; + const remaining = (pendingViewLifecycle.get(viewId) ?? 1) - 1; + if (remaining) pendingViewLifecycle.set(viewId, remaining); + else pendingViewLifecycle.delete(viewId); + }); + } else if (viewId && pendingViewLifecycle.has(viewId)) + void viewLifecycleQueue.then(() => handleMessage(event)).catch(fatal); + else void handleMessage(event).catch(fatal); +}; diff --git a/vendor/fxnode/src/worker/interaction.ts b/vendor/fxnode/src/worker/interaction.ts new file mode 100644 index 0000000..083c4c6 --- /dev/null +++ b/vendor/fxnode/src/worker/interaction.ts @@ -0,0 +1,292 @@ +import { linkId, type GraphLink, type LinkId, type NodeId, type SocketId, type Vec2 } from "../core/types.js"; +import type { Command } from "../commands/types.js"; +import { viewToWorld } from "../layout/geometry.js"; +import { GEOMETRY as G } from "../layout/constants.js"; +import { + layoutSocketsCompatible, + type LayoutControl, + type LayoutSnapshot, + type Rect, + type ViewTransform, +} from "../layout/types.js"; +import { isColorRamp } from "../widgets/color-ramp.js"; + +const inRect = (p: Vec2, r: Rect, tolerance = 0): boolean => + p.x >= r.x - tolerance && + p.x <= r.x + r.width + tolerance && + p.y <= r.y + tolerance && + p.y >= r.y - r.height - tolerance; + +export type RampTarget = + | "add" + | "remove" + | "flip" + | "distribute" + | "mode" + | "interpolation" + | "hue" + | "gradient" + | "selector" + | "position" + | "swatch"; +export type Hit = + | { readonly kind: "color-wheel"; readonly id: string; readonly region: "plane" | "lightness" } + | { + readonly kind: "ramp"; + readonly id: string; + readonly target: RampTarget | "handle"; + readonly stopId?: string; + readonly position?: number; + } + | { readonly kind: "control-step"; readonly id: string; readonly component: number; readonly direction: -1 | 1 } + | { readonly kind: "resource"; readonly id: string } + | { readonly kind: "control"; readonly id: string; readonly component: number } + | { readonly kind: "socket"; readonly id: SocketId } + | { readonly kind: "collapse" | "resize" | "node" | "frame-header" | "frame-body"; readonly id: NodeId } + | { readonly kind: "link"; readonly id: LinkId } + | { readonly kind: "canvas" }; +export function hitRamp(control: LayoutControl, p: Vec2, tolerance = 0): Extract | undefined { + const b = control.rampBounds, + v = control.value as { kind?: unknown; value?: unknown }; + if (control.kind !== "color-ramp" || !b || v.kind !== "json" || !isColorRamp(v.value)) return; + const ramp = v.value; + if (inRect(p, b.toolbar)) { + const x = (p.x - b.toolbar.x) / b.toolbar.width; + return { + kind: "ramp", + id: control.id, + target: x < 0.12 ? "add" : x < 0.24 ? "remove" : x < 0.62 ? "flip" : "distribute", + }; + } + if (inRect(p, b.mode)) return { kind: "ramp", id: control.id, target: "mode" }; + if (inRect(p, b.interpolation)) return { kind: "ramp", id: control.id, target: "interpolation" }; + if (inRect(p, b.hue)) return { kind: "ramp", id: control.id, target: "hue" }; + if (inRect(p, b.handles, tolerance)) { + const position = Math.max(0, Math.min(1, (p.x - b.handles.x) / b.handles.width)), + near = ramp.stops + .filter((s) => Math.abs(s.position - position) * b.handles.width <= Math.max(7, tolerance)) + .sort((a, c) => a.position - c.position || a.id.localeCompare(c.id)); + if (near.length) return { kind: "ramp", id: control.id, target: "handle", stopId: near[0]!.id, position }; + } + if (inRect(p, b.gradient)) + return { + kind: "ramp", + id: control.id, + target: "gradient", + position: Math.max(0, Math.min(1, (p.x - b.gradient.x) / b.gradient.width)), + }; + if (inRect(p, b.selector)) return { kind: "ramp", id: control.id, target: "selector" }; + if (inRect(p, b.position)) return { kind: "ramp", id: control.id, target: "position" }; + if (inRect(p, b.color)) return { kind: "ramp", id: control.id, target: "swatch" }; +} +const segmentDistance = (p: Vec2, a: Vec2, b: Vec2): number => { + const dx = b.x - a.x, + dy = b.y - a.y, + l = dx * dx + dy * dy; + if (!l) return Math.hypot(p.x - a.x, p.y - a.y); + const q = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / l)); + return Math.hypot(p.x - a.x - q * dx, p.y - a.y - q * dy); +}; +export function hitTest(layout: LayoutSnapshot, view: Vec2, preferredDirection?: "input" | "output"): Hit { + const world = viewToWorld(view, layout.transform), + tolerance = 7 / layout.transform.zoom; + const topNode = layout.drawOrder + .slice() + .reverse() + .map((id) => layout.nodes.get(id)) + .find((node) => node !== undefined && node.kind !== "frame" && inRect(world, node.bounds)); + for (const control of [...layout.controls.values()].reverse()) + if (!control.linked && (!topNode || control.nodeId === topNode.id)) { + if (control.kind === "resource") { + if ( + control.resourceBounds && + (inRect(world, control.resourceBounds.preview) || inRect(world, control.resourceBounds.open)) + ) + return { kind: "resource", id: control.id }; + continue; + } + if (control.colorWheelBounds) { + if (inRect(world, control.colorWheelBounds.plane)) + return { kind: "color-wheel", id: control.id, region: "plane" }; + if (inRect(world, control.colorWheelBounds.lightness)) + return { kind: "color-wheel", id: control.id, region: "lightness" }; + continue; + } + const ramp = hitRamp(control, world, tolerance); + if (ramp) return ramp; + for (const field of control.numericFields) { + if (inRect(world, field.decrement)) + return { kind: "control-step", id: control.id, component: field.component, direction: -1 }; + if (inRect(world, field.increment)) + return { kind: "control-step", id: control.id, component: field.component, direction: 1 }; + } + if (inRect(world, control.bounds)) { + const component = control.subfields.find((field) => inRect(world, field.bounds))?.index ?? 0; + return { kind: "control", id: control.id, component }; + } + } + for (const id of layout.drawOrder.slice().reverse()) { + const node = layout.nodes.get(id); + if (node?.kind !== "reroute") continue; + const center = { x: node.bounds.x + node.bounds.width / 2, y: node.bounds.y - node.bounds.height / 2 }, + distance = Math.hypot(world.x - center.x, world.y - center.y), + core = Math.max(G.reroute, 7 / layout.transform.zoom), + halo = core + 8 / layout.transform.zoom; + if (distance > core && distance <= halo) return { kind: "node", id }; + } + const socketHits = [...layout.sockets.values()] + .filter( + (socket) => + (!topNode || socket.nodeId === topNode.id) && + Math.hypot(world.x - socket.anchor.x, world.y - socket.anchor.y) <= tolerance, + ) + .sort((a, b) => { + const an = layout.nodes.get(a.nodeId), + bn = layout.nodes.get(b.nodeId), + rank = (node: typeof an) => (node ? layout.drawOrder.indexOf(node.id) : -1); + return ( + rank(bn) - rank(an) || Number(b.direction === preferredDirection) - Number(a.direction === preferredDirection) + ); + }); + if (socketHits[0]) return { kind: "socket", id: socketHits[0].id }; + const regular = layout.drawOrder + .slice() + .reverse() + .map((id) => layout.nodes.get(id)) + .filter((n) => n?.kind !== "frame"); + for (const node of regular) + if (node && node.kind === "node" && inRect(world, node.collapseHitRect, tolerance)) + return { kind: "collapse", id: node.id }; + for (const node of regular) + if (node && node.kind === "node" && !node.collapsed && inRect(world, node.resizeHitRect, tolerance)) + return { kind: "resize", id: node.id }; + for (const node of regular) if (node && inRect(world, node.bounds)) return { kind: "node", id: node.id }; + for (const link of [...layout.links.values()].reverse()) + if (link.visible && link.points.slice(1).some((p, i) => segmentDistance(world, link.points[i]!, p) <= tolerance)) + return { kind: "link", id: link.id }; + for (const id of layout.drawOrder.slice().reverse()) { + const n = layout.nodes.get(id); + if (n?.kind === "frame" && inRect(world, n.header)) return { kind: "frame-header", id }; + } + for (const id of layout.drawOrder.slice().reverse()) { + const n = layout.nodes.get(id); + if (n?.kind === "frame" && inRect(world, n.bounds)) return { kind: "frame-body", id }; + } + return { kind: "canvas" }; +} + +/** Hit order is stable and all tolerances are expressed in view pixels. */ +export function hitNode(layout: LayoutSnapshot, view: Vec2): NodeId | undefined { + const hit = hitTest(layout, view); + return hit.kind === "control" || + hit.kind === "control-step" || + hit.kind === "ramp" || + hit.kind === "color-wheel" || + hit.kind === "resource" + ? layout.controls.get(hit.id)?.nodeId + : hit.kind === "socket" + ? layout.sockets.get(hit.id)?.nodeId + : hit.kind === "node" || + hit.kind === "collapse" || + hit.kind === "resize" || + hit.kind === "frame-header" || + hit.kind === "frame-body" + ? hit.id + : undefined; +} + +export const boxNodes = (layout: LayoutSnapshot, a: Vec2, b: Vec2): NodeId[] => { + const p = viewToWorld(a, layout.transform), + q = viewToWorld(b, layout.transform), + left = Math.min(p.x, q.x), + right = Math.max(p.x, q.x), + top = Math.max(p.y, q.y), + bottom = Math.min(p.y, q.y); + return layout.drawOrder.filter((id) => { + const n = layout.nodes.get(id); + return ( + !!n && + n.kind !== "frame" && + n.bounds.x >= left && + n.bounds.x + n.bounds.width <= right && + n.bounds.y <= top && + n.bounds.y - n.bounds.height >= bottom + ); + }); +}; +export const compatibleTargets = (layout: LayoutSnapshot, fromId: SocketId) => { + const from = layout.sockets.get(fromId); + if (!from || from.direction !== "output") return []; + return [...layout.sockets.values()].filter((to) => to.nodeId !== from.nodeId && layoutSocketsCompatible(from, to)); +}; +export function planLink( + layout: LayoutSnapshot, + fromId: SocketId, + toId: SocketId, + newId: LinkId = linkId(`gesture-${fromId}-${toId}`), +): Command | undefined { + const from = layout.sockets.get(fromId), + to = layout.sockets.get(toId); + if (!from || !to || !compatibleTargets(layout, fromId).some((s) => s.id === toId)) return; + const link: GraphLink = { + id: to.linkIds[0] ?? newId, + fromNodeId: from.nodeId, + fromSocketId: from.id, + toNodeId: to.nodeId, + toSocketId: to.id, + muted: false, + extensions: {}, + }; + return to.capacity === 1 && to.linkIds.length + ? { type: "link.replace", removeId: to.linkIds[0]!, link } + : { type: "link.add", link }; +} +export const clampResize = (layout: LayoutSnapshot, id: NodeId, world: Vec2): Vec2 | undefined => { + const n = layout.nodes.get(id); + if (!n || n.kind !== "node") return; + return { + x: Math.min(G.maxWidth, Math.max(n.minimumSize.x, world.x - n.bounds.x)), + y: Math.max(n.minimumSize.y, n.bounds.y - world.y), + }; +}; +export function frameDropCandidate(layout: LayoutSnapshot, id: NodeId, world: Vec2): NodeId | undefined { + const node = layout.nodes.get(id); + if (!node) return; + const descendants = new Set(); + for (const n of layout.nodes.values()) { + let p = n.parentId; + while (p) { + if (p === id) { + descendants.add(n.id); + break; + } + p = layout.nodes.get(p)?.parentId; + } + } + return [...layout.nodes.values()] + .filter((n) => n.kind === "frame" && n.id !== id && !descendants.has(n.id) && inRect(world, n.bounds)) + .sort((a, b) => a.bounds.width * a.bounds.height - b.bounds.width * b.bounds.height)[0]?.id; +} + +export function zoomAt(transform: ViewTransform, cursor: Vec2, deltaY: number): { center: Vec2; zoom: number } { + const anchor = viewToWorld(cursor, transform); + const zoom = Math.min(4, Math.max(0.1, transform.zoom * Math.exp(-deltaY * 0.0015))); + return { + zoom, + center: { + x: anchor.x - (cursor.x - transform.viewport.x / 2) / zoom, + y: anchor.y + (cursor.y - transform.viewport.y / 2) / zoom, + }, + }; +} + +export function groupRoots(selected: ReadonlySet, layout: LayoutSnapshot): readonly NodeId[] { + return [...selected].filter((id) => { + let parent = layout.nodes.get(id)?.parentId; + while (parent) { + if (selected.has(parent)) return false; + parent = layout.nodes.get(parent)?.parentId; + } + return true; + }); +} diff --git a/vendor/fxnode/src/worker/journal.ts b/vendor/fxnode/src/worker/journal.ts new file mode 100644 index 0000000..3397bcc --- /dev/null +++ b/vendor/fxnode/src/worker/journal.ts @@ -0,0 +1,103 @@ +import type { Command, CompatibleFxNodeSaveData, FxNodeReplayCommand, FxNodeSaveData } from "../commands/types.js"; +import { FXNODE_SAVE_DATA_LIMITS } from "../commands/save-data.js"; +import type { FxNodeCompositionData } from "../composition/types.js"; +import { admitStructuredData, cloneJson, deepFreeze, type StructuredDataMetrics } from "../core/json.js"; +import type { GraphLayoutV2 } from "../core/types.js"; + +export interface JournalEntry { + readonly command: FxNodeReplayCommand; + readonly metrics: StructuredDataMetrics; + readonly atomicCommands: number; +} +export interface WorkerJournal { + readonly baseline: GraphLayoutV2; + readonly applied: readonly JournalEntry[]; + readonly redo: readonly JournalEntry[]; + readonly values: number; + readonly stringCodeUnits: number; + readonly atomicCommands: number; + readonly depth: number; +} +const metrics = (command: FxNodeReplayCommand): JournalEntry | undefined => { + const admitted = admitStructuredData(command, FXNODE_SAVE_DATA_LIMITS); + if (!admitted.ok) return; + return deepFreeze({ + command: admitted.value as FxNodeReplayCommand, + metrics: admitted.metrics, + atomicCommands: command.type === "batch" ? command.commands.length : 1, + }); +}; +const fold = ( + baseline: GraphLayoutV2, + applied: readonly JournalEntry[], + redo: readonly JournalEntry[], +): WorkerJournal => + deepFreeze({ + baseline: cloneJson(baseline), + applied: [...applied], + redo: [...redo], + values: applied.reduce((n, x) => n + x.metrics.values, 0), + stringCodeUnits: applied.reduce((n, x) => n + x.metrics.stringCodeUnits, 0), + atomicCommands: applied.reduce((n, x) => n + x.atomicCommands, 0), + depth: applied.reduce((n, x) => Math.max(n, x.metrics.depth), 0), + }); +export const checkpointJournal = (baseline: GraphLayoutV2): WorkerJournal => + fold(baseline, [], []); +export const importJournal = ( + data: CompatibleFxNodeSaveData | FxNodeSaveData, +): WorkerJournal => + fold( + data.baseline, + data.commands.map((command) => metrics(command) as JournalEntry), + [], + ); +export const journalSaveData = ( + journal: WorkerJournal, + composition: C, +): FxNodeSaveData => + cloneJson({ + kind: "fxnode.command-log", + schemaVersion: 2, + composition, + baseline: journal.baseline, + commands: journal.applied.map((entry) => entry.command), + } as FxNodeSaveData); +export function advanceJournal( + journal: WorkerJournal, + command: Command, + candidateBaseline: GraphLayoutV2, + strictReplayValid: (baseline: GraphLayoutV2, commands: readonly FxNodeReplayCommand[]) => boolean, +): WorkerJournal { + let applied = journal.applied, + redo = journal.redo; + if (command.type === "undo") { + const entry = applied.at(-1); + if (!entry) return checkpointJournal(candidateBaseline); + applied = applied.slice(0, -1); + redo = [...redo, entry]; + } else if (command.type === "redo") { + const entry = redo.at(-1); + if (!entry) return checkpointJournal(candidateBaseline); + applied = [...applied, entry]; + redo = redo.slice(0, -1); + } else { + const entry = metrics(command); + if (!entry) return checkpointJournal(candidateBaseline); + applied = [...applied, entry as JournalEntry]; + redo = []; + } + const next = fold(journal.baseline, applied, redo), + over = + next.applied.length > FXNODE_SAVE_DATA_LIMITS.maxCommands || + next.atomicCommands > FXNODE_SAVE_DATA_LIMITS.maxAtomicCommands || + next.values > FXNODE_SAVE_DATA_LIMITS.maxValues || + next.stringCodeUnits > FXNODE_SAVE_DATA_LIMITS.maxStringCodeUnits || + next.depth > FXNODE_SAVE_DATA_LIMITS.maxDepth; + return over || + !strictReplayValid( + next.baseline, + next.applied.map((entry) => entry.command), + ) + ? checkpointJournal(candidateBaseline) + : next; +} diff --git a/vendor/fxnode/src/worker/knife-path.ts b/vendor/fxnode/src/worker/knife-path.ts new file mode 100644 index 0000000..52f78b4 --- /dev/null +++ b/vendor/fxnode/src/worker/knife-path.ts @@ -0,0 +1,42 @@ +import type { LinkId, Vec2 } from "../core/types.js"; +import { worldToView } from "../layout/geometry.js"; +import type { LayoutSnapshot } from "../layout/types.js"; + +export const MAX_KNIFE_POINTS = 256; +export function appendKnifePoint(points: readonly Vec2[], point: Vec2, minimumDistance = 2): readonly Vec2[] { + const last = points.at(-1); + if (last && Math.hypot(last.x - point.x, last.y - point.y) < minimumDistance) return points; + return points.length < MAX_KNIFE_POINTS ? [...points, point] : [...points.slice(1), point]; +} +const orient = (a: Vec2, b: Vec2, c: Vec2) => (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); +const between = (a: number, b: number, x: number) => x >= Math.min(a, b) - 1e-7 && x <= Math.max(a, b) + 1e-7; +export function segmentsIntersect(a: Vec2, b: Vec2, c: Vec2, d: Vec2): boolean { + const abC = orient(a, b, c), + abD = orient(a, b, d), + cdA = orient(c, d, a), + cdB = orient(c, d, b); + if (((abC > 0 && abD < 0) || (abC < 0 && abD > 0)) && ((cdA > 0 && cdB < 0) || (cdA < 0 && cdB > 0))) return true; + return ( + (Math.abs(abC) < 1e-7 && between(a.x, b.x, c.x) && between(a.y, b.y, c.y)) || + (Math.abs(abD) < 1e-7 && between(a.x, b.x, d.x) && between(a.y, b.y, d.y)) || + (Math.abs(cdA) < 1e-7 && between(c.x, d.x, a.x) && between(c.y, d.y, a.y)) || + (Math.abs(cdB) < 1e-7 && between(c.x, d.x, b.x) && between(c.y, d.y, b.y)) + ); +} +export function crossedLinks(layout: LayoutSnapshot, path: readonly Vec2[], includeMuted = false): Set { + const result = new Set(); + if (path.length < 2) return result; + const planned = layout as LayoutSnapshot & { candidateLinkIds?: readonly LinkId[] }; + for (const id of planned.candidateLinkIds ?? layout.links.keys()) { + const link = layout.links.get(id); + if (!link?.visible || (!includeMuted && link.muted)) continue; + const samples = link.points.map((p) => worldToView(p, layout.transform)); + outer: for (let i = 1; i < path.length; i++) + for (let j = 1; j < samples.length; j++) + if (segmentsIntersect(path[i - 1]!, path[i]!, samples[j - 1]!, samples[j]!)) { + result.add(id); + break outer; + } + } + return result; +} diff --git a/vendor/fxnode/src/worker/render-scheduler.ts b/vendor/fxnode/src/worker/render-scheduler.ts new file mode 100644 index 0000000..c79f6e7 --- /dev/null +++ b/vendor/fxnode/src/worker/render-scheduler.ts @@ -0,0 +1,92 @@ +export const enum DirtyReason { + Scene = 1, + Camera = 2, + Selection = 4, + Preview = 8, + Viewport = 16, + Barrier = 32, + HostInteraction = 64, +} +export interface SchedulerMetrics { + requests: number; + coalesced: number; + frames: number; + maxInFlight: number; + staleAcks: number; +} +type InvalidationTarget = { readonly scheduler: Pick }; + +/** Worker-internal bridge from atlas invalidations to repaint requests. */ +export function requestViewInvalidations( + viewIds: readonly string[], + views: ReadonlyMap, +): void { + for (const viewId of viewIds) views.get(viewId)?.scheduler.request(0, DirtyReason.Viewport); +} + +export class RenderScheduler { + private dirty = 0; + private inFlight: number | undefined; + private scheduled = false; + private running = false; + private latestRenderId = 0; + private nextFrameId = 1; + private poll = () => {}; + readonly metrics: SchedulerMetrics = { requests: 0, coalesced: 0, frames: 0, maxInFlight: 0, staleAcks: 0 }; + constructor( + private readonly draw: (frameId: number, renderId: number, reasons: number) => void, + private readonly enqueue: (callback: () => void) => void = (callback) => { + const raf = (globalThis as { requestAnimationFrame?: (cb: () => void) => void }).requestAnimationFrame; + raf ? raf(callback) : setTimeout(callback, 16); + }, + ) {} + start(poll: () => void = () => {}): void { + if (this.running) return; + this.running = true; + this.poll = poll; + this.schedule(); + } + stop(): void { + this.running = false; + } + request(renderId = this.latestRenderId, reason: DirtyReason = DirtyReason.Scene): void { + this.metrics.requests++; + this.latestRenderId = Math.max(this.latestRenderId, renderId); + if (this.dirty || this.inFlight !== undefined) this.metrics.coalesced++; + this.dirty |= reason; + } + consumed(frameId: number): void { + if (frameId !== this.inFlight) { + this.metrics.staleAcks++; + return; + } + this.inFlight = undefined; + } + defer(frameId: number, reasons: number): void { + if (frameId !== this.inFlight) return; + this.inFlight = undefined; + this.dirty |= reasons; + } + private schedule(): void { + if (!this.running || this.scheduled) return; + this.scheduled = true; + this.enqueue(() => { + this.scheduled = false; + if (!this.running) return; + try { + this.poll(); + if (this.dirty && this.inFlight === undefined) { + const reasons = this.dirty; + this.dirty = 0; + const frameId = this.nextFrameId++; + this.inFlight = frameId; + this.metrics.frames++; + this.metrics.maxInFlight = Math.max(this.metrics.maxInFlight, 1); + this.draw(frameId, this.latestRenderId, reasons); + } + } finally { + this.schedule(); + } + }); + } +} diff --git a/vendor/fxnode/src/worker/selection-actions.ts b/vendor/fxnode/src/worker/selection-actions.ts new file mode 100644 index 0000000..523d0c4 --- /dev/null +++ b/vendor/fxnode/src/worker/selection-actions.ts @@ -0,0 +1,38 @@ +import type { BatchCommand, Command } from "../commands/types.js"; +import type { GraphDocument, LinkId, NodeId } from "../core/types.js"; + +/** Deterministic removal: selected links not incident to selected nodes, then nodes. */ +export function planSelectionRemoval( + document: GraphDocument, + selectedNodes: ReadonlySet, + selectedLinks: ReadonlySet, +): Command | null { + const nodes = [...selectedNodes].filter((id) => document.nodes[id]).sort(); + const nodeSet = new Set(nodes); + const links = [...selectedLinks] + .filter((id) => { + const link = document.links[id]; + return !!link && !nodeSet.has(link.fromNodeId) && !nodeSet.has(link.toNodeId); + }) + .sort(); + const commands: BatchCommand[] = [ + ...links.map((id) => ({ type: "link.remove" as const, id })), + ...nodes.map((id) => ({ type: "node.remove" as const, id })), + ]; + return { type: "batch", commands }; +} + +/** Only known standard nodes can be muted. Omitted desired state toggles uniformly (mixed => mute all). */ +export function planSelectionMute( + document: GraphDocument, + selectedNodes: ReadonlySet, + isStandard: (id: NodeId) => boolean, + desired?: boolean, +): Command | null { + const nodes = [...selectedNodes].filter((id) => document.nodes[id]?.known && isStandard(id)).sort(); + const value = desired ?? nodes.some((id) => !document.nodes[id]!.muted); + const commands: BatchCommand[] = nodes + .filter((id) => document.nodes[id]!.muted !== value) + .map((id) => ({ type: "node.mute", id, value })); + return { type: "batch", commands }; +} diff --git a/vendor/fxnode/src/worker/session.ts b/vendor/fxnode/src/worker/session.ts new file mode 100644 index 0000000..97b2021 --- /dev/null +++ b/vendor/fxnode/src/worker/session.ts @@ -0,0 +1,151 @@ +import type { LinkId, NodeId, ParameterValue, Vec2 } from "../core/types.js"; +import type { ColorRamp } from "../widgets/color-ramp.js"; +import type { Oklch, Rgba } from "../color/oklab.js"; +import type { ColorPickerLayout } from "../layout/types.js"; +export interface DragSession { + readonly pointerId: number; + readonly startView: Vec2; + readonly startWorld: Vec2; + readonly origins: ReadonlyMap; + moved: boolean; +} +export interface CollapseAnimation { + from: number; + to: 0 | 1; + value: number; + startedAt: number; + durationMs: number; +} +export type ControlEdit = + | { kind: "string"; controlId: string; buffer: string } + | { kind: "number"; controlId: string; component: number; buffer: string; selectAll: boolean }; +export interface WorkerSession { + knife?: { pointerId: number; points: readonly Vec2[]; crossed: Set; mode: "remove" | "mute" }; +} +export interface WorkerSession { + colorPicker?: { + layout: ColorPickerLayout; + controlId: string; + target: { kind: "control" } | { kind: "ramp-stop"; stopId: string; original: ColorRamp }; + model: Oklch; + rgba: Rgba; + hsv: readonly [number, number, number]; + edit?: { field: "rgba" | "hsv" | "hex"; index: number; buffer: string; selectAll: boolean; invalid: boolean }; + drag?: { pointerId: number; region: "plane" | "lightness" | "alpha" }; + }; +} +export interface WorkerSession { + colorWheel?: { + controlId: string; + original: ParameterValue; + model: Oklch; + rgba: Rgba; + pointerId: number; + region: "plane" | "lightness"; + bounds: { x: number; y: number; width: number; height: number }; + }; +} +export interface WorkerSession { + cameraCenter: Vec2; + zoom: number; + selectedNodes: Set; + selectedLinks: Set; + activeNode?: NodeId; + hoverNode?: NodeId; + hoveredControl?: string; + focusedControl?: string; + hoveredRampTarget?: string; + focusedRampTarget?: string; + activeRampStopByControl: Map; + collapseAnimations: Map; + controlEdit?: ControlEdit; + previewValues: Map; + scrub?: { + pointerId: number; + controlId: string; + component: number; + startX: number; + original: ParameterValue; + moved: boolean; + }; + rampDrag?: { pointerId: number; controlId: string; stopId: string; original: ParameterValue }; + reroutePress?: { pointerId: number; nodeId: NodeId; socketId: import("../core/types.js").SocketId; start: Vec2 }; + uiOrder: NodeId[]; + previewPositions: Map; + previewSizes: Map; + pointer?: Vec2; + drag?: DragSession; + modalMove?: Omit; + box?: { pointerId: number; start: Vec2; current: Vec2; checkpoint: Set; add: boolean }; + linkDrag?: { + pointerId: number; + from: import("../core/types.js").SocketId; + current: Vec2; + candidate?: import("../core/types.js").SocketId; + }; + resize?: { pointerId: number; id: NodeId }; + parentHighlight?: NodeId; + pan?: { pointerId: number; last: Vec2 }; +} +export const createSession = ( + camera: { readonly center: Vec2; readonly zoom: number } = { + center: { x: 0, y: 0 }, + zoom: 1, + }, +): WorkerSession => ({ + cameraCenter: { ...camera.center }, + zoom: camera.zoom, + selectedNodes: new Set(), + selectedLinks: new Set(), + activeRampStopByControl: new Map(), + collapseAnimations: new Map(), + uiOrder: [], + previewPositions: new Map(), + previewSizes: new Map(), + previewValues: new Map(), +}); +function resetDocumentTransients(session: WorkerSession): void { + delete session.hoverNode; + delete session.hoveredControl; + delete session.focusedControl; + delete session.hoveredRampTarget; + delete session.focusedRampTarget; + delete session.knife; + delete session.drag; + delete session.scrub; + delete session.rampDrag; + delete session.reroutePress; + delete session.modalMove; + delete session.box; + delete session.linkDrag; + delete session.resize; + delete session.parentHighlight; + delete session.pan; + delete session.controlEdit; + delete session.colorPicker; + delete session.colorWheel; + session.activeRampStopByControl.clear(); + session.collapseAnimations.clear(); + session.previewValues.clear(); + session.previewPositions.clear(); + session.previewSizes.clear(); +} +export function resetSessionForGraphReplacement(session: WorkerSession): void { + session.selectedNodes.clear(); + session.selectedLinks.clear(); + session.uiOrder = []; + delete session.activeNode; + resetDocumentTransients(session); +} +export function resetSessionForCompositionRebind( + session: WorkerSession, + nodeIds: ReadonlySet, + linkIds: ReadonlySet, + retainedUiOrder: readonly NodeId[], +): void { + session.selectedNodes = new Set([...session.selectedNodes].filter((id) => nodeIds.has(id))); + session.selectedLinks = new Set([...session.selectedLinks].filter((id) => linkIds.has(id))); + session.uiOrder = [...retainedUiOrder]; + if (session.activeNode && !nodeIds.has(session.activeNode)) delete session.activeNode; + resetDocumentTransients(session); +} diff --git a/vendor/fxnode/src/worker/view-atlas.ts b/vendor/fxnode/src/worker/view-atlas.ts new file mode 100644 index 0000000..6b60e0a --- /dev/null +++ b/vendor/fxnode/src/worker/view-atlas.ts @@ -0,0 +1,428 @@ +import { + planAtlasCompaction, + planAtlasUpsert, + removeAtlasItem, + type AtlasLayout, + type AtlasRect, + type AtlasSize, +} from "./atlas-allocator.js"; + +export type AtlasErrorCode = + | "atlas.dimension" + | "atlas.capacity" + | "atlas.create" + | "atlas.context" + | "atlas.crop" + | "atlas.context-lost"; +export class ViewAtlasError extends Error { + override readonly name = "ViewAtlasError"; + constructor( + message: string, + readonly code: AtlasErrorCode, + readonly fatal = false, + options?: ErrorOptions, + ) { + super(message, options); + } +} +export interface ViewAtlasSlot extends AtlasRect { + readonly viewId: string; + readonly slotGeneration: number; +} +export interface ViewAtlasSurface { + readonly canvas: OffscreenCanvas; + readonly context: OffscreenCanvasRenderingContext2D; + readonly width: number; + readonly height: number; + readonly atlasGeneration: number; + readonly allocationEpoch: number; +} +export interface ViewAtlasMutation { + readonly slot: ViewAtlasSlot; + readonly movedViewIds: readonly string[]; + readonly invalidatedViewIds: readonly string[]; + readonly atlasGeneration: number; + readonly allocationEpoch: number; +} +export interface ViewAtlasDetachResult { + readonly invalidatedViewIds: readonly string[]; +} +export interface ViewAtlasPaintTarget { + readonly context: OffscreenCanvasRenderingContext2D; + readonly deviceX: number; + readonly deviceY: number; + readonly deviceWidth: number; + readonly deviceHeight: number; +} +export interface ViewAtlasPlatform { + createCanvas(width: number, height: number): OffscreenCanvas; + createBitmap(canvas: OffscreenCanvas, x: number, y: number, width: number, height: number): Promise; +} +const browserPlatform: ViewAtlasPlatform = { + createCanvas: (width, height) => new OffscreenCanvas(width, height), + createBitmap: (canvas, x, y, width, height) => createImageBitmap(canvas, x, y, width, height), +}; + +export class ViewAtlasManager { + private layout: AtlasLayout | undefined; + private canvas: OffscreenCanvas | undefined; + private context: OffscreenCanvasRenderingContext2D | undefined; + private atlasGeneration = 0; + private allocationEpoch = 0; + private readonly slotGenerations = new Map(); + private queue: Promise = Promise.resolve(); + private disposed = false; + private lifecycle = 0; + private lost = false; + private surfaceTransition = false; + private terminalError: ViewAtlasError | undefined; + private contextLossEpoch = 0; + private readonly contextLost = (event: Event) => { + if ("preventDefault" in event) event.preventDefault(); + this.contextLossEpoch++; + this.lost = true; + }; + private readonly contextRestored = (event: Event) => { + const canvas = this.canvas; + if (!canvas || event.currentTarget !== canvas) return; + const lifecycle = this.lifecycle, + lossEpoch = this.contextLossEpoch; + void this.serial(async () => { + if (this.canvas !== canvas || lossEpoch !== this.contextLossEpoch) return; + const context = this.getContext(canvas); + await this.probe(canvas); + this.ensureLifecycle(lifecycle); + if (this.canvas !== canvas || lossEpoch !== this.contextLossEpoch) return; + this.context = context; + this.lost = false; + this.atlasGeneration++; + this.allocationEpoch++; + for (const id of this.layout?.items.keys() ?? []) this.bumpSlot(id); + }).catch((cause) => { + if ( + this.disposed || + lifecycle !== this.lifecycle || + this.canvas !== canvas || + lossEpoch !== this.contextLossEpoch + ) + return; + const error = new ViewAtlasError("Unable to restore the view atlas", "atlas.context-lost", true, { cause }); + this.terminalError = error; + this.onFatal(error); + }); + }; + constructor( + private readonly platform: ViewAtlasPlatform = browserPlatform, + private readonly onFatal: (error: ViewAtlasError) => void = () => {}, + ) {} + + surface(): ViewAtlasSurface | undefined { + if (!this.canvas || !this.context || this.lost || this.surfaceTransition || this.terminalError) return; + return Object.freeze({ + canvas: this.canvas, + context: this.context, + width: this.canvas.width, + height: this.canvas.height, + atlasGeneration: this.atlasGeneration, + allocationEpoch: this.allocationEpoch, + }); + } + slot(viewId: string): ViewAtlasSlot | undefined { + const region = this.layout?.regions.get(viewId), + size = this.layout?.items.get(viewId), + generation = this.slotGenerations.get(viewId); + if (!region || !size || generation === undefined) return; + return Object.freeze({ + viewId, + x: region.x, + y: region.y, + width: size.width, + height: size.height, + slotGeneration: generation, + }); + } + attach(viewId: string, size: AtlasSize): Promise { + return this.mutate(viewId, size, false); + } + resize(viewId: string, size: AtlasSize): Promise { + return this.mutate(viewId, size, true); + } + renderAndCrop( + viewId: string, + expected: AtlasSize, + paint: (target: ViewAtlasPaintTarget) => void, + ): Promise { + return this.serial(async () => { + const canvas = this.canvas, + context = this.context, + slot = this.slot(viewId), + lifecycle = this.lifecycle, + atlasGeneration = this.atlasGeneration, + lossEpoch = this.contextLossEpoch; + if ( + !canvas || + !context || + !slot || + this.lost || + this.surfaceTransition || + slot.width !== expected.width || + slot.height !== expected.height + ) + return; + context.save(); + try { + context.setTransform(1, 0, 0, 1, 0, 0); + context.beginPath(); + context.rect(slot.x, slot.y, slot.width, slot.height); + context.clip(); + context.clearRect(slot.x, slot.y, slot.width, slot.height); + paint({ + context, + deviceX: slot.x, + deviceY: slot.y, + deviceWidth: slot.width, + deviceHeight: slot.height, + }); + } finally { + context.restore(); + } + const bitmap = await this.platform.createBitmap(canvas, slot.x, slot.y, slot.width, slot.height); + const current = this.slot(viewId); + if ( + this.disposed || + lifecycle !== this.lifecycle || + this.canvas !== canvas || + this.context !== context || + this.lost || + lossEpoch !== this.contextLossEpoch || + atlasGeneration !== this.atlasGeneration || + !current || + current.slotGeneration !== slot.slotGeneration || + bitmap.width !== expected.width || + bitmap.height !== expected.height + ) { + bitmap.close(); + return; + } + return bitmap; + }); + } + detach(viewId: string): Promise { + return this.serial(async () => { + const lifecycle = this.lifecycle; + if (!this.layout?.items.has(viewId)) return Object.freeze({ invalidatedViewIds: Object.freeze([]) }); + const previous = this.layout, + next = removeAtlasItem(previous, viewId); + this.slotGenerations.delete(viewId); + this.allocationEpoch++; + if (!next) { + this.unregisterContextEvents(this.canvas); + this.layout = this.canvas = this.context = undefined; + this.lost = false; + this.lifecycle++; + this.atlasGeneration++; + return Object.freeze({ invalidatedViewIds: Object.freeze([]) }); + } + this.layout = next; + const compact = planAtlasCompaction(next); + if (compact?.ok && (compact.layout.width !== next.width || compact.layout.height !== next.height)) + try { + this.surfaceTransition = true; + await this.resizeSurface(compact.layout.width, compact.layout.height); + this.layout = compact.layout; + for (const id of compact.layout.items.keys()) this.bumpSlot(id); + this.ensureLifecycle(lifecycle); + return Object.freeze({ + invalidatedViewIds: Object.freeze([...compact.layout.items.keys()].sort()), + }); + } catch (error) { + this.ensureLifecycle(lifecycle); + this.layout = next; + if (error instanceof ViewAtlasError && error.fatal) throw error; + for (const id of next.items.keys()) this.bumpSlot(id); + return Object.freeze({ invalidatedViewIds: Object.freeze([...next.items.keys()].sort()) }); + } finally { + this.surfaceTransition = false; + } + this.ensureLifecycle(lifecycle); + return Object.freeze({ invalidatedViewIds: Object.freeze([]) }); + }); + } + dispose(): void { + this.disposed = true; + this.lifecycle++; + this.unregisterContextEvents(this.canvas); + this.layout = this.canvas = this.context = undefined; + this.slotGenerations.clear(); + this.allocationEpoch++; + this.atlasGeneration++; + } + private mutate(viewId: string, size: AtlasSize, mustExist: boolean): Promise { + return this.serial(async () => { + const lifecycle = this.lifecycle; + if (mustExist && !this.layout?.items.has(viewId)) + throw new ViewAtlasError("View is not allocated in the atlas", "atlas.capacity"); + if (!mustExist && this.layout?.items.has(viewId)) + throw new ViewAtlasError("View is already allocated in the atlas", "atlas.capacity"); + const previous = this.layout, + plan = planAtlasUpsert(previous, { id: viewId, ...size }); + if (!plan.ok) + throw new ViewAtlasError( + plan.code === "atlas.dimension" ? "View dimensions exceed atlas limits" : "View atlas capacity exceeded", + plan.code, + ); + const resized = + !this.canvas || this.canvas.width !== plan.layout.width || this.canvas.height !== plan.layout.height; + if (resized) this.surfaceTransition = true; + try { + if (resized) await this.resizeSurface(plan.layout.width, plan.layout.height); + this.ensureLifecycle(lifecycle); + this.layout = plan.layout; + this.allocationEpoch++; + const changed = new Set(plan.movedIds); + if ( + !previous || + previous.items.get(viewId)?.width !== size.width || + previous.items.get(viewId)?.height !== size.height + ) + changed.add(viewId); + if (resized) for (const id of plan.layout.items.keys()) changed.add(id); + for (const id of changed) this.bumpSlot(id); + const slot = this.slot(viewId)!; + return Object.freeze({ + slot, + movedViewIds: Object.freeze(plan.movedIds.slice()), + invalidatedViewIds: Object.freeze([...changed].sort()), + atlasGeneration: this.atlasGeneration, + allocationEpoch: this.allocationEpoch, + }); + } finally { + if (resized) this.surfaceTransition = false; + } + }); + } + private bumpSlot(viewId: string): void { + this.slotGenerations.set(viewId, (this.slotGenerations.get(viewId) ?? 0) + 1); + } + private async resizeSurface(width: number, height: number): Promise { + const lifecycle = this.lifecycle; + if (!this.canvas) { + let canvas: OffscreenCanvas; + try { + canvas = this.platform.createCanvas(width, height); + } catch (cause) { + throw new ViewAtlasError("Unable to create the view atlas", "atlas.create", false, { cause }); + } + if (canvas.width !== width || canvas.height !== height) + throw new ViewAtlasError("Atlas dimensions were not accepted", "atlas.create"); + this.registerContextEvents(canvas); + const lossEpoch = this.contextLossEpoch; + try { + const context = this.getContext(canvas); + await this.probe(canvas); + this.ensureLifecycle(lifecycle); + if (lossEpoch !== this.contextLossEpoch || this.lost) + throw new ViewAtlasError("View atlas context was lost during creation", "atlas.context-lost"); + this.canvas = canvas; + this.context = context; + this.atlasGeneration++; + return; + } catch (error) { + this.unregisterContextEvents(canvas); + this.lost = false; + throw error; + } + } + const canvas = this.canvas, + oldWidth = canvas.width, + oldHeight = canvas.height, + lossEpoch = this.contextLossEpoch; + try { + try { + canvas.width = width; + canvas.height = height; + } catch (cause) { + throw new ViewAtlasError("Unable to resize the view atlas", "atlas.create", false, { cause }); + } + if (canvas.width !== width || canvas.height !== height) + throw new ViewAtlasError("Atlas dimensions were not accepted", "atlas.create"); + const context = this.getContext(canvas); + await this.probe(canvas); + this.ensureLifecycle(lifecycle); + if (lossEpoch !== this.contextLossEpoch || this.lost) + throw new ViewAtlasError("View atlas context was lost while resizing", "atlas.context-lost"); + this.context = context; + this.atlasGeneration++; + } catch (cause) { + if (this.disposed || lifecycle !== this.lifecycle) throw cause; + try { + canvas.width = oldWidth; + canvas.height = oldHeight; + if (canvas.width !== oldWidth || canvas.height !== oldHeight) + throw new ViewAtlasError("Atlas dimensions could not be restored", "atlas.context-lost", true); + this.context = this.getContext(canvas); + await this.probe(canvas); + this.ensureLifecycle(lifecycle); + this.atlasGeneration++; + // Resizing clears the canvas even when the old dimensions are restored. + // Make every surviving slot stale so callers cannot reuse its old pixels. + for (const id of this.layout?.items.keys() ?? []) this.bumpSlot(id); + } catch (restoreCause) { + this.unregisterContextEvents(canvas); + this.canvas = this.context = undefined; + throw new ViewAtlasError("Unable to restore the view atlas", "atlas.context-lost", true, { + cause: restoreCause, + }); + } + throw cause; + } + } + private getContext(canvas: OffscreenCanvas): OffscreenCanvasRenderingContext2D { + let context: OffscreenCanvasRenderingContext2D | null; + try { + context = canvas.getContext("2d"); + } catch (cause) { + throw new ViewAtlasError("Unable to create the atlas 2D context", "atlas.context", false, { cause }); + } + if (!context) throw new ViewAtlasError("Atlas 2D context is unavailable", "atlas.context"); + return context; + } + private async probe(canvas: OffscreenCanvas): Promise { + let bitmap: ImageBitmap; + try { + bitmap = await this.platform.createBitmap(canvas, 0, 0, 1, 1); + } catch (cause) { + throw new ViewAtlasError("Cropped atlas bitmaps are unavailable", "atlas.crop", false, { cause }); + } + try { + if (bitmap.width !== 1 || bitmap.height !== 1 || typeof bitmap.close !== "function") + throw new ViewAtlasError("Cropped atlas bitmap capability is invalid", "atlas.crop"); + } finally { + bitmap.close?.(); + } + } + private ensureLifecycle(lifecycle: number): void { + if (this.disposed || lifecycle !== this.lifecycle) + throw new ViewAtlasError("View atlas operation was cancelled", "atlas.context-lost", true); + } + private registerContextEvents(canvas: OffscreenCanvas): void { + canvas.addEventListener?.("contextlost", this.contextLost); + canvas.addEventListener?.("contextrestored", this.contextRestored); + } + private unregisterContextEvents(canvas: OffscreenCanvas | undefined): void { + canvas?.removeEventListener?.("contextlost", this.contextLost); + canvas?.removeEventListener?.("contextrestored", this.contextRestored); + } + private serial(operation: () => Promise): Promise { + const result = this.queue.then(() => { + if (this.terminalError) throw this.terminalError; + if (this.disposed) throw new ViewAtlasError("View atlas has been disposed", "atlas.context-lost", true); + return operation(); + }); + this.queue = result.then( + () => {}, + () => {}, + ); + return result; + } +} diff --git a/vendor/fxnode/test/application.ts b/vendor/fxnode/test/application.ts new file mode 100644 index 0000000..aea56bd --- /dev/null +++ b/vendor/fxnode/test/application.ts @@ -0,0 +1,88 @@ +/** Test-only headless authority assembled from the example's public node definitions. */ +import { compileFxNodeComposition, composeNode, composeSocket, setTheme } from "@lib/composition/index.js"; +import type { FxNodeCompositionData } from "@lib/composition/index.js"; +import { bindFxNodeHeadless } from "@lib/headless-runtime.js"; +import { + APPLICATION_HEADER_STYLES, + APPLICATION_ID, + APPLICATION_RESOURCES, + APPLICATION_VERSION, + applicationCompatibility, +} from "../examples/blender/nodes/application.js"; +import { exampleTheme } from "../examples/shared/theme.js"; +import { + anySocket, + floatSocket, + vectorSocket, + colorSocket, + shaderSocket, + geometrySocket, +} from "../examples/blender/nodes/socket-types.js"; +import { frameNode } from "../examples/blender/nodes/common/frame.js"; +import { rerouteNode } from "../examples/blender/nodes/common/reroute.js"; +import { groupInputNode } from "../examples/blender/nodes/common/group-input.js"; +import { groupOutputNode } from "../examples/blender/nodes/common/group-output.js"; +import { valueNode } from "../examples/blender/nodes/shader/value.js"; +import { colorNode } from "../examples/blender/nodes/shader/color.js"; +import { mathNode } from "../examples/blender/nodes/shader/math.js"; +import { vectorMathNode } from "../examples/blender/nodes/shader/vector-math.js"; +import { mixNode } from "../examples/blender/nodes/shader/mix.js"; +import { colorRampNode } from "../examples/blender/nodes/shader/color-ramp.js"; +import { textureCoordinateNode } from "../examples/blender/nodes/shader/texture-coordinate.js"; +import { noiseTextureNode } from "../examples/blender/nodes/shader/noise-texture.js"; +import { imageTextureNode } from "../examples/blender/nodes/shader/image-texture.js"; +import { principledBsdfNode } from "../examples/blender/nodes/shader/principled-bsdf.js"; +import { materialOutputNode } from "../examples/blender/nodes/shader/material-output.js"; +import { positionNode } from "../examples/blender/nodes/geometry/position.js"; +import { meshCubeNode } from "../examples/blender/nodes/geometry/mesh-cube.js"; +import { setPositionNode } from "../examples/blender/nodes/geometry/set-position.js"; +import { transformGeometryNode } from "../examples/blender/nodes/geometry/transform-geometry.js"; +import { joinGeometryNode } from "../examples/blender/nodes/geometry/join-geometry.js"; +import { imageNode } from "../examples/blender/nodes/compositor/image.js"; +import { colorBalanceNode } from "../examples/shared/nodes/color-balance.js"; + +const seed = { + schemaVersion: 2, + id: APPLICATION_ID, + version: APPLICATION_VERSION, + nodeStyles: APPLICATION_HEADER_STYLES, + resources: APPLICATION_RESOURCES, + compatibility: applicationCompatibility, + socketTypes: {}, + nodes: {}, +} as const; +const themed = setTheme(seed, exampleTheme); +const socket1 = composeSocket(themed, ...anySocket); +const socket2 = composeSocket(socket1, ...floatSocket); +const socket3 = composeSocket(socket2, ...vectorSocket); +const socket4 = composeSocket(socket3, ...colorSocket); +const socket5 = composeSocket(socket4, ...shaderSocket); +const socket6 = composeSocket(socket5, ...geometrySocket); +const node1 = composeNode(socket6, ...frameNode); +const node2 = composeNode(node1, ...rerouteNode); +const node3 = composeNode(node2, ...groupInputNode); +const node4 = composeNode(node3, ...groupOutputNode); +const node5 = composeNode(node4, ...valueNode); +const node6 = composeNode(node5, ...colorNode); +const node7 = composeNode(node6, ...mathNode); +const node8 = composeNode(node7, ...vectorMathNode); +const node9 = composeNode(node8, ...mixNode); +const firstBranch = composeNode(node9, ...colorRampNode); +const node11 = composeNode(socket6, ...textureCoordinateNode); +const node12 = composeNode(node11, ...noiseTextureNode); +const node13 = composeNode(node12, ...imageTextureNode); +const node14 = composeNode(node13, ...principledBsdfNode); +const node15 = composeNode(node14, ...materialOutputNode); +const secondBranch = composeNode(node15, ...positionNode); +const node17 = composeNode(socket6, ...meshCubeNode); +const node18 = composeNode(node17, ...setPositionNode); +const node19 = composeNode(node18, ...transformGeometryNode); +const node20 = composeNode(node19, ...joinGeometryNode); +const node21 = composeNode(node20, ...imageNode); +const thirdBranch = composeNode(node21, ...colorBalanceNode); +const source: FxNodeCompositionData = { + ...firstBranch, + nodes: { ...firstBranch.nodes, ...secondBranch.nodes, ...thirdBranch.nodes }, +}; +export const APPLICATION_COMPILED = compileFxNodeComposition(source); +export const APPLICATION_HEADLESS = bindFxNodeHeadless(APPLICATION_COMPILED); diff --git a/vendor/fxnode/test/atlas-allocator.test.ts b/vendor/fxnode/test/atlas-allocator.test.ts new file mode 100644 index 0000000..b3185f8 --- /dev/null +++ b/vendor/fxnode/test/atlas-allocator.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + planAtlasCompaction, + planAtlasUpsert, + removeAtlasItem, + type AtlasLayout, +} from "@lib/worker/atlas-allocator.js"; + +const add = (layout: AtlasLayout | undefined, id: string, width: number, height: number) => { + const plan = planAtlasUpsert(layout, { id, width, height }); + assert.equal(plan.ok, true); + return plan.ok ? plan.layout : undefined!; +}; +const overlaps = (a: { x: number; y: number; width: number; height: number }, b: typeof a) => + a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y; +function valid(layout: AtlasLayout): void { + const regions = [...layout.regions.values()], + free = [...layout.free]; + regions.forEach((region, index) => { + assert(region.x >= 0 && region.y >= 0); + assert(region.x + region.width <= layout.width); + assert(region.y + region.height <= layout.height); + regions.slice(index + 1).forEach((other) => assert.equal(overlaps(region, other), false)); + free.forEach((other) => assert.equal(overlaps(region, other), false)); + }); + free.forEach((region, index) => { + assert(region.x >= 0 && region.y >= 0); + assert(region.x + region.width <= layout.width); + assert(region.y + region.height <= layout.height); + free.slice(index + 1).forEach((other) => assert.equal(overlaps(region, other), false)); + }); + const covered = [...regions, ...free].reduce((sum, region) => sum + region.width * region.height, 0); + assert.equal(covered, layout.width * layout.height); +} + +test("atlas allocation is deterministic, bounded, and preserves shrink capacity", () => { + const first = add(undefined, "a", 100, 80); + assert.deepEqual( + { width: first.width, height: first.height, region: first.regions.get("a") }, + { + width: 256, + height: 256, + region: { x: 0, y: 0, width: 100, height: 80 }, + }, + ); + const second = add(first, "b", 90, 70), + replay = add(add(undefined, "a", 100, 80), "b", 90, 70); + assert.deepEqual([...second.regions], [...replay.regions]); + const shrunk = add(second, "a", 40, 30); + assert.deepEqual(shrunk.regions.get("a"), second.regions.get("a")); + assert.deepEqual(shrunk.items.get("a"), { width: 40, height: 30 }); + valid(shrunk); +}); + +test("atlas relocation, removal, growth, and capacity failure remain atomic", () => { + let layout = add(undefined, "a", 200, 200); + layout = add(layout, "b", 200, 200); + const before = [...layout.regions]; + const oversized = planAtlasUpsert(layout, { id: "bad", width: 8193, height: 1 }); + assert.deepEqual(oversized, { ok: false, code: "atlas.dimension" }); + assert.deepEqual([...layout.regions], before); + const grown = add(layout, "a", 500, 300); + assert(grown.width * grown.height >= layout.width * layout.height); + valid(grown); + const removed = removeAtlasItem(grown, "b")!; + assert.equal(removed.items.has("b"), false); + valid(removed); + assert.equal(removeAtlasItem(removed, "a"), undefined); +}); + +test("atlas compaction is deferred unless occupancy and savings justify it", () => { + let layout = add(undefined, "large", 1000, 1000); + layout = add(layout, "small", 100, 100); + layout = removeAtlasItem(layout, "large")!; + const compact = planAtlasCompaction(layout); + assert(compact?.ok); + if (compact?.ok) { + assert(compact.layout.width * compact.layout.height <= (layout.width * layout.height) / 2); + valid(compact.layout); + } +}); diff --git a/vendor/fxnode/test/browser-client-boundary.test.ts b/vendor/fxnode/test/browser-client-boundary.test.ts new file mode 100644 index 0000000..d0152de --- /dev/null +++ b/vendor/fxnode/test/browser-client-boundary.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import test from "node:test"; +import ts from "typescript"; + +test("browser client has no DOM lifecycle ownership", () => { + const path = new URL("../src/browser/client.ts", import.meta.url), + text = readFileSync(path, "utf8"), + source = ts.createSourceFile(path.pathname, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const forbidden = new Set([ + "document", + "window", + "ResizeObserver", + "MutationObserver", + "isConnected", + "addEventListener", + "removeEventListener", + "createElement", + "getBoundingClientRect", + "focus", + "setPointerCapture", + "releasePointerCapture", + "hasPointerCapture", + "tabIndex", + "touchAction", + "clientWidth", + "clientHeight", + ]), + found = new Set(); + const visit = (node: ts.Node): void => { + if (ts.isIdentifier(node) && forbidden.has(node.text)) found.add(node.text); + ts.forEachChild(node, visit); + }; + visit(source); + assert.deepEqual([...found], []); + assert(!text.includes('from "./add-node-menu.js"')); + assert(!/\b(?:this\.)?canvas\.(?:width|height)\s*=/.test(text)); + assert(!existsSync(new URL("../src/browser/add-node-menu.ts", import.meta.url))); + const publicIndex = readFileSync(new URL("../src/index.ts", import.meta.url), "utf8"); + assert(publicIndex.includes("FxNodeResourceOpenRequest")); + assert(!publicIndex.includes("FxNodeResourceOpenTarget")); + assert(!publicIndex.includes("FxNodeResourceHitRegion")); +}); diff --git a/vendor/fxnode/test/browser/add-node-menu.spec.ts b/vendor/fxnode/test/browser/add-node-menu.spec.ts new file mode 100644 index 0000000..97110b6 --- /dev/null +++ b/vendor/fxnode/test/browser/add-node-menu.spec.ts @@ -0,0 +1,141 @@ +import { expect, test } from "@playwright/test"; + +test("right-click DOM menu searches and adds through one worker gesture", async ({ page }) => { + await page.goto("/test/browser/index.html"); + await page.evaluate(() => window.ready); + await page.evaluate(() => { + const events: { + mutations: Array<{ version: number; cause: string; mutations: readonly unknown[] }>; + snapshots: number[]; + requests: Array<{ frozen: boolean; revision: number; position: { x: number; y: number } }>; + } = { mutations: [], snapshots: [], requests: [] }; + (window as typeof window & { menuEvents: typeof events }).menuEvents = events; + window.root.onMutations((event) => events.mutations.push(event)); + window.root.onSnapshots((event) => events.snapshots.push(event.version)); + window.view.onHostRequests((request) => { + if (request.kind === "add-node-menu") + events.requests.push({ + frozen: Object.isFrozen(request) && Object.isFrozen(request.viewPosition), + revision: request.compositionRevision, + position: { ...request.viewPosition }, + }); + }); + }); + const canvas = page.locator("#primary"); + await canvas.click({ button: "right", position: { x: 40, y: 50 } }); + const dialog = page.getByRole("dialog", { name: "Add node" }); + await expect(dialog).toBeVisible(); + expect( + await dialog.getByRole("group").evaluateAll((groups) => + groups.map((group) => ({ + name: group.getAttribute("aria-label"), + options: [...group.querySelectorAll('[role="option"]')].map((item) => item.textContent?.trim()), + })), + ), + ).toEqual([ + { name: "Common", options: ["Frame", "Reroute", "Group Input", "Group Output"] }, + { + name: "Shader", + options: [ + "Value", + "Color", + "Math", + "Vector Math", + "Mix", + "Color Ramp", + "Texture Coordinate", + "Noise Texture", + "Image Texture", + "Principled BSDF", + "Material Output", + ], + }, + { name: "Geometry", options: ["Position", "Mesh Cube", "Set Position", "Transform Geometry", "Join Geometry"] }, + { name: "Compositor", options: ["Image", "Color Balance"] }, + ]); + const search = page.getByRole("combobox", { name: "Search nodes" }); + await search.fill(" ShAdEr "); + await expect(dialog.getByRole("option")).toHaveText([ + "Value", + "Color", + "Math", + "Vector Math", + "Mix", + "Color Ramp", + "Texture Coordinate", + "Noise Texture", + "Image Texture", + "Principled BSDF", + "Material Output", + ]); + await search.fill("noise-texture"); + await expect(dialog.getByRole("option")).toHaveText(["Noise Texture"]); + await search.press("Enter"); + await expect(dialog).toHaveCount(0); + await expect + .poll(() => page.evaluate(() => window.root.getState().then((snapshot) => snapshot.nodes.length))) + .toBe(1); + const result = await page.evaluate(async () => ({ + snapshot: await window.root.getState(), + events: ( + window as typeof window & { + menuEvents: { + mutations: Array<{ version: number; cause: string; mutations: readonly unknown[] }>; + snapshots: number[]; + requests: Array<{ frozen: boolean; revision: number; position: { x: number; y: number } }>; + }; + } + ).menuEvents, + })); + expect(result.snapshot.nodes[0]?.typeId).toBe("fxnode.shader.noise-texture"); + expect(result.snapshot.nodes[0]?.position).toEqual({ x: -120, y: 40 }); + expect(result.events.mutations).toHaveLength(1); + expect(result.events.mutations[0]).toMatchObject({ version: 2, cause: "api" }); + expect(result.events.mutations[0]?.mutations).toHaveLength(1); + expect(result.events.snapshots).toEqual([2]); + expect(result.events.requests).toEqual([{ frozen: true, revision: 31, position: { x: 40, y: 50 } }]); + const rootAdded = await page.evaluate(async () => { + const beforeSelection = window.view.getHostSnapshot().selection.nodeCount; + await window.root.dispatch({ type: "node.add", nodeType: "fxnode.shader.value", position: { x: -120, y: 40 } }); + const snapshot = await window.root.getState(); + return { + id: snapshot.nodes.find((node) => node.typeId === "fxnode.shader.value")!.id, + beforeSelection, + afterSelection: window.view.getHostSnapshot().selection.nodeCount, + }; + }); + expect(rootAdded.afterSelection).toBe(rootAdded.beforeSelection); + await canvas.press("Delete"); + expect((await page.evaluate(() => window.root.getState())).nodes.map((node) => node.id)).toEqual([rootAdded.id]); + await windowUndo(page); + await windowUndo(page); + await windowUndo(page); + expect((await page.evaluate(() => window.root.getState())).nodes).toHaveLength(0); + await canvas.click({ button: "right", position: { x: 80, y: 80 } }); + await expect(dialog).toBeVisible(); + await page.evaluate(async () => { + window.fxnodeHost.destroy(); + await window.view.detach(); + window.root.destroy(); + }); + await expect(dialog).toHaveCount(0); +}); + +test("right-click on a node and Ctrl-RMB never open the add menu", async ({ page }) => { + await page.goto("/examples/blender/"); + await page.evaluate(() => window.fxnodeExample.ready); + const canvas = page.locator("#graph"), + dialog = page.getByRole("dialog", { name: "Add node" }); + await canvas.click({ button: "right", position: { x: 320, y: 160 } }); + await expect(dialog).toHaveCount(0); + await canvas.hover({ position: { x: 30, y: 30 } }); + await page.keyboard.down("Control"); + await page.mouse.down({ button: "right" }); + await page.mouse.up({ button: "right" }); + await page.keyboard.up("Control"); + await expect(dialog).toHaveCount(0); +}); + +async function windowUndo(page: import("@playwright/test").Page): Promise { + await page.evaluate(() => window.root.undo()); +} diff --git a/vendor/fxnode/test/browser/all-supported.spec.ts b/vendor/fxnode/test/browser/all-supported.spec.ts new file mode 100644 index 0000000..3b1d662 --- /dev/null +++ b/vendor/fxnode/test/browser/all-supported.spec.ts @@ -0,0 +1,10 @@ +import { expect, test } from "@playwright/test"; +test("@visual all supported catalog", async ({ page }) => { + await page.setViewportSize({ width: 1600, height: 900 }); + await page.goto("/examples/blender/all-supported/"); + await page.waitForFunction(() => Boolean(window.fxnodeExample)); + await page.evaluate(() => window.fxnodeExample.ready); + await page.locator("canvas").press("Home"); + await page.evaluate(() => window.fxnodeExample.view!.whenRendered()); + await expect(page.locator("canvas")).toHaveScreenshot("all-supported.png", { animations: "disabled" }); +}); diff --git a/vendor/fxnode/test/browser/all-supported.spec.ts-snapshots/all-supported-linux.png b/vendor/fxnode/test/browser/all-supported.spec.ts-snapshots/all-supported-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..068ed48f9d2e4317d14b47a69fc19f8e9e1b3539 GIT binary patch literal 133972 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

uZiD(YO?m50 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

-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 literal 0 HcmV?d00001 diff --git a/vendor/fxnode/test/browser/application-live-startup.spec.ts b/vendor/fxnode/test/browser/application-live-startup.spec.ts new file mode 100644 index 0000000..65c0805 --- /dev/null +++ b/vendor/fxnode/test/browser/application-live-startup.spec.ts @@ -0,0 +1,129 @@ +import { expect, test } from "@playwright/test"; + +const expectedNodeTypes = [ + "fxnode.common.frame", + "fxnode.common.reroute", + "fxnode.common.group-input", + "fxnode.common.group-output", + "fxnode.shader.value", + "fxnode.shader.color", + "fxnode.shader.math", + "fxnode.shader.vector-math", + "fxnode.shader.mix", + "fxnode.shader.color-ramp", + "fxnode.shader.texture-coordinate", + "fxnode.shader.noise-texture", + "fxnode.shader.image-texture", + "fxnode.shader.principled-bsdf", + "fxnode.shader.material-output", + "fxnode.geometry.position", + "fxnode.geometry.mesh-cube", + "fxnode.geometry.set-position", + "fxnode.geometry.transform-geometry", + "fxnode.geometry.join-geometry", + "fxnode.compositor.image", + "fxnode.compositor.color-balance", +] as const; + +test("example installs every startup node through the live worker API", async ({ page }) => { + await page.addInitScript(() => { + const sent: unknown[] = [], + received: unknown[] = []; + ( + window as unknown as { applicationStartupMessages: { sent: unknown[]; received: unknown[] } } + ).applicationStartupMessages = { sent, received }; + const NativeWorker = Worker; + class StartupWorker extends NativeWorker { + constructor(url: string | URL, options?: WorkerOptions) { + super(url, options); + super.addEventListener("message", (event) => received.push(structuredClone(event.data))); + } + override postMessage(message: unknown, options?: StructuredSerializeOptions | Transferable[]) { + sent.push(structuredClone(message)); + super.postMessage(message, options as StructuredSerializeOptions); + } + } + Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: StartupWorker }); + }); + await page.goto("/examples/blender/"); + await page.evaluate(() => window.fxnodeExample.ready); + const result = await page.evaluate(async () => { + const messages = (window as unknown as { applicationStartupMessages: { sent: any[]; received: any[] } }) + .applicationStartupMessages, + init = messages.sent.find((message) => message.type === "init"), + updates = messages.sent.filter((message) => message.type === "composition.update"), + stateSet = messages.sent.find((message) => message.type === "state.set"), + api = window.fxnodeExample.root!; + const updateIds = new Set(updates.map((message) => message.id)), + compositionReceipts = messages.received + .filter((message) => message.type === "response" && updateIds.has(message.id)) + .map((message) => message.value), + stateSetIndex = messages.sent.indexOf(stateSet), + lastUpdateIndex = Math.max(...updates.map((message) => messages.sent.indexOf(message))), + snapshot = await api.getState(), + undo = await api.undo({ expectedVersion: 1 }), + empty = await api.getState(), + redo = await api.redo({ expectedVersion: 2 }), + restored = await api.getState(); + return { + initKeys: Object.keys(init).sort(), + updates: updates.map((message) => ({ + kind: message.update.kind, + id: message.update.id, + expected: message.expected, + })), + compositionReceipts, + stateSet, + stateSetIndex, + lastUpdateIndex, + snapshot, + undo, + empty, + redo, + restored, + saved: await api.save(), + initialLayout: await (await fetch("/examples/blender/initialLayout.json")).json(), + }; + }); + expect(result.initKeys).toEqual([ + "applicationId", + "applicationVersion", + "historyLimit", + "id", + "protocol", + "resources", + "type", + ]); + expect(result.updates).toHaveLength(31); + expect(result.updates.slice(0, 9).map((update) => update.kind)).toEqual([ + "theme.set", + "header-styles.set", + ...Array(6).fill("socket.compose"), + "compatibility.set", + ]); + expect(result.updates.slice(9).map((update) => update.kind)).toEqual(Array(22).fill("node.compose")); + expect(result.updates.slice(9).map((update) => update.id)).toEqual(expectedNodeTypes); + expect(result.updates.map((update) => update.expected)).toEqual(Array(31).fill({ kind: "current" })); + expect(result.compositionReceipts).toHaveLength(31); + expect( + result.compositionReceipts.every((receipt) => receipt.graphVersion === 0 && receipt.graphChanged === false), + ).toBe(true); + expect(result.stateSetIndex).toBeGreaterThan(result.lastUpdateIndex); + expect(result.stateSet.expected).toEqual({ kind: "current" }); + expect(result.stateSet.state.schemaVersion).toBeUndefined(); + expect(result.stateSet.state.version).toBeUndefined(); + expect(result.stateSet.state.nodes.every((node: any) => node.known === true)).toBe(true); + expect(result.snapshot.version).toBe(1); + expect(result.snapshot.nodes.length).toBe(result.initialLayout.nodes.length); + expect(result.snapshot.nodes.every((node: any) => node.known)).toBe(true); + expect(result.undo).toEqual({ status: "committed", version: 2 }); + expect(result.empty).toMatchObject({ version: 2, nodes: [], links: [] }); + expect(result.redo).toEqual({ status: "committed", version: 3 }); + expect(result.restored.nodes).toEqual(result.snapshot.nodes); + expect(result.restored.links).toEqual(result.snapshot.links); + expect(result.saved).toEqual({ + schemaVersion: 2, + ...result.initialLayout, + nodes: result.initialLayout.nodes.map(({ known: _known, ...node }: any) => node), + }); +}); diff --git a/vendor/fxnode/test/browser/client-multiview.spec.ts b/vendor/fxnode/test/browser/client-multiview.spec.ts new file mode 100644 index 0000000..9173f18 --- /dev/null +++ b/vendor/fxnode/test/browser/client-multiview.spec.ts @@ -0,0 +1,261 @@ +import { expect, test } from "@playwright/test"; + +test("canvas-free authority owns independent attachable views", async ({ page }) => { + await page.goto("/test/browser/client-runtime.html"); + const result = await page.evaluate(async () => { + type Wire = Record & { type: string; id?: string; viewId?: string }; + class FakeWorker { + static readonly instances: FakeWorker[] = []; + readonly posted: Wire[] = []; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: ErrorEvent) => void) | null = null; + onmessageerror: ((event: MessageEvent) => void) | null = null; + terminated = false; + holdViewports = false; + readonly heldViewportIds: string[] = []; + + constructor() { + FakeWorker.instances.push(this); + } + + postMessage(message: Wire): void { + this.posted.push(message); + if (!message.id) return; + if (message.type === "init") this.respond(message.id); + else if (message.type === "view.attach") { + // Exercise the valid event-before-acknowledgement ordering. + this.emit({ + protocol: 3, + type: "view.selection.host", + viewId: message.viewId, + projection: { nodeCount: 0, linkCount: 0, canRemove: false, mute: { enabled: false } }, + }); + this.respond(message.id); + } else if (message.type === "view.detach") this.respond(message.id); + else if (message.type === "command") this.respond(message.id, { status: "committed", version: 1 }); + else if (message.type === "view.viewport") { + if (this.holdViewports) this.heldViewportIds.push(message.id); + else this.respond(message.id); + } else if (message.type.startsWith("view.")) this.respond(message.id, { status: "noop", version: 0 }); + } + + terminate(): void { + this.terminated = true; + } + + emit(data: unknown): void { + queueMicrotask(() => this.onmessage?.(new MessageEvent("message", { data }))); + } + + settleViewport(ok: boolean): void { + const id = this.heldViewportIds.shift()!; + this.emit({ + protocol: 3, + type: "response", + id, + ok, + ...(ok ? {} : { error: { code: "viewport.test", message: "Test rejection" } }), + }); + } + + private respond(id: string, value?: unknown): void { + this.emit({ + protocol: 3, + type: "response", + id, + ok: true, + ...(value === undefined ? {} : { value }), + }); + } + } + + const NativeWorker = window.Worker; + Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: FakeWorker }); + try { + const { createFxNode, FxNodeViewDetachedError } = (await import( + "/src/index.ts" as string + )) as typeof import("@lib/index.js"); + const api = await createFxNode({ applicationId: "client-test", applicationVersion: 1, resources: {} }); + const worker = FakeWorker.instances[0]!; + const rootMessages = worker.posted.map((message) => message.type); + const firstCanvas = document.createElement("canvas"), + secondCanvas = document.createElement("canvas"), + viewport = { width: 320, height: 180, dpr: 1 }; + const first = await api.attachView({ canvas: firstCanvas, viewport }); + const second = await api.attachView({ + canvas: secondCanvas, + viewport, + initialCamera: { center: { x: 2_000, y: 0 }, zoom: 0.75 }, + }); + const pointerLaneCount = worker.posted.filter( + (message) => + message.type === "view.attach" && + typeof SharedArrayBuffer === "function" && + message.pointerLane instanceof SharedArrayBuffer, + ).length; + let duplicateCode = ""; + try { + await api.attachView({ canvas: firstCanvas, viewport }); + } catch (error) { + duplicateCode = (error as { code?: string }).code ?? ""; + } + worker.emit({ + protocol: 3, + type: "view.selection.host", + viewId: first.id, + projection: { + nodeCount: 1, + linkCount: 0, + canRemove: true, + mute: { enabled: true, state: "all-unmuted" }, + }, + }); + await new Promise((resolve) => setTimeout(resolve)); + const selections = [first.getHostSnapshot().selection.nodeCount, second.getHostSnapshot().selection.nodeCount]; + + const modifiers = { alt: false, control: false, meta: false, shift: false }; + const viewportCount = () => worker.posted.filter((message) => message.type === "view.viewport").length; + const beforeNoop = viewportCount(); + await first.setViewport(viewport); + const noopDidNotPost = viewportCount() === beforeNoop; + worker.holdViewports = true; + const resizeA = first.setViewport({ width: 321, height: 180, dpr: 1 }); + await new Promise((resolve) => setTimeout(resolve)); + const resizeB = first.setViewport({ width: 322, height: 180, dpr: 1 }); + const resizeC = first.setViewport({ width: 323, height: 180, dpr: 1 }); + const resizeMessagesBeforeA = viewportCount() - beforeNoop; + const resourceRequests = [0, 0]; + first.onHostRequests((request) => { + if (request.kind === "resource-open") resourceRequests[0] = resourceRequests[0]! + 1; + }); + second.onHostRequests((request) => { + if (request.kind === "resource-open") resourceRequests[1] = resourceRequests[1]! + 1; + }); + first.feedInput({ + kind: "pointer", + phase: "down", + pointerId: 7, + pointerType: "mouse", + position: { x: 12, y: 14 }, + button: 0, + buttons: 1, + modifiers, + }); + const inputDuringResize = [...worker.posted] + .reverse() + .find((message) => message.type === "view.input" && message.viewId === first.id)!; + const resizeAWire = worker.posted.find( + (message) => message.type === "view.viewport" && message.viewId === first.id, + )!; + worker.settleViewport(true); + await resizeA; + await new Promise((resolve) => setTimeout(resolve)); + const coalescedWire = [...worker.posted] + .reverse() + .find((message) => message.type === "view.viewport" && message.viewId === first.id)!; + worker.settleViewport(false); + const coalescedSettlements = await Promise.allSettled([resizeB, resizeC]); + worker.holdViewports = false; + const resourceOpenRequestId = [...worker.posted] + .reverse() + .find((message) => message.type === "view.input" && message.viewId === first.id) + ?.resourceOpenRequestId as string; + first.feedInput({ + kind: "pointer", + phase: "up", + pointerId: 7, + pointerType: "mouse", + position: { x: 12, y: 14 }, + button: 0, + buttons: 0, + modifiers, + }); + worker.emit({ + protocol: 3, + type: "view.resource.open", + viewId: first.id, + requestId: resourceOpenRequestId, + authorization: { viewId: first.id, token: "node:parameter:image", graphVersion: 0, compositionRevision: 0 }, + resource: { + id: "image", + kind: "image", + title: "Image", + openTitle: "Open image", + accept: ["image/png"], + maxBytes: 1024, + maxWidth: 64, + maxHeight: 64, + maxPixels: 4096, + }, + }); + await new Promise((resolve) => setTimeout(resolve)); + await first.setViewport({ width: 324, height: 180, dpr: 1 }); + for (const view of [first, second]) + view.feedInput({ + kind: "pointer", + phase: "move", + pointerId: 1, + pointerType: "mouse", + position: { x: 10, y: 10 }, + button: -1, + buttons: 0, + modifiers, + }); + const beforeDispatch = worker.posted.length; + await api.dispatch({ type: "undo" }); + const dispatchTrace = worker.posted.slice(beforeDispatch).map((message) => message.type); + + const detachA = first.detach(), + detachB = first.detach(), + stableDetachPromise = detachA === detachB; + let detachedImmediately = false; + try { + first.getHostSnapshot(); + } catch (error) { + detachedImmediately = error instanceof FxNodeViewDetachedError; + } + await detachA; + const replacement = await api.attachView({ canvas: firstCanvas, viewport }); + await replacement.detach(); + await second.detach(); + api.destroy(); + return { + rootMessages, + idsDiffer: first.id !== second.id, + duplicateCode, + selections, + resourceRequests, + dispatchTrace, + pointerLaneCount, + noopDidNotPost, + resizeMessagesBeforeA, + coalescedWidth: (coalescedWire.viewport as { width: number }).width, + coalescedSettlements: coalescedSettlements.map((item) => item.status), + resizeHostGeneration: resizeAWire.hostGeneration, + inputHostGeneration: inputDuringResize.hostGeneration, + stableDetachPromise, + detachedImmediately, + terminated: worker.terminated, + }; + } finally { + Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: NativeWorker }); + } + }); + + expect(result.rootMessages).toEqual(["init"]); + expect(result.idsDiffer).toBe(true); + expect(result.duplicateCode).toBe("canvas.in-use"); + expect(result.selections).toEqual([1, 0]); + // The coalesced resize is posted after the pointer request and intentionally clears that pre-existing request. + expect(result.resourceRequests).toEqual([0, 0]); + expect(result.dispatchTrace.at(-1)).toBe("command"); + expect(result.dispatchTrace.filter((type) => type === "view.pointer.flush")).toHaveLength(result.pointerLaneCount); + expect(result.noopDidNotPost).toBe(true); + expect(result.resizeMessagesBeforeA).toBe(1); + expect(result.coalescedWidth).toBe(323); + expect(result.coalescedSettlements).toEqual(["rejected", "rejected"]); + expect(result.inputHostGeneration).toBeGreaterThan(result.resizeHostGeneration as number); + expect(result.stableDetachPromise).toBe(true); + expect(result.detachedImmediately).toBe(true); + expect(result.terminated).toBe(true); +}); diff --git a/vendor/fxnode/test/browser/client-runtime.html b/vendor/fxnode/test/browser/client-runtime.html new file mode 100644 index 0000000..381b30d --- /dev/null +++ b/vendor/fxnode/test/browser/client-runtime.html @@ -0,0 +1,8 @@ + + + + + FxNode client runtime test + + + diff --git a/vendor/fxnode/test/browser/controls.spec.ts b/vendor/fxnode/test/browser/controls.spec.ts new file mode 100644 index 0000000..ead097f --- /dev/null +++ b/vendor/fxnode/test/browser/controls.spec.ts @@ -0,0 +1,277 @@ +import { expect, test, type Page } from "@playwright/test"; + +type Point = Readonly<{ x: number; y: number }>; +type ControlPage = Page & { readonly __controlPageBrand?: never }; + +// Canvas is 1200x640 and the zoom-1 origin is its center (600,320). +// Layout rows are 24px high below a 24px header. Scalar numeric fields span +// nearly the full row; compound and non-numeric controls retain split layout. +const CONTROL_COORDS = Object.freeze({ + value: { x: 196, y: 147 }, + mathOperation: { x: 473, y: 147 }, + mathClamp: { x: 473, y: 171 }, + mathA: { x: 473, y: 195 }, + mathB: { x: 473, y: 219 }, + vectorOperation: { x: 853, y: 147 }, + vectorAX: { x: 792, y: 171 }, + vectorAY: { x: 853, y: 171 }, + colorSwatch: { x: 1003, y: 147 }, + groupString: { x: 623, y: 456 }, +} satisfies Record); + +const open = async (page: Page): Promise => { + await page.goto("/examples/blender/control-test/"); + await page.evaluate(() => window.controlTest.ready); + expect(await page.locator('input[type="file"]').count()).toBe(0); + await page.evaluate(() => { + window.controlEvents = { mutations: [], snapshots: [] }; + window.controlTest.root!.onMutations((event) => window.controlEvents.mutations.push(event.version)); + window.controlTest.root!.onSnapshots((event) => window.controlEvents.snapshots.push(event.version)); + }); + return page; +}; +const state = (page: ControlPage) => + page.evaluate(async () => ({ + snapshot: await window.controlTest.root!.getState(), + layout: await window.controlTest.root!.save(), + events: window.controlEvents, + })); +const value = (layout: Awaited>["layout"], id: string, parameter: string) => + layout.nodes.find((node) => node.id === id)!.parameters[parameter]!; +const socketValue = (layout: Awaited>["layout"], id: string, socket: string) => + layout.nodes.find((node) => node.id === id)!.sockets.find((item) => item.id === socket)!.defaultValue!; +const invariant = ( + before: Awaited>, + after: Awaited>, + commits: number, +) => { + expect(after.snapshot.version - before.snapshot.version).toBe(commits); + expect(after.events.mutations.slice(before.events.mutations.length)).toEqual( + after.events.snapshots.slice(before.events.snapshots.length), + ); + expect(after.events.mutations.length - before.events.mutations.length).toBe(commits); +}; +async function scrub( + page: ControlPage, + point: Point, + dx: number, + modifiers: { shift?: boolean; control?: boolean } = {}, +) { + const canvas = page.locator("#controls"), + box = await canvas.boundingBox(); + if (!box) throw new Error("Canvas bounds missing"); + if (modifiers.shift) await page.keyboard.down("Shift"); + if (modifiers.control) await page.keyboard.down("Control"); + await page.mouse.move(box.x + point.x, box.y + point.y); + await page.mouse.down(); + await page.mouse.move(box.x + point.x + dx / 2, box.y + point.y, { steps: 3 }); + await page.mouse.move(box.x + point.x + dx, box.y + point.y, { steps: 3 }); + return async () => { + await page.mouse.up(); + if (modifiers.control) await page.keyboard.up("Control"); + if (modifiers.shift) await page.keyboard.up("Shift"); + }; +} + +test("number scrub is transient, atomic, cancellable, precise and snapping", async ({ page }) => { + const p = await open(page), + initial = await state(p), + release = await scrub(p, CONTROL_COORDS.value, 30); + const preview = await state(p); + expect(preview.snapshot.version).toBe(initial.snapshot.version); + expect(preview.events).toEqual(initial.events); + await release(); + const normal = await state(p); + invariant(initial, normal, 1); + expect(value(normal.layout, "value", "value")).toEqual({ kind: "number", value: 3 }); + const cancelStart = await state(p), + cancel = await scrub(p, CONTROL_COORDS.value, 30); + await p.locator("#controls").press("Escape"); + await cancel(); + invariant(cancelStart, await state(p), 0); + const shiftStart = await state(p), + shiftedRelease = await scrub(p, CONTROL_COORDS.value, 30, { shift: true }); + await shiftedRelease(); + const shifted = await state(p); + invariant(shiftStart, shifted, 1); + expect((value(shifted.layout, "value", "value") as { value: number }).value - 3).toBeLessThan(3); + const ctrlStart = await state(p), + ctrlRelease = await scrub(p, CONTROL_COORDS.value, 37, { control: true }); + await ctrlRelease(); + const snapped = await state(p); + invariant(ctrlStart, snapped, 1); + expect(Number.isInteger((value(snapped.layout, "value", "value") as { value: number }).value)).toBe(true); +}); + +test("numeric fields support typed assignment, cancellation, clamping and step arrows", async ({ page }) => { + const p = await open(page), + canvas = p.locator("#controls"), + a = await state(p); + await canvas.click({ position: CONTROL_COORDS.value }); + await p.keyboard.type("0.375"); + await p.keyboard.press("Enter"); + const b = await state(p); + invariant(a, b, 1); + expect(value(b.layout, "value", "value")).toEqual({ kind: "number", value: 0.375 }); + await canvas.click({ position: CONTROL_COORDS.value }); + await p.keyboard.type("999"); + await p.keyboard.press("Enter"); + const clamped = await state(p); + invariant(b, clamped, 1); + expect(value(clamped.layout, "value", "value")).toEqual({ kind: "number", value: 999 }); + await canvas.click({ position: CONTROL_COORDS.value }); + await p.keyboard.type("bad"); + await p.keyboard.press("Enter"); + invariant(clamped, await state(p), 0); + await p.keyboard.press("Escape"); + await canvas.click({ position: { x: 235, y: 147 } }); + const stepped = await state(p); + invariant(clamped, stepped, 1); + expect(value(stepped.layout, "value", "value")).toEqual({ kind: "number", value: 1000 }); + await canvas.click({ position: { x: 115, y: 147 } }); + const decremented = await state(p); + invariant(stepped, decremented, 1); + expect(value(decremented.layout, "value", "value")).toEqual({ kind: "number", value: 999 }); +}); + +test("vector component scrubs commit only the selected component", async ({ page }) => { + const p = await open(page), + a = await state(p), + r1 = await scrub(p, CONTROL_COORDS.vectorAY, 20); + await r1(); + const b = await state(p); + invariant(a, b, 1); + expect(socketValue(b.layout, "vector", "vector:a")).toEqual({ kind: "vector", value: [0, 2, 0] }); +}); + +test("color picker keeps RGBA, HSV and hex previews transient until confirmation", async ({ page }) => { + const p = await open(page), + canvas = p.locator("#controls"), + before = await state(p); + await canvas.click({ position: CONTROL_COORDS.colorSwatch }); + const box = await canvas.boundingBox(); + if (!box) throw new Error("Canvas bounds missing"); + await page.mouse.move(box.x + 805, box.y + 245); + await page.mouse.down(); + await page.mouse.move(box.x + 850, box.y + 245, { steps: 4 }); + await page.mouse.up(); + invariant(before, await state(p), 0); + for (const [field, text] of [ + [{ x: 742, y: 378 }, "0.25"], + [{ x: 752, y: 408 }, "120"], + [{ x: 830, y: 439 }, "#336699CC"], + ] as const) { + await canvas.click({ position: field }); + await page.keyboard.type(text); + await page.keyboard.press("Enter"); + invariant(before, await state(p), 0); + } + await canvas.click({ position: { x: 723, y: 163 } }); + const committed = await state(p); + invariant(before, committed, 1); + expect(value(committed.layout, "color", "color")).toEqual({ kind: "color", value: [0.2, 0.4, 0.6, 0.8] }); + await canvas.click({ position: CONTROL_COORDS.colorSwatch }); + await canvas.click({ position: { x: 830, y: 439 } }); + await page.keyboard.type("#FF0000"); + await canvas.click({ position: { x: 20, y: 20 } }); + const outside = await state(p); + invariant(committed, outside, 1); + expect(value(outside.layout, "color", "color")).toEqual({ kind: "color", value: [1, 0, 0, 1] }); + await canvas.click({ position: CONTROL_COORDS.colorSwatch }); + await page.mouse.move(box.x + 805, box.y + 245); + await page.mouse.down(); + await page.mouse.move(box.x + 825, box.y + 220); + await page.mouse.up(); + await canvas.press("Escape"); + invariant(outside, await state(p), 0); +}); + +test("enum and boolean controls emit one mutation/snapshot pair", async ({ page }) => { + const p = await open(page), + canvas = p.locator("#controls"), + a = await state(p); + await canvas.click({ position: CONTROL_COORDS.mathOperation }); + const b = await state(p); + invariant(a, b, 1); + expect(value(b.layout, "math", "operation")).toEqual({ kind: "string", value: "subtract" }); + await canvas.press("ArrowDown"); + const c = await state(p); + invariant(b, c, 1); + expect(value(c.layout, "math", "operation")).toEqual({ kind: "string", value: "multiply" }); + await canvas.click({ position: CONTROL_COORDS.mathClamp }); + const d = await state(p); + invariant(c, d, 1); + expect(value(d.layout, "math", "clamp")).toEqual({ kind: "boolean", value: true }); +}); + +test("string editing commits on Enter and cancels on Escape or blur", async ({ page }) => { + const p = await open(page), + canvas = p.locator("#controls"), + a = await state(p); + await canvas.click({ position: CONTROL_COORDS.groupString }); + await p.keyboard.type(" Name"); + await p.keyboard.press("Enter"); + const b = await state(p); + invariant(a, b, 1); + expect(value(b.layout, "group", "interfaceName")).toEqual({ kind: "string", value: "Socket Name" }); + await canvas.click({ position: CONTROL_COORDS.groupString }); + await p.keyboard.type(" bad"); + await p.keyboard.press("Escape"); + invariant(b, await state(p), 0); + await canvas.click({ position: CONTROL_COORDS.groupString }); + await p.keyboard.type(" worse"); + await p.locator("body").evaluate((body) => (body as HTMLElement).focus()); + await canvas.evaluate((element) => (element as HTMLCanvasElement).blur()); + invariant(b, await state(p), 0); +}); + +test("Backspace resets defaults, undo restores, and linked socket is inert", async ({ page }) => { + const p = await open(page), + canvas = p.locator("#controls"), + a = await state(p), + r = await scrub(p, CONTROL_COORDS.value, 30); + await r(); + const changed = await state(p); + invariant(a, changed, 1); + await canvas.hover({ position: CONTROL_COORDS.value }); + await canvas.press("Backspace"); + const reset = await state(p); + invariant(changed, reset, 1); + expect(value(reset.layout, "value", "value")).toEqual({ kind: "number", value: 0 }); + await p.evaluate(() => window.controlTest.root!.undo()); + const undone = await state(p); + invariant(reset, undone, 1); + expect(value(undone.layout, "value", "value")).toEqual({ kind: "number", value: 3 }); + await canvas.hover({ position: CONTROL_COORDS.mathA }); + await canvas.press("Backspace"); + invariant(undone, await state(p), 0); +}); + +test("muting a link reveals its default control and unmuting hides it", async ({ page }) => { + const p = await open(page), + canvas = p.locator("#controls"), + a = await state(p); + await p.evaluate(() => { + const id = window.controlTest.root!.save().then((layout) => layout.links[0]!.id); + return id.then((link) => window.controlTest.root!.dispatch({ type: "link.mute", id: link, value: true })); + }); + await p.evaluate(() => window.controlTest.view!.whenRendered()); + const muted = await state(p); + invariant(a, muted, 1); + expect(muted.layout.links[0]!.muted).toBe(true); + const r = await scrub(p, CONTROL_COORDS.mathA, 20); + await r(); + const edited = await state(p); + invariant(muted, edited, 1); + expect(socketValue(edited.layout, "math", "math:a")).toEqual({ kind: "number", value: 2 }); + await p.evaluate(() => { + const id = window.controlTest.root!.save().then((layout) => layout.links[0]!.id); + return id.then((link) => window.controlTest.root!.dispatch({ type: "link.mute", id: link, value: false })); + }); + await p.evaluate(() => window.controlTest.view!.whenRendered()); + const live = await state(p); + invariant(edited, live, 1); + await canvas.click({ position: CONTROL_COORDS.mathA }); + invariant(live, await state(p), 0); + expect((await state(p)).layout.links[0]!.muted).toBe(false); +}); diff --git a/vendor/fxnode/test/browser/examples.docs.visual.spec.ts b/vendor/fxnode/test/browser/examples.docs.visual.spec.ts new file mode 100644 index 0000000..f281b3f --- /dev/null +++ b/vendor/fxnode/test/browser/examples.docs.visual.spec.ts @@ -0,0 +1,10 @@ +import { expect, test } from "@playwright/test"; +for (const example of ["minimal", "color-balance", "live-composition", "multi-view"]) + test(`${example} documentation image`, async ({ page }) => { + await page.goto(`/examples/${example}/`); + await page.evaluate( + (name) => (name === "multi-view" ? window.fxnodeMultiView.ready : window.fxnodeStandalone.ready), + example, + ); + await expect(page).toHaveScreenshot(`${example}.png`, { animations: "disabled", fullPage: true }); + }); diff --git a/vendor/fxnode/test/browser/examples.spec.ts b/vendor/fxnode/test/browser/examples.spec.ts new file mode 100644 index 0000000..ff5ef34 --- /dev/null +++ b/vendor/fxnode/test/browser/examples.spec.ts @@ -0,0 +1,193 @@ +import { expect, test, type Page } from "@playwright/test"; + +const examples = [ + { path: "minimal", nodeId: "value", typeId: "example.minimal.value" }, + { path: "color-balance", nodeId: "color-balance", typeId: "fxnode.compositor.color-balance" }, + { path: "live-composition", nodeId: "live-node", typeId: "example.live.parameter" }, +] as const; + +function capturePageErrors(page: Page): Error[] { + const errors: Error[] = []; + page.on("pageerror", (error) => errors.push(error)); + return errors; +} + +test("gallery links every standalone application and loads its images", async ({ page }) => { + await page.goto("/examples/"); + await expect(page.locator(".gallery a")).toHaveCount(5); + expect( + await page.locator(".gallery a").evaluateAll((links) => links.map((link) => link.getAttribute("href"))), + ).toEqual(["./minimal/", "./color-balance/", "./live-composition/", "./multi-view/", "./blender/"]); + await expect(page.locator(".gallery img")).toHaveCount(4); + expect( + await page + .locator(".gallery img") + .evaluateAll((images) => + images.every((image) => image instanceof HTMLImageElement && image.complete && image.naturalWidth > 0), + ), + ).toBe(true); +}); + +test("multi-view keeps view input and selection local while graph changes fan out", async ({ page }) => { + await page.addInitScript(() => { + const original = EventTarget.prototype.addEventListener; + (window as unknown as { canvasListeners: Record }).canvasListeners = {}; + EventTarget.prototype.addEventListener = function (type, listener, options) { + if (this instanceof HTMLCanvasElement) { + const events = (window as unknown as { canvasListeners: Record }).canvasListeners; + (events[this.id] ??= []).push(type); + } + return original.call(this, type, listener, options); + }; + }); + await page.goto("/examples/multi-view/"); + await page.evaluate(() => window.fxnodeMultiView.ready); + expect( + await page.evaluate(() => (window as unknown as { canvasListeners: Record }).canvasListeners), + ).toEqual({ + "view-a": [ + "pointerdown", + "pointerdown", + "pointermove", + "pointerup", + "pointercancel", + "mousedown", + "wheel", + "keydown", + "keyup", + "focus", + "blur", + "contextmenu", + "lostpointercapture", + ], + "view-b": [ + "pointerdown", + "pointerdown", + "pointermove", + "pointerup", + "pointercancel", + "mousedown", + "wheel", + "keydown", + "keyup", + "focus", + "blur", + "contextmenu", + "lostpointercapture", + ], + }); + + const baseline = await page.evaluate(async () => ({ + renders: [...window.fxnodeMultiView.renderCounts], + ids: (await window.fxnodeMultiView.root!.getState()).nodes.map((node) => node.id), + })); + await page.getByRole("button", { name: "Add" }).click(); + await expect + .poll(() => page.evaluate(() => window.fxnodeMultiView.root!.getState().then((state) => state.nodes.length))) + .toBe(baseline.ids.length + 1); + await page.locator("#view-b").dispatchEvent("pointerdown", { + pointerId: 2, + pointerType: "mouse", + clientX: 900, + clientY: 300, + button: 0, + buttons: 1, + }); + await expect(page.locator("article").nth(1)).toHaveClass(/active/); + await page.getByRole("button", { name: "Add" }).click(); + await expect + .poll(() => page.evaluate(() => window.fxnodeMultiView.root!.getState().then((state) => state.nodes.length))) + .toBe(baseline.ids.length + 2); + await expect + .poll(() => + page.evaluate( + (counts) => window.fxnodeMultiView.renderCounts.every((value, i) => value > counts[i]!), + baseline.renders, + ), + ) + .toBe(true); + + let result = await page.evaluate(async (baselineIds) => { + const state = await window.fxnodeMultiView.root!.getState(); + const added = state.nodes.filter((node) => !baselineIds.includes(node.id)); + return { + positions: added.map((node) => node.position), + selections: window.fxnodeMultiView.views.map((view) => view.getHostSnapshot().selection.nodeCount), + }; + }, baseline.ids); + expect(result.positions.sort((a, b) => a.x - b.x)).toEqual([ + { x: 480, y: -550 }, + { x: 2080, y: -400 }, + ]); + expect(result.selections).toEqual([1, 1]); + + await page.getByRole("button", { name: "Mute" }).click(); + await expect(page.getByRole("button", { name: "Unmute" })).toHaveAttribute("aria-pressed", "true"); + await page.getByRole("button", { name: "Delete" }).click(); + await expect + .poll(() => page.evaluate(() => window.fxnodeMultiView.root!.getState().then((state) => state.nodes.length))) + .toBe(baseline.ids.length + 1); + result = await page.evaluate( + async (baselineIds) => ({ + positions: (await window.fxnodeMultiView.root!.getState()).nodes + .filter((node) => !baselineIds.includes(node.id)) + .map((node) => node.position), + selections: window.fxnodeMultiView.views.map((view) => view.getHostSnapshot().selection.nodeCount), + }), + baseline.ids, + ); + expect(result.positions).toEqual([{ x: 480, y: -550 }]); + expect(result.selections).toEqual([1, 0]); + await page.evaluate(() => Promise.all([window.fxnodeMultiView.cleanup(), window.fxnodeMultiView.cleanup()])); + expect( + await page.evaluate(() => ({ root: window.fxnodeMultiView.root, views: window.fxnodeMultiView.views.length })), + ).toEqual({ root: null, views: 0 }); +}); + +for (const example of examples) { + test(`${example.path} renders its known node and cleans up on pagehide`, async ({ page }) => { + const errors = capturePageErrors(page); + await page.goto(`/examples/${example.path}/`); + await page.evaluate(() => window.fxnodeStandalone.ready); + const result = await page.evaluate(async ({ nodeId }) => { + const api = window.fxnodeStandalone.root; + if (!api) return null; + const node = (await api.getState()).nodes.find((candidate) => candidate.id === nodeId); + const canvas = document.querySelector("canvas")!; + const pixels = canvas.getContext("2d")!.getImageData(0, 0, canvas.width, canvas.height).data; + return { + node: node && { id: node.id, typeId: node.typeId, known: node.known }, + nonEmpty: pixels.some((channel) => channel !== 0), + }; + }, example); + expect(result).not.toBeNull(); + expect(result?.node).toEqual({ id: example.nodeId, typeId: example.typeId, known: true }); + expect(result?.nonEmpty).toBe(true); + await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent("pagehide"))); + expect(await page.evaluate(() => window.fxnodeStandalone.root)).toBeNull(); + expect(errors).toEqual([]); + }); +} + +test("live composition migrates the real node and records the graph/history transition", async ({ page }) => { + const errors = capturePageErrors(page); + await page.goto("/examples/live-composition/"); + await page.evaluate(() => window.fxnodeStandalone.ready); + const beforeVersion = await page.evaluate(() => window.fxnodeStandalone.graphVersion); + expect(beforeVersion).toBeDefined(); + await page.getByRole("button", { name: "Compose version 2" }).click(); + await expect(page.locator("#status")).toContainText("Version 2 committed"); + const result = await page.evaluate(async () => ({ + receipt: window.fxnodeStandalone.lastCompositionReceipt, + state: await window.fxnodeStandalone.root!.getState(), + })); + expect(result.receipt?.status).toBe("committed"); + expect(result.receipt?.graphChanged).toBe(true); + expect(result.receipt?.historyReset).toBe(true); + expect(result.receipt?.graphVersion).toBe(beforeVersion! + 1); + const node = result.state.nodes.find((candidate) => candidate.id === "live-node"); + expect(node?.typeId).toBe("example.live.parameter"); + expect(node?.typeVersion).toBe(2); + expect(node?.parameters["detail"]).toEqual({ kind: "number", value: 0.5 }); + expect(errors).toEqual([]); +}); diff --git a/vendor/fxnode/test/browser/fixture.ts b/vendor/fxnode/test/browser/fixture.ts new file mode 100644 index 0000000..1fba377 --- /dev/null +++ b/vendor/fxnode/test/browser/fixture.ts @@ -0,0 +1,27 @@ +import { createApplicationFxNode } from "../../examples/blender/application-browser.js"; +import { prepareFxNodeBrowserHost } from "../../examples/shared/browser-host.js"; +const initialLayout = { graphId: "browser", catalogVersion: 4, nodes: [], links: [], metadata: {} }; +window.ready = (async () => { + const primary = document.querySelector("#primary"); + const addNodeMenuTemplate = document.querySelector("#add-node-menu-template"); + if (!primary || !addNodeMenuTemplate) throw new Error("Primary canvas or add-node menu template missing"); + const host = prepareFxNodeBrowserHost({ canvas: primary, addNodeMenuTemplate }); + let root: Awaited> | undefined, + view: Awaited["attachView"]>> | undefined; + try { + root = await createApplicationFxNode(); + await root.setState(initialLayout); + view = await root.attachView({ canvas: primary, viewport: host.initialViewport }); + host.attach(root, view); + window.root = root; + window.view = view; + window.fxnodeHost = host; + await view.whenRendered(); + return true; + } catch (error) { + host.destroy(); + await view?.detach(); + root?.destroy(); + throw error; + } +})(); diff --git a/vendor/fxnode/test/browser/fxnode.spec.ts b/vendor/fxnode/test/browser/fxnode.spec.ts new file mode 100644 index 0000000..ffcef10 --- /dev/null +++ b/vendor/fxnode/test/browser/fxnode.spec.ts @@ -0,0 +1,450 @@ +import { test, expect } from "@playwright/test"; +test("host snapshots are stable immutable projections with isolated subscriptions", async ({ page }) => { + await page.goto("/test/browser/index.html"); + await page.evaluate(() => window.ready); + const result = await page.evaluate(async () => { + const api = window.root, + view = window.view, + first = view.getHostSnapshot(), + same = first === view.getHostSnapshot(), + frozen = Object.isFrozen(first) && Object.isFrozen(first.selection) && Object.isFrozen(first.selection.mute); + let good = 0, + bad = 0; + const offBad = view.subscribeHost(() => { + bad++; + throw new Error("subscriber"); + }), + offGood = view.subscribeHost(() => good++); + await api.composeNode("phase-one", { + version: 1, + title: "Phase One", + behavior: "standard", + style: "shader", + parameters: {}, + sockets: {}, + ui: [], + muteBypass: [], + migrations: [], + }); + const projected = view.getHostSnapshot(); + offBad(); + offBad(); + offGood(); + await api.removeNode("phase-one"); + const detached = structuredClone(projected); + api.destroy(); + let readable = true, + terminal = ""; + try { + view.getHostSnapshot(); + } catch (e) { + readable = false; + terminal = (e as Error).name; + } + return { + same, + frozen, + good, + bad, + firstRevision: first.compositionRevision, + revision: projected.compositionRevision, + detachedKeys: Object.keys(detached).sort(), + readable, + terminal, + }; + }); + expect(result).toEqual({ + same: true, + frozen: true, + good: 1, + bad: 1, + firstRevision: 31, + revision: 32, + detachedKeys: ["colorPickerOpen", "compositionRevision", "selection"], + readable: false, + terminal: "FxNodeDestroyedError", + }); +}); +test("getState is detached and setState is atomic, ordered, and undoable", async ({ page }) => { + await page.goto("/examples/blender/"); + await page.evaluate(() => window.fxnodeExample.ready); + const result = await page.evaluate(async () => { + const api = window.fxnodeExample.root!, + view = window.fxnodeExample.view!, + original = await api.getState(), + detached = structuredClone(original); + (detached.nodes as unknown[]).push({}); + const events: string[] = []; + api.onMutations((event) => events.push(`mutation:${event.version}`)); + api.onSnapshots((event) => events.push(`snapshot:${event.version}`)); + const noop = await api.setState(original), + target = { ...original, graphId: "replacement" as typeof original.graphId }, + changed = await api.setState(target, { expectedVersion: original.version }), + after = await api.getState(), + undo = await api.undo(), + undone = await api.getState(); + api.destroy(); + return { + originalVersion: original.version, + originalNodes: original.nodes.length, + detachedNodes: detached.nodes.length, + noop, + changed, + afterGraphId: after.graphId, + undo, + undoneGraphId: undone.graphId, + events, + }; + }); + const v = result.originalVersion; + expect(result.detachedNodes).toBe(result.originalNodes + 1); + expect(result.noop).toEqual({ status: "noop", version: v }); + expect(result.changed).toEqual({ status: "committed", version: v + 1 }); + expect(result.afterGraphId).toBe("replacement"); + expect(result.undo).toEqual({ status: "committed", version: v + 2 }); + expect(result.undoneGraphId).not.toBe("replacement"); + expect(result.events).toEqual([`mutation:${v + 1}`, `snapshot:${v + 1}`, `mutation:${v + 2}`, `snapshot:${v + 2}`]); +}); +test("explicit node and selection actions preserve worker authority and structured failures", async ({ page }) => { + await page.goto("/test/browser/index.html"); + await page.evaluate(() => window.ready); + const result = await page.evaluate(async () => { + const api = window.root, + view = window.view, + initial = await api.getState(); + let notifications = 0, + selection = JSON.stringify(view.getHostSnapshot().selection); + const off = view.subscribeHost(() => { + const next = JSON.stringify(view.getHostSnapshot().selection); + if (next !== selection) { + selection = next; + notifications++; + } + }); + let stale = "", + unknown = "", + duplicate = ""; + try { + await view.removeSelected({ expectedVersion: initial.version + 1 }); + } catch (error) { + stale = (error as Error & { code?: string }).code ?? ""; + } + try { + await view.addNode({ typeId: "missing", viewPosition: { x: 40, y: 40 }, nodeId: "unknown" }); + } catch (error) { + unknown = (error as Error & { code?: string }).code ?? ""; + } + const added = await view.addNode({ + typeId: "fxnode.shader.value", + viewPosition: { x: 40, y: 40 }, + nodeId: "supplied-id", + }), + selected = structuredClone(view.getHostSnapshot().selection); + try { + await view.addNode({ typeId: "fxnode.shader.value", viewPosition: { x: 80, y: 80 }, nodeId: "supplied-id" }); + } catch (error) { + duplicate = (error as Error & { code?: string }).code ?? ""; + } + const muted = await view.setSelectedMuted(true), + mutedSelection = structuredClone(view.getHostSnapshot().selection), + afterMute = await api.getState(); + const removed = await view.removeSelected(), + afterRemove = await api.getState(), + empty = structuredClone(view.getHostSnapshot().selection); + off(); + return { + stale, + unknown, + duplicate, + added, + selected, + muted, + mutedSelection, + node: afterMute.nodes.find((node) => node.id === "supplied-id"), + removed, + remaining: afterRemove.nodes.length, + empty, + notifications, + }; + }); + expect(result.stale).toBe("version.stale"); + expect(result.unknown).toBe("node.type-unknown"); + expect(result.duplicate).toBe("node.duplicate"); + expect(result.added).toEqual({ status: "committed", version: 2 }); + expect(result.selected).toEqual({ + nodeCount: 1, + linkCount: 0, + canRemove: true, + mute: { enabled: true, state: "all-unmuted" }, + }); + expect(result.muted).toEqual({ status: "committed", version: 3 }); + expect(result.node?.muted).toBe(true); + expect(result.mutedSelection.mute).toEqual({ enabled: true, state: "all-muted" }); + expect(result.removed).toEqual({ status: "committed", version: 4 }); + expect(result.remaining).toBe(0); + expect(result.empty).toEqual({ nodeCount: 0, linkCount: 0, canRemove: false, mute: { enabled: false } }); + expect(result.notifications).toBe(3); +}); +test("dedicated worker renders, commits FIFO, fans out and destroys", async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(HTMLCanvasElement.prototype, "transferControlToOffscreen", { + value: () => { + throw new Error("sabotaged"); + }, + }); + Object.defineProperty(window, "OffscreenCanvas", { value: undefined }); + }); + await page.goto("/test/browser/index.html"); + await page.evaluate(() => window.ready); + const result = await page.evaluate(async () => { + const api = window.root; + const trace: Array< + { kind: "mutation"; baseVersion: number; version: number; cause: string } | { kind: "snapshot"; version: number } + > = []; + api.onMutations((event) => + trace.push({ kind: "mutation", baseVersion: event.baseVersion, version: event.version, cause: event.cause }), + ); + api.onSnapshots((event) => trace.push({ kind: "snapshot", version: event.version })); + + const [a, b] = await Promise.all([ + api.dispatch({ type: "node.add", nodeType: "fxnode.shader.value", position: { x: 0, y: 0 } }), + api.dispatch({ type: "node.add", nodeType: "fxnode.shader.value", position: { x: 1, y: 1 } }), + ]); + const beforeStale = { trace: structuredClone(trace), snapshot: await api.getState() }; + let staleError: { name: string; message: string; code?: string } | undefined; + try { + await api.dispatch({ type: "undo" }, { expectedVersion: 0 }); + } catch (error) { + const value = error as Error & { code?: string }; + staleError = { name: value.name, message: value.message, ...(value.code ? { code: value.code } : {}) }; + } + const afterStale = { trace: structuredClone(trace), snapshot: await api.getState() }; + const undo = await api.undo(); + const redo = await api.redo(); + + const beforeQueries = trace.length; + const snap = await api.getState(); + const saved = await api.save(); + const queriesSilent = trace.length === beforeQueries; + const mirror = document.querySelector("#mirror"); + const copy = document.querySelector("#copy"); + const primary = document.querySelector("#primary"); + if (!mirror || !copy || !primary) throw new Error("Canvas missing"); + mirror.getContext("2d")!.drawImage(primary, 0, 0); + copy.getContext("2d")!.drawImage(primary, 0, 0); + const pixel = (canvas: HTMLCanvasElement) => Array.from(canvas.getContext("2d")!.getImageData(2, 2, 1, 1).data); + const pixels = [pixel(primary), pixel(mirror), pixel(copy)]; + api.destroy(); + let destroyed = false; + try { + await api.getState(); + } catch { + destroyed = true; + } + return { + a, + b, + undo, + redo, + trace, + beforeStale, + afterStale, + staleError, + queriesSilent, + pixels, + catalog: saved.catalogVersion, + nodes: snap.nodes.length, + destroyed, + }; + }); + + expect(result.a).toEqual({ status: "committed", version: 2 }); + expect(result.b).toEqual({ status: "committed", version: 3 }); + expect(result.beforeStale.trace).toEqual([ + { kind: "mutation", baseVersion: 1, version: 2, cause: "api" }, + { kind: "snapshot", version: 2 }, + { kind: "mutation", baseVersion: 2, version: 3, cause: "api" }, + { kind: "snapshot", version: 3 }, + ]); + expect(result.beforeStale.snapshot.version).toBe(3); + expect(result.staleError).toEqual({ + name: "FxNodeWorkerError", + message: "Expected version does not match", + code: "version.stale", + }); + expect(result.afterStale).toEqual(result.beforeStale); + expect(result.undo).toEqual({ status: "committed", version: 4 }); + expect(result.redo).toEqual({ status: "committed", version: 5 }); + expect(result.trace).toEqual([ + ...result.beforeStale.trace, + { kind: "mutation", baseVersion: 3, version: 4, cause: "undo" }, + { kind: "snapshot", version: 4 }, + { kind: "mutation", baseVersion: 4, version: 5, cause: "redo" }, + { kind: "snapshot", version: 5 }, + ]); + expect(result.queriesSilent).toBe(true); + expect(result.catalog).toBe(4); + expect(result.nodes).toBe(2); + expect(result.destroyed).toBe(true); + expect(result.pixels[0]).toEqual(result.pixels[1]); + expect(result.pixels[0]).toEqual(result.pixels[2]); + expect(result.pixels[0]?.[3]).toBe(255); +}); + +test("steady-size frame presentation does not reallocate canvas backing stores", async ({ page }) => { + await page.addInitScript(() => { + const counts = { width: 0, height: 0 }; + (window as unknown as { canvasAssignments: typeof counts }).canvasAssignments = counts; + for (const key of ["width", "height"] as const) { + const descriptor = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, key)!; + Object.defineProperty(HTMLCanvasElement.prototype, key, { + ...descriptor, + set(value: number) { + counts[key]++; + descriptor.set!.call(this, value); + }, + }); + } + }); + await page.goto("/test/browser/index.html"); + await page.evaluate(() => window.ready); + await page.evaluate(async () => { + const counts = (window as unknown as { canvasAssignments: { width: number; height: number } }).canvasAssignments; + counts.width = counts.height = 0; + for (let index = 0; index < 8; index++) + await window.root.dispatch({ + type: "node.add", + nodeType: "fxnode.shader.value", + position: { x: index * 10, y: index * 10 }, + }); + await window.view.whenRendered(); + }); + expect( + await page.evaluate( + () => (window as unknown as { canvasAssignments: { width: number; height: number } }).canvasAssignments, + ), + ).toEqual({ width: 0, height: 0 }); +}); + +test("structured command errors with paths reject only their RPC", async ({ page }) => { + await page.goto("/test/browser/index.html"); + await page.evaluate(() => window.ready); + const result = await page.evaluate(async () => { + let error: { name?: string; code?: string; path?: string } = {}; + try { + await window.root.dispatch({ + type: "link.add", + link: { + id: "stale", + fromNodeId: "missing-a", + fromSocketId: "missing-a:out", + toNodeId: "missing-b", + toSocketId: "missing-b:in", + muted: false, + extensions: {}, + }, + } as any); + } catch (value) { + const e = value as typeof error; + error = { + ...(e.name === undefined ? {} : { name: e.name }), + ...(e.code === undefined ? {} : { code: e.code }), + ...(e.path === undefined ? {} : { path: e.path }), + }; + } + const snapshot = await window.root.getState(); + window.root.destroy(); + return { error, version: snapshot.version }; + }); + expect(result).toEqual({ + error: { name: "FxNodeWorkerError", code: "link.endpoint", path: "/links/stale" }, + version: 1, + }); +}); + +test("malformed and incoherent state receipts terminate the client", async ({ browser }) => { + const installCorruptor = () => { + const NativeWorker = Worker; + let corruptType = ""; + class CorruptingWorker extends NativeWorker { + private requestId = ""; + constructor(url: string | URL, options?: WorkerOptions) { + super(url, options); + super.addEventListener("message", (event) => { + const message = event.data; + if (!this.requestId || message?.type !== "response" || message.id !== this.requestId) return; + event.stopImmediatePropagation(); + const type = corruptType, + id = this.requestId; + this.requestId = ""; + queueMicrotask(() => + this.dispatchEvent( + new MessageEvent("message", { + data: { + protocol: 2, + type: "response", + id, + ok: true, + value: type === "state.set" ? null : { status: "noop", version: 2 }, + }, + }), + ), + ); + }); + } + override postMessage(message: unknown, options?: StructuredSerializeOptions | Transferable[]) { + if ((message as { type?: string }).type === corruptType) this.requestId = (message as { id: string }).id; + super.postMessage(message, options as StructuredSerializeOptions); + } + } + Object.defineProperty(window, "corruptReceiptType", { set: (value: string) => (corruptType = value) }); + Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: CorruptingWorker }); + }; + const statePage = await browser.newPage(); + await statePage.addInitScript(installCorruptor); + await statePage.goto("/test/browser/index.html"); + await statePage.evaluate(() => window.ready); + const malformed = await statePage.evaluate(async () => { + const state = await window.root.getState(); + (window as unknown as { corruptReceiptType: string }).corruptReceiptType = "state.set"; + let first = "", + terminal = ""; + try { + await window.root.setState(state, { expectedVersion: state.version }); + } catch (error) { + first = (error as Error).name; + } + try { + await window.root.getState(); + } catch (error) { + terminal = (error as Error).name; + } + return { first, terminal }; + }); + await statePage.close(); + const loadPage = await browser.newPage(); + await loadPage.addInitScript(installCorruptor); + await loadPage.goto("/test/browser/index.html"); + await loadPage.evaluate(() => window.ready); + const incoherent = await loadPage.evaluate(async () => { + const state = await window.root.getState(), + data = await window.root.getSaveData(); + (window as unknown as { corruptReceiptType: string }).corruptReceiptType = "load"; + let first = "", + terminal = ""; + try { + await window.root.load(data, { expectedVersion: state.version }); + } catch (error) { + first = (error as Error).name; + } + try { + await window.root.getState(); + } catch (error) { + terminal = (error as Error).name; + } + return { first, terminal }; + }); + await loadPage.close(); + expect(malformed).toEqual({ first: "FxNodeProtocolError", terminal: "FxNodeProtocolError" }); + expect(incoherent).toEqual({ first: "FxNodeProtocolError", terminal: "FxNodeProtocolError" }); +}); diff --git a/vendor/fxnode/test/browser/globals.d.ts b/vendor/fxnode/test/browser/globals.d.ts new file mode 100644 index 0000000..89c1386 --- /dev/null +++ b/vendor/fxnode/test/browser/globals.d.ts @@ -0,0 +1,28 @@ +import type { FxNode, FxNodeView } from "@lib/index.js"; +import type { PreparedFxNodeBrowserHost } from "../../examples/shared/browser-host.js"; + +declare global { + interface Window { + root: FxNode; + view: FxNodeView; + fxnodeHost: PreparedFxNodeBrowserHost; + ready: Promise; + fxnodeExample: FxNodeExampleHandle; + parityExample: { root: FxNode; view: FxNodeView }; + controlTest: { root: FxNode | null; view: FxNodeView | null; ready: Promise }; + linkToolsTest: { root: FxNode | null; view: FxNodeView | null; ready: Promise }; + controlEvents: { mutations: number[]; snapshots: number[] }; + } + interface FxNodeExampleHandle { + root: FxNode | null; + view: FxNodeView | null; + ready: Promise; + readonly rendered: Promise; + } + interface FxNodeEvidenceCounters { + mutations: number; + snapshots: number; + } +} + +export {}; diff --git a/vendor/fxnode/test/browser/index.html b/vendor/fxnode/test/browser/index.html new file mode 100644 index 0000000..addddbe --- /dev/null +++ b/vendor/fxnode/test/browser/index.html @@ -0,0 +1,315 @@ + + + + + diff --git a/vendor/fxnode/test/browser/interactions.spec.ts b/vendor/fxnode/test/browser/interactions.spec.ts new file mode 100644 index 0000000..0400113 --- /dev/null +++ b/vendor/fxnode/test/browser/interactions.spec.ts @@ -0,0 +1,507 @@ +import { expect, test } from "@playwright/test"; + +test("direct input preserves modifiers, SAB ordering, and right-button menu rules", async ({ page }) => { + await page.addInitScript(() => { + const Native = Worker; + const messages: unknown[] = []; + let lane: SharedArrayBuffer | undefined; + class Spy extends Native { + override postMessage(message: unknown, options?: Transferable[] | StructuredSerializeOptions) { + const value = message as { type?: string; pointerLane?: SharedArrayBuffer }; + if (value.type === "view.attach") lane = value.pointerLane; + if (["view.input", "view.pointer.flush", "view.viewport", "view.render"].includes(value.type ?? "")) + messages.push(message); + super.postMessage(message, options as StructuredSerializeOptions); + } + } + Object.defineProperties(window, { phase1Messages: { value: messages }, phase1Lane: { get: () => lane } }); + Object.defineProperty(window, "Worker", { value: Spy }); + }); + await page.goto("/examples/blender/"); + await page.evaluate(() => window.fxnodeExample.ready); + const result = await page.evaluate(async () => { + const api = window.fxnodeExample.root!, + view = window.fxnodeExample.view!, + w = window as unknown as { phase1Messages: any[]; phase1Lane?: SharedArrayBuffer }; + w.phase1Messages.length = 0; + const mods0 = { alt: false, control: false, meta: false, shift: false }, + mods15 = { alt: true, control: true, meta: true, shift: true }; + const before = api.getState(); + view.feedInput({ + kind: "pointer", + phase: "down", + pointerId: 9, + pointerType: "mouse", + position: { x: 380, y: 160 }, + button: 0, + buttons: 1, + modifiers: mods0, + }); + view.feedInput({ + kind: "pointer", + phase: "move", + pointerId: 9, + pointerType: "mouse", + position: { x: 410, y: 180 }, + button: 0, + buttons: 1, + modifiers: mods15, + }); + view.feedInput({ + kind: "pointer", + phase: "up", + pointerId: 9, + pointerType: "mouse", + position: { x: 410, y: 180 }, + button: 0, + buttons: 0, + modifiers: mods0, + }); + await view.whenRendered(); + const after = await api.getState(); + const invalidBefore = w.phase1Messages.length, + laneBefore = w.phase1Lane ? Array.from(new Int32Array(w.phase1Lane)) : []; + let invalid = ""; + try { + view.feedInput({ kind: "wheel", position: { x: 0, y: 0 }, delta: { x: NaN, y: 0 }, modifiers: mods0 }); + } catch (e) { + invalid = (e as Error).name; + } + const invalidAfter = w.phase1Messages.length, + laneAfter = w.phase1Lane ? Array.from(new Int32Array(w.phase1Lane)) : []; + w.phase1Messages.length = 0; + view.feedInput({ + kind: "pointer", + phase: "move", + pointerId: 10, + pointerType: "mouse", + position: { x: -3, y: -4 }, + button: 0, + buttons: 0, + modifiers: mods15, + }); + await view.setViewport({ width: 301, height: 179, dpr: 1 }); + const ordered = structuredClone(w.phase1Messages); + w.phase1Messages.length = 0; + const rendered = view.whenRendered(); + await rendered; + const renderMessage = w.phase1Messages.find((x) => x.type === "view.render"); + w.phase1Messages.length = 0; + view.feedInput({ + kind: "pointer", + phase: "down", + pointerId: 11, + pointerType: "mouse", + position: { x: 1, y: 1 }, + button: 2, + buttons: 3, + modifiers: mods0, + }); + const rmb = structuredClone(w.phase1Messages.at(-1)); + return { + before: await before, + after, + invalid, + invalidBefore, + invalidAfter, + laneBefore, + laneAfter, + ordered, + renderedWithDedicatedMessage: !!renderMessage, + rmb, + }; + }); + expect(result.after.nodes.find((n) => n.id === "math")?.position).toEqual({ x: -270, y: 150 }); + const inputs = result.ordered.length; + expect(result.invalid).toBe("TypeError"); + expect(result.invalidAfter).toBe(result.invalidBefore); + expect(result.laneAfter).toEqual(result.laneBefore); + expect(result.ordered.map((x: any) => x.type)).toEqual(["view.viewport"]); + expect(result.ordered[0].pointerFence.before.event.modifiers).toBe(15); + expect(result.ordered[0].viewport).toEqual({ width: 301, height: 179, dpr: 1 }); + expect(result.renderedWithDedicatedMessage).toBe(true); + const sentMasks = (result as any).rmb.event.modifiers; + expect(sentMasks).toBe(0); + expect((result as any).rmb.nodeMenuRequestId).toBeUndefined(); + expect(inputs).toBe(1); +}); + +test("direct input invalidates a pending add-menu request", async ({ page }) => { + await page.goto("/examples/blender/"); + await page.evaluate(() => window.fxnodeExample.ready); + await page.evaluate(() => { + const api = window.fxnodeExample.root!, + view = window.fxnodeExample.view!, + modifiers = { alt: false, control: false, meta: false, shift: false }; + view.feedInput({ + kind: "pointer", + phase: "down", + pointerId: 31, + pointerType: "mouse", + position: { x: 20, y: 20 }, + button: 2, + buttons: 2, + modifiers, + }); + view.feedInput({ kind: "wheel", position: { x: 20, y: 20 }, delta: { x: 0, y: 1 }, modifiers }); + }); + await page.waitForTimeout(100); + expect(await page.locator("[data-fxnode-add-menu]").count()).toBe(0); +}); + +test("synchronous input and viewport postMessage failures throw", async ({ page }) => { + await page.addInitScript(() => { + const Native = Worker; + let fail = ""; + class Spy extends Native { + override postMessage(message: unknown, options?: Transferable[] | StructuredSerializeOptions) { + if ((message as any)?.type === fail) throw new DOMException("blocked", "DataCloneError"); + super.postMessage(message, options as StructuredSerializeOptions); + } + } + Object.defineProperty(window, "failWorkerPost", { set: (v: string) => (fail = v) }); + Object.defineProperty(window, "Worker", { value: Spy }); + }); + await page.goto("/test/browser/index.html"); + await page.evaluate(() => window.ready); + expect( + await page.evaluate(() => { + (window as any).failWorkerPost = "view.input"; + try { + window.view.feedInput({ kind: "focus", phase: "focus" }); + return "none"; + } catch (e) { + return (e as Error).name; + } + }), + ).toBe("FxNodeProtocolError"); + await page.reload(); + await page.evaluate(() => window.ready); + expect( + await page.evaluate(async () => { + (window as any).failWorkerPost = "view.viewport"; + try { + await window.view.setViewport({ width: 10, height: 10, dpr: 1 }); + return "none"; + } catch (e) { + return (e as Error).name; + } + }), + ).toBe("FxNodeProtocolError"); +}); + +test("dedicated actions and resource transfers embed transactional SAB fences", async ({ page }) => { + await page.addInitScript(() => { + const Native = Worker, + messages: unknown[] = [], + resourceTransfers: boolean[] = []; + let lane: SharedArrayBuffer | undefined, + fail = ""; + class Spy extends Native { + override postMessage(message: unknown, options?: Transferable[] | StructuredSerializeOptions) { + const value = message as { type?: string; pointerLane?: SharedArrayBuffer; resource?: { bytes?: ArrayBuffer } }; + if (value.type === "view.attach") lane = value.pointerLane; + if ( + ["view.node.add", "view.selection.mute", "view.selection.remove", "view.resource.set"].includes( + value.type ?? "", + ) + ) + messages.push(structuredClone(message)); + if (value.type === "view.resource.set") + resourceTransfers.push(Array.isArray(options) && options[0] === value.resource?.bytes); + if (value.type === fail) throw new DOMException("blocked", "DataCloneError"); + super.postMessage(message, options as StructuredSerializeOptions); + } + } + Object.defineProperties(window, { + actionMessages: { value: messages }, + actionLane: { get: () => lane }, + resourceTransfers: { value: resourceTransfers }, + failActionPost: { set: (value: string) => (fail = value) }, + }); + Object.defineProperty(window, "Worker", { value: Spy }); + }); + await page.goto("/test/browser/index.html"); + await page.evaluate(() => window.ready); + const result = await page.evaluate(async () => { + const api = window.root, + view = window.view, + w = window as any, + modifiers = { alt: false, control: false, meta: false, shift: false }, + move = (x: number) => + view.feedInput({ + kind: "pointer", + phase: "move", + pointerId: 7, + pointerType: "mouse", + position: { x, y: x + 1 }, + button: 0, + buttons: 0, + modifiers, + }); + move(1); + await view.addNode({ typeId: "fxnode.shader.value", viewPosition: { x: 40, y: 40 }, nodeId: "fenced" }); + move(2); + await view.setSelectedMuted(true); + move(3); + await view.removeSelected(); + move(4); + const transferred = new ArrayBuffer(1); + let stale = ""; + try { + await view.provideResource( + { token: "missing", viewId: view.id, graphVersion: 4, compositionRevision: 30 }, + { name: "x.png", mime: "image/png", bytes: transferred }, + ); + } catch (error) { + stale = (error as any).code; + } + const laneBefore = Array.from(new Int32Array(w.actionLane)); + move(5); + const beforeFailure = Array.from(new Int32Array(w.actionLane)), + failedBuffer = new ArrayBuffer(1); + w.failActionPost = "view.resource.set"; + let failure = ""; + try { + await view.provideResource( + { token: "missing", viewId: view.id, graphVersion: 4, compositionRevision: 30 }, + { name: "x.png", mime: "image/png", bytes: failedBuffer }, + ); + } catch (error) { + failure = (error as Error).name; + } + const sent = structuredClone(w.actionMessages), + afterFailure = Array.from(new Int32Array(w.actionLane)); + return { + sent, + laneBefore, + beforeFailure, + afterFailure, + failure, + stale, + detached: transferred.byteLength, + failedBytes: failedBuffer.byteLength, + transfers: w.resourceTransfers, + }; + }); + expect(result.sent.map((message: any) => message.type)).toEqual([ + "view.node.add", + "view.selection.mute", + "view.selection.remove", + "view.resource.set", + "view.resource.set", + ]); + const generations = result.sent.map((message: any) => message.pointerFence.generation), + base = generations[0]; + expect(generations).toEqual([base, base + 1, base + 2, base + 3, base + 4]); + expect(result.sent.map((message: any) => message.pointerFence.before.event.position.x)).toEqual([1, 2, 3, 4, 5]); + expect(result.laneBefore[1]).toBe(base + 3); + expect(result.beforeFailure[1]).toBe(base + 3); + expect(result.afterFailure[1]).toBe(base + 3); + expect(result.failure).toBe("FxNodeProtocolError"); + expect(result.stale).toBe("resource.stale"); + expect(result.detached).toBe(0); + expect(result.failedBytes).toBe(1); + expect(result.transfers).toEqual([true, true]); +}); + +test("selection publication does not wait for frame consumption", async ({ page }) => { + await page.addInitScript(() => { + const Native = Worker; + let block = false; + class Spy extends Native { + override postMessage(message: unknown, options?: Transferable[] | StructuredSerializeOptions) { + if ((message as any)?.type === "view.frame.consumed" && block) return; + super.postMessage(message, options as StructuredSerializeOptions); + } + } + Object.defineProperty(window, "blockFrameConsumption", { set: (value: boolean) => (block = value) }); + Object.defineProperty(window, "Worker", { value: Spy }); + }); + await page.goto("/test/browser/index.html"); + await page.evaluate(() => window.ready); + const result = await page.evaluate(async () => { + (window as any).blockFrameConsumption = true; + const api = window.root, + view = window.view, + before = view.getHostSnapshot(); + const receipt = await view.addNode({ + typeId: "fxnode.shader.value", + viewPosition: { x: 40, y: 40 }, + nodeId: "blocked-frame", + }), + after = view.getHostSnapshot(); + return { receipt, changed: before !== after, selection: structuredClone(after.selection) }; + }); + expect(result).toEqual({ + receipt: { status: "committed", version: 2 }, + changed: true, + selection: { nodeCount: 1, linkCount: 0, canRemove: true, mute: { enabled: true, state: "all-unmuted" } }, + }); +}); + +test("worker gestures stay transient and commit exactly one paired event", async ({ page }) => { + await page.addInitScript(() => { + const NativeWorker = window.Worker; + let moves = 0; + class TrackedWorker extends NativeWorker { + override postMessage(message: unknown, transfer: Transferable[]): void; + override postMessage(message: unknown, options?: StructuredSerializeOptions): void; + override postMessage(message: unknown, transferOrOptions?: Transferable[] | StructuredSerializeOptions): void { + if ( + typeof message === "object" && + message !== null && + (message as { type?: unknown }).type === "view.input" && + (message as { event?: { kind?: unknown; phase?: unknown } }).event?.kind === "pointer" && + (message as { event?: { phase?: unknown } }).event?.phase === "move" + ) + moves++; + if (Array.isArray(transferOrOptions)) super.postMessage(message, transferOrOptions); + else super.postMessage(message, transferOrOptions); + } + } + Object.defineProperty(window, "Worker", { value: TrackedWorker }); + Object.defineProperty(window, "pointerMoveMessages", { get: () => moves }); + }); + await page.goto("/examples/blender/"); + await page.evaluate(() => window.fxnodeExample.ready); + expect(await page.evaluate(() => crossOriginIsolated)).toBe(true); + await page.evaluate(() => { + const h = window.fxnodeExample; + const w = window as typeof window & { gestureEvents: { m: number; s: number } }; + w.gestureEvents = { m: 0, s: 0 }; + h.root!.onMutations(() => w.gestureEvents.m++); + h.root!.onSnapshots(() => w.gestureEvents.s++); + }); + const canvas = page.locator("#graph"), + snapshot = () => page.evaluate(() => window.fxnodeExample.root!.getState()); + const original = await snapshot(); + // Math header is deterministic: world (-300,170), view origin (600,320). + await canvas.click({ position: { x: 380, y: 160 } }); + expect((await snapshot()).version).toBe(original.version); + expect( + await page.evaluate(() => (window as typeof window & { gestureEvents: { m: number; s: number } }).gestureEvents), + ).toEqual({ m: 0, s: 0 }); + const bounds = await canvas.boundingBox(); + if (!bounds) throw new Error("canvas bounds missing"); + await canvas.hover({ position: { x: 380, y: 160 } }); + await page.mouse.down(); + await page.mouse.move(bounds.x + 410, bounds.y + 180, { steps: 4 }); + expect((await snapshot()).nodes.find((n) => n.id === "math")?.position).toEqual({ x: -300, y: 170 }); + await page.mouse.up(); + expect((await snapshot()).nodes.find((n) => n.id === "math")?.position).toEqual({ x: -270, y: 150 }); + expect( + await page.evaluate(() => (window as typeof window & { gestureEvents: { m: number; s: number } }).gestureEvents), + ).toEqual({ m: 1, s: 1 }); + expect( + await page.evaluate(() => (window as typeof window & { pointerMoveMessages: number }).pointerMoveMessages), + ).toBe(0); + // G and ordinary drag cancel without mutation; RMB is suppressed by the canvas. + await canvas.press("g"); + await page.mouse.move(bounds.x + 390, bounds.y + 210); + await canvas.press("Escape"); + expect((await snapshot()).version).toBe(original.version + 1); + await canvas.hover({ position: { x: 390, y: 180 } }); + await page.mouse.down(); + await page.mouse.move(bounds.x + 430, bounds.y + 220); + await canvas.press("Escape"); + await page.mouse.up(); + await canvas.click({ position: { x: 30, y: 30 }, button: "right" }); + expect((await snapshot()).version).toBe(original.version + 1); + // Box, MMB, wheel and Home are view/selection-only. + await canvas.hover({ position: { x: 20, y: 20 } }); + await page.mouse.down(); + await page.mouse.move(bounds.x + 250, bounds.y + 300); + await page.mouse.up(); + await canvas.hover({ position: { x: 30, y: 30 } }); + await page.mouse.down({ button: "middle" }); + await page.mouse.move(bounds.x + 40, bounds.y + 40); + await page.mouse.up({ button: "middle" }); + await page.mouse.wheel(0, 30); + await canvas.press("Home"); + expect((await snapshot()).version).toBe(original.version + 1); +}); + +test("non-isolated hosts retain the ordered pointer message fallback", async ({ page }) => { + await page.addInitScript(() => { + Object.defineProperty(window, "crossOriginIsolated", { value: false }); + const NativeWorker = window.Worker; + let moves = 0; + class TrackedWorker extends NativeWorker { + override postMessage(message: unknown, transfer: Transferable[]): void; + override postMessage(message: unknown, options?: StructuredSerializeOptions): void; + override postMessage(message: unknown, transferOrOptions?: Transferable[] | StructuredSerializeOptions): void { + if ( + typeof message === "object" && + message !== null && + (message as { type?: unknown }).type === "view.input" && + (message as { event?: { kind?: unknown; phase?: unknown } }).event?.kind === "pointer" && + (message as { event?: { phase?: unknown } }).event?.phase === "move" + ) + moves++; + if (Array.isArray(transferOrOptions)) super.postMessage(message, transferOrOptions); + else super.postMessage(message, transferOrOptions); + } + } + Object.defineProperty(window, "Worker", { value: TrackedWorker }); + Object.defineProperty(window, "pointerMoveMessages", { get: () => moves }); + }); + await page.goto("/examples/blender/"); + await page.evaluate(() => window.fxnodeExample.ready); + expect(await page.evaluate(() => crossOriginIsolated)).toBe(false); + const canvas = page.locator("#graph"), + bounds = await canvas.boundingBox(); + if (!bounds) throw new Error("canvas bounds missing"); + await canvas.hover({ position: { x: 380, y: 160 } }); + await page.mouse.down(); + await page.mouse.move(bounds.x + 410, bounds.y + 180, { steps: 4 }); + await page.mouse.up(); + expect( + (await page.evaluate(() => window.fxnodeExample.root!.getState())).nodes.find((node) => node.id === "math") + ?.position, + ).toEqual({ x: -270, y: 150 }); + expect( + await page.evaluate(() => (window as typeof window & { pointerMoveMessages: number }).pointerMoveMessages), + ).toBeGreaterThan(0); +}); + +test("wheel input visibly zooms around the pointer without changing graph state", async ({ page }) => { + await page.addInitScript(() => { + const NativeWorker = window.Worker; + let wheels = 0; + class TrackedWorker extends NativeWorker { + override postMessage(message: unknown, transfer: Transferable[]): void; + override postMessage(message: unknown, options?: StructuredSerializeOptions): void; + override postMessage(message: unknown, transferOrOptions?: Transferable[] | StructuredSerializeOptions): void { + if ( + typeof message === "object" && + message !== null && + (message as { type?: unknown }).type === "view.input" && + (message as { event?: { kind?: unknown } }).event?.kind === "wheel" + ) + wheels++; + if (Array.isArray(transferOrOptions)) super.postMessage(message, transferOrOptions); + else super.postMessage(message, transferOrOptions); + } + } + Object.defineProperty(window, "Worker", { value: TrackedWorker }); + Object.defineProperty(window, "wheelMessages", { get: () => wheels }); + }); + await page.goto("/examples/blender/"); + await page.evaluate(() => window.fxnodeExample.ready); + const canvas = page.locator("#graph"), + before = await canvas.screenshot(), + version = (await page.evaluate(() => window.fxnodeExample.root!.getState())).version; + await canvas.hover({ position: { x: 400, y: 240 } }); + await page.mouse.wheel(0, -240); + await expect + .poll(() => page.evaluate(() => (window as typeof window & { wheelMessages: number }).wheelMessages)) + .toBe(1); + await page.evaluate(() => window.fxnodeExample.view!.whenRendered()); + const zoomed = await canvas.screenshot(); + expect(zoomed.equals(before)).toBe(false); + expect((await page.evaluate(() => window.fxnodeExample.root!.getState())).version).toBe(version); + await page.mouse.wheel(0, 240); + await page.evaluate(() => window.fxnodeExample.view!.whenRendered()); + expect((await canvas.screenshot()).equals(zoomed)).toBe(false); +}); diff --git a/vendor/fxnode/test/browser/knife-mute.spec.ts b/vendor/fxnode/test/browser/knife-mute.spec.ts new file mode 100644 index 0000000..41f5830 --- /dev/null +++ b/vendor/fxnode/test/browser/knife-mute.spec.ts @@ -0,0 +1,222 @@ +import { expect, test, type Page } from "@playwright/test"; + +const MAP = { + knifeX: 600, + parallelTop: 90, + parallelBottom: 420, + chainKnifeX: 430, + chainTop: 390, + chainBottom: 485, + mathAHeader: { x: 850, y: 90 }, + transformHeader: { x: 440, y: 560 }, + noiseHeader: { x: 850, y: 560 }, +} as const; + +async function open(page: Page) { + await page.goto("/examples/blender/link-tools-test/"); + await page.evaluate(() => window.linkToolsTest.ready); + await page.evaluate(() => { + const events = { mutations: [] as number[], snapshots: [] as number[] }; + Object.assign(window, { linkToolEvents: events }); + window.linkToolsTest.root!.onMutations((e) => events.mutations.push(e.version)); + window.linkToolsTest.root!.onSnapshots((e) => events.snapshots.push(e.version)); + }); + return page.locator("#link-tools"); +} + +async function publicState(page: Page) { + return page.evaluate(async () => ({ + save: await window.linkToolsTest.root!.save(), + snapshot: await window.linkToolsTest.root!.getState(), + events: structuredClone( + (window as typeof window & { linkToolEvents: { mutations: number[]; snapshots: number[] } }).linkToolEvents, + ), + })); +} + +async function knife( + page: Page, + canvas: ReturnType, + from: { x: number; y: number }, + to: { x: number; y: number }, + alt = false, + release = true, +) { + await canvas.hover({ position: from }); + await page.keyboard.down("Control"); + if (alt) await page.keyboard.down("Alt"); + await page.mouse.down({ button: "right" }); + const box = await canvas.boundingBox(); + if (!box) throw new Error("canvas bounds missing"); + await page.mouse.move(box.x + to.x, box.y + to.y, { steps: 12 }); + if (release) await page.mouse.up({ button: "right" }); + if (alt) await page.keyboard.up("Alt"); + await page.keyboard.up("Control"); +} + +test("Ctrl-RMB knife is transient then removes every crossed link atomically", async ({ page }) => { + const canvas = await open(page), + before = await publicState(page); + await knife( + page, + canvas, + { x: MAP.knifeX, y: MAP.parallelTop }, + { x: MAP.knifeX, y: MAP.parallelBottom }, + false, + false, + ); + const during = await publicState(page); + expect(during).toEqual(before); + // The actual canvas must contain the white knife/highlight, not merely worker state. + expect( + await page.evaluate( + ({ x, top, bottom }) => { + const c = document.querySelector("#link-tools")!, + d = c.getContext("2d")!.getImageData(x - 3, top, 7, bottom - top).data; + let n = 0; + for (let i = 0; i < d.length; i += 4) if (d[i]! > 220 && d[i + 1]! > 220 && d[i + 2]! > 220) n++; + return n; + }, + { x: MAP.knifeX, top: MAP.parallelTop, bottom: MAP.parallelBottom }, + ), + ).toBeGreaterThan(150); + await page.mouse.up({ button: "right" }); + await page.keyboard.up("Control"); + const removed = await publicState(page); + expect(removed.save.links.map((l) => l.id).sort()).toEqual(["chain-in", "chain-out"]); + expect(removed.snapshot.version).toBe(before.snapshot.version + 1); + expect(removed.events).toEqual({ + mutations: [before.snapshot.version + 1], + snapshots: [before.snapshot.version + 1], + }); + await page.keyboard.press("Control+z"); + const undone = await publicState(page); + expect(undone.save.links).toEqual(before.save.links); + expect(undone.snapshot.version).toBe(before.snapshot.version + 2); +}); + +test("Escape and pointer cancellation discard knife and context menu stays suppressed", async ({ page }) => { + const canvas = await open(page); + await page.evaluate(() => { + (window as any).unblockedMenus = 0; + document.addEventListener("contextmenu", (event) => { + if (!event.defaultPrevented) (window as any).unblockedMenus++; + }); + }); + await knife(page, canvas, { x: 600, y: 55 }, { x: 600, y: 390 }, false, false); + await page.keyboard.press("Escape"); + await page.mouse.up({ button: "right" }); + await page.keyboard.up("Control"); + expect((await publicState(page)).events).toEqual({ mutations: [], snapshots: [] }); + await knife(page, canvas, { x: 600, y: 55 }, { x: 600, y: 390 }, false, false); + await canvas.dispatchEvent("pointercancel", { + pointerId: 1, + pointerType: "mouse", + button: 2, + buttons: 0, + clientX: 600, + clientY: 390, + }); + await page.mouse.up({ button: "right" }); + await page.keyboard.up("Control"); + expect((await publicState(page)).save.links).toHaveLength(5); + expect(await page.evaluate(() => (window as any).unblockedMenus)).toBe(0); +}); + +test("Ctrl-Alt-RMB mutes a link, restores its default editor, and undo is atomic", async ({ page }) => { + const canvas = await open(page), + before = await publicState(page); + await knife(page, canvas, { x: 600, y: 100 }, { x: 600, y: 205 }, true); + const muted = await publicState(page), + link = muted.save.links.find((l) => l.id === "parallel-a"); + expect(link?.muted).toBe(true); + expect(muted.events).toEqual({ mutations: [before.snapshot.version + 1], snapshots: [before.snapshot.version + 1] }); + // Red pixels on the formerly grey link and a clickable default-value row prove rendering/hit behavior. + expect( + await page.evaluate(() => { + const c = document.querySelector("#link-tools")!, + d = c.getContext("2d")!.getImageData(500, 50, 200, 130).data; + let n = 0; + for (let i = 0; i < d.length; i += 4) if (d[i]! > 150 && d[i + 1]! < 110) n++; + return n; + }), + ).toBeGreaterThan(5); + await canvas.click({ position: { x: 850, y: 125 } }); + expect((await publicState(page)).snapshot.version).toBe(before.snapshot.version + 1); + await knife(page, canvas, { x: 600, y: 100 }, { x: 600, y: 205 }, true); + expect((await publicState(page)).save.links.find((l) => l.id === "parallel-a")?.muted).toBe(false); + await page.keyboard.press("Control+z"); + expect((await publicState(page)).save.links.find((l) => l.id === "parallel-a")?.muted).toBe(true); +}); + +test("reroute effective mute is red without changing downstream authored flags", async ({ page }) => { + const canvas = await open(page); + await knife(page, canvas, { x: MAP.chainKnifeX, y: MAP.chainTop }, { x: MAP.chainKnifeX, y: MAP.chainBottom }, true); + const state = await publicState(page); + expect(state.save.links.find((l) => l.id === "chain-in")?.muted).toBe(true); + expect(state.save.links.find((l) => l.id === "chain-out")?.muted).toBe(false); + await page.evaluate(() => window.linkToolsTest.view!.whenRendered()); + expect( + await page.evaluate(() => { + const c = document.querySelector("#link-tools")!, + d = c.getContext("2d")!.getImageData(590, 420, 250, 100).data; + let n = 0; + for (let i = 0; i < d.length; i += 4) if (d[i]! > 150 && d[i + 1]! < 110) n++; + return n; + }), + ).toBeGreaterThan(5); +}); + +test("M mutes operators with bypasses and visibly grays generators", async ({ page }) => { + const canvas = await open(page); + for (const [id, point] of [ + ["math-a", MAP.mathAHeader], + ["transform", MAP.transformHeader], + ] as const) { + await canvas.click({ position: point }); + const prior = await publicState(page), + before = prior.snapshot.version; + await canvas.press("m"); + const muted = await publicState(page); + expect(muted.save.nodes.find((n) => n.id === id)?.muted).toBe(true); + expect(muted.snapshot.version).toBe(before + 1); + expect(muted.events.mutations).toHaveLength(prior.events.mutations.length + 1); + expect(muted.events.snapshots).toHaveLength(prior.events.snapshots.length + 1); + await page.evaluate(() => window.linkToolsTest.view!.whenRendered()); + expect( + await page.evaluate(({ x, y }) => { + const c = document.querySelector("#link-tools")!, + d = c.getContext("2d")!.getImageData(x - 100, y, 200, 100).data; + let n = 0; + for (let i = 0; i < d.length; i += 4) if (d[i]! > 150 && d[i + 1]! < 110) n++; + return n; + }, point), + ).toBeGreaterThan(5); + await page.keyboard.press("Control+z"); + expect((await publicState(page)).save.nodes.find((n) => n.id === id)?.muted).toBe(false); + } + await canvas.click({ position: MAP.noiseHeader }); + const before = await publicState(page), + beforeLight = await page.evaluate(() => { + const c = document.querySelector("#link-tools")!, + d = c.getContext("2d")!.getImageData(780, 550, 165, 85).data; + let n = 0; + for (let i = 0; i < d.length; i += 4) n += d[i]! + d[i + 1]! + d[i + 2]!; + return n; + }); + await canvas.press("m"); + await page.evaluate(() => window.linkToolsTest.view!.whenRendered()); + const muted = await publicState(page), + afterLight = await page.evaluate(() => { + const c = document.querySelector("#link-tools")!, + d = c.getContext("2d")!.getImageData(780, 550, 165, 85).data; + let n = 0; + for (let i = 0; i < d.length; i += 4) n += d[i]! + d[i + 1]! + d[i + 2]!; + return n; + }); + expect(muted.save.nodes.find((n) => n.id === "noise")?.muted).toBe(true); + expect(muted.snapshot.version).toBe(before.snapshot.version + 1); + expect(afterLight).toBeLessThan(beforeLight * 0.8); + await page.keyboard.press("Control+z"); + expect((await publicState(page)).save.nodes.find((n) => n.id === "noise")?.muted).toBe(false); +}); diff --git a/vendor/fxnode/test/browser/lifecycle.spec.ts b/vendor/fxnode/test/browser/lifecycle.spec.ts new file mode 100644 index 0000000..c0a405b --- /dev/null +++ b/vendor/fxnode/test/browser/lifecycle.spec.ts @@ -0,0 +1,490 @@ +import { expect, test } from "@playwright/test"; +import type { GraphState } from "@lib/core/types.js"; + +test("browser host disconnect lifecycle is opt-in and preserves same-task reparenting", async ({ page }) => { + await page.goto("/test/browser/index.html"); + const result = await page.evaluate(async () => { + const { prepareFxNodeBrowserHost } = await import("../../examples/shared/browser-host.js"); + const makeApi = () => { + let detaches = 0; + const api = { + feedInput() {}, + setViewport() {}, + detach() { + detaches++; + return Promise.resolve(); + }, + getHostSnapshot: () => ({ colorPickerOpen: false }), + onHostRequests: () => () => {}, + onCompositionChanges: () => () => {}, + onMutations: () => () => {}, + } as any; + return { api, count: () => detaches }; + }; + const explicitCanvas = document.createElement("canvas"), + automaticCanvas = document.createElement("canvas"), + replacementParent = document.createElement("div"); + document.body.append(explicitCanvas, automaticCanvas, replacementParent); + const explicitApi = makeApi(), + automaticApi = makeApi(), + explicitHost = prepareFxNodeBrowserHost({ canvas: explicitCanvas }), + automaticHost = prepareFxNodeBrowserHost({ + canvas: automaticCanvas, + lifecycle: "detach-on-disconnect", + }); + explicitHost.attach(explicitApi.api, explicitApi.api); + automaticHost.attach(automaticApi.api, automaticApi.api); + explicitCanvas.remove(); + replacementParent.append(automaticCanvas); + await new Promise((resolve) => setTimeout(resolve)); + const afterReparent = automaticApi.count(); + automaticCanvas.remove(); + await new Promise((resolve) => setTimeout(resolve)); + const resultValue = { + explicit: explicitApi.count(), + afterReparent, + afterRemoval: automaticApi.count(), + }; + explicitHost.destroy(); + automaticHost.destroy(); + replacementParent.remove(); + return resultValue; + }); + expect(result).toEqual({ explicit: 0, afterReparent: 0, afterRemoval: 1 }); +}); + +test("core lifecycle owns no DOM state and the host adapter cleans up its policy", async ({ page }) => { + test.setTimeout(45_000); + await page.goto("/test/browser/index.html"); + await page.evaluate(() => window.ready); + const result = await page.evaluate(async () => { + window.fxnodeHost.destroy(); + window.root.destroy(); + const { createFxNode } = await import("@lib/index.js"); + const { prepareFxNodeBrowserHost } = await import("../../examples/shared/browser-host.js"); + const { APPLICATION_ID, APPLICATION_RESOURCES, APPLICATION_VERSION, APPLICATION_HEADER_STYLES } = await import( + "../../examples/blender/nodes/application.js" + ); + const { exampleTheme } = await import("../../examples/shared/theme.js"); + const { anySocket, floatSocket } = await import("../../examples/blender/nodes/socket-types.js"); + const { valueNode } = await import("../../examples/blender/nodes/shader/value.js"); + const { applicationCompatibility } = await import("../../examples/blender/nodes/application.js"); + const layout = { schemaVersion: 1, graphId: "lifecycle", catalogVersion: 7, nodes: [], links: [], metadata: {} }; + const applicationOptions = { + applicationId: APPLICATION_ID, + applicationVersion: APPLICATION_VERSION, + resources: APPLICATION_RESOURCES, + }; + const state = { + graphId: layout.graphId as GraphState["graphId"], + catalogVersion: APPLICATION_VERSION, + nodes: [], + links: [], + metadata: {}, + }; + const canvas = document.querySelector("#primary")!; + canvas.setAttribute("tabindex", "7"); + canvas.style.touchAction = "pan-x"; + const width = canvas.width, + height = canvas.height; + const installValue = async (api: Awaited>) => { + await api.setTheme(exampleTheme); + await api.setHeaderStyles(APPLICATION_HEADER_STYLES); + await api.composeSocket(...anySocket); + await api.composeSocket(...floatSocket); + await api.setCompatibility(applicationCompatibility); + await api.composeNode(...valueNode); + }; + let later = 0; + for (let index = 0; index < 20; index++) { + const api = await createFxNode(applicationOptions); + if (index === 0) await installValue(api); + await api.setState(state, { expectedVersion: 0 }); + if (index === 0) { + api.onMutations(() => { + throw new Error("intentional subscriber failure"); + }); + api.onMutations(() => later++); + await api.dispatch({ type: "node.add", nodeType: "fxnode.shader.value", position: { x: 0, y: 0 } }); + } + const view = await api.attachView({ canvas, viewport: { width: 1200, height: 640, dpr: 1 } }); + const barrier = view.whenRendered(); + await view.detach(); + api.destroy(); + await barrier.catch((error) => error); + } + const host = prepareFxNodeBrowserHost({ canvas }), + api = await createFxNode(applicationOptions), + view = await api.attachView({ canvas, viewport: host.initialViewport }); + await api.setState(state, { expectedVersion: 0 }); + host.attach(api, view); + host.destroy(); + api.destroy(); + return { + later, + tabIndex: canvas.getAttribute("tabindex"), + touchAction: canvas.style.touchAction, + widthUnchanged: canvas.width === width, + heightUnchanged: canvas.height === height, + }; + }); + expect(result).toEqual({ + later: 1, + tabIndex: "7", + touchAction: "pan-x", + widthUnchanged: true, + heightUnchanged: true, + }); +}); + +test("bad load retains structured issues and returned values are detached", async ({ page }) => { + await page.goto("/test/browser/index.html"); + await page.evaluate(() => window.ready); + const result = await page.evaluate(async () => { + let error: { code?: string; issues?: readonly unknown[] } = {}; + try { + await window.root.load({ nope: true }); + } catch (value) { + error = value as typeof error; + } + const saved = await window.root.save(); + (saved.nodes as unknown as unknown[]).push({}); + const snapshot = await window.root.getState(); + (snapshot.nodes as unknown as unknown[]).push({}); + const nextSaved = await window.root.save(); + const nextSnapshot = await window.root.getState(); + window.fxnodeHost.destroy(); + window.root.destroy(); + return { + code: error.code, + issues: error.issues?.length ?? 0, + saved: nextSaved.nodes.length, + snapshot: nextSnapshot.nodes.length, + }; + }); + expect(result.code).toBeTruthy(); + expect(result.issues).toBeGreaterThan(0); + expect(result.saved).toBe(0); + expect(result.snapshot).toBe(0); +}); + +test("direct client never touches DOM lifecycle APIs", async ({ page }) => { + await page.goto("/test/browser/index.html"); + await page.evaluate(() => window.ready); + const result = await page.evaluate(async () => { + window.fxnodeHost.destroy(); + window.root.destroy(); + const { createFxNode } = await import("@lib/index.js"), + { APPLICATION_ID, APPLICATION_RESOURCES, APPLICATION_VERSION } = await import( + "../../examples/blender/nodes/application.js" + ), + canvas = document.querySelector("#primary")!, + NativeObserver = ResizeObserver; + const before = { + tabindex: canvas.getAttribute("tabindex"), + touchAction: canvas.style.touchAction, + width: canvas.width, + height: canvas.height, + children: document.body.childElementCount, + }; + class ThrowingObserver { + constructor() { + throw new Error("ResizeObserver constructed"); + } + } + Object.defineProperty(window, "ResizeObserver", { configurable: true, writable: true, value: ThrowingObserver }); + const original = { + add: canvas.addEventListener, + rect: canvas.getBoundingClientRect, + focus: canvas.focus, + capture: canvas.setPointerCapture, + }; + canvas.addEventListener = (() => { + throw new Error("listener registered"); + }) as typeof canvas.addEventListener; + canvas.getBoundingClientRect = (() => { + throw new Error("layout read"); + }) as typeof canvas.getBoundingClientRect; + canvas.focus = (() => { + throw new Error("focused"); + }) as typeof canvas.focus; + canvas.setPointerCapture = (() => { + throw new Error("captured"); + }) as typeof canvas.setPointerCapture; + try { + const version = APPLICATION_VERSION, + api = await createFxNode({ + applicationId: APPLICATION_ID, + applicationVersion: APPLICATION_VERSION, + resources: APPLICATION_RESOURCES, + }); + await api.setState( + { + graphId: "direct-dom-boundary" as GraphState["graphId"], + catalogVersion: version, + nodes: [], + links: [], + metadata: {}, + }, + { expectedVersion: 0 }, + ); + api.destroy(); + } finally { + canvas.addEventListener = original.add; + canvas.getBoundingClientRect = original.rect; + canvas.focus = original.focus; + canvas.setPointerCapture = original.capture; + Object.defineProperty(window, "ResizeObserver", { configurable: true, writable: true, value: NativeObserver }); + } + return { + before, + after: { + tabindex: canvas.getAttribute("tabindex"), + touchAction: canvas.style.touchAction, + width: canvas.width, + height: canvas.height, + children: document.body.childElementCount, + }, + }; + }); + expect(result.after).toEqual(result.before); +}); + +test("browser host is exclusive and preserves application DOM changes on cleanup", async ({ page }) => { + await page.goto("/test/browser/index.html"); + await page.evaluate(() => window.ready); + const result = await page.evaluate(async () => { + window.fxnodeHost.destroy(); + window.root.destroy(); + const { createFxNode } = await import("@lib/index.js"), + { prepareFxNodeBrowserHost } = await import("../../examples/shared/browser-host.js"), + { APPLICATION_ID, APPLICATION_RESOURCES, APPLICATION_VERSION } = await import( + "../../examples/blender/nodes/application.js" + ), + canvas = document.querySelector("#primary")!; + canvas.removeAttribute("tabindex"); + canvas.style.touchAction = ""; + const host = prepareFxNodeBrowserHost({ canvas }); + let duplicate = ""; + try { + prepareFxNodeBrowserHost({ canvas }); + } catch (error) { + duplicate = (error as Error).message; + } + const version = APPLICATION_VERSION, + api = await createFxNode({ + applicationId: APPLICATION_ID, + applicationVersion: APPLICATION_VERSION, + resources: APPLICATION_RESOURCES, + }); + await api.setState( + { + graphId: "host-lifecycle" as GraphState["graphId"], + catalogVersion: version, + nodes: [], + links: [], + metadata: {}, + }, + { expectedVersion: 0 }, + ); + const view = await api.attachView({ canvas, viewport: host.initialViewport }); + host.attach(api, view); + canvas.setAttribute("tabindex", "9"); + canvas.style.touchAction = "pan-y"; + host.destroy(); + api.destroy(); + const replacement = prepareFxNodeBrowserHost({ canvas }); + replacement.destroy(); + return { duplicate, tabindex: canvas.getAttribute("tabindex"), touchAction: canvas.style.touchAction }; + }); + expect(result).toEqual({ + duplicate: "Canvas already has an active FxNode browser host", + tabindex: "9", + touchAction: "pan-y", + }); +}); + +test("worker resource requests close delayed add-menu UI without intercepting input", async ({ page }) => { + await page.goto("/test/browser/index.html"); + await page.evaluate(() => window.ready); + const result = await page.evaluate(async () => { + const { prepareFxNodeBrowserHost } = await import("../../examples/shared/browser-host.js"), + canvas = document.createElement("canvas"); + canvas.style.cssText = "width:200px;height:120px"; + document.body.append(canvas); + let request: ((value: any) => void) | undefined, + pickers = 0, + forwarded = 0; + const target = Object.freeze({ + kind: "resource-open" as const, + authorization: Object.freeze({ token: "resource", graphVersion: 0, compositionRevision: 0 }), + resource: Object.freeze({ + id: "image", + kind: "image" as const, + title: "Image", + openTitle: "Open", + accept: Object.freeze(["image/png"]), + maxBytes: 100, + maxWidth: 10, + maxHeight: 10, + maxPixels: 100, + }), + }), + snapshot = Object.freeze({ + compositionRevision: 0, + colorPickerOpen: false, + selection: Object.freeze({ + nodeCount: 0, + linkCount: 0, + canRemove: false, + mute: Object.freeze({ enabled: false as const }), + }), + }); + const api = { + feedInput(value: any) { + if (value.kind === "pointer" && value.phase === "down" && value.button === 0) forwarded++; + }, + setViewport() {}, + getHostSnapshot: () => snapshot, + onHostRequests(callback: (value: any) => void) { + request = callback; + return () => {}; + }, + onCompositionChanges() { + return () => {}; + }, + onMutations() { + return () => {}; + }, + addNode() { + return Promise.resolve({ status: "noop", version: 0 }); + }, + } as any, + host = prepareFxNodeBrowserHost({ + canvas, + activateResourcePicker: () => { + pickers++; + }, + }); + host.attach(api, api); + const rect = canvas.getBoundingClientRect(), + fire = (pointerId: number, button: number, x: number) => { + canvas.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + pointerId, + pointerType: "mouse", + button, + buttons: button === 2 ? 2 : 1, + clientX: rect.left + x, + clientY: rect.top + 10, + }), + ); + }; + fire(81, 2, 80); + fire(82, 0, 10); + request?.(target); + request?.({ + kind: "add-node-menu", + viewPosition: { x: 80, y: 10 }, + compositionRevision: 0, + }); + const menus = document.querySelectorAll("[data-fxnode-add-menu]").length; + host.destroy(); + canvas.remove(); + return { pickers, menus, forwarded }; + }); + expect(result).toEqual({ pickers: 1, menus: 0, forwarded: 1 }); +}); + +test("default resource picker ignores obsolete asynchronous reads", async ({ page }) => { + await page.goto("/test/browser/index.html"); + await page.evaluate(() => window.ready); + const result = await page.evaluate(async () => { + const { prepareFxNodeBrowserHost } = await import("../../examples/shared/browser-host.js"), + canvas = document.createElement("canvas"); + canvas.style.cssText = "width:100px;height:100px"; + document.body.append(canvas); + const target = Object.freeze({ + kind: "resource-open" as const, + authorization: Object.freeze({ token: "resource", graphVersion: 0, compositionRevision: 0 }), + resource: Object.freeze({ + id: "image", + kind: "image" as const, + title: "Image", + openTitle: "Open", + accept: Object.freeze(["image/png"]), + maxBytes: 100, + maxWidth: 10, + maxHeight: 10, + maxPixels: 100, + }), + }), + snapshot = Object.freeze({ + compositionRevision: 0, + colorPickerOpen: false, + selection: Object.freeze({ + nodeCount: 0, + linkCount: 0, + canRemove: false, + mute: Object.freeze({ enabled: false as const }), + }), + }); + let resolveA!: (value: ArrayBuffer) => void, resolveB!: (value: ArrayBuffer) => void; + const a = new File([new Uint8Array([1])], "a.png", { type: "image/png" }), + b = new File([new Uint8Array([2])], "b.png", { type: "image/png" }); + Object.defineProperty(a, "arrayBuffer", { + value: () => new Promise((resolve) => (resolveA = resolve)), + }); + Object.defineProperty(b, "arrayBuffer", { + value: () => new Promise((resolve) => (resolveB = resolve)), + }); + let request: ((value: any) => void) | undefined; + const provided: string[] = [], + api = { + feedInput() {}, + setViewport() {}, + getHostSnapshot: () => snapshot, + onHostRequests(callback: (value: any) => void) { + request = callback; + return () => {}; + }, + onCompositionChanges() { + return () => {}; + }, + onMutations() { + return () => {}; + }, + addNode() { + return Promise.resolve({ status: "noop", version: 0 }); + }, + provideResource(_authorization: unknown, data: { name: string }) { + provided.push(data.name); + return Promise.resolve({ status: "committed", version: provided.length }); + }, + } as any, + host = prepareFxNodeBrowserHost({ canvas }); + host.attach(api, api); + const choose = (file: File) => { + request?.(target); + const input = document.querySelector("[data-fxnode-resource-file]")!; + Object.defineProperty(input, "files", { + configurable: true, + value: { 0: file, length: 1, item: (index: number) => (index === 0 ? file : null) }, + }); + input.dispatchEvent(new Event("change")); + }; + choose(a); + choose(b); + resolveB(new Uint8Array([2]).buffer); + await Promise.resolve(); + await Promise.resolve(); + resolveA(new Uint8Array([1]).buffer); + await Promise.resolve(); + await Promise.resolve(); + host.destroy(); + canvas.remove(); + return provided; + }); + expect(result).toEqual(["b.png"]); +}); diff --git a/vendor/fxnode/test/browser/link-tools.spec.ts b/vendor/fxnode/test/browser/link-tools.spec.ts new file mode 100644 index 0000000..441a3b9 --- /dev/null +++ b/vendor/fxnode/test/browser/link-tools.spec.ts @@ -0,0 +1,177 @@ +import { expect, test } from "@playwright/test"; + +test("M muting is one paired gesture for bypass nodes and generators", async ({ page }) => { + await page.goto("/examples/blender/"); + await page.evaluate(() => window.fxnodeExample.ready); + const canvas = page.locator("#graph"); + await page.evaluate(() => { + const api = window.fxnodeExample.root!, + view = window.fxnodeExample.view!; + const state = { mutations: [] as number[], snapshots: [] as number[] }; + (window as typeof window & { muteEvents: typeof state }).muteEvents = state; + // Subscriber exceptions are deliberately isolated from subsequent listeners. + api.onMutations(() => { + throw new Error("intentional test subscriber"); + }); + api.onMutations((event) => state.mutations.push(event.version)); + api.onSnapshots((event) => state.snapshots.push(event.version)); + }); + const baseline = (await page.evaluate(() => window.fxnodeExample.root!.getState())).version; + + // Fixture coordinates use the documented 1200x640 viewport and world origin + // (600,320). Header centers are stable layout data, not worker introspection. + await canvas.click({ position: { x: 380, y: 160 } }); // Math (-300,170), width 160 + await canvas.press("m"); + let snapshot = await page.evaluate(() => window.fxnodeExample.root!.getState()); + expect(snapshot.nodes.find((node) => node.id === "math")?.muted).toBe(true); + expect( + await page.evaluate( + () => (window as typeof window & { muteEvents: { mutations: number[]; snapshots: number[] } }).muteEvents, + ), + ).toEqual({ mutations: [baseline + 1], snapshots: [baseline + 1] }); + + await canvas.press("m"); + snapshot = await page.evaluate(() => window.fxnodeExample.root!.getState()); + expect(snapshot.nodes.find((node) => node.id === "math")?.muted).toBe(false); + expect( + await page.evaluate( + () => (window as typeof window & { muteEvents: { mutations: number[]; snapshots: number[] } }).muteEvents, + ), + ).toEqual({ mutations: [baseline + 1, baseline + 2], snapshots: [baseline + 1, baseline + 2] }); + + await canvas.click({ position: { x: 580, y: 140 } }); // Noise generator (-100,190) + await canvas.press("m"); + snapshot = await page.evaluate(() => window.fxnodeExample.root!.getState()); + expect(snapshot.nodes.find((node) => node.id === "noise")?.muted).toBe(true); + expect(snapshot.version).toBe(baseline + 3); + expect( + await page.evaluate( + () => (window as typeof window & { muteEvents: { mutations: number[]; snapshots: number[] } }).muteEvents, + ), + ).toEqual({ + mutations: [baseline + 1, baseline + 2, baseline + 3], + snapshots: [baseline + 1, baseline + 2, baseline + 3], + }); + await canvas.press("Control+z"); + snapshot = await page.evaluate(() => window.fxnodeExample.root!.getState()); + expect(snapshot.nodes.find((node) => node.id === "noise")?.muted).toBe(false); + expect(snapshot.nodes.find((node) => node.id === "math")?.muted).toBe(false); +}); + +test("equivalent selection summaries retain identity and API mute is one-step undoable", async ({ page }) => { + await page.goto("/examples/blender/"); + await page.evaluate(() => window.fxnodeExample.ready); + const canvas = page.locator("#graph"); + expect( + await page.evaluate(async () => { + const api = window.fxnodeExample.root!, + view = window.fxnodeExample.view!; + await view.addNode({ typeId: "fxnode.shader.value", viewPosition: { x: 900, y: 500 }, nodeId: "summary-a" }); + const first = view.getHostSnapshot().selection; + await view.addNode({ typeId: "fxnode.shader.value", viewPosition: { x: 1100, y: 500 }, nodeId: "summary-b" }); + return view.getHostSnapshot().selection === first; + }), + ).toBe(true); + await page.evaluate(() => + window.fxnodeExample.root!.dispatch({ type: "node.mute", id: "summary-b" as never, value: true }), + ); + expect((await page.evaluate(() => window.fxnodeExample.view!.getHostSnapshot())).selection.mute).toEqual({ + enabled: true, + state: "all-muted", + }); + const result = await page.evaluate(async () => { + const api = window.fxnodeExample.root!, + view = window.fxnodeExample.view!, + order: string[] = []; + api.onMutations((event) => order.push(`mutation:${event.version}`)); + api.onSnapshots((event) => order.push(`snapshot:${event.version}`)); + const receipt = await view.setSelectedMuted(false); + order.push(`receipt:${receipt.version}`); + const unmuted = structuredClone(view.getHostSnapshot().selection.mute), + undo = await api.undo(), + muted = structuredClone(view.getHostSnapshot().selection.mute), + redo = await api.redo(), + again = structuredClone(view.getHostSnapshot().selection.mute); + return { receipt, unmuted, undo, muted, redo, again, order }; + }); + expect(result.unmuted).toEqual({ enabled: true, state: "all-unmuted" }); + expect(result.muted).toEqual({ enabled: true, state: "all-muted" }); + expect(result.again).toEqual({ enabled: true, state: "all-unmuted" }); + expect(result.receipt.status).toBe("committed"); + expect(result.undo.status).toBe("committed"); + expect(result.redo.status).toBe("committed"); + expect(result.order).toEqual([ + `mutation:${result.receipt.version}`, + `snapshot:${result.receipt.version}`, + `receipt:${result.receipt.version}`, + `mutation:${result.undo.version}`, + `snapshot:${result.undo.version}`, + `mutation:${result.redo.version}`, + `snapshot:${result.redo.version}`, + ]); +}); + +test("collapse chevron animates for click, H, API, and interrupted reversal then idles", async ({ page }) => { + await page.addInitScript(() => { + let frames = 0; + const original = CanvasRenderingContext2D.prototype.drawImage; + Object.defineProperty(CanvasRenderingContext2D.prototype, "drawImage", { + value: function (this: CanvasRenderingContext2D, ...args: unknown[]) { + frames++; + return Reflect.apply(original, this, args); + }, + }); + Object.defineProperty(window, "frameDraws", { get: () => frames }); + }); + await page.goto("/examples/blender/"); + await page.evaluate(() => window.fxnodeExample.ready); + const canvas = page.locator("#graph"); + await canvas.click({ position: { x: 380, y: 160 } }); + const chevron = () => + page.evaluate(() => + Array.from( + document.querySelector("#graph")!.getContext("2d")!.getImageData(304, 154, 16, 16).data, + ), + ); + const expanded = await chevron(), + before = await page.evaluate(() => (window as typeof window & { frameDraws: number }).frameDraws); + await canvas.click({ position: { x: 312, y: 162 } }); + expect( + (await page.evaluate(() => window.fxnodeExample.root!.getState())).nodes.find((node) => node.id === "math") + ?.collapsed, + ).toBe(true); + await page.waitForTimeout(180); + const collapsed = await chevron(), + animated = await page.evaluate(() => (window as typeof window & { frameDraws: number }).frameDraws); + expect(collapsed).not.toEqual(expanded); + expect(animated - before).toBeGreaterThan(1); + await canvas.press("h"); + await page.waitForTimeout(180); + expect( + (await page.evaluate(() => window.fxnodeExample.root!.getState())).nodes.find((node) => node.id === "math") + ?.collapsed, + ).toBe(false); + expect(await chevron()).toEqual(expanded); + await page.evaluate(async () => { + const api = window.fxnodeExample.root!, + view = window.fxnodeExample.view!, + id = (await api.getState()).nodes.find((node) => node.id === "math")!.id; + return api.dispatch({ type: "node.collapse", id, value: true }); + }); + await page.waitForTimeout(30); + await page.evaluate(async () => { + const api = window.fxnodeExample.root!, + view = window.fxnodeExample.view!, + id = (await api.getState()).nodes.find((node) => node.id === "math")!.id; + return api.dispatch({ type: "node.collapse", id, value: false }); + }); + await page.waitForTimeout(180); + expect( + (await page.evaluate(() => window.fxnodeExample.root!.getState())).nodes.find((node) => node.id === "math") + ?.collapsed, + ).toBe(false); + expect(await chevron()).toEqual(expanded); + const idle = await page.evaluate(() => (window as typeof window & { frameDraws: number }).frameDraws); + await page.waitForTimeout(180); + expect(await page.evaluate(() => (window as typeof window & { frameDraws: number }).frameDraws)).toBe(idle); +}); diff --git a/vendor/fxnode/test/browser/parity.visual.spec.ts b/vendor/fxnode/test/browser/parity.visual.spec.ts new file mode 100644 index 0000000..94d110e --- /dev/null +++ b/vendor/fxnode/test/browser/parity.visual.spec.ts @@ -0,0 +1,51 @@ +import { expect, test, type Locator } from "@playwright/test"; + +async function stable(canvas: Locator): Promise { + const first = await canvas.screenshot(); + await canvas.page().evaluate(() => window.parityExample.view.whenRendered()); + expect((await canvas.screenshot()).equals(first)).toBe(true); +} + +test("@visual structural parity baseline and focused controls", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 960 }); + await page.goto("/examples/blender/parity/"); + await page.waitForFunction(() => Boolean(window.parityExample)); + const canvas = page.locator("canvas"); + await canvas.press("Home"); + await page.evaluate(() => window.parityExample.view.whenRendered()); + await stable(canvas); + const regions = await canvas.evaluate((element) => { + const context = (element as HTMLCanvasElement).getContext("2d")!; + const regions = [ + { x: 40, y: 110, w: 260, h: 290 }, + { x: 1025, y: 110, w: 370, h: 330 }, + { x: 650, y: 615, w: 470, h: 240 }, + ]; + return regions.map((region) => { + const data = context.getImageData(region.x, region.y, region.w, region.h).data; + let body = 0, + controls = 0; + for (let i = 0; i < data.length; i += 4) { + if (data[i] === 53 && data[i + 1] === 56 && data[i + 2] === 62) body++; + if (data[i] === 36 && data[i + 1] === 39 && data[i + 2] === 43) controls++; + } + return { body, controls }; + }); + }); + for (const region of regions) expect(region.body).toBeGreaterThan(1000); + expect(regions.reduce((sum, region) => sum + region.controls, 0)).toBeGreaterThan(500); + await expect(canvas).toHaveScreenshot("parity-structural.png", { animations: "disabled" }); + + // Crops deliberately cover the widget structures rather than Blender pixels: + // image selectors/projection, grading mode rows, and ramp/noise conditional rows. + const imageGrading = await page.screenshot({ + clip: { x: 90, y: 500, width: 1260, height: 420 }, + animations: "disabled", + }); + expect(imageGrading).toMatchSnapshot("parity-image-grading.png"); + const rampNoise = await page.screenshot({ + clip: { x: 380, y: 55, width: 1060, height: 450 }, + animations: "disabled", + }); + expect(rampNoise).toMatchSnapshot("parity-ramp-noise.png"); +}); diff --git a/vendor/fxnode/test/browser/parity.visual.spec.ts-snapshots/parity-image-grading-linux.png b/vendor/fxnode/test/browser/parity.visual.spec.ts-snapshots/parity-image-grading-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..0c6ba69280e334b41bf5dfe640a407e762c0488e GIT binary patch literal 54272 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 t-jF}`A2_A-|NrbNYbl@=Y@kfS%B!ruS@ literal 0 HcmV?d00001 diff --git a/vendor/fxnode/test/browser/parity.visual.spec.ts-snapshots/parity-ramp-noise-linux.png b/vendor/fxnode/test/browser/parity.visual.spec.ts-snapshots/parity-ramp-noise-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..3932d72e56f36e3c4673ee797e6ecf7db4a666c3 GIT binary patch literal 59961 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%0A#!>tm`Pf=W1y)%+kh0~AF(Xn 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 lA0DD-HxEFAm literal 0 HcmV?d00001 diff --git a/vendor/fxnode/test/browser/parity.visual.spec.ts-snapshots/parity-structural-linux.png b/vendor/fxnode/test/browser/parity.visual.spec.ts-snapshots/parity-structural-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..fe33b31103587e5e02e9da32e1ddb24e2837de3a GIT binary patch literal 141472 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 literal 0 HcmV?d00001 diff --git a/vendor/fxnode/test/browser/presentation-render.html b/vendor/fxnode/test/browser/presentation-render.html new file mode 100644 index 0000000..f18e37a --- /dev/null +++ b/vendor/fxnode/test/browser/presentation-render.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/vendor/fxnode/test/browser/presentation-render.spec.ts b/vendor/fxnode/test/browser/presentation-render.spec.ts new file mode 100644 index 0000000..90439c9 --- /dev/null +++ b/vendor/fxnode/test/browser/presentation-render.spec.ts @@ -0,0 +1,28 @@ +import { expect, test } from "@playwright/test"; + +test("renderer paints composition-resolved background, header, socket, and link colors", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto("/test/browser/presentation-render.html"); + await page.waitForTimeout(500); + expect(errors).toEqual([]); + const result = await page.evaluate( + () => + ( + window as unknown as { + presentationResult: { + background: number[]; + header: number[]; + socket: number[]; + linkContainsCustomColor: boolean; + }; + } + ).presentationResult, + ); + expect(result).toEqual({ + background: [1, 2, 3, 255], + header: [118, 84, 171, 255], + socket: [161, 178, 195, 255], + linkContainsCustomColor: true, + }); +}); diff --git a/vendor/fxnode/test/browser/presentation-render.ts b/vendor/fxnode/test/browser/presentation-render.ts new file mode 100644 index 0000000..e62a367 --- /dev/null +++ b/vendor/fxnode/test/browser/presentation-render.ts @@ -0,0 +1,130 @@ +import { compileFxNodeComposition } from "@lib/composition/index.js"; +import { bindFxNodeHeadless } from "@lib/headless-runtime.js"; +import { layoutGraph } from "@lib/layout/layout-graph.js"; +import { worldToView } from "@lib/layout/geometry.js"; +import { renderCanvas } from "@lib/render/canvas-renderer.js"; +import { linkId } from "@lib/core/types.js"; + +const theme = { + background: "#010203", + grid: "#010203", + frame: "#070809", + frameHeader: "#0a0b0c", + body: "#0d0e0f", + control: "#101112", + controlFill: "#131415", + controlEditing: "#161718", + textSelection: "#191a1b", + outline: "#1c1d1e", + text: "#1f2021", + muted: "#222324", + shadow: "#00000000", + nodeSelected: "#28292a", + nodeActive: "#2b2c2d", + unknownHeader: "#2e2f30", + unknownSocket: "#313233", + linkMuted: "#343536", + knifeMuted: "#373839", + emphasis: "#3a3b3c", + focus: "#3d3e3f", + editOutline: "#404142", + resize: "#434445", + muteOverlay: "#464748", + boxSelectionFill: "#494a4b", + checkerLight: "#4c4d4e", + checkerDark: "#4f5051", + widgetBorder: "#525354", + rampBorder: "#555657", + resourceBackground: "#58595a", +} as const; +const base = () => + ({ + version: 1, + behavior: "standard", + style: "custom", + parameters: {}, + muteBypass: [], + migrations: [], + }) as const; +const composition = { + schemaVersion: 2, + id: "browser-render-proof", + version: 1, + compatibility: { wildcardInputTypes: [] }, + theme, + socketTypes: { signal: { title: "Signal", color: "#a1b2c3", acceptsFrom: ["signal"] } }, + nodeStyles: { custom: { header: "#7654ab" } }, + resources: {}, + nodes: { + source: { + ...base(), + title: "Source", + sockets: { + out: { + title: "Out", + direction: "output", + type: "signal", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [{ kind: "socket", socket: "out" }], + }, + target: { + ...base(), + title: "Target", + sockets: { + in: { + title: "In", + direction: "input", + type: "signal", + maxIncomingLinks: 1, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [{ kind: "socket", socket: "in" }], + }, + }, +} as const; +const compiled = compileFxNodeComposition(composition), + runtime = bindFxNodeHeadless(compiled); +const source = runtime.materializeNode("source", "source", { x: -200, y: 80 }), + target = runtime.materializeNode("target", "target", { x: 100, y: 80 }); +const link = { + id: linkId("wire"), + fromNodeId: source.id, + fromSocketId: source.sockets[0]!.id, + toNodeId: target.id, + toSocketId: target.sockets[0]!.id, + muted: false, + extensions: {}, +}; +const graph = { ...runtime.emptyDocument("render"), nodes: { source, target }, links: { wire: link } }; +const transform = { center: { x: 0, y: 0 }, zoom: 1, viewport: { x: 500, y: 300 }, dpr: 1 }, + layout = layoutGraph(compiled, graph, transform); +const canvas = document.querySelector("#presentation")!, + context = canvas.getContext("2d")!; +renderCanvas(context as unknown as OffscreenCanvasRenderingContext2D, layout, compiled.theme); +const pixel = (x: number, y: number) => Array.from(context.getImageData(Math.round(x), Math.round(y), 1, 1).data); +const contains = (x: number, y: number, rgba: readonly number[]) => { + const data = context.getImageData(Math.round(x) - 3, Math.round(y) - 3, 7, 7).data; + for (let i = 0; i < data.length; i += 4) if (rgba.every((v, j) => data[i + j] === v)) return true; + return false; +}; +const node = layout.nodes.get(source.id)!, + socket = layout.sockets.get(source.sockets[0]!.id)!, + wire = layout.links.get("wire" as never)!, + header = worldToView({ x: node.bounds.x + 60, y: node.bounds.y - 12 }, transform), + socketPoint = worldToView(socket.anchor, transform), + middle = worldToView(wire.points[Math.floor(wire.points.length / 2)]!, transform); +const result = { + background: pixel(3, 3), + header: pixel(header.x, header.y), + socket: pixel(socketPoint.x, socketPoint.y), + linkContainsCustomColor: contains(middle.x, middle.y, [161, 178, 195, 255]), +}; +(window as unknown as { presentationResult: typeof result }).presentationResult = result; diff --git a/vendor/fxnode/test/browser/ramp.spec.ts b/vendor/fxnode/test/browser/ramp.spec.ts new file mode 100644 index 0000000..1463019 --- /dev/null +++ b/vendor/fxnode/test/browser/ramp.spec.ts @@ -0,0 +1,274 @@ +import { expect, test, type Page } from "@playwright/test"; +import type { FxNode } from "@lib/index.js"; +import type { GraphLayoutV2, GraphSnapshot } from "@lib/core/types.js"; +import type { ColorRamp } from "@lib/widgets/color-ramp.js"; + +type Point = Readonly<{ x: number; y: number }>; +type Events = { mutations: number[]; snapshots: number[] }; +type State = { snapshot: GraphSnapshot; layout: GraphLayoutV2; events: Events }; +type RampWindow = Window & + typeof globalThis & { + rampTest: { root: FxNode | null; view: import("@lib/index.js").FxNodeView | null; ready: Promise }; + rampEvents: Events; + }; + +// The fixture ramp's compound world origin is frame (-300,250) + child (40,-40), +// hence (-260,210), width 320. At zoom 1 the canvas origin is (600,320). +// Layout's documented ramp bounds start at node.x+10 and node.y-header-2 rows-3: +// screen origin (350,185), width 300; rows are toolbar +0, menus +22, +// gradient +46, handles +74, and details +104. +const P = Object.freeze({ + add: { x: 368, y: 195 }, + remove: { x: 404, y: 195 }, + flip: { x: 479, y: 195 }, + distribute: { x: 593, y: 195 }, + mode: { x: 395, y: 217 }, + interpolation: { x: 501, y: 217 }, + hue: { x: 614, y: 217 }, + gradient: { x: 500, y: 242 }, + firstHandle: { x: 386, y: 271 }, + selector: { x: 387, y: 299 }, + position: { x: 483, y: 299 }, + swatch: { x: 380, y: 321 }, + r: { x: 440, y: 321 }, + g: { x: 500, y: 321 }, + b: { x: 560, y: 321 }, + a: { x: 620, y: 321 }, +} satisfies Record); + +async function open(page: Page) { + await page.goto("/examples/blender/ramp-test/"); + await page.evaluate(() => (window as unknown as RampWindow).rampTest.ready); + await page.evaluate(() => { + const w = window as unknown as RampWindow; + w.rampEvents = { mutations: [], snapshots: [] }; + w.rampTest.root!.onMutations((e) => w.rampEvents.mutations.push(e.version)); + w.rampTest.root!.onSnapshots((e) => w.rampEvents.snapshots.push(e.version)); + }); + return page.locator("#ramp"); +} +const state = (page: Page): Promise => + page.evaluate(async () => { + const w = window as unknown as RampWindow; + return { + snapshot: await w.rampTest.root!.getState(), + layout: await w.rampTest.root!.save(), + events: structuredClone(w.rampEvents), + }; + }); +const ramp = (s: State): ColorRamp => + (s.layout.nodes.find((n) => n.id === "ramp")!.parameters.ramp as { readonly kind: "json"; readonly value: unknown }) + .value as ColorRamp; +function pairs(before: State, after: State, count: number) { + const m = after.events.mutations.slice(before.events.mutations.length), + s = after.events.snapshots.slice(before.events.snapshots.length); + expect(m).toEqual(s); + expect(m).toHaveLength(count); + expect(after.snapshot.version - before.snapshot.version).toBe(count); +} +async function drag(page: Page, from: Point, dx: number, shift = false) { + const box = await page.locator("#ramp").boundingBox(); + if (!box) throw new Error("Canvas missing"); + if (shift) await page.keyboard.down("Shift"); + await page.mouse.move(box.x + from.x, box.y + from.y); + await page.mouse.down(); + await page.mouse.move(box.x + from.x + dx, box.y + from.y, { steps: 5 }); + return async () => { + await page.mouse.up(); + if (shift) await page.keyboard.up("Shift"); + }; +} +const undo = (page: Page) => page.evaluate(() => (window as unknown as RampWindow).rampTest.root!.undo()); + +test("handle drag previews pixels, commits once, and Escape/RMB cancel", async ({ page }) => { + const canvas = await open(page), + a = await state(page), + pixels = await canvas.screenshot(), + release = await drag(page, P.firstHandle, 45); + await expect.poll(async () => (await canvas.screenshot()).equals(pixels)).toBe(false); + const preview = await state(page); + expect(preview).toEqual(a); + await release(); + const b = await state(page); + pairs(a, b, 1); + expect(ramp(b).stops.find((s) => s.id === "red")!.position).toBeGreaterThan(0.2); + await page.evaluate(() => (window as unknown as RampWindow).rampTest.view!.whenRendered()); + const stopped = await canvas.screenshot(); + await page.mouse.move(700, 400); + expect(await canvas.screenshot()).toEqual(stopped); + const cancel = await drag(page, { ...P.firstHandle, x: 386 + 45 }, 30); + await canvas.press("Escape"); + await cancel(); + pairs(b, await state(page), 0); + const cancelRmb = await drag(page, { ...P.firstHandle, x: 386 + 45 }, -25); + await page.mouse.down({ button: "right" }); + await page.mouse.up({ button: "right" }); + await cancelRmb(); + pairs(b, await state(page), 0); +}); + +test("plain gradient click inserts one transiently-active stop and undo removes it", async ({ page }) => { + const canvas = await open(page), + a = await state(page); + await canvas.click({ position: P.gradient }); + const b = await state(page); + pairs(a, b, 1); + expect(ramp(b).stops).toHaveLength(4); + expect(ramp(b).stops.filter((s) => !ramp(a).stops.some((old) => old.id === s.id))).toHaveLength(1); + expect(JSON.stringify(b.layout)).not.toContain("activeRamp"); + expect(JSON.stringify(b.snapshot)).not.toContain("activeRamp"); + await undo(page); + const c = await state(page); + pairs(b, c, 1); + expect(ramp(c)).toEqual(ramp(a)); +}); + +test("transparent gradient checker is clipped to its exact bounds", async ({ page }) => { + const canvas = await open(page); + const pixels = await canvas.evaluate((element) => { + const context = (element as HTMLCanvasElement).getContext("2d")!; + return { + x: Array.from(context.getImageData(643, 240, 1, 1).data), + y: Array.from(context.getImageData(500, 260, 1, 1).data), + }; + }); + for (const pixel of [pixels.x, pixels.y]) { + expect(pixel[3]).toBe(255); + expect(pixel.slice(0, 3)).not.toEqual([119, 119, 119]); + expect(pixel.slice(0, 3)).not.toEqual([170, 170, 170]); + } +}); + +test("toolbar add/remove commits atomically and enforces two stops; shortcuts stay scoped", async ({ page }) => { + const canvas = await open(page), + a = await state(page); + await canvas.click({ position: P.firstHandle }); + await canvas.click({ position: P.add }); + const b = await state(page); + pairs(a, b, 1); + expect(ramp(b).stops).toHaveLength(4); + await canvas.click({ position: P.remove }); + const c = await state(page); + pairs(b, c, 1); + expect(ramp(c).stops).toHaveLength(3); + await canvas.press("Delete"); + const d = await state(page); + pairs(c, d, 1); + expect(ramp(d).stops).toHaveLength(2); + await canvas.press("Delete"); + pairs(d, await state(page), 0); + await canvas.press("g"); + await canvas.press("m"); + expect((await state(page)).layout.nodes).toHaveLength(2); +}); + +test("mode, interpolation, and hue controls cycle legal values and survive save/load", async ({ page }) => { + const canvas = await open(page); + let a = await state(page); + const controls: readonly [Point, "colorMode" | "interpolation" | "hueInterpolation", readonly string[]][] = [ + [P.mode, "colorMode", ["rgb", "hsv", "hsl"]], + [P.interpolation, "interpolation", ["linear", "ease", "constant", "cardinal", "b-spline"]], + [P.hue, "hueInterpolation", ["near", "far", "clockwise", "counter-clockwise"]], + ]; + for (const [point, key, legal] of controls) { + await canvas.click({ position: point }); + const b = await state(page); + pairs(a, b, 1); + expect(legal).toContain(ramp(b)[key]); + a = b; + } + const saved = a.layout, + persisted = ramp(a); + await canvas.click({ position: P.mode }); + const changed = await state(page); + await page.evaluate( + (layout: unknown) => (window as unknown as RampWindow).rampTest.root!.load(layout), + saved as unknown, + ); + const loaded = await state(page); + pairs(changed, loaded, 1); + expect(ramp(loaded)).toEqual(persisted); +}); + +test("position scrub alters only the active stop position", async ({ page }) => { + await open(page); + let a = await state(page); + const finishPos = await drag(page, P.position, 10, true); + await finishPos(); + let b = await state(page); + pairs(a, b, 1); + expect(ramp(b).stops[0]!.position).toBeCloseTo(ramp(a).stops[0]!.position + 0.01); + expect(ramp(b).stops[0]!.color).toEqual(ramp(a).stops[0]!.color); +}); + +test("active stop swatch opens an Oklch picker with transient preview and atomic commit", async ({ page }) => { + const canvas = await open(page), + before = await state(page); + await canvas.click({ position: P.swatch }); + const box = await canvas.boundingBox(); + if (!box) throw new Error("Canvas missing"); + await page.mouse.move(box.x + 756, box.y + 409); + await page.mouse.down(); + await page.mouse.move(box.x + 816, box.y + 409, { steps: 4 }); + pairs(before, await state(page), 0); + await page.mouse.up(); + pairs(before, await state(page), 0); + await canvas.click({ position: { x: 676, y: 322 } }); + const committed = await state(page); + pairs(before, committed, 1); + expect(ramp(committed).stops[0]!.color).not.toEqual(ramp(before).stops[0]!.color); + await canvas.click({ position: P.swatch }); + await page.mouse.move(box.x + 756, box.y + 409); + await page.mouse.down(); + await page.mouse.move(box.x + 776, box.y + 372); + await page.mouse.up(); + await canvas.press("Escape"); + pairs(committed, await state(page), 0); +}); + +test("Backspace resets ramp or active color, undo restores, and Escape is inert", async ({ page }) => { + const canvas = await open(page), + a = await state(page); + await canvas.click({ position: P.position }); + await canvas.press("Backspace"); + const reset = await state(page); + pairs(a, reset, 1); + expect(ramp(reset).stops.map((s) => s.position)).toEqual([0, 1]); + await undo(page); + const restored = await state(page); + pairs(reset, restored, 1); + expect(ramp(restored)).toEqual(ramp(a)); + await canvas.hover({ position: P.swatch }); + await canvas.press("Backspace"); + const black = await state(page); + pairs(restored, black, 1); + expect(ramp(black).stops[0]!.color).toEqual([0, 0, 0, 1]); + await canvas.press("Escape"); + pairs(black, await state(page), 0); +}); + +test("flip and distribute are one-commit, one-step-undo tools", async ({ page }) => { + const canvas = await open(page); + let a = await state(page); + await canvas.click({ position: P.flip }); + let b = await state(page); + pairs(a, b, 1); + expect(ramp(b).stops.map((s) => s.position)).toEqual([ + expect.closeTo(0.1), + expect.closeTo(0.65), + expect.closeTo(0.9), + ]); + await undo(page); + let c = await state(page); + pairs(b, c, 1); + expect(ramp(c)).toEqual(ramp(a)); + a = c; + await canvas.click({ position: P.distribute }); + b = await state(page); + pairs(a, b, 1); + expect(ramp(b).stops.map((s) => s.position)).toEqual([0.1, 0.5, 0.9]); + await undo(page); + c = await state(page); + pairs(b, c, 1); + expect(ramp(c)).toEqual(ramp(a)); +}); diff --git a/vendor/fxnode/test/browser/requested-nodes.spec.ts b/vendor/fxnode/test/browser/requested-nodes.spec.ts new file mode 100644 index 0000000..fcd19a4 --- /dev/null +++ b/vendor/fxnode/test/browser/requested-nodes.spec.ts @@ -0,0 +1,299 @@ +import { expect, test, type Page } from "@playwright/test"; + +type Point = Readonly<{ x: number; y: number }>; +type Saved = Awaited>; + +// The parity page is deliberately used instead of a test-only worker hook. At +// 1280x1200, zoom one maps world (0,0) to canvas (640,600). These are centers +// of public canvas controls derived from the descriptor layout's 24px rows. +const P = Object.freeze({ + image: { + resource: { x: 260, y: 525 }, + interpolation: { x: 312, y: 552 }, + projection: { x: 312, y: 576 }, + flatExtension: { x: 312, y: 600 }, + boxBlend: { x: 312, y: 600 }, + boxExtension: { x: 312, y: 624 }, + }, + compositor: { + resource: { x: 420, y: 965 }, + source: { x: 464, y: 992 }, + frames: { x: 464, y: 1016 }, + start: { x: 464, y: 1040 }, + offset: { x: 464, y: 1064 }, + cyclic: { x: 464, y: 1088 }, + refresh: { x: 464, y: 1112 }, + }, + noise: { dimensions: { x: 653, y: 456 }, type: { x: 653, y: 480 } }, + master: { + mode: { x: 961, y: 896 }, + scalar: { x: 729, y: 1048 }, + liftWheel: { x: 729, y: 990 }, + whiteTemperature: { x: 961, y: 944 }, + eye: { x: 860, y: 1016 }, + }, +}); + +async function open(page: Page) { + await page.setViewportSize({ width: 1280, height: 1200 }); + await page.goto("/examples/blender/parity/"); + await page.waitForFunction(() => !!window.parityExample); + await page.evaluate(() => { + const w = window as typeof window & { requestedEvents: { m: number[]; s: number[] } }; + w.requestedEvents = { m: [], s: [] }; + window.parityExample!.root.onMutations((e) => w.requestedEvents.m.push(e.version)); + window.parityExample!.root.onMutations(() => { + throw new Error("intentional requested-node subscriber failure"); + }); + window.parityExample!.root.onSnapshots((e) => w.requestedEvents.s.push(e.version)); + }); + return page.locator("#graph"); +} +async function saved(page: Page) { + return page.evaluate(async () => ({ + layout: await window.parityExample!.root.save(), + snapshot: await window.parityExample!.root.getState(), + events: (window as typeof window & { requestedEvents: { m: number[]; s: number[] } }).requestedEvents, + })); +} +const node = (state: Saved, id: string) => state.layout.nodes.find((n) => n.id === id)!; +const parameter = (state: Saved, id: string, key: string) => node(state, id).parameters[key]!; +function paired(a: Saved, b: Saved, count = 1) { + expect(b.snapshot.version - a.snapshot.version).toBe(count); + expect(b.events.m.slice(a.events.m.length)).toEqual(b.events.s.slice(a.events.s.length)); + expect(b.events.m.length - a.events.m.length).toBe(count); +} +async function pixels(page: Page, x: number, y: number, w: number, h: number) { + return page.locator("#graph").evaluate( + (c, r) => { + const d = (c as HTMLCanvasElement).getContext("2d")!.getImageData(r.x, r.y, r.w, r.h).data; + let hash = 2166136261; + for (const v of d) hash = Math.imul(hash ^ v, 16777619); + return hash >>> 0; + }, + { x, y, w, h }, + ); +} +async function scrub(page: Page, p: Point, dx = 25) { + const box = await page.locator("#graph").boundingBox(); + if (!box) throw Error("canvas bounds missing"); + await page.mouse.move(box.x + p.x, box.y + p.y); + await page.mouse.down(); + await page.mouse.move(box.x + p.x + dx, box.y + p.y, { steps: 4 }); + await page.mouse.up(); +} +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACAQMAAABIeJ9nAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAABlBMVEUzZpn////xDcYdAAAAAWJLR0QB/wIt3gAAAAd0SU1FB+oHFQw4FzO6KaEAAAAMSURBVAjXY2BgYAAAAAQAASc0JwoAAAAldEVYdGRhdGU6Y3JlYXRlADIwMjYtMDctMjFUMTI6NTY6MjMrMDA6MDC80CO3AAAAJXRFWHRkYXRlOm1vZGlmeQAyMDI2LTA3LTIxVDEyOjU2OjIzKzAwOjAwzY2bCwAAAABJRU5ErkJggg==", + "base64", +); +async function chooseImage(page: Page, canvas: ReturnType, position: Point, name: string) { + const pending = page.waitForEvent("filechooser"); + await canvas.click({ position }); + const chooser = await pending; + await chooser.setFiles({ name, mimeType: "image/png", buffer: PNG }); +} + +test("Image Texture conditional blend and persisted texture modes", async ({ page }) => { + const canvas = await open(page); + let a = await saved(page); + const emptyPreview = await pixels(page, 130, 448, 260, 62); + await chooseImage(page, canvas, P.image.resource, "texture.png"); + await expect + .poll(async () => (parameter(await saved(page), "image-texture", "image") as { value: string }).value) + .toContain("texture.png"); + await page.evaluate(() => window.parityExample!.view.whenRendered()); + const uploaded = await saved(page); + paired(a, uploaded); + expect(await pixels(page, 130, 448, 260, 62)).not.toBe(emptyPreview); + a = uploaded; + const flatPixels = await pixels(page, 110, 410, 300, 300); + expect(node(a, "image-texture").sockets.map((s) => [s.key, s.direction, s.dataType])).toEqual([ + ["vector", "input", "vector"], + ["color", "output", "color"], + ["alpha", "output", "float"], + ]); + await canvas.click({ position: P.image.projection }); + await page.evaluate(() => window.parityExample!.view.whenRendered()); + const box = await saved(page); + paired(a, box); + expect(parameter(box, "image-texture", "projection")).toEqual({ kind: "string", value: "Box" }); + expect(await pixels(page, 110, 410, 300, 300)).not.toBe(flatPixels); + await scrub(page, P.image.boxBlend); + const blended = await saved(page); + paired(box, blended); + expect((parameter(blended, "image-texture", "blend") as { value: number }).value).toBeGreaterThan(0); + await canvas.hover({ position: P.image.boxBlend }); + await canvas.press("Backspace"); + const reset = await saved(page); + paired(blended, reset); + expect(parameter(reset, "image-texture", "blend")).toEqual({ kind: "number", value: 0 }); + await scrub(page, P.image.boxBlend, 15); + await canvas.click({ position: P.image.projection }); + const sphere = await saved(page); + expect(parameter(sphere, "image-texture", "projection")).toEqual({ kind: "string", value: "Sphere" }); + expect((parameter(sphere, "image-texture", "blend") as { value: number }).value).toBeGreaterThan(0); + // The old Blend coordinate is now Extension: hit behavior proves that no + // Blend control remains there, while the saved Blend value stays authored. + await canvas.click({ position: P.image.boxBlend }); + let hidden = await saved(page); + expect(parameter(hidden, "image-texture", "extension")).toEqual({ kind: "string", value: "Extend" }); + expect((parameter(hidden, "image-texture", "blend") as { value: number }).value).toBeGreaterThan(0); + await canvas.click({ position: P.image.interpolation }); + const changed = await saved(page); + expect(parameter(changed, "image-texture", "interpolation")).toEqual({ kind: "string", value: "Closest" }); + expect(parameter(changed, "image-texture", "extension")).toEqual({ kind: "string", value: "Extend" }); + await page.evaluate(async () => { + const x = await window.parityExample!.root.save(); + await window.parityExample!.root.load(x); + }); + const loaded = await saved(page); + expect(node(loaded, "image-texture").parameters).toEqual(node(changed, "image-texture").parameters); +}); + +test("Compositor Image movie rows, resource editing and output contract", async ({ page }) => { + const canvas = await open(page), + a = await saved(page); + const n = node(a, "compositor-image"); + expect(n.sockets.map((s) => [s.key, s.direction, s.dataType])).toEqual([ + ["image", "output", "color"], + ["alpha", "output", "float"], + ["z", "output", "float"], + ]); + expect(Object.keys(n.parameters)).not.toEqual(expect.arrayContaining(["projection", "interpolation", "vector"])); + await chooseImage(page, canvas, P.compositor.resource, "plate.png"); + await expect + .poll(async () => (parameter(await saved(page), "compositor-image", "image") as { value: string }).value) + .toContain("plate.png"); + let b = await saved(page); + paired(a, b); + await canvas.click({ position: P.compositor.source }); + b = await saved(page); + expect(parameter(b, "compositor-image", "source")).toEqual({ kind: "string", value: "Movie" }); + await scrub(page, P.compositor.frames); + await scrub(page, P.compositor.start); + await scrub(page, P.compositor.offset); + await canvas.click({ position: P.compositor.cyclic }); + await canvas.click({ position: P.compositor.refresh }); + const edited = await saved(page); + expect((parameter(edited, "compositor-image", "frames") as { value: number }).value).toBeGreaterThan(1); + await canvas.hover({ position: { x: 10, y: 10 } }); + await canvas.hover({ position: P.compositor.frames }); + await page.keyboard.press("Backspace"); + expect(parameter(await saved(page), "compositor-image", "frames")).toEqual({ kind: "number", value: 1 }); + // Movie -> Sequence retains the same rows; cycling through Multilayer, + // Generated and File hides them without deleting their authored values. + await canvas.click({ position: P.compositor.source }); + expect(parameter(await saved(page), "compositor-image", "source")).toEqual({ kind: "string", value: "Sequence" }); + for (let i = 0; i < 3; i++) { + await canvas.click({ position: P.compositor.source }); + await page.evaluate(() => window.parityExample!.view.whenRendered()); + } + const file = await saved(page); + expect(parameter(file, "compositor-image", "source")).toEqual({ kind: "string", value: "File" }); + expect(parameter(file, "compositor-image", "offset")).toEqual(parameter(edited, "compositor-image", "offset")); + const v = file.snapshot.version; + await canvas.click({ position: P.compositor.frames }); + expect((await saved(page)).snapshot.version).toBe(v); +}); + +test("Noise dimensions/types use conditional hit rows and preserve hidden values", async ({ page }) => { + const canvas = await open(page); + let prior = await saved(page), + hash = await pixels(page, 410, 410, 220, 380); + // 3D -> 4D -> 1D, then 1D -> 2D -> 3D -> 4D (actual enum events). + await canvas.click({ position: P.noise.dimensions }); + await canvas.click({ position: P.noise.dimensions }); + let one = await saved(page); + paired(prior, one, 2); + expect(parameter(one, "noise-3d", "dimensions")).toEqual({ kind: "string", value: "1d" }); + expect(await pixels(page, 410, 410, 220, 380)).not.toBe(hash); + // W is the first socket row in 1D (y=528). + await scrub(page, { x: 653, y: 528 }); + const wEdited = await saved(page); + expect( + (node(wEdited, "noise-3d").sockets.find((s) => s.key === "w")!.defaultValue as { value: number }).value, + ).toBeGreaterThan(0); + for (let i = 0; i < 3; i++) await canvas.click({ position: P.noise.dimensions }); + let four = await saved(page); + expect(parameter(four, "noise-3d", "dimensions")).toEqual({ kind: "string", value: "4d" }); + await canvas.click({ position: P.noise.type }); + await canvas.click({ position: P.noise.type }); + let hybrid = await saved(page); + expect(parameter(hybrid, "noise-3d", "noiseType")).toEqual({ kind: "string", value: "hybrid-multifractal" }); + // In 4D hybrid: vector,w,scale,detail,roughness,lacunarity,offset,gain. + await scrub(page, { x: 653, y: 648 }); + await scrub(page, { x: 653, y: 672 }); + const conditional = await saved(page); + for (const key of ["offset", "gain"]) + expect( + (node(conditional, "noise-3d").sockets.find((s) => s.key === key)!.defaultValue as { value: number }).value, + ).toBeGreaterThan(0); + await canvas.click({ position: P.noise.type }); + expect(parameter(await saved(page), "noise-3d", "noiseType")).toEqual({ + kind: "string", + value: "ridged-multifractal", + }); + await canvas.click({ position: P.noise.type }); + const hetero = await saved(page); + expect(parameter(hetero, "noise-3d", "noiseType")).toEqual({ kind: "string", value: "hetero-terrain" }); + expect(node(hetero, "noise-3d").sockets.find((s) => s.key === "gain")!.defaultValue).toEqual( + node(conditional, "noise-3d").sockets.find((s) => s.key === "gain")!.defaultValue, + ); + expect(parameter(hetero, "noise-3d", "normalize")).toEqual({ kind: "boolean", value: false }); + await canvas.hover({ position: P.noise.type }); + await canvas.press("Backspace"); + await page.evaluate(() => window.parityExample!.view.whenRendered()); + await canvas.hover({ position: { x: 10, y: 10 } }); + await canvas.hover({ position: P.noise.dimensions }); + await canvas.press("Backspace"); + const reset = await saved(page); + expect(parameter(reset, "noise-3d", "noiseType")).toEqual({ kind: "string", value: "fbm" }); + expect(parameter(reset, "noise-3d", "dimensions")).toEqual({ kind: "string", value: "3d" }); +}); + +test("Master Color Grading modes edit, persist, load and undo", async ({ page }) => { + const canvas = await open(page), + initial = await saved(page); + expect(node(initial, "master").label).toBe("Master Color Grading"); + const lgg = await pixels(page, 650, 850, 420, 350); + await scrub(page, P.master.scalar); + const box = await canvas.boundingBox(); + if (!box) throw Error("canvas bounds missing"); + await page.mouse.move(box.x + P.master.liftWheel.x, box.y + P.master.liftWheel.y); + await page.mouse.down(); + await page.mouse.move(box.x + P.master.liftWheel.x + 25, box.y + P.master.liftWheel.y, { steps: 4 }); + paired(initial, await saved(page), 1); + await page.mouse.up(); + const lift = await saved(page); + paired(initial, lift, 2); + expect((parameter(lift, "master", "lift") as { value: number }).value).toBeGreaterThan(0); + expect(parameter(lift, "master", "liftColor")).not.toEqual(parameter(initial, "master", "liftColor")); + await canvas.click({ position: P.master.mode }); + let ops = await saved(page); + expect(parameter(ops, "master", "mode")).toEqual({ kind: "string", value: "Offset/Power/Slope" }); + expect(await pixels(page, 650, 850, 420, 350)).not.toBe(lgg); + await scrub(page, P.master.scalar); + ops = await saved(page); + expect((parameter(ops, "master", "offset") as { value: number }).value).toBeGreaterThan(0); + expect(parameter(ops, "master", "lift")).toEqual(parameter(lift, "master", "lift")); + await canvas.click({ position: P.master.mode }); + let white = await saved(page); + expect(parameter(white, "master", "mode")).toEqual({ kind: "string", value: "White Point" }); + await scrub(page, P.master.whiteTemperature); + white = await saved(page); + expect((parameter(white, "master", "inputTemperature") as { value: number }).value).toBeGreaterThan(6500); + const beforeEye = white.snapshot.version; + await canvas.click({ position: P.master.eye }); + expect((await saved(page)).snapshot.version).toBe(beforeEye); + const persisted = await page.evaluate(async () => { + const x = await window.parityExample!.root.save(); + await window.parityExample!.root.load(x); + return window.parityExample!.root.save(); + }); + expect(persisted.nodes.find((n) => n.id === "master")!.label).toBe("Master Color Grading"); + expect(persisted.nodes.find((n) => n.id === "master")!.parameters).toEqual(node(white, "master").parameters); + await canvas.click({ position: P.master.mode }); + expect(parameter(await saved(page), "master", "mode")).toEqual({ kind: "string", value: "Lift/Gamma/Gain" }); + await page.evaluate(() => window.parityExample!.root.undo()); + expect(parameter(await saved(page), "master", "mode")).toEqual({ kind: "string", value: "White Point" }); +}); diff --git a/vendor/fxnode/test/browser/visual-baselines.sha256 b/vendor/fxnode/test/browser/visual-baselines.sha256 new file mode 100644 index 0000000..49f64fe --- /dev/null +++ b/vendor/fxnode/test/browser/visual-baselines.sha256 @@ -0,0 +1,9 @@ +bddbb2638889d825407600a55ddd427a04fa26cf624a718dde13bb3429e8b3aa test/browser/all-supported.spec.ts-snapshots/all-supported-linux.png +66021d791bacd650a5c0d983d0a2c5ba377aba23c598dd022de7fc18f9ad7f68 test/browser/parity.visual.spec.ts-snapshots/parity-image-grading-linux.png +6d24bc2f6783a4bbad5d416b72442c02e0b05260700fb06d6aaaa1e61aff53dc test/browser/parity.visual.spec.ts-snapshots/parity-ramp-noise-linux.png +acd64dbf5847551a9403517be7c7f94ca6c7691fc14c641b82b687ce7b16a919 test/browser/parity.visual.spec.ts-snapshots/parity-structural-linux.png +b22233380b3509b79769cc1dd9dca809360e097c337c258638871867f4442673 test/browser/visual.spec.ts-snapshots/color-picker-linux.png +db27823fafd21ef361dfa12e13d9424972e111d13babfc4fee53614bfdde37e4 test/browser/visual.spec.ts-snapshots/numeric-editing-linux.png +83a60b97d9dd423e614d52e0427e3a17515f92ec94a467cd9bd3fe1723157fc5 test/browser/visual.spec.ts-snapshots/numeric-fields-linux.png +5e578055b7ade004c9252ca785491dd39211b92ceb68d6eb8a37421e1c2b30fd test/browser/visual.spec.ts-snapshots/phase-4-example-linux.png +0ba342da7237709e47ee607726fc131f5dcdbb84650aed5d6ac3e2d1ced8b727 test/browser/visual.spec.ts-snapshots/zoomed-out-lod-linux.png diff --git a/vendor/fxnode/test/browser/visual.spec.ts b/vendor/fxnode/test/browser/visual.spec.ts new file mode 100644 index 0000000..1ddbc5d --- /dev/null +++ b/vendor/fxnode/test/browser/visual.spec.ts @@ -0,0 +1,82 @@ +import { expect, test } from "@playwright/test"; + +test("@visual deterministic example canvas", async ({ page }) => { + await page.goto("/examples/blender/"); + await page.evaluate(() => window.fxnodeExample.ready); + await page.evaluate(() => window.fxnodeExample.rendered); + + const canvas = page.locator("#graph"); + await expect(canvas).toHaveAttribute("width", "1200"); + await expect(canvas).toHaveAttribute("height", "640"); + const evidence = await canvas.evaluate((element) => { + const context = (element as HTMLCanvasElement).getContext("2d"); + if (!context) throw new Error("Canvas context missing"); + const data = context.getImageData(0, 0, 1200, 640).data; + let background = 0, + nodePixels = 0, + linkPixels = 0; + for (let index = 0; index < data.length; index += 4) { + const r = data[index], + g = data[index + 1], + b = data[index + 2], + a = data[index + 3]; + if (r === 29 && g === 31 && b === 35 && a === 255) background++; + if (r === 53 && g === 56 && b === 62 && a === 255) nodePixels++; + if ((r === 168 && g === 168 && b === 168) || (r === 98 && g === 179 && b === 79)) linkPixels++; + } + return { background, nodePixels, linkPixels }; + }); + expect(evidence.background).toBeGreaterThan(300_000); + expect(evidence.nodePixels).toBeGreaterThan(20_000); + expect(evidence.linkPixels).toBeGreaterThan(100); + + const first = await canvas.screenshot(); + const second = await canvas.screenshot(); + expect(second.equals(first)).toBe(true); + await expect(canvas).toHaveScreenshot("phase-4-example.png", { animations: "disabled" }); + + await canvas.click({ position: { x: 380, y: 160 } }); + await canvas.click({ position: { x: 560, y: 140 }, modifiers: ["Shift"] }); + await page.evaluate(() => window.fxnodeExample.view!.whenRendered()); + const selection = await canvas.evaluate((element) => { + const context = (element as HTMLCanvasElement).getContext("2d")!; + const pixels = context.getImageData(0, 0, 1200, 640).data; + let selected = 0, + expanded = 0; + for (let y = 150; y < 260; y++) + for (let x = 295; x < 485; x++) { + const index = (y * 1200 + x) * 4; + if (pixels[index] === 237 && pixels[index + 1] === 87 && pixels[index + 2] === 0 && pixels[index + 3] === 255) { + selected++; + if (x < 299) expanded++; + } + } + const socketIndex = (234 * 1200 + 300) * 4; + return { selected, expanded, socket: Array.from(pixels.slice(socketIndex, socketIndex + 4)) }; + }); + expect(selection.selected).toBeGreaterThan(100); + expect(selection.expanded).toBe(0); + expect(selection.socket).toEqual([168, 168, 168, 255]); +}); + +test("@visual distant zoom keeps text inside scaled nodes", async ({ page }) => { + await page.goto("/examples/blender/"); + await page.evaluate(() => window.fxnodeExample.ready); + const canvas = page.locator("#graph"); + await canvas.hover({ position: { x: 600, y: 320 } }); + await page.mouse.wheel(0, 900); + await page.evaluate(() => window.fxnodeExample.view!.whenRendered()); + await expect(canvas).toHaveScreenshot("zoomed-out-lod.png", { animations: "disabled" }); +}); + +test("@visual Blender-style numeric fields and text editing", async ({ page }) => { + await page.goto("/examples/blender/control-test/"); + await page.evaluate(() => window.controlTest.ready); + const canvas = page.locator("#controls"); + await expect(canvas).toHaveScreenshot("numeric-fields.png", { animations: "disabled" }); + await canvas.click({ position: { x: 196, y: 147 } }); + await expect(canvas).toHaveScreenshot("numeric-editing.png", { animations: "disabled" }); + await canvas.press("Escape"); + await canvas.click({ position: { x: 1003, y: 147 } }); + await expect(canvas).toHaveScreenshot("color-picker.png", { animations: "disabled" }); +}); diff --git a/vendor/fxnode/test/browser/visual.spec.ts-snapshots/color-picker-linux.png b/vendor/fxnode/test/browser/visual.spec.ts-snapshots/color-picker-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..06056312d31f0c46d60ea5d8aa7e632d5fa79869 GIT binary patch literal 55734 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 literal 0 HcmV?d00001 diff --git a/vendor/fxnode/test/browser/visual.spec.ts-snapshots/numeric-editing-linux.png b/vendor/fxnode/test/browser/visual.spec.ts-snapshots/numeric-editing-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..5d145abc1d4092c9725735ed391f8a2c1f835e93 GIT binary patch literal 31476 zcmc$`cT^K^*Y}GBK}CuPC{;yzlirJVYs}Zk$jEMJ zK7IV0jO_9cGO~-WDK7$BF0C`tkde`nX+C~r7@WN|PyW(kd7)>QXR0W-f2#V``X|vK z{EhT>pCIVZE)hJ=uQ^@dCrqX@#>!#sNBfmmK|!dNfQn-Fk<$mR5Fan=LMs~2a43~qoyTp#I@1K7%8SS>EUg?nWUq87997W+53Ci=0 z<-gYt#ID5R=v2q=}EX_I&&60?MfK&EI(ckG?$? z7cG`WzYOb264*#Icl%zP_P-xy#VrJG-@APNsgACLPOm(|lg^)$l@&cyj-gXMe`;{+ z4ej~=TTkx(v)>as$_v0tm1md#KX^OES|SCJ&5IREvwQ}cc&;tf_}?CK5$5fb|1zYu zy;PR{Qu03+sKWLBSoYKZn-={a-md+E{K1vSA>Os<*ptHlX7qFO9}Z4kQ~#GC&6w*E ztSosC&W%jQ&o?+?8}}H;?Ca3V z%+Z5$k03M9w2h?Zj5>e%f7pfphX()8@PWDT*2_Lyun=r6AeQ}icLyx5Sl{^e-;SNu z?IG3E&2#nqf?sQ0+k5rzk??=k!&9uk0)-1fmya11eg|%_x!6hqPzhb+~f!yufVA07i+lTYrAN|LgZSsNg2?uI+0p^~6MP!cJ$H zIXzKK=3~dv(y#NAJTy%(WjH^{V^LMptkB3xvn$LW)0tGD@;M!n2F}vw9`Y;>CcuW?N!q{pGwsiKy7?%-Yv{{;E35UYn(_ z+ft4V;#sqKFLcrNz|ES&DD>CTDMfq&_ZkWpZ&WZFZ&6u))KJ)-V*cCo`D=Qnn*(1; za497odTwl|&x_Y zQ$sLnm0wx8=0Xlx8`DODxm)M=DHn|t(eshGqX4@Rpggb5A?OSTcT>K zp}+gJ(@BV5@N>?)q`#^e(Gts9@fB9TRfF7pt=gCXYyG%oI)LD0EkV0@vyTOnB*|~S z{2g1B;4XV&;$kS_R4*%~r1{NKh+t2WhIej%*qo3Ecxmy5_Di!vQ2U{vT2wov;IO9Y z@#H+qt-0EK;k?6?m8#jGZSL1gh-SymQ8kJVzw^6(H{4xT+cj|Zs~&Szci4C^cEC9J zk-Jnt3;ljghnj6o)cGJYOkh4pXxD<^sHMsz_hckSVGs3g?1qRQ05$Xu10yl<*>QG{qjNLAa?tBds__1>Mn(78OEs{_J@Jb? zgQwJ?{KMwP7-Ut#Qur(j!e*&*c-^~oXBw`2tno&DWJw<3G$ZJe-&iCeCD=@q=MZwZ|tOJvR*lp6b~=9 z7)a39J$W~PX*q?J;$rv6^|)R<_y5vWKIwYt^!Pe!;oos~dKMnN88{L9rD^82?CzWZ zc55DFpy$6iw!3Xn{e@nmu8wqqHF5tQfy;+xNoybfsrOE+G3TLuUa<66TRXNR^pSjq zcE3Fywb0_YhguaPp2XM?gmUB>V5DIdE8*$9ya-YFw;$Ug0d;B2X_nstf$uUP@KGm$ zjyVxuew{r&DD~S;!k6=}!x<&XudMZpG>IkDZkv29>q}yDM-yICD_#XP(f;Vv^Wv$b^gbITm%EjG~awJcS_5ihc zeBd|{Gntk>SzgGxj|}xODL#B}VPYwH^p@NAV@?(Xm07D@IJ%Ghtp*mE1(!du+sG4v zNsD%G`9WXB;-uJTh`VRrah_c5%bYFLP0nWlr*-1&$LKs<4^s{4)!wg(^vK+n;8XtN zrC;owJpmkPH}f3$__Cp{>m~zEl!l8S>G%D}T{k04sUj?bC0-pGvAXT1EmsVAC1(Xa z+@hEKfxsxX1SBNJ#eaC1Jt=ENboo>^1IF#ZAYj*d)r!&HHZ1X73_NewqI;2ZL}6FO zFA1N7lC}=`-b#=QYagC>tN|h3(>n&43CsIEcAT1faxwn-jED=5ucldBDB=C;Crzz~ zK`zM9)!YbN{rsMXw~S?D;Ja4rOj}jBgiz!6&c&Hl&((Ak>G|{L{759&?H{7TPTY$U;(9xjUr2HsMD+ainb=8z*VohG738;uxDEs<5BtgF_=&czPPxC@!$CYy z2$*|mPS_!QZ;?YJ;|=0^w;yO)0XBxToElrx6eMOfw!h@T2 z-u{C}3O!0B%Lt==qOo@Z!BbOFYhi`3CfJ2FA6e>UHrOhx)cR;KN7|V%0*bPQq#ej4 z9v^LYq6t0M+ftWbI7Qxcc_ir;Y++=wR~j*uP44%1&bKg{O2G+In}6xFPhD2j{qU`u z%^0Y}6>%pu;@6vqFsik2P9AA*XbsjP65V`+AMl3breg9U2us@Dk$(<@GfRv67ce65 z5fNU&q{M1-$oeK0glGteA@#}SY!~x!oMGNiJFc1@8Xy}NnIFcRHVM0wzSJ~p3@mKL zh6FN&ph|npaFLy9o_@UEM}HziVUg?o0WXvZ=E8ToxQd;FFcS}CpCz33m4UsMra)~+ zD?K*h_`$uJ)RZ)1E4OIsef9Ag(9tqXzb)l@LiY9~tirPG3~ng=2pgfG+g4|J!aqc# zEo?yXM#eNyDYZ^LbrgQhY)i~M3%A#66i$9+5ZPoHNwAUHZ;hkS^>4ARYE9{YC z_bsbEkC2d{7rg1TY$8~!@+x=89z-HuTWo_9y;JzJwp9ZZ);iCY7+shvFoAQmX|ap< zkO%$!`l>s@U)D<7B$Vbo+YDs-S1l*{S0emu+xz>9WCV+zl`tM>w26NCK06ocgi^tSZ{ae|nE&4EBp_n7C|nI=aOQiUaYCm7gukHZziv=e;0Q_G0=c z9I4vLAcwk4l17^4pWuEGsb_~A@o-HHfot97z3|1w-N}a09*$!N?i6X@_<}SE-oEyq z%ktAVHs`P{w&{=WaK4HYtT+sY+!&DHWr1)k?5$MlC)v~2+zyYNG>4Ag!*X)Au0H%A z`h=?|^AgQ6!>We51i`CokS!yOAhc3OrK6$u9~K}(ljTy5OPQ}NFE`fB3j}ExANns3 zq5kHT6(t7v(cbKccqHGf9m`5(3Gvm2FU*U`g2IdX9Qs6Nn!3V#-el9Z&5JOJ*kC|T zmL7u(ggeCdWsc_M@MkLm=>y9?G6F)9$H(t=vmO+eNFZ80ohuk!fnd?iX(n5hDNWxf ztSi{8lJmpe;o*r%{wI}S*4&>~0S-Ikn+JY|4?kapTi~>XTz`A_9Zrg!Hfrf5j}HV@ z;l1M~-2-q7ZjkkWXj`ij6N@J0)6d8yJy9N%?BPbqt|r|{?9!=om5op52hC3CetJB6 zqP%3jtEIzW<&Ep)WL>8Sxcbkw?PRM|jE3aDV zUgXwt);%PwhmRb_L*)FTPPIK<7gFyiFnukmXvgxmURP3`A4dHSC7?OJjBiSL9e_jz zZCVSwN4#>zkbS=$>*WjX>s~t9H~oRG*<%#Kj@MhPCSbgqxBaq)B8^w~L9l(}w!$A{ z&+A{V_iHlgH2M7f3a&D5@J4DWH8>GgR8hh?0l$*2Pn3mAUqJ1Ai{Uj)AR0aWd%GiZ zA|PvIDl&UMrP+wea;=^GlNo2$nmq-U-kBMty5- zgV4MwwtofItzE&#dC}rPg5PtpoG`vn;2D}lol`EfHPx7G+2=i1r%N`8fWAVuE6R5` zo#z+^A1w!#KW;?{f0uiw=lO%o_J{N{B{NH*rsgGq(ZN0Vr9Mf=*fyUl-~o>DQAsH#+dW-JW#9^>nhj zQ;)V3H`dngV z7O%&KT5gA7n_ulF6}wMW&NsV5C~K_L3kwP|i%Y0EL_H>Y7eNFhT(a`4lkUw<5g+dl zf5nQdPOp((ncyRX=%20=f>j}VD?(IN=H3epq6s=5uJG~k1-0CImsRC)ope^4a`M(Xlmdb?QXpZ-@4j0oQZGYpK39D;@vnGm#$= z7Q*uI8!5{hw&Wv2b>*E(uzZCZ2F?q~GV zAFGCjGaiH$N)%ZBpPk zOZ)Q0zG^>8Q|);uBr%=7B5(y%fAlnhh3+ni=!pa~$#mVnPo;K+#&xuyxpRHOwXtHl za=y+nPUidHzkg>NTStyS z-+zVtl_n5`CN-U{g%C35B&v^5$|#027kI+%JLm(2gFfm-x)ICtHvH4|4J+~h?^`m) ze#zVv)U32JsoZ+m-^SOK30v3G2Qso#B-ZOR*%QMyr#%0nKPJXuXB)M@Wyuqu9NBtu zuP8+Y`CeZWWQ?1y?x&+5Cx8F8>doQ@m0-7GF6I~X_akkxgi4)j2AW-7Y9$|is62~h z(8#0q9e}JdS-@EQ9lf@a*+uNF=UK>oLb)D1XmA}rB;Zl$5thgHfqKKS3<4+n7y)Q= zl?8-Z&f)1ZOU=9XXkMqPwwu$$Nrn8RE{?{=4Kv-$ZIQxqS>$ zgBon=32uJ1x{O92fm|Qi+Bz7fbb94UZbAwQ?$fzd!w)kDgrP-u;VMK97;ZMj3JT-- z4&8aX$896^y{Ra8yd>7P*o|eBvbN~YCl(2xpK731c-ik$bF0wv5FYQzj8CJX?vjHx5{!`x*w{aq zwgKV#NMd5t@oeH#q`|1md-!oU>1=Og`D-6wT~x^Ge4^6HFHGQ#HI%+mi_7BQ*10*6 z65Zs8xt}7jA3x?kv^{GL*(2fCopHt7pE$7IiPBk2LgosXIM=!gMVH?ybZ?C-Ekh9K ziW=DBitHYv!u}r)2kpHE354?L^d6~ssruCaq^|y!A@W1_M=o;bIpQUX?Uqv-=>@-2 zcphnlfS+e!ViI8C2;Cu~RtI62;V`E&x%vobAko8)XgHJvOC3j=K%_{zXiAbuYLvHSaTvb-38i0)Wzbxp zb__L!^ZtGG(*%$81sJkq$U@3%?m?;YY4|MpYL3MVPE|VXYkHR|CPPQxGx@L2@o{r^ zyNH23$GMk&-)sJEH=AxVpy51{5wSu@=2hHM z&p{J@g9$4kPY#>P>Vn!?J$r>3rYq=Vh8#qsl$5NgA6<@CG6%bS$#VXF*k_(IytM0q zXm#uxD^6vGgjBz5-D@S@KVPr2t~03^Q^-);-rPRk;*1}zOm zoYI8L9I!QaJ>rzW&C^t>XSXG4G{%#qbO;xgPUTx#c;(waoxUuq*x;_Jo*)VR$%1YH zf71MPu)WtD>ih_`A6)oIz&k?jS0Bgbx$B06goNR@*_0z&hl6gkaO-C7Z%%tp_NNjx z=;`SRmn;=S4|_)-GoE0@utR@&Xx1E=5R0ScK$;Lg?4_NZ>YL&}JNcb@rS&gL6Nu^+ z^ZXEJlTashb#*N*EjO*$>}@1O-#8GgC|J#Un}r4NDrs-x+d_@{?s&E(Z%as|T2I2d zQ+*Ik=VE8{)Y!k=(Q+VdD80tCIS3Ug`*fxt5WX7IZhWh1^Vyh8M9h7$a)_io~7yPkb(kQ1~At36+c0_ zUja`oMTx-A#2j`$SJbq0Ec&}i&K~bEUDnI~nw&FVs*kemjFD$cFcWv~V?^*+gFTL; zt}ZDu>xNs-l}}dCu*$6XEz^WKsliM~$XeNLM3)6#@0mgYO=bT7#EvOwaqwOB!bh2?#*b)X6q} z_r{+q$pRu!&stPOcvY9d)h$^3g~YH$rJpQ_6_acC3k#EzlHAKNlTbV!6~T5?+arnn zFn<3PVV{#F^43oC&R8?&x0wPYfT#K?roWuf0)KgKGHhWn`vvsl5Yr#iOo8Q8J!vkUC$(4C35gz z9l*YKd%q9Nm)Uw6w)a?`KZ8X@og73hn-6={cXwq4n0va7qjO=Q*`S5yP?&XT@{QS_ z{32&MA$u4UC=dC7tMR!!q2hD3(0T1`r>JkZ|EA11Ajp6^^Xu{Z+}yLbZEv}Kh>J`1 z)xWzXp+GAo_%7ND5R}OlZ{0)Q&U?A1O`YxJQ@s&?OeC;``XngZVG$jR(eI?8B^w{RniOL7jjQdqn&o8hJfMQFo#65gG7rLpNsB~Dm zyVU)OT~SO@(kLp&3K}?)B@J3Qd!ML$xFb*l7orV3Qdf3$phncW+WUJ{>Tm$llL2(R#O>;T6&cIhHKz?#ep8&+ z4*zfKg=Jk!yB}zOd0diVkoitU7Ti4VRneF-pf@4xgSaZRnyE&~&->#R0|UZSs<~Af z9H+l_BeXgE$?bSLI=WQ7rF+g(UiC;%V@S`EpWGAsf@zipxvFf}ccZ2OU^c0&Y~MsA z#>1oD^I>nZWTuXarDYIMf`$ zoI2bYR^;EL0`GL)p=G#pyp60;JlL<755gQm8r&vv#~=bG4Tx*Ar|dnIgewYXJrtSJ`W8S8r{IWqesHxu@_6^JHV;#J%7^YAjPZ#e zo$lC4&_<-Tp@+Dv!rvdutP6PEXWP-4x!IpzM&hfcSB7kk`rW;+W#5}hx(Rz!GrsN? z+VS%Ofg|E55l~AOj~?Zp?Z%tfnAdq6_vD3#o>ocIu*sQ%2lL=N^LXVlLrK41KQ?Qi z>YNf3)a+;YJ(7^HaJ7kjBNZJT*D^rcO0|Kq>Wyf(ygN)xr|n_bv(saephcT1@oQ!# z?l7xHnfNPD+8beh>H+aYu6&7MPf<${11sV?K=|!>lbY^KC!}$%Jwbzt*^PayHGHfX zzwpZ0N88$Zp)0vshEOl`qN1_EFskqs_a_!*uZCzack|1 zfJxQ&%q#lYTh)EM!Rt>}izPg7-nny!fngFzr;y&z ztBua-Mnz2myP{iJe*N3o1I)NQSMjzDz(*BH7 z;}eJSVeTn=>uMVXw;rpq$J*yVNkv7!dN?A`?@4Xp<5fYu7-ituy~ ztp8A{{TIZ+vWX1{y+~ni4~zM!xVzRV?sUdK{t{C!qzIUDwxM}d)7e>3%=T0F@v&R!!Uxzln2q zcJm%b2dcC!OxcNxDx%T<5jC5N@*#ACXHLqV^@M+Lilp<`ViWzv24D>c=cS33nR9@VRXA zL02a_0m5l;{++HS@u*oOfagR@kZ9$J|OZa7hjq5|k(-;I=kySBNcapw-eJPP? zw`7;#z;4wTI@!C(SpY=CDjT@lGXHUu0Jr{d-_SYd@~xE*+O~WM%BKucY{`pQ#XvHL zc3)V7mOB%b*1zUPA{^6ip_$NVbn8F)TM$JqA2boa5aA3B^vrs|@5KHs5Tc1pZTn>Ou}_sA7+yc zpBY3sU6(MffhjF_(k#G^3a-~=ha=|kigzv4sW{}Ehg&;Tv0oSGLiS>4I9gsV7W~}P zNVwewsJx*pNbfpF_Av4wH;$0@qhDHZlWMA(j0q<~NvkA_;KlrRl!@Z@glOGj zu3~_D+*)7@I6+i`jVh)c99SdwKf=!z+cg|_pqMEZd;0{?RE_dn&~&Vk`!wWH^KDSplZ^eX_u={E8vuIZQ&xtTm6et7%xop(MV_q#1A=%~FCteI^m(b4 zc=&Z@1`E$4?rP*c;O08SjN>b6;K%#p`eFDDh8INP&4ZJgwv&}Y8fAb1RoVCW(``>x zp@4Lpgak*#PzC$B*md=%rt7wy%(8gpihH-K(Gc$)Wpu_+Yr4+v10)T8*6&U_u32@A zn@bQ@hS4aW{JJ5H0=w>|#jrQo{PA~19rU{)C7C_P8R^TxL7P*OVq$)MW6OX}OPTk7 zqkdOdX}cg!*lV)fsQB=2(#?#k9Id2w9*#B7MRHtHBcwBwu$&j6${jfZ?b$`FVq9@%ZqDlk54Os|Z0`lm@ zPHAHS2@b*28>kS#Q}@dhYB1V~86^YCRh; zL3XD=^jAHA@a{agCaRvS8O4W)(0cl?#>yl_`EX()1-jkjxDk`5u+!56_L>hInTJp) z%Y~-Qfgc{NI}>+z>_G{MiOE?{&M?4~sB}8A$`OjzZ+A72^VU&qgj;!0=Z4$5zxh12 zL#JAgfExY$cl%HG2U-~^xrW|^nmHdOQ2YH5_{&8*gk7WANXA29MVOkuf6Azf$7FdI zkaW9;kTiM(M}RxSRst;=7NxOJ1<__4q9=Oge7>$}00!jK*eAHQ&# z4Ga1E$S36Fy_l(2a)Dr_F1#xLYSEyKNIhaIFF941O@U9fh9SZTSK&+Q6 z!puw~(W%)|FQguA=0Nk4LnJ5!@}eK#(Hu0AyCP)nfvTQ*l#3hj?eT>4j!u90@L{Sd zalO^cC zH8x_=NMu#=ZdYw?>XTdun6~CSEuUoXemAbB-fpWFbFe+GQ8Qj+0ReB&U!lHzxA5f% z`#gXhGT~f4uWNgjs!zg$LJGsp|2&Lpk-qZy$rEa3nc&~w+OIPX0`kMPSi|uat61<; zcqD-;`hyjeA3O8hz~E@_lZ{-^2Abnd<+uk_fLXEQ1*-e?(_~k4*|SR&RF+LXi(_1I z0*d_f)4$u*03-3jf5QofEYG?=EOx3VGKZ`x;>m|F>t8*aGgD>3HY6f2K~>8kZB?kr zjFH09nb?K;pw4dH4(RlDP`YLKqaC2N%Q+x5r#71v+rLwq2ZGt*9)M0y4p^>NAg|4p zKLN7xLr?ol+;?C<97!i6&LBJ&jSNpZrTk+@L&#__^LVpNd!;*Ai- zfzubta$kMdCew4J*`No*%YN$(j;oh;@^1fJ_tDZv76`U z>Vo)h^a=oTzW|EuK2Kd;{X4kRtq0<-!oyoxrz%`O?`+kNq^;?eOPylquJr0#KzkUst5D+*z#^uOQfa8yE5Y@w>Ed_d13Yd8cno|AoOO2m<3-Ot5KQ>tT%{#=MnlO1%e9SoCm@wY8n+T zy$L}8l5eIiAZt!l5MF>T7nkMJ^b%(g51nY7wmjZZ!;?<)Kw&$?FdiEvR~1s^8&vT` z-RJal$g9!o?tk;lbG z6(&ZrElg|-2fsP6q!Eyfu%O`CNSp{NVj6Sx>eW3aL1tOICd+=b=dU06IE_WC^?=Vd z&pU#+2ZeLw8(o*?<+tZPE-{H%nb&l`VYMOx)4!)ZtDq5d341smz_g06(c8i%-hw?U2 z0bFO{ctYZ|b;RflD--Ig~RJuBk4 z>I8N+2T=*EvKz>1Gw{924KD@V%m)LQ6y2m`hf>REwGfdP6|D!GNQ)z>!+U-l|6u_? z`VQ9HB2Jg5N4Dp~+?2nY&m0|0XI!G7{`%1NE$g>Nq(_VxH95AUA#nagTco+ku_A41 zED1nxEpU%VkB9)7E1cd8+@3RAe=KRMAE*M)B6e!LK*{?b`AkMQF8}=V4v*sLxw>q6 zz%<@A3;OfAweL}M3XYB+v!5 zogxU>#ZlwHUTr2cq|m8i!Vl$TY9%IMSp(P#aAH{Hg0DElDn z*t$USN=Lbao$Gi@Bj|eVnbP&G-8CPXK%gG!I4}hWZpK@TvFbI?B4%s8Duk@p6^oh2 ziT`q{PfN)@tH_P2?d^4Waii3sx33S3US_5WMd6IV;(-xN4c&zsNn_2^R+tu@^8JRv zi1Ea&EaBg#47W_tVD(pa1upyO?xDBg+Nwzq?-jo$boOZ}i!210nx33K@$0l}QTed~ zpR7cS4({D(zWwS+D%t_*In@+gkNrxYQ?k`W?+yCq;aJ)oP*vUB&=>V(MJt{ovTd`_ zHB2qEu_m+Qj8&bIj<_;t(G=NPq67;AyDaE<;b+_Ids;)TC$J8=&203o`(x%BEf$=P z4yW9wt37-6_w{ndl5YYflXXGwWZ{EcRLE!nSE`=(rMn6qW`u4cn{orj&& zZzeNf$Hd~hIZq>vB76Si=40N^S#17t5x@OE41zV*Zk)urM)dAKJh0S)`n zA)6MDCm*rs>G&IYhc5%6RZUG#zYP}@+TVAR!TZf5(&AvWv0Hkl?Xa+=!Ikd38ZB)t z2bA1w(22&e=m5mj&d!b*}ExyVd-@bs2rEY4|Mw91U4uD zpaeB2>Cv>UvPwc#2!QwiI*k{S%Rba!9$K*4{diO#I@hx4*F3i!Z3%z;C=_;Y?3USh zVX5;Y`SBxbN<|!jpPwI>OVBQKjarJrRXpM@`&)2d*1P??kyrRGb(<_}d?46yP?%3R zZ6LtVzl%@ZzYP&6!|LZcE!jV>v1|ivXfjPW7E^Av)VOT!sZ$XzfqllSXqu;&c zZZ3ZQS+~jl?d!Ea7|pAOUq1ZmruR;z&{6N{#72V{eOg*d@7!{rWHpyn&Gf()kF@Ax zR{3)7WFwDO#J2oi>Y!D%dZ3(pbI4Pok*<1Sd+?wV+E2hDTXw;#u`j*|N3$?9+##f4 z>G=4|M`7TPpI@EKjg$6b=@bCI(L&tZp(-Sx&bXh-8GU|lLnl5g3m6Y&!5=Cs+kEr= zJkOMImHhP%1Cw)_^digjwXS>DomhT;|0!G;&|dw>_Ak};*h`4~;T8hwGn%xum5oL_ z)XTO7RHUiJP`S+(X&MIxK6|#*{O1C&K!Z2%&XXmk0uEIt=$Du2qgpeL5=FHGygufA zRYgF-KNV8n68Pm^RF!$-)?`IYD`uwIeKX55{$C4vNn+fOpQ2*1(=OqUm)1o z+XMA9MK|bI6#*FW&q8p?9={BQjHT4}t(LkWC@@<#Ri` zk=*%ZA@RN5*whM>8nf}Q!y4Df@_!|zXQiiCOm8lic@?`8B(D&!k6Qp?!8$%Z?s@~% zvby8#MVOwmTV=hFbJK)dD6CbbbLfN`1h#g|u3PL7XH6tEm1V!v=kH+s!$De=e9&wC}x zaGl!@xY^95TU&)!-rbYq1{$+Ic54VTiBebK;76DMSV)C4YZy=;H93G?$ zv}V5oL9;!Nzv_VBq;;zggIVvT*niba7vSawTQ>sjrB|V!q9p?FkTW+9S%CX3oVmGo4d{buw5#bbPvKVaFUVr*ayuns~Be^1eq1@h@&g}xA@PNyaSRAk`Hh5ma5rVXh!up)SHuk14mP+{@1!F)!M1sqql>CfA;+)d7xKUKc2`> z=4r~ZA9d=BqpO@pw%=XZ_XZ{qhobKwQX4KU&*q2Y<`jW~dV3x!A|PNfv5N1HRG&m4 zi(i$#8p+`8d9d8w`nIFseOx+b-ePGn?&{T*AXKO)P#3CcJuD&9({YIqkkeLod? zR_p3l0qIP86^xR8(#%pGvBJaAmX(tXbnc*O`_qIhl#kZNlag+}86P%(ULd{Ze#e1D z<{PPyhP2yl;U&P!o1)ydyP>TO=!TJ!K-%#hQ`;$#BDGvS;dUS)VA>^W z~*=3{DPX}Lv#h3#QiN%bDb*N*Mo-1?E; zZNwvbP@2U}_dE>P6RCjv{q8*I{{zIArJn*gH`5a+OywPNh`N-8P)rZ-ScPI0gRVf( z%ga^@>#^g?NU@$!3US|li4`J4;|22zcOO?;K_mMuutXqZh}gsI3t2$=(vFT~5Fazb z5ygp0XMb!+*uE^URhR$qr3%Arf}+>D@`lX{I)SeHw%TJILyyfsXT{HBCrO!Y&zJ2^ zd+k9)WIYMsV4J;kYUIPTcdFe&{MYN2#f;}cD45?#xd6{VK!MJ?LcoB^kz0y*#o&<$ z8l7yJ$eG{w+PVCY2!O%iIzTpU>r?U$f70Fmgi205j#f_}FcY=fTQ_wGj5wa0<_I}z zpE1SK{-sY;Qn2r?5xX&^gd9uOR{#h1jahiZwHK6$Yn=z4m62e{j&&bno#{}<3=ufd z>DSsW5&3IRIOPpy2!c}NtQV4HqrOm9wfM%s>GzeM-v*V92+xa)0#QoI$H&LoX~H1T zh6a%RgRr^;121(&|Fs_;>3r4h>BB(u2LS~53{!K?L=zW6_io^N7GR-L?rEWOC}f)z z($`+Yq(2hZbAb>F0U9f2W$!3?^iHF6l)ruv^*efaA^WR=*%kV%+?)l>M!B|@R>fYy zP>ws_zKxlLyu8M@+EB~^pJHfs#O@O&YuV#EXVS4ZLd@>yx9qDGhERr~9BI(ejy>+I zm#z~Stgw(hMp;5r++8oj(M;RcPOnm8hs7R9@&D!fU+6=zsHF zwpoZi(8;$Q|JNbe#^m2%H(wH2#yN>o<(;}VEOsqQxS0^JIfcP-hr9kzI}Kc4XPa&G z9B*)?Uu09*-0^>dE))*0npM#TL z76-~s-P53RlmG};GU~55C`*Wpya5~P*oa|{2fM~y4HL|&P};|W6qfQ#+w4+XuV%aY z?)n0rVqNcaKqT~R-L$cMeqVeM@MICc27b<^&mNk3de-%UIi%5d6g0&UZ;rDw?!LYl z@x-H2{MLNdSc5B!`P~f;QB~?1A)B_!v^R}kXTH*PYIva9B6MRwa$P{ommyPrHY8Q!t$d&Be&es&`p?fnJeL^$_|t&if9l zoP2!G7SNzGdSB~W93$`UQPVtez_dpDmMC(!;h?nOi z4;AY}74`O9#TL-vu>>g2av)W|sX_^5wQxHebMQZ*eSt=yhF-I$A>AT5@&UL+l_^*7 zJVQMD;&*R*m7+cgzlpLpm#zp~HW_qxXeBc1mgK*Sp}H+&5D5=I#Yh*#(sjnfvrFQJ z!?v$yBF9CM=MnD>eiH@SP3kgoW-*|BLrWrvLO|n3}DJj)#XwI0967 zgHD|kQUI(W5~jGcc_aX|KLQ#oxijhig22cO-d&;)2U@8nN`v$-+7@zAAVLqfB9Ux( zoe<3Ou6@to83w<#wH1Y~_Tw*1!D>S%+3GQYEl;W( zh`ufS(TqTl9vmDzYLaQ5O<&h89nV-A!kuV+59jii`9Uqflbk=(m*{-ilXn48!_6w&5 zIn8~|(Pz(S2)jfe5%&gKb3oSN$0Q=Il%c|S#OB9Zi+gqE;p8;#DrfYK2=9#;zx#@? zcb4wkfLwle0H_bz{G?^mUhJ3qs7jycX(7;cs#(Mn1f~sI6z5CM#N7!gIF1t1;&_0?!R= zCoOSs9KE9w(g#Qe2C|?KK4XwE4yd~=IBf@gqT*jasDuBl(v>=T;nIq`sHD$mOja|i zu>*Eui=>dE>278A_KgmA z2M7AJ-Tq>@fg*x-E*D>Ox9~UyGVeEJWMb&n`sc5viC){8#0dC-@@+P`sX+BIH!Z(9 zjFh`&t6AwkJ>x+EJch7`ia-mJ@z*@Hy9!s{JZ!D)xMqAX9to6n6K`7|XCK9-SDV)P zzbD%eY`v6!26N9&)M|4JMa3>)0>1^`v7D z=D48pi0t$75 zbTxFFj*N_}>({UT56=xvT_DIOOG~@K4ki5%Q}j;eJr@OoZU$LZQ$vH_F3w1&Mn?3i zd*kefZ<~mIM4)WWdp|IXyI=}ObjMk^EE&*7$m3%NSWqtTPSrz^xaNvVm7a6#r%y70(%LzCOf24Mwf*Mz zSD~gQf}>x1_xVZX+=ZjGDOb2MXqE#>QA~Tv&l|O zY+tLGGC3~(M(|1x=L@4}f!ewVD+Bhf03Vrw9?-G105q-&+)8A%2anDGtD$LtRn|M8 zYDd-y7fC<)bQMZmO;r^3elsrORvRQ$l97y8;PKwo`gryCM5CEMw>xGpE6qCA{_fN( z%IEn3zS$0OXPzzz#C^Xs7shK2Ev#n;%1Ptcpo|v~q8!jWxt*2BarWm99t` z)pY|uSyILWTxPV2sl0C28YC!@LRZuG3s>T%e@yhB##Am5NJG&KbhO;Vv)C4X1?Z(a zDF87<0zJ=15{^OaiQfDo(nzQJk|HU3mtXvzP<}o>9M?%*#2wRTz|TocFcB(#Ozfz* z{O4CFY@SZ^k3f3TFA!uO3Quhv1_P<5>L%a+YVRzgqHOoJKY|Jvgn&q+NQr=ygus9Z zh*HvxNP~3eP>QsKbR*p*4Fb|FCEXGN0|N}rJm+}py`OvUcdxzIdY|`M?}t6#SOd&8 z*LB7}j^lT--kT}|F7iP#Y5JCuEI)BQt1+zu@RVv=EGpFss6sWqu5)=jPRE&P614hQ ziz*9Co_#+*3rr(R?X|iUO-no?A|hUJ_=Yt<1;+g9UP7`UQ8cZd?+b`r_=w@YDuPSB zVB>lh%xxM+Iz@q?aAhzRMjVAE1}sKU8;7DA;}HGT%6Yc6pbcro7q~Hl#oWiOaHeLo zciGa|j-SkCYYump5d; z+ZA!n=X1VIrw^^Ijm7_$)vj(c7i#c=byaGbbY(G5h-FMqecG zs&w=S+!cKcgjb$qS9W)4r80+y%O{efA|lK?ySZ=eHMC0SG3jh!1Zo(w7&A0{gkW>p zutZ1Lf2qY&JNo>RhW6-br$4fD)Yh%JsQTI*> zutU=~o<4@Vf4I*GMb0#d%$DMpuVnk#GAH7+*YPbgVB8>PUJH;`R#KV5Uj14GpudT7uW+L3WK4{-kcmJl=GI(VteorvKD?x z=It+$Ql`e%d*-wpczwP3Nyw$EB$uG`oeWg*k1Wp_fO%rVtkC6j&<|dB(g1QfUj?iS zyk$U<#%sM$B}}t1TNGMX^qefRZ+$||a)5^m-&A2z^K1EXwU?!&UWANj8_H<12CuRp zUcKSOIQ9Xq7@$hr@G367=NhbBZgR>H!)LfOwTRlb#n;tK!wL9K*2_-V&PAz_e$1cm zUi)dWEu?Um5p^H)cwZ>S+8A~2vvAR=zv=Fm!E`kYhR%2MqANYIDypiCSq2H@LT<)Z zG_U`XA^lRo5D>iOe@MmerV0UlR>yO1a?7erkPHF&UlcTig8g-qcPl&lAc&>Rqx%x| z9nH_)2E!XrVcpB&pnGfQ-j2Ju@8YhjJE#G#gW9dqkiO#2bX5K1whYq2#`&5&5P@!5 z*Wyw1IGs%AfEbTw)=5iUy)0(cSk!mkpCS3vB(qG@4a!;ej1lJ#hT5YX42xA_n%bCt{fyrOck*;J9|sp_bweUJbKsOY%@Z2lnZgCvKX-bLO9xZKH z)+hkcF+KqSyW5v{9%mzLM@(R7!+f^GdwYo1#9bc$lu>zqAX;bl82L%Q;4;%k!ono?z_`&Q_!`DLC~W! zg{8N(gkz+<_*P|Y3sQ^sO_tIM>vwsxI>vLBzvD(ualjbsV}0_$_<-px-SG~E1y9uvb9L<%rt)13!-)-^o83`iW zoND0CktBSgM97Utsj<|vrcE+ z?k9UT)UNWRN2YMtw)M#DE%U93)RPva$>oWcYw*w|N-dHav+9VzbEOWz2LrJgPV9po z5VVch+p^VcpMvaN*=1rOgE{KH){9MM3LgABr})@7zMiFDC$vUBwpKk0o*aW!>9t7Q zu8(@;52D&Tq}Y6Ki)BLi{IAVF;3^5{FZ^qUFsXoNNTFTfVlHE$E!{sxehnjFUK%SU zU$hq5%_mSP8Hn@3I&s=U#o>GIbbRA|R)G{JET40wTQvBiqp8-M*MhY^bvO3Rt0W z#%@31{r3@W8Jw|F;tG)v2pb%S7_bwVI5_SFaEls)%GIgWx(1jpJ-yw#KQ-)|Ohu=N zANOmgU#xY4q&afhViq=+!h6AdqMRQ!wX@hBLI$;vDoPT(fAhJ6j73ZmER^mN@2+b1 z1%^HgW&?nfYgUjn0mWYC_8?MHW70=1OI=E40RaK6o%F6yQ7pWwy|i6WGVtFs_y{WS zQy!Bnpo%Knj{TXbSsj;`3EWBiPv3hIq59jxi82gs^|A9UemzN?jX)s`uRl9xW)ott z^ufBGt%#WDX0uz5+)DPCuQqbs-(Y2!D7V-jjtK4a({^4v0y6Qu<3E+b+F_^G8!<0H z-SR4gmm7VxGHhTBnEoLrEv@8&HKloB{?e0jJ(qR|dQu?b&CIGq4K3nXek;x?&dx@T z9ksYS_8yz}1?d>l)6of?^eCJlslU}3m~9gt?{6xZrz+=B^1V6?NvypHNx;Zo_9T>` zx#(&2&}haRxWYOL!|Qa4tx8^(Oyt{~IJ&s_t*$br4}Qy43Y3no97S9Ky$m3A2_oZ@ znpp}3dlHZ$<_;^Fe!ZKMi2xNy42TTrsLf{_;2Ia^J#j})J!h?4mFC@jeFP1`N-hXlXn);7(j=(F0>LUHC+E=YVOO3)Vg2_m6>T>e%YoX2!ba3$^)n@Al_K-W zOr;T>O@~_Gdt`>pDZXOCO27^te;pDmdjmoQl5lSZL4zrTj$ez3%u^7g-xN*^CSxym zEZX`&20OAJ?NMv=+as<~m4_U@`Js**0Du9v@i79@;sYaCnb=ch;YloUJQ49dmGE!u zmNVY85@nCi!h7#CK`@$aSg=|_1;T!?x0NaU&%A{yJW3dwrpI?2{IdwJ_j;g2(ROf4 z=KUS&+fhWqYWL5v8LmUSv;VRRm^y;Y@f`pR7~HRmRGkCBZB zgG$PPb?u1YMuV)PD$*a^819_1C?C^r21K4JbMXcFW#nATDOP@0CNg?v2g{9XMHfc+&@Sgi;?X!=kYbHrv*{AbcTHi8FQuO$!hXTZ~>{K z>bUFRR&Ng}Zk#R%9*Zc*YwCA)6ABl2pGZd4O??f21^ zTIj&^3?kg>s6K9Cl|e`6P%B7JuM8QAUC?Ur5)q{g|LaX5CgX@IK`oq%Sy_ z*~dLwncB6KC>rLuGF=vQ1w22xfc)i@Dj1MEJBSif^b3^&@L!jqCh& zSSif6F|fNbapJUnKz8}|hocJODPq&WW2-4kVRcVp#RW+ASX`mi74o9)4qyAAH0FZ-wsx&SZy zrgVC~%caI^%VPZf({W+R0c{(%>-Si#jDzW|5_;Ls?h+J&fg;9O8|5R9?dw^SyKz-z zFmq> z8%@U{`@VfKGo|Jk=F29H?vd3SfSRW(b5ud0C_-~FQh9nnLd|u2ZP%x9&%3rxSdJto zEu%*I`!e2}Z6iOrOL7tXW_;N}!r0k`nMoFNc?BRE#$@zdAD$t+;^T1x+=;4Uoo*2| z@R=2RcHLA3U)#c7WJ`=Oygrr$7xYX~vs*+Oys2&AC*r*R4YnMQXMe*Z@op3mr0JbE zHm3TZoS|)6o&V)jfK??oGS_POcBt_}qkY0zS=Vcs$b*~D3$vkrr6#MJ_P;K8`3I#pGUFFz2Yu}+;XgHc z|9gDofA&0F{+J+@sy1S0FaAnRmUlHYyY!3Fd;j+yjlTE~MBi6_VSWGK0;&JW4gdYH z|HG^P*Neixw;(-3htge(JJ`bPWWnN~vJX~di0&JF?AA+c7`~zqURKb?^V}98yXal= z1oGJ$O)HvDNutzo>BPpgCp+6^BfCb7AuNrK{Sl_JKG(u&q?FEIj)fmD$qUn8^fU#5 z7fty{!ofYkg`G1@Ut(!chxYMGn`jc{xD8RvoA(;{yBQ>_W$xzoSgZiJnroNqCpVG9 zw+Z8d=$$Pq)X)QU)1+`;0(umxocBRQZl(4hqq%gW-36@V0GM3`=**t@%G28^S^49R zD|Y+}`-ai&i8m7Q3qpzPiCD-00$A44yrgXJsY#M{y5L6n>b{H4v*E)w%r}3t~%tC}NtYzD8iM5@|h%_qJJ+3La~ZxiVbfpnCywXdU$ef=%9V zpRrCQ=Bd9VK7*+I$uO15wP4F6A_77+Q>Ly-Tm6-d>waT!;;g}9EH=~eqLv1_$(!}Z z>AX1;xSRZ|TekPHN?{%wqFxWe$zo%a-WQFSbmQGzN)dr%1F<6HvG!4k{Q1+>vl1#m zcU8-~KfVGb9p9T$c2*Yl z2C>r0T%*abP;K=DEt(eHKq1P~Pn4S5U-6i{*c@07UFl$W5|_^A zyeOlF7%?vz4>ZSw#|&6JsibTy(AYV*tS_V*Tt01xC9mU*N|?#NTV%Y?u!~zT&SI*J zbhW*LTQD=7rc<*qXL;m262)p!TKc;dK&{f@*9LW0vS}|D+~sMll#1Gotvp!$TIs>k z)01a%8G)qSgmv?dTUf-p?PuXtQxvjUI7!lI9!9?@y($ZVn6|NlCrs7Vj@6ynZNepL z5%=KqlkKCBjTZoaid(xmmC7TVfBgQM$_syXn^z<4wx=d?_jn^+UmhVp+os)krH*{w zKULMQE*CePI^NMv&%HLV(ed3UqgssR#QxT9bmv<}y}3>cxTH{Sn2Up1fk<@ifw5-U zm(&9t{CLxw?SzR$WBC#qq?FR%SCdq(U1v4n{P^5j*~3KJIzpz`PtZx%iXxi}GwN)d zG|#aU$4sPiG+D(QWgjf8igW*Jk{%eOpG(jauXrwp;{`I^=q$9`MTft9Ow7mN;lx8n zt+_CnbQffu)%yfh)Z*9NB9iz8X*G@~%Ui&7bv(;o*s{81bI0kfbO<%8+j2W?JhFSu z%TZ>S)Xw#$g*Kw?+R>s|(bUbMyA5KqxUub%ja-(nQOka}+YEhY2iI+i3k^^q@r^p# zuKw-I=bN$p&us$rO~Js9Oh{6WG1-BZ+xA`NLo!NSV)NK{I5L&ol*cgn6uv>}hh&?c zsdIES^XOcc{eH!lTK1^PhbrXGMNhIn7P-^YG%rGCb~Th=XacK9ceT$iC9?VV+0 z08c=uT(7Aumrd2_3wcR7zqwrmFJD--?rhSfcxFKfaV8j9WZDhA<$4P8J=AKbE(hV| zBu$5lDjx(7142~28T6wF+ZEz;nixhZdrgkxV?#dc zDU`J1cwe$o5%nCZoZV1lcMy@&a}01yOQ^*(1o=(F&@s8A_-<0z91?A8zRd z1csYz&vhjt?3a9{+|MjYRl-#whgmD+bv1WCiZ~|IGTcz-ww_3AYG-^X{6i$spy#>q zv|{>@Ixbk8x`-My)i7^K!$6@8x~jl_x(b}4$-_rr_qVu8to>VRb-lpSI(jnRgaqB zlSYNaa+BV)KS&HLhspQI*a|X(uoE=ip7~z*Tm;VI&=S8O%z`z#0Z1kgA@rWWvvCWn zm|;Vf#VqK-70BZ+t+9)X zJ!vAUD{>$#yf=(i5u$^?u7&&Q5d3gczV7Unc?$VmHF2-v%DIl?H{;~j6AXwTAzA-; zAV{m4_ywA4<~EdF<>Qx9K2oTB0&RQXwm|>Czoj+c$|2cF(-vjw&!<_VCFZUTne2;4 z3>tIcKt4;N^N19(hw&93KdPo!TU960(aBV{Q$qy|KFGqW& zD#v#bh$rd#K@UtT+Q$<>tMFAt)ViH|FZ&WL8H(@}3V{GqMF==_MR9ei+7?(vlUn7Y z8O*pQE?}NC?z9n+CV%kZ-HH4YYE?>es*rvi1oPLj-*7G~xA_l=K>c!F;639xVjfB%Ol^(=RG=}(I8(#{%{9$LV99K{w2^x4=wsT58W zP~Ex_(}03_VwY7jIWHV(p-`on^WDT!@J$pK_ za`xV=+|kYc)6L2;Z5ZeFowH-4^S0MX5^TQJ+q?OZMI#+Ick3b&5HkRr3I))K31dLW zrD4$kh=9?uXWQw(_<02)tP0E|o;6GOvno37EbRvtRrAM`j~swLct5{zTDefO+7YgO zwEB*q#h~tFHY>Sv4fyK!)`rYRaz~oUckN1_WmaK}eqpJcMzofjG>EszLeKB|-3%6A z%!pv^?GbRB``mO}^fFx>p%Acj53N0YCKDwa!7|d2*)p$LVLCF9D(Z3WEdCM+JW|Q$ zaD~{r2Adhtny2$v)Z>&6C^r~TSARb|%qyxk9a$gYFv`rx0B#Iq3m!BeAcX9Z#T2-f zveHse#jl_9zB=Oy%Vh`(xqP~oMT&Al#yZX>mBLWx2Hh&>$dlL6-S&VaBOxIH+_;n+ z=k@1Z&%sbSaz`dGP{H(D-Uf#XoHO`{%w|fHTb5W-sE9XoMoS+LMaXVWSL!tMg+hA| zEshVQ;7#!Y#~pcsxNm);+BOSj+27op2CDXHX=!s3 z8WpdZe1;7o=sQgmK1$+VK5mH*3NaYJ^DfAZMg7yQ2Ufyvnr@2viU4DD%@;uo#0WIh z;j$f_eTB>186$aPQ+B(-HW%+7a`$h|2o|VuMht%BxpgD_1^|SFZPq$=ralg(n|)O_ z;Q&W$jXSLlqynLRtYv%__t=m9bctX@qxYcxvu9EreVqjJk3+6^&5fOHSO7V0^-19* zKyo0zM92b3_5ME3OnB!5;6VY%4p-!AgTpjGpS%m4nu@5QxL&Va#wLl8ByC8B9HJG-oZ>k|+YUx3Krfbe;k_!fTz;Yd=p-qut#;11m52DY`_ zeu0yXvZ0|z9f#5J5syrVpMnBt|kPca=z$ZWJQqEg4O&t;`|3@Gcw zjWJ+Ye7CVov{;W;?8aYz(z_@43UEEO_#L+=J1lHCI0(KcMCvD1yQ2XK38A z+6A`EfWAhuEna`NFJop*>giOOQK*zpr=%o~KTaAhE>*`<8H=wu3KFPLFpY`lGaQSO zKnDvcd$2a0SM~8@Rh2P-2}H2gt7gsv>p0LW7HF4^zAD*(T5T6Tve<w5#k(ne#(K7%#;Nrr9I06Z)T5J8s^ozZ3jR0{e_`Dzu37{QfN+^Cg-@KyjGzjZ`yjUQvAr~|2`2)YS z4_$v%%0_eZ{_L$vpyLra7Fu6izoCTp9RR=l9@h5O+_W|4JLTRGm65H(OWy&W2iYT$ z1p8!Q7bHg4oP@dE*u9H7#M@6AjDK$FuL{6&%q{Y0?L9gAad8!2k*A0 z=a0%)b}tj~w%DoH59&u+20$PQiXbY2yV z4d*Ik?LnbCtM)uI%0q+}H9HdkMboov&gg74guMA+6BlskVxh*vRSGQ@L9y6o*VzKf6mhmx#~ z)Jq(cE%`Rmad04jIwzs=HeTVi@jq8BK0cPBOs%WfMtzAH1pzD?n zETYASqjB24y#V;55PZq9CoVhT1~zHZKa|IG9PMr7)A*VMEN7YzGCh{ea}aS&@(OXt zjllx>bhjN{Q7kZUU-r+qgNH(iE=Of21AV{#(}MKK zfwG#_D)WfvT#SwdGl7F{YZCTyH>#jiaJM7Oappu=Cg_%s-Cp)Q>R=4kv1XW)6DqV5 zosclKw}*teoX!K#0`X1aPL*$^29umHRJ3-Ho+ zo54>i4JEQ&9Rj(cgg#U7PeM)P4t!v~N0~(f%|$}I$Qr74J(y9~=A>a_O3kbWnk<6- zc78t)cPpj-0Tf?<{+$cu+rBvX!wxy;M}ZK@3bIuSWw_78QgAE}NIs%Tt%&pCP+UU_ zm;H8l&_7ZU*cy;y4mC%zoK0fCcEO+wGh$~K!>czii_Rk8vC*c9`W6ip+n1_XBW+fl3Fc-;G zBjcELoOk%@Mc_6y=jWj-rc9@QQXCTU^Iwx*Jqtz<19Ecin6}4lAt8^oQsN?6=aZ_U zYTcozD3-6e4Gk2Qn&nRZV1fz%EqnS?STQzl6-RgjKt(U5gaa3(-J?9)jJE3Cn?3Q3 za_Kud!D$--L|)GoAf5}qPi~ukxrR{EsN7t2P>wdtkp={Y!s*&*R;{GOi07x6p6Vb* z1}Q@E63Kndy88?ali|^34_4hVebhltL24%X@2m&^V!8MiZ~sMj0>+1+eMK8~I)vtl zW&)p3-+eM)dt7kTUf$z>>@DES1IGRKPuS`YE8bole@V$s3^{ojnNM_VV=o8H>dNCd z5Cp+!94Z9ze|`Mn*Q*d=r}^+n$ny)3VFGkQN0UDV8na-k^PqE2`=B=v;y*j#zI2x$ zxu7fPX(d0pY(KGyAqRH+p-lJr?88$+j0yoX-<)X^V#)k~;IJbkFkzIJ6bOzJ}R zZQkotodWksu;B73Ogi_T`a%O*T$23MBbz2*gGFE$^X}Ka}W?7*(S# zb8qlmfmDDnU-)nvPEGPnffb0*AE{&b7CqDRadzhOntsAfEb0sKB;!8)$np2vnN<~3PJZ)qhbh5iyH2dz<9yA5m z_A0_gN$Z=)$r3}}YF2oIDz{z%&)nG}9LDmV_ZK6G=t4GVLy=+5;3JsB24MOm_oo;2u3L}(y!!v&tFFEY-K#Kk>!=@xMf zap66=^SGCt+FaWCxf*2nA|8%%`2~onI2bM4^OQ7u?+{YAu-_W^_!)=>h}8BEDu`!a zr4Y`>p^$cxA|nEIDRhTHQEA+)JWYBL+SJ2-GU|Lft%rN(N!7qb^KS#jA+fxfYv0H? z?m#Gf(9MlDlFQwXQ+bY^xkNC%lRgvSBklz|V1?q`IEAEypsVf8x#V~6o3TBD8(TH< z@={>viqM91zDaxQ)r9anJj4_>Kz|i;py3pTSAwp!UcTo8CD$3+>lnfn=s!o8_W2rw zDw{0cdhF}va!~*r?fOT7J7vRDM3ypwDp4=nn-M&NArQY*rNFOV)bodUF8);9bg?sp z?G&3aMv7uz^mGBL3*m2RM%O@*xScf}z4-jCW&*wmc>R4BF8gh=!SLTTRQ`6F>wj93 z;FkU82;G065&!21-G4%8{vSWmct&{DhX1ExM40sa*6MZNhZ_FfV)Pf$bD6G#i%~Bn z0ccDNFQ9*x>qlWiASl$OP8@ph<7IS!i3zchr5B}%RR`$|1R^OW^Q7?cOYi>zro>uf literal 0 HcmV?d00001 diff --git a/vendor/fxnode/test/browser/visual.spec.ts-snapshots/numeric-fields-linux.png b/vendor/fxnode/test/browser/visual.spec.ts-snapshots/numeric-fields-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..80e89d4e6cb05d0be6873f7e323b08aba3c45194 GIT binary patch literal 31554 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 z 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 literal 0 HcmV?d00001 diff --git a/vendor/fxnode/test/browser/visual.spec.ts-snapshots/zoomed-out-lod-linux.png b/vendor/fxnode/test/browser/visual.spec.ts-snapshots/zoomed-out-lod-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..f0d4597fc455ed9fad0b95c581f21373408106d4 GIT binary patch literal 41550 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 literal 0 HcmV?d00001 diff --git a/vendor/fxnode/test/browser/worker-composition.html b/vendor/fxnode/test/browser/worker-composition.html new file mode 100644 index 0000000..4212cf6 --- /dev/null +++ b/vendor/fxnode/test/browser/worker-composition.html @@ -0,0 +1,27 @@ + + + + + + + + + + + + diff --git a/vendor/fxnode/test/browser/worker-composition.spec.ts b/vendor/fxnode/test/browser/worker-composition.spec.ts new file mode 100644 index 0000000..b2a8f02 --- /dev/null +++ b/vendor/fxnode/test/browser/worker-composition.spec.ts @@ -0,0 +1,1166 @@ +import { expect, test } from "@playwright/test"; + +test("real worker migrates historical custom graph atomically and keeps init state private", async ({ page }) => { + await page.goto("/test/browser/worker-composition.html"); + const result = await page.evaluate(async () => { + const h = (window as any).workerCompositionTest, + original = Worker.prototype.postMessage; + let initHasValue = false; + Worker.prototype.postMessage = function (this: Worker, message: any, transfer?: Transferable[]) { + if (message?.type === "init") { + const id = message.id; + this.addEventListener("message", (e: any) => { + if (e.data?.type === "response" && e.data.id === id) initHasValue = "value" in e.data; + }); + } + return Reflect.apply(original, this, [message, transfer ?? []]); + } as typeof Worker.prototype.postMessage; + const api = await h.createMigration(); + Worker.prototype.postMessage = original; + const initial = await api.getState(), + saved = await api.save(); + const edit = await api.dispatch({ + type: "node.parameter", + id: "left", + key: "level", + value: { kind: "number", value: 12 }, + }); + const edited = await api.getState(); + let mutations = 0, + snapshots = 0; + api.onMutations(() => mutations++); + api.onSnapshots(() => snapshots++); + const malformed = structuredClone(saved); + delete malformed.nodes[0].parameters.level; + let code = ""; + try { + await api.load(malformed); + } catch (e) { + code = (e as any).code ?? ""; + } + await new Promise((resolve) => setTimeout(resolve, 30)); + const after = await api.getState(); + api.destroy(); + return { initHasValue, initial, saved, edit, edited, code, mutations, snapshots, after }; + }); + expect(result.initHasValue).toBe(false); + expect(result.initial.catalogVersion).toBe(73); + expect(result.saved.catalogVersion).toBe(73); + expect(result.saved.nodes.every((n: any) => n.typeVersion === 2 && n.parameters.level)).toBe(true); + expect(result.saved.links[0]).toMatchObject({ + id: "historical-link", + fromSocketId: "left:source", + toSocketId: "right:sink", + extensions: { kept: true }, + }); + expect(result.edit).toEqual({ status: "committed", version: 2 }); + expect(result.edited.nodes.find((n: any) => n.id === "left").parameters.level.value).toBe(12); + expect(result.code).toBe("catalog.invalid"); + expect(result.mutations).toBe(0); + expect(result.snapshots).toBe(0); + expect(result.after).toEqual(result.edited); +}); + +test("command-log API is worker-authoritative, side-effect-free to query, and atomically replayable", async ({ + page, +}) => { + await page.goto("/test/browser/worker-composition.html"); + const result = await page.evaluate(async () => { + const api = await (window as any).workerCompositionTest.createA(), + mutations: any[] = [], + snapshots: any[] = []; + api.onMutations((event: any) => mutations.push(event)); + api.onSnapshots((event: any) => snapshots.push(event)); + const initial = await api.getState(), + empty = await api.getSaveData(), + afterQuery = await api.getState(), + queryEvents = { mutations: mutations.length, snapshots: snapshots.length }; + await api.dispatch({ type: "node.add", nodeId: "logged", nodeType: "alpha-node", position: { x: 12, y: 34 } }); + const populated = await api.getSaveData(), + eventsAfterAdd = { mutations: mutations.length, snapshots: snapshots.length }; + const noop = await api.load(populated), + afterNoop = await api.getState(), + eventsAfterNoop = { mutations: mutations.length, snapshots: snapshots.length }; + const undo = await api.undo(), + afterUndo = await api.getState(), + undoneSave = await api.getSaveData(); + const changed = await api.load(populated), + afterChanged = await api.getState(), + eventsAfterChanged = { mutations: mutations.length, snapshots: snapshots.length }; + const malformed = structuredClone(populated); + malformed.commands.push({ type: "node.remove", id: "missing" }); + let malformedCode = ""; + try { + await api.load(malformed); + } catch (error) { + malformedCode = (error as any).code ?? ""; + } + const afterMalformed = await api.getState(), + afterMalformedSave = await api.getSaveData(), + eventsAfterMalformed = { mutations: mutations.length, snapshots: snapshots.length }; + const legacy = await api.save(), + legacyResult = await api.load(legacy), + legacySaveData = await api.getSaveData(); + api.destroy(); + return { + initial, + empty, + afterQuery, + queryEvents, + populated, + eventsAfterAdd, + noop, + afterNoop, + eventsAfterNoop, + undo, + afterUndo, + undoneSave, + changed, + afterChanged, + eventsAfterChanged, + malformedCode, + afterMalformed, + afterMalformedSave, + eventsAfterMalformed, + legacy, + legacyResult, + legacySaveData, + }; + }); + expect(result.empty).toMatchObject({ + kind: "fxnode.command-log", + schemaVersion: 2, + composition: { id: "application-a", version: 11 }, + commands: [], + }); + expect(result.afterQuery).toEqual(result.initial); + expect(result.queryEvents).toEqual({ mutations: 0, snapshots: 0 }); + expect(result.populated.commands).toEqual([ + { type: "node.add", nodeId: "logged", nodeType: "alpha-node", position: { x: 12, y: 34 } }, + ]); + expect(result.eventsAfterAdd).toEqual({ mutations: 1, snapshots: 1 }); + expect(result.noop).toEqual({ status: "noop", version: 1 }); + expect(result.afterNoop.nodes).toHaveLength(1); + expect(result.eventsAfterNoop).toEqual(result.eventsAfterAdd); + expect(result.undo).toEqual({ status: "committed", version: 2 }); + expect(result.afterUndo.nodes).toHaveLength(0); + expect(result.undoneSave.commands).toEqual([]); + expect(result.changed).toEqual({ status: "committed", version: 3 }); + expect(result.afterChanged.nodes).toEqual(result.afterNoop.nodes); + expect(result.eventsAfterChanged).toEqual({ mutations: 3, snapshots: 3 }); + expect(result.malformedCode).toBe("node.missing"); + expect(result.afterMalformed).toEqual(result.afterChanged); + expect(result.afterMalformedSave).toEqual(result.populated); + expect(result.eventsAfterMalformed).toEqual(result.eventsAfterChanged); + expect(result.legacyResult).toEqual({ status: "committed", version: 4 }); + expect(result.legacySaveData.commands).toEqual([]); + expect(result.legacySaveData.baseline).toEqual(result.legacy); +}); + +test("save data carries effective composition and reports incompatible current authority", async ({ page }) => { + await page.goto("/test/browser/worker-composition.html"); + const result = await page.evaluate(async () => { + const h = (window as any).workerCompositionTest, + api = await h.createA(), + saved = await api.getSaveData(), + definition = { ...h.compositionA.nodes["alpha-node"], title: "Dynamic" }; + const added = await api.composeNode("dynamic-node", definition), + loaded = await api.load(saved), + underSuperset = await api.getSaveData(); + const removed = await api.removeNode("alpha-node", { expectedRevision: added.revision }), + before = await api.getState(); + let error: any = {}; + try { + await api.load(saved); + } catch (value) { + const e = value as any; + error = { code: e.code, path: e.path, message: e.message, issues: e.issues }; + } + const after = await api.getState(), + stillCurrent = await api.getSaveData(); + api.destroy(); + return { saved, loaded, underSuperset, before, error, after, stillCurrent }; + }); + expect(result.saved.composition.nodes["alpha-node"]).toBeTruthy(); + expect(result.saved.composition.nodes["dynamic-node"]).toBeUndefined(); + expect(result.loaded.status).toBe("noop"); + expect(result.underSuperset.composition.nodes["dynamic-node"]).toBeTruthy(); + expect(result.error).toMatchObject({ + code: "composition.incompatible", + path: "/composition", + message: expect.stringContaining("semantic superset"), + issues: [ + { code: "composition.incompatible" }, + { code: "composition.definition-missing", path: "/composition/nodes/alpha-node" }, + ], + }); + expect(result.after).toEqual(result.before); + expect(result.stillCurrent.composition.nodes["alpha-node"]).toBeUndefined(); + expect(result.stillCurrent.composition.nodes["dynamic-node"]).toBeTruthy(); +}); + +test("dedicated workers bind disjoint application compositions and plain init sources", async ({ page }) => { + await page.goto("/test/browser/worker-composition.html"); + const initialized = await page.evaluate(async () => { + const harness = ( + window as unknown as { + workerCompositionTest: { + createA: () => Promise; + createB: () => Promise; + view: (api: any) => any; + }; + } + ).workerCompositionTest; + const original = Worker.prototype.postMessage, + initMessages: Array<{ id: string; applicationId: unknown; applicationVersion: unknown; resources: unknown }> = [], + initResponses: Array<{ id: string; hasValue: boolean }> = [], + initOrder: string[] = []; + Worker.prototype.postMessage = function (this: Worker, message: unknown, transfer?: Transferable[]) { + const value = message as { + type?: string; + id?: string; + applicationId?: unknown; + applicationVersion?: unknown; + resources?: unknown; + }; + if (value?.type === "init" && typeof value.id === "string") { + initMessages.push({ + id: value.id, + applicationId: value.applicationId, + applicationVersion: value.applicationVersion, + resources: value.resources, + }); + const id = value.id; + this.addEventListener( + "message", + (event: MessageEvent) => { + const response = event.data as any; + if (response?.type === "response" && response.id === id && response.ok) { + initResponses.push({ id, hasValue: "value" in response }); + initOrder.push(`${id}:response`); + } + }, + { once: false }, + ); + } + return Reflect.apply(original, this, [message, transfer ?? []]); + } as typeof Worker.prototype.postMessage; + try { + const [a, b] = await Promise.all([harness.createA(), harness.createB()]); + await Promise.all([harness.view(a).whenRendered(), harness.view(b).whenRendered()]); + const addA = await a.dispatch({ type: "node.add", nodeType: "alpha-node", position: { x: 0, y: 0 } }), + addB = await b.dispatch({ type: "node.add", nodeType: "beta-node", position: { x: 0, y: 0 } }); + let crossA = "", + crossB = ""; + try { + await a.dispatch({ type: "node.add", nodeType: "beta-node", position: { x: 0, y: 0 } }); + } catch (error) { + crossA = (error as { code?: string }).code ?? ""; + } + try { + await b.dispatch({ type: "node.add", nodeType: "alpha-node", position: { x: 0, y: 0 } }); + } catch (error) { + crossB = (error as { code?: string }).code ?? ""; + } + const beforeDestroy = { + a: await a.getState(), + b: await b.getState(), + saveA: await a.save(), + saveB: await b.save(), + }; + a.destroy(); + const afterDestroy = await b.getState(); + const wire = initMessages.map((item) => { + return { + id: item.id, + plain: Object.getPrototypeOf(item.resources as object) === Object.prototype, + cloneable: !!structuredClone(item), + applicationId: item.applicationId, + applicationVersion: item.applicationVersion, + }; + }); + b.destroy(); + return { + addA, + addB, + crossA, + crossB, + beforeDestroy, + afterDestroy, + wire, + responses: initResponses, + initOrder, + }; + } finally { + Worker.prototype.postMessage = original; + } + }); + expect(initialized.addA).toEqual({ status: "committed", version: 1 }); + expect(initialized.addB).toEqual({ status: "committed", version: 1 }); + expect(initialized.crossA).toBe("node.type-unknown"); + expect(initialized.crossB).toBe("node.type-unknown"); + expect(initialized.beforeDestroy.a.nodes.map((node: any) => node.typeId)).toEqual(["alpha-node"]); + expect(initialized.beforeDestroy.b.nodes.map((node: any) => node.typeId)).toEqual(["beta-node"]); + expect(initialized.beforeDestroy.saveA.catalogVersion).toBe(11); + expect(initialized.beforeDestroy.saveB.catalogVersion).toBe(22); + expect(initialized.afterDestroy.nodes.map((node: any) => node.typeId)).toEqual(["beta-node"]); + expect(initialized.wire).toHaveLength(2); + expect(initialized.wire.every((item) => item.plain && item.cloneable)).toBe(true); + expect( + initialized.wire.map(({ applicationId, applicationVersion }) => ({ applicationId, applicationVersion })), + ).toEqual([ + { applicationId: "application-a", applicationVersion: 11 }, + { applicationId: "application-b", applicationVersion: 22 }, + ]); + expect(initialized.responses).toHaveLength(2); + expect(initialized.responses.every((item) => !item.hasValue)).toBe(true); + expect(initialized.initOrder.every((value: string) => value.endsWith(":response"))).toBe(true); + + const a = page.locator("#application-a"), + b = page.locator("#application-b"); + // Recreate editors solely to verify the DOM menu remains the host's static HTML. + await page.reload(); + await page.evaluate(async () => { + const h = (window as any).workerCompositionTest; + const [a, b] = await Promise.all([h.createA(), h.createB()]); + (window as any).menuApis = [a, b]; + }); + await a.click({ button: "right", position: { x: 20, y: 20 } }); + await expect(page.getByRole("option")).toHaveText(["Alpha Node"]); + await page.keyboard.press("Escape"); + await b.click({ button: "right", position: { x: 20, y: 20 } }); + await expect(page.getByRole("option")).toHaveText(["Alpha Node"]); + await page.evaluate(() => (window as any).menuApis.forEach((api: any) => api.destroy())); +}); + +test("value inherited from Object.prototype is never mistaken for worker response state", async ({ page }) => { + await page.goto("/test/browser/worker-composition.html"); + const result = await page.evaluate(async () => { + const h = (window as any).workerCompositionTest; + Object.defineProperty(Object.prototype, "value", { configurable: true, value: { leaked: true } }); + try { + const api = await h.createA(), + snapshot = await api.getState(); + api.destroy(); + return { created: true, version: snapshot.version }; + } finally { + delete (Object.prototype as any).value; + } + }); + expect(result).toEqual({ created: true, version: 0 }); +}); + +test("live composition instance API keeps one worker-authoritative revisioned handle", async ({ page }) => { + await page.goto("/test/browser/worker-composition.html"); + const result = await page.evaluate(async () => { + const h = (window as any).workerCompositionTest, + NativeWorker = Worker, + workers: Worker[] = []; + class TrackingWorker extends NativeWorker { + constructor(url: string | URL, options?: WorkerOptions) { + super(url, options); + workers.push(this); + } + } + Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: TrackingWorker }); + const api = await h.createA(), + compositionEvents: any[] = [], + mutations: any[] = [], + snapshots: any[] = []; + api.onCompositionChanges((event: any) => compositionEvents.push(event)); + api.onMutations((event: any) => mutations.push(event)); + api.onSnapshots((event: any) => snapshots.push(event)); + const definition = { ...h.compositionA.nodes["alpha-node"], title: "Dynamic Node" }; + const composed = await api.composeNode("dynamic-node", definition, { expectedRevision: 1 }), + sameObject = true; + const added = await api.dispatch({ + type: "node.add", + nodeId: "dynamic", + nodeType: "dynamic-node", + position: { x: 0, y: 0 }, + }); + const removed = await api.removeNode("dynamic-node", { expectedRevision: 2 }), + afterRemoval = await api.getState(), + undo = await api.undo(); + const themeA = { ...h.compositionA.theme, background: "#111111" }, + themeB = { ...h.compositionA.theme, background: "#222222" }; + const concurrent = await Promise.allSettled([ + api.setTheme(themeA, { expectedRevision: 3 }), + api.setTheme(themeB, { expectedRevision: 3 }), + ]); + const raw = new Promise((resolve) => { + const id = "malformed-live-update", + worker = workers[0]!; + const listener = (event: MessageEvent) => { + if (event.data?.type === "response" && event.data.id === id) { + worker.removeEventListener("message", listener); + resolve(event.data); + } + }; + worker.addEventListener("message", listener); + worker.postMessage({ + protocol: 3, + type: "composition.update", + id, + expected: { kind: "exact", revision: -1 }, + update: { kind: "node.remove", id: "dynamic-node" }, + }); + }); + const malformed = await raw, + stillAlive = await api.getState(); + api.destroy(); + Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: NativeWorker }); + return { + sameObject, + composed, + added, + removed, + afterRemoval, + undo, + concurrent: concurrent.map((item) => + item.status === "fulfilled" + ? { status: "fulfilled", value: item.value } + : { status: "rejected", code: (item.reason as any).code }, + ), + compositionEvents, + mutations, + snapshots, + malformed, + stillAlive, + }; + }); + expect(result.sameObject).toBe(true); + expect(result.composed).toEqual({ + status: "committed", + revision: 2, + graphVersion: 0, + graphChanged: false, + historyReset: true, + }); + expect(result.added).toEqual({ status: "committed", version: 1 }); + expect(result.removed).toEqual({ + status: "committed", + revision: 3, + graphVersion: 2, + graphChanged: true, + historyReset: true, + }); + expect(result.afterRemoval.nodes).toHaveLength(1); + expect(result.afterRemoval.nodes[0]).toMatchObject({ id: "dynamic", typeId: "dynamic-node", known: false }); + expect(result.undo).toEqual({ status: "noop", version: 2 }); + expect(result.concurrent.filter((item: any) => item.status === "fulfilled")).toHaveLength(1); + expect(result.concurrent.filter((item: any) => item.status === "rejected")).toEqual([ + { status: "rejected", code: "composition.revision.stale" }, + ]); + expect(result.compositionEvents.map((event: any) => event.revision)).toEqual([2, 3, 4]); + expect(result.mutations).toHaveLength(2); + expect(result.mutations[1]).toMatchObject({ + cause: "composition", + baseVersion: 1, + version: 2, + mutations: [{ kind: "document.replaced" }], + }); + expect(result.snapshots).toHaveLength(2); + expect(result.snapshots[0].snapshot.nodes[0]).toMatchObject({ typeId: "dynamic-node", known: true }); + expect(result.malformed).toMatchObject({ ok: false, error: { code: "composition.request.invalid" } }); + expect(result.stillAlive.version).toBe(2); +}); + +test("canonical live-composition noops preserve revision, history, and events", async ({ page }) => { + await page.goto("/test/browser/worker-composition.html"); + const result = await page.evaluate(async () => { + const h = (window as any).workerCompositionTest, + api = await h.createA(), + events: any[] = []; + api.onCompositionChanges((event: any) => events.push(event)); + await api.dispatch({ type: "node.add", nodeId: "alpha", nodeType: "alpha-node", position: { x: 0, y: 0 } }); + const noop = await api.setTheme(structuredClone(h.compositionA.theme), { expectedRevision: 1 }); + const undo = await api.undo(), + snapshot = await api.getState(); + api.destroy(); + return { noop, undo, events, snapshot }; + }); + expect(result.noop).toEqual({ + status: "noop", + revision: 1, + graphVersion: 1, + graphChanged: false, + historyReset: false, + }); + expect(result.events).toEqual([]); + expect(result.undo).toEqual({ status: "committed", version: 2 }); + expect(result.snapshot.nodes).toHaveLength(0); +}); + +test("loadComposition atomically replaces authority, checkpoints persistence, and handles prototype-named definitions", async ({ + page, +}) => { + await page.goto("/test/browser/worker-composition.html"); + const result = await page.evaluate(async () => { + const h = (window as any).workerCompositionTest, + api = await h.createA(), + compositionEvents: any[] = [], + mutations: any[] = [], + snapshots: any[] = []; + api.onCompositionChanges((event: any) => compositionEvents.push(event)); + api.onMutations((event: any) => mutations.push(event)); + api.onSnapshots((event: any) => snapshots.push(event)); + const definition = { ...h.compositionA.nodes["alpha-node"], title: "Prototype Name" }; + const composed = await api.composeNode("toString", definition); + await api.dispatch({ type: "node.add", nodeId: "alpha", nodeType: "alpha-node", position: { x: 0, y: 0 } }); + await api.dispatch({ type: "node.add", nodeId: "prototype", nodeType: "toString", position: { x: 200, y: 0 } }); + const withDefinitions = await api.getSaveData(), + withoutDefinitions = { ...withDefinitions.composition, nodes: {} }; + const replaced = await api.loadComposition(withoutDefinitions, { expectedRevision: composed.revision }), + demoted = await api.getState(), + checkpoint = await api.getSaveData(); + const replay = await api.load(checkpoint), + noop = await api.loadComposition(structuredClone(withoutDefinitions), { expectedRevision: replaced.revision }), + undo = await api.undo(); + const presentation = { ...withoutDefinitions, theme: { ...withoutDefinitions.theme, background: "#303134" } }, + presented = await api.loadComposition(presentation, { expectedRevision: replaced.revision }); + const beforeRejected = { + snapshot: await api.getState(), + save: await api.getSaveData(), + eventCount: compositionEvents.length, + mutationCount: mutations.length, + snapshotCount: snapshots.length, + }; + const errors: any[] = []; + try { + await api.loadComposition(withDefinitions.composition, { expectedRevision: replaced.revision }); + } catch (error) { + errors.push({ code: (error as any).code }); + } + try { + await api.loadComposition({ ...presentation, schemaVersion: 1 } as any, { expectedRevision: presented.revision }); + } catch (error) { + errors.push({ code: (error as any).code }); + } + const afterRejected = { + snapshot: await api.getState(), + save: await api.getSaveData(), + eventCount: compositionEvents.length, + mutationCount: mutations.length, + snapshotCount: snapshots.length, + }; + const promotedComposition = { ...presentation, nodes: { toString: definition } }, + promoted = await api.loadComposition(promotedComposition, { expectedRevision: presented.revision }), + promotedSnapshot = await api.getState(); + api.destroy(); + return { + composed, + replaced, + demoted, + checkpoint, + replay, + noop, + undo, + presented, + beforeRejected, + errors, + afterRejected, + promoted, + promotedSnapshot, + compositionEvents, + mutations, + snapshots, + }; + }); + expect(result.replaced).toEqual({ + status: "committed", + revision: 3, + graphVersion: 3, + graphChanged: true, + historyReset: true, + }); + expect(result.demoted.nodes).toHaveLength(2); + expect(result.demoted.nodes.every((node: any) => node.known === false)).toBe(true); + expect(result.demoted.nodes.map((node: any) => node.typeId).sort()).toEqual(["alpha-node", "toString"]); + expect(result.checkpoint.composition.nodes).toEqual({}); + expect(result.checkpoint.commands).toEqual([]); + expect(result.checkpoint.baseline.nodes).toHaveLength(2); + expect(result.replay).toEqual({ status: "noop", version: 3 }); + expect(result.noop).toEqual({ + status: "noop", + revision: 3, + graphVersion: 3, + graphChanged: false, + historyReset: false, + }); + expect(result.undo).toEqual({ status: "noop", version: 3 }); + expect(result.presented).toEqual({ + status: "committed", + revision: 4, + graphVersion: 3, + graphChanged: false, + historyReset: true, + }); + expect(result.errors).toEqual([{ code: "composition.revision.stale" }, { code: "composition.invalid" }]); + expect(result.afterRejected).toEqual(result.beforeRejected); + expect(result.promoted).toEqual({ + status: "committed", + revision: 5, + graphVersion: 4, + graphChanged: true, + historyReset: true, + }); + expect(result.promotedSnapshot.nodes.find((node: any) => node.id === "prototype")).toMatchObject({ + typeId: "toString", + known: true, + }); + expect(result.promotedSnapshot.nodes.find((node: any) => node.id === "alpha")).toMatchObject({ + typeId: "alpha-node", + known: false, + }); + expect(result.compositionEvents.map((event: any) => event.change.kind)).toEqual([ + "node.compose", + "composition.load", + "composition.load", + "composition.load", + ]); + expect(result.mutations).toHaveLength(4); + expect(result.snapshots).toHaveLength(4); +}); + +test("live node composition does not alter the host-owned DOM add menu", async ({ page }) => { + await page.goto("/test/browser/worker-composition.html"); + const canvas = page.locator("#application-a"); + await page.evaluate(async () => { + const h = (window as any).workerCompositionTest, + api = await h.createA(), + definition = { + ...h.compositionA.nodes["alpha-node"], + title: "Dynamic Node", + }; + const update = await api.composeNode("dynamic-node", definition); + (window as any).dynamicMenu = { api, revision: update.revision }; + }); + await canvas.click({ button: "right", position: { x: 20, y: 20 } }); + await expect(page.getByRole("option")).toHaveText(["Alpha Node"]); + await page.keyboard.press("Escape"); + await page.evaluate(async () => { + const h = (window as any).dynamicMenu; + await h.api.removeNode("dynamic-node", { expectedRevision: h.revision }); + }); + await canvas.click({ button: "right", position: { x: 340, y: 200 } }); + await expect(page.getByRole("option")).toHaveText(["Alpha Node"]); + await page.keyboard.press("Escape"); + await page.evaluate(() => (window as any).dynamicMenu.api.destroy()); +}); + +test("worker independently rejects a composition corrupted only on the init wire", async ({ page }) => { + await page.goto("/test/browser/worker-composition.html"); + const result = await page.evaluate(async () => { + const harness = (window as any).workerCompositionTest, + canvas = document.querySelector("#application-a")!, + original = Worker.prototype.postMessage; + Worker.prototype.postMessage = function (this: Worker, message: unknown, transfer?: Transferable[]) { + const value = message as { type?: string }; + return Reflect.apply(original, this, [ + value?.type === "init" ? { ...(message as object), resources: { malformed: true } } : message, + transfer ?? [], + ]); + } as typeof Worker.prototype.postMessage; + let error: { name?: string; code?: string; issues?: readonly unknown[]; message?: string } = {}; + try { + await harness.createA(); + } catch (value) { + const e = value as typeof error; + error = { + ...(e.name === undefined ? {} : { name: e.name }), + ...(e.code === undefined ? {} : { code: e.code }), + ...(e.issues === undefined ? {} : { issues: e.issues }), + ...(e.message === undefined ? {} : { message: e.message }), + }; + } finally { + Worker.prototype.postMessage = original; + } + return { + error, + tabindex: canvas.getAttribute("tabindex"), + touchAction: canvas.style.touchAction, + inputs: document.querySelectorAll('input[type="file"]').length, + }; + }); + expect(result.error.name).toBe("FxNodeWorkerError"); + expect(result.error.code).toBe("composition.invalid"); + expect(result.error.issues?.length).toBeGreaterThan(0); + expect(result.error.issues?.length).toBeLessThanOrEqual(100); + expect(result).toMatchObject({ tabindex: null, touchAction: "", inputs: 0 }); +}); + +test("overlapping resource tokens resolve isolated worker byte and dimension policies", async ({ page }) => { + await page.goto("/test/browser/worker-composition.html"); + const result = await page.evaluate(async () => { + const harness = (window as any).workerCompositionTest, + openedA: any[] = [], + openedB: any[] = []; + const [a, b] = await Promise.all([ + harness.createA((request: any) => openedA.push(request)), + harness.createB((request: any) => openedB.push(request)), + ]), + viewA = harness.view(a), + viewB = harness.view(b); + await Promise.all([ + a.dispatch({ type: "node.add", nodeId: "shared", nodeType: "alpha-node", position: { x: -60, y: 30 } }), + b.dispatch({ type: "node.add", nodeId: "shared", nodeType: "beta-node", position: { x: -60, y: 30 } }), + ]); + await Promise.all([ + a.dispatch({ type: "node.move", id: "shared", position: { x: -59, y: 30 } }), + b.dispatch({ type: "node.move", id: "shared", position: { x: -59, y: 30 } }), + ]); + await Promise.all([viewA.whenRendered(), viewB.whenRendered()]); + const click = async (canvas: HTMLCanvasElement, pointerId: number, opened: any[]) => { + const rect = canvas.getBoundingClientRect(), + before = opened.length, + init = { + bubbles: true, + pointerId, + pointerType: "mouse", + button: 0, + clientX: rect.left + 200, + clientY: rect.top + 115, + }; + canvas.dispatchEvent(new PointerEvent("pointerdown", { ...init, buttons: 1 })); + for (let i = 0; i < 50 && opened.length === before; i++) await new Promise((resolve) => setTimeout(resolve, 10)); + canvas.dispatchEvent(new PointerEvent("pointerup", { ...init, buttons: 0 })); + }; + await Promise.all([ + click(document.querySelector("#application-a")!, 41, openedA), + click(document.querySelector("#application-b")!, 42, openedB), + ]); + const initial = [openedA[0], openedB[0]], + accepted = initial.map((item) => item.resource.accept.join(",")), + resourceIds = initial.map((item) => [item.resource.id]); + const frozen = initial.every( + (item) => + Object.isFrozen(item) && + Object.isFrozen(item.authorization) && + Object.isFrozen(item.resource) && + Object.isFrozen(item.resource.accept), + ); + const pointerUpDidNotDuplicate = openedA.length === 1; + const attempt = async (promise: Promise) => { + try { + return { ok: true, value: await promise }; + } catch (error) { + return { ok: false, code: (error as { code?: string }).code }; + } + }; + const image = document.createElement("canvas"); + image.width = 2; + image.height = 2; + image.getContext("2d")!.fillRect(0, 0, 2, 2); + const raw = await (await fetch(image.toDataURL("image/png"))).arrayBuffer(); + const padded = new Uint8Array(600); + padded.set(new Uint8Array(raw)); + const dimensions = await Promise.all([ + attempt( + viewA.provideResource(initial[0].authorization, { + name: "pixel.png", + mime: "image/png", + bytes: raw.slice(0), + }), + ), + attempt( + viewB.provideResource(initial[1].authorization, { + name: "pixel.png", + mime: "image/png", + bytes: raw.slice(0), + }), + ), + ]); + await Promise.all([viewA.whenRendered(), viewB.whenRendered()]); + await Promise.all([ + click(document.querySelector("#application-a")!, 43, openedA), + click(document.querySelector("#application-b")!, 44, openedB), + ]); + const next = [openedA.at(-1), openedB.at(-1)]; + const bytes = await Promise.all([ + attempt( + viewA.provideResource(next[0].authorization, { + name: "pixel.png", + mime: "image/png", + bytes: padded.buffer.slice(0), + }), + ), + attempt( + viewB.provideResource(next[1].authorization, { + name: "pixel.png", + mime: "image/png", + bytes: padded.buffer.slice(0), + }), + ), + ]); + await Promise.all([viewA.whenRendered(), viewB.whenRendered()]); + image.width = 4; + image.height = 4; + image.getContext("2d")!.fillRect(0, 0, 4, 4); + await click(document.querySelector("#application-b")!, 45, openedB); + const rebindingBytes = await (await fetch(image.toDataURL("image/png"))).arrayBuffer(), + rebinding = attempt( + viewB.provideResource(openedB.at(-1).authorization, { + name: "rebind.png", + mime: "image/png", + bytes: rebindingBytes, + }), + ); + await b.dispatch({ type: "node.remove", id: "shared" }); + await b.dispatch({ type: "node.add", nodeId: "shared", nodeType: "beta-node", position: { x: -60, y: 30 } }); + const rebound = await rebinding; + const snapshots = await Promise.all([a.getState(), b.getState()]); + a.destroy(); + b.destroy(); + return { + accepted, + resourceIds, + frozen, + pointerUpDidNotDuplicate, + rawBytes: raw.byteLength, + dimensions, + bytes, + rebound, + values: snapshots.map((snapshot) => (snapshot.nodes[0] as any).parameters.image.value), + types: snapshots.map((snapshot) => snapshot.nodes[0]?.typeId), + }; + }); + expect(result.accepted).toEqual(["image/png", "image/webp"]); + expect(result.resourceIds).toEqual([["image"], ["image"]]); + expect(result.frozen).toBe(true); + expect(result.pointerUpDidNotDuplicate).toBe(true); + expect(result.rawBytes).toBeLessThanOrEqual(512); + expect(result.dimensions[0]).toEqual({ ok: false, code: "resource.dimensions" }); + expect(result.dimensions[1]).toMatchObject({ ok: true, value: { status: "committed", version: 3 } }); + expect(result.bytes[0]).toEqual({ ok: false, code: "resource.bytes" }); + expect(result.bytes[1]).toMatchObject({ ok: true, value: { status: "committed", version: 4 } }); + expect(result.rebound).toEqual({ ok: false, code: "resource.stale" }); + expect(result.values).toEqual(["", ""]); + expect(result.types).toEqual(["alpha-node", "beta-node"]); +}); + +test("authoritative resource hits follow paint order and block click-through", async ({ page }) => { + await page.goto("/test/browser/worker-composition.html"); + const result = await page.evaluate(async () => { + const h = (window as any).workerCompositionTest, + opened: any[] = [], + api = await h.createA((target: any) => opened.push(target)), + view = h.view(api), + canvas = document.querySelector("#application-a")!; + await view.addNode({ typeId: "alpha-node", nodeId: "bottom", viewPosition: { x: 120, y: 80 } }); + await view.addNode({ typeId: "alpha-node", nodeId: "top", viewPosition: { x: 120, y: 80 } }); + await view.whenRendered(); + const rect = canvas.getBoundingClientRect(), + click = async (pointerId: number) => { + const before = opened.length; + const init = { + bubbles: true, + pointerId, + pointerType: "mouse", + button: 0, + clientX: rect.left + 200, + clientY: rect.top + 115, + }; + canvas.dispatchEvent(new PointerEvent("pointerdown", { ...init, buttons: 1 })); + for (let i = 0; i < 50 && opened.length === before; i++) + await new Promise((resolve) => setTimeout(resolve, 10)); + canvas.dispatchEvent(new PointerEvent("pointerup", { ...init, buttons: 0 })); + }; + await click(71); + const topToken = opened[0]?.authorization.token; + const blocker = { + ...h.compositionA.nodes["alpha-node"], + title: "Blocker", + parameters: {}, + ui: [ + { kind: "socket", socket: "output" }, + ...Array.from({ length: 7 }, (_, index) => ({ kind: "text", variant: "section", title: `Row ${index}` })), + ], + }; + await api.composeNode("blocker", blocker); + await view.addNode({ typeId: "blocker", nodeId: "cover", viewPosition: { x: 120, y: 80 } }); + await view.whenRendered(); + await click(72); + const blocked = opened.length === 1; + await api.dispatch({ type: "node.remove", id: "cover" }); + await view.whenRendered(); + h.destroy(api); + return { topToken, blocked }; + }); + expect(result).toEqual({ topToken: expect.stringMatching(/^top:/), blocked: true }); +}); + +test("delayed resource requests survive pointer-up but not newer host intent", async ({ page }) => { + await page.goto("/test/browser/worker-composition.html"); + const result = await page.evaluate(async () => { + const harness = (window as any).workerCompositionTest, + NativeWorker = Worker; + class DelayedWorker extends NativeWorker { + static hold = false; + static held: { worker: DelayedWorker; event: MessageEvent } | undefined; + private receiver: ((this: Worker, event: MessageEvent) => any) | null = null; + constructor(url: string | URL, options?: WorkerOptions) { + super(url, options); + this.addEventListener("message", (event) => { + if (DelayedWorker.hold && (event.data as { type?: unknown })?.type === "view.resource.open") { + DelayedWorker.hold = false; + DelayedWorker.held = { worker: this, event }; + return; + } + this.receiver?.call(this, event); + }); + } + override set onmessage(value: ((this: Worker, event: MessageEvent) => any) | null) { + this.receiver = value; + } + override get onmessage() { + return this.receiver; + } + static release() { + const held = DelayedWorker.held; + DelayedWorker.held = undefined; + held?.worker.receiver?.call(held.worker, held.event); + } + } + Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: DelayedWorker }); + let api: any; + try { + const opened: any[] = []; + api = await harness.createA((request: any) => opened.push(request)); + const view = harness.view(api); + await view.addNode({ typeId: "alpha-node", nodeId: "resource", viewPosition: { x: 120, y: 80 } }); + await view.whenRendered(); + const canvas = document.querySelector("#application-a")!, + rect = canvas.getBoundingClientRect(), + click = (pointerId: number) => { + const init = { + bubbles: true, + pointerId, + pointerType: "mouse", + button: 0, + clientX: rect.left + 200, + clientY: rect.top + 115, + }; + canvas.dispatchEvent(new PointerEvent("pointerdown", { ...init, buttons: 1 })); + canvas.dispatchEvent(new PointerEvent("pointerup", { ...init, buttons: 0 })); + }, + waitFor = async (predicate: () => boolean) => { + for (let index = 0; index < 100; index++) { + if (predicate()) return true; + await new Promise(requestAnimationFrame); + } + return false; + }; + DelayedWorker.hold = true; + click(91); + const heldThroughPointerUp = await waitFor(() => !!DelayedWorker.held); + DelayedWorker.release(); + const deliveredAfterPointerUp = await waitFor(() => opened.length === 1); + + DelayedWorker.hold = true; + click(92); + const heldBeforeNewIntent = await waitFor(() => !!DelayedWorker.held); + view.feedInput({ + kind: "wheel", + position: { x: 200, y: 115 }, + delta: { x: 0, y: -20 }, + modifiers: { alt: false, control: false, meta: false, shift: false }, + }); + DelayedWorker.release(); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + return { + heldThroughPointerUp, + deliveredAfterPointerUp, + heldBeforeNewIntent, + staleIgnored: opened.length === 1, + }; + } finally { + if (api) harness.destroy(api); + Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: NativeWorker }); + } + }); + expect(result).toEqual({ + heldThroughPointerUp: true, + deliveredAfterPointerUp: true, + heldBeforeNewIntent: true, + staleIgnored: true, + }); +}); + +test("an ordinary graph commit invalidates delayed add-menu authorization", async ({ page }) => { + await page.goto("/test/browser/worker-composition.html"); + const result = await page.evaluate(async () => { + const harness = (window as any).workerCompositionTest, + NativeWorker = Worker; + class DelayedMenuWorker extends NativeWorker { + static held: { worker: DelayedMenuWorker; event: MessageEvent } | undefined; + private receiver: ((this: Worker, event: MessageEvent) => any) | null = null; + constructor(url: string | URL, options?: WorkerOptions) { + super(url, options); + this.addEventListener("message", (event) => { + if (!DelayedMenuWorker.held && (event.data as { type?: unknown })?.type === "view.node-menu.result") { + DelayedMenuWorker.held = { worker: this, event }; + return; + } + this.receiver?.call(this, event); + }); + } + override set onmessage(value: ((this: Worker, event: MessageEvent) => any) | null) { + this.receiver = value; + } + override get onmessage() { + return this.receiver; + } + static release() { + const held = DelayedMenuWorker.held; + DelayedMenuWorker.held = undefined; + held?.worker.receiver?.call(held.worker, held.event); + } + } + Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: DelayedMenuWorker }); + let api: any; + try { + api = await harness.createA(); + const view = harness.view(api); + let opened = 0; + view.onHostRequests((request: { kind: string }) => { + if (request.kind === "add-node-menu") opened++; + }); + const canvas = document.querySelector("#application-a")!, + rect = canvas.getBoundingClientRect(); + canvas.dispatchEvent( + new PointerEvent("pointerdown", { + bubbles: true, + pointerId: 101, + pointerType: "mouse", + button: 2, + buttons: 2, + clientX: rect.left + 20, + clientY: rect.top + 20, + }), + ); + for (let index = 0; index < 100 && !DelayedMenuWorker.held; index++) await new Promise(requestAnimationFrame); + const held = !!DelayedMenuWorker.held; + await view.addNode({ typeId: "alpha-node", nodeId: "new-node", viewPosition: { x: 300, y: 180 } }); + DelayedMenuWorker.release(); + await new Promise(requestAnimationFrame); + await new Promise(requestAnimationFrame); + return { held, opened, menuOpen: !!document.querySelector("[data-fxnode-add-menu]") }; + } finally { + if (api) harness.destroy(api); + Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: NativeWorker }); + } + }); + expect(result).toEqual({ held: true, opened: 0, menuOpen: false }); +}); + +test("invalid bootstrap authority rejects before constructing a worker", async ({ page }) => { + await page.goto("/test/browser/worker-composition.html"); + const result = await page.evaluate(async () => { + const { createFxNode } = await import("@lib/index.js"), + canvas = document.createElement("canvas"), + NativeWorker = Worker; + let constructed = 0; + class TrackingWorker extends NativeWorker { + constructor(url: string | URL, options?: WorkerOptions) { + constructed++; + super(url, options); + } + } + Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: TrackingWorker }); + const errors: string[] = []; + try { + for (const authority of [ + { applicationId: "", applicationVersion: 1, resources: {} }, + { applicationId: "valid", applicationVersion: 0, resources: {} }, + { applicationId: "valid", applicationVersion: 1, resources: { broken: { kind: "image" } } }, + ]) + try { + await createFxNode({ + applicationId: authority.applicationId, + applicationVersion: authority.applicationVersion, + resources: authority.resources as never, + }); + } catch (error) { + errors.push((error as Error).name); + } + return { constructed, errors }; + } finally { + Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: NativeWorker }); + } + }); + expect(result).toEqual({ + constructed: 0, + errors: ["FxNodeCompositionError", "FxNodeCompositionError", "FxNodeCompositionError"], + }); +}); + +test("startup attachment failure terminates the worker and invalid composition has no side effects", async ({ + page, +}) => { + await page.goto("/test/browser/worker-composition.html"); + const result = await page.evaluate(async () => { + const harness = (window as any).workerCompositionTest, + canvas = document.querySelector("#application-a")!, + NativeWorker = Worker, + NativeObserver = ResizeObserver; + let constructed = 0, + terminated = 0; + class TrackingWorker extends NativeWorker { + constructor(url: string | URL, options?: WorkerOptions) { + super(url, options); + constructed++; + } + override terminate() { + terminated++; + super.terminate(); + } + } + class ThrowingObserver { + constructor(_callback: ResizeObserverCallback) {} + observe() { + throw new Error("observe exploded"); + } + disconnect() {} + unobserve() {} + } + Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: TrackingWorker }); + Object.defineProperty(window, "ResizeObserver", { configurable: true, writable: true, value: ThrowingObserver }); + canvas.setAttribute("tabindex", "7"); + canvas.style.touchAction = "pan-x"; + let attachMessage = ""; + try { + await harness.createA(); + } catch (error) { + attachMessage = (error as Error).message; + } + const attached = { + constructed, + terminated, + tabindex: canvas.getAttribute("tabindex"), + touchAction: canvas.style.touchAction, + inputs: document.querySelectorAll('input[type="file"]').length, + }; + Object.defineProperty(window, "ResizeObserver", { configurable: true, writable: true, value: NativeObserver }); + let invalidMessage = ""; + try { + await harness.createRaw(canvas, { malformed: true }); + } catch (error) { + invalidMessage = (error as Error).message; + } + const invalid = { + constructed, + terminated, + tabindex: canvas.getAttribute("tabindex"), + touchAction: canvas.style.touchAction, + inputs: document.querySelectorAll('input[type="file"]').length, + }; + Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: NativeWorker }); + return { attachMessage, attached, invalidMessage, invalid }; + }); + expect(result.attachMessage).toBe("observe exploded"); + expect(result.attached).toEqual({ constructed: 1, terminated: 1, tabindex: "7", touchAction: "pan-x", inputs: 0 }); + expect(result.invalidMessage).toContain("Invalid fxnode composition"); + expect(result.invalid).toEqual({ constructed: 2, terminated: 2, tabindex: "7", touchAction: "pan-x", inputs: 0 }); +}); diff --git a/vendor/fxnode/test/browser/worker-composition.ts b/vendor/fxnode/test/browser/worker-composition.ts new file mode 100644 index 0000000..93565c5 --- /dev/null +++ b/vendor/fxnode/test/browser/worker-composition.ts @@ -0,0 +1,372 @@ +import { createFxNode, type FxNode, type FxNodeCompositionData, type FxNodeView } from "@lib/index.js"; +import { prepareFxNodeBrowserHost, type PreparedFxNodeBrowserHost } from "../../examples/shared/browser-host.js"; +import type { FxNodeResourceOpenRequest } from "@lib/index.js"; +import type { GraphState } from "@lib/core/types.js"; + +const color = "#202124" as const; +const theme = { + background: color, + grid: color, + frame: color, + frameHeader: color, + body: "#35383e", + control: color, + controlFill: color, + controlEditing: color, + textSelection: color, + outline: "#101114", + text: "#eeeeee", + muted: "#999999", + shadow: "#000000", + nodeSelected: "#ed5700", + nodeActive: "#ed5700", + unknownHeader: "#555555", + unknownSocket: "#777777", + linkMuted: "#d94b4b", + knifeMuted: "#e85b5b", + emphasis: "#ffffff", + focus: "#f5a623", + editOutline: "#666a70", + resize: "#8b8e95", + muteOverlay: "#14141459", + boxSelectionFill: "#f5a6231f", + checkerLight: "#aaaaaa", + checkerDark: "#777777", + widgetBorder: "#111216", + rampBorder: "#111111", + resourceBackground: "#202228", +} as const; +const composition = ( + id: string, + version: number, + nodeType: string, + title: string, + header: `#${string}`, + maxBytes: number, + maxWidth: number, + accept: string, +) => + ({ + schemaVersion: 2, + id, + version, + compatibility: { wildcardInputTypes: [] }, + theme, + socketTypes: { signal: { title: "Signal", color: header, acceptsFrom: ["signal"] } }, + nodeStyles: { application: { header } }, + resources: { + image: { + kind: "image", + title: "Image", + openTitle: "Open", + accept: [accept], + referencePrefix: `${id}-image:`, + maxBytes, + maxWidth, + maxHeight: 4, + maxPixels: maxWidth * 4, + }, + strictImage: { + kind: "image", + title: "Strict Image", + openTitle: "Open", + accept: [accept], + referencePrefix: `${id}-image:`, + maxBytes, + maxWidth: 1, + maxHeight: 1, + maxPixels: 1, + }, + }, + nodes: { + [nodeType]: { + version: 1, + title, + behavior: "standard", + style: "application", + parameters: { image: { type: "string", default: { kind: "string", value: "" } } }, + sockets: { + output: { + title: "Output", + direction: "output", + type: "signal", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { kind: "resource", resource: "image", parameter: "image" }, + { kind: "socket", socket: "output" }, + ], + muteBypass: [], + migrations: [], + }, + [`${nodeType}-strict`]: { + version: 1, + title: `${title} Strict`, + behavior: "standard", + style: "application", + parameters: { image: { type: "string", default: { kind: "string", value: "" } } }, + sockets: { + output: { + title: "Output", + direction: "output", + type: "signal", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { kind: "resource", resource: "strictImage", parameter: "image" }, + { kind: "socket", socket: "output" }, + ], + muteBypass: [], + migrations: [], + }, + }, + }) as unknown as FxNodeCompositionData; + +export const compositionA = composition( + "application-a", + 11, + "alpha-node", + "Alpha Node", + "#9b59b6", + 512, + 1, + "image/png", +); +export const compositionB = composition( + "application-b", + 22, + "beta-node", + "Beta Node", + "#2e86de", + 2048, + 4, + "image/webp", +); +export const migrationComposition = { + ...compositionA, + id: "worker-migration-custom", + version: 73, + nodes: { + "odd-migrator": { + ...compositionA.nodes["alpha-node"]!, + version: 2, + parameters: { level: { type: "number", default: { kind: "number", value: 5 } } }, + sockets: { + source: { + title: "Source", + direction: "output", + type: "signal", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + sink: { + title: "Sink", + direction: "input", + type: "signal", + maxIncomingLinks: 4, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { kind: "parameter", parameter: "level" }, + { kind: "socket", socket: "source" }, + { kind: "socket", socket: "sink" }, + ], + migrations: [ + { + fromVersion: 1, + toVersion: 2, + steps: [ + { kind: "rename-parameter", from: "oldLevel", to: "level" }, + { kind: "rename-socket", from: "oldSource", to: "source" }, + { kind: "rename-socket", from: "oldSink", to: "sink" }, + ], + }, + ], + }, + }, +} as unknown as FxNodeCompositionData; +const layout = (id: string, version: number) => ({ + schemaVersion: 2 as const, + graphId: id, + catalogVersion: version, + nodes: [], + links: [], + metadata: {}, +}); +const migrationLayout = { + schemaVersion: 2 as const, + graphId: "worker-historical", + catalogVersion: 1, + nodes: [ + { + id: "left", + typeId: "odd-migrator", + typeVersion: 1, + position: { x: 0, y: 0 }, + size: { x: 180, y: 190 }, + label: "", + parameters: { oldLevel: { kind: "number", value: 9 } }, + sockets: [ + { + id: "left:oldSource", + key: "oldSource", + label: "Source", + direction: "output", + dataType: "signal", + accepts: [], + maxIncomingLinks: 0, + visible: true, + }, + { + id: "left:oldSink", + key: "oldSink", + label: "Sink", + direction: "input", + dataType: "signal", + accepts: ["signal"], + maxIncomingLinks: 4, + visible: true, + }, + ], + muted: false, + collapsed: false, + extensions: {}, + }, + { + id: "right", + typeId: "odd-migrator", + typeVersion: 1, + position: { x: 300, y: 0 }, + size: { x: 180, y: 190 }, + label: "", + parameters: { oldLevel: { kind: "number", value: 3 } }, + sockets: [ + { + id: "right:oldSource", + key: "oldSource", + label: "Source", + direction: "output", + dataType: "signal", + accepts: [], + maxIncomingLinks: 0, + visible: true, + }, + { + id: "right:oldSink", + key: "oldSink", + label: "Sink", + direction: "input", + dataType: "signal", + accepts: ["signal"], + maxIncomingLinks: 4, + visible: true, + }, + ], + muted: false, + collapsed: false, + extensions: {}, + }, + ], + links: [ + { + id: "historical-link", + fromNodeId: "left", + fromSocketId: "left:oldSource", + toNodeId: "right", + toSocketId: "right:oldSink", + muted: false, + extensions: { kept: true }, + }, + ], + metadata: { fixture: "migration" }, +}; +const hosts = new WeakMap(), + views = new WeakMap(); +const create = async ( + canvas: HTMLCanvasElement, + value: C, + initialLayout: unknown = layout(value.id, value.version), + activateResourcePicker?: (request: FxNodeResourceOpenRequest) => void, +) => { + const host = prepareFxNodeBrowserHost({ + canvas, + addNodeMenuTemplate: document.querySelector("#add-node-menu-template")!, + ...(activateResourcePicker ? { activateResourcePicker } : {}), + }); + let api: FxNode | undefined; + const { id, version, resources } = value; + try { + api = await createFxNode({ + applicationId: id, + applicationVersion: version, + resources, + }); + await api.loadComposition(value as never, { expectedRevision: 0 }); + if (initialLayout === migrationLayout) await api.load(initialLayout); + const view = await api.attachView({ canvas, viewport: host.initialViewport }); + host.attach(api, view); + hosts.set(api, host); + views.set(api, view); + return api; + } catch (error) { + host.destroy(); + api?.destroy(); + throw error; + } +}; +const destroy = (api: FxNode) => { + hosts.get(api)?.destroy(); + hosts.delete(api); + views.delete(api); + api.destroy(); +}; + +type Harness = { + readonly compositionA: typeof compositionA; + readonly compositionB: typeof compositionB; + createA(activateResourcePicker?: (request: FxNodeResourceOpenRequest) => void): Promise; + createB(activateResourcePicker?: (request: FxNodeResourceOpenRequest) => void): Promise; + createMigration(): Promise; + createRaw(canvas: HTMLCanvasElement, value: FxNodeCompositionData): Promise; + view(api: FxNode): FxNodeView; + destroy(api: FxNode): void; +}; +(window as unknown as { workerCompositionTest: Harness }).workerCompositionTest = { + compositionA, + compositionB, + createA: (activate) => + create(document.querySelector("#application-a")!, compositionA, undefined, activate), + createB: (activate) => + create(document.querySelector("#application-b")!, compositionB, undefined, activate), + createMigration: () => + create(document.querySelector("#application-a")!, migrationComposition, migrationLayout), + createRaw: async (canvas, value) => { + const api = await create(canvas, compositionA); + try { + await api.loadComposition(value, { expectedRevision: 1 }); + return api; + } catch (error) { + destroy(api); + throw error; + } + }, + view: (api) => { + const view = views.get(api); + if (!view) throw new Error("FxNode test view is unavailable"); + return view; + }, + destroy, +}; diff --git a/vendor/fxnode/test/browser/worker-multiview.spec.ts b/vendor/fxnode/test/browser/worker-multiview.spec.ts new file mode 100644 index 0000000..b377515 --- /dev/null +++ b/vendor/fxnode/test/browser/worker-multiview.spec.ts @@ -0,0 +1,156 @@ +import { expect, test } from "@playwright/test"; + +test("real worker supports a browser root with no attached views", async ({ page }) => { + await page.goto("/test/browser/client-runtime.html"); + const result = await page.evaluate(async () => { + const { createFxNode } = (await import("/src/index.ts" as string)) as typeof import("@lib/index.js"); + const { minimalStyles, numberSocket, valueNode } = (await import( + "../../examples/minimal/definition.js" + )) as typeof import("../../examples/minimal/definition.js"); + const root = await createFxNode({ applicationId: "worker-headless", applicationVersion: 1, resources: {} }); + await root.setHeaderStyles(minimalStyles); + await root.composeSocket(...numberSocket); + await root.composeNode(...valueNode); + const added = await root.dispatch({ + type: "node.add", + nodeId: "headless-node" as never, + nodeType: valueNode[0], + position: { x: 12, y: 34 }, + }); + const state = await root.getState(), + saved = await root.getSaveData(), + undone = await root.undo(), + empty = await root.getState(); + root.destroy(); + return { + addedVersion: added.version, + undoneVersion: undone.version, + node: state.nodes[0], + journalLength: saved.commands.length, + emptyCount: empty.nodes.length, + }; + }); + + expect(result.addedVersion).toBe(1); + expect(result.undoneVersion).toBe(2); + expect(result.node).toMatchObject({ id: "headless-node", position: { x: 12, y: 34 } }); + expect(result.journalLength).toBe(1); + expect(result.emptyCount).toBe(0); +}); + +test("real worker shares graph history while views keep camera and selection", async ({ page }) => { + await page.goto("/test/browser/client-runtime.html"); + const result = await page.evaluate(async () => { + const { createFxNode } = (await import("/src/index.ts" as string)) as typeof import("@lib/index.js"); + const { minimalStyles, numberSocket, valueNode } = (await import( + "../../examples/minimal/definition.js" + )) as typeof import("../../examples/minimal/definition.js"); + const api = await createFxNode({ applicationId: "worker-multiview", applicationVersion: 1, resources: {} }); + await api.setHeaderStyles(minimalStyles); + await api.composeSocket(...numberSocket); + await api.composeNode(...valueNode); + + const makeCanvas = () => { + const canvas = document.createElement("canvas"), + context = canvas.getContext("2d")!; + canvas.width = 400; + canvas.height = 200; + let frames = 0; + const drawImage = context.drawImage.bind(context); + context.drawImage = ((...args: Parameters) => { + frames++; + Reflect.apply(drawImage, context, args); + }) as CanvasRenderingContext2D["drawImage"]; + return { canvas, frames: () => frames }; + }; + const firstCanvas = makeCanvas(), + secondCanvas = makeCanvas(), + first = await api.attachView({ + canvas: firstCanvas.canvas, + viewport: { width: 400, height: 200, dpr: 1 }, + initialCamera: { center: { x: 1_000, y: -200 }, zoom: 2 }, + }), + second = await api.attachView({ + canvas: secondCanvas.canvas, + viewport: { width: 400, height: 200, dpr: 1 }, + }); + await Promise.all([first.whenRendered(), second.whenRendered()]); + + const beforeRootFrames = [firstCanvas.frames(), secondCanvas.frames()]; + const rootAdd = await api.dispatch({ + type: "node.add", + nodeId: "root-node" as never, + nodeType: valueNode[0], + position: { x: 0, y: 0 }, + }); + const deadline = performance.now() + 2_000; + while ( + performance.now() < deadline && + (firstCanvas.frames() <= beforeRootFrames[0]! || secondCanvas.frames() <= beforeRootFrames[1]!) + ) + await new Promise((resolve) => setTimeout(resolve, 16)); + const rootSelections = [first.getHostSnapshot().selection.nodeCount, second.getHostSnapshot().selection.nodeCount]; + + const firstAdd = await first.addNode({ + typeId: valueNode[0], + nodeId: "first-node", + viewPosition: { x: 300, y: 50 }, + }); + const afterFirstSelection = [ + first.getHostSnapshot().selection.nodeCount, + second.getHostSnapshot().selection.nodeCount, + ]; + const secondAdd = await second.addNode({ + typeId: valueNode[0], + nodeId: "second-node", + viewPosition: { x: 200, y: 100 }, + }); + const afterSecondSelection = [ + first.getHostSnapshot().selection.nodeCount, + second.getHostSnapshot().selection.nodeCount, + ]; + const mute = await first.setSelectedMuted(true), + remove = await second.removeSelected(), + afterActions = await api.getState(), + undo = await api.undo(), + afterUndo = await api.getState(), + redo = await api.redo(), + afterRedo = await api.getState(); + const firstNode = afterActions.nodes.find((node) => node.id === "first-node"); + + await first.detach(); + await second.detach(); + api.destroy(); + return { + rootSelections, + afterFirstSelection, + afterSecondSelection, + rootRenderedBoth: firstCanvas.frames() > beforeRootFrames[0]! && secondCanvas.frames() > beforeRootFrames[1]!, + firstPosition: firstNode?.position, + firstMuted: firstNode?.muted, + secondRemoved: !afterActions.nodes.some((node) => node.id === "second-node"), + undoRestoredSecond: afterUndo.nodes.some((node) => node.id === "second-node"), + redoRemovedSecond: !afterRedo.nodes.some((node) => node.id === "second-node"), + versions: [ + rootAdd.version, + firstAdd.version, + secondAdd.version, + mute.version, + remove.version, + undo.version, + redo.version, + ], + }; + }); + + expect(result.rootSelections).toEqual([0, 0]); + expect(result.afterFirstSelection).toEqual([1, 0]); + expect(result.afterSecondSelection).toEqual([1, 1]); + expect(result.rootRenderedBoth).toBe(true); + expect(result.firstPosition).toEqual({ x: 1_050, y: -175 }); + expect(result.firstMuted).toBe(true); + expect(result.secondRemoved).toBe(true); + expect(result.undoRestoredSecond).toBe(true); + expect(result.redoRemovedSecond).toBe(true); + expect(result.versions).toEqual([1, 2, 3, 4, 5, 6, 7]); +}); diff --git a/vendor/fxnode/test/color-ramp-noise.test.ts b/vendor/fxnode/test/color-ramp-noise.test.ts new file mode 100644 index 0000000..0c4cafa --- /dev/null +++ b/vendor/fxnode/test/color-ramp-noise.test.ts @@ -0,0 +1,116 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + addRampMidpoint, + addRampStop, + distributeColorRamp, + flipColorRamp, + isColorRamp, + migrateColorRamp, + moveRampStop, + removeRampStop, + sampleColorRamp, + setRampColor, + type ColorRamp, +} from "@lib/widgets/color-ramp.js"; +const DEFAULT_COLOR_RAMP: ColorRamp = { + colorMode: "rgb", + interpolation: "linear", + hueInterpolation: "near", + stops: [ + { id: "stop-0", position: 0, color: [0, 0, 0, 1] }, + { id: "stop-1", position: 1, color: [1, 1, 1, 1] }, + ], +}; +import { layoutGraph as genericLayoutGraph } from "@lib/layout/layout-graph.js"; +import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js"; +const layoutGraph = ( + document: Parameters[1], + transform: Parameters[2], +) => genericLayoutGraph(APPLICATION_COMPILED, document, transform); +const { materializeNode } = APPLICATION_HEADLESS; + +test("Color Ramp migration, validation and stable pure operations", () => { + const migrated = migrateColorRamp([ + { position: 0, color: [0, 0, 0, 1] }, + { position: 0.5, color: [1, 0, 0, 1] }, + { position: 1, color: [1, 1, 1, 1] }, + ])!; + assert.deepEqual( + migrated.stops.map((s) => s.id), + ["stop-0", "stop-1", "stop-2"], + ); + assert.equal(isColorRamp(migrated), true); + const added = addRampStop(migrated, 0.25, "worker-1"); + assert.equal(added.stops.find((s) => s.id === "worker-1")?.color[0], 0.5); + assert.equal(addRampMidpoint(migrated, "stop-1", "worker-2").stops.find((s) => s.id === "worker-2")?.position, 0.25); + assert.deepEqual( + moveRampStop(migrated, "stop-0", 0.75).stops.map((s) => s.id), + ["stop-1", "stop-0", "stop-2"], + ); + assert.equal(removeRampStop(DEFAULT_COLOR_RAMP, "stop-0"), DEFAULT_COLOR_RAMP); + assert.deepEqual( + distributeColorRamp(migrated).stops.map((s) => s.position), + [0, 0.5, 1], + ); + assert.deepEqual( + flipColorRamp(migrated).stops.map((s) => s.position), + [0, 0.5, 1], + ); + assert.deepEqual(sampleColorRamp({ ...DEFAULT_COLOR_RAMP, interpolation: "constant" }, 0.5), [0, 0, 0, 1]); +}); + +test("Color Ramp public operations preserve validity for empty IDs and non-finite numbers", () => { + for (const position of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { + assert.equal(addRampStop(DEFAULT_COLOR_RAMP, position, "new"), DEFAULT_COLOR_RAMP); + assert.equal(moveRampStop(DEFAULT_COLOR_RAMP, "stop-0", position), DEFAULT_COLOR_RAMP); + assert.deepEqual(sampleColorRamp(DEFAULT_COLOR_RAMP, position), DEFAULT_COLOR_RAMP.stops[0]!.color); + } + assert.equal(addRampStop(DEFAULT_COLOR_RAMP, 0.5, ""), DEFAULT_COLOR_RAMP); + for (let channel = 0; channel < 4; channel++) { + const color = [0, 0, 0, 1] as number[]; + color[channel] = Number.NaN; + assert.equal( + setRampColor(DEFAULT_COLOR_RAMP, "stop-0", color as [number, number, number, number]), + DEFAULT_COLOR_RAMP, + ); + } + assert.equal(isColorRamp(DEFAULT_COLOR_RAMP), true); +}); + +test("Noise Texture exhaustive Blender 4.5 visibility matrix and immediate height", () => { + const dimensions = ["1d", "2d", "3d", "4d"], + types = ["fbm", "multifractal", "hybrid-multifractal", "ridged-multifractal", "hetero-terrain"]; + for (const dimension of dimensions) + for (const noiseType of types) { + const base = materializeNode("noise", "fxnode.shader.noise-texture"); + const node = { + ...base, + parameters: { + ...base.parameters, + dimensions: { kind: "string" as const, value: dimension }, + noiseType: { kind: "string" as const, value: noiseType }, + }, + }; + const document = { + schemaVersion: 2 as const, + graphId: "g" as never, + catalogVersion: 2, + nodes: { noise: node }, + links: {}, + metadata: {}, + }; + const layout = layoutGraph(document, { center: { x: 0, y: 0 }, zoom: 1, viewport: { x: 800, y: 600 }, dpr: 1 }); + const keys = new Set([...layout.sockets.values()].map((s) => s.id.split(":").at(-1))); + assert.equal(keys.has("vector"), dimension !== "1d"); + assert.equal(keys.has("w"), dimension === "1d" || dimension === "4d"); + assert.equal( + keys.has("offset"), + ["hybrid-multifractal", "ridged-multifractal", "hetero-terrain"].includes(noiseType), + ); + assert.equal(keys.has("gain"), ["hybrid-multifractal", "ridged-multifractal"].includes(noiseType)); + const hasNormalize = layout.controls.has("noise:parameter:normalize"); + assert.equal(hasNormalize, noiseType === "fbm"); + assert.ok(layout.nodes.get("noise" as never)!.bounds.height > 0); + } +}); diff --git a/vendor/fxnode/test/command-log.test.ts b/vendor/fxnode/test/command-log.test.ts new file mode 100644 index 0000000..67c2a41 --- /dev/null +++ b/vendor/fxnode/test/command-log.test.ts @@ -0,0 +1,327 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { validCommand, validFxNodeReplayCommand } from "@lib/commands/validate.js"; +import { commandId, linkId, nodeId, socketId } from "@lib/core/types.js"; +import { saveCompositionCompatibility } from "@lib/composition/save-compatibility.js"; +import { createFxNodeHeadless } from "@lib/headless-runtime.js"; +import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js"; +import type { FxNodeCompositionData } from "@lib/composition/index.js"; + +const composition = { + ...APPLICATION_COMPILED.source, + id: "command-log-test", + version: 7, + nodes: { value: APPLICATION_COMPILED.source.nodes["fxnode.shader.value"]! }, +}; +const runtime = createFxNodeHeadless(composition); +const save = (commands: readonly unknown[], baseline: unknown = runtime.save(runtime.emptyDocument())) => ({ + kind: "fxnode.command-log", + schemaVersion: 2, + composition, + baseline, + commands, +}); + +test("command validators are total and replay is forward-only", () => { + const hostile = new Proxy( + {}, + { + ownKeys() { + throw new Error("hostile"); + }, + }, + ); + assert.doesNotThrow(() => validCommand(hostile)); + assert.equal(validCommand(hostile), false); + assert.equal(validCommand({ type: "undo" }), true); + assert.equal(validFxNodeReplayCommand({ type: "undo" }), false); + assert.equal( + validFxNodeReplayCommand({ type: "batch", commands: [{ type: "node.move", id: "n", position: { x: 1, y: 2 } }] }), + true, + ); +}); + +test("replay commits atomically once, preserves bounded staged history, and accepts empty logs", () => { + const original = runtime.createEngine(runtime.emptyDocument(), 1), + commands = [ + { type: "node.add", nodeId: nodeId("n"), nodeType: "value", position: { x: 0, y: 0 } } as const, + { type: "node.move", id: nodeId("n"), position: { x: 2, y: 3 } } as const, + ]; + const result = runtime.replaySaveData(original, save(commands), 0, commandId("restore")); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.status, "committed"); + if (result.status !== "committed") return; + assert.equal(Object.isFrozen(result.saveData), true); + assert.notEqual(result.saveData, save(commands)); + assert.equal(result.state.version, 1); + assert.deepEqual(result.state.document.nodes.n!.position, { x: 2, y: 3 }); + assert.equal(result.state.undo.length, 1); + assert.equal(result.state.redo.length, 0); + assert.equal(result.mutationEnvelope.cause, "load"); + assert.equal(result.mutationEnvelope.mutations.length, 1); + assert.equal(result.mutationEnvelope.mutations[0]?.kind, "document.replaced"); + const empty = runtime.replaySaveData(result.state, save([])); + assert.equal(empty.ok, true); + if (empty.ok) { + assert.equal(empty.state.version, 2); + assert.equal(empty.state.undo.length, 0); + } +}); + +test("replay rejection, noop, composition mismatch, and stale source inspection are atomic", () => { + const state = runtime.createEngine(runtime.emptyDocument()); + for (const data of [ + save([{ type: "node.remove", id: "missing" }]), + save([{ type: "batch", commands: [] }]), + { ...save([]), composition: { ...composition, id: "other" } }, + ]) { + const result = runtime.replaySaveData(state, data); + assert.equal(result.ok, false); + if (!result.ok) assert.equal(result.state, state); + } + const noop = runtime.replaySaveData(state, save([{ type: "batch", commands: [] }])); + assert.equal(noop.ok, false); + if (!noop.ok) { + assert.equal(noop.issues[0]?.code, "replay.noop"); + assert.equal(noop.issues[0]?.path, "/commands/0"); + } + let inspected = false; + const hostile = new Proxy( + {}, + { + ownKeys() { + inspected = true; + throw new Error("inspected"); + }, + }, + ); + const stale = runtime.replaySaveData(state, hostile, 1); + assert.equal(stale.ok, false); + assert.equal(inspected, false); + if (!stale.ok) { + assert.equal(stale.state, state); + assert.equal(stale.issues[0]?.code, "version.stale"); + } + const unchanged = runtime.replaySaveData(state, save([])); + assert.equal(unchanged.ok, true); + if (unchanged.ok) { + assert.equal(unchanged.status, "noop"); + assert.equal(unchanged.state.version, state.version); + assert.equal(unchanged.state.document, state.document); + } +}); + +test("persisted commands reject malformed durable values and links atomically", () => { + const state = runtime.createEngine(runtime.emptyDocument()); + const link = { + id: "l", + fromNodeId: "a", + fromSocketId: "a:o", + toNodeId: "b", + toSocketId: "b:i", + muted: false, + extensions: {}, + }; + const malformed = [ + { type: "link.add", link: { ...link, muted: 0 } }, + { type: "link.add", link: { ...link, extensions: { bad: undefined } } }, + { type: "node.parameter", id: "n", key: "value", value: { kind: "number", value: "1" } }, + { type: "node.socket-default", id: "n", socketId: "n:o", value: { kind: "vector", value: [1, 2] } }, + { type: "node.add", nodeId: "n", nodeType: "not-bound", position: { x: 0, y: 0 } }, + ]; + for (const command of malformed) { + const result = runtime.replaySaveData(state, save([command])); + assert.equal(result.ok, false); + if (!result.ok) { + assert.equal(result.state, state); + assert.match(result.issues[0]?.path ?? "", /^\/commands\/0/); + } + } +}); + +test("command and baseline admissions have independent budgets and require V2", () => { + const state = runtime.createEngine(runtime.emptyDocument()), + baseline = runtime.save(runtime.emptyDocument()); + const large = { ...baseline, metadata: { values: Array.from({ length: 100_001 }, () => 0) } }; + assert.equal(runtime.replaySaveData(state, save([], large)).ok, true); + const commands = Array.from({ length: 1_001 }, () => ({ type: "node.remove", id: "n" })); + const over = runtime.replaySaveData(state, save(commands, baseline)); + assert.equal(over.ok, false); + if (!over.ok) assert.equal(over.state, state); + const v1 = runtime.replaySaveData(state, save([], { ...baseline, schemaVersion: 1 })); + assert.equal(v1.ok, false); + if (!v1.ok) { + assert.equal(v1.state, state); + assert.equal(v1.issues[0]?.code, "baseline.schema"); + } +}); + +test("save schema v1 and future versions have distinct structured errors", () => { + const state = runtime.createEngine(runtime.emptyDocument()); + for (const [schemaVersion, code] of [ + [1, "save.schema.unsupported"], + [3, "save.schema.future"], + ] as const) { + const result = runtime.replaySaveData(state, { ...save([]), schemaVersion }); + assert.equal(result.ok, false); + if (!result.ok) { + assert.equal(result.issues[0]?.code, code); + assert.equal(result.issues[0]?.path, "/schemaVersion"); + assert.equal(result.state, state); + } + } +}); + +test("save decode alone normalizes embedded schema-1 composition menus", () => { + const state = runtime.createEngine(runtime.emptyDocument()); + const legacyComposition = { + ...composition, + schemaVersion: 1, + menuGroups: { legacy: { title: "Legacy", order: 0 } }, + nodes: Object.fromEntries( + Object.entries(composition.nodes).map(([id, node]) => [ + id, + { ...node, menu: { kind: "entry", group: "legacy", order: 0, keywords: [] } }, + ]), + ), + }; + const result = runtime.replaySaveData(state, { ...save([]), composition: legacyComposition }); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.saveData.composition.schemaVersion, 2); + assert.equal("menuGroups" in result.saveData.composition, false); + assert.equal("menu" in result.saveData.composition.nodes.value!, false); + assert.equal(validateLegacyRaw(legacyComposition), false); + + for (const invalid of [ + { ...legacyComposition, menuGroups: undefined }, + { ...legacyComposition, nodes: { ...legacyComposition.nodes, value: composition.nodes.value } }, + { + ...legacyComposition, + nodes: { + ...legacyComposition.nodes, + value: { ...legacyComposition.nodes.value, menu: { kind: "entry", group: "missing", order: 0, keywords: [] } }, + }, + }, + { ...legacyComposition, unexpected: true }, + ]) { + const rejected = runtime.replaySaveData(state, { ...save([]), composition: invalid }); + assert.equal(rejected.ok, false); + } +}); + +const validateLegacyRaw = (value: unknown) => { + try { + createFxNodeHeadless(value as FxNodeCompositionData); + return true; + } catch { + return false; + } +}; + +test("composition compatibility accepts true supersets and rejects changed replay semantics", () => { + const extra = APPLICATION_COMPILED.source.nodes["fxnode.shader.color"]!, + presentation = { + ...composition, + version: 8, + theme: { ...composition.theme, background: "#123456" as const }, + nodes: { ...composition.nodes, extra }, + }; + const reordered = { + ...presentation, + nodes: { + ...presentation.nodes, + value: { ...presentation.nodes.value!, ui: [...presentation.nodes.value!.ui].reverse() }, + }, + }; + const baseline = runtime.save(runtime.emptyDocument()); + assert.deepEqual(saveCompositionCompatibility(composition, reordered, baseline), []); + const supersetRuntime = createFxNodeHeadless(reordered), + accepted = supersetRuntime.replaySaveData(supersetRuntime.createEngine(supersetRuntime.emptyDocument()), save([])); + assert.equal(accepted.ok, true); + const changed = { + ...presentation, + nodes: { + ...presentation.nodes, + value: { + ...presentation.nodes.value!, + parameters: { + value: { ...presentation.nodes.value!.parameters.value!, default: { kind: "number" as const, value: 42 } }, + }, + }, + }, + }; + const semantic = saveCompositionCompatibility(composition, changed as FxNodeCompositionData, baseline); + assert.equal(semantic[0]?.code, "composition.incompatible"); + assert.equal(semantic[1]?.code, "composition.node-semantic"); + assert.equal(semantic[1]?.path, "/composition/nodes/value"); + const missing = { ...presentation, nodes: { extra } }; + const absent = saveCompositionCompatibility(composition, missing, baseline); + assert.equal(absent[0]?.code, "composition.incompatible"); + assert.equal(absent[1]?.code, "composition.definition-missing"); + const forged = { + ...save([{ type: "node.add", nodeId: "x", nodeType: "extra", position: { x: 0, y: 0 } }]), + composition, + }; + const rejected = supersetRuntime.replaySaveData( + supersetRuntime.createEngine(supersetRuntime.emptyDocument()), + forged, + ); + assert.equal(rejected.ok, false); + if (!rejected.ok) assert.equal(rejected.issues[0]?.code, "command.invalid"); +}); + +test("compatibility guards opaque promotion and caps structured issues", () => { + const extra = APPLICATION_COMPILED.source.nodes["fxnode.shader.color"]!, + current = { ...composition, nodes: { ...composition.nodes, opaque: extra } }; + const opaque = { ...runtime.save(runtime.emptyDocument()), nodes: [{ id: "o", typeId: "opaque" }] as never }; + const promoted = saveCompositionCompatibility(composition, current, opaque); + assert.equal(promoted[0]?.code, "composition.incompatible"); + assert.equal(promoted[1]?.code, "composition.opaque-promotion"); + assert.equal(promoted[1]?.path, "/baseline/nodes/0/typeId"); + const many = Object.fromEntries( + Array.from({ length: 150 }, (_, i) => [`style-${i}`, { header: "#000000" }]), + ) as unknown as typeof composition.nodeStyles, + withMany = { ...composition, nodeStyles: many }; + const capped = saveCompositionCompatibility( + withMany, + { ...composition, nodeStyles: {} }, + runtime.save(runtime.emptyDocument()), + ); + assert.equal(capped.length, 100); +}); + +test("trusted journal validation rejects executable commands outside the durable schema", () => { + const full = createFxNodeHeadless(APPLICATION_COMPILED.source), + from = full.materializeNode("from", "fxnode.shader.value"), + to = full.materializeNode("to", "fxnode.shader.math"), + baseline = full.save({ ...full.emptyDocument(), nodes: { from, to } }), + link = { + id: linkId("l"), + fromNodeId: nodeId("from"), + fromSocketId: socketId("from:value"), + toNodeId: nodeId("to"), + toSocketId: socketId("to:a"), + muted: false, + extensions: {}, + }; + assert.equal(full.validateReplayJournal(baseline, [{ type: "link.add", link }]), true); + assert.equal( + full.validateReplayJournal(baseline, [{ type: "link.add", link: { ...link, transient: "not durable" } as never }]), + false, + ); +}); + +test("replay rejects a command whose resulting aggregate exceeds persistence closure", () => { + const node = runtime.materializeNode("n", "value"), + baseline = runtime.save({ ...runtime.emptyDocument(), nodes: { n: node } }); + const baselineText = "b".repeat(8_388_350), + commandText = "c".repeat(1_048_554); + const result = runtime.replaySaveData( + runtime.createEngine(runtime.emptyDocument()), + save([{ type: "node.label", id: "n", label: commandText }], { ...baseline, metadata: { text: baselineText } }), + ); + assert.equal(result.ok, false); + if (!result.ok) assert.equal(result.state.document.nodes.n, undefined); +}); diff --git a/vendor/fxnode/test/composition-types.test.ts b/vendor/fxnode/test/composition-types.test.ts new file mode 100644 index 0000000..8f20140 --- /dev/null +++ b/vendor/fxnode/test/composition-types.test.ts @@ -0,0 +1,578 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { + createFxNode as CreateFxNode, + FxNode, + FxNodeView, + FxNodeViewOptions, + FxNodeCompositionSeed, + NodeParameterId, + NodeSocketId, + NodeStyleId, + NodeTypeId, + ResourceId, + SocketTypeId, + Themed, + RemovedNode, + RemovedSocket, +} from "@lib/index.js"; +import { compileFxNodeComposition } from "@lib/composition/compile.js"; +import { + composeNode, + composeSocket, + removeNode, + removeSocket, + setHeaderStyles, + setTheme, +} from "@lib/composition/compose.js"; +import { APPLICATION_ID, APPLICATION_RESOURCES, APPLICATION_VERSION } from "../examples/blender/nodes/application.js"; +import type { + createApplicationFxNode as CreateApplicationFxNode, + ApplicationFxNode, +} from "../examples/blender/application-browser.js"; + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Expect = T; +const theme = { + background: "#000", + grid: "#000", + frame: "#000", + frameHeader: "#000", + body: "#000", + control: "#000", + controlFill: "#000", + controlEditing: "#000", + textSelection: "#000", + outline: "#000", + text: "#000", + muted: "#000", + shadow: "#000", + nodeSelected: "#000", + nodeActive: "#000", + unknownHeader: "#000", + unknownSocket: "#000", + linkMuted: "#000", + knifeMuted: "#000", + emphasis: "#000", + focus: "#000", + editOutline: "#000", + resize: "#000", + muteOverlay: "#000", + boxSelectionFill: "#000", + checkerLight: "#000", + checkerDark: "#000", + widgetBorder: "#000", + rampBorder: "#000", + resourceBackground: "#000", +} as const; +const valid = { + schemaVersion: 2, + id: "types", + version: 1, + compatibility: { wildcardInputTypes: [] }, + theme, + socketTypes: { number: { title: "Number", color: "#abc", acceptsFrom: ["number"] } }, + nodeStyles: { basic: { header: "#abc" } }, + resources: { + preview: { + kind: "image", + title: "Preview", + openTitle: "Open", + accept: ["image/png"], + referencePrefix: "test-image:", + maxBytes: 100_000_000, + maxWidth: 10_000, + maxHeight: 3, + maxPixels: 100_000_000, + }, + }, + nodes: { + math: { + version: 2, + title: "Math", + behavior: "standard", + style: "basic", + parameters: { + amount: { type: "number", default: { kind: "number", value: 1 }, precision: 2 }, + ramp: { type: "json", codec: "color-ramp/v1", default: { kind: "json", value: null } }, + }, + sockets: { + input: { + title: "In", + direction: "input", + type: "number", + maxIncomingLinks: 1, + visible: true, + value: null, + showValue: false, + }, + output: { + title: "Out", + direction: "output", + type: "number", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { kind: "parameter", parameter: "amount", visibleWhen: { all: [{ parameter: "amount", equals: 1 }] } }, + { kind: "widget", widget: "color-ramp", parameter: "ramp" }, + { kind: "resource", resource: "preview", parameter: "amount" }, + ], + muteBypass: [["input", "output"]], + migrations: [ + { + fromVersion: 1, + toVersion: 2, + steps: [ + { kind: "materialize-missing", target: "parameter", key: "amount" }, + { kind: "rename-socket", from: "old", to: "input" }, + ], + }, + ], + }, + other: { + version: 1, + title: "Other", + behavior: "standard", + style: "basic", + parameters: { label: { type: "string", default: { kind: "string", value: "" } } }, + sockets: { + result: { + title: "Result", + direction: "output", + type: "number", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { kind: "parameter", parameter: "label" }, + { kind: "socket", socket: "result" }, + ], + muteBypass: [], + migrations: [], + }, + }, +} as const; +type _N = Expect, "math" | "other">>; +type _T = Expect, "number">>; +type _S = Expect, "basic">>; +type _R = Expect, "preview">>; +type _P = Expect, "amount" | "ramp">>; +type _K = Expect, "input" | "output">>; +type _OP = Expect, "label">>; +type _OK = Expect, "result">>; +if (false) { + const createFxNode = null as unknown as typeof CreateFxNode; + const compiled = compileFxNodeComposition(valid); + const restyled = setHeaderStyles(valid, { alternate: { header: "#123456" } }); + type _HeaderStyle = Expect, "alternate">>; + void (null as _HeaderStyle | null); + const typeId = compiled.nodes.get("math")?.typeId; + type _CN = Expect, "math" | "other">>; + void (null as _CN | null); + const requestedBytes = compiled.source.resources.preview.maxBytes; + type _RequestedBytes = Expect>; + void (null as _RequestedBytes | null); + const effectiveBytes = compiled.resources.get("preview")!.maxBytes; + type _EffectiveBytes = Expect>; + void (null as _EffectiveBytes | null); + // @ts-expect-error exact compiled node keys + compiled.nodes.get("unknown"); + const canvas = null as unknown as HTMLCanvasElement; + const viewport = { width: 1, height: 1, dpr: 1 }; + type _ApplicationApi = Expect>, ApplicationFxNode>>; + void (null as _ApplicationApi | null); + const options = { + applicationId: APPLICATION_ID, + applicationVersion: APPLICATION_VERSION, + resources: APPLICATION_RESOURCES, + }; + const nodeless = createFxNode(options); + void nodeless; + // @ts-expect-error application identity, version, and resources are mandatory + void createFxNode({}); + // @ts-expect-error constructor composition was removed + void createFxNode({ ...options, composition: valid }); + // @ts-expect-error constructor graph layout was removed + void createFxNode({ ...options, layout: {} }); + // @ts-expect-error canvases belong to attached views, not root construction + void createFxNode({ ...options, canvas }); + // @ts-expect-error viewports belong to attached views, not root construction + void createFxNode({ ...options, viewport }); + const apiPromise = createFxNode(options); + type _Api = Expect, FxNode>>; + void (null as _Api | null); + void apiPromise.then((api) => { + const viewOptions: FxNodeViewOptions = { + canvas, + viewport, + initialCamera: { center: { x: 0, y: 0 }, zoom: 1 }, + }; + api.attachView(viewOptions).then((view) => { + type _View = Expect>; + void (null as _View | null); + view.feedInput({ kind: "focus", phase: "focus" }); + view.setViewport(viewport); + view.getHostSnapshot().selection.nodeCount; + const unsubscribeHost = view.subscribeHost(() => view.getHostSnapshot()); + const unsubscribeRequests = view.onHostRequests((request) => void request); + void view.addNode({ typeId: "math", viewPosition: { x: 0, y: 0 } }); + void view.removeSelected(); + void view.setSelectedMuted(true); + void view.provideResource( + { viewId: view.id, token: "token", graphVersion: 0, compositionRevision: 0 }, + { name: "image.png", mime: "image/png", bytes: new ArrayBuffer(1) }, + ); + unsubscribeHost(); + unsubscribeRequests(); + void view.whenRendered(); + void view.detach(); + view.id.length; + // @ts-expect-error a view ID cannot be replaced by consumers + view.id = "replacement"; + // @ts-expect-error graph authority remains on the root + view.dispatch({ type: "undo" }); + // @ts-expect-error graph snapshots remain on the root + view.getState(); + // @ts-expect-error composition authority remains on the root + view.setTheme(theme); + // @ts-expect-error composition authority remains on the root + view.composeNode("other", valid.nodes.other); + }); + // @ts-expect-error canvas is required + api.attachView({ viewport }); + // @ts-expect-error viewport is required + api.attachView({ canvas }); + // @ts-expect-error view IDs are internal + api.attachView({ canvas, viewport, viewId: "caller-owned" }); + // @ts-expect-error view-only operation + api.feedInput({ kind: "focus", phase: "focus" }); + // @ts-expect-error old copy presentation API was removed + api.copyTo(canvas); + // @ts-expect-error old mirror presentation API was removed + api.addMirror(canvas); + // @ts-expect-error old mirror presentation API was removed + api.removeMirror(canvas); + // @ts-expect-error rendering barriers belong to views + api.whenRendered(); + api.setCompatibility({ wildcardInputTypes: ["any"] }); + api.dispatch({ type: "node.add", nodeType: "math", position: { x: 0, y: 0 } }); + api.getState().then((snapshot) => { + const known = snapshot.nodes.find((node) => node.known); + if (known) { + type _Known = Expect>; + void (null as _Known | null); + } + }); + api.onSnapshots((event) => { + const known = event.snapshot.nodes.find((node) => node.known); + if (known) { + type _Known = Expect>; + void (null as _Known | null); + } + }); + api.onMutations((event) => { + for (const mutation of event.mutations) + if (mutation.kind === "node.set" && mutation.after?.known) { + type _Known = Expect>; + void (null as _Known | null); + } + }); + // @ts-expect-error live composition validates definition-local parameter references + void api.composeNode("bad-live-node", { ...valid.nodes.other, ui: [{ kind: "parameter", parameter: "missing" }] }); + void api + .composeSocket("dynamic", { title: "Dynamic", color: "#abcdef", acceptsFrom: ["dynamic"] } as const) + .then(async (receipt) => { + await api.composeNode("dynamic-node", { + version: 1, + title: "Dynamic Node", + behavior: "standard", + style: "basic", + parameters: {}, + sockets: { + out: { + title: "Out", + direction: "output", + type: "dynamic", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [{ kind: "socket", socket: "out" }], + muteBypass: [], + migrations: [], + }); + api.dispatch({ type: "node.add", nodeType: "dynamic-node", position: { x: 0, y: 0 } }); + await api.removeNode("dynamic-node"); + void receipt.revision; + }); + api.onCompositionChanges((event) => { + event.change.kind; + event.revision; + event.graphVersion; + // @ts-expect-error composition events never expose definitions + event.change.definition; + }); + }); +} +test("public composition definition preserves identity", () => assert.equal(valid.id, "types")); + +const seed = { + schemaVersion: 2, + id: "composed-types", + version: 1, + compatibility: { wildcardInputTypes: [] }, + nodeStyles: { basic: { header: "#abc" } }, + resources: {}, + socketTypes: {}, + nodes: {}, +} as const satisfies FxNodeCompositionSeed; +const themed = setTheme(seed, theme); +const scalar = composeSocket(themed, "scalar", { title: "Scalar", color: "#abc", acceptsFrom: ["scalar"] }); +const vector = composeSocket(scalar, "vector", { title: "Vector", color: "#def", acceptsFrom: ["vector", "scalar"] }); +const composed = composeNode(vector, "constant", { + version: 1, + title: "Constant", + behavior: "standard", + style: "basic", + parameters: { value: { type: "number", default: { kind: "number", value: 1 } } }, + sockets: { + out: { + title: "Out", + direction: "output", + type: "scalar", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { kind: "parameter", parameter: "value" }, + { kind: "socket", socket: "out" }, + ], + muteBypass: [], + migrations: [], +}); +const overridden = composeNode(composed, "constant", { + ...composed.nodes.constant, + title: "Replacement", + sockets: { + result: { + title: "Result", + direction: "output", + type: "vector", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { kind: "parameter", parameter: "value" }, + { kind: "socket", socket: "result" }, + ], + muteBypass: [], +}); +const noNode = removeNode(overridden, "constant"), + noVector = removeSocket(vector, "vector"); +type _ComposedSockets = Expect, "scalar" | "vector">>; +type _ComposedNodes = Expect, "constant">>; +type _OverriddenSocket = Expect, "result">>; +type _RemovedNode = Expect, never>>; +type _RemovedSocket = Expect, "scalar">>; +type _ThemedExport = Expect>>; +type _RemovedNodeExport = Expect>>; +type _RemovedSocketExport = Expect>>; +void (null as + | _ComposedSockets + | _ComposedNodes + | _OverriddenSocket + | _RemovedNode + | _RemovedSocket + | _ThemedExport + | _RemovedNodeExport + | _RemovedSocketExport + | null); +if (false) { + // @ts-expect-error acceptsFrom may only reference itself or a composed socket type + composeSocket(scalar, "bad", { title: "Bad", color: "#000", acceptsFrom: ["missing"] }); + // @ts-expect-error removeNode only accepts a currently composed node ID + removeNode(composed, "missing"); + // @ts-expect-error removeSocket only accepts a currently composed socket ID + removeSocket(vector, "missing"); + // @ts-expect-error composeNode validates style references against the current composition + composeNode(vector, "bad", { ...composed.nodes.constant, style: "missing" }); + composeNode(vector, "bad-size", { + ...composed.nodes.constant, + // @ts-expect-error node dimensions are calculated internally + defaultSize: { x: 1, y: 1 }, + }); + compileFxNodeComposition(composed); + + // Every representable reference and finite schema discriminator is checked below. + compileFxNodeComposition({ + ...valid, + socketTypes: { + number: { + ...valid.socketTypes.number, + // @ts-expect-error unknown acceptsFrom socket type + acceptsFrom: ["missing"], + }, + }, + }); + compileFxNodeComposition({ + ...valid, + nodes: { + math: { + ...valid.nodes.math, + // @ts-expect-error unknown node socket type + sockets: { ...valid.nodes.math.sockets, input: { ...valid.nodes.math.sockets.input, type: "missing" } }, + }, + }, + }); + compileFxNodeComposition({ + ...valid, + nodes: { + math: { + ...valid.nodes.math, + // @ts-expect-error unknown style + style: "missing", + }, + }, + }); + compileFxNodeComposition({ + ...valid, + nodes: { + math: { + ...valid.nodes.math, + // @ts-expect-error unknown resource + ui: [{ kind: "resource", resource: "missing", parameter: "amount" }], + }, + }, + }); + compileFxNodeComposition({ + ...valid, + nodes: { + math: { + ...valid.nodes.math, + // @ts-expect-error unknown UI parameter + ui: [{ kind: "parameter", parameter: "missing" }], + }, + }, + }); + compileFxNodeComposition({ + ...valid, + nodes: { + math: { + ...valid.nodes.math, + // @ts-expect-error unknown UI socket + ui: [{ kind: "socket", socket: "missing" }], + }, + }, + }); + compileFxNodeComposition({ + ...valid, + nodes: { + math: { + ...valid.nodes.math, + // @ts-expect-error unknown nested visibility parameter + ui: [{ kind: "parameter", parameter: "amount", visibleWhen: { any: [{ parameter: "missing", equals: 1 }] } }], + }, + }, + }); + compileFxNodeComposition({ + ...valid, + nodes: { + math: { + ...valid.nodes.math, + // @ts-expect-error unknown grading scalar/color references + ui: [ + { + kind: "widget", + widget: "grading-wheels", + bindings: [ + { title: "A", scalar: "missing", color: "amount" }, + { title: "B", scalar: "amount", color: "amount" }, + { title: "C", scalar: "amount", color: "amount" }, + ], + }, + ], + }, + }, + }); + compileFxNodeComposition({ + ...valid, + nodes: { + math: { + ...valid.nodes.math, + // @ts-expect-error unknown mute bypass socket + muteBypass: [["missing", "output"]], + }, + }, + }); + compileFxNodeComposition({ + ...valid, + nodes: { + math: { + ...valid.nodes.math, + // @ts-expect-error unknown migration current-side parameter + migrations: [ + { fromVersion: 1, toVersion: 2, steps: [{ kind: "rename-parameter", from: "old", to: "missing" }] }, + ], + }, + }, + }); + compileFxNodeComposition({ + ...valid, + nodes: { + math: { + ...valid.nodes.math, + // @ts-expect-error unknown migration current-side socket + migrations: [ + { fromVersion: 1, toVersion: 2, steps: [{ kind: "materialize-missing", target: "socket", key: "missing" }] }, + ], + }, + }, + }); + compileFxNodeComposition({ + ...valid, + nodes: { + math: { + ...valid.nodes.math, + // @ts-expect-error unsupported widget + ui: [{ kind: "widget", widget: "dial", parameter: "amount" }], + }, + }, + }); + compileFxNodeComposition({ + ...valid, + nodes: { + math: { + ...valid.nodes.math, + migrations: [ + { + fromVersion: 1, + toVersion: 2, + steps: [ + { + kind: "migrate-parameter", + parameter: "amount", + // @ts-expect-error unsupported migration codec + codec: "unknown", + }, + ], + }, + ], + }, + }, + }); +} diff --git a/vendor/fxnode/test/composition.test.ts b/vendor/fxnode/test/composition.test.ts new file mode 100644 index 0000000..7090cc1 --- /dev/null +++ b/vendor/fxnode/test/composition.test.ts @@ -0,0 +1,627 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + compileFxNodeComposition, + composeNode, + composeSocket, + FXNODE_COMPOSITION_LIMITS, + FxNodeCompositionError, + removeNode, + removeSocket, + setTheme, + validateFxNodeComposition, + type FxNodeCompositionSeed, +} from "@lib/composition/index.js"; + +const theme = { + background: "#112233", + grid: "#112233", + frame: "#112233", + frameHeader: "#112233", + body: "#112233", + control: "#112233", + controlFill: "#112233", + controlEditing: "#112233", + textSelection: "#112233", + outline: "#112233", + text: "#112233", + muted: "#112233", + shadow: "#112233", + nodeSelected: "#112233", + nodeActive: "#112233", + unknownHeader: "#112233", + unknownSocket: "#112233", + linkMuted: "#112233", + knifeMuted: "#112233", + emphasis: "#112233", + focus: "#112233", + editOutline: "#112233", + resize: "#112233", + muteOverlay: "#112233", + boxSelectionFill: "#112233", + checkerLight: "#112233", + checkerDark: "#112233", + widgetBorder: "#112233", + rampBorder: "#112233", + resourceBackground: "#112233", +} as const; +const socket = ( + direction: "input" | "output", + type: "scalar" | "wide", + value: null | { type: "number"; default: { kind: "number"; value: number } } = null, +) => ({ + title: direction, + direction, + type, + maxIncomingLinks: direction === "input" ? 1 : 0, + visible: true, + value: direction === "input" ? value : null, + showValue: direction === "input" && value !== null, +}); +const valid = () => + ({ + schemaVersion: 2, + id: "studio", + version: 4, + compatibility: { wildcardInputTypes: [] }, + theme, + socketTypes: { + scalar: { title: "Scalar", color: "#abcdef", acceptsFrom: ["scalar"] }, + wide: { title: "Wide", color: "#fedcba", acceptsFrom: ["wide", "scalar"] }, + }, + nodeStyles: { grade: { header: "#123456" }, utility: { header: "#654321" } }, + resources: { + image: { + kind: "image", + title: "Image", + openTitle: "Open", + accept: ["image/png", "image/jpeg"], + referencePrefix: "test-image:", + maxBytes: 1024, + maxWidth: 64, + maxHeight: 64, + maxPixels: 4096, + }, + }, + nodes: { + grade: { + version: 3, + title: "Grade", + behavior: "standard", + style: "grade", + parameters: { + exposure: { + type: "number", + default: { kind: "number", value: 0 }, + minimum: -10, + maximum: 10, + step: 0.1, + precision: 2, + }, + mode: { type: "string", default: { kind: "string", value: "film" }, enum: ["film", "raw"] }, + file: { type: "string", default: { kind: "string", value: "" } }, + tint: { type: "color", default: { kind: "color", value: [1, 1, 1, 1] }, minimum: 0, maximum: 1 }, + ramp: { + type: "json", + codec: "color-ramp/v1", + default: { + kind: "json", + value: { + colorMode: "rgb", + interpolation: "linear", + hueInterpolation: "near", + stops: [ + { id: "a", position: 0, color: [0, 0, 0, 1] }, + { id: "b", position: 1, color: [1, 1, 1, 1] }, + ], + }, + }, + }, + lift: { type: "number", default: { kind: "number", value: 0 } }, + gamma: { type: "number", default: { kind: "number", value: 1 } }, + gain: { type: "number", default: { kind: "number", value: 1 } }, + liftColor: { type: "color", default: { kind: "color", value: [0, 0, 0, 1] } }, + gammaColor: { type: "color", default: { kind: "color", value: [0.5, 0.5, 0.5, 1] } }, + gainColor: { type: "color", default: { kind: "color", value: [1, 1, 1, 1] } }, + }, + sockets: { + input: socket("input", "wide", { type: "number", default: { kind: "number", value: 0 } }), + output: socket("output", "scalar"), + secret: socket("input", "scalar"), + }, + ui: [ + { + kind: "text", + variant: "header", + title: "Color", + visibleWhen: { + all: [ + { parameter: "mode", equals: "film" }, + { + any: [ + { parameter: "exposure", in: [0, 1] }, + { parameter: "mode", equals: "raw" }, + ], + }, + ], + }, + }, + { kind: "parameter", parameter: "exposure" }, + { kind: "parameter", parameter: "mode" }, + { kind: "resource", resource: "image", parameter: "file" }, + { kind: "parameter", parameter: "tint" }, + { kind: "widget", widget: "color-ramp", parameter: "ramp" }, + { + kind: "widget", + widget: "grading-wheels", + bindings: [ + { title: "Lift", scalar: "lift", color: "liftColor" }, + { title: "Gamma", scalar: "gamma", color: "gammaColor" }, + { title: "Gain", scalar: "gain", color: "gainColor" }, + ], + }, + { kind: "socket", socket: "input" }, + { kind: "socket", socket: "output" }, + { kind: "hidden", target: "socket", socket: "secret" }, + ], + muteBypass: [["input", "output"]], + migrations: [ + { + fromVersion: 1, + toVersion: 3, + steps: [ + { kind: "materialize-missing", target: "parameter", key: "exposure" }, + { kind: "migrate-parameter", parameter: "ramp", codec: "color-ramp/legacy-stops" }, + { kind: "rename-socket", from: "source", to: "input" }, + ], + }, + ], + }, + constant: { + version: 1, + title: "Constant", + behavior: "standard", + style: "utility", + parameters: { value: { type: "number", default: { kind: "number", value: 1 }, integer: true } }, + sockets: { out: socket("output", "scalar") }, + ui: [ + { kind: "parameter", parameter: "value" }, + { kind: "socket", socket: "out" }, + ], + muteBypass: [], + migrations: [], + }, + internal: { + version: 1, + title: "Internal", + behavior: "reroute", + style: "utility", + parameters: {}, + sockets: {}, + ui: [], + muteBypass: [], + migrations: [], + }, + }, + }) as const; + +const clone = () => structuredClone(valid()); +function issue(value: unknown, code: string, path: string) { + const r = validateFxNodeComposition(value); + assert.equal(r.ok, false); + if (!r.ok) + assert( + r.issues.some((x) => x.code === code && x.path === path), + `${code} ${path}\n${JSON.stringify(r.issues)}`, + ); +} + +test("valid fixture compiles without menu projection", () => { + const v = valid(); + assert.equal(validateFxNodeComposition(v).ok, true); + const c = compileFxNodeComposition(v); + assert.equal("menuEntries" in c, false); +}); + +test("composition authoring helpers are immutable, replaceable, removable and clone-safe", () => { + const seed = { + schemaVersion: 2, + id: "helpers", + version: 1, + compatibility: { wildcardInputTypes: [] }, + nodeStyles: { utility: { header: "#654321" } }, + resources: {}, + socketTypes: {}, + nodes: {}, + } as const satisfies FxNodeCompositionSeed; + const themed = setTheme(seed, theme); + assert.equal("theme" in seed, false); + assert.notEqual(themed, seed); + const rethemed = setTheme(themed, { ...theme, grid: "#abcdef" }); + assert.equal(themed.theme.grid, "#112233"); + assert.equal(rethemed.theme.grid, "#abcdef"); + const sockets = composeSocket(themed, "scalar", { title: "Scalar", color: "#abcdef", acceptsFrom: ["scalar"] }); + assert.deepEqual(seed.socketTypes, {}); + assert.notEqual(sockets.socketTypes, seed.socketTypes); + const definition = { + version: 1, + title: "Constant", + behavior: "standard", + style: "utility", + parameters: {}, + sockets: { + out: { + title: "Out", + direction: "output", + type: "scalar", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [{ kind: "socket", socket: "out" }], + muteBypass: [], + migrations: [], + } as const; + const nodes = composeNode(sockets, "constant", definition), + replacement = composeNode(nodes, "constant", { ...definition, title: "Replacement" }); + assert.equal(nodes.nodes.constant.title, "Constant"); + assert.equal(replacement.nodes.constant.title, "Replacement"); + assert.deepEqual(sockets.nodes, {}); + assert.doesNotThrow(() => structuredClone(replacement)); + assert.equal(validateFxNodeComposition(replacement).ok, true); + const withoutNode = removeNode(replacement, "constant"); + assert.equal("constant" in withoutNode.nodes, false); + assert.equal("constant" in replacement.nodes, true); + const dangling = removeSocket(replacement, "scalar"), + checked = validateFxNodeComposition(dangling); + assert.equal(checked.ok, false); + if (!checked.ok) assert(checked.issues.some((issue) => issue.code === "reference.socketType")); + const withSize: any = structuredClone(replacement); + withSize.nodes.constant.defaultSize = { x: 1, y: 1 }; + issue(withSize, "shape.unknown", "/nodes/constant/defaultSize"); +}); + +test("compilations are independent immutable clones and expose readonly facades", () => { + const a: any = clone(), + b: any = clone(); + a.id = "same"; + b.id = "same"; + const ca = compileFxNodeComposition(a), + cb = compileFxNodeComposition(b); + assert.notEqual(ca.source, cb.source); + a.nodes.grade.parameters.exposure.default.value = 9; + assert.equal(ca.source.nodes.grade.parameters.exposure.default.value, 0); + assert.equal(ca.nodes.get("grade")?.typeId, "grade"); + assert(Object.isFrozen(ca)); + assert(Object.isFrozen(ca.source)); + assert(Object.isFrozen(ca.source.nodes.grade.parameters)); + for (const facade of [ca.nodes, ca.socketTypes, ca.styles, ca.resources]) + for (const method of ["set", "delete", "clear"]) assert.equal(method in facade, false); + let leaked: unknown = "unset"; + const size = ca.nodes.size; + ca.nodes.forEach(function () { + leaked = arguments[2]; + }); + assert.equal(leaked, undefined); + assert.equal(ca.nodes.size, size); +}); + +test("compiled image policies preserve requested source limits and expose effective ceilings", () => { + const source: any = clone(); + source.resources.image = { + ...source.resources.image, + maxBytes: 100_000_000, + maxWidth: 10_000, + maxHeight: 3, + maxPixels: 100_000_000, + }; + const compiled = compileFxNodeComposition(source); + assert.deepEqual( + { + maxBytes: compiled.source.resources.image.maxBytes, + maxWidth: compiled.source.resources.image.maxWidth, + maxHeight: compiled.source.resources.image.maxHeight, + maxPixels: compiled.source.resources.image.maxPixels, + }, + { maxBytes: 100_000_000, maxWidth: 10_000, maxHeight: 3, maxPixels: 100_000_000 }, + ); + const effective = compiled.resources.get("image")!; + assert.deepEqual( + { + maxBytes: effective.maxBytes, + maxWidth: effective.maxWidth, + maxHeight: effective.maxHeight, + maxPixels: effective.maxPixels, + }, + { maxBytes: 33_554_432, maxWidth: 8192, maxHeight: 3, maxPixels: 24_576 }, + ); +}); + +type Mutator = (x: any) => void; +const cases: [string, string, Mutator][] = [ + ["reference.style", "/nodes/grade/style", (x) => (x.nodes.grade.style = "no")], + ["reference.socketType", "/socketTypes/wide/acceptsFrom/0", (x) => (x.socketTypes.wide.acceptsFrom[0] = "no")], + ["reference.socketType", "/nodes/grade/sockets/input/type", (x) => (x.nodes.grade.sockets.input.type = "no")], + ["reference.resource", "/nodes/grade/ui/3/resource", (x) => (x.nodes.grade.ui[3].resource = "no")], + ["reference.parameter", "/nodes/grade/ui/1/parameter", (x) => (x.nodes.grade.ui[1].parameter = "no")], + ["reference.socket", "/nodes/grade/ui/7/socket", (x) => (x.nodes.grade.ui[7].socket = "no")], + [ + "reference.parameter", + "/nodes/grade/ui/0/visibleWhen/all/0/parameter", + (x) => (x.nodes.grade.ui[0].visibleWhen.all[0].parameter = "no"), + ], + [ + "schema.bounds", + "/nodes/grade/parameters/exposure/minimum", + (x) => (x.nodes.grade.parameters.exposure.minimum = 20), + ], + [ + "schema.integer", + "/nodes/constant/parameters/value/integer", + (x) => (x.nodes.constant.parameters.value.integer = "yes"), + ], + [ + "schema.precision", + "/nodes/grade/parameters/exposure/precision", + (x) => (x.nodes.grade.parameters.exposure.precision = 21), + ], + [ + "value.duplicate", + "/nodes/grade/parameters/mode/enum", + (x) => (x.nodes.grade.parameters.mode.enum = ["film", "film"]), + ], + [ + "schema.enum", + "/nodes/grade/parameters/mode/default/value", + (x) => (x.nodes.grade.parameters.mode.default.value = "no"), + ], + [ + "schema.bounds", + "/nodes/grade/parameters/tint/default/value", + (x) => (x.nodes.grade.parameters.tint.default.value[0] = 2), + ], + [ + "schema.bounds", + "/nodes/grade/parameters/exposure/default/value", + (x) => (x.nodes.grade.parameters.exposure.default.value = 20), + ], + [ + "socket.links", + "/nodes/grade/sockets/input/maxIncomingLinks", + (x) => (x.nodes.grade.sockets.input.maxIncomingLinks = 0), + ], + ["socket.output", "/nodes/grade/sockets/output", (x) => (x.nodes.grade.sockets.output.showValue = true)], + [ + "socket.value", + "/nodes/grade/sockets/input/value", + (x) => { + x.nodes.grade.sockets.input.value = null; + x.nodes.grade.sockets.input.showValue = true; + }, + ], + ["ui.widget", "/nodes/grade/ui/5/widget", (x) => (x.nodes.grade.ui[5].widget = "dial")], + ["ui.widget", "/nodes/grade/ui/5/parameter", (x) => (x.nodes.grade.ui[5].parameter = "exposure")], + ["ui.widget", "/nodes/grade/ui/6/bindings/0/scalar", (x) => (x.nodes.grade.ui[6].bindings[0].scalar = "liftColor")], + ["ui.widget", "/nodes/grade/ui/6/bindings/0/color", (x) => (x.nodes.grade.ui[6].bindings[0].color = "lift")], + ["ui.resource", "/nodes/grade/ui/3/parameter", (x) => (x.nodes.grade.ui[3].parameter = "exposure")], + ["ui.placement", "/nodes/grade/parameters/exposure", (x) => x.nodes.grade.ui.splice(1, 1)], + ["ui.placement", "/nodes/grade/ui/2", (x) => (x.nodes.grade.ui[2] = { kind: "parameter", parameter: "exposure" })], + ["socket.bypass", "/nodes/grade/muteBypass/0", (x) => (x.nodes.grade.muteBypass = [["output", "input"]])], + [ + "socket.compatibility", + "/nodes/grade/muteBypass/0", + (x) => { + x.nodes.grade.sockets.input.type = "scalar"; + x.nodes.grade.sockets.output.type = "wide"; + }, + ], + ["value.duplicate", "/nodes/grade/muteBypass/1", (x) => x.nodes.grade.muteBypass.push(["input", "output"])], + ["limit.resource", "/resources/image/maxWidth", (x) => (x.resources.image.maxWidth = 0)], + ["shape.string", "/resources/image/referencePrefix", (x) => (x.resources.image.referencePrefix = "bad")], + ["migration.edge", "/nodes/grade/migrations/0", (x) => (x.nodes.grade.migrations[0].toVersion = 4)], + [ + "migration.outgoing", + "/nodes/grade/migrations/1/fromVersion", + (x) => x.nodes.grade.migrations.push({ fromVersion: 1, toVersion: 2, steps: [] }), + ], + [ + "reference.migrationTarget", + "/nodes/grade/migrations/0/steps/0/key", + (x) => (x.nodes.grade.migrations[0].steps[0].key = "no"), + ], + [ + "migration.codec", + "/nodes/grade/migrations/0/steps/1/codec", + (x) => (x.nodes.grade.migrations[0].steps[1].codec = "no"), + ], + [ + "migration.rename", + "/nodes/grade/migrations/0/steps/3/from", + (x) => x.nodes.grade.migrations[0].steps.push({ kind: "rename-socket", from: "source", to: "output" }), + ], + ["shape.literal", "/nodes/grade/behavior", (x) => (x.nodes.grade.behavior = ["standard"])], + [ + "socket.direction", + "/nodes/grade/sockets/input/direction", + (x) => (x.nodes.grade.sockets.input.direction = ["input"]), + ], + ["ui.text", "/nodes/grade/ui/0/variant", (x) => (x.nodes.grade.ui[0].variant = ["header"])], + ["shape.string", "/nodes/grade/ui/1/parameter", (x) => (x.nodes.grade.ui[1].parameter = 0)], + ["shape.string", "/nodes/grade/ui/3/resource", (x) => (x.nodes.grade.ui[3].resource = 0)], + ["ui.target", "/nodes/grade/ui/9/target", (x) => (x.nodes.grade.ui[9].target = "bogus")], + ["ui.visibility", "/nodes/grade/ui/0/visibleWhen/all", (x) => (x.nodes.grade.ui[0].visibleWhen = { all: [] })], + ["ui.visibility", "/nodes/grade/ui/0/visibleWhen/any", (x) => (x.nodes.grade.ui[0].visibleWhen = { any: [] })], + [ + "migration.codec", + "/nodes/grade/migrations/0/steps/1/parameter", + (x) => (x.nodes.grade.migrations[0].steps[1].parameter = "exposure"), + ], +]; +test("semantic invalid cases report precise codes and paths", () => { + for (const [code, path, mutate] of cases) { + const x: any = clone(); + mutate(x); + issue(x, code, path); + } +}); + +test("hostile structures fail without executing code or throwing", () => { + for (const array of [false, true]) { + let calls = 0; + const x: any = clone(); + if (array) + Object.defineProperty(x.nodes.grade.ui, 0, { + get() { + calls++; + return {}; + }, + enumerable: true, + }); + else + Object.defineProperty(x, "id", { + get() { + calls++; + return "x"; + }, + enumerable: true, + }); + issue(x, "data.inspect", array ? "/nodes/grade/ui/0" : "/id"); + assert.equal(calls, 0); + } + const hostile: [unknown, string, string][] = [ + [Object.assign(clone(), { [Symbol("x")]: 1 }), "data.symbol", ""], + [Object.assign(clone(), { id: new (class X {})() }), "data.type", "/id"], + [Object.assign(clone(), { id: new Date() }), "data.type", "/id"], + [Object.assign(clone(), { id: new Map() }), "data.type", "/id"], + [Object.assign(clone(), { id: new ArrayBuffer(1) }), "data.type", "/id"], + [Object.assign(clone(), { id: 1n }), "data.type", "/id"], + [Object.assign(clone(), { id: undefined }), "data.type", "/id"], + ]; + for (const [x, c, p] of hostile) issue(x, c, p); + const sparse: any = clone(); + sparse.nodes.grade.ui.length++; + issue(sparse, "data.array", "/nodes/grade/ui/10"); + const keyed: any = []; + Object.defineProperty(keyed, "x".repeat(FXNODE_COMPOSITION_LIMITS.maxStringCodeUnits + 1), { + value: null, + enumerable: true, + }); + issue(keyed, "limit.strings", ""); + const shared: any = clone(); + shared.extra = shared.theme; + issue(shared, "data.identity", "/extra"); + const cycle: any = clone(); + cycle.extra = cycle; + issue(cycle, "data.identity", "/extra"); + const revoked = Proxy.revocable({}, {}); + revoked.revoke(); + for (const proxy of [ + new Proxy( + {}, + { + ownKeys() { + throw Error("no"); + }, + }, + ), + revoked.proxy, + ]) + assert.doesNotThrow(() => issue(proxy, "data.inspect", "")); + const np = Object.assign(Object.create(null), clone()); + const r = validateFxNodeComposition(np); + assert.equal(r.ok, true); + if (r.ok) assert.equal(r.value.id, "studio"); +}); + +test("collection, visibility, and issue caps", () => { + const limits: [string, string, (x: any) => void][] = [ + [ + "limit.collection", + "/nodes", + (x) => { + const n = x.nodes.internal; + x.nodes = {}; + for (let i = 0; i <= FXNODE_COMPOSITION_LIMITS.maxNodes; i++) x.nodes[`n${i}`] = structuredClone(n); + }, + ], + [ + "limit.collection", + "/socketTypes", + (x) => { + for (let i = 0; i <= FXNODE_COMPOSITION_LIMITS.maxSocketTypes; i++) + x.socketTypes[`s${i}`] = { title: "S", color: "#abcdef", acceptsFrom: [] }; + }, + ], + [ + "limit.ui", + "/nodes/grade/ui", + (x) => { + x.nodes.grade.ui = Array.from({ length: 257 }, () => ({ kind: "text", variant: "header", title: "x" })); + }, + ], + [ + "limit.enum", + "/nodes/grade/parameters/mode/enum", + (x) => (x.nodes.grade.parameters.mode.enum = Array.from({ length: 257 }, (_, i) => `v${i}`)), + ], + [ + "limit.migrations", + "/nodes/grade/migrations", + (x) => + (x.nodes.grade.migrations = Array.from({ length: 65 }, (_, i) => ({ + fromVersion: i + 1, + toVersion: i + 2, + steps: [], + }))), + ], + [ + "limit.migrations", + "/nodes/grade/migrations/0/steps", + (x) => + (x.nodes.grade.migrations[0].steps = Array.from({ length: 129 }, () => ({ + kind: "materialize-missing", + target: "parameter", + key: "exposure", + }))), + ], + [ + "limit.visibility", + "/nodes/grade/ui/0/visibleWhen/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0", + (x) => { + let v: any = { parameter: "mode", equals: "film" }; + for (let i = 0; i < 17; i++) v = { all: [v] }; + x.nodes.grade.ui[0].visibleWhen = v; + }, + ], + ]; + for (const [c, p, m] of limits) { + const x: any = clone(); + m(x); + issue(x, c, p); + } + for (const target of ["top", "node"]) { + const x: any = clone(); + const o = target === "top" ? x : x.nodes.grade; + for (let i = 0; i < 110; i++) o[`unknown${i}`] = i; + const r = validateFxNodeComposition(x); + assert.equal(r.ok, false); + if (!r.ok) { + assert.equal(r.issues.length, FXNODE_COMPOSITION_LIMITS.maxIssues); + assert(r.issues.every((i) => i.code === "shape.unknown")); + } + } +}); + +test("compile error carries frozen issues", () => { + try { + compileFxNodeComposition({ ...valid(), schemaVersion: 1 } as unknown as ReturnType); + assert.fail("expected error"); + } catch (e) { + assert(e instanceof FxNodeCompositionError); + assert(Object.isFrozen(e.issues)); + assert(Object.isFrozen(e.issues[0])); + assert(e.issues.some((i) => i.path === "/schemaVersion")); + } +}); diff --git a/vendor/fxnode/test/control-edit.test.ts b/vendor/fxnode/test/control-edit.test.ts new file mode 100644 index 0000000..7548ca6 --- /dev/null +++ b/vendor/fxnode/test/control-edit.test.ts @@ -0,0 +1,71 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + clampNumber, + cycleEnum, + numericStep, + scrubValue, + setNumericComponent, + snapNumber, +} from "@lib/worker/control-edit.js"; +import type { LayoutControl } from "@lib/layout/types.js"; + +const numberControl: LayoutControl = { + id: "number", + nodeId: "node" as LayoutControl["nodeId"], + source: "parameter", + key: "value", + label: "Value", + kind: "number", + value: { kind: "number", value: 0 }, + schema: { type: "number", default: { kind: "number", value: 0 }, minimum: -1, maximum: 1, step: 0.25 }, + linked: false, + bounds: { x: 0, y: 0, width: 10, height: 10 }, + subfields: [], + numericFields: [], +}; + +test("control edit helpers clamp, snap and apply fine scrub", () => { + assert.equal(clampNumber(2, numberControl.schema), 1); + assert.equal(snapNumber(0.38, numberControl.schema), 0.5); + assert.deepEqual(scrubValue(numberControl, { kind: "number", value: 0 }, 0, 5, true, false), { + kind: "number", + value: 0.05, + }); + assert.deepEqual(scrubValue(numberControl, { kind: "number", value: 0 }, 0, 3.8, false, true), { + kind: "number", + value: 0.5, + }); + assert.deepEqual(setNumericComponent(numberControl, { kind: "number", value: 0 }, 0, 2), { + kind: "number", + value: 1, + }); + assert.equal(numericStep(numberControl, false), 0.25); + assert.equal(numericStep(numberControl, true), 0.025); +}); + +test("vector/color edits preserve other components and enum cycling wraps", () => { + const vector = { + ...numberControl, + kind: "vector" as const, + schema: { type: "vector" as const, default: { kind: "vector" as const, value: [0, 0, 0] as const } }, + }; + assert.deepEqual(scrubValue(vector, { kind: "vector", value: [1, 2, 3] }, 1, 10, false, false), { + kind: "vector", + value: [1, 3, 3], + }); + assert.deepEqual(setNumericComponent(vector, { kind: "vector", value: [1, 2, 3] }, 1, 4), { + kind: "vector", + value: [1, 4, 3], + }); + const color = { + ...numberControl, + kind: "color" as const, + schema: { type: "color" as const, default: { kind: "color" as const, value: [0, 0, 0, 1] as const } }, + }; + assert.deepEqual(scrubValue(color, { kind: "color", value: [0, 0.5, 0, 1] }, 1, 10, false, false), { + kind: "color", + value: [0, 1, 0, 1], + }); + assert.equal(cycleEnum(["a", "b"], "b", 1), "a"); +}); diff --git a/vendor/fxnode/test/engine.test.ts b/vendor/fxnode/test/engine.test.ts new file mode 100644 index 0000000..8baf0f7 --- /dev/null +++ b/vendor/fxnode/test/engine.test.ts @@ -0,0 +1,397 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js"; +import { commandId, linkId, nodeId, socketId } from "@lib/core/types.js"; +import { reduceMutations } from "@lib/engine/reducer.js"; +import { minimumNodeSize } from "@lib/layout/node-dimensions.js"; +import { validCommand } from "@lib/commands/validate.js"; +import { planSelectionMute, planSelectionRemoval } from "@lib/worker/selection-actions.js"; +const { + createEngine, + decodeGraphDocument, + emptyDocument, + materializeNode, + save, + serializeGraphDocument, + getState, + transition, +} = APPLICATION_HEADLESS; +const BUILTIN_DESCRIPTORS = Object.freeze([...APPLICATION_COMPILED.nodes.values()]); +const CATALOG_NODE_IDS = [...APPLICATION_COMPILED.nodes.keys()]; +import type { EngineState, Command, CommandRequest } from "@lib/headless.js"; + +type ApplicationEngineState = EngineState; + +let sequence = 0; +function run(state: ApplicationEngineState, command: Command) { + const request: CommandRequest = { + commandId: commandId(`command-${++sequence}`), + expectedVersion: state.version, + source: "api", + command, + }; + return transition(state, request); +} +function committed(state: ApplicationEngineState, command: Command): ApplicationEngineState { + const result = run(state, command); + assert.equal(result.status, "committed"); + if (result.status !== "committed") throw new Error("expected commit"); + return result.state; +} + +test("catalog has exact, frozen coverage and every built-in materializes generic initial state", () => { + const expectedTypeIds = [ + "fxnode.common.frame", + "fxnode.common.reroute", + "fxnode.common.group-input", + "fxnode.common.group-output", + "fxnode.shader.value", + "fxnode.shader.color", + "fxnode.shader.math", + "fxnode.shader.vector-math", + "fxnode.shader.mix", + "fxnode.shader.color-ramp", + "fxnode.shader.texture-coordinate", + "fxnode.shader.noise-texture", + "fxnode.shader.image-texture", + "fxnode.shader.principled-bsdf", + "fxnode.shader.material-output", + "fxnode.geometry.position", + "fxnode.geometry.mesh-cube", + "fxnode.geometry.set-position", + "fxnode.geometry.transform-geometry", + "fxnode.geometry.join-geometry", + "fxnode.compositor.image", + "fxnode.compositor.color-balance", + ] as const; + assert.deepEqual( + BUILTIN_DESCRIPTORS.map((item) => item.typeId), + expectedTypeIds, + ); + assert.deepEqual(CATALOG_NODE_IDS, expectedTypeIds); + assert.equal(BUILTIN_DESCRIPTORS.length, 22); + assert.ok(BUILTIN_DESCRIPTORS.every(Object.isFrozen)); + assert.ok(Object.isFrozen(BUILTIN_DESCRIPTORS)); + assert.ok(Object.isFrozen(BUILTIN_DESCRIPTORS[0]!.sockets)); + assert.ok(Object.isFrozen(BUILTIN_DESCRIPTORS[4]!.parameters)); + for (const [index, typeId] of expectedTypeIds.entries()) { + const node = materializeNode(`catalog-${index}`, typeId, { x: index, y: -index }); + assert.equal(node.typeId, typeId); + assert.deepEqual(node.position, { x: index, y: -index }); + assert.equal( + node.size.y, + typeId === "fxnode.common.reroute" ? 10 : Math.max(100, minimumNodeSize(BUILTIN_DESCRIPTORS[index]!, node).y), + ); + assert.equal(node.known, true); + assert.equal(node.muted, false); + assert.equal(node.collapsed, false); + assert.equal(node.parentId, undefined); + assert.deepEqual(Object.keys(node.extensions), []); + assert.ok(Object.isFrozen(node)); + assert.ok(Object.isFrozen(node.parameters)); + assert.ok(Object.isFrozen(node.sockets)); + } +}); + +test("commit envelopes carry versions, command id, cause and explicit null", () => { + const state = createEngine(emptyDocument()); + const id = commandId("add-1"); + const result = transition(state, { + commandId: id, + expectedVersion: 0, + source: "gesture", + command: { type: "node.add", nodeId: nodeId("n"), nodeType: "fxnode.shader.value", position: { x: 0, y: 0 } }, + }); + assert.equal(result.status, "committed"); + if (result.status !== "committed") return; + assert.deepEqual( + { + base: result.mutationEnvelope.baseVersion, + version: result.mutationEnvelope.version, + cause: result.mutationEnvelope.cause, + }, + { base: 0, version: 1, cause: "gesture" }, + ); + assert.equal(result.mutationEnvelope.commandId, id); + assert.equal(result.mutationEnvelope.mutations[0]?.before, null); + assert.equal(result.snapshotEnvelope.version, 1); +}); + +test("stale, duplicate and missing commands reject with exact state identity", () => { + let state = createEngine(emptyDocument()); + state = committed(state, { + type: "node.add", + nodeId: nodeId("n"), + nodeType: "fxnode.shader.value", + position: { x: 0, y: 0 }, + }); + for (const command of [ + { type: "node.add", nodeId: nodeId("n"), nodeType: "fxnode.shader.value", position: { x: 0, y: 0 } }, + { type: "node.remove", id: nodeId("missing") }, + ] as const) { + const result = run(state, command); + assert.equal(result.status, "rejected"); + assert.equal(result.state, state); + } + const stale = transition(state, { + commandId: commandId("stale"), + expectedVersion: 0, + source: "api", + command: { type: "undo" }, + }); + assert.equal(stale.status, "rejected"); + assert.equal(stale.state, state); +}); + +test("same-value editing is a no-op and untouched records are shared", () => { + let state = createEngine(emptyDocument()); + state = committed(state, { + type: "node.add", + nodeId: nodeId("a"), + nodeType: "fxnode.shader.value", + position: { x: 0, y: 0 }, + }); + state = committed(state, { + type: "node.add", + nodeId: nodeId("b"), + nodeType: "fxnode.shader.value", + position: { x: 0, y: 0 }, + }); + const noop = run(state, { type: "node.move", id: nodeId("a"), position: { x: 0, y: 0 } }); + assert.equal(noop.status, "noop"); + assert.equal(noop.state, state); + const before = state.document.nodes.b; + state = committed(state, { type: "node.move", id: nodeId("a"), position: { x: 5, y: 6 } }); + assert.equal(state.document.nodes.b, before); + assert.ok(Object.isFrozen(state.document.nodes.a?.parameters)); +}); + +test("undo and redo restore remove cascades and frame children", () => { + let state = createEngine(emptyDocument()); + state = committed(state, { + type: "node.add", + nodeId: nodeId("frame"), + nodeType: "fxnode.common.frame", + position: { x: 0, y: 0 }, + }); + state = committed(state, { + type: "node.add", + nodeId: nodeId("child"), + nodeType: "fxnode.shader.value", + position: { x: 0, y: 0 }, + parentId: nodeId("frame"), + }); + const beforeRemoval = state, + removal = run(state, { type: "node.remove", id: nodeId("frame") }); + assert.equal(removal.status, "committed"); + if (removal.status !== "committed") return; + state = removal.state; + assert.equal(state.document.nodes.child?.parentId, undefined); + assert.equal(Object.hasOwn(state.document.nodes.child!, "parentId"), false); + assert.deepEqual(reduceMutations(beforeRemoval.document, removal.mutationEnvelope.mutations), state.document); + const unparented = save(state.document), + savedChild = unparented.nodes.find((node) => node.id === "child"); + assert.equal(Object.hasOwn(savedChild!, "parentId"), false); + assert.equal(decodeGraphDocument(unparented).ok, true); + state = committed(state, { type: "undo" }); + assert.equal(state.document.nodes.child?.parentId, "frame"); + state = committed(state, { type: "redo" }); + assert.equal(state.document.nodes.frame, undefined); +}); + +test("history limits 0, 1 and 2 bound undo and new edits clear redo", () => { + for (const limit of [0, 1, 2]) { + let state = createEngine(emptyDocument(), limit); + state = committed(state, { + type: "node.add", + nodeId: nodeId(`n${limit}`), + nodeType: "fxnode.shader.value", + position: { x: 0, y: 0 }, + }); + assert.equal(state.undo.length, limit === 0 ? 0 : 1); + } + assert.throws(() => createEngine(emptyDocument(), 1.5), RangeError); +}); + +test("frame cycles reject atomically", () => { + let state = createEngine(emptyDocument()); + state = committed(state, { + type: "node.add", + nodeId: nodeId("a"), + nodeType: "fxnode.common.frame", + position: { x: 0, y: 0 }, + }); + state = committed(state, { + type: "node.add", + nodeId: nodeId("b"), + nodeType: "fxnode.common.frame", + position: { x: 0, y: 0 }, + parentId: nodeId("a"), + }); + const result = run(state, { type: "node.parent", id: nodeId("a"), parentId: nodeId("b") }); + assert.equal(result.status, "rejected"); + assert.equal(result.state, state); +}); + +test("save, decode and save is canonical; snapshot arrays sort by id", () => { + let state = createEngine(emptyDocument("canonical")); + state = committed(state, { + type: "node.add", + nodeId: nodeId("z"), + nodeType: "fxnode.shader.value", + position: { x: 0, y: 0 }, + }); + state = committed(state, { + type: "node.add", + nodeId: nodeId("a"), + nodeType: "fxnode.shader.value", + position: { x: 0, y: 0 }, + }); + const layout = save(state.document); + assert.deepEqual( + layout.nodes.map((item) => item.id), + ["a", "z"], + ); + const decoded = decodeGraphDocument(structuredClone(layout)); + assert.equal(decoded.ok, true); + if (decoded.ok) assert.equal(serializeGraphDocument(decoded.value), serializeGraphDocument(state.document)); + assert.deepEqual( + getState(state).nodes.map((item) => item.id), + ["a", "z"], + ); + assert.equal("known" in layout.nodes[0]!, false); +}); + +test("transient fields are rejected even through extension escape hatches", () => { + const raw = { ...save(emptyDocument()), extensions: { selection: [] } }; + assert.equal(decodeGraphDocument(raw).ok, false); + const nested = { ...save(emptyDocument()), metadata: { extension: { history: [] } } }; + assert.equal(decodeGraphDocument(nested).ok, false); +}); + +test("bound persistence normalizes v1 and rejects future document schemas", () => { + const v1 = { schemaVersion: 1, graphId: "old", catalogVersion: 1, nodes: [], links: [], metadata: {} }; + const migrated = decodeGraphDocument(v1); + assert.equal(migrated.ok, true); + if (migrated.ok) assert.equal(migrated.value.catalogVersion, emptyDocument().catalogVersion); + const future = decodeGraphDocument({ ...v1, schemaVersion: 3 }); + assert.equal(future.ok, false); + if (!future.ok) assert.equal(future.issues.at(-1)?.code, "schema.future"); +}); + +test("parameter reset resolves descriptor default and is one-step undoable", () => { + let state = createEngine(emptyDocument()); + state = committed(state, { + type: "node.add", + nodeId: nodeId("v"), + nodeType: "fxnode.shader.value", + position: { x: 0, y: 0 }, + }); + state = committed(state, { + type: "node.parameter", + id: nodeId("v"), + key: "value", + value: { kind: "number", value: 9 }, + }); + state = committed(state, { type: "node.parameter-reset", id: nodeId("v"), key: "value" }); + assert.deepEqual(state.document.nodes.v?.parameters.value, { kind: "number", value: 0 }); + state = committed(state, { type: "undo" }); + assert.deepEqual(state.document.nodes.v?.parameters.value, { kind: "number", value: 9 }); +}); + +test("batch is atomic, increments once, and undoes as one entry", () => { + let state = createEngine(emptyDocument()); + state = committed(state, { + type: "node.add", + nodeId: nodeId("a"), + nodeType: "fxnode.shader.value", + position: { x: 0, y: 0 }, + }); + state = committed(state, { + type: "node.add", + nodeId: nodeId("b"), + nodeType: "fxnode.shader.value", + position: { x: 0, y: 0 }, + }); + const before = state, + result = run(state, { + type: "batch", + commands: [ + { type: "node.move", id: nodeId("a"), position: { x: 3, y: 4 } }, + { type: "node.move", id: nodeId("b"), position: { x: 5, y: 6 } }, + ], + }); + assert.equal(result.status, "committed"); + if (result.status !== "committed") return; + assert.equal(result.state.version, before.version + 1); + assert.equal(result.state.undo.length, before.undo.length + 1); + state = committed(result.state, { type: "undo" }); + assert.deepEqual(state.document.nodes.a?.position, { x: 0, y: 0 }); + assert.deepEqual(state.document.nodes.b?.position, { x: 0, y: 0 }); + const rejected = run(state, { + type: "batch", + commands: [ + { type: "node.move", id: nodeId("a"), position: { x: 9, y: 9 } }, + { type: "node.remove", id: nodeId("missing") }, + ], + }); + assert.equal(rejected.status, "rejected"); + assert.equal(rejected.state, state); +}); + +test("selection planners are complete, deterministic, and enforce the shared atomic limit", () => { + const a = materializeNode("a", "fxnode.shader.value", { x: 0, y: 0 })!, + b = materializeNode("b", "fxnode.shader.value", { x: 1, y: 1 })!; + const incident = { + id: linkId("incident"), + fromNodeId: nodeId("a"), + fromSocketId: socketId("a:out"), + toNodeId: nodeId("b"), + toSocketId: socketId("b:in"), + muted: false, + extensions: {}, + }, + standalone = { ...incident, id: linkId("standalone"), fromNodeId: nodeId("b") }; + const document = { ...emptyDocument(), nodes: { a, b }, links: { incident, standalone } }; + const removal = planSelectionRemoval( + document, + new Set([nodeId("a")]), + new Set([linkId("incident"), linkId("standalone")]), + )!; + assert.deepEqual(removal, { + type: "batch", + commands: [ + { type: "link.remove", id: linkId("standalone") }, + { type: "node.remove", id: nodeId("a") }, + ], + }); + const mixed = { ...document, nodes: { a: { ...a, muted: true }, b } }; + assert.deepEqual( + planSelectionMute(mixed, new Set([nodeId("b"), nodeId("a")]), () => true), + { type: "batch", commands: [{ type: "node.mute", id: nodeId("b"), value: true }] }, + ); + const nodes = Object.fromEntries( + Array.from({ length: 257 }, (_, index) => { + const id = `n-${String(index).padStart(3, "0")}`; + return [id, materializeNode(id, "fxnode.shader.value", { x: index, y: 0 })!]; + }), + ); + const overflow = planSelectionRemoval( + { ...emptyDocument(), nodes }, + new Set(Object.keys(nodes).map(nodeId)), + new Set(), + )!; + assert.equal(overflow.type, "batch"); + if (overflow.type !== "batch") return; + assert.equal(overflow.commands.length, 257); + assert.equal(validCommand(overflow), false); + const overflowState = createEngine({ ...emptyDocument(), nodes }), + rejected = run(overflowState, overflow); + assert.equal(rejected.status, "rejected"); + assert.equal(rejected.state, overflowState); + assert.equal(overflowState.undo.length, 0); + assert.deepEqual( + planSelectionMute(mixed, new Set([nodeId("b"), nodeId("a")]), (id) => id === nodeId("a"), false), + { type: "batch", commands: [{ type: "node.mute", id: nodeId("a"), value: false }] }, + ); +}); diff --git a/vendor/fxnode/test/headless-runtime.test.ts b/vendor/fxnode/test/headless-runtime.test.ts new file mode 100644 index 0000000..05adca6 --- /dev/null +++ b/vendor/fxnode/test/headless-runtime.test.ts @@ -0,0 +1,494 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createFxNodeHeadless } from "@lib/headless-runtime.js"; +import { commandId, nodeId, type GraphState } from "@lib/core/types.js"; +import { rebindBoundEngineAuthority } from "@lib/composition/bound-engine.js"; +import type { FxNodeCompositionData } from "@lib/composition/types.js"; +import { canonicalJsonEqual } from "@lib/core/json.js"; +import { admitStructuredData } from "@lib/core/json.js"; +import { PERSISTENCE_LIMITS } from "@lib/composition/bound-document.js"; +import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js"; + +const composition = { + ...APPLICATION_COMPILED.source, + id: "runtime-test", + version: 91, + nodes: { + value: APPLICATION_COMPILED.source.nodes["fxnode.shader.value"]!, + frame: APPLICATION_COMPILED.source.nodes["fxnode.common.frame"]!, + }, +}; +const runtime = createFxNodeHeadless(composition); +const request = (version: number, command: Parameters[1]["command"]) => ({ + commandId: commandId(`c${version}`), + expectedVersion: version, + source: "api" as const, + command, +}); + +test("bound runtime materializes, edits, resets, undoes, redoes, saves and loads", () => { + assert.equal(runtime.emptyDocument().catalogVersion, 91); + const materialized = runtime.materializeNode("direct", "value"); + assert.equal(materialized.typeId, "value"); + let state = runtime.createEngine(runtime.emptyDocument()); + let result = runtime.transition( + state, + request(0, { type: "node.add", nodeId: nodeId("n"), nodeType: "value", position: { x: 1, y: 2 } }), + ); + assert.equal(result.status, "committed"); + if (result.status !== "committed") return; + state = result.state; + const key = Object.keys(state.document.nodes.n!.parameters)[0]!; + result = runtime.transition( + state, + request(1, { type: "node.parameter", id: nodeId("n"), key, value: { kind: "number", value: 4 } }), + ); + assert.equal(result.status, "committed"); + if (result.status !== "committed") return; + state = result.state; + result = runtime.transition(state, request(2, { type: "node.parameter-reset", id: nodeId("n"), key })); + assert.equal(result.status, "committed"); + if (result.status !== "committed") return; + state = result.state; + result = runtime.transition(state, request(3, { type: "undo" })); + assert.equal(result.status, "committed"); + if (result.status !== "committed") return; + result = runtime.transition(result.state, request(4, { type: "redo" })); + assert.equal(result.status, "committed"); + if (result.status !== "committed") return; + const saved = runtime.save(result.state.document), + decoded = runtime.decodeGraphDocument(saved); + assert.equal(decoded.ok, true); + const loaded = runtime.load(result.state, saved); + assert.equal(loaded.ok, true); + assert.equal( + runtime.serializeGraphDocument(result.state.document), + runtime.serializeGraphDocument(decoded.ok ? decoded.value : result.state.document), + ); +}); + +test("bound runtime rejects unknown add and invalid initial engine", () => { + const state = runtime.createEngine(runtime.emptyDocument()); + const bad = { type: "node.add", nodeId: nodeId("x"), nodeType: "missing", position: { x: 0, y: 0 } } as never; + const result = runtime.transition(state, request(0, bad)); + assert.equal(result.status, "rejected"); + if (result.status === "rejected") assert.equal(result.error.code, "node.type-unknown"); + assert.throws(() => + runtime.createEngine({ + ...runtime.emptyDocument(), + nodes: { x: { ...runtime.materializeNode("x", "value"), size: { x: 0, y: 1 } } }, + }), + ); + assert.throws(() => runtime.createEngine({ ...runtime.emptyDocument(), catalogVersion: 999 })); + assert.throws(() => + runtime.createEngine({ + ...runtime.emptyDocument(), + links: { + wrong: { + id: "actual", + fromNodeId: "a", + fromSocketId: "a:out", + toNodeId: "b", + toSocketId: "b:in", + muted: false, + extensions: {}, + }, + }, + } as never), + ); +}); + +test("graph state decoding is exact for current composition and ignores only observational version", () => { + const document = { + ...runtime.emptyDocument("state-source"), + nodes: { n: runtime.materializeNode("n", "value", { x: 4, y: 5 }) }, + }, + state = runtime.createEngine(document), + snapshot = runtime.getState(state), + input = structuredClone(snapshot), + nested = input.nodes[0]!; + const decoded = runtime.decodeGraphState(input); + assert.equal(decoded.ok, true); + assert.deepEqual(input, snapshot); + assert.equal(Object.isFrozen(input), false); + assert.equal(Object.isFrozen(nested), false); + if (!decoded.ok) return; + assert.equal(decoded.value.nodes.n?.known, true); + assert.ok(Object.isFrozen(decoded.value)); + const spoofed = runtime.decodeGraphState({ ...snapshot, version: Number.MAX_SAFE_INTEGER }); + assert.equal(spoofed.ok, true); + const missingKnown = { ...snapshot, nodes: snapshot.nodes.map(({ known: _, ...node }) => node) }, + wrongKnown = { ...snapshot, nodes: snapshot.nodes.map((node) => ({ ...node, known: false })) }, + foreign = { ...snapshot, catalogVersion: 999 }; + for (const value of [missingKnown, wrongKnown, foreign]) { + const result = runtime.decodeGraphState(value); + assert.equal(result.ok, false); + if (!result.ok) assert.equal(result.issues[0]?.code, "state.inexact"); + } + const malformed = runtime.decodeGraphState({ ...snapshot, extra: true }); + assert.equal(malformed.ok, false); + if (!malformed.ok) assert.equal(malformed.issues[0]?.code, "state.shape"); + const failed = structuredClone(wrongKnown), + failedNested = failed.nodes[0]!; + runtime.decodeGraphState(failed); + assert.equal(Object.isFrozen(failed), false); + assert.equal(Object.isFrozen(failedNested), false); + assert.deepEqual(failed, wrongKnown); + const cycle: any = {}; + cycle.self = cycle; + const sparse: any = structuredClone(snapshot); + sparse.nodes.length = 2; + const nonfinite: any = structuredClone(snapshot); + nonfinite.nodes[0].position.x = Infinity; + const overLimit: any = { ...snapshot, nodes: Array(100_001).fill(null) }; + for (const value of [cycle, sparse, nonfinite, overLimit]) + assert.doesNotThrow(() => assert.equal(runtime.decodeGraphState(value).ok, false)); +}); + +test("maximum admitted durable layout round-trips through getState and state decoding", () => { + const base = runtime.save({ + ...runtime.emptyDocument("state-limit"), + nodes: { n: runtime.materializeNode("n", "value") }, + }), + metrics = admitStructuredData(base, PERSISTENCE_LIMITS); + assert.equal(metrics.ok, true); + if (!metrics.ok) return; + const paddingLength = PERSISTENCE_LIMITS.maxValues - metrics.metrics.values - 1, + layout = { ...base, metadata: { padding: Array(paddingLength).fill(null) } } as typeof base, + admitted = admitStructuredData(layout, PERSISTENCE_LIMITS); + assert.equal(admitted.ok, true); + if (!admitted.ok) return; + assert.equal(admitted.metrics.values, PERSISTENCE_LIMITS.maxValues); + const document = runtime.decodeGraphDocument(layout); + assert.equal(document.ok, true); + if (!document.ok) return; + const state = runtime.getState(runtime.createEngine(document.value)), + decoded = runtime.decodeGraphState(state); + assert.equal(decoded.ok, true); + if (decoded.ok) assert.equal((decoded.value.metadata.padding as readonly unknown[]).length, paddingLength); +}); + +test("state replacement commits once and is one-step undoable and redoable", () => { + const targetDocument = { + ...runtime.emptyDocument("replacement"), + nodes: { n: runtime.materializeNode("n", "value", { x: 7, y: 8 }) }, + }, + target = runtime.getState(runtime.createEngine(targetDocument)); + let state = runtime.createEngine(runtime.emptyDocument("before"), 2); + const replaced = runtime.replaceState(state, { commandId: commandId("replace"), expectedVersion: 0, target }); + assert.equal(replaced.status, "committed"); + if (replaced.status !== "committed") return; + assert.equal(replaced.state.version, 1); + assert.equal(replaced.state.document.graphId, "replacement"); + assert.equal(replaced.state.undo.length, 1); + assert.equal(replaced.mutationEnvelope.cause, "api"); + assert.deepEqual( + replaced.mutationEnvelope.mutations.map((m) => m.kind), + ["document.replaced"], + ); + state = replaced.state; + const undo = runtime.transition(state, request(1, { type: "undo" })); + assert.equal(undo.status, "committed"); + if (undo.status !== "committed") return; + assert.equal(undo.state.version, 2); + assert.equal(undo.state.document.graphId, "before"); + assert.equal(undo.state.redo.length, 1); + const redo = runtime.transition(undo.state, request(2, { type: "redo" })); + assert.equal(redo.status, "committed"); + if (redo.status !== "committed") return; + assert.equal(redo.state.version, 3); + assert.equal(redo.state.document.graphId, "replacement"); + const noop = runtime.replaceState(redo.state, { + commandId: commandId("noop"), + expectedVersion: 3, + target: runtime.getState(redo.state), + }); + assert.equal(noop.status, "noop"); + assert.equal(noop.state, redo.state); + const withoutHistory = runtime.createEngine(runtime.emptyDocument("zero"), 0), + zero = runtime.replaceState(withoutHistory, { commandId: commandId("zero"), expectedVersion: 0, target }); + assert.equal(zero.status, "committed"); + if (zero.status === "committed") assert.equal(zero.state.undo.length, 0); +}); + +test("state replacement checks staleness before inspecting hostile input and noops before overflow", () => { + const state = runtime.createEngine(runtime.emptyDocument()); + let inspections = 0; + const hostile = Object.defineProperty({}, "graphId", { + enumerable: true, + get() { + inspections++; + throw new Error("must not inspect"); + }, + }) as GraphState; + const stale = runtime.replaceState(state, { + commandId: commandId("stale-state"), + expectedVersion: 1, + target: hostile, + }); + assert.equal(stale.status, "rejected"); + assert.equal(stale.state, state); + assert.equal(inspections, 0); + if (stale.status === "rejected") assert.equal(stale.error.code, "version.stale"); + const current = runtime.replaceState(state, { + commandId: commandId("hostile-state"), + expectedVersion: 0, + target: hostile, + }); + assert.equal(current.status, "rejected"); + assert.equal(inspections, 0); + if (current.status === "rejected") assert.equal(current.error.code, "data.inspect"); + const maximum = { ...state, version: Number.MAX_SAFE_INTEGER } as typeof state, + target = runtime.getState(state), + noop = runtime.replaceState(maximum, { + commandId: commandId("maximum-noop"), + expectedVersion: Number.MAX_SAFE_INTEGER, + target, + }); + assert.equal(noop.status, "noop"); + assert.equal(noop.state, maximum); + const changed = { ...target, graphId: "changed" as typeof target.graphId }, + overflow = runtime.replaceState(maximum, { + commandId: commandId("maximum-change"), + expectedVersion: Number.MAX_SAFE_INTEGER, + target: changed, + }); + assert.equal(overflow.status, "rejected"); + assert.equal(overflow.state, maximum); + if (overflow.status === "rejected") assert.equal(overflow.error.code, "version.overflow"); +}); + +test("state replacement preserves bounded prior history, clears redo, and ignores spoofed target version", () => { + const target = runtime.getState( + runtime.createEngine({ + ...runtime.emptyDocument("target"), + nodes: { target: runtime.materializeNode("target", "value") }, + }), + ); + let state = runtime.createEngine(runtime.emptyDocument("history"), 2), + first = runtime.transition( + state, + request(0, { type: "node.add", nodeId: nodeId("a"), nodeType: "value", position: { x: 0, y: 0 } }), + ); + assert.equal(first.status, "committed"); + if (first.status !== "committed") return; + state = first.state; + const second = runtime.transition( + state, + request(1, { type: "node.add", nodeId: nodeId("b"), nodeType: "value", position: { x: 0, y: 0 } }), + ); + assert.equal(second.status, "committed"); + if (second.status !== "committed") return; + const undone = runtime.transition(second.state, request(2, { type: "undo" })); + assert.equal(undone.status, "committed"); + if (undone.status !== "committed") return; + assert.equal(undone.state.redo.length, 1); + const replaced = runtime.replaceState(undone.state, { + commandId: commandId("history-replace"), + expectedVersion: 3, + target: { ...target, version: 999 } as GraphState, + }); + assert.equal(replaced.status, "committed"); + if (replaced.status !== "committed") return; + assert.equal(replaced.state.version, 4); + assert.equal(replaced.state.redo.length, 0); + assert.equal(replaced.state.undo.length, 2); + assert.deepEqual( + replaced.state.undo.at(-1)?.forward.map((m) => m.kind), + ["document.replaced"], + ); + assert.deepEqual( + replaced.state.undo.at(-1)?.inverse.map((m) => m.kind), + ["document.replaced"], + ); + const undoIdentity = replaced.state.undo, + redoIdentity = replaced.state.redo, + entryIdentity = replaced.state.undo[0], + noop = runtime.replaceState(replaced.state, { + commandId: commandId("history-noop"), + expectedVersion: 4, + target: runtime.getState(replaced.state), + }); + assert.equal(noop.status, "noop"); + assert.equal(noop.state.undo, undoIdentity); + assert.equal(noop.state.redo, redoIdentity); + assert.equal(noop.state.undo[0], entryIdentity); + const limited = runtime.createEngine(runtime.emptyDocument("limited"), 1), + added = runtime.transition( + limited, + request(0, { type: "node.add", nodeId: nodeId("old"), nodeType: "value", position: { x: 0, y: 0 } }), + ); + assert.equal(added.status, "committed"); + if (added.status !== "committed") return; + const one = runtime.replaceState(added.state, { + commandId: commandId("limited-replace"), + expectedVersion: 1, + target, + }); + assert.equal(one.status, "committed"); + if (one.status === "committed") { + assert.equal(one.state.undo.length, 1); + assert.deepEqual( + one.state.undo[0]?.forward.map((m) => m.kind), + ["document.replaced"], + ); + } +}); + +test("composition runtimes isolate overlapping IDs; unknown versions roundtrip read-only", () => { + const other = createFxNodeHeadless({ + ...composition, + id: "other", + version: 92, + nodes: { ...composition.nodes, value: { ...composition.nodes.value, title: "Other" } }, + }); + assert.equal(other.materializeNode("a", "value").label, "Other"); + const raw = runtime.save({ ...runtime.emptyDocument(), nodes: { a: runtime.materializeNode("a", "value") } }); + const unknownVersion = { ...raw, nodes: raw.nodes.map((n) => ({ ...n, typeVersion: n.typeVersion + 1 })) }; + const decoded = runtime.decodeGraphDocument(unknownVersion); + assert.equal(decoded.ok, true); + if (!decoded.ok) return; + assert.equal(decoded.value.nodes.a!.known, false); + assert.equal(runtime.parseGraphDocument(runtime.serializeGraphDocument(decoded.value)).ok, true); + let state = runtime.createEngine(decoded.value); + const key = Object.keys(state.document.nodes.a!.parameters)[0]!; + const edit = runtime.transition(state, request(0, { type: "node.parameter-reset", id: nodeId("a"), key })); + assert.equal(edit.status, "rejected"); + const malformed = { ...raw, nodes: raw.nodes.map((n) => ({ ...n, parameters: {} })) }; + assert.equal(runtime.decodeGraphDocument(malformed).ok, false); +}); + +test("foreign catalog versions normalize and future node payloads remain opaque", () => { + const raw = runtime.save({ ...runtime.emptyDocument(), nodes: { a: runtime.materializeNode("a", "value") } }); + const foreign = { ...raw, catalogVersion: 999 }; + const normalized = runtime.decodeGraphDocument(foreign); + assert.equal(normalized.ok, true); + if (!normalized.ok) return; + assert.equal(normalized.value.catalogVersion, 91); + assert.equal(runtime.save(normalized.value).catalogVersion, 91); + const loaded = runtime.load(runtime.createEngine(runtime.emptyDocument()), foreign); + assert.equal(loaded.ok, true); + if (loaded.ok) assert.equal(loaded.snapshotEnvelope.snapshot.catalogVersion, 91); + const future = { + ...raw, + nodes: raw.nodes.map((n) => ({ + ...n, + typeId: "future.node", + typeVersion: 12, + parameters: { future: { history: [] } }, + extensions: { session: { selection: [1] } }, + futureField: { hovered: true }, + sockets: [ + { + ...n.sockets[0]!, + dataType: "future-socket", + accepts: ["future-source"], + defaultValue: null, + metadata: { runtimeVersion: 7 }, + }, + ], + })), + }; + const decoded = runtime.decodeGraphDocument(future); + assert.equal(decoded.ok, true); + if (!decoded.ok) return; + assert.equal(decoded.value.nodes.a!.known, false); + assert.equal(JSON.stringify(runtime.save(decoded.value)), JSON.stringify(future)); +}); + +test("authority rebind preserves graph version for presentation changes and resets history", () => { + const source: FxNodeCompositionData = composition, + candidateSource: FxNodeCompositionData = { ...source, theme: { ...source.theme, background: "#123456" } }, + current = createFxNodeHeadless(source), + candidate = createFxNodeHeadless(candidateSource); + let state = current.createEngine(current.emptyDocument(), 7), + result = current.transition(state, { + commandId: commandId("add"), + expectedVersion: 0, + source: "api", + command: { type: "node.add", nodeId: nodeId("n"), nodeType: "value", position: { x: 0, y: 0 } }, + }); + assert.equal(result.status, "committed"); + if (result.status !== "committed") return; + state = result.state; + assert.equal(state.undo.length, 1); + const rebound = rebindBoundEngineAuthority(state, current, candidate, { commandId: commandId("rebind") }); + assert.equal(rebound.ok, true); + if (!rebound.ok) return; + assert.equal(rebound.graphChanged, false); + assert.equal(rebound.state.version, state.version); + assert.equal(rebound.state.historyLimit, 7); + assert.equal(rebound.state.undo.length, 0); + assert.equal(rebound.state.redo.length, 0); + assert.equal(rebound.state.document.nodes.n!.known, true); + assert.equal(canonicalJsonEqual({ b: 1, a: 2 }, { a: 2, b: 1 }), true); + const left = Object.create(null) as Record, + right = Object.create(null) as Record; + left.__proto__ = { value: 1 }; + right.__proto__ = { value: 2 }; + assert.equal(canonicalJsonEqual(left, right), false); +}); + +test("authority rebind promotes opaque nodes and explicitly removed definitions remain opaque", () => { + const full: FxNodeCompositionData = composition, + withoutFrame: FxNodeCompositionData = { ...full, nodes: { value: full.nodes.value! } }, + old = createFxNodeHeadless(withoutFrame), + candidate = createFxNodeHeadless(full); + const authored = candidate.save({ + ...candidate.emptyDocument(), + nodes: { f: candidate.materializeNode("f", "frame") }, + }), + opaque = old.decodeGraphDocument(authored); + assert.equal(opaque.ok, true); + if (!opaque.ok) return; + assert.equal(opaque.value.nodes.f!.known, false); + const promoted = rebindBoundEngineAuthority(old.createEngine(opaque.value), old, candidate, { + commandId: commandId("promote"), + }); + assert.equal(promoted.ok, true); + if (!promoted.ok || !promoted.graphChanged) return; + assert.equal(promoted.state.version, 1); + assert.equal(promoted.state.document.nodes.f!.known, true); + assert.equal(promoted.mutationEnvelope.cause, "composition"); + assert.equal(promoted.mutationEnvelope.mutations[0]?.kind, "document.replaced"); + assert.deepEqual(old.save(opaque.value), candidate.save(promoted.state.document)); + + const current = createFxNodeHeadless(full), + removed = createFxNodeHeadless(withoutFrame), + known = current.createEngine({ ...current.emptyDocument(), nodes: { f: current.materializeNode("f", "frame") } }); + const rejected = rebindBoundEngineAuthority(known, current, removed, { commandId: commandId("reject") }); + assert.equal(rejected.ok, false); + if (rejected.ok) return; + assert.equal(rejected.state, known); + assert.equal(rejected.issues[0]?.code, "composition.node-demotion"); + const accepted = rebindBoundEngineAuthority(known, current, removed, { + commandId: commandId("remove"), + removedNodeTypes: new Set(["frame"]), + }); + assert.equal(accepted.ok, true); + if (!accepted.ok) return; + assert.equal(accepted.graphChanged, true); + assert.equal(accepted.state.document.nodes.f!.known, false); + assert.equal(accepted.state.document.nodes.f!.id, "f"); +}); + +test("graph-changing authority rebind rejects version overflow atomically", () => { + const full: FxNodeCompositionData = composition, + removedSource: FxNodeCompositionData = { ...full, nodes: { value: full.nodes.value! } }, + current = createFxNodeHeadless(full), + removed = createFxNodeHeadless(removedSource); + const normal = current.createEngine({ + ...current.emptyDocument(), + nodes: { f: current.materializeNode("f", "frame") }, + }), + state = { ...normal, version: Number.MAX_SAFE_INTEGER, undo: [{ forward: [], inverse: [] }] } as typeof normal; + const result = rebindBoundEngineAuthority(state, current, removed, { + commandId: commandId("overflow"), + removedNodeTypes: new Set(["frame"]), + }); + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.state, state); + assert.equal(result.issues[0]?.code, "version.overflow"); + assert.equal(result.state.undo.length, 1); +}); diff --git a/vendor/fxnode/test/import-boundary.test.ts b/vendor/fxnode/test/import-boundary.test.ts new file mode 100644 index 0000000..91750e8 --- /dev/null +++ b/vendor/fxnode/test/import-boundary.test.ts @@ -0,0 +1,36 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile, readdir } from "node:fs/promises"; +import { extname, join } from "node:path"; +test("browser entry does not import the engine runtime", async () => { + const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8"); + assert.doesNotMatch(source, /headless|engine\/engine|core\/document/); + assert.match(source, /browser\/client/); +}); + +test("examples server explicitly loads the repository Vite config", async () => { + const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) as { + scripts?: { example?: string; examples?: string }; + }; + assert.equal(packageJson.scripts?.example, "npm run examples"); + assert.match(packageJson.scripts?.examples ?? "", /vite\s+--config\s+vite\.config\.ts\s+examples(?:\s|$)/); +}); + +test("standalone examples import library types and values only through the public entrypoint", async () => { + const root = new URL("../examples/", import.meta.url); + const directories = ["shared", "minimal", "color-balance", "live-composition"]; + const files: string[] = []; + async function collect(directory: string): Promise { + for (const entry of await readdir(new URL(directory, root), { withFileTypes: true })) { + const relative = join(directory, entry.name); + if (entry.isDirectory()) await collect(`${relative}/`); + else if ([".ts", ".tsx", ".js", ".mjs"].includes(extname(entry.name))) files.push(relative); + } + } + await Promise.all(directories.map((directory) => collect(`${directory}/`))); + for (const file of files) { + const source = await readFile(new URL(file, root), "utf8"); + const imports = source.matchAll(/(?:from\s*|import\s*)["'](@lib\/[^"']+)["']/g); + for (const match of imports) assert.equal(match[1], "@lib/index.js", `${file} bypasses the public entrypoint`); + } +}); diff --git a/vendor/fxnode/test/knife-mute.test.ts b/vendor/fxnode/test/knife-mute.test.ts new file mode 100644 index 0000000..7588a83 --- /dev/null +++ b/vendor/fxnode/test/knife-mute.test.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { appendKnifePoint, crossedLinks, MAX_KNIFE_POINTS, segmentsIntersect } from "@lib/worker/knife-path.js"; +import { effectivelyMutedLinks } from "@lib/layout/link-mute.js"; +import { layoutGraph as genericLayoutGraph } from "@lib/layout/layout-graph.js"; +import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js"; +const layoutGraph = ( + document: Parameters[1], + transform: Parameters[2], +) => genericLayoutGraph(APPLICATION_COMPILED, document, transform); +import { worldToView } from "@lib/layout/geometry.js"; +const { emptyDocument, materializeNode } = APPLICATION_HEADLESS; + +test("knife intersection handles directions, dedupes links, candidates, mute and point cap", () => { + assert.equal(segmentsIntersect({ x: 0, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }, { x: 10, y: 0 }), true); + assert.equal(segmentsIntersect({ x: 10, y: 10 }, { x: 0, y: 0 }, { x: 0, y: 10 }, { x: 10, y: 0 }), true); + assert.equal(segmentsIntersect({ x: 0, y: 0 }, { x: 2, y: 0 }, { x: 3, y: 0 }, { x: 5, y: 0 }), false); + let points: readonly { x: number; y: number }[] = []; + for (let i = 0; i < 300; i++) points = appendKnifePoint(points, { x: i * 3, y: 0 }); + assert.equal(points.length, MAX_KNIFE_POINTS); + const source = materializeNode("source", "fxnode.shader.value", { x: -200, y: 50 }), + target = materializeNode("target", "fxnode.shader.math", { x: 100, y: 50 }); + const base = emptyDocument(); + const document = { + ...base, + nodes: { source, target }, + links: { + wire: { + id: "wire", + fromNodeId: "source", + fromSocketId: source.sockets[0]!.id, + toNodeId: "target", + toSocketId: target.sockets[0]!.id, + muted: false, + }, + }, + } as never; + const layout = layoutGraph(document, { center: { x: 0, y: 0 }, zoom: 1, viewport: { x: 1200, y: 640 }, dpr: 1 }); + const link = [...layout.links.values()].find((item) => item.visible && !item.muted)!; + const middle = link.points[Math.floor(link.points.length / 2)]!; + const view = worldToView(middle, layout.transform); + const found = crossedLinks(layout, [ + { x: view.x, y: view.y - 100 }, + { x: view.x, y: view.y + 100 }, + ]); + assert.equal(found.has(link.id), true); + assert.equal(found.size, 1); +}); + +test("reroute effective mute propagates down chains and branches without changing authored flags", () => { + const doc = { + nodes: { + a: { id: "a", typeId: "x", known: false, sockets: [] }, + r: { id: "r", typeId: "fxnode.common.reroute", known: true, sockets: [] }, + b: { id: "b", typeId: "x", known: false, sockets: [] }, + }, + links: { + one: { id: "one", fromNodeId: "a", toNodeId: "r", muted: true }, + two: { id: "two", fromNodeId: "r", toNodeId: "b", muted: false }, + three: { id: "three", fromNodeId: "r", toNodeId: "a", muted: false }, + }, + } as never; + assert.deepEqual([...effectivelyMutedLinks(APPLICATION_COMPILED, doc)].sort(), ["one", "three", "two"]); + assert.equal((doc as any).links.two.muted, false); +}); diff --git a/vendor/fxnode/test/layout.test.ts b/vendor/fxnode/test/layout.test.ts new file mode 100644 index 0000000..ecf104a --- /dev/null +++ b/vendor/fxnode/test/layout.test.ts @@ -0,0 +1,807 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js"; +import { commandId, nodeId } from "@lib/core/types.js"; +import { layoutGraph as genericLayoutGraph } from "@lib/layout/layout-graph.js"; +import { viewToWorld, worldToView } from "@lib/layout/geometry.js"; +const layoutGraph = (document: any, transform: Parameters[2]) => + genericLayoutGraph(APPLICATION_COMPILED, document as never, transform); +const { createEngine, emptyDocument, materializeNode, transition, socketsCompatible } = APPLICATION_HEADLESS; +const BUILTIN_DESCRIPTORS = [...APPLICATION_COMPILED.nodes.values()]; +import type { GraphDocument } from "@lib/headless.js"; +import { applyNodeOrder } from "@lib/layout/layout-graph.js"; +import { + boxNodes, + clampResize, + compatibleTargets, + frameDropCandidate, + groupRoots, + hitRamp, + hitTest, + planLink, + zoomAt, +} from "@lib/worker/interaction.js"; + +const transform = { center: { x: 0, y: 0 }, zoom: 2, viewport: { x: 800, y: 600 }, dpr: 1 }; +test("world/view transforms round trip with +Y up", () => { + const point = { x: 17, y: -23 }; + assert.deepEqual(viewToWorld(worldToView(point, transform), transform), point); + assert.ok(worldToView({ x: 0, y: 1 }, transform).y < worldToView({ x: 0, y: 0 }, transform).y); +}); +test("all catalog types lay out deterministically", () => { + let state = createEngine(emptyDocument()); + for (const [index, descriptor] of BUILTIN_DESCRIPTORS.entries()) { + const result = transition(state, { + commandId: commandId(String(index)), + expectedVersion: state.version, + source: "api", + command: { + type: "node.add", + nodeId: nodeId(`n${index}`), + nodeType: descriptor.typeId, + position: { x: index * 10, y: 0 }, + }, + }); + assert.notEqual(result.status, "rejected"); + if (result.status === "committed") state = result.state; + } + const a = layoutGraph(state.document, transform), + b = layoutGraph(state.document, transform); + assert.equal(a.nodes.size, BUILTIN_DESCRIPTORS.length); + assert.deepEqual([...a.drawOrder], [...b.drawOrder]); + const expected = [ + ["fxnode.common.frame", "frame", "common", [], [100, 100], [300, 100]], + ["fxnode.common.reroute", "reroute", "common", ["socket:input:input:1"], [10, 10], [10, 10]], + [ + "fxnode.shader.texture-coordinate", + "node", + "shader", + ["socket:output:generated:1", "socket:output:normal:1", "socket:output:uv:1", "socket:output:object:1"], + [192, 124], + [192, 124], + ], + [ + "fxnode.shader.noise-texture", + "node", + "texture", + [ + "control:enum:dimensions:1", + "control:enum:noiseType:1", + "control:boolean:normalize:1", + "socket:input:vector:1", + "socket:input:scale:1", + "socket:input:detail:1", + "socket:input:roughness:1", + "socket:input:lacunarity:1", + "socket:input:distortion:1", + "socket:output:factor:1", + "socket:output:color:1", + ], + [286, 292], + [286, 292], + ], + [ + "fxnode.shader.image-texture", + "node", + "input", + [ + "control:resource:image:4", + "control:enum:interpolation:1", + "control:enum:projection:1", + "control:enum:extension:1", + "control:enum:colorSpace:1", + "control:enum:alphaMode:1", + "socket:input:vector:1", + "socket:output:color:1", + "socket:output:alpha:1", + ], + [257, 316], + [257, 316], + ], + [ + "fxnode.shader.principled-bsdf", + "node", + "shader", + [ + "socket:input:base-color:1", + "socket:input:metallic:1", + "socket:input:roughness:1", + "socket:input:ior:1", + "socket:input:alpha:1", + "socket:input:normal:1", + "socket:output:bsdf:1", + ], + [208, 196], + [208, 196], + ], + [ + "fxnode.shader.material-output", + "node", + "output", + ["socket:input:surface:1", "socket:input:volume:1", "socket:input:displacement:1"], + [340, 100], + [340, 100], + ], + ["fxnode.geometry.position", "node", "geometry", ["socket:output:position:1"], [175, 52], [175, 100]], + [ + "fxnode.geometry.mesh-cube", + "node", + "geometry", + [ + "socket:input:size:1", + "socket:input:vertices-x:1", + "socket:input:vertices-y:1", + "socket:input:vertices-z:1", + "socket:output:mesh:1", + ], + [340, 148], + [340, 148], + ], + [ + "fxnode.geometry.set-position", + "node", + "geometry", + ["socket:input:geometry:1", "socket:input:position:1", "socket:input:offset:1", "socket:output:result:1"], + [340, 124], + [340, 124], + ], + [ + "fxnode.geometry.transform-geometry", + "node", + "geometry", + [ + "socket:input:geometry:1", + "socket:input:translation:1", + "socket:input:rotation:1", + "socket:input:scale:1", + "socket:output:result:1", + ], + [340, 148], + [340, 148], + ], + [ + "fxnode.geometry.join-geometry", + "node", + "geometry", + ["socket:input:geometry:1", "socket:output:result:1"], + [175, 76], + [175, 100], + ], + [ + "fxnode.common.group-input", + "node", + "input", + ["control:string:interfaceName:1", "socket:output:output:1"], + [257, 76], + [257, 100], + ], + [ + "fxnode.compositor.image", + "node", + "compositorInput", + [ + "control:resource:image:4", + "control:enum:source:1", + "socket:output:image:1", + "socket:output:alpha:1", + "socket:output:z:1", + ], + [176, 220], + [176, 220], + ], + [ + "fxnode.compositor.color-balance", + "node", + "compositorColor", + [ + "control:enum:mode:1", + "grading-wheels:Lift:number:lift:color:liftColor,Gamma:number:gamma:color:gammaColor,Gain:number:gain:color:gainColor:7", + "socket:input:image:1", + "socket:input:factor:1", + "socket:output:result:1", + ], + [400, 292], + [400, 292], + ], + [ + "fxnode.common.group-output", + "node", + "output", + ["control:string:interfaceName:1", "socket:input:input:1"], + [257, 76], + [257, 100], + ], + [ + "fxnode.shader.value", + "node", + "shader", + ["control:number:value:1", "socket:output:value:1"], + [151, 76], + [151, 100], + ], + [ + "fxnode.shader.color", + "node", + "shader", + ["control:color:color:1", "socket:output:color:1"], + [151, 76], + [151, 100], + ], + [ + "fxnode.shader.math", + "node", + "converter", + [ + "control:enum:operation:1", + "control:boolean:clamp:1", + "socket:input:a:1", + "socket:input:b:1", + "socket:output:value:1", + ], + [192, 148], + [192, 148], + ], + [ + "fxnode.shader.vector-math", + "node", + "converter", + [ + "control:enum:operation:1", + "socket:input:a:1", + "socket:input:b:1", + "socket:output:vector:1", + "socket:output:value:1", + ], + [340, 148], + [340, 148], + ], + [ + "fxnode.shader.mix", + "node", + "shader", + [ + "control:enum:blend:1", + "control:boolean:clamp:1", + "socket:input:factor:1", + "socket:input:a:1", + "socket:input:b:1", + "socket:output:result:1", + ], + [151, 172], + [151, 172], + ], + [ + "fxnode.shader.color-ramp", + "node", + "shader", + ["socket:output:color:1", "socket:output:alpha:1", "control:color-ramp:ramp:8", "socket:input:factor:1"], + [320, 292], + [320, 292], + ], + ] as Array<[string, string, string, string[], [number, number], [number, number]]>; + const layoutByType = new Map([...a.nodes.values()].map((node) => [node.typeId, node])); + const aggregate = expected.map(([typeId]) => { + const node = layoutByType.get(typeId)!; + const sourceNode = state.document.nodes[node.id]!; + return { + typeId: node.typeId, + kind: node.kind, + category: node.styleId, + rows: node.rows.map((row) => { + if (row.kind === "control") { + const control = a.controls.get(row.controlId)!; + return `control:${control.kind}:${control.key}:${row.units}`; + } + if (row.kind === "socket") { + const socket = sourceNode.sockets.find((item) => item.id === row.socketId)!; + return `socket:${socket.direction}:${socket.key}:${row.units}`; + } + if (row.kind === "grading-wheels") { + const wheels = row.wheels.map((wheel) => { + const scalar = a.controls.get(wheel.scalarControlId)!; + const color = a.controls.get(wheel.colorControlId)!; + return `${wheel.label}:${scalar.kind}:${scalar.key}:${color.kind}:${color.key}`; + }); + return `grading-wheels:${wheels.join(",")}:${row.units}`; + } + return `${row.kind}:${row.label}:${row.units}`; + }), + minimum: node.minimumSize, + effective: { x: node.bounds.width, y: node.bounds.height }, + }; + }); + const expectedLayout = expected.map( + ([typeId, kind, category, rows, [minimumX, minimumY], [effectiveX, effectiveY]]) => ({ + typeId, + kind, + category, + rows, + minimum: { x: minimumX, y: minimumY }, + effective: { x: effectiveX, y: effectiveY }, + }), + ); + assert.deepEqual(aggregate, expectedLayout); +}); + +test("socket compatibility and exact theme palettes are stable", () => { + const types = ["float", "vector", "color", "shader", "geometry", "any"] as const; + const matrix = types.map((output) => + types.map((input) => + socketsCompatible( + { direction: "output", dataType: output }, + { direction: "input", dataType: input, accepts: input === "any" ? ["any"] : [input, "any"] }, + ), + ), + ); + assert.deepEqual(matrix, [ + [true, false, false, false, false, true], + [false, true, false, false, false, true], + [false, false, true, false, false, true], + [false, false, false, true, false, true], + [false, false, false, false, true, true], + [true, true, true, true, true, true], + ]); + assert.equal( + socketsCompatible( + { direction: "input", dataType: "float" }, + { direction: "input", dataType: "float", accepts: ["float"] }, + ), + false, + ); + assert.equal( + socketsCompatible( + { direction: "output", dataType: "float" }, + { direction: "output", dataType: "float", accepts: [] }, + ), + false, + ); + assert.deepEqual(Object.fromEntries([...APPLICATION_COMPILED.styles].map(([id, style]) => [id, style.header])), { + input: "#8b3f72", + converter: "#4f5964", + texture: "#a36b34", + shader: "#3b7551", + output: "#963d3d", + geometry: "#2c7a75", + common: "#555b64", + compositorInput: "#8b3f72", + compositorColor: "#4f5964", + }); + assert.deepEqual(Object.fromEntries([...APPLICATION_COMPILED.socketTypes].map(([id, type]) => [id, type.color])), { + float: "#a8a8a8", + vector: "#6476dc", + color: "#d7ca63", + shader: "#62b34f", + geometry: "#00bfa5", + any: "#999999", + }); +}); + +test("mute bypass map and endpoint anchors are exact", () => { + const expected = [ + ["fxnode.common.reroute", [["input", "output"]]], + ["fxnode.shader.math", [["a", "value"]]], + ["fxnode.shader.vector-math", [["a", "vector"]]], + ["fxnode.shader.mix", [["a", "result"]]], + ["fxnode.geometry.set-position", [["geometry", "result"]]], + ["fxnode.geometry.transform-geometry", [["geometry", "result"]]], + ["fxnode.compositor.color-balance", [["image", "result"]]], + ] as const; + assert.deepEqual( + BUILTIN_DESCRIPTORS.filter((item) => item.muteBypass.length).map((item) => [item.typeId, item.muteBypass]), + expected, + ); + for (const [typeId, pairs] of expected) { + const node = { ...materializeNode("muted", typeId, { x: 0, y: 0 }), muted: true }; + const document: GraphDocument = { ...emptyDocument("mute"), nodes: { muted: node } }; + const layout = layoutGraph(document, transform), + placed = layout.nodes.get(nodeId("muted"))!; + assert.equal(placed.bypasses.length, pairs.length); + for (const [index, [from, to]] of pairs.entries()) { + const fromSocket = node.sockets.find((socket) => socket.key === from)!; + const toSocket = node.sockets.find((socket) => socket.key === to)!; + assert.deepEqual(placed.bypasses[index], { + from: layout.sockets.get(fromSocket.id)!.anchor, + to: layout.sockets.get(toSocket.id)!.anchor, + }); + } + } +}); +test("expanded, collapsed, reroute and links have pinned geometry", () => { + const value = materializeNode("value", "fxnode.shader.value", { x: -100, y: 20 }); + const math = materializeNode("math", "fxnode.shader.math", { x: 100, y: 20 }); + const link = { + id: "link", + fromNodeId: "value", + fromSocketId: "value:value", + toNodeId: "math", + toSocketId: "math:a", + extensions: {}, + }; + const raw = { + schemaVersion: 1, + graphId: "layout", + catalogVersion: 1, + nodes: { value, math }, + links: { link }, + metadata: {}, + } as unknown as Parameters[0]; + const snapshot = layoutGraph(raw, transform); + assert.equal(snapshot.links.values().next().value?.points.length, 13); + const valueLayout = snapshot.nodes.get(nodeId("value"))!; + assert.equal(valueLayout.bounds.width, valueLayout.minimumSize.x); + assert.ok(valueLayout.bounds.width >= 140); + assert.equal(snapshot.sockets.size, 4); + assert.equal((snapshot.controls.get("value:parameter:value")?.value as { value: number }).value, 0); + assert.equal((snapshot.controls.get("math:parameter:operation")?.value as { value: string }).value, "add"); + const mathLayout = snapshot.nodes.get(nodeId("math"))!; + assert.ok(mathLayout.bounds.height >= 126); + assert.equal(mathLayout.collapseHitRect.x, mathLayout.bounds.x); + assert.equal(mathLayout.collapseHitRect.width, 14); + assert.deepEqual( + hitTest(snapshot, worldToView({ x: mathLayout.bounds.x + 10, y: mathLayout.bounds.y - 12 }, snapshot.transform)), + { kind: "collapse", id: nodeId("math") }, + ); + assert.deepEqual( + hitTest(snapshot, worldToView({ x: mathLayout.bounds.x + 22, y: mathLayout.bounds.y - 12 }, snapshot.transform)), + { kind: "node", id: nodeId("math") }, + ); + assert.equal(snapshot.controls.get("math:socket:math:a")?.linked, true, "linked inputs hide controls"); +}); + +test("frames have labelled fitted bounds around parent-local children", () => { + const frame = { + ...materializeNode("frame", "fxnode.common.frame", { x: -200, y: 200 }), + label: "Surface Controls", + size: { x: 100, y: 100 }, + }; + const child = { ...materializeNode("child", "fxnode.shader.value", { x: 30, y: -50 }), parentId: "frame" }; + const raw = { + schemaVersion: 1, + graphId: "frame-layout", + catalogVersion: 1, + nodes: { frame, child }, + links: {}, + metadata: {}, + } as unknown as Parameters[0]; + const snapshot = layoutGraph(raw, transform), + frameLayout = snapshot.nodes.get(nodeId("frame"))!, + childLayout = snapshot.nodes.get(nodeId("child"))!; + assert.equal(frameLayout.label, "Surface Controls"); + assert.ok(frameLayout.bounds.x <= childLayout.bounds.x - 30); + assert.ok(frameLayout.bounds.x + frameLayout.bounds.width >= childLayout.bounds.x + childLayout.bounds.width + 30); + assert.ok(frameLayout.bounds.y >= childLayout.bounds.y + 30); + assert.ok(frameLayout.bounds.y - frameLayout.bounds.height <= childLayout.bounds.y - childLayout.bounds.height - 30); +}); +test("socket compatibility does not treat accepts any as wildcard", () => { + const output = { direction: "output" as const, dataType: "float" as const }; + assert.equal(socketsCompatible(output, { direction: "input", dataType: "vector", accepts: ["any"] }), false); + assert.equal(socketsCompatible(output, { direction: "input", dataType: "any", accepts: [] }), true); +}); +test("wheel zoom preserves world point and group roots omit selected descendants", () => { + const cursor = { x: 123, y: 234 }, + before = viewToWorld(cursor, transform), + next = zoomAt(transform, cursor, 120), + after = viewToWorld(cursor, { ...transform, ...next }); + assert.ok(Math.abs(before.x - after.x) < 1e-9); + assert.ok(Math.abs(before.y - after.y) < 1e-9); + const frame = { ...materializeNode("f", "fxnode.common.frame", { x: 0, y: 0 }) }, + child = { ...materializeNode("c", "fxnode.shader.value", { x: 10, y: -10 }), parentId: nodeId("f") }; + const doc = { + schemaVersion: 1, + graphId: "g", + catalogVersion: 1, + nodes: { f: frame, c: child }, + links: {}, + metadata: {}, + } as unknown as Parameters[0]; + assert.deepEqual(groupRoots(new Set([nodeId("f"), nodeId("c")]), layoutGraph(doc, transform)), [nodeId("f")]); +}); +test("transient node order controls overlapping paint and hit order", () => { + const a = materializeNode("a", "fxnode.shader.value", { x: 0, y: 0 }), + b = materializeNode("b", "fxnode.shader.value", { x: 0, y: 0 }), + doc = { + schemaVersion: 1, + graphId: "z", + catalogVersion: 1, + nodes: { a, b }, + links: {}, + metadata: {}, + } as unknown as Parameters[0]; + const original = layoutGraph(doc, { ...transform, zoom: 1 }), + raised = applyNodeOrder(original, [nodeId("b"), nodeId("a")]), + point = worldToView({ x: 40, y: -20 }, raised.transform); + assert.deepEqual(raised.drawOrder.slice(-2), [nodeId("b"), nodeId("a")]); + assert.deepEqual(hitTest(raised, point), { kind: "node", id: nodeId("a") }); + const shifted = { ...b, position: { x: 30, y: 0 } }, + overlap = applyNodeOrder(layoutGraph({ ...doc, nodes: { a, b: shifted } }, { ...transform, zoom: 1 }), [ + nodeId("a"), + nodeId("b"), + ]); + assert.deepEqual(hitTest(overlap, worldToView({ x: 35, y: -36 }, overlap.transform)), { + kind: "node", + id: nodeId("b"), + }); +}); +test("reroute core starts links while its outer halo selects the node", () => { + const reroute = materializeNode("r", "fxnode.common.reroute", { x: 0, y: 0 }), + doc = { + schemaVersion: 1, + graphId: "reroute", + catalogVersion: 1, + nodes: { r: reroute }, + links: {}, + metadata: {}, + } as unknown as Parameters[0], + layout = layoutGraph(doc, { ...transform, zoom: 1 }), + node = layout.nodes.get(nodeId("r"))!, + center = worldToView({ x: node.bounds.x + 5, y: node.bounds.y - 5 }, layout.transform); + assert.equal(hitTest(layout, center, "output").kind, "socket"); + assert.deepEqual(hitTest(layout, { x: center.x + 11, y: center.y }), { kind: "node", id: nodeId("r") }); +}); +test("interaction helpers hit sampled links, box nodes, plan replacement and clamp resize", () => { + const value = materializeNode("v", "fxnode.shader.value", { x: -100, y: 50 }), + math = materializeNode("m", "fxnode.shader.math", { x: 100, y: 50 }); + const old = { id: "old", fromNodeId: "v", fromSocketId: "v:value", toNodeId: "m", toSocketId: "m:a", extensions: {} }; + const doc = { + schemaVersion: 1, + graphId: "i", + catalogVersion: 1, + nodes: { v: value, m: math }, + links: { old }, + metadata: {}, + } as unknown as Parameters[0], + layout = layoutGraph(doc, { ...transform, zoom: 1 }); + const link = layout.links.values().next().value!, + mid = worldToView(link.points[6]!, layout.transform); + assert.deepEqual(hitTest(layout, mid), { kind: "link", id: "old" }); + const v = layout.nodes.get(nodeId("v"))!; + assert.deepEqual( + boxNodes( + layout, + worldToView({ x: v.bounds.x - 1, y: v.bounds.y + 1 }, layout.transform), + worldToView({ x: v.bounds.x + v.bounds.width + 1, y: v.bounds.y - v.bounds.height - 1 }, layout.transform), + ), + [nodeId("v")], + ); + assert.equal( + compatibleTargets(layout, "v:value" as never).some((s) => s.id === "m:a"), + true, + ); + assert.equal(planLink(layout, "v:value" as never, "m:a" as never)?.type, "link.replace"); + assert.deepEqual(clampResize(layout, nodeId("m"), { x: 10000, y: 10000 }), { + x: 700, + y: layout.nodes.get(nodeId("m"))!.minimumSize.y, + }); +}); +test("frame drop picks containing frame and rejects self", () => { + const frame = materializeNode("f", "fxnode.common.frame", { x: -200, y: 200 }), + node = materializeNode("n", "fxnode.shader.value", { x: 0, y: 0 }); + const doc = { + schemaVersion: 1, + graphId: "d", + catalogVersion: 1, + nodes: { f: frame, n: node }, + links: {}, + metadata: {}, + } as unknown as Parameters[0], + layout = layoutGraph(doc, transform); + assert.equal(frameDropCandidate(layout, nodeId("n"), { x: -100, y: 100 }), nodeId("f")); + assert.equal(frameDropCandidate(layout, nodeId("f"), { x: -100, y: 100 }), undefined); +}); +test("Color Ramp authoritative bounds resolve every interaction region", () => { + const node = materializeNode("r", "fxnode.shader.color-ramp", { x: 0, y: 0 }), + doc = { + schemaVersion: 1, + graphId: "r", + catalogVersion: 1, + nodes: { r: node }, + links: {}, + metadata: {}, + } as unknown as Parameters[0], + layout = layoutGraph(doc, { ...transform, zoom: 1 }), + control = layout.controls.get("r:parameter:ramp")!, + b = control.rampBounds!, + center = (r: typeof b.toolbar) => ({ x: r.x + r.width / 2, y: r.y - r.height / 2 }); + assert.equal(hitRamp(control, { x: b.toolbar.x + 1, y: b.toolbar.y - 10 })?.target, "add"); + assert.equal(hitRamp(control, { x: b.toolbar.x + b.toolbar.width * 0.18, y: b.toolbar.y - 10 })?.target, "remove"); + assert.equal(hitRamp(control, { x: b.toolbar.x + b.toolbar.width * 0.4, y: b.toolbar.y - 10 })?.target, "flip"); + assert.equal(hitRamp(control, { x: b.toolbar.x + b.toolbar.width * 0.8, y: b.toolbar.y - 10 })?.target, "distribute"); + assert.equal(hitRamp(control, center(b.mode))?.target, "mode"); + assert.equal(hitRamp(control, center(b.interpolation))?.target, "interpolation"); + assert.equal(hitRamp(control, center(b.hue))?.target, "hue"); + assert.equal(hitRamp(control, center(b.gradient))?.target, "gradient"); + assert.equal(hitRamp(control, center(b.selector))?.target, "selector"); + assert.equal(hitRamp(control, center(b.position))?.target, "position"); + for (const x of [0.05, 0.5, 0.95]) + assert.equal(hitRamp(control, { x: b.color.x + b.color.width * x, y: b.color.y - 10 })?.target, "swatch"); +}); + +test("Color Balance owns three disjoint Blender-style grading wheels", () => { + const node = materializeNode("balance", "fxnode.compositor.color-balance", { x: 0, y: 0 }), + doc = { + schemaVersion: 1, + graphId: "balance", + catalogVersion: 1, + nodes: { balance: node }, + links: {}, + metadata: {}, + } as unknown as Parameters[0], + layout = layoutGraph(doc, { ...transform, zoom: 1 }), + placed = layout.nodes.get(nodeId("balance"))!, + row = placed.rows.find((item) => item.kind === "grading-wheels"); + assert.ok(row && row.kind === "grading-wheels"); + assert.deepEqual( + row.wheels.map((wheel) => wheel.label), + ["Lift", "Gamma", "Gain"], + ); + assert.ok(placed.minimumSize.x >= 400); + assert.equal(row.units, 7); + for (const wheel of row.wheels) { + const color = layout.controls.get(wheel.colorControlId)!, + scalar = layout.controls.get(wheel.scalarControlId)!, + bounds = color.colorWheelBounds!; + assert.equal(bounds.plane.width, bounds.plane.height); + assert.ok(bounds.lightness.x >= bounds.plane.x + bounds.plane.width); + for (const [region, rect] of Object.entries(bounds) as ["plane" | "lightness", typeof bounds.plane][]) + assert.deepEqual( + hitTest(layout, worldToView({ x: rect.x + rect.width / 2, y: rect.y - rect.height / 2 }, layout.transform)), + { kind: "color-wheel", id: color.id, region }, + ); + assert.equal( + hitTest( + layout, + worldToView( + { x: scalar.bounds.x + scalar.bounds.width / 2, y: scalar.bounds.y - scalar.bounds.height / 2 }, + layout.transform, + ), + ).kind, + "control", + ); + } + for (let i = 1; i < row.wheels.length; i++) { + const prior = layout.controls.get(row.wheels[i - 1]!.colorControlId)!.bounds, + next = layout.controls.get(row.wheels[i]!.colorControlId)!.bounds; + assert.ok(next.x >= prior.x + prior.width); + } + const collapsed = layoutGraph( + { ...doc, nodes: { balance: { ...node, collapsed: true } } }, + { ...transform, zoom: 1 }, + ).nodes.get(nodeId("balance"))!; + assert.equal(collapsed.bounds.width, placed.bounds.width); + assert.equal(collapsed.bounds.height, 24); +}); + +test("compound and component controls have bounded, disjoint layout cells", () => { + const types = [ + ["r", "fxnode.shader.color-ramp"], + ["i", "fxnode.shader.image-texture"], + ["g", "fxnode.compositor.color-balance"], + ] as const; + const nodes = Object.fromEntries( + types.map(([id, type], index) => [id, materializeNode(id, type, { x: index * 500, y: 0 })]), + ); + const doc = { + schemaVersion: 1, + graphId: "cells", + catalogVersion: 1, + nodes, + links: {}, + metadata: {}, + } as unknown as Parameters[0], + layout = layoutGraph(doc, transform); + const inside = ( + outer: { x: number; y: number; width: number; height: number }, + inner: { x: number; y: number; width: number; height: number }, + ) => + inner.x >= outer.x && + inner.x + inner.width <= outer.x + outer.width && + inner.y <= outer.y && + inner.y - inner.height >= outer.y - outer.height; + for (const control of layout.controls.values()) { + const node = layout.nodes.get(control.nodeId)!; + assert.ok(inside(node.bounds, control.bounds), `${control.id} is inside its node`); + for (let index = 1; index < control.subfields.length; index++) + assert.ok( + control.subfields[index]!.bounds.x - + (control.subfields[index - 1]!.bounds.x + control.subfields[index - 1]!.bounds.width) >= + 2, + "component gutter", + ); + } + const ramp = layout.controls.get("r:parameter:ramp")!, + rampRow = layout.nodes.get(nodeId("r"))!.rows.find((row) => row.kind === "control")!; + for (const row of layout.nodes.get(nodeId("r"))!.rows.filter((row) => row.kind === "socket")) + assert.ok( + row.bounds.y - row.bounds.height >= rampRow.bounds.y || rampRow.bounds.y - rampRow.bounds.height >= row.bounds.y, + "ramp and sockets disjoint", + ); + const ordinary = [layout.controls.get("i:parameter:interpolation")!, layout.controls.get("i:parameter:projection")!], + factor = layout.controls.get("g:socket:g:factor")!; + assert.ok([...ordinary, factor].every((control) => control.bounds.height === 18)); + assert.ok( + ordinary.every( + (control) => + Math.abs( + (control.bounds.x - layout.nodes.get(control.nodeId)!.bounds.x) / + layout.nodes.get(control.nodeId)!.bounds.width - + 0.42, + ) < 1e-9, + ), + ); + assert.equal(factor.bounds.x - layout.nodes.get(factor.nodeId)!.bounds.x, 12); +}); + +test("numeric fields expose Blender-style range fill and step geometry", () => { + const principled = materializeNode("p", "fxnode.shader.principled-bsdf", { x: 0, y: 0 }), + valueNode = materializeNode("v", "fxnode.shader.value", { x: 500, y: 0 }), + doc = { + schemaVersion: 1, + graphId: "numeric-fields", + catalogVersion: 1, + nodes: { p: principled, v: valueNode }, + links: {}, + metadata: {}, + } as unknown as Parameters[0], + layout = layoutGraph(doc, transform), + roughness = layout.controls.get("p:socket:p:roughness")!.numericFields[0]!, + unbounded = layout.controls.get("v:parameter:value")!.numericFields[0]!; + assert.deepEqual(roughness.range, { minimum: 0, maximum: 1 }); + assert.equal(unbounded.range, undefined); + assert.equal(roughness.decrement.width, 7); + assert.equal(roughness.increment.width, 7); + assert.ok(roughness.value.x >= roughness.decrement.x + roughness.decrement.width); + assert.ok(roughness.increment.x >= roughness.value.x + roughness.value.width); +}); + +test("color controls are compact swatches rather than RGBA fields", () => { + const colorNode = materializeNode("c", "fxnode.shader.color", { x: 0, y: 0 }), + principled = materializeNode("p", "fxnode.shader.principled-bsdf", { x: 300, y: 0 }), + doc = { + schemaVersion: 1, + graphId: "swatches", + catalogVersion: 1, + nodes: { c: colorNode, p: principled }, + links: {}, + metadata: {}, + } as unknown as Parameters[0], + layout = layoutGraph(doc, transform), + parameter = layout.controls.get("c:parameter:color")!, + socket = layout.controls.get("p:socket:p:base-color")!; + for (const control of [parameter, socket]) { + assert.equal(control.kind, "color"); + assert.equal(control.subfields.length, 0); + assert.equal(control.numericFields.length, 0); + assert.equal( + hitTest( + layout, + worldToView( + { x: control.bounds.x + control.bounds.width / 2, y: control.bounds.y - control.bounds.height / 2 }, + layout.transform, + ), + ).kind, + "control", + ); + } + assert.equal(layout.nodes.get(nodeId("c"))!.minimumSize.x, 151); + assert.equal(layout.nodes.get(nodeId("p"))!.bounds.width, 208); +}); + +test("resource previews and open buttons are authoritative worker hit targets", () => { + const image = materializeNode("i", "fxnode.shader.image-texture", { x: 0, y: 0 }), + document = { + schemaVersion: 1, + graphId: "resource-hits", + catalogVersion: 1, + nodes: { i: image }, + links: {}, + metadata: {}, + } as unknown as Parameters[0], + layout = layoutGraph(document, transform), + control = layout.controls.get("i:parameter:image")!, + bounds = control.resourceBounds!, + center = (rect: typeof bounds.preview) => + worldToView({ x: rect.x + rect.width / 2, y: rect.y - rect.height / 2 }, layout.transform); + assert.equal(control.kind, "resource"); + assert.deepEqual(hitTest(layout, center(bounds.preview)), { kind: "resource", id: control.id }); + assert.deepEqual(hitTest(layout, center(bounds.open)), { kind: "resource", id: control.id }); +}); diff --git a/vendor/fxnode/test/oklab.test.ts b/vendor/fxnode/test/oklab.test.ts new file mode 100644 index 0000000..4f7d241 --- /dev/null +++ b/vendor/fxnode/test/oklab.test.ts @@ -0,0 +1,22 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { isInSrgbGamut, mapOklchToSrgb, maxSrgbChroma, oklabToOklch, srgbToOklab } from "@lib/color/oklab.js"; +test("Oklab conversion matches reference primaries", () => { + const red = srgbToOklab([1, 0, 0]); + assert.ok(Math.abs(red.l - 0.627955) < 1e-5); + assert.ok(Math.abs(red.a - 0.224863) < 1e-5); + assert.ok(Math.abs(red.b - 0.125846) < 1e-5); + const white = srgbToOklab([1, 1, 1]); + assert.ok(Math.abs(white.l - 1) < 1e-6); +}); +test("Oklch gamut mapping preserves lightness and hue by reducing chroma", () => { + const requested = { l: 0.65, c: 0.6, h: 1.2 }, + limit = maxSrgbChroma(requested.l, requested.h); + assert.ok(limit < requested.c); + assert.equal(isInSrgbGamut({ ...requested, c: limit }), true); + const rgb = mapOklchToSrgb(requested); + assert.ok(rgb.every((value) => value >= 0 && value <= 1)); + const mapped = oklabToOklch(srgbToOklab(rgb)); + assert.ok(Math.abs(mapped.l - requested.l) < 2e-5); + assert.ok(Math.abs(mapped.h - requested.h) < 2e-4); +}); diff --git a/vendor/fxnode/test/persistence-migrations.test.ts b/vendor/fxnode/test/persistence-migrations.test.ts new file mode 100644 index 0000000..39f57b8 --- /dev/null +++ b/vendor/fxnode/test/persistence-migrations.test.ts @@ -0,0 +1,536 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { PERSISTENCE_LIMITS } from "@lib/composition/bound-document.js"; +import { createFxNodeHeadless } from "@lib/headless-runtime.js"; + +const numberValue = (value: number) => ({ kind: "number" as const, value }); +const rampDefault = { + kind: "json" as const, + value: { + colorMode: "rgb", + interpolation: "linear", + hueInterpolation: "near", + stops: [ + { id: "a", position: 0, color: [0, 0, 0, 1] }, + { id: "b", position: 1, color: [1, 1, 1, 1] }, + ], + }, +}; +const node = (migrations: readonly any[], version = 3) => ({ + version, + title: "Arbitrary Migrating Thing", + behavior: "standard" as const, + style: "common", + parameters: { + gain: { type: "number" as const, default: numberValue(7) }, + bonus: { type: "number" as const, default: numberValue(11) }, + spectrum: { type: "json" as const, codec: "color-ramp/v1" as const, default: rampDefault }, + }, + sockets: { + source: { + title: "Source", + direction: "output" as const, + type: "float", + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, + }, + sink: { + title: "Sink", + direction: "input" as const, + type: "float", + maxIncomingLinks: 8, + visible: true, + value: null, + showValue: false, + }, + }, + ui: [ + { kind: "parameter" as const, parameter: "gain" }, + { kind: "parameter" as const, parameter: "bonus" }, + { kind: "widget" as const, widget: "color-ramp" as const, parameter: "spectrum" }, + { kind: "socket" as const, socket: "source" }, + { kind: "socket" as const, socket: "sink" }, + ], + muteBypass: [], + migrations, +}); +const edges = [ + { + fromVersion: 1, + toVersion: 2, + steps: [ + { kind: "rename-parameter", from: "strength", to: "gain" }, + { kind: "rename-socket", from: "send", to: "source" }, + { kind: "rename-socket", from: "receive", to: "sink" }, + ], + }, + { + fromVersion: 2, + toVersion: 3, + steps: [ + { kind: "materialize-missing", target: "parameter", key: "bonus" }, + { kind: "migrate-parameter", parameter: "spectrum", codec: "color-ramp/legacy-stops" }, + ], + }, +] as const; +const color = "#000000" as const; +const theme = Object.fromEntries( + [ + "background", + "grid", + "frame", + "frameHeader", + "body", + "control", + "controlFill", + "controlEditing", + "textSelection", + "outline", + "text", + "muted", + "shadow", + "nodeSelected", + "nodeActive", + "unknownHeader", + "unknownSocket", + "linkMuted", + "knifeMuted", + "emphasis", + "focus", + "editOutline", + "resize", + "muteOverlay", + "boxSelectionFill", + "checkerLight", + "checkerDark", + "widgetBorder", + "rampBorder", + "resourceBackground", + ].map((key) => [key, color]), +); +const make = (migrations: readonly any[] = edges, version = 3) => + createFxNodeHeadless({ + schemaVersion: 2, + id: "peculiar-migration-suite", + version: 47, + compatibility: { wildcardInputTypes: [] }, + theme, + socketTypes: { float: { title: "Float", color, acceptsFrom: ["float"] } }, + nodeStyles: { common: { header: color } }, + resources: {}, + nodes: { "acme.odd-unit": node(migrations, version) }, + } as any); +const current = (runtime = make(), id = "n") => + runtime.save({ ...runtime.emptyDocument(), nodes: { [id]: runtime.materializeNode(id, "acme.odd-unit") } } as any); +const historical = (runtime = make(), id = "n") => { + const raw = structuredClone(current(runtime, id)); + const n: any = raw.nodes[0]; + n.typeVersion = 1; + n.parameters.strength = numberValue(23); + delete n.parameters.gain; + delete n.parameters.bonus; + n.parameters.spectrum = [ + { position: 0, color: [0, 0, 0, 1] }, + { position: 1, color: [1, 0, 0, 1] }, + ]; + for (const s of n.sockets) { + if (s.key === "source") { + s.key = "send"; + s.id = `${id}:send`; + } else { + s.key = "receive"; + s.id = `${id}:receive`; + } + } + return raw; +}; +const decode = (runtime: ReturnType, raw: unknown) => { + const result = runtime.decodeGraphDocument(raw); + assert.equal(result.ok, true, result.ok ? undefined : JSON.stringify(result.issues)); + return result.ok ? result.value : runtime.emptyDocument(); +}; + +test("custom parameter rename and missing default materialize", () => { + const r = make(), + d = decode(r, historical(r)), + n: any = d.nodes.n; + assert.deepEqual(n.parameters.gain, numberValue(23)); + assert.deepEqual(n.parameters.bonus, numberValue(11)); + assert.equal("strength" in n.parameters, false); +}); +test("socket rename rewrites output/input endpoints and preserves link identity and extensions", () => { + const r = make(), + raw: any = historical(r); + raw.nodes.push(structuredClone(raw.nodes[0])); + raw.nodes[1].id = "m"; + for (const s of raw.nodes[1].sockets) s.id = s.id.replace(/^n:/, "m:"); + raw.links = [ + { + id: "out", + fromNodeId: "n", + fromSocketId: "n:send", + toNodeId: "m", + toSocketId: "m:receive", + muted: false, + extensions: { custom: { x: 1 } }, + }, + { + id: "in", + fromNodeId: "m", + fromSocketId: "m:send", + toNodeId: "n", + toSocketId: "n:receive", + muted: true, + extensions: { tag: "kept" }, + }, + ]; + const saved = r.save(decode(r, raw)); + assert.deepEqual( + saved.links.map((x) => [x.id, x.fromSocketId, x.toSocketId, x.extensions]), + [ + ["in", "m:source", "n:sink", { tag: "kept" }], + ["out", "n:source", "m:sink", { custom: { x: 1 } }], + ], + ); +}); +test("graph state decoding never performs persistence migrations, materialization, rewrites, or reordering", () => { + const r = make(), + raw: any = historical(r); + raw.nodes.push(structuredClone(raw.nodes[0])); + raw.nodes[1].id = "m"; + for (const s of raw.nodes[1].sockets) s.id = s.id.replace(/^n:/, "m:"); + raw.links = [ + { + id: "out", + fromNodeId: "n", + fromSocketId: "n:send", + toNodeId: "m", + toSocketId: "m:receive", + muted: false, + extensions: {}, + }, + { + id: "in", + fromNodeId: "m", + fromSocketId: "m:send", + toNodeId: "n", + toSocketId: "n:receive", + muted: false, + extensions: {}, + }, + ]; + assert.equal(r.decodeGraphDocument(raw).ok, true); + const state = { + graphId: raw.graphId, + catalogVersion: raw.catalogVersion, + nodes: raw.nodes.map((node: any) => ({ ...node, known: true })), + links: raw.links, + metadata: raw.metadata, + }, + migrating = r.decodeGraphState(state); + assert.equal(migrating.ok, false); + if (!migrating.ok) assert.equal(migrating.issues[0]?.code, "state.inexact"); + const currentState = r.getState(r.createEngine(decode(r, current(r)))), + reordered = { ...currentState, nodes: [...currentState.nodes].reverse() }; + assert.equal(r.decodeGraphState(reordered).ok, currentState.nodes.length < 2); + const promoted = { ...currentState, nodes: currentState.nodes.map((node: any) => ({ ...node, known: false })) }, + promotion = r.decodeGraphState(promoted); + assert.equal(promotion.ok, false); + if (!promotion.ok) assert.equal(promotion.issues[0]?.code, "state.inexact"); + const currentTwo = decode(r, { ...current(r), nodes: [...current(r, "a").nodes, ...current(r, "b").nodes] } as any), + twoState = r.getState(r.createEngine(currentTwo)), + reorderedNodes = { ...twoState, nodes: [...twoState.nodes].reverse() }; + assert.equal(r.decodeGraphState(reorderedNodes).ok, false); + const currentLinks = decode( + r, + r.save( + decode(r, { + ...raw, + nodes: raw.nodes.map((node: any) => ({ + ...node, + typeVersion: 3, + parameters: { gain: numberValue(7), bonus: numberValue(11), spectrum: structuredClone(rampDefault) }, + sockets: node.sockets.map((socket: any) => ({ + ...socket, + key: socket.key === "send" ? "source" : "sink", + id: socket.id.replace(/:(send|receive)$/, (_: string, key: string) => + key === "send" ? ":source" : ":sink", + ), + })), + })), + links: raw.links.map((link: any) => ({ + ...link, + fromSocketId: link.fromSocketId.replace(":send", ":source"), + toSocketId: link.toSocketId.replace(":receive", ":sink"), + })), + } as any), + ), + ), + linkState = r.getState(r.createEngine(currentLinks)), + reorderedLinks = { ...linkState, links: [...linkState.links].reverse() }; + assert.equal(r.decodeGraphState(reorderedLinks).ok, false); +}); +test("legacy color-ramp codec works on arbitrary node and parameter", () => { + const r = make(), + n: any = decode(r, historical(r)).nodes.n; + assert.equal(n.parameters.spectrum.kind, "json"); + assert.deepEqual( + n.parameters.spectrum.value.stops.map((x: any) => x.id), + ["stop-0", "stop-1"], + ); +}); +test("direct and multi-edge migration paths execute", () => { + const direct = make([ + { fromVersion: 2, toVersion: 3, steps: [{ kind: "materialize-missing", target: "parameter", key: "bonus" }] }, + ]); + const raw: any = structuredClone(current(direct)); + raw.nodes[0].typeVersion = 2; + delete raw.nodes[0].parameters.bonus; + assert.equal((decode(direct, raw).nodes.n as any).known, true); + assert.equal((decode(make(), historical()).nodes.n as any).typeVersion, 3); +}); +test("catalogVersion mismatch is irrelevant and output is normalized", () => { + const r = make(), + raw = historical(r) as any; + raw.catalogVersion = 999; + const d = decode(r, raw); + assert.equal(d.catalogVersion, 47); + assert.equal(r.save(d).catalogVersion, 47); +}); +test("missing route variants and unknown types preserve original save payload", () => { + const cases: any[] = []; + const noEdge: any = historical(make([])); + cases.push([make([]), noEdge]); + const gap: any = historical(make([edges[0]])); + cases.push([make([edges[0]]), gap]); + const future: any = structuredClone(current()); + future.nodes[0].typeVersion = 99; + cases.push([make(), future]); + const absent: any = structuredClone(current()); + absent.nodes[0].typeId = "vendor.absent"; + cases.push([make(), absent]); + for (const [r, raw] of cases) { + const d = decode(r, raw); + assert.equal((Object.values(d.nodes)[0] as any).known, false); + assert.deepEqual(r.save(d).nodes[0], raw.nodes[0]); + } +}); +test("failed migration steps and obsolete final fields remain original unknown", () => { + const variants: any[] = []; + const malformed: any = historical(); + malformed.nodes[0].parameters.spectrum = [{ bad: true }]; + variants.push(malformed); + const missing: any = historical(); + delete missing.nodes[0].parameters.strength; + variants.push(missing); + const parameterCollision: any = historical(); + parameterCollision.nodes[0].parameters.gain = numberValue(4); + variants.push(parameterCollision); + const socketCollision: any = historical(); + socketCollision.nodes[0].sockets.push({ + ...structuredClone(socketCollision.nodes[0].sockets[0]), + id: "n:source", + key: "source", + }); + variants.push(socketCollision); + const obsolete: any = historical(); + obsolete.nodes[0].parameters.obsolete = numberValue(1); + variants.push(obsolete); + for (const raw of variants) { + const r = make(), + d = decode(r, raw); + assert.equal((d.nodes.n as any).known, false); + assert.deepEqual(r.save(d).nodes[0], raw.nodes[0]); + } +}); +test("malformed current-version payload hard fails catalog.invalid", () => { + const r = make(), + raw: any = structuredClone(current(r)); + delete raw.nodes[0].parameters.gain; + const d = r.decodeGraphDocument(raw); + assert.equal(d.ok, false); + if (!d.ok) assert.ok(d.issues.some((x) => x.code === "catalog.invalid")); +}); +test("current known nodes reject extra durable fields at every exactness level", () => { + const variants = [ + (n: any) => { + n.obsolete = true; + }, + (n: any) => { + n.parameters.gain.obsolete = true; + }, + (n: any) => { + n.sockets[0].obsolete = true; + }, + (n: any) => { + n.position.obsolete = true; + }, + ]; + for (const mutate of variants) { + const r = make(), + raw: any = structuredClone(current(r)); + mutate(raw.nodes[0]); + const decoded = r.decodeGraphDocument(raw); + assert.equal(decoded.ok, false); + if (!decoded.ok) assert.ok(decoded.issues.some((x) => x.code === "catalog.invalid")); + } +}); +test("historical extras abort migration and preserve the original unknown payload", () => { + const variants = [ + (n: any) => { + n.obsolete = true; + }, + (n: any) => { + n.parameters.strength.obsolete = true; + }, + (n: any) => { + n.sockets[0].obsolete = true; + }, + ]; + for (const mutate of variants) { + const r = make(), + raw: any = historical(r); + mutate(raw.nodes[0]); + const decoded = decode(r, raw); + assert.equal((decoded.nodes.n as any).known, false); + assert.deepEqual(r.save(decoded).nodes[0], raw.nodes[0]); + } +}); +test("persisted known is rejected rather than silently stripped from opaque nodes", () => { + const r = make(), + raw: any = structuredClone(current(r)); + raw.nodes[0].typeId = "vendor.opaque"; + raw.nodes[0].known = false; + const decoded = r.decodeGraphDocument(raw); + assert.equal(decoded.ok, false); + if (!decoded.ok) assert.ok(decoded.issues.some((x) => x.code === "decode.node")); +}); +test("materialized defaults are independent clones", () => { + const r = make(), + a: any = r.materializeNode("a", "acme.odd-unit"), + b: any = r.materializeNode("b", "acme.odd-unit"); + assert.notEqual(a.parameters.spectrum, b.parameters.spectrum); + assert.notEqual(a.parameters.spectrum.value, b.parameters.spectrum.value); +}); +test("materialized socket compatibility arrays are independently clone-safe", () => { + const r = make(), + a: any = r.materializeNode("a", "acme.odd-unit"), + b: any = r.materializeNode("b", "acme.odd-unit"), + raw = r.save({ ...r.emptyDocument(), nodes: { a, b } } as any); + assert.notEqual(a.sockets[1].accepts, b.sockets[1].accepts); + assert.equal(r.decodeGraphDocument(raw).ok, true); +}); +test("link destination collision causes no partial node or link migration", () => { + const r = make(), + raw: any = historical(r); + raw.links = [ + { + id: "collision", + fromNodeId: "n", + fromSocketId: "n:send", + toNodeId: "n", + toSocketId: "n:sink", + muted: false, + extensions: { x: 1 }, + }, + ]; + const copy = structuredClone(raw), + d = r.decodeGraphDocument(raw); + assert.equal(d.ok, false); + assert.deepEqual(raw, copy); +}); +test("opaque unknown custom socket fields roundtrip", () => { + const r = make(), + raw: any = structuredClone(current(r)); + raw.nodes[0].typeId = "opaque.widget"; + raw.nodes[0].sockets[0] = { + ...raw.nodes[0].sockets[0], + dataType: "quark", + accepts: ["boson"], + defaultValue: null, + metadata: { private: { v: 1 } }, + extensions: { socketExtra: true }, + }; + assert.deepEqual(r.save(decode(r, raw)), raw); +}); +test("caller input is unchanged after successful and failed decode", () => { + const r = make(); + for (const raw of [ + historical(r), + (() => { + const x: any = structuredClone(current(r)); + delete x.nodes[0].parameters.gain; + return x; + })(), + ]) { + const copy = structuredClone(raw); + r.decodeGraphDocument(raw); + assert.deepEqual(raw, copy); + } +}); +test("decode-save-decode stabilizes", () => { + const r = make(), + first = r.save(decode(r, historical(r))), + second = r.save(decode(r, first)); + assert.deepEqual(second, first); +}); +test("getter, cycle, sparse and nonfinite inputs error without throwing or executing getter", () => { + const r = make(); + let calls = 0; + const getter: any = { + get schemaVersion() { + calls++; + throw Error("no"); + }, + }; + const cycle: any = {}; + cycle.self = cycle; + const sparse: any = structuredClone(current(r)); + sparse.nodes.length = 2; + const hugeSparse = new Array(4_000_000_000); + const nonfinite: any = structuredClone(current(r)); + nonfinite.nodes[0].position.x = Infinity; + for (const value of [getter, cycle, sparse, hugeSparse, nonfinite]) + assert.doesNotThrow(() => assert.equal(r.decodeGraphDocument(value).ok, false)); + const huge = r.decodeGraphDocument(hugeSparse); + assert.equal(huge.ok, false); + if (!huge.ok) assert.equal(huge.issues[0]?.code, "limit.values"); + assert.equal(calls, 0); +}); +test("node and link collection limits reject over-limit and admit at-limit", () => { + const r = make(), + base: any = { schemaVersion: 2, graphId: "limits", catalogVersion: 1, nodes: [], links: [], metadata: {} }; + base.nodes = Array(PERSISTENCE_LIMITS.maxNodes + 1).fill(null); + let d = r.decodeGraphDocument(base); + assert.equal(d.ok, false); + if (!d.ok) assert.equal(d.issues[0]?.path, "/nodes"); + base.nodes = []; + base.links = Array(PERSISTENCE_LIMITS.maxLinks + 1).fill(null); + d = r.decodeGraphDocument(base); + assert.equal(d.ok, false); + if (!d.ok) assert.equal(d.issues[0]?.path, "/links"); + base.links = []; + assert.equal(r.decodeGraphDocument(base).ok, true); +}); +test("bound runtime failed load returns the identical state object with version and history", () => { + const r = make(); + let state: any = r.createEngine(r.emptyDocument()); + const request: any = { + commandId: "x", + expectedVersion: 0, + source: "api", + command: { type: "node.add", nodeId: "n", nodeType: "acme.odd-unit", position: { x: 0, y: 0 } }, + }; + const added = r.transition(state, request); + assert.equal(added.status, "committed"); + if (added.status === "committed") state = added.state; + const bad: any = structuredClone(current(r)); + delete bad.nodes[0].parameters.gain; + const result = r.load(state, bad); + assert.equal(result.ok, false); + assert.equal(result.state, state); + assert.equal(result.state.version, 1); + assert.equal(result.state.undo, state.undo); +}); diff --git a/vendor/fxnode/test/pointer-lane.test.ts b/vendor/fxnode/test/pointer-lane.test.ts new file mode 100644 index 0000000..1b37b2b --- /dev/null +++ b/vendor/fxnode/test/pointer-lane.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + advancePointerLaneFence, + createPointerLane, + pointerLaneFence, + publishPointerMove, + readPointerMove, + type PointerMoveWire, +} from "@lib/browser/pointer-lane.js"; + +const move = (x: number, y: number): PointerMoveWire => ({ + kind: "pointer", + phase: "move", + pointerId: 7, + pointerType: "pen", + position: { x, y }, + button: -1, + buttons: 1, + modifiers: 8, +}); + +test("shared pointer lane publishes exact latest coordinates and fences", () => { + const lane = createPointerLane(); + assert.equal(pointerLaneFence(lane), 0); + const generation = 0x1_0000_0007; + const sequence = publishPointerMove(lane, move(-12.25, 987.125), generation); + assert.equal(sequence, 1); + assert.deepEqual(readPointerMove(lane), { sequence: 1, hostGeneration: generation, event: move(-12.25, 987.125) }); + assert.equal(advancePointerLaneFence(lane), 1); + assert.equal(pointerLaneFence(lane), 1); + assert.equal(publishPointerMove(lane, { ...move(4, 5), pointerType: "unknown" }, generation + 1), undefined); + assert.deepEqual(readPointerMove(lane), { sequence: 1, hostGeneration: generation, event: move(-12.25, 987.125) }); +}); + +test("shared pointer lane rejects an in-progress publication", () => { + const lane = createPointerLane(); + publishPointerMove(lane, move(1, 2), 3); + const words = new Int32Array(lane); + Atomics.store(words, 0, Atomics.load(words, 0) | 1); + assert.equal(readPointerMove(lane), undefined); +}); diff --git a/vendor/fxnode/test/presentation-composition.test.ts b/vendor/fxnode/test/presentation-composition.test.ts new file mode 100644 index 0000000..eb4fcc8 --- /dev/null +++ b/vendor/fxnode/test/presentation-composition.test.ts @@ -0,0 +1,304 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { compileFxNodeComposition } from "@lib/composition/index.js"; +import { bindFxNodeHeadless } from "@lib/headless-runtime.js"; +import { layoutGraph } from "@lib/layout/layout-graph.js"; +import { effectivelyMutedLinks } from "@lib/layout/link-mute.js"; +import { IndexedLayoutStore } from "@lib/layout/indexed-layout-store.js"; +import { layoutSocketsCompatible } from "@lib/layout/types.js"; +import { compatibleTargets } from "@lib/worker/interaction.js"; + +const theme = { + background: "#010203", + grid: "#040506", + frame: "#070809", + frameHeader: "#0a0b0c", + body: "#0d0e0f", + control: "#101112", + controlFill: "#131415", + controlEditing: "#161718", + textSelection: "#191a1b", + outline: "#1c1d1e", + text: "#1f2021", + muted: "#222324", + shadow: "#252627", + nodeSelected: "#28292a", + nodeActive: "#2b2c2d", + unknownHeader: "#2e2f30", + unknownSocket: "#313233", + linkMuted: "#343536", + knifeMuted: "#373839", + emphasis: "#3a3b3c", + focus: "#3d3e3f", + editOutline: "#404142", + resize: "#434445", + muteOverlay: "#464748", + boxSelectionFill: "#494a4b", + checkerLight: "#4c4d4e", + checkerDark: "#4f5051", + widgetBorder: "#525354", + rampBorder: "#555657", + resourceBackground: "#58595a", +} as const; +const ramp = () => ({ + kind: "json" as const, + value: { + colorMode: "rgb", + interpolation: "linear", + hueInterpolation: "near", + stops: [ + { id: "dark", position: 0, color: [0, 0, 0, 1] }, + { id: "light", position: 1, color: [1, 1, 1, 1] }, + ], + }, +}); +const input = (type: T, title = "Input", visible = true) => ({ + title, + direction: "input" as const, + type, + maxIncomingLinks: 1, + visible, + value: null, + showValue: false, +}); +const output = (type: T, title = "Output") => ({ + title, + direction: "output" as const, + type, + maxIncomingLinks: 0, + visible: true, + value: null, + showValue: false, +}); +const base = (behavior: "standard" | "frame" | "reroute", style: "violet" | "frameStyle" = "violet") => ({ + version: 1, + title: "Node", + behavior, + style, + parameters: {}, + sockets: {}, + ui: [], + muteBypass: [], + migrations: [], +}); +const compositionSource = { + schemaVersion: 2, + id: "adversarial-presentation", + version: 73, + compatibility: { wildcardInputTypes: ["universal-destination"] }, + theme, + socketTypes: { + "source-signal": { title: "Signal", color: "#a1b2c3", acceptsFrom: ["source-signal"] }, + "universal-destination": { title: "Everything", color: "#d4e5f6", acceptsFrom: [] }, + }, + nodeStyles: { violet: { header: "#7654ab" }, frameStyle: { header: "#abcdef" } }, + resources: { + photo: { + kind: "image", + title: "Default image title", + openTitle: "Default open", + accept: ["image/png"], + referencePrefix: "test-image:", + maxBytes: 1024, + maxWidth: 64, + maxHeight: 64, + maxPixels: 4096, + }, + }, + nodes: { + "fxnode.common.frame": { ...base("standard"), title: "Frame-looking ordinary" }, + "fxnode.common.reroute": { ...base("standard"), title: "Reroute-looking ordinary" }, + "totally-unrelated-container": { ...base("frame", "frameStyle"), title: "Actual frame" }, + "odd-junction": { + ...base("reroute"), + title: "Actual reroute", + sockets: { in: input("source-signal"), out: output("source-signal") }, + ui: [ + { kind: "socket", socket: "in" }, + { kind: "socket", socket: "out" }, + ], + muteBypass: [["in", "out"]], + }, + source: { ...base("standard"), sockets: { out: output("source-signal") }, ui: [{ kind: "socket", socket: "out" }] }, + destination: { + ...base("standard"), + parameters: { mode: { type: "string", default: { kind: "string", value: "show" }, enum: ["show", "hide"] } }, + sockets: { + wild: input("universal-destination", "Schema label"), + hidden: input("source-signal", "Hidden socket"), + conditional: input("source-signal", "Conditional socket"), + }, + ui: [ + { kind: "parameter", parameter: "mode" }, + { kind: "socket", socket: "wild", title: "Overridden socket title" }, + { kind: "hidden", target: "socket", socket: "hidden" }, + { kind: "socket", socket: "conditional", visibleWhen: { parameter: "mode", equals: "show" } }, + ], + }, + controls: { + ...base("standard"), + parameters: { + ramp: { type: "json", codec: "color-ramp/v1", default: ramp() }, + ordinaryRampJson: { type: "json", codec: "color-ramp/v1", default: ramp() }, + imageRef: { type: "string", default: { kind: "string", value: "" } }, + lift: { type: "number", default: { kind: "number", value: 0 } }, + gamma: { type: "number", default: { kind: "number", value: 1 } }, + gain: { type: "number", default: { kind: "number", value: 1 } }, + liftColor: { type: "color", default: { kind: "color", value: [0, 0, 0, 1] } }, + gammaColor: { type: "color", default: { kind: "color", value: [0.5, 0.5, 0.5, 1] } }, + gainColor: { type: "color", default: { kind: "color", value: [1, 1, 1, 1] } }, + }, + sockets: {}, + ui: [ + { kind: "widget", widget: "color-ramp", parameter: "ramp" }, + { kind: "parameter", parameter: "ordinaryRampJson", title: "Plain JSON" }, + { kind: "resource", resource: "photo", parameter: "imageRef", title: "Plate", openTitle: "Choose plate" }, + { + kind: "widget", + widget: "grading-wheels", + bindings: [ + { title: "Shadows", scalar: "lift", color: "liftColor" }, + { title: "Midtones", scalar: "gamma", color: "gammaColor" }, + { title: "Highlights", scalar: "gain", color: "gainColor" }, + ], + }, + ], + }, + }, +} as const; +const compiled = compileFxNodeComposition(structuredClone(compositionSource)); +const runtime = bindFxNodeHeadless(compiled); +const transform = { center: { x: 200, y: 0 }, zoom: 1, viewport: { x: 1600, y: 1000 }, dpr: 1 }; +const link = ( + id: string, + fromNodeId: string, + fromSocketId: string, + toNodeId: string, + toSocketId: string, + muted = false, +) => ({ id, fromNodeId, fromSocketId, toNodeId, toSocketId, muted, extensions: {} }); + +test("custom behavior metadata, UI placement, widgets, resources, and colors are authoritative", () => { + const lookFrame = runtime.materializeNode("look-frame", "fxnode.common.frame", { x: 0, y: 0 }); + const lookReroute = runtime.materializeNode("look-reroute", "fxnode.common.reroute", { x: 220, y: 0 }); + const frame = runtime.materializeNode("container", "totally-unrelated-container", { x: 0, y: 300 }); + const child = runtime.materializeNode("child", "source", { x: 40, y: -40 }, "container"); + const junction = runtime.materializeNode("junction", "odd-junction", { x: 400, y: 100 }); + const controls = runtime.materializeNode("arbitrary", "controls", { x: 600, y: 300 }); + const destination = runtime.materializeNode("dest", "destination", { x: 1100, y: 200 }); + const document = { + ...runtime.emptyDocument("custom"), + nodes: { + "look-frame": lookFrame, + "look-reroute": lookReroute, + container: frame, + child, + junction, + arbitrary: controls, + dest: destination, + }, + links: {}, + } as any; + const layout = layoutGraph(compiled, document, transform); + assert.equal(layout.nodes.get("look-frame" as never)?.kind, "node"); + assert.equal(layout.nodes.get("look-reroute" as never)?.kind, "node"); + assert.equal(layout.nodes.get("container" as never)?.kind, "frame"); + assert.equal(layout.nodes.get("junction" as never)?.kind, "reroute"); + assert.equal(layout.drawOrder[0], "container"); + const f = layout.nodes.get("container" as never)!, + c = layout.nodes.get("child" as never)!; + assert.ok(f.bounds.width > 100 && f.bounds.x <= c.bounds.x - 30, "custom frame fits its child and paints behind it"); + assert.equal(layout.sockets.has("dest:hidden" as never), false); + assert.equal(layout.sockets.get("dest:wild" as never)?.label, "Overridden socket title"); + assert.equal(layout.sockets.has("dest:conditional" as never), true); + const hiddenMode = { + ...destination, + parameters: { ...destination.parameters, mode: { kind: "string", value: "hide" } }, + }; + const conditional = layoutGraph(compiled, { ...document, nodes: { ...document.nodes, dest: hiddenMode } }, transform); + assert.equal(conditional.sockets.has("dest:conditional" as never), false); + assert.equal( + conditional.nodes.get("dest" as never)?.rows.some((r: any) => r.socketId === "dest:conditional"), + false, + ); + const explicit = layout.controls.get("arbitrary:parameter:ramp")!, + plain = layout.controls.get("arbitrary:parameter:ordinaryRampJson")!, + resource = layout.controls.get("arbitrary:parameter:imageRef")!; + assert.equal(explicit.kind, "color-ramp"); + assert.ok(explicit.rampBounds); + assert.equal(plain.kind, "readonly-json"); + assert.equal(plain.rampBounds, undefined); + assert.equal(resource.kind, "resource"); + assert.ok(resource.resourceBounds); + assert.equal(resource.label, "Plate"); + assert.equal(resource.openTitle, "Choose plate"); + const wheels = layout.nodes.get("arbitrary" as never)?.rows.find((r) => r.kind === "grading-wheels"); + assert.ok(wheels?.kind === "grading-wheels"); + assert.deepEqual( + wheels.wheels.map((w) => w.label), + ["Shadows", "Midtones", "Highlights"], + ); + assert.ok(wheels.wheels.every((w) => layout.controls.get(w.colorControlId)?.colorWheelBounds)); + assert.equal(layout.nodes.get("arbitrary" as never)?.headerColor, "#7654ab"); + assert.equal(layout.sockets.get("junction:out" as never)?.color, "#a1b2c3"); + const unknown = { + ...junction, + known: false, + typeId: "odd-junction", + id: "opaque", + sockets: junction.sockets.map((s) => ({ ...s, id: `opaque:${s.key}` })), + }; + const opaqueLayout = layoutGraph(compiled, { ...document, nodes: { opaque: unknown }, links: {} }, transform); + assert.equal(opaqueLayout.nodes.get("opaque" as never)?.kind, "node"); + assert.equal(opaqueLayout.nodes.get("opaque" as never)?.headerColor, theme.unknownHeader); + assert.ok([...opaqueLayout.sockets.values()].every((s) => s.color === theme.unknownSocket)); +}); + +test("custom wildcard compatibility agrees across layout, interaction, and bound runtime", () => { + const source = runtime.materializeNode("s", "source"), + destination = runtime.materializeNode("d", "destination", { x: 300, y: 0 }), + document = { ...runtime.emptyDocument("compat"), nodes: { s: source, d: destination }, links: {} } as any, + layout = layoutGraph(compiled, document, transform), + from = layout.sockets.get("s:out" as never)!, + to = layout.sockets.get("d:wild" as never)!; + assert.equal(to.dataType, "universal-destination"); + assert.equal(to.wildcardInput, true); + assert.equal(layoutSocketsCompatible(from, to), true); + assert.equal( + compatibleTargets(layout, from.id).some((x) => x.id === to.id), + true, + ); + assert.equal(runtime.socketsCompatible(source.sockets[0]!, destination.sockets[0]!), true); +}); + +test("custom reroute mute propagation excludes opaque lookalikes and survives indexed rebuild authority", () => { + const source = runtime.materializeNode("s", "source"), + reroute = runtime.materializeNode("r", "odd-junction"), + destination = runtime.materializeNode("d", "destination"), + unknown = { + ...reroute, + known: false, + id: "u", + typeId: "odd-junction", + sockets: reroute.sockets.map((s) => ({ ...s, id: `u:${s.key}` })), + }; + const links = { + a: link("a", "s", "s:out", "r", "r:in", true), + b: link("b", "r", "r:out", "d", "d:wild"), + c: link("c", "s", "s:out", "u", "u:in", true), + d: link("d", "u", "u:out", "d", "d:wild"), + }; + const document = { + ...runtime.emptyDocument("mute"), + nodes: { s: source, r: reroute, d: destination, u: unknown }, + links, + } as any, + muted = effectivelyMutedLinks(compiled, document); + assert.deepEqual([...muted].sort(), ["a", "b", "c"]); + const store = new IndexedLayoutStore(compiled, document), + authority = store.compiled; + store.rebuild({ ...document, links: {} }); + assert.equal(store.compiled, authority); + assert.equal(store.compiled, compiled); + assert.equal(store.scene.nodes.get("r" as never)?.kind, "reroute"); +}); diff --git a/vendor/fxnode/test/protocol.test.ts b/vendor/fxnode/test/protocol.test.ts new file mode 100644 index 0000000..8df3fce --- /dev/null +++ b/vendor/fxnode/test/protocol.test.ts @@ -0,0 +1,879 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + PROTOCOL_VERSION, + validCommandReceipt, + validCompositionReceipt, + validRequest, + validWorkerMessage, +} from "@lib/browser/protocol.js"; +import { + decodeFxNodeActionOptions, + decodeFxNodeAddNodeParams, + decodeFxNodeCamera, + decodeFxNodeInput, + decodeFxNodeResourceAuthorization, + decodeFxNodeResourceData, + decodeFxNodeViewport, +} from "@lib/browser/host-decode.js"; +import { fxNodeDevicePixels } from "@lib/browser/view-limits.js"; + +const viewport = { width: 1, height: 1, dpr: 1 }; +const viewId = "internal-view"; + +test("protocol v2 messages are rejected", () => { + assert.equal(validRequest({ protocol: 2, type: "state.get", id: "request" }), false); + assert.equal( + validWorkerMessage({ protocol: 2, type: "view.node-menu.result", viewId, requestId: "request", open: false }), + false, + ); +}); + +test("old unprefixed view message names are rejected", () => { + for (const type of [ + "viewport", + "input", + "node.add", + "selection.remove", + "selection.mute", + "resource.set", + "pointer.flush", + "frame.consumed", + ]) + assert.equal(validRequest({ protocol: PROTOCOL_VERSION, type, viewId }), false); + for (const type of ["frame", "selection.host", "node-menu.result", "resource.open"]) + assert.equal(validWorkerMessage({ protocol: PROTOCOL_VERSION, type, viewId }), false); +}); + +test("view protocol is internally scoped and camera decoding is hostile-safe", () => { + const attach = { + protocol: PROTOCOL_VERSION, + type: "view.attach", + id: "request", + viewId: "internal-view", + viewport, + camera: { center: { x: 3, y: -4 }, zoom: 2 }, + }; + assert.equal(validRequest(attach), true); + assert.equal(validRequest({ ...attach, viewId: undefined }), false); + assert.equal(validRequest({ ...attach, camera: undefined }), false); + assert.equal(validRequest({ ...attach, callerViewId: "public" }), false); + for (const id of [undefined, "", 1, "x".repeat(513)]) assert.equal(validRequest({ ...attach, viewId: id }), false); + assert.equal(validRequest({ ...attach, extra: true }), false); + assert.equal(validRequest({ protocol: PROTOCOL_VERSION, type: "view.detach", id: "request", viewId }), true); + assert.equal( + validRequest({ protocol: PROTOCOL_VERSION, type: "view.detach", id: "request", viewId, extra: true }), + false, + ); + assert.deepEqual(decodeFxNodeCamera(attach.camera), attach.camera); + const frozen = Object.freeze({ center: Object.freeze({ x: 3, y: -4 }), zoom: 0.1 }); + assert.deepEqual(decodeFxNodeCamera(frozen), frozen); + assert.deepEqual(decodeFxNodeCamera({ center: { x: 0, y: 0 }, zoom: 4 }), { + center: { x: 0, y: 0 }, + zoom: 4, + }); + for (const value of [NaN, Infinity, -Infinity]) { + assert.throws(() => decodeFxNodeCamera({ center: { x: value, y: 0 }, zoom: 1 }), TypeError); + assert.throws(() => decodeFxNodeCamera({ center: { x: 0, y: 0 }, zoom: value }), TypeError); + } + assert.throws( + () => decodeFxNodeCamera({ center: { x: 0, y: 0 }, zoom: 1, extra: true } as never), + new TypeError("Invalid FxNode camera"), + ); + assert.throws( + () => decodeFxNodeCamera({ center: { x: 0, y: 0 }, zoom: 1, [Symbol()]: true } as never), + new TypeError("Invalid FxNode camera"), + ); + let cameraReads = 0; + const accessorCamera = Object.defineProperty({ center: { x: 0, y: 0 } }, "zoom", { + enumerable: true, + get() { + cameraReads++; + return 1; + }, + }); + assert.throws(() => decodeFxNodeCamera(accessorCamera as never), new TypeError("Invalid FxNode camera")); + assert.equal(cameraReads, 0); + const center = { x: 7, y: 8 }; + const detachedCamera = decodeFxNodeCamera({ center, zoom: 1 }); + center.x = 99; + assert.deepEqual(detachedCamera, { center: { x: 7, y: 8 }, zoom: 1 }); + for (const zoom of [0.099, 4.001]) + assert.throws(() => decodeFxNodeCamera({ center: { x: 0, y: 0 }, zoom }), RangeError); + const nested = Proxy.revocable({ x: 0, y: 0 }, {}); + nested.revoke(); + assert.throws(() => decodeFxNodeCamera({ center: nested.proxy, zoom: 1 }), new TypeError("Invalid FxNode camera")); + const revoked = Proxy.revocable({}, {}); + revoked.revoke(); + assert.throws(() => decodeFxNodeCamera(revoked.proxy as never), new TypeError("Invalid FxNode camera")); +}); + +test("view.frame worker messages are exact and view IDs are bounded", () => { + const previous = globalThis.ImageBitmap; + class TestImageBitmap { + readonly width = 1; + readonly height = 1; + } + Object.defineProperty(globalThis, "ImageBitmap", { configurable: true, value: TestImageBitmap }); + try { + const frame = { + protocol: PROTOCOL_VERSION, + type: "view.frame", + viewId, + bitmap: new TestImageBitmap(), + renderId: 0, + frameId: 0, + hostGeneration: 0, + surfaceGeneration: 0, + deviceWidth: 1, + deviceHeight: 1, + host: { colorPickerOpen: false }, + }; + assert.equal(validWorkerMessage(frame), true); + for (const candidate of [ + { ...frame, bitmap: {} }, + { ...frame, frameId: -1 }, + { ...frame, renderId: 0.5 }, + { ...frame, hostGeneration: Number.MAX_SAFE_INTEGER + 1 }, + { ...frame, surfaceGeneration: -1 }, + { ...frame, deviceWidth: 0 }, + { ...frame, deviceHeight: 8193 }, + { ...frame, host: { colorPickerOpen: false, extra: true } }, + { ...frame, extra: true }, + ]) + assert.equal(validWorkerMessage(candidate), false); + for (const invalidViewId of [undefined, "", 1, "x".repeat(513)]) { + const candidate = { ...frame, viewId: invalidViewId }; + if (invalidViewId === undefined) delete (candidate as { viewId?: unknown }).viewId; + assert.equal(validWorkerMessage(candidate), false); + } + } finally { + if (previous === undefined) delete (globalThis as { ImageBitmap?: unknown }).ImageBitmap; + else Object.defineProperty(globalThis, "ImageBitmap", { configurable: true, value: previous }); + } +}); + +test("input and viewport decoder tables cover every variant and boundary", () => { + const m0 = { alt: false, control: false, meta: false, shift: false }, + m15 = { alt: true, control: true, meta: true, shift: true }; + const cases = [ + [ + { kind: "focus", phase: "focus" }, + { kind: "focus", phase: "focus" }, + ], + [ + { kind: "outside-pointer", button: -1 }, + { kind: "outside-pointer", button: -1 }, + ], + [ + { + kind: "pointer", + phase: "move", + pointerId: 1, + pointerType: "pen", + position: { x: -2, y: -3 }, + button: -1, + buttons: 0, + modifiers: m0, + }, + { + kind: "pointer", + phase: "move", + pointerId: 1, + pointerType: "pen", + position: { x: -2, y: -3 }, + button: -1, + buttons: 0, + modifiers: 0, + }, + ], + [ + { kind: "wheel", position: { x: -1, y: -2 }, delta: { x: -3, y: 4 }, modifiers: m15 }, + { kind: "wheel", position: { x: -1, y: -2 }, delta: { x: -3, y: 4 }, modifiers: 15 }, + ], + [ + { kind: "key", phase: "up", key: "A", code: "KeyA", repeat: false, modifiers: m15 }, + { kind: "key", phase: "up", key: "A", code: "KeyA", repeat: false, modifiers: 15 }, + ], + ] as const; + for (const [input, expected] of cases) assert.deepEqual(decodeFxNodeInput(input as never), expected); + for (const value of [ + { width: 0, height: 8192, dpr: Number.MIN_VALUE }, + { width: 8192, height: 0, dpr: 1 }, + { width: 4096, height: 4096, dpr: 1 }, + ]) + assert.deepEqual(decodeFxNodeViewport(value), value); + assert.deepEqual(decodeFxNodeViewport({ width: 0, height: 0, dpr: 4 }), { width: 0, height: 0, dpr: 4 }); + assert.deepEqual(fxNodeDevicePixels(0, 0, 1), { width: 1, height: 1 }); + assert.deepEqual(fxNodeDevicePixels(0.49, 0.5, 1), { width: 1, height: 1 }); + assert.deepEqual(fxNodeDevicePixels(1.5, 2.5, 1), { width: 2, height: 3 }); + assert.equal(fxNodeDevicePixels(4097, 4096, 1), undefined); + for (const value of [ + { width: -1, height: 1, dpr: 1 }, + { width: 8193, height: 0, dpr: 1 }, + { width: 4097, height: 4096, dpr: 1 }, + { width: 1, height: 1, dpr: 0 }, + ]) + assert.throws(() => decodeFxNodeViewport(value), RangeError); +}); + +test("protocol tables reject malformed pointer fences", () => { + const base = { protocol: PROTOCOL_VERSION, type: "view.pointer.flush", viewId }, + move = { + kind: "pointer", + phase: "move", + pointerId: 1, + pointerType: "mouse", + position: { x: 0, y: 0 }, + button: 0, + buttons: 0, + modifiers: 0, + }; + assert.equal( + validRequest({ ...base, pointerFence: { generation: 0, before: { sequence: 1, hostGeneration: 2, event: move } } }), + true, + ); + for (const pointerFence of [ + {}, + { generation: 0.5 }, + { generation: 0, extra: true }, + { generation: 0, before: {} }, + { generation: 0, before: { sequence: 1, event: move } }, + { generation: 0, before: { sequence: 1, hostGeneration: -1, event: move } }, + { generation: 0, before: { sequence: 1, hostGeneration: 0, event: { ...move, phase: "down" } } }, + { generation: 0, before: { sequence: 1, hostGeneration: 0, event: { ...move, position: { x: Infinity, y: 0 } } } }, + ]) + assert.equal(validRequest({ ...base, pointerFence }), false); +}); +test("host generations are required, exact, and nonnegative", () => { + const event = { kind: "focus", phase: "focus" }, + input = { protocol: PROTOCOL_VERSION, type: "view.input", viewId, event, hostGeneration: 0 }, + viewportRequest = { + protocol: PROTOCOL_VERSION, + type: "view.viewport", + id: "resize", + viewId, + viewport, + expectedSurfaceGeneration: 1, + hostGeneration: 0, + }; + for (const value of [input, viewportRequest]) assert.equal(validRequest(value), true); + for (const generation of [undefined, -1, 0.5]) { + const candidate = { ...input, ...(generation === undefined ? {} : { hostGeneration: generation }) }; + if (generation === undefined) delete (candidate as { hostGeneration?: unknown }).hostGeneration; + assert.equal(validRequest(candidate), false); + } + assert.equal(validRequest({ ...input, extra: true }), false); + assert.equal(validRequest({ ...viewportRequest, hostGeneration: -1 }), false); + assert.equal(validRequest({ ...viewportRequest, pointerFence: { generation: 1 } }), true); + assert.equal(validRequest({ ...viewportRequest, pointerFence: { generation: 0.5 } }), false); + for (const expectedSurfaceGeneration of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1]) + assert.equal(validRequest({ ...viewportRequest, expectedSurfaceGeneration }), false); + const render = { protocol: PROTOCOL_VERSION, type: "view.render", viewId, renderId: 1, hostGeneration: 0 }; + assert.equal(validRequest(render), true); + for (const renderId of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1]) + assert.equal(validRequest({ ...render, renderId }), false); + const consumed = { protocol: PROTOCOL_VERSION, type: "view.frame.consumed", viewId, frameId: 0 }; + assert.equal(validRequest(consumed), true); + assert.equal(validRequest({ ...consumed, extra: true }), false); + for (const frameId of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1]) + assert.equal(validRequest({ ...consumed, frameId }), false); +}); +test("resource open correlation is exact on input and worker messages", () => { + const event = { + kind: "pointer", + phase: "down", + pointerId: 1, + pointerType: "mouse", + position: { x: 2, y: 3 }, + button: 0, + buttons: 1, + modifiers: 0, + }, + input = { + protocol: PROTOCOL_VERSION, + type: "view.input", + viewId, + event, + hostGeneration: 0, + resourceOpenRequestId: "open-1", + }, + resource = { + id: "image", + kind: "image", + title: "Image", + openTitle: "Open image", + accept: ["image/png"], + maxBytes: 1024, + maxWidth: 4, + maxHeight: 4, + maxPixels: 16, + }, + open = { + protocol: PROTOCOL_VERSION, + type: "view.resource.open", + viewId, + requestId: "open-1", + authorization: { viewId, token: "node:parameter:image", graphVersion: 2, compositionRevision: 3 }, + resource, + }; + assert.equal(validRequest(input), true); + assert.equal(validWorkerMessage(open), true); + assert.equal(validWorkerMessage({ ...open, authorization: { ...open.authorization, viewId: "other" } }), true); + assert.equal(validWorkerMessage({ ...open, authorization: { ...open.authorization, extra: true } }), false); + assert.equal(validWorkerMessage({ ...open, authorization: { ...open.authorization, viewId: "" } }), false); + for (const requestId of ["", "x".repeat(513), 1]) { + assert.equal(validRequest({ ...input, resourceOpenRequestId: requestId }), false); + assert.equal(validWorkerMessage({ ...open, requestId }), false); + } + assert.equal(validRequest({ ...input, extra: true }), false); + assert.equal(validWorkerMessage({ ...open, bounds: { x: 0, y: 0, width: 1, height: 1 } }), false); + assert.equal(validWorkerMessage({ ...open, authorization: { ...open.authorization, extra: true } }), false); + assert.equal(validWorkerMessage({ ...open, resource: { ...resource, accept: ["image/png"], extra: true } }), false); +}); +test("public host values decode once without getters and detach nested input", () => { + let reads = 0; + const getter = Object.defineProperty({ kind: "focus" }, "phase", { + enumerable: true, + get() { + reads++; + return "focus"; + }, + }); + assert.throws(() => decodeFxNodeInput(getter as never), new TypeError("Invalid FxNode input")); + assert.equal(reads, 0); + const position = { x: 1, y: 2 }, + mods = { alt: true, control: false, meta: true, shift: false }, + input = { + kind: "pointer", + phase: "down", + pointerId: 1, + pointerType: "mouse", + position, + button: 0, + buttons: 1, + modifiers: mods, + } as const, + wire = decodeFxNodeInput(input); + position.x = 9; + mods.control = true; + assert.deepEqual(wire, { + kind: "pointer", + phase: "down", + pointerId: 1, + pointerType: "mouse", + position: { x: 1, y: 2 }, + button: 0, + buttons: 1, + modifiers: 5, + }); + assert.throws( + () => decodeFxNodeInput({ ...input, [Symbol()]: true } as never), + new TypeError("Invalid FxNode input"), + ); + assert.throws( + () => decodeFxNodeViewport({ width: Infinity, height: 1, dpr: 1 }), + new TypeError("Invalid FxNode viewport"), + ); + assert.throws( + () => decodeFxNodeViewport({ width: 8192, height: 8192, dpr: 1 }), + new RangeError("FxNode viewport is outside supported bounds"), + ); +}); + +test("public action values reject accessors, symbols, and hostile proxies", () => { + assert.deepEqual(decodeFxNodeActionOptions({}), { kind: "current" }); + let reads = 0; + const options = Object.defineProperty({}, "expectedVersion", { + enumerable: true, + get() { + reads++; + return 1; + }, + }); + const params = Object.defineProperty({ typeId: "test", viewPosition: { x: 1, y: 2 } }, "nodeId", { + enumerable: true, + get() { + reads++; + return "node"; + }, + }); + assert.throws(() => decodeFxNodeActionOptions(options), new TypeError("Invalid action options")); + assert.throws(() => decodeFxNodeAddNodeParams(params), new TypeError("Invalid add-node parameters")); + assert.equal(reads, 0); + assert.throws( + () => decodeFxNodeAddNodeParams({ typeId: "test", viewPosition: { x: 1, y: 2 }, [Symbol()]: true }), + new TypeError("Invalid add-node parameters"), + ); + const revoked = Proxy.revocable({}, {}); + revoked.revoke(); + assert.throws(() => decodeFxNodeActionOptions(revoked.proxy), new TypeError("Invalid action options")); + assert.throws(() => decodeFxNodeAddNodeParams(revoked.proxy), new TypeError("Invalid add-node parameters")); +}); +test("protocol validators are total and bound request and node type ids", () => { + const throwing = new Proxy( + {}, + { + get() { + throw new Error("hostile"); + }, + ownKeys() { + throw new Error("hostile"); + }, + }, + ); + const target = {}; + const revoked = Proxy.revocable(target, {}); + revoked.revoke(); + for (const value of [throwing, revoked.proxy]) { + assert.doesNotThrow(() => validRequest(value)); + assert.equal(validRequest(value), false); + assert.doesNotThrow(() => validWorkerMessage(value)); + assert.equal(validWorkerMessage(value), false); + } + assert.equal(validRequest({ protocol: PROTOCOL_VERSION, type: "state.get", id: "x".repeat(513) }), false); + assert.equal( + validRequest({ + protocol: PROTOCOL_VERSION, + type: "node.add-at-view", + id: "r", + nodeId: "n", + nodeType: "x".repeat(129), + viewPosition: { x: 0, y: 0 }, + }), + false, + ); + // The live command wire format retains its historical 512-code-unit node type bound. + assert.equal( + validRequest({ + protocol: PROTOCOL_VERSION, + type: "command", + id: "r", + expected: { kind: "current" }, + command: { type: "node.add", nodeId: "n", nodeType: "x".repeat(129), position: { x: 0, y: 0 } }, + }), + true, + ); + assert.equal( + validRequest({ + protocol: PROTOCOL_VERSION, + type: "command", + id: "r", + expected: { kind: "current" }, + command: { type: "node.add", nodeId: "n", nodeType: "x".repeat(513), position: { x: 0, y: 0 } }, + }), + false, + ); + assert.equal( + validRequest({ + protocol: PROTOCOL_VERSION, + type: "command", + id: "r", + expected: { kind: "current" }, + command: Object.create({ type: "undo" }), + }), + false, + ); + assert.equal( + validRequest({ protocol: PROTOCOL_VERSION, type: "load", id: "r", data: {}, expected: { kind: "current" } }), + true, + ); + assert.equal( + validRequest({ protocol: PROTOCOL_VERSION, type: "load", id: "r", layout: {}, expected: { kind: "current" } }), + false, + ); + assert.equal(validRequest({ protocol: PROTOCOL_VERSION, type: "save.data", id: "r" }), true); + assert.equal(validRequest({ protocol: PROTOCOL_VERSION, type: "save.data", id: "r", extra: true }), false); + assert.equal(validRequest(Object.create({ protocol: PROTOCOL_VERSION, type: "state.get", id: "r" })), false); + assert.equal(validRequest({ protocol: PROTOCOL_VERSION, type: "snapshot", id: "r" }), false); + assert.equal(validRequest({ protocol: PROTOCOL_VERSION, type: "state.get", id: "r" }), true); + let inspected = 0; + const opaque = Object.defineProperty({}, "nodes", { + enumerable: true, + get() { + inspected++; + throw new Error("payload inspected"); + }, + }); + assert.equal( + validRequest({ + protocol: PROTOCOL_VERSION, + type: "state.set", + id: "r", + state: opaque, + expected: { kind: "current" }, + }), + true, + ); + assert.equal(inspected, 0); + assert.equal( + validRequest({ + protocol: PROTOCOL_VERSION, + type: "state.set", + id: "r", + state: {}, + expected: { kind: "current" }, + extra: true, + }), + false, + ); + assert.equal( + validRequest({ + protocol: PROTOCOL_VERSION, + type: "view.resource.set", + viewId, + id: "r", + authorization: { viewId, token: "t", graphVersion: 2, compositionRevision: 0 }, + resource: { name: "x.png", mime: "image/png", bytes: new ArrayBuffer(1) }, + expected: { kind: "current" }, + }), + true, + ); + assert.equal( + validRequest( + Object.assign(Object.create({ event: { kind: "focus", phase: "focus" } }), { + protocol: PROTOCOL_VERSION, + type: "view.input", + viewId, + }), + ), + false, + ); + assert.equal( + validRequest({ + protocol: PROTOCOL_VERSION, + type: "view.pointer.flush", + viewId, + pointerFence: Object.create({ generation: 0 }), + }), + false, + ); +}); + +test("action requests, selection projections, menu results, and receipts are exact", () => { + const current = { kind: "current" as const }; + assert.equal( + validRequest({ + protocol: PROTOCOL_VERSION, + type: "view.node.add", + viewId, + id: "r", + nodeId: "n", + typeId: "t", + viewPosition: { x: 1, y: 2 }, + expected: current, + }), + true, + ); + assert.equal( + validRequest({ protocol: PROTOCOL_VERSION, type: "view.selection.remove", viewId, id: "r", expected: current }), + true, + ); + assert.equal( + validRequest({ + protocol: PROTOCOL_VERSION, + type: "view.selection.remove", + viewId, + id: "r", + expected: current, + value: false, + }), + false, + ); + assert.equal( + validRequest({ + protocol: PROTOCOL_VERSION, + type: "view.selection.mute", + viewId, + id: "r", + expected: current, + value: false, + }), + true, + ); + assert.equal( + validRequest({ protocol: PROTOCOL_VERSION, type: "view.selection.mute", viewId, id: "r", expected: current }), + false, + ); + const selection = (nodeCount: number, linkCount: number, canRemove: boolean) => ({ + protocol: PROTOCOL_VERSION, + type: "view.selection.host", + viewId, + projection: { nodeCount, linkCount, canRemove, mute: { enabled: false } }, + }); + assert.equal(validWorkerMessage(selection(0, 0, false)), true); + assert.equal(validWorkerMessage(selection(1, 0, true)), true); + assert.equal(validWorkerMessage(selection(0, 0, true)), false); + assert.equal(validWorkerMessage(selection(1, 0, false)), false); + assert.equal( + validWorkerMessage({ + protocol: PROTOCOL_VERSION, + type: "view.node-menu.result", + viewId, + requestId: "r", + open: false, + }), + true, + ); + assert.equal( + validWorkerMessage({ + protocol: PROTOCOL_VERSION, + type: "view.node-menu.result", + viewId, + requestId: "r", + open: true, + compositionRevision: 0, + viewPosition: { x: 1, y: 2 }, + }), + true, + ); + assert.equal( + validWorkerMessage({ + protocol: PROTOCOL_VERSION, + type: "view.node-menu.result", + viewId, + requestId: "r", + open: false, + viewPosition: { x: 1, y: 2 }, + }), + false, + ); + assert.equal(validCommandReceipt({ status: "noop", version: 0 }), true); + assert.equal(validCommandReceipt({ status: "committed", version: 1 }), true); + assert.equal(validCommandReceipt({ status: "noop", version: 0, extra: true }), false); +}); + +test("resource DTO and wire decoding is exact, bounded, and hostile-safe", () => { + const authorization = { viewId, token: "control", graphVersion: 2, compositionRevision: 3 }, + bytes = new ArrayBuffer(4), + resource = { name: "pixel.png", mime: "image/png", bytes }; + assert.deepEqual(decodeFxNodeResourceAuthorization(authorization), authorization); + assert.throws( + () => decodeFxNodeResourceAuthorization({ ...authorization, viewId: "" }), + new TypeError("Invalid resource authorization"), + ); + assert.throws( + () => decodeFxNodeResourceAuthorization({ ...authorization, extra: true }), + new TypeError("Invalid resource authorization"), + ); + assert.equal(decodeFxNodeResourceData(resource).bytes, bytes); + let reads = 0; + const accessor = Object.defineProperty({ token: "control", graphVersion: 2 }, "compositionRevision", { + enumerable: true, + get() { + reads++; + return 3; + }, + }); + assert.throws(() => decodeFxNodeResourceAuthorization(accessor), new TypeError("Invalid resource authorization")); + assert.equal(reads, 0); + assert.throws( + () => decodeFxNodeResourceData({ ...resource, [Symbol()]: true }), + new TypeError("Invalid resource data"), + ); + assert.throws( + () => decodeFxNodeResourceData({ ...resource, bytes: new ArrayBuffer(0) }), + new RangeError("Resource data is outside supported bounds"), + ); + const request = { + protocol: PROTOCOL_VERSION, + type: "view.resource.set", + viewId, + id: "r", + authorization, + resource, + expected: { kind: "exact", version: 2 }, + pointerFence: { generation: 1 }, + }; + assert.equal(validRequest(request), true); + assert.equal(validRequest({ ...request, authorization: { ...authorization, extra: true } }), false); + assert.equal(validRequest({ ...request, resource: { ...resource, name: "bad\nname" } }), false); + assert.equal(validRequest({ ...request, expected: { kind: "exact", version: -1 } }), false); + const nested = Proxy.revocable({ x: 1, y: 2 }, {}); + nested.revoke(); + assert.throws( + () => decodeFxNodeAddNodeParams({ typeId: "test", viewPosition: nested.proxy }), + new TypeError("Invalid add-node parameters"), + ); + const bytesProxy = Proxy.revocable(new ArrayBuffer(4), {}); + bytesProxy.revoke(); + assert.throws( + () => decodeFxNodeResourceData({ ...resource, bytes: bytesProxy.proxy }), + new TypeError("Invalid resource data"), + ); +}); + +test("malformed composition init remains recognizable protocol", () => { + const init = { + protocol: PROTOCOL_VERSION, + type: "init", + id: "init", + applicationId: "app", + applicationVersion: 1, + resources: {}, + historyLimit: 0, + }; + assert.equal(validRequest(init), true); + for (const presentation of [ + { viewport }, + { camera: { center: { x: 0, y: 0 }, zoom: 1 } }, + { pointerLane: new SharedArrayBuffer(32) }, + ]) + assert.equal(validRequest({ ...init, ...presentation }), false); + assert.equal( + validRequest({ + protocol: PROTOCOL_VERSION, + type: "init", + id: "init", + composition: {}, + layout: null, + historyLimit: 0, + }), + false, + ); + assert.equal( + validWorkerMessage({ + protocol: PROTOCOL_VERSION, + type: "response", + id: "init", + ok: false, + error: { + code: "composition.invalid", + message: "Invalid composition", + issues: [{ code: "composition.schema", path: "/", message: "invalid" }], + }, + }), + true, + ); + assert.equal( + validWorkerMessage({ + protocol: PROTOCOL_VERSION, + type: "response", + id: "init", + ok: false, + error: { code: "link.endpoint", message: "Missing endpoint", path: "/links/stale" }, + }), + true, + ); + assert.equal( + validWorkerMessage({ + protocol: PROTOCOL_VERSION, + type: "response", + id: "init", + ok: false, + error: { code: "link.endpoint", message: "Missing endpoint", path: "/" + "x".repeat(513) }, + }), + false, + ); + assert.equal( + validWorkerMessage(Object.create({ protocol: PROTOCOL_VERSION, type: "response", id: "init", ok: true })), + false, + ); + assert.equal( + validWorkerMessage({ + protocol: PROTOCOL_VERSION, + type: "response", + id: "init", + ok: false, + error: Object.create({ code: "worker.error", message: "inherited" }), + }), + false, + ); +}); + +test("live composition protocol validates updates, revisions, receipts and events", () => { + const request = (update: unknown, expected: unknown = { kind: "current" }) => ({ + protocol: PROTOCOL_VERSION, + type: "composition.update", + id: "request", + expected, + update, + }); + for (const update of [ + { kind: "composition.load", composition: {} }, + { kind: "theme.set", theme: {} }, + { kind: "compatibility.set", compatibility: { wildcardInputTypes: [] } }, + { kind: "socket.compose", id: "signal", definition: {} }, + { kind: "socket.remove", id: "signal" }, + { kind: "node.compose", id: "source", definition: {} }, + { kind: "node.remove", id: "source" }, + ]) + assert.equal(validRequest(request(update)), true); + assert.equal(validRequest(request({ kind: "composition.load", composition: {}, extra: true })), false); + for (const expected of [ + { kind: "exact", revision: -1 }, + { kind: "exact", revision: 0.5 }, + { kind: "exact", revision: Number.MAX_SAFE_INTEGER + 1 }, + ]) + assert.equal(validRequest(request({ kind: "node.remove", id: "source" }, expected)), false); + for (const id of ["", "x".repeat(129), "bad\nvalue", "__proto__", "prototype", "constructor"]) + assert.equal(validRequest(request({ kind: "node.remove", id })), false); + assert.equal(validRequest({ ...request({ kind: "node.remove", id: "source" }), extra: true }), false); + assert.equal(validRequest(request({ kind: "node.remove", id: "source", extra: true })), false); + assert.equal( + validCompositionReceipt({ + status: "committed", + revision: 1, + graphVersion: 0, + graphChanged: false, + historyReset: true, + }), + true, + ); + assert.equal( + validCompositionReceipt({ status: "noop", revision: 0, graphVersion: 0, graphChanged: false, historyReset: false }), + true, + ); + assert.equal( + validCompositionReceipt( + Object.create({ status: "noop", revision: 0, graphVersion: 0, graphChanged: false, historyReset: false }), + ), + false, + ); + assert.equal( + validCompositionReceipt({ status: "noop", revision: 0, graphVersion: 0, graphChanged: true, historyReset: false }), + false, + ); + assert.equal( + validCompositionReceipt({ + status: "committed", + revision: 1, + graphVersion: 0, + graphChanged: false, + historyReset: false, + }), + false, + ); + assert.equal( + validWorkerMessage({ + protocol: PROTOCOL_VERSION, + type: "composition.event", + envelope: { + baseRevision: 0, + revision: 1, + change: { kind: "node.compose", id: "source" }, + baseGraphVersion: 2, + graphVersion: 3, + graphChanged: true, + historyReset: true, + }, + }), + true, + ); + assert.equal( + validWorkerMessage({ + protocol: PROTOCOL_VERSION, + type: "composition.event", + envelope: { + baseRevision: 0, + revision: 2, + change: { kind: "theme.set" }, + baseGraphVersion: 2, + graphVersion: 2, + graphChanged: false, + historyReset: true, + }, + }), + false, + ); +}); diff --git a/vendor/fxnode/test/readme-snippets.compile.ts b/vendor/fxnode/test/readme-snippets.compile.ts new file mode 100644 index 0000000..77e4dba --- /dev/null +++ b/vendor/fxnode/test/readme-snippets.compile.ts @@ -0,0 +1,41 @@ +/** Compile-only mirror of README composition snippets; check:readme guards their key syntax. */ +import type { FxNode, FxNodeDefinition, FxNodeSocketTypeDefinition, FxNodeStyleDefinition } from "@lib/index.js"; +import { createFxNodeHeadless } from "@lib/headless.js"; +import { minimalStyles, numberSocket, valueNode } from "../examples/minimal/definition.js"; +import { colorBalanceNode } from "../examples/shared/nodes/color-balance.js"; +import { exampleTheme } from "../examples/shared/theme.js"; + +const floatSocket = [ + "float", + { title: "Float", color: "#a8a8a8", acceptsFrom: ["float"] }, +] as const satisfies readonly [string, FxNodeSocketTypeDefinition]; +const styles = { compositorColor: { header: "#8c5cc4" } } as const satisfies Readonly< + Record +>; +export async function installColorBalance(api: FxNode) { + await api.setTheme(exampleTheme); + await api.setHeaderStyles(styles); + await api.composeSocket(...floatSocket); + await api.composeNode(...colorBalanceNode); +} +export const gradingWheelsRow = { + kind: "widget", + widget: "grading-wheels", + bindings: [ + { title: "Lift", scalar: "lift", color: "liftColor" }, + { title: "Gamma", scalar: "gamma", color: "gammaColor" }, + { title: "Gain", scalar: "gain", color: "gainColor" }, + ], + visibleWhen: { parameter: "mode", equals: "Lift/Gamma/Gain" }, +} satisfies FxNodeDefinition["ui"][number]; +export const readmeHeadless = createFxNodeHeadless({ + schemaVersion: 2, + id: "fxnode.example.minimal", + version: 1, + compatibility: { wildcardInputTypes: [] }, + theme: exampleTheme, + socketTypes: { [numberSocket[0]]: numberSocket[1] }, + nodeStyles: minimalStyles, + resources: {}, + nodes: { [valueNode[0]]: valueNode[1] }, +} as const); diff --git a/vendor/fxnode/test/render-scheduler.test.ts b/vendor/fxnode/test/render-scheduler.test.ts new file mode 100644 index 0000000..72092cb --- /dev/null +++ b/vendor/fxnode/test/render-scheduler.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { DirtyReason, RenderScheduler, requestViewInvalidations } from "@lib/worker/render-scheduler.js"; + +test("atlas cleanup invalidations request worker viewport repaints", () => { + const requests: Array = []; + const views = new Map([ + ["a", { scheduler: { request: (id: number, reason: DirtyReason) => requests.push([id, reason]) } }], + ]); + requestViewInvalidations(["missing", "a"], views); + assert.deepEqual(requests, [[0, DirtyReason.Viewport]]); +}); + +test("continuous RAF polls while idle and preserves one-frame backpressure", () => { + const callbacks: Array<() => void> = []; + const draws: Array = []; + let polls = 0; + const scheduler = new RenderScheduler( + (...args) => draws.push(args), + (callback) => callbacks.push(callback), + ); + const tick = () => { + const callback = callbacks.shift(); + assert.ok(callback); + callback(); + }; + + scheduler.start(() => polls++); + assert.equal(callbacks.length, 1); + tick(); + assert.equal(polls, 1); + assert.equal(draws.length, 0); + assert.equal(callbacks.length, 1); + + scheduler.request(4, DirtyReason.Preview); + tick(); + assert.deepEqual(draws, [[1, 4, DirtyReason.Preview]]); + scheduler.request(5, DirtyReason.Scene); + tick(); + assert.equal(draws.length, 1); + scheduler.consumed(99); + tick(); + assert.equal(draws.length, 1); + scheduler.consumed(1); + tick(); + assert.deepEqual(draws[1], [2, 5, DirtyReason.Scene]); + assert.equal(scheduler.metrics.staleAcks, 1); + assert.equal(scheduler.metrics.maxInFlight, 1); + + scheduler.stop(); + tick(); + assert.equal(callbacks.length, 0); +}); + +test("requests coalesce and only a matching defer retries the same reasons", () => { + const callbacks: Array<() => void> = [], + draws: Array = []; + const scheduler = new RenderScheduler( + (...args) => draws.push(args), + (callback) => callbacks.push(callback), + ); + const tick = () => callbacks.shift()!(); + scheduler.start(); + scheduler.request(2, DirtyReason.Scene); + scheduler.request(7, DirtyReason.Selection); + tick(); + assert.deepEqual(draws, [[1, 7, DirtyReason.Scene | DirtyReason.Selection]]); + + scheduler.defer(99, DirtyReason.Camera); + tick(); + assert.equal(draws.length, 1, "a stale defer must not clear the in-flight frame"); + scheduler.defer(1, DirtyReason.Scene | DirtyReason.Selection); + tick(); + assert.deepEqual(draws[1], [2, 7, DirtyReason.Scene | DirtyReason.Selection]); + + scheduler.consumed(1); + tick(); + assert.equal(draws.length, 2, "a stale ack must not consume the retried frame"); + scheduler.consumed(2); + tick(); + assert.equal(draws.length, 2); + scheduler.stop(); +}); diff --git a/vendor/fxnode/test/view-atlas-manager.test.ts b/vendor/fxnode/test/view-atlas-manager.test.ts new file mode 100644 index 0000000..8c8b37c --- /dev/null +++ b/vendor/fxnode/test/view-atlas-manager.test.ts @@ -0,0 +1,311 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { ViewAtlasError, ViewAtlasManager, type ViewAtlasPlatform } from "@lib/worker/view-atlas.js"; + +class FakeBitmap { + width = 1; + height = 1; + closed = false; + close() { + this.closed = true; + } +} +class FakeCanvas extends EventTarget { + context = {} as OffscreenCanvasRenderingContext2D; + constructor( + public width: number, + public height: number, + ) { + super(); + } + getContext() { + return this.context; + } +} +function platform(options: { failCrop?: boolean; deferred?: { resolve: (bitmap: FakeBitmap) => void } } = {}) { + const canvases: FakeCanvas[] = [], + bitmaps: FakeBitmap[] = []; + const value: ViewAtlasPlatform = { + createCanvas(width, height) { + const canvas = new FakeCanvas(width, height); + canvases.push(canvas); + return canvas as unknown as OffscreenCanvas; + }, + async createBitmap() { + if (options.failCrop) throw new Error("crop"); + if (options.deferred) + return new Promise((resolve) => (options.deferred!.resolve = resolve)) as Promise; + const bitmap = new FakeBitmap(); + bitmaps.push(bitmap); + return bitmap as unknown as ImageBitmap; + }, + }; + return { value, canvases, bitmaps }; +} + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +test("atlas manager is lazy, serialized, and releases its only canvas after last detach", async () => { + const fake = platform(), + atlas = new ViewAtlasManager(fake.value); + assert.equal(atlas.surface(), undefined); + const [a, b] = await Promise.all([ + atlas.attach("a", { width: 100, height: 80 }), + atlas.attach("b", { width: 90, height: 70 }), + ]); + assert.equal(fake.canvases.length, 1); + assert.equal(a.slot.viewId, "a"); + assert.equal(b.slot.viewId, "b"); + assert.deepEqual(await atlas.detach("a"), { invalidatedViewIds: [] }); + assert(atlas.surface()); + assert.deepEqual(await atlas.detach("b"), { invalidatedViewIds: [] }); + assert.equal(atlas.surface(), undefined); + await atlas.attach("c", { width: 20, height: 20 }); + assert.equal(fake.canvases.length, 2); +}); + +test("detach reports only survivors invalidated by successful compaction", async () => { + const fake = platform(), + atlas = new ViewAtlasManager(fake.value); + await atlas.attach("large", { width: 1000, height: 1000 }); + await atlas.attach("small", { width: 100, height: 100 }); + assert.deepEqual(await atlas.detach("large"), { invalidatedViewIds: ["small"] }); + assert(atlas.slot("small")); +}); + +test("failed detach compaction with a successful rollback invalidates every survivor", async () => { + const fake = platform(), + atlas = new ViewAtlasManager(fake.value); + await atlas.attach("large", { width: 1000, height: 1000 }); + await atlas.attach("small-a", { width: 100, height: 100 }); + await atlas.attach("small-b", { width: 90, height: 90 }); + let probes = 0; + fake.value.createBitmap = async () => { + if (probes++ === 0) throw new Error("compact probe failed"); + return new FakeBitmap() as unknown as ImageBitmap; + }; + assert.deepEqual(await atlas.detach("large"), { + invalidatedViewIds: ["small-a", "small-b"], + }); + assert(atlas.slot("small-a")); + assert(atlas.slot("small-b")); +}); + +test("failed surface transition with successful rollback stales all previous slots", async () => { + const fake = platform(), + atlas = new ViewAtlasManager(fake.value); + await atlas.attach("a", { width: 100, height: 100 }); + const generation = atlas.surface()!.atlasGeneration, + slotGeneration = atlas.slot("a")!.slotGeneration; + let probes = 0; + fake.value.createBitmap = async () => { + if (probes++ === 0) throw new Error("new surface probe failed"); + return new FakeBitmap() as unknown as ImageBitmap; + }; + await assert.rejects(atlas.attach("b", { width: 1000, height: 1000 }), (error: unknown) => { + assert(error instanceof ViewAtlasError); + assert.equal(error.code, "atlas.crop"); + return true; + }); + assert.equal(atlas.slot("b"), undefined); + assert(atlas.slot("a")!.slotGeneration > slotGeneration); + assert(atlas.surface()!.atlasGeneration > generation); +}); + +test("failed first probe leaves the manager empty and retryable", async () => { + const bad = platform({ failCrop: true }), + atlas = new ViewAtlasManager(bad.value); + await assert.rejects(atlas.attach("a", { width: 10, height: 10 }), (error: unknown) => { + assert(error instanceof ViewAtlasError); + assert.equal(error.code, "atlas.crop"); + return true; + }); + assert.equal(atlas.surface(), undefined); + bad.value.createBitmap = async () => new FakeBitmap() as unknown as ImageBitmap; + await atlas.attach("a", { width: 10, height: 10 }); + assert(atlas.surface()); +}); + +test("dispose during a pending probe cannot resurrect atlas state", async () => { + const deferred = { resolve: (_bitmap: FakeBitmap) => {} }, + fake = platform({ deferred }), + atlas = new ViewAtlasManager(fake.value), + attached = atlas.attach("a", { width: 10, height: 10 }); + await new Promise((resolve) => setTimeout(resolve, 0)); + atlas.dispose(); + const bitmap = new FakeBitmap(); + deferred.resolve(bitmap); + await assert.rejects(attached, { name: "ViewAtlasError" }); + assert.equal(bitmap.closed, true); + assert.equal(atlas.surface(), undefined); +}); + +test("context loss during initial probe cannot expose the lost candidate", async () => { + const deferred = { resolve: (_bitmap: FakeBitmap) => {} }, + fake = platform({ deferred }), + atlas = new ViewAtlasManager(fake.value), + attached = atlas.attach("a", { width: 10, height: 10 }); + await tick(); + fake.canvases[0]!.dispatchEvent(new Event("contextlost", { cancelable: true })); + deferred.resolve(new FakeBitmap()); + await assert.rejects(attached, (error: unknown) => { + assert(error instanceof ViewAtlasError); + assert.equal(error.code, "atlas.context-lost"); + return true; + }); + assert.equal(atlas.surface(), undefined); +}); + +test("restoration events from a discarded candidate cannot affect its replacement", async () => { + const deferred = { resolve: (_bitmap: FakeBitmap) => {} }, + fake = platform({ deferred }), + fatals: ViewAtlasError[] = [], + atlas = new ViewAtlasManager(fake.value, (error) => fatals.push(error)), + first = atlas.attach("a", { width: 10, height: 10 }); + await tick(); + const discarded = fake.canvases[0]!; + discarded.dispatchEvent(new Event("contextlost", { cancelable: true })); + discarded.dispatchEvent(new Event("contextrestored")); + deferred.resolve(new FakeBitmap()); + await assert.rejects(first, { name: "ViewAtlasError" }); + fake.value.createBitmap = async () => new FakeBitmap() as unknown as ImageBitmap; + await atlas.attach("b", { width: 10, height: 10 }); + const generation = atlas.surface()!.atlasGeneration; + discarded.dispatchEvent(new Event("contextrestored")); + await tick(); + assert.equal(atlas.surface()!.atlasGeneration, generation); + assert.deepEqual(fatals, []); +}); + +test("dispose during an existing resize does not enter rollback or republish state", async () => { + const fake = platform(), + atlas = new ViewAtlasManager(fake.value); + await atlas.attach("a", { width: 10, height: 10 }); + let resolve = (_bitmap: FakeBitmap) => {}; + fake.value.createBitmap = () => new Promise((done) => (resolve = done)) as Promise; + const resized = atlas.resize("a", { width: 400, height: 400 }); + await tick(); + atlas.dispose(); + resolve(new FakeBitmap()); + await assert.rejects(resized, { name: "ViewAtlasError" }); + assert.equal(atlas.surface(), undefined); +}); + +test("context loss hides the surface and restoration reprobes before exposing it", async () => { + const fake = platform(), + fatals: ViewAtlasError[] = [], + atlas = new ViewAtlasManager(fake.value, (error) => fatals.push(error)); + await atlas.attach("a", { width: 10, height: 10 }); + const before = atlas.surface()!; + fake.canvases[0]!.dispatchEvent(new Event("contextlost", { cancelable: true })); + assert.equal(atlas.surface(), undefined); + fake.canvases[0]!.dispatchEvent(new Event("contextrestored")); + for (let index = 0; index < 20 && !atlas.surface(); index++) await new Promise((resolve) => setTimeout(resolve, 0)); + const restored = atlas.surface()!; + assert(restored); + assert(restored.atlasGeneration > before.atlasGeneration); + assert.deepEqual(fatals, []); + await atlas.detach("a"); + await atlas.attach("b", { width: 10, height: 10 }); + assert(atlas.surface()); +}); + +test("a stale restoration cannot expose or poison a newer context loss", async () => { + const fake = platform(), + fatals: ViewAtlasError[] = [], + atlas = new ViewAtlasManager(fake.value, (error) => fatals.push(error)); + await atlas.attach("a", { width: 10, height: 10 }); + const canvas = fake.canvases[0]!; + let rejectRestore = (_error: Error) => {}; + fake.value.createBitmap = () => new Promise((_resolve, reject) => (rejectRestore = reject)); + canvas.dispatchEvent(new Event("contextlost", { cancelable: true })); + canvas.dispatchEvent(new Event("contextrestored")); + await tick(); + canvas.dispatchEvent(new Event("contextlost", { cancelable: true })); + rejectRestore(new Error("stale restoration")); + await tick(); + assert.equal(atlas.surface(), undefined); + assert.deepEqual(fatals, []); + fake.value.createBitmap = async () => new FakeBitmap() as unknown as ImageBitmap; + canvas.dispatchEvent(new Event("contextrestored")); + for (let index = 0; index < 20 && !atlas.surface(); index++) await tick(); + assert(atlas.surface()); + assert.deepEqual(fatals, []); +}); + +test("renderAndCrop clips, clears, paints, and crops a slot without yielding", async () => { + const calls: unknown[][] = [], + context = new Proxy( + {}, + { + get: + (_target, property) => + (...args: unknown[]) => + calls.push([property, ...args]), + }, + ) as OffscreenCanvasRenderingContext2D, + canvases: FakeCanvas[] = []; + const crops: number[][] = []; + const atlas = new ViewAtlasManager({ + createCanvas: (width, height) => { + const canvas = new FakeCanvas(width, height); + canvas.context = context; + canvases.push(canvas); + return canvas as unknown as OffscreenCanvas; + }, + async createBitmap(_canvas, x, y, width, height) { + crops.push([x, y, width, height]); + return Object.assign(new FakeBitmap(), { width, height }) as unknown as ImageBitmap; + }, + }); + await atlas.attach("a", { width: 12, height: 8 }); + calls.length = crops.length = 0; + const bitmap = await atlas.renderAndCrop("a", { width: 12, height: 8 }, (target) => { + calls.push(["paint", target.deviceX, target.deviceY, target.deviceWidth, target.deviceHeight]); + }); + assert(bitmap); + assert.deepEqual(crops, [[0, 0, 12, 8]]); + assert.deepEqual(calls.slice(0, 6), [ + ["save"], + ["setTransform", 1, 0, 0, 1, 0, 0], + ["beginPath"], + ["rect", 0, 0, 12, 8], + ["clip"], + ["clearRect", 0, 0, 12, 8], + ]); + assert.deepEqual(calls.at(-2), ["paint", 0, 0, 12, 8]); + assert.deepEqual(calls.at(-1), ["restore"]); +}); + +test("detach waits for a delayed render crop and only then retires its slot", async () => { + const fake = platform(), + atlas = new ViewAtlasManager(fake.value); + await atlas.attach("a", { width: 10, height: 10 }); + Object.assign(fake.canvases[0]!.context, { + save() {}, + setTransform() {}, + beginPath() {}, + rect() {}, + clip() {}, + clearRect() {}, + restore() {}, + }); + let resolve = (_bitmap: FakeBitmap) => {}, + cropStarted = false; + fake.value.createBitmap = () => + new Promise((done) => { + cropStarted = true; + resolve = done; + }) as Promise; + const rendered = atlas.renderAndCrop("a", { width: 10, height: 10 }, () => {}), + detached = atlas.detach("a"); + for (let index = 0; index < 10 && !cropStarted; index++) await tick(); + assert.equal(cropStarted, true); + assert(atlas.slot("a"), "detach must wait behind the crop"); + const bitmap = Object.assign(new FakeBitmap(), { width: 10, height: 10 }); + resolve(bitmap); + assert.equal(await rendered, bitmap, "the crop ticket remains valid until it is returned"); + assert.equal(bitmap.closed, false); + assert.deepEqual(await detached, { invalidatedViewIds: [] }); + assert.equal(atlas.slot("a"), undefined); +}); diff --git a/vendor/fxnode/test/worker-journal.test.ts b/vendor/fxnode/test/worker-journal.test.ts new file mode 100644 index 0000000..23da587 --- /dev/null +++ b/vendor/fxnode/test/worker-journal.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { FxNodeSaveData } from "@lib/commands/types.js"; +import { createFxNodeHeadless } from "@lib/headless-runtime.js"; +import { nodeId } from "@lib/core/types.js"; +import { advanceJournal, checkpointJournal, importJournal, journalSaveData } from "@lib/worker/journal.js"; +import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js"; + +const composition = { + ...APPLICATION_COMPILED.source, + id: "journal", + version: 1, + nodes: { value: APPLICATION_COMPILED.source.nodes["fxnode.shader.value"]! }, + }, + runtime = createFxNodeHeadless(composition), + baseline = runtime.save(runtime.emptyDocument()), + authority = composition, + valid = () => true; +const add = { type: "node.add", nodeId: nodeId("n"), nodeType: "value", position: { x: 0, y: 0 } } as const, + move = { type: "node.move", id: nodeId("n"), position: { x: 1, y: 2 } } as const; + +test("worker journal folds forward undo redo and clears redo on a new forward", () => { + const empty = checkpointJournal(baseline), + one = advanceJournal(empty, add, baseline, valid), + two = advanceJournal(one, move, baseline, valid); + assert.deepEqual(journalSaveData(two, authority).commands, [add, move]); + const undone = advanceJournal(two, { type: "undo" }, baseline, valid); + assert.deepEqual(journalSaveData(undone, authority).commands, [add]); + assert.equal(undone.redo.length, 1); + const redone = advanceJournal(undone, { type: "redo" }, baseline, valid); + assert.deepEqual(journalSaveData(redone, authority).commands, [add, move]); + const replaced = advanceJournal(undone, { ...move, position: { x: 3, y: 4 } }, baseline, valid); + assert.equal(replaced.redo.length, 0); + assert.deepEqual(journalSaveData(replaced, authority).commands.at(-1), { ...move, position: { x: 3, y: 4 } }); + assert.equal(Object.isFrozen(replaced), true); + assert.equal(Object.isFrozen(journalSaveData(replaced, authority)), true); +}); + +test("worker journal checkpoints on strict replay failure and imported journals append", () => { + const one = advanceJournal(checkpointJournal(baseline), add, baseline, valid), + saved = journalSaveData(one, authority), + imported = importJournal(saved as unknown as FxNodeSaveData), + appended = advanceJournal(imported, move, baseline, valid); + assert.deepEqual(journalSaveData(appended, authority).commands, [add, move]); + const checkpointed = advanceJournal(one, move, baseline, () => false); + assert.equal(checkpointed.applied.length, 0); + assert.equal(JSON.stringify(checkpointed.baseline), JSON.stringify(baseline)); + const undone = advanceJournal(checkpointed, { type: "undo" }, { ...baseline, metadata: { after: "undo" } }, valid); + assert.deepEqual(undone.baseline.metadata, { after: "undo" }); + assert.equal(undone.applied.length, 0); + const oversized = advanceJournal( + one, + { type: "node.label", id: nodeId("n"), label: "x".repeat(1_048_577) }, + { ...baseline, metadata: { after: "large command" } }, + valid, + ); + assert.deepEqual(oversized.baseline.metadata, { after: "large command" }); + assert.equal(oversized.applied.length, 0); +}); + +test("strict callback receives only baseline and applied commands", () => { + let received: readonly unknown[] = []; + const next = advanceJournal(checkpointJournal(baseline), add, baseline, (candidate, commands) => { + received = [candidate, commands]; + return true; + }); + assert.equal(received[0], next.baseline); + assert.deepEqual(received[1], [add]); + assert.equal("composition" in (received[0] as object), false); +}); diff --git a/vendor/fxnode/tools/all-supported.ts b/vendor/fxnode/tools/all-supported.ts new file mode 100644 index 0000000..f106540 --- /dev/null +++ b/vendor/fxnode/tools/all-supported.ts @@ -0,0 +1,132 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "../test/application.js"; +import { graphId, linkId, socketId, type GraphDocument, type GraphLink } from "@lib/core/types.js"; +import { nullRecord } from "@lib/core/json.js"; + +const { decodeGraphDocument, materializeNode, save } = APPLICATION_HEADLESS; +const definitions = [...APPLICATION_COMPILED.nodes.values()]; +const positions = { common: 0, shader: -280, geometry: -560, compositor: -840 }; +// This visual fixture intentionally pins authored document sizes. Fresh nodes use calculated dimensions. +const authoredSizes: Readonly> = { + "fxnode.common.frame": { x: 300, y: 100 }, + "fxnode.common.reroute": { x: 140, y: 100 }, + "fxnode.common.group-input": { x: 180, y: 100 }, + "fxnode.common.group-output": { x: 180, y: 100 }, + "fxnode.shader.value": { x: 140, y: 100 }, + "fxnode.shader.color": { x: 140, y: 100 }, + "fxnode.shader.math": { x: 180, y: 100 }, + "fxnode.shader.vector-math": { x: 190, y: 100 }, + "fxnode.shader.mix": { x: 190, y: 100 }, + "fxnode.shader.color-ramp": { x: 320, y: 100 }, + "fxnode.shader.texture-coordinate": { x: 190, y: 100 }, + "fxnode.shader.noise-texture": { x: 200, y: 100 }, + "fxnode.shader.image-texture": { x: 280, y: 100 }, + "fxnode.shader.principled-bsdf": { x: 220, y: 100 }, + "fxnode.shader.material-output": { x: 190, y: 100 }, + "fxnode.geometry.position": { x: 140, y: 100 }, + "fxnode.geometry.mesh-cube": { x: 190, y: 100 }, + "fxnode.geometry.set-position": { x: 190, y: 100 }, + "fxnode.geometry.transform-geometry": { x: 210, y: 100 }, + "fxnode.geometry.join-geometry": { x: 180, y: 100 }, + "fxnode.compositor.image": { x: 240, y: 100 }, + "fxnode.compositor.color-balance": { x: 400, y: 100 }, +}; +const nodes = definitions.map((definition) => { + const family = definition.typeId.split(".")[1] as keyof typeof positions; + const peers = definitions.filter((item) => item.typeId.split(".")[1] === family); + const materialized = materializeNode(`all-${definition.typeId.replaceAll(".", "-")}`, definition.typeId, { + x: 40 + peers.indexOf(definition) * 240, + y: positions[family], + }); + const node = { ...materialized, size: authoredSizes[definition.typeId] ?? materialized.size }; + if (definition.typeId === "fxnode.shader.color-ramp") + return { + ...node, + parameters: { + ramp: { + kind: "json" as const, + value: { + colorMode: "hsv", + interpolation: "ease", + hueInterpolation: "far", + stops: [ + { id: "black", position: 0, color: [0, 0, 0, 1] }, + { id: "accent", position: 0.38, color: [0.05, 0.35, 1, 1] }, + { id: "white", position: 1, color: [1, 1, 1, 1] }, + ], + }, + }, + }, + } as typeof node; + if (definition.typeId === "fxnode.shader.noise-texture") + return { + ...node, + parameters: { + ...node.parameters, + dimensions: { kind: "string" as const, value: "4d" }, + noiseType: { kind: "string" as const, value: "hybrid-multifractal" }, + }, + } as typeof node; + return node; +}); +const byType = (type: string) => nodes.find((node) => node.typeId === type)!; +const cube = byType("fxnode.geometry.mesh-cube"), + position = byType("fxnode.geometry.position"), + set = byType("fxnode.geometry.set-position"), + transform = byType("fxnode.geometry.transform-geometry"), + join = byType("fxnode.geometry.join-geometry"); +const make = (id: string, from: typeof cube, fromKey: string, to: typeof join, toKey: string): GraphLink => ({ + id: linkId(id), + fromNodeId: from.id, + fromSocketId: socketId(`${from.id}:${fromKey}`), + toNodeId: to.id, + toSocketId: socketId(`${to.id}:${toKey}`), + muted: false, + extensions: {}, +}); +const links = [ + make("all-link-position", position, "position", set, "position"), + make("all-link-cube", cube, "mesh", set, "geometry"), + make("all-link-set", set, "result", join, "geometry"), + make("all-link-transform", transform, "result", join, "geometry"), +]; +const document: GraphDocument = { + schemaVersion: 2, + graphId: graphId("all-supported"), + catalogVersion: APPLICATION_COMPILED.source.version, + nodes: nullRecord(nodes.map((node) => [node.id, node])), + links: nullRecord(links.map((link) => [link.id, link])), + metadata: nullRecord(), +}; +const saved = save(document); +const { schemaVersion: _schemaVersion, ...persistedLayout } = saved; +export const initialLayout = { + ...persistedLayout, + nodes: persistedLayout.nodes.map((node) => ({ ...node, known: true })), +}; +const path = new URL("../examples/blender/all-supported/initialLayout.json", import.meta.url), + canonical = JSON.stringify(initialLayout, null, 2) + "\n"; +if (process.argv.includes("--write")) writeFileSync(path, canonical); +else { + const bytes = readFileSync(path, "utf8"), + actual = JSON.parse(bytes), + decoded = decodeGraphDocument({ + ...actual, + schemaVersion: 2, + nodes: actual.nodes.map(({ known: _, ...node }: any) => node), + }); + if (!decoded.ok) throw new Error(`Initial layout decode failed: ${JSON.stringify(decoded.issues)}`); + const ids = actual.nodes.map((node: { typeId: string }) => node.typeId), + expected = definitions.length; + if ( + ids.length !== expected || + new Set(ids).size !== expected || + definitions.some((item) => !ids.includes(item.typeId)) + ) + throw new Error("Fixture must contain each application type exactly once"); + if (JSON.stringify(actual) !== JSON.stringify(initialLayout) || bytes !== canonical) + throw new Error("Initial layout is not canonical; run generate:all-supported"); + if (actual.links.filter((link: { toNodeId: string }) => link.toNodeId === join.id).length < 2) + throw new Error("Join Geometry needs two incoming links"); + console.log("all-supported fixture verified"); +} diff --git a/vendor/fxnode/tools/blender-fixture.ts b/vendor/fxnode/tools/blender-fixture.ts new file mode 100644 index 0000000..7b09d28 --- /dev/null +++ b/vendor/fxnode/tools/blender-fixture.ts @@ -0,0 +1,79 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { APPLICATION_HEADLESS } from "../test/application.js"; +import { APPLICATION_VERSION } from "../examples/blender/nodes/application.js"; +import { + graphId, + linkId, + nodeId, + socketId, + type GraphDocument, + type GraphLink, + type GraphNode, +} from "@lib/core/types.js"; +import { nullRecord } from "@lib/core/json.js"; + +const { materializeNode, save } = APPLICATION_HEADLESS; +const node = ( + id: string, + typeId: Parameters[1], + position: { readonly x: number; readonly y: number }, + size: { readonly x: number; readonly y: number }, + options: { readonly label?: string; readonly collapsed?: boolean; readonly parentId?: string } = {}, +): GraphNode => ({ + ...materializeNode(id, typeId, position), + size, + ...(options.label === undefined ? {} : { label: options.label }), + ...(options.collapsed === undefined ? {} : { collapsed: options.collapsed }), + ...(options.parentId === undefined ? {} : { parentId: nodeId(options.parentId) }), +}); + +const nodes = [ + node("frame", "fxnode.common.frame", { x: -550, y: 250 }, { x: 190, y: 270 }, { label: "Surface Controls" }), + node("value-expanded", "fxnode.shader.value", { x: 30, y: -55 }, { x: 140, y: 100 }, { parentId: "frame" }), + node( + "value-collapsed", + "fxnode.shader.value", + { x: 30, y: -175 }, + { x: 140, y: 100 }, + { collapsed: true, parentId: "frame" }, + ), + node("math", "fxnode.shader.math", { x: -300, y: 170 }, { x: 160, y: 110 }), + node("noise", "fxnode.shader.noise-texture", { x: -100, y: 190 }, { x: 165, y: 130 }), + node("reroute", "fxnode.common.reroute", { x: 105, y: 55 }, { x: 140, y: 100 }), + node("principled", "fxnode.shader.principled-bsdf", { x: 165, y: 175 }, { x: 185, y: 125 }), + node("output", "fxnode.shader.material-output", { x: 405, y: 115 }, { x: 155, y: 100 }), +]; +const byId = nullRecord(nodes.map((item) => [item.id, item])); +const link = (id: string, fromNodeId: string, fromKey: string, toNodeId: string, toKey: string): GraphLink => ({ + id: linkId(id), + fromNodeId: byId[fromNodeId]!.id, + fromSocketId: socketId(`${fromNodeId}:${fromKey}`), + toNodeId: byId[toNodeId]!.id, + toSocketId: socketId(`${toNodeId}:${toKey}`), + muted: false, + extensions: {}, +}); +const links = [ + link("link-1", "value-expanded", "value", "math", "a"), + link("link-2", "value-collapsed", "value", "math", "b"), + link("link-3", "math", "value", "noise", "scale"), + link("link-4", "noise", "factor", "reroute", "input"), + link("link-5", "reroute", "output", "principled", "roughness"), + link("link-6", "principled", "bsdf", "output", "surface"), +]; +const document: GraphDocument = { + schemaVersion: 2, + graphId: graphId("phase-4-example"), + catalogVersion: APPLICATION_VERSION, + nodes: byId, + links: nullRecord(links.map((item) => [item.id, item])), + metadata: nullRecord([["description", "Deterministic fxnode browser baseline"]]), +}; +const path = new URL("../examples/blender/initialLayout.json", import.meta.url); +const saved = save(document); +const { schemaVersion: _schemaVersion, ...layout } = saved; +const canonical = `${JSON.stringify({ ...layout, nodes: layout.nodes.map((node) => ({ ...node, known: true })) }, null, 2)}\n`; +if (process.argv.includes("--write")) writeFileSync(path, canonical); +else if (readFileSync(path, "utf8") !== canonical) + throw new Error("Blender fixture is not canonical; run npm run generate:blender-fixture"); +else console.log("blender fixture verified"); diff --git a/vendor/fxnode/tools/blender/create-reference-fixtures.py b/vendor/fxnode/tools/blender/create-reference-fixtures.py new file mode 100644 index 0000000..9d1ea06 --- /dev/null +++ b/vendor/fxnode/tools/blender/create-reference-fixtures.py @@ -0,0 +1,106 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Create deterministic, project-authored Blender node-editor reference fixtures.""" + +import argparse +import sys +from pathlib import Path + +import bpy + +FIXTURES = ( + "shader-basic-linked-near", "shader-basic-linked-far", + "shader-selected-active-hover-near", "shader-collapsed-near", + "common-frame-reroute-near", "shader-widget-rich-near", + "shader-socket-gallery-near", "geometry-socket-gallery-near", +) + + +def arguments(): + parser = argparse.ArgumentParser() + parser.add_argument("--fixture", choices=FIXTURES, required=True) + parser.add_argument("--save", type=Path) + return parser.parse_args(sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []) + + +def add(nodes, node_type, name, x, y): + node = nodes.new(node_type) + node.name = name + node.label = name + node.location = (x, y) + return node + + +def shader_tree(fixture): + material = bpy.data.materials.new("fxnode-reference") + material.use_nodes = True + tree = material.node_tree + tree.nodes.clear() + nodes, links = tree.nodes, tree.links + output = add(nodes, "ShaderNodeOutputMaterial", "Material Output", 520, 0) + principled = add(nodes, "ShaderNodeBsdfPrincipled", "Principled BSDF", 160, 0) + noise = add(nodes, "ShaderNodeTexNoise", "Noise Texture", -440, 160) + ramp = add(nodes, "ShaderNodeValToRGB", "Color Ramp", -140, 180) + links.new(noise.outputs["Fac"], ramp.inputs["Fac"]) + links.new(ramp.outputs["Color"], principled.inputs["Base Color"]) + links.new(principled.outputs["BSDF"], output.inputs["Surface"]) + if fixture == "shader-collapsed-near": + noise.hide = True + ramp.hide = True + elif fixture == "common-frame-reroute-near": + frame = add(nodes, "NodeFrame", "Texture controls", -500, 100) + noise.parent = frame + reroute = add(nodes, "NodeReroute", "Reroute", 60, 260) + links.new(ramp.outputs["Color"], reroute.inputs[0]) + links.new(reroute.outputs[0], principled.inputs["Base Color"]) + elif fixture == "shader-widget-rich-near": + add(nodes, "ShaderNodeMix", "Mix", -140, -240) + add(nodes, "ShaderNodeMath", "Math", 160, -280) + elif fixture == "shader-socket-gallery-near": + add(nodes, "ShaderNodeTexCoord", "Texture Coordinate", -700, -260) + add(nodes, "ShaderNodeVectorMath", "Vector Math", -400, -260) + add(nodes, "ShaderNodeValue", "Value", -100, -320) + add(nodes, "ShaderNodeRGB", "Color", 160, -320) + nodes.active = principled + principled.select = True + return tree + + +def geometry_tree(): + obj = bpy.data.objects.new("fxnode-geometry", bpy.data.meshes.new("fxnode-mesh")) + bpy.context.collection.objects.link(obj) + bpy.context.view_layer.objects.active = obj + modifier = obj.modifiers.new("fxnode-reference", "NODES") + tree = bpy.data.node_groups.new("fxnode-geometry", "GeometryNodeTree") + modifier.node_group = tree + nodes = tree.nodes + for node_type, name, x, y in ( + ("GeometryNodeInputPosition", "Position", -600, 200), + ("GeometryNodeMeshCube", "Mesh Cube", -600, -100), + ("GeometryNodeSetPosition", "Set Position", -200, 80), + ("GeometryNodeTransform", "Transform Geometry", 160, 80), + ("GeometryNodeJoinGeometry", "Join Geometry", 500, 80), + ): + add(nodes, node_type, name, x, y) + return tree + + +def main(): + args = arguments() + if bpy.app.version[:2] != (4, 5): + raise RuntimeError(f"Blender 4.5.x required, found {bpy.app.version_string}") + bpy.ops.wm.read_factory_settings(use_empty=True) + tree = geometry_tree() if args.fixture == "geometry-socket-gallery-near" else shader_tree(args.fixture) + for area in bpy.context.screen.areas: + if area.type == "NODE_EDITOR": + area.spaces.active.pin = True + area.spaces.active.geometry_nodes_type = "MODIFIER" if tree.bl_idname == "GeometryNodeTree" else "MODIFIER" + with bpy.context.temp_override(area=area): + bpy.ops.node.view_all() + if args.fixture == "shader-basic-linked-far": + bpy.ops.view2d.zoom_out(zoomfacx=3.0, zoomfacy=3.0) + if args.save: + bpy.ops.wm.save_as_mainfile(filepath=str(args.save.resolve())) + + +if __name__ == "__main__": + main() diff --git a/vendor/fxnode/tools/browser-fixtures.ts b/vendor/fxnode/tools/browser-fixtures.ts new file mode 100644 index 0000000..f27f252 --- /dev/null +++ b/vendor/fxnode/tools/browser-fixtures.ts @@ -0,0 +1,106 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { APPLICATION_HEADLESS } from "../test/application.js"; +import { APPLICATION_VERSION } from "../examples/blender/nodes/application.js"; + +const { materializeNode } = APPLICATION_HEADLESS; +const plain = (id: string, type: Parameters[1], x: number, y: number) => { + return materializeNode(id, type, { x, y }); +}; +const byId = (items: readonly T[]): T[] => + [...items].sort((left, right) => left.id.localeCompare(right.id)); +const fixtures: Record = { + "control-test": { + graphId: "control-test", + catalogVersion: APPLICATION_VERSION, + nodes: byId([ + plain("value", "fxnode.shader.value", -500, 200), + plain("math", "fxnode.shader.math", -250, 200), + plain("vector", "fxnode.shader.vector-math", 20, 200), + plain("color", "fxnode.shader.color", 300, 200), + plain("group", "fxnode.common.group-input", -100, -100), + ]), + links: [ + { + id: "value-math", + fromNodeId: "value", + fromSocketId: "value:value", + toNodeId: "math", + toSocketId: "math:a", + muted: false, + extensions: {}, + }, + ], + metadata: {}, + }, + "link-tools-test": (() => { + const nodes = [ + plain("source-a", "fxnode.shader.value", -560, 240), + plain("math-a", "fxnode.shader.math", 180, 240), + plain("source-b", "fxnode.shader.value", -560, 120), + plain("math-b", "fxnode.shader.math", 180, 120), + plain("source-c", "fxnode.shader.value", -560, 0), + plain("math-c", "fxnode.shader.math", 180, 0), + plain("chain-source", "fxnode.shader.value", -560, -100), + plain("reroute", "fxnode.common.reroute", 0, -100), + plain("chain-math", "fxnode.shader.math", 260, -100), + plain("transform", "fxnode.geometry.transform-geometry", -300, -230), + plain("noise", "fxnode.shader.noise-texture", 180, -230), + ]; + const links = [ + ["parallel-a", "source-a", "source-a:value", "math-a", "math-a:a"], + ["parallel-b", "source-b", "source-b:value", "math-b", "math-b:a"], + ["parallel-c", "source-c", "source-c:value", "math-c", "math-c:a"], + ["chain-in", "chain-source", "chain-source:value", "reroute", "reroute:input"], + ["chain-out", "reroute", "reroute:output", "chain-math", "chain-math:a"], + ] as const; + return { + graphId: "link-tools-test", + catalogVersion: APPLICATION_VERSION, + nodes: byId(nodes), + links: byId( + links.map(([id, fromNodeId, fromSocketId, toNodeId, toSocketId]) => ({ + id, + fromNodeId, + fromSocketId, + toNodeId, + toSocketId, + muted: false, + extensions: {}, + })), + ), + metadata: {}, + }; + })(), + "ramp-test": (() => { + const frame = plain("frame", "fxnode.common.frame", -300, 250), + ramp = plain("ramp", "fxnode.shader.color-ramp", 40, -40), + value = { + colorMode: "rgb", + interpolation: "linear", + hueInterpolation: "near", + stops: [ + { id: "red", position: 0.1, color: [1, 0, 0, 1] }, + { id: "green", position: 0.35, color: [0, 1, 0, 0.8] }, + { id: "blue", position: 0.9, color: [0, 0, 1, 0.6] }, + ], + }; + return { + graphId: "ramp-test", + catalogVersion: APPLICATION_VERSION, + nodes: [ + { ...frame, size: { x: 380, y: 310 } }, + { ...ramp, parentId: "frame", parameters: { ...ramp.parameters, ramp: { kind: "json", value } } }, + ], + links: [], + metadata: {}, + }; + })(), +}; +for (const [name, fixture] of Object.entries(fixtures)) { + const url = new URL(`../examples/blender/${name}/initialLayout.json`, import.meta.url), + canonical = `${JSON.stringify(fixture, null, 2)}\n`; + if (process.argv.includes("--write")) writeFileSync(url, canonical); + else if (readFileSync(url, "utf8") !== canonical) + throw new Error(`${name} fixture is not canonical; run npm run generate:browser-fixtures`); +} +if (!process.argv.includes("--write")) console.log("browser fixtures verified"); diff --git a/vendor/fxnode/tools/check-api-docs.ts b/vendor/fxnode/tools/check-api-docs.ts new file mode 100644 index 0000000..b4da7f4 --- /dev/null +++ b/vendor/fxnode/tools/check-api-docs.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +const root = new URL("..", import.meta.url).pathname; +const docs = join(root, "docs"); +const output = join(root, "docs/reference/generated"); +const files = readdirSync(output, { recursive: true }).map(String); +const sidebarFile = files.find((file) => /sidebar.*\.json$/i.test(file)); +assert(sidebarFile, "VitePress sidebar JSON was not generated"); +const sidebar: unknown = JSON.parse(readFileSync(join(output, sidebarFile), "utf8")); +assert(Array.isArray(sidebar), "sidebar root must be the observed VitePress array structure"); + +type SidebarItem = { text?: unknown; link?: unknown; items?: unknown }; +const sidebarItems: SidebarItem[] = []; +const visit = (items: unknown): void => { + assert(Array.isArray(items), "sidebar items must be arrays"); + for (const item of items) { + assert(item && typeof item === "object", "sidebar entries must be objects"); + const typed = item as SidebarItem; + sidebarItems.push(typed); + if (typed.items !== undefined) visit(typed.items); + } +}; +visit(sidebar); +for (const item of sidebarItems) { + if (item.link === undefined) continue; + assert(typeof item.link === "string", "sidebar links must be strings"); + const link = item.link; + const relative = link.replace(/^\/reference\/generated\//, ""); + assert(relative !== link, `sidebar link is outside generated reference: ${link}`); + const target = relative.endsWith("/") ? `${relative}index.md` : relative; + assert(existsSync(join(output, target)), `broken sidebar link: ${link}`); +} +const packageModules = sidebarItems + .filter( + (item): item is SidebarItem & { text: string; link: string } => + typeof item.text === "string" && typeof item.link === "string" && item.link.endsWith("/"), + ) + .map(({ text, link }) => ({ text, link })); +assert.deepEqual(packageModules, [ + { text: "fxnode", link: "/reference/generated/fxnode/" }, + { text: "headless", link: "/reference/generated/fxnode/headless/" }, + { text: "color-ramp", link: "/reference/generated/fxnode/widgets/color-ramp/" }, +]); + +const text = files + .filter((file) => file.endsWith(".md") || file.endsWith(".json")) + .map((file) => readFileSync(join(output, file), "utf8")) + .join("\n"); +for (const moduleName of ["fxnode", "fxnode/headless", "fxnode/widgets/color-ramp"]) + assert(text.includes(moduleName), `missing documented package module: ${moduleName}`); +for (const denied of ["BoundEngine", "BoundDocument", "bindEngine", "bindDocument", "@lib/"]) + assert(!text.includes(denied), `implementation name leaked into docs: ${denied}`); +for (const line of text.split("\n").filter((line) => line.startsWith("Defined in:"))) + assert.match( + line, + /^Defined in: \[[^\]]+:\d+\]\(https:\/\/github\.com\/Heaust-ops\/fxnode\/blob\/main\/[A-Za-z0-9._/-]+#L\d+\)$/, + `invalid source location: ${line}`, + ); +for (const denied of ["/home/", "file:", "node\\_modules/", "node_modules/"]) + assert(!text.includes(denied), `local or dependency path leaked into docs: ${denied}`); + +// VitePress treats a relative link outside docs as a page link and can fail to +// report a missing repository source file. Repository TypeScript links must be +// durable GitHub links; relative image links deliberately remain supported. +const markdownFiles = readdirSync(docs, { recursive: true }) + .map(String) + .filter((file) => file.endsWith(".md") && !file.startsWith("reference/generated/")); +for (const file of markdownFiles) { + const markdown = readFileSync(join(docs, file), "utf8"); + for (const match of markdown.matchAll(/(? file.endsWith(".md")).length} generated API pages, ${markdownFiles.length} authored pages; ${sidebarFile})`, +); diff --git a/vendor/fxnode/tools/check-application-composition.ts b/vendor/fxnode/tools/check-application-composition.ts new file mode 100644 index 0000000..e3e7207 --- /dev/null +++ b/vendor/fxnode/tools/check-application-composition.ts @@ -0,0 +1,90 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "../test/application.js"; +import { APPLICATION_VERSION } from "../examples/blender/nodes/application.js"; + +const ids = [...APPLICATION_COMPILED.nodes.keys()]; +if (ids.length !== 22 || new Set(ids).size !== ids.length) + throw new Error(`Application composition must contain 22 unique nodes; found ${ids.length}`); +if (APPLICATION_COMPILED.nodes.size !== ids.length) + throw new Error("Compiled application composition coverage differs from source"); +if (APPLICATION_COMPILED.version !== APPLICATION_VERSION) + throw new Error("Compiled application version differs from its declared version"); +for (const [index, id] of ids.entries()) + if (APPLICATION_HEADLESS.materializeNode(`check-${index}`, id).typeId !== id) + throw new Error(`Could not materialize ${id}`); +const root = new URL("..", import.meta.url).pathname, + src = join(root, "src"); +if (existsSync(join(src, "catalog"))) throw new Error("Published source must not contain an application catalog"); +const productionFiles = readdirSync(src, { recursive: true }) + .map(String) + .filter((file) => /\.(?:ts|tsx)$/.test(file)); +const productionText = productionFiles.map((file) => readFileSync(join(src, file), "utf8")).join("\n"); +for (const forbidden of [ + /fxnode\.(?:common|shader|geometry|compositor)\./, + /\b(?:LEGACY_COMPOSITION|BUILTIN_DESCRIPTORS|DESCRIPTOR_REGISTRY|CATALOG_NODE_IDS|BLENDER_DARK_THEME|BuiltinNodeTypeId|RenderTheme)\b/, + /from\s+["'][^"']*examples\//, +]) + if (forbidden.test(productionText)) throw new Error(`Concrete application authority leaked into src: ${forbidden}`); +const exampleRoot = join(root, "examples/blender"); +const nodeRoot = join(exampleRoot, "nodes"); +const categoryIndexes = existsSync(nodeRoot) + ? readdirSync(nodeRoot, { recursive: true }) + .map(String) + .filter((file) => file.endsWith("/index.ts")) + : []; +if (categoryIndexes.length) throw new Error(`Category helper indexes are forbidden: ${categoryIndexes.join(", ")}`); +if (existsSync(join(exampleRoot, "application-composition.ts"))) + throw new Error("Deleted application aggregate was restored"); +if (existsSync(join(exampleRoot, "application-runtime.ts"))) + throw new Error("Application runtime was restored under example"); +for (const browserFile of [ + "application-browser.ts", + "control-test/main.ts", + "link-tools-test/main.ts", + "ramp-test/main.ts", +]) { + const text = readFileSync(join(exampleRoot, browserFile), "utf8"); + if (/test\/application/.test(text)) throw new Error(`${browserFile} imports the test-only application authority`); +} +for (const externalRoot of ["examples", "test", "tools"]) + for (const file of readdirSync(join(root, externalRoot), { recursive: true }).map(String)) { + if (!/\.tsx?$/.test(file)) continue; + const text = readFileSync(join(root, externalRoot, file), "utf8"); + if (/(?:from\s*|import\s*\()["'](?:\.\.\/)+src(?:\/|["'])/.test(text)) + throw new Error(`${externalRoot}/${file} must import library source through @lib/`); + } +for (const sibling of ["minimal", "color-balance", "live-composition"]) + for (const file of readdirSync(join(root, "examples", sibling), { recursive: true }).map(String)) { + if (!/\.tsx?$/.test(file)) continue; + const text = readFileSync(join(root, "examples", sibling, file), "utf8"); + if (/blender\/(?:application-browser|main)|test\/application/.test(text)) + throw new Error(`examples/${sibling}/${file} imports Blender or test application bootstrap`); + } +const authoringFiles = [ + join(root, "test/application.ts"), + join(root, "examples/shared/nodes/color-balance.ts"), + ...(existsSync(nodeRoot) + ? readdirSync(nodeRoot, { recursive: true }) + .map(String) + .filter((file) => file.endsWith(".ts")) + .map((file) => join(nodeRoot, file)) + : []), +]; +const source = authoringFiles.map((file) => readFileSync(file, "utf8")).join("\n"); +for (const forbidden of [ + /Object\.fromEntries/, + /\.reduce\s*\(/, + /defaultSize/, + /src\/catalog/, + /src\/render\/theme/, + /BUILTIN_DESCRIPTORS/, + /LEGACY_COMPOSITION/, + /defineFxNodeComposition/, +]) + if (forbidden.test(source)) + throw new Error(`Application composition is derived through a forbidden adapter: ${forbidden}`); +structuredClone(APPLICATION_COMPILED.source); +console.log( + `application composition: ${ids.length} unique, materializable nodes (version ${APPLICATION_COMPILED.source.version})`, +); diff --git a/vendor/fxnode/tools/check-package.ts b/vendor/fxnode/tools/check-package.ts new file mode 100644 index 0000000..d2021c3 --- /dev/null +++ b/vendor/fxnode/tools/check-package.ts @@ -0,0 +1,509 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import ts from "typescript"; + +const root = new URL("..", import.meta.url).pathname; +const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { + exports: Record; +}; +assert.deepEqual( + Object.keys(packageJson.exports).sort(), + [".", "./headless", "./widgets/color-ramp"].sort(), + "package exports must be exact", +); +const run = (command: string, args: string[], cwd: string) => + execFileSync(command, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); +run("npm", ["run", "build"], root); +const declarationSurfaces = { + "dist/index.d.ts": [ + "AddNodeParams", + "AnyNodeParameterId", + "AnyNodeSocketId", + "BatchCommand", + "Command", + "CommandId", + "CommandIntent", + "CommandReceipt", + "CompiledFxNodeComposition", + "CompiledNode", + "CompiledResource", + "ComposedNode", + "ComposedSocket", + "CompositionChange", + "CompositionChangeEnvelope", + "CompositionReceipt", + "CompositionUpdateOptions", + "CreateFxNodeOptions", + "DEFAULT_FXNODE_THEME", + "FXNODE_COMPOSITION_LIMITS", + "FXNODE_VIEW_LIMITS", + "FxNode", + "FxNodeActionOptions", + "FxNodeAddNodeMenuRequest", + "FxNodeCapabilityError", + "FxNodeComposition", + "FxNodeCompositionData", + "FxNodeCompositionError", + "FxNodeCompositionIssue", + "FxNodeCompositionSeed", + "FxNodeCompositionValidation", + "FxNodeDefinition", + "FxNodeDestroyedError", + "FxNodeCamera", + "FxNodeGradingBinding", + "FxNodeHexColor", + "FxNodeHostRequest", + "FxNodeHostSnapshot", + "FxNodeImageResourceDefinition", + "FxNodeImageResourceDescriptor", + "FxNodeInput", + "FxNodeIssue", + "FxNodeJsonValue", + "FxNodeMigration", + "FxNodeMigrationStep", + "FxNodeModifiers", + "FxNodeParameterValue", + "FxNodeProtocolError", + "FxNodeReadonlyMap", + "FxNodeReplayCommand", + "FxNodeResourceAuthorization", + "FxNodeResourceData", + "FxNodeResourceDefinition", + "FxNodeResourceOpenRequest", + "FxNodeSaveData", + "FxNodeSelectionSnapshot", + "FxNodeSocketDefinition", + "FxNodeSocketTypeDefinition", + "FxNodeStyleDefinition", + "FxNodeTheme", + "FxNodeUiRow", + "FxNodeValueSchema", + "FxNodeViewport", + "FxNodeView", + "FxNodeViewOptions", + "FxNodeViewDetachedError", + "FxNodeVisibility", + "FxNodeWorkerError", + "GraphDocument", + "GraphId", + "GraphLayoutNodeV1", + "GraphLayoutV1", + "GraphLayoutV2", + "GraphLink", + "GraphLinkV1", + "GraphNode", + "GraphSnapshot", + "GraphState", + "HeaderStyled", + "JsonValue", + "KnownNode", + "LinkId", + "Mutation", + "MutationEnvelope", + "NodeBase", + "NodeId", + "NodeParameterId", + "NodeSocketId", + "NodeStyleId", + "NodeTypeId", + "ParameterValue", + "RemovedNode", + "RemovedSocket", + "ResourceId", + "SnapshotEnvelope", + "Socket", + "SocketDataType", + "SocketId", + "SocketTypeId", + "Themed", + "UnknownNode", + "Vec2", + "commandId", + "compileFxNodeComposition", + "composeNode", + "composeSocket", + "createFxNode", + "createInitialFxNodeComposition", + "graphId", + "fxNodeDevicePixels", + "linkId", + "nodeId", + "removeNode", + "removeSocket", + "setHeaderStyles", + "setTheme", + "socketId", + "validateFxNodeComposition", + ], + "dist/headless.d.ts": [ + "AnyNodeParameterId", + "AnyNodeSocketId", + "BatchCommand", + "Command", + "CommandError", + "CommandId", + "CommandRequest", + "CompatibleFxNodeSaveData", + "CompiledFxNodeComposition", + "CompiledNode", + "CompiledResource", + "ComposedNode", + "ComposedSocket", + "DEFAULT_FXNODE_THEME", + "DecodeResult", + "EngineState", + "FXNODE_COMPOSITION_LIMITS", + "FXNODE_SAVE_DATA_LIMITS", + "FxNodeComposition", + "FxNodeCompositionData", + "FxNodeCompositionError", + "FxNodeCompositionIssue", + "FxNodeCompositionSeed", + "FxNodeCompositionValidation", + "FxNodeDefinition", + "FxNodeGradingBinding", + "FxNodeHeadless", + "FxNodeHexColor", + "FxNodeImageResourceDefinition", + "FxNodeJsonValue", + "FxNodeMigration", + "FxNodeMigrationStep", + "FxNodeParameterValue", + "FxNodeReadonlyMap", + "FxNodeReplayCommand", + "FxNodeResourceDefinition", + "FxNodeSaveData", + "FxNodeSocketDefinition", + "FxNodeSocketTypeDefinition", + "FxNodeStyleDefinition", + "FxNodeTheme", + "FxNodeUiRow", + "FxNodeValueSchema", + "FxNodeVisibility", + "GraphDocument", + "GraphId", + "GraphLayoutNodeV1", + "GraphLayoutV1", + "GraphLayoutV2", + "GraphLink", + "GraphLinkV1", + "GraphNode", + "GraphSnapshot", + "GraphState", + "HeaderStyled", + "JsonValue", + "KnownNode", + "LinkId", + "LoadResult", + "Mutation", + "MutationEnvelope", + "NodeBase", + "NodeId", + "NodeParameterId", + "NodeSocketId", + "NodeStyleId", + "NodeTypeId", + "ParameterValue", + "RemovedNode", + "RemovedSocket", + "ReplayResult", + "ResourceId", + "SnapshotEnvelope", + "Socket", + "SocketDataType", + "SocketId", + "SocketTypeId", + "StateReplacementRequest", + "Themed", + "TransitionResult", + "UnknownNode", + "ValidationIssue", + "Vec2", + "commandId", + "compileFxNodeComposition", + "composeNode", + "composeSocket", + "createFxNodeHeadless", + "createInitialFxNodeComposition", + "graphId", + "linkId", + "nodeId", + "removeNode", + "removeSocket", + "setHeaderStyles", + "setTheme", + "socketId", + "validateFxNodeComposition", + ], + "dist/widgets/color-ramp.d.ts": [ + "ColorRamp", + "ColorRampInterpolation", + "ColorRampMode", + "ColorRampStop", + "HueInterpolation", + "addRampMidpoint", + "addRampStop", + "distributeColorRamp", + "flipColorRamp", + "isColorRamp", + "migrateColorRamp", + "moveRampStop", + "removeRampStop", + "sampleColorRamp", + "selectRampStop", + "setRampColor", + ], +} as const; +const declarationProgram = ts.createProgram( + Object.keys(declarationSurfaces).map((file) => join(root, file)), + { + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + }, +); +const declarationChecker = declarationProgram.getTypeChecker(); +for (const [file, expected] of Object.entries(declarationSurfaces)) { + const source = declarationProgram.getSourceFile(join(root, file)); + assert(source, `missing declaration entrypoint: ${file}`); + const moduleSymbol = declarationChecker.getSymbolAtLocation(source); + assert(moduleSymbol, `declaration entrypoint is not a module: ${file}`); + const actual = declarationChecker + .getExportsOfModule(moduleSymbol) + .map((symbol) => symbol.name) + .sort(); + assert.deepEqual(actual, [...expected].sort(), `declaration exports changed for ${file}`); +} +const builtFiles = readdirSync(join(root, "dist"), { recursive: true }) + .map(String) + .filter((file) => /\.(?:js|d\.ts)$/.test(file)); +const builtText = builtFiles.map((file) => readFileSync(join(root, "dist", file), "utf8")).join("\n"); +const runtimeSurfaces = { + ".": [ + "DEFAULT_FXNODE_THEME", + "FXNODE_COMPOSITION_LIMITS", + "FXNODE_VIEW_LIMITS", + "FxNodeCapabilityError", + "FxNodeCompositionError", + "FxNodeDestroyedError", + "FxNodeViewDetachedError", + "FxNodeProtocolError", + "FxNodeWorkerError", + "commandId", + "compileFxNodeComposition", + "composeNode", + "composeSocket", + "createFxNode", + "createInitialFxNodeComposition", + "graphId", + "fxNodeDevicePixels", + "linkId", + "nodeId", + "removeNode", + "removeSocket", + "setHeaderStyles", + "setTheme", + "socketId", + "validateFxNodeComposition", + ], + "./headless": [ + "DEFAULT_FXNODE_THEME", + "FXNODE_COMPOSITION_LIMITS", + "FXNODE_SAVE_DATA_LIMITS", + "FxNodeCompositionError", + "commandId", + "compileFxNodeComposition", + "composeNode", + "composeSocket", + "createFxNodeHeadless", + "createInitialFxNodeComposition", + "graphId", + "linkId", + "nodeId", + "removeNode", + "removeSocket", + "setHeaderStyles", + "setTheme", + "socketId", + "validateFxNodeComposition", + ], + "./widgets/color-ramp": [ + "addRampMidpoint", + "addRampStop", + "distributeColorRamp", + "flipColorRamp", + "isColorRamp", + "migrateColorRamp", + "moveRampStop", + "removeRampStop", + "sampleColorRamp", + "selectRampStop", + "setRampColor", + ], +} as const; +for (const [entry, expected] of Object.entries(runtimeSurfaces)) { + const target = packageJson.exports[entry] as { import: string }; + const actual = Object.keys(await import(join(root, target.import))).sort(); + assert.deepEqual(actual, [...expected].sort(), `runtime exports changed for ${entry}`); +} +for (const symbol of [ + "LEGACY_COMPOSITION", + "INTERNAL_LEGACY_HEADLESS", + "BUILTIN_DESCRIPTORS", + "CATALOG_NODE_IDS", + "BuiltinNodeTypeId", + "DESCRIPTOR_REGISTRY", + "BLENDER_DARK_THEME", + "getDescriptor", +]) + assert(!builtText.includes(symbol), `obsolete concrete symbol built: ${symbol}`); +assert(!builtText.includes("defineFxNodeComposition"), "removed composition identity helper was built"); +assert( + !builtFiles.some((file) => /^composition\/define\.(?:js|d\.ts)$/.test(file)), + "stale composition/define output was built", +); +const temporary = mkdtempSync(join(tmpdir(), "fxnode-package-")); +try { + const json = JSON.parse(run("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], root)) as { + files: { path: string }[]; + }[]; + const files = json[0]?.files.map((item) => item.path) ?? []; + assert( + files.includes("dist/index.js") && + files.includes("dist/headless.js") && + files.includes("dist/index.d.ts") && + files.includes("LICENSE") && + files.includes("NOTICE.md"), + ); + assert( + !files.some((file) => /(^|\/)(\.env|src|test|tools|playwright)(\/|\.|$)/i.test(file)), + `forbidden package file: ${files.join(", ")}`, + ); + assert(!files.some((file) => /^docs\//.test(file)), "documentation must not be packed"); + assert( + !files.some((file) => /dist\/(catalog|core\/document|engine\/engine|render\/theme)(\/|\.|$)/.test(file)), + "obsolete compatibility module was built", + ); + assert(!files.some((file) => /dist\/browser\/add-node-menu\./.test(file)), "host-owned add-node menu was built"); + const packed = run("npm", ["pack", "--json", "--ignore-scripts", "--pack-destination", temporary], root); + const tarball = join(temporary, (JSON.parse(packed) as { filename: string }[])[0]!.filename); + const consumer = join(temporary, "consumer"); + run("mkdir", [consumer], temporary); + writeFileSync( + join(consumer, "package.json"), + JSON.stringify({ + private: true, + type: "module", + dependencies: { fxnode: `file:${tarball}`, typescript: "5.7.2", vite: "6.1.0" }, + }), + ); + run("npm", ["install", "--ignore-scripts"], consumer); + writeFileSync( + join(consumer, "ramp.mjs"), + "import { isColorRamp } from 'fxnode/widgets/color-ramp'; if (typeof isColorRamp !== 'function') throw new Error('color ramp export missing');\n", + ); + run("node", ["ramp.mjs"], consumer); + writeFileSync( + join(consumer, "headless.mjs"), + "import * as fxnode from 'fxnode/headless'; if (!fxnode.createFxNodeHeadless || !fxnode.compileFxNodeComposition || !fxnode.compileFxNodeComposition || fxnode.bindFxNodeHeadless || fxnode.createEngine || fxnode.materializeNode || fxnode.socketsCompatible) throw new Error('headless export boundary invalid');\n", + ); + run("node", ["headless.mjs"], consumer); + writeFileSync( + join(consumer, "main.ts"), + `import { createFxNode, compileFxNodeComposition, type CompositionChange, type CompositionChangeEnvelope, type CompositionReceipt, type CompositionUpdateOptions, type FxNode, type FxNodeSaveData, type FxNodeView, type FxNodeViewOptions, type NodeTypeId } from 'fxnode'; +import { createFxNodeHeadless } from 'fxnode/headless'; +// @ts-expect-error color ramps have a dedicated entrypoint +import { isColorRamp } from 'fxnode'; +// @ts-expect-error protocol-only version expectation is not public +import type { VersionExpectation } from 'fxnode'; +// @ts-expect-error implementation-bound state names are not public +import type { BoundEngineState } from 'fxnode/headless'; +type Equal = (()=>T extends A?1:2) extends (()=>T extends B?1:2) ? true : false; +type Expect = T; +const color='#000000' as const, theme={background:color,grid:color,frame:color,frameHeader:color,body:color,control:color,controlFill:color,controlEditing:color,textSelection:color,outline:color,text:color,muted:color,shadow:color,nodeSelected:color,nodeActive:color,unknownHeader:color,unknownSocket:color,linkMuted:color,knifeMuted:color,emphasis:color,focus:color,editOutline:color,resize:color,muteOverlay:color,boxSelectionFill:color,checkerLight:color,checkerDark:color,widgetBorder:color,rampBorder:color,resourceBackground:color}; +const composition={schemaVersion:2,id:'consumer',version:1,compatibility:{wildcardInputTypes:[]},theme,socketTypes:{value:{title:'Value',color,acceptsFrom:['value']}},nodeStyles:{basic:{header:color}},resources:{},nodes:{tiny:{version:1,title:'Tiny',behavior:'standard',style:'basic',parameters:{},sockets:{},ui:[],muteBypass:[],migrations:[]}}} as const; +type InferredNode = Expect,'tiny'>>; +const compiled=compileFxNodeComposition(composition); compiled.nodes.get('tiny'); +// @ts-expect-error packed declarations retain exact node IDs +compiled.nodes.get('missing'); +const runtime=createFxNodeHeadless(composition); runtime.materializeNode('n','tiny'); +// @ts-expect-error complete compilation rejects unknown style references +compileFxNodeComposition({...composition,nodes:{tiny:{...composition.nodes.tiny,style:'missing'}}}); +async function liveCompositionSmoke(api:FxNode,options:CompositionUpdateOptions):Promise{ + const canvas=null as unknown as HTMLCanvasElement,viewOptions:FxNodeViewOptions={canvas,viewport:{width:1,height:1,dpr:1}}; + const view:FxNodeView=await api.attachView(viewOptions);view.id;view.feedInput({kind:'focus',phase:'focus'});await view.detach(); + // @ts-expect-error view handles do not own graph authority + view.dispatch({type:'undo'}); + // @ts-expect-error root authority does not own presentation input + api.feedInput({kind:'focus',phase:'focus'}); + // @ts-expect-error removed copy API + api.copyTo(canvas); + // @ts-expect-error removed mirror API + api.addMirror(canvas); + const saveData:FxNodeSaveData=await api.getSaveData();await api.load(saveData); + const before=await api.getState(),known=before.nodes.find(node=>node.known);if(known){type MutableOutput=Expect>;void(null as MutableOutput|null);} + const themed=await api.setTheme(theme,{expectedRevision:options.expectedRevision??0}); + const receipt:CompositionReceipt=themed; + if(receipt.status==='committed'){const reset:true=receipt.historyReset;void reset;}else{const reset:false=receipt.historyReset,changed:false=receipt.graphChanged;void reset;void changed;} + const socket=await api.composeSocket('dynamic',{title:'Dynamic',color,acceptsFrom:['dynamic']},{expectedRevision:themed.revision}); + const node=await api.composeNode('dynamic-node',{version:1,title:'Dynamic',behavior:'standard',style:'basic',parameters:{},sockets:{out:{title:'Out',direction:'output',type:'dynamic',maxIncomingLinks:0,visible:true,value:null,showValue:false}},ui:[{kind:'socket',socket:'out'}],muteBypass:[],migrations:[]},{expectedRevision:socket.revision}); + await api.dispatch({type:'node.add',nodeType:'dynamic-node',position:{x:0,y:0}}); + const removedNode=await api.removeNode('dynamic-node',{expectedRevision:node.revision}); + const removedSocket=await api.removeSocket('dynamic',{expectedRevision:removedNode.revision});void removedSocket; + await api.loadComposition(composition); + api.onCompositionChanges(event=>{const envelope:CompositionChangeEnvelope=event,change:CompositionChange=event.change;envelope.revision;if(change.kind!=='theme.set'&&change.kind!=='header-styles.set'&&change.kind!=='compatibility.set'&&change.kind!=='composition.load')change.id; + // @ts-expect-error composition events do not expose definition payloads + event.change.definition;}); +} +async function ownershipSmoke():Promise{ + const api:FxNode=await createFxNode({applicationId:'consumer',applicationVersion:1,resources:composition.resources}); + const canvas=null as unknown as HTMLCanvasElement,view:FxNodeView=await api.attachView({canvas,viewport:{width:1,height:1,dpr:1}}); + view.getHostSnapshot();const offHost=view.subscribeHost(()=>{}),offRequests=view.onHostRequests(()=>{});view.feedInput({kind:'focus',phase:'focus'});view.setViewport({width:2,height:2,dpr:1}); + await view.addNode({typeId:'tiny',viewPosition:{x:0,y:0}});await view.removeSelected();await view.setSelectedMuted(false);await view.provideResource({viewId:view.id,token:'token',graphVersion:0,compositionRevision:0},{name:'x',mime:'image/png',bytes:new ArrayBuffer(1)});await view.whenRendered();offHost();offRequests();await view.detach(); + await api.getState();api.onSnapshots(()=>{});api.onMutations(()=>{});await api.dispatch({type:'undo'});await api.setTheme(theme);api.onCompositionChanges(()=>{}); + // @ts-expect-error views do not own graph snapshots + view.getState(); + // @ts-expect-error views do not own composition mutation + view.setTheme(theme); + // @ts-expect-error roots do not own rendering barriers + api.whenRendered(); + api.destroy(); +} +void runtime; void compiled; void liveCompositionSmoke; void ownershipSmoke; void (null as InferredNode | null); +`, + ); + writeFileSync( + join(consumer, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + strict: true, + noEmit: true, + target: "ES2022", + module: "NodeNext", + moduleResolution: "NodeNext", + skipLibCheck: true, + }, + include: ["main.ts"], + }), + ); + run("npx", ["tsc"], consumer); + writeFileSync(join(consumer, "index.html"), ""); + run("npx", ["vite", "build", "--base", "./"], consumer); + const output = readdirSync(join(consumer, "dist", "assets")); + const js = output + .filter((file) => file.endsWith(".js")) + .map((file) => readFileSync(join(consumer, "dist", "assets", file), "utf8")) + .join("\n"); + assert(!js.includes('new URL("/assets/'), "worker URL is root-absolute"); + const packageIndex = readFileSync(join(consumer, "node_modules", "fxnode", "dist", "index.js"), "utf8"); + assert(!packageIndex.includes('new URL("/assets/'), "packed worker URL is root-absolute"); + const workerMatch = packageIndex.match(/"assets\/([^"?]*worker[^"?]*\.js)"/); + assert(workerMatch, "packed package has no package-relative worker URL"); + const packagedAssets = readdirSync(join(consumer, "node_modules", "fxnode", "dist", "assets")); + assert(packagedAssets.includes(workerMatch[1]!), `worker asset ${workerMatch[1]} does not exist`); + console.log(`package smoke passed (${files.length} files; worker assets/${workerMatch[1]})`); +} finally { + rmSync(temporary, { recursive: true, force: true }); +} diff --git a/vendor/fxnode/tools/check-readme.ts b/vendor/fxnode/tools/check-readme.ts new file mode 100644 index 0000000..63ff7e5 --- /dev/null +++ b/vendor/fxnode/tools/check-readme.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const root = new URL("..", import.meta.url).pathname; +const readme = readFileSync(join(root, "README.md"), "utf8"); +const fixture = readFileSync(join(root, "test/readme-snippets.compile.ts"), "utf8"); +const links = [...readme.matchAll(/!?\[[^\]]*\]\(([^)]+)\)/g)].map((match) => match[1]!); +for (const link of links) { + if (/^(?:https:\/\/|LICENSE$|NOTICE\.md$)/.test(link)) continue; + assert.fail(`README link is not package-safe: ${link}`); +} +for (const stale of ["docs/reference/generated", "docs/.vitepress", "](docs/", "](examples/", "](src/"]) { + assert(!readme.includes(stale), `README contains generated, VitePress-root, or unpacked path: ${stale}`); +} +for (const required of [ + "theme: exampleTheme", + "createFxNodeHeadless({", + "api.composeSocket(...numberSocket)", + "api.composeNode(...valueNode)", + 'window.addEventListener("pagehide", cleanup)', + "if (cleaned) created.destroy()", + "api = null", + "host.destroy()", + "const gradingWheelsRow = {", + 'satisfies FxNodeDefinition["ui"][number]', + "async function installColorBalance(api: FxNode)", +]) { + assert(readme.includes(required), `README current-API regression: ${required}`); + if ( + ["theme: exampleTheme", "const gradingWheelsRow = {", "async function installColorBalance(api: FxNode)"].includes( + required, + ) + ) + assert( + fixture.includes( + required + .replace("const gradingWheelsRow", "export const gradingWheelsRow") + .replace("async function", "export async function"), + ), + `compile fixture is not synchronized: ${required}`, + ); +} +assert(!readme.includes("Immutable current graph snapshot"), "getState must not claim runtime immutability"); +console.log(`README checks passed (${readme.split("\n").length - 1} lines, ${links.length} links).`); diff --git a/vendor/fxnode/tools/check-references.ts b/vendor/fxnode/tools/check-references.ts new file mode 100644 index 0000000..c3b7226 --- /dev/null +++ b/vendor/fxnode/tools/check-references.ts @@ -0,0 +1,38 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { referenceManifest } from "@lib/research/reference-manifest.js"; +import { REFERENCE_IDS } from "@lib/research/reference-types.js"; + +const strict = process.argv.includes("--strict"); +const sha256Pattern = /^[a-f0-9]{64}$/u; +const ids = referenceManifest.references.map(({ id }) => id); +if ( + ids.length !== REFERENCE_IDS.length || + new Set(ids).size !== REFERENCE_IDS.length || + REFERENCE_IDS.some((id) => !ids.includes(id)) +) { + throw new Error("Reference manifest must contain each of the eight reference IDs exactly once."); +} +if (!sha256Pattern.test(referenceManifest.baseline.binarySha256)) throw new Error("Invalid baseline binary SHA256."); + +let captured = 0; +for (const reference of referenceManifest.references) { + if (reference.status === "pending") { + if (!reference.reason.trim()) throw new Error(`${reference.id}: pending references require a reason.`); + continue; + } + captured++; + if (!sha256Pattern.test(reference.sha256)) throw new Error(`${reference.id}: invalid SHA256.`); + const bytes = await readFile(resolve(reference.relativePath)); + const actual = createHash("sha256").update(bytes).digest("hex"); + if (actual !== reference.sha256) throw new Error(`${reference.id}: hash mismatch.`); + if (bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a") throw new Error(`${reference.id}: not a PNG.`); + if (bytes.readUInt32BE(16) !== reference.width || bytes.readUInt32BE(20) !== reference.height) + throw new Error(`${reference.id}: dimensions mismatch.`); +} +if (strict && captured !== REFERENCE_IDS.length) + throw new Error(`Strict reference check: ${captured}/8 captured; ${8 - captured} pending.`); +console.log( + `references: manifest valid; ${captured}/8 captured, ${8 - captured}/8 pending${strict ? " (strict)" : ""}`, +); diff --git a/vendor/fxnode/tools/parity.ts b/vendor/fxnode/tools/parity.ts new file mode 100644 index 0000000..b52efc5 --- /dev/null +++ b/vendor/fxnode/tools/parity.ts @@ -0,0 +1,46 @@ +import { writeFileSync } from "node:fs"; +import { APPLICATION_COMPILED } from "../test/application.js"; +import { APPLICATION_HEADLESS } from "../test/application.js"; +import type { NodeTypeId } from "@lib/composition/index.js"; +import { graphId, linkId, socketId, type GraphDocument } from "@lib/core/types.js"; +import { nullRecord } from "@lib/core/json.js"; + +const { materializeNode, save } = APPLICATION_HEADLESS; +const specs: readonly [string, NodeTypeId, number, number][] = [ + ["image-texture", "fxnode.shader.image-texture", -520, 180], + ["noise-3d", "fxnode.shader.noise-texture", -220, 180], + ["noise-4d", "fxnode.shader.noise-texture", 60, 180], + ["color-ramp", "fxnode.shader.color-ramp", 350, 180], + ["compositor-image", "fxnode.compositor.image", -340, -260], + ["master", "fxnode.compositor.color-balance", 20, -260], +]; +const nodes = specs.map(([id, type, x, y]) => { + const node = materializeNode(id, type, { x, y }); + if (id === "noise-4d") + return { ...node, parameters: { ...node.parameters, dimensions: { kind: "string" as const, value: "4d" } } }; + if (id === "master") return { ...node, label: "Master Color Grading" }; + return node; +}); +const link = { + id: linkId("compositor-grade"), + fromNodeId: nodes[4]!.id, + fromSocketId: socketId("compositor-image:image"), + toNodeId: nodes[5]!.id, + toSocketId: socketId("master:image"), + muted: false, + extensions: {}, +}; +const document: GraphDocument = { + schemaVersion: 2, + graphId: graphId("parity"), + catalogVersion: APPLICATION_COMPILED.source.version, + nodes: nullRecord(nodes.map((n) => [n.id, n])), + links: nullRecord([[link.id, link]]), + metadata: nullRecord(), +}; +const saved = save(document); +const { schemaVersion: _schemaVersion, ...layout } = saved; +writeFileSync( + new URL("../examples/blender/parity/initialLayout.json", import.meta.url), + JSON.stringify({ ...layout, nodes: layout.nodes.map((node) => ({ ...node, known: true })) }, null, 2) + "\n", +); diff --git a/vendor/fxnode/tools/performance-phase7.ts b/vendor/fxnode/tools/performance-phase7.ts new file mode 100644 index 0000000..4970dc8 --- /dev/null +++ b/vendor/fxnode/tools/performance-phase7.ts @@ -0,0 +1,72 @@ +import { performance } from "node:perf_hooks"; +import { LooseQuadtree } from "@lib/layout/spatial-index.js"; + +const seed = 7, + nodes = 5000, + links = 10000, + viewport = { x: 1200, y: 800, dpr: 2 }; +const nodeIndex = new LooseQuadtree(), + linkIndex = new LooseQuadtree(); +const start = performance.now(); +for (let i = 0; i < nodes; i++) { + const x = (i % 100) * 400, + y = Math.floor(i / 100) * 300; + nodeIndex.insert(`n${i}`, i, { minX: x, minY: y - 180, maxX: x + 180, maxY: y }); +} +for (let i = 0; i < links; i++) { + const a = i % nodes, + row = Math.floor(a / 100), + b = row * 100 + (((a % 100) + 1 + (i % 7)) % 100), + ax = (a % 100) * 400 + 180, + ay = row * 300 - 90, + bx = (b % 100) * 400, + by = row * 300 - 90; + linkIndex.insert(`l${i}`, i, { + minX: Math.min(ax, bx), + minY: Math.min(ay, by), + maxX: Math.max(ax, bx), + maxY: Math.max(ay, by), + }); +} +const buildMs = performance.now() - start, + times: number[] = [], + counts: number[] = []; +for (let i = 0; i < 100; i++) { + const x = (i * 997) % 38000, + y = (i * 431) % 14000, + s = performance.now(); + const nc = nodeIndex.query({ minX: x, minY: y - viewport.y, maxX: x + viewport.x, maxY: y }).length, + lc = linkIndex.query({ minX: x, minY: y - viewport.y, maxX: x + viewport.x, maxY: y }).length; + times.push(performance.now() - s); + counts.push(nc + lc); +} +times.sort((a, b) => a - b); +counts.sort((a, b) => a - b); +const p = (a: number[], q: number) => a[Math.floor((a.length - 1) * q)]!; +const result = { + seed, + nodes, + links, + viewport, + buildMs: +buildMs.toFixed(3), + candidateP50: p(counts, 0.5), + candidateP95: p(counts, 0.95), + queryMsP50: +p(times, 0.5).toFixed(3), + queryMsP95: +p(times, 0.95).toFixed(3), + cullingP95: +(1 - p(counts, 0.95) / (nodes + links)).toFixed(4), +}; +console.log(JSON.stringify(result)); +if ( + process.argv.includes("--check") && + (result.seed !== 7 || + result.nodes !== 5000 || + result.links !== 10000 || + result.viewport.x !== 1200 || + result.viewport.y !== 800 || + result.viewport.dpr !== 2 || + times.length !== 100 || + result.candidateP50 !== 72 || + result.candidateP95 !== 75 || + result.cullingP95 < 0.9) +) + throw new Error(`Phase 7 deterministic workload baseline missed: ${JSON.stringify(result)}`); diff --git a/vendor/fxnode/tsconfig.build.json b/vendor/fxnode/tsconfig.build.json new file mode 100644 index 0000000..9eb23e8 --- /dev/null +++ b/vendor/fxnode/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "types": ["vite/client"], + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/worker/**/*.ts"] +} diff --git a/vendor/fxnode/tsconfig.json b/vendor/fxnode/tsconfig.json new file mode 100644 index 0000000..24a30d6 --- /dev/null +++ b/vendor/fxnode/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "baseUrl": ".", + "paths": { + "@lib/*": ["src/*"] + }, + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": false, + "forceConsistentCasingInFileNames": true, + "verbatimModuleSyntax": true, + "resolveJsonModule": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "tools/**/*.ts", "test/**/*.ts", "examples/**/*.ts", "vite-env.d.ts"] +} diff --git a/vendor/fxnode/typedoc.json b/vendor/fxnode/typedoc.json new file mode 100644 index 0000000..4ef33aa --- /dev/null +++ b/vendor/fxnode/typedoc.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "entryPoints": ["src/index.ts", "src/headless.ts", "src/widgets/color-ramp.ts"], + "entryPointStrategy": "resolve", + "alwaysCreateEntryPointModule": true, + "plugin": ["typedoc-plugin-markdown", "typedoc-vitepress-theme"], + "out": "docs/reference/generated", + "docsRoot": "docs", + "router": "member", + "readme": "none", + "disableGit": true, + "sourceLinkTemplate": "https://github.com/Heaust-ops/fxnode/blob/main/src/{path}#L{line}", + "intentionallyNotExported": ["ReferenceCheck"], + "excludeInternal": true, + "excludePrivate": true, + "excludeProtected": true, + "excludeExternals": true, + "cleanOutputDir": true, + "categorizeByGroup": true, + "categoryOrder": ["Classes", "Interfaces", "Type Aliases", "Functions", "Variables", "*"], + "navigation": { "includeCategories": true, "includeGroups": true }, + "sidebar": { "pretty": true }, + "validation": { + "invalidLink": true, + "notDocumented": false, + "rewrittenLink": true, + "notExported": true + }, + "treatWarningsAsErrors": true +} diff --git a/vendor/fxnode/vite-env.d.ts b/vendor/fxnode/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/vendor/fxnode/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/vendor/fxnode/vite.config.ts b/vendor/fxnode/vite.config.ts new file mode 100644 index 0000000..bb0c1dc --- /dev/null +++ b/vendor/fxnode/vite.config.ts @@ -0,0 +1,40 @@ +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig, type Plugin } from "vite"; + +const sourceRoot = fileURLToPath(new URL("./src", import.meta.url)); +const inside = (root: string, path: string): boolean => { + const value = relative(root, path); + return value === "" || (!value.startsWith("..") && !value.startsWith("/")); +}; +const enforceLibraryAlias = (): Plugin => ({ + name: "fxnode-enforce-library-alias", + enforce: "pre", + resolveId(source, importer) { + if (!importer || source.startsWith("@lib/") || inside(sourceRoot, importer.split("?", 1)[0]!)) return null; + // Vite rewrites module-worker URLs to root-relative /src requests before + // resolving them, so only author-written relative imports are boundaries. + const target = source.startsWith(".") ? resolve(dirname(importer.split("?", 1)[0]!), source) : undefined; + if (target && inside(sourceRoot, target)) + throw new Error(`Modules outside src must import library code through @lib/: ${source}`); + return null; + }, +}); + +export default defineConfig({ + base: "./", + plugins: [enforceLibraryAlias()], + resolve: { alias: { "@lib": sourceRoot } }, + server: { + allowedHosts: [".onamp.dev", ".e2b.app"], + headers: { "Cross-Origin-Opener-Policy": "same-origin", "Cross-Origin-Embedder-Policy": "require-corp" }, + }, + build: { + assetsInlineLimit: 0, + lib: { + entry: { index: "src/index.ts", headless: "src/headless.ts", "widgets/color-ramp": "src/widgets/color-ramp.ts" }, + formats: ["es"], + }, + rollupOptions: { output: { entryFileNames: "[name].js" } }, + }, +}); diff --git a/vite.config.js b/vite.config.js index 075ba8d..248a2ad 100644 --- a/vite.config.js +++ b/vite.config.js @@ -3,7 +3,8 @@ import { fileURLToPath } from "node:url"; import { defineConfig } from "vite"; import { ViteRsw } from "vite-plugin-rsw"; import wasm from "vite-plugin-wasm"; -import copy from "rollup-plugin-copy"; +import { cp, mkdir } from "node:fs/promises"; +import { cpSync, mkdirSync } from "node:fs"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -11,6 +12,34 @@ const portalHost = process.env.PUBLIC_URL ? new URL(process.env.PUBLIC_URL).hostname : undefined; const serverPort = Number(process.env.PORT) || 8080; +const wasmSource = path.resolve(__dirname, "level-editor/pkg"); +const wasmDestination = path.resolve(__dirname, "static/level-editor/pkg"); + +// Vite resolves entry imports before plugin build hooks. Mirror synchronously while +// loading the config so a clean build always sees the complete generated package. +mkdirSync(wasmDestination, { recursive: true }); +cpSync(wasmSource, wasmDestination, { recursive: true, force: true }); + +function freshWasmPackage() { + let copying, timer; + const mirror = () => copying ??= mkdir(wasmDestination, { recursive: true }) + .then(() => cp(wasmSource, wasmDestination, { recursive: true, force: true })) + .finally(() => { copying = undefined; }); + return { + name: "fresh-wasm-package", + async buildStart() { await mirror(); }, + configureServer(server) { + server.watcher.add(wasmSource); + const update = file => { + if (!file.startsWith(wasmSource)) return; + clearTimeout(timer); + // rsw replaces several package files; mirror only after that write burst settles. + timer = setTimeout(() => mirror().then(() => server.ws.send({ type: "full-reload" })), 100); + }; + server.watcher.on("add", update).on("change", update).on("unlink", update); + }, + }; +} export default defineConfig({ build: { @@ -25,22 +54,20 @@ export default defineConfig({ }, // For getting out of index.html from dist/static directory. root: "static", - publicDir: ".", + // `static` is source code, not Vite's untransformed public directory. Treating it + // as both makes dev mode reject the generated WASM worker's module imports. + publicDir: false, worker: { format: "es", }, resolve: { alias: { + "@fxnode/": `${path.resolve(__dirname, "vendor/fxnode/src")}/`, pkg: path.resolve(__dirname, "level-editor/pkg"), }, }, plugins: [ - copy({ - targets: [ - { src: "level-editor/pkg/*", dest: "static/level-editor/pkg" }, - ], - hook: "buildStart", - }), + freshWasmPackage(), ViteRsw(), // Makes us be able to use top level await for wasm. // Otherwise, we can restrict build.target to 'es2022', which allows top level await.

+
+
+ Start here +

Standalone applications

+
+

Open any card to run it.

+
+
+ +
+
+
+ Component lab +

Focused test scenes

+
+

Useful for inspecting specific interactions and controls.

+
+ +
+