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 <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-07-27 02:53:44 +00:00
co-authored by heaust
parent 8a8369706b
commit d4e8634f67
290 changed files with 48804 additions and 1995 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>fxnode all supported</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<canvas id="graph"></canvas>
<script type="module" src="./main.ts"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+20
View File
@@ -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<ReturnType<typeof createApplicationFxNode>> | undefined,
view: Awaited<ReturnType<NonNullable<typeof root>["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;
}
+13
View File
@@ -0,0 +1,13 @@
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #202124;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
+86
View File
@@ -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<CreateFxNodeOptions, "applicationId" | "applicationVersion" | "resources">;
export async function createApplicationFxNode(options: ApplicationBrowserOptions = {}): Promise<ApplicationFxNode> {
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;
}
}
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>FxNode controls</title>
<style>
html,
body {
margin: 0;
background: #111;
}
canvas {
display: block;
width: 1200px;
height: 640px;
}
</style>
</head>
<body>
<canvas id="controls"></canvas>
<script type="module" src="./main.ts"></script>
</body>
</html>
@@ -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": {}
}
+30
View File
@@ -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<HTMLCanvasElement>("#controls");
if (!canvas) throw new Error("Control test canvas missing");
const host = prepareFxNodeBrowserHost({ canvas });
const handle: { root: ApplicationFxNode | null; view: FxNodeView | null; ready: Promise<void> } = {
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;
}
})();
+18
View File
@@ -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<void>;
readonly rendered: Promise<void>;
}
interface Window {
fxnodeExample: FxNodeExampleHandle;
linkToolsTest: { root: FxNode | null; view: FxNodeView | null; ready: Promise<void> };
}
}
export {};
+304
View File
@@ -0,0 +1,304 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>fxnode deterministic browser example</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main>
<h1>fxnode browser baseline</h1>
<p>Deterministic fxnode regression composition; genuine Blender parity references remain pending.</p>
<canvas id="graph" width="1200" height="640" aria-label="Material node graph"></canvas>
</main>
<template id="add-node-menu-template">
<div data-fxnode-add-menu style="position: fixed; z-index: 2147483647; visibility: hidden">
<style>
[data-fxnode-add-menu] * {
box-sizing: border-box;
}
.fxnode-add-dialog {
width: 286px;
max-height: min(520px, calc(100vh - 16px));
display: flex;
flex-direction: column;
padding: 8px;
background: #25282d;
color: #e5e5e5;
border: 1px solid #111216;
border-radius: 7px;
box-shadow: 0 12px 32px #000a;
font:
12px/1.3 system-ui,
sans-serif;
}
.fxnode-add-title {
font-weight: 650;
margin: 1px 2px 7px;
}
.fxnode-add-search {
width: 100%;
border: 1px solid #111216;
border-radius: 4px;
background: #181a1e;
color: #fff;
padding: 7px 8px;
outline: none;
}
.fxnode-add-search:focus {
border-color: #ed5700;
}
.fxnode-add-results {
overflow: auto;
margin-top: 7px;
min-height: 40px;
}
.fxnode-add-group {
margin: 4px 0 7px;
}
.fxnode-add-heading {
padding: 3px 7px;
color: #a5a8ad;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
}
.fxnode-add-option {
display: block;
width: 100%;
padding: 6px 8px;
border: 0;
border-radius: 4px;
background: transparent;
color: #e5e5e5;
text-align: left;
font: inherit;
cursor: default;
}
.fxnode-add-option:hover,
.fxnode-add-option.active {
background: #ed5700;
color: #fff;
}
.fxnode-add-empty {
padding: 14px 8px;
color: #a5a8ad;
text-align: center;
}
</style>
<section class="fxnode-add-dialog" role="dialog" aria-label="Add node">
<div class="fxnode-add-title">Add Node</div>
<input
data-fxnode-menu-search
class="fxnode-add-search"
type="search"
role="combobox"
aria-label="Search nodes"
aria-autocomplete="list"
aria-controls="fxnode-node-results"
autocomplete="off"
placeholder="Search…"
/>
<div data-fxnode-menu-results class="fxnode-add-results" id="fxnode-node-results" role="listbox">
<section data-fxnode-menu-group class="fxnode-add-group" role="group" aria-label="Common">
<h2 data-fxnode-menu-heading class="fxnode-add-heading">Common</h2>
<button
data-fxnode-menu-option
data-type-id="fxnode.common.frame"
class="fxnode-add-option"
type="button"
role="option"
>
Frame</button
><button
data-fxnode-menu-option
data-type-id="fxnode.common.reroute"
class="fxnode-add-option"
type="button"
role="option"
>
Reroute</button
><button
data-fxnode-menu-option
data-type-id="fxnode.common.group-input"
class="fxnode-add-option"
type="button"
role="option"
>
Group Input</button
><button
data-fxnode-menu-option
data-type-id="fxnode.common.group-output"
class="fxnode-add-option"
type="button"
role="option"
>
Group Output
</button>
</section>
<section data-fxnode-menu-group class="fxnode-add-group" role="group" aria-label="Shader">
<h2 data-fxnode-menu-heading class="fxnode-add-heading">Shader</h2>
<button
data-fxnode-menu-option
data-type-id="fxnode.shader.value"
class="fxnode-add-option"
type="button"
role="option"
>
Value</button
><button
data-fxnode-menu-option
data-type-id="fxnode.shader.color"
class="fxnode-add-option"
type="button"
role="option"
>
Color</button
><button
data-fxnode-menu-option
data-type-id="fxnode.shader.math"
class="fxnode-add-option"
type="button"
role="option"
>
Math</button
><button
data-fxnode-menu-option
data-type-id="fxnode.shader.vector-math"
class="fxnode-add-option"
type="button"
role="option"
>
Vector Math</button
><button
data-fxnode-menu-option
data-type-id="fxnode.shader.mix"
class="fxnode-add-option"
type="button"
role="option"
>
Mix</button
><button
data-fxnode-menu-option
data-type-id="fxnode.shader.color-ramp"
class="fxnode-add-option"
type="button"
role="option"
>
Color Ramp</button
><button
data-fxnode-menu-option
data-type-id="fxnode.shader.texture-coordinate"
class="fxnode-add-option"
type="button"
role="option"
>
Texture Coordinate</button
><button
data-fxnode-menu-option
data-type-id="fxnode.shader.noise-texture"
class="fxnode-add-option"
type="button"
role="option"
>
Noise Texture</button
><button
data-fxnode-menu-option
data-type-id="fxnode.shader.image-texture"
class="fxnode-add-option"
type="button"
role="option"
>
Image Texture</button
><button
data-fxnode-menu-option
data-type-id="fxnode.shader.principled-bsdf"
class="fxnode-add-option"
type="button"
role="option"
>
Principled BSDF</button
><button
data-fxnode-menu-option
data-type-id="fxnode.shader.material-output"
class="fxnode-add-option"
type="button"
role="option"
>
Material Output
</button>
</section>
<section data-fxnode-menu-group class="fxnode-add-group" role="group" aria-label="Geometry">
<h2 data-fxnode-menu-heading class="fxnode-add-heading">Geometry</h2>
<button
data-fxnode-menu-option
data-type-id="fxnode.geometry.position"
class="fxnode-add-option"
type="button"
role="option"
>
Position</button
><button
data-fxnode-menu-option
data-type-id="fxnode.geometry.mesh-cube"
class="fxnode-add-option"
type="button"
role="option"
>
Mesh Cube</button
><button
data-fxnode-menu-option
data-type-id="fxnode.geometry.set-position"
class="fxnode-add-option"
type="button"
role="option"
>
Set Position</button
><button
data-fxnode-menu-option
data-type-id="fxnode.geometry.transform-geometry"
class="fxnode-add-option"
type="button"
role="option"
>
Transform Geometry</button
><button
data-fxnode-menu-option
data-type-id="fxnode.geometry.join-geometry"
class="fxnode-add-option"
type="button"
role="option"
>
Join Geometry
</button>
</section>
<section data-fxnode-menu-group class="fxnode-add-group" role="group" aria-label="Compositor">
<h2 data-fxnode-menu-heading class="fxnode-add-heading">Compositor</h2>
<button
data-fxnode-menu-option
data-type-id="fxnode.compositor.image"
class="fxnode-add-option"
type="button"
role="option"
>
Image</button
><button
data-fxnode-menu-option
data-type-id="fxnode.compositor.color-balance"
class="fxnode-add-option"
type="button"
role="option"
>
Color Balance
</button>
</section>
<div data-fxnode-menu-empty class="fxnode-add-empty" role="status" hidden>No nodes found</div>
</div>
</section>
</div>
</template>
<script type="module" src="./main.ts"></script>
</body>
</html>
+697
View File
@@ -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"
}
}
@@ -0,0 +1,23 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>FxNode link tools test</title>
<style>
html,
body {
margin: 0;
background: #111;
}
canvas {
display: block;
width: 1200px;
height: 640px;
}
</style>
</head>
<body>
<canvas id="link-tools"></canvas>
<script type="module" src="./main.ts"></script>
</body>
</html>
@@ -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": {}
}
+30
View File
@@ -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<HTMLCanvasElement>("#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<void> } = {
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;
}
})();
+35
View File
@@ -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<HTMLCanvasElement>("#graph");
const addNodeMenuTemplate = document.querySelector<HTMLTemplateElement>("#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<void>((resolve) => {
resolveRendered = resolve;
});
const handle: FxNodeExampleHandle = { root: null, view: null, host, ready: Promise.resolve(), rendered };
window.fxnodeExample = handle;
handle.ready = (async () => {
let root: Awaited<ReturnType<typeof createApplicationFxNode>> | undefined,
view: Awaited<ReturnType<NonNullable<typeof root>["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;
}
})();
+49
View File
@@ -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"];
+15
View File
@@ -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];
@@ -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];
@@ -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];
+43
View File
@@ -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];
+170
View File
@@ -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];
@@ -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];
@@ -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];
@@ -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];
@@ -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];
@@ -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];
@@ -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];
+44
View File
@@ -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];
@@ -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];
@@ -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];
+92
View File
@@ -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];
+117
View File
@@ -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];
@@ -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];
@@ -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];
@@ -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];
+43
View File
@@ -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];
@@ -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];
+25
View File
@@ -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];
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>fxnode image and grading parity</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<canvas id="graph"></canvas>
<script type="module" src="./main.ts"></script>
</body>
</html>
+754
View File
@@ -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": {}
}
+20
View File
@@ -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<ReturnType<typeof createApplicationFxNode>> | undefined,
view: Awaited<ReturnType<NonNullable<typeof root>["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;
}
+13
View File
@@ -0,0 +1,13 @@
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #202124;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>FxNode ramp test</title>
<style>
html,
body {
margin: 0;
background: #111;
}
canvas {
display: block;
width: 1200px;
height: 640px;
}
</style>
</head>
<body>
<canvas id="ramp"></canvas>
<script type="module" src="./main.ts"></script>
</body>
</html>
@@ -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": {}
}
+30
View File
@@ -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<HTMLCanvasElement>("#ramp");
if (!canvas) throw new Error("Ramp test canvas missing");
const host = prepareFxNodeBrowserHost({ canvas });
const handle: { root: ApplicationFxNode | null; view: FxNodeView | null; ready: Promise<void> } = {
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;
}
})();
+30
View File
@@ -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;
}
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>fxnode Color Balance</title>
<link rel="stylesheet" href="../shared/example.css" />
</head>
<body>
<main>
<h1>Color Balance schema</h1>
<p>
Demonstrates schema, controls, persistence, and presentation. It does not process or composite pixels and does
not execute or evaluate the graph.
</p>
<canvas id="graph" width="1000" height="560" aria-label="Color Balance node graph"></canvas>
</main>
<script type="module" src="./main.ts"></script>
</body>
</html>
+67
View File
@@ -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<string, FxNodeStyleDefinition>
>;
const canvas = document.querySelector<HTMLCanvasElement>("#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;
}
})();
+255
View File
@@ -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;
}
}
+80
View File
@@ -0,0 +1,80 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>fxnode examples</title>
<link rel="stylesheet" href="./gallery.css" />
</head>
<body>
<header class="hero">
<div>
<span class="eyebrow">Interactive TypeScript examples</span>
<h1>Build node editors with <em>fxnode</em></h1>
<p>
Explore composable nodes, live worker-owned state, independent views, and a Blender-inspired editor. Every
example runs locally in your browser.
</p>
</div>
<div class="architecture" aria-label="Architecture summary">
<span>Main thread host</span><i></i><span>Worker authority</span><i></i><span>Shared atlas</span>
</div>
</header>
<main>
<div class="section-heading">
<div>
<span class="eyebrow">Start here</span>
<h2>Standalone applications</h2>
</div>
<p>Open any card to run it.</p>
</div>
<nav class="gallery" aria-label="Featured examples">
<a href="./minimal/">
<img src="./assets/minimal.png" alt="Minimal node example" />
<span class="tag">Fundamentals</span><strong>Minimal</strong>
<p>Compose a socket and value node from scratch.</p>
</a>
<a href="./color-balance/">
<img src="./assets/color-balance.png" alt="Color Balance example" />
<span class="tag">Custom controls</span><strong>Color Balance</strong>
<p>Oklab grading wheels, controls, and persistence.</p>
</a>
<a href="./live-composition/">
<img src="./assets/live-composition.png" alt="Live composition example" />
<span class="tag">Runtime API</span><strong>Live composition</strong>
<p>Version and migrate definitions while the editor is running.</p>
</a>
<a href="./multi-view/">
<img src="./assets/multi-view.png" alt="Multi-view example" />
<span class="tag">Shared graph</span><strong>Multi-view</strong>
<p>Two cameras and selections backed by one worker and graph.</p>
</a>
<a class="featured" href="./blender/">
<span class="tag">Full application</span><strong>Blender-inspired catalog</strong>
<p>Explore the complete interactive node editor and its host-owned add menu.</p>
<span class="open">Launch editor <b></b></span>
</a>
</nav>
<section class="labs">
<div class="section-heading">
<div>
<span class="eyebrow">Component lab</span>
<h2>Focused test scenes</h2>
</div>
<p>Useful for inspecting specific interactions and controls.</p>
</div>
<nav aria-label="Focused examples">
<a href="./blender/all-supported/"><strong>All supported nodes</strong><span>Complete catalog</span></a>
<a href="./blender/parity/"><strong>Parity scene</strong><span>Image and grading nodes</span></a>
<a href="./blender/control-test/"><strong>Control test</strong><span>Input editing</span></a>
<a href="./blender/ramp-test/"><strong>Color Ramp test</strong><span>Stops and interpolation</span></a>
<a href="./blender/link-tools-test/"><strong>Link tools</strong><span>Knife, mute, and reroute</span></a>
</nav>
</section>
</main>
<footer>fxnode keeps graph authority in a worker. Application code owns DOM presentation and lifecycle.</footer>
</body>
</html>
+57
View File
@@ -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<string, FxNodeStyleDefinition>
>;
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];
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>fxnode live composition</title>
<link rel="stylesheet" href="../shared/example.css" />
</head>
<body>
<main>
<h1>Live composition migration</h1>
<p>
Updates a version 1 schema to version 2 and materializes a missing detail parameter. This demonstrates
composition, not graph execution or evaluation.
</p>
<button id="compose" type="button">Compose version 2</button>
<span id="status" role="status">Version 1 ready</span
><canvas id="graph" width="1000" height="560" aria-label="Live composition node graph"></canvas>
</main>
<script type="module" src="./main.ts"></script>
</body>
</html>
+82
View File
@@ -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<HTMLCanvasElement>("#graph")!,
button = document.querySelector<HTMLButtonElement>("#compose")!,
status = document.querySelector<HTMLElement>("#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;
}
})();
+35
View File
@@ -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<string, FxNodeStyleDefinition>
>;
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];
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>fxnode minimal example</title>
<link rel="stylesheet" href="../shared/example.css" />
</head>
<body>
<main>
<h1>Minimal custom node</h1>
<p>The smallest useful custom number/value node.</p>
<canvas id="graph" width="1000" height="560" aria-label="Minimal node graph"></canvas>
</main>
<script type="module" src="./main.ts"></script>
</body>
</html>
+56
View File
@@ -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<HTMLCanvasElement>("#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;
}
})();
+45
View File
@@ -0,0 +1,45 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>fxnode multi-view example</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main>
<header>
<div>
<h1>One graph, two views</h1>
<p>Selection and camera are local to each canvas; graph edits render in both.</p>
</div>
<div id="toolbar" role="toolbar" aria-label="Active view tools">
<label
>Node
<select id="node-type">
<option value="fxnode.shader.value">Value</option>
<option value="fxnode.shader.color">Color</option>
<option value="fxnode.shader.math">Math</option>
<option value="fxnode.shader.noise-texture">Noise Texture</option>
</select></label
>
<button data-action="add">Add</button>
<button data-action="delete" disabled>Delete</button>
<button data-action="mute" aria-pressed="false" disabled>Mute</button>
<output id="toolbar-status" aria-live="polite"></output>
</div>
</header>
<section class="views">
<article class="active">
<h2>Material detail</h2>
<canvas id="view-a"></canvas>
</article>
<article>
<h2>Output overview</h2>
<canvas id="view-b"></canvas>
</article>
</section>
</main>
<script type="module" src="./main.ts"></script>
</body>
</html>
+124
View File
@@ -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<HTMLCanvasElement>("#view-a")!,
document.querySelector<HTMLCanvasElement>("#view-b")!,
];
const toolbar = document.querySelector<HTMLElement>("#toolbar")!;
const nodeType = document.querySelector<HTMLSelectElement>("#node-type")!;
const addButton = toolbar.querySelector<HTMLButtonElement>("[data-action=add]")!;
const deleteButton = toolbar.querySelector<HTMLButtonElement>("[data-action=delete]")!;
const muteButton = toolbar.querySelector<HTMLButtonElement>("[data-action=mute]")!;
const status = toolbar.querySelector<HTMLOutputElement>("#toolbar-status")!;
let activeIndex = 0,
toolbarPending = false,
cleaned = false,
root: Awaited<ReturnType<typeof createApplicationFxNode>> | 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<HTMLButtonElement>("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<CanvasRenderingContext2D["drawImage"]>) => {
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;
}
})();
+84
View File
@@ -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;
}
+154
View File
@@ -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<T extends Element>(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<HTMLButtonElement>("button[data-fxnode-menu-option]")].map(
(option) => {
const typeId = option.dataset.typeId;
const title = option.textContent?.trim();
const group = option.closest<HTMLElement>("[data-fxnode-menu-group]");
const groupTitle = group?.querySelector<HTMLElement>("[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<HTMLDivElement>(template, "root");
const input = host.querySelector<HTMLInputElement>("[data-fxnode-menu-search]");
const results = host.querySelector<HTMLDivElement>("[data-fxnode-menu-results]");
const empty = host.querySelector<HTMLElement>("[data-fxnode-menu-empty]");
const options = [...host.querySelectorAll<HTMLButtonElement>("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<HTMLElement>("[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) };
}
+439
View File
@@ -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<void>;
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<HTMLCanvasElement, PreparedFxNodeBrowserHost>();
type DisconnectRegistration = { canvas: HTMLCanvasElement; disconnected(): void };
const disconnectRegistries = new WeakMap<
Document,
{ observer: MutationObserver; registrations: Set<DisconnectRegistration> }
>();
function watchDisconnect(documentValue: Document, registration: DisconnectRegistration): () => void {
let registry = disconnectRegistries.get(documentValue);
if (!registry) {
const registrations = new Set<DisconnectRegistration>();
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<number>(),
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;
}
+38
View File
@@ -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;
}
+26
View File
@@ -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<void>;
graphVersion?: number;
lastCompositionReceipt?: CompositionReceipt;
cleanup(): void;
}
interface Window {
fxnodeStandalone: StandaloneExampleHandle;
fxnodeMultiView: MultiViewExampleHandle;
}
interface MultiViewExampleHandle {
root: FxNode | null;
views: FxNodeView[];
ready: Promise<void>;
cleanup(): Promise<void>;
renderCounts: number[];
}
}
export {};
+352
View File
@@ -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];
+33
View File
@@ -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;