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:
Vendored
+1238
File diff suppressed because it is too large
Load Diff
+229
@@ -0,0 +1,229 @@
|
||||
import type {
|
||||
AddNodeParams,
|
||||
FxNodeInput,
|
||||
FxNodeResourceAuthorization,
|
||||
FxNodeResourceData,
|
||||
FxNodeCamera,
|
||||
FxNodeViewport,
|
||||
} from "./host-types.js";
|
||||
import { fxNodeDevicePixels, FXNODE_VIEW_LIMITS } from "./view-limits.js";
|
||||
import type { InputEventWire, VersionExpectation } from "./protocol.js";
|
||||
|
||||
type Values = Record<string, unknown>;
|
||||
function inspect(value: unknown): { values: Values; keys: readonly string[] } | undefined {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return;
|
||||
const own = Reflect.ownKeys(value);
|
||||
if (own.some((key) => typeof key !== "string")) return;
|
||||
const result: Values = {};
|
||||
for (const key of own as string[]) {
|
||||
const descriptor = Reflect.getOwnPropertyDescriptor(value, key);
|
||||
if (!descriptor || !("value" in descriptor)) return;
|
||||
result[key] = descriptor.value;
|
||||
}
|
||||
return { values: result, keys: own as string[] };
|
||||
}
|
||||
const exact = (
|
||||
item: { values: Values; keys: readonly string[] } | undefined,
|
||||
keys: readonly string[],
|
||||
): Values | undefined =>
|
||||
item && item.keys.length === keys.length && item.keys.every((key) => keys.includes(key)) ? item.values : undefined;
|
||||
const data = (value: unknown, keys: readonly string[]) => exact(inspect(value), keys);
|
||||
const point = (value: unknown): { x: number; y: number } | undefined => {
|
||||
const v = data(value, ["x", "y"]);
|
||||
return v && typeof v.x === "number" && Number.isFinite(v.x) && typeof v.y === "number" && Number.isFinite(v.y)
|
||||
? { x: v.x, y: v.y }
|
||||
: undefined;
|
||||
};
|
||||
const modifiers = (value: unknown): number | undefined => {
|
||||
const v = data(value, ["alt", "control", "meta", "shift"]);
|
||||
if (
|
||||
!v ||
|
||||
typeof v.alt !== "boolean" ||
|
||||
typeof v.control !== "boolean" ||
|
||||
typeof v.meta !== "boolean" ||
|
||||
typeof v.shift !== "boolean"
|
||||
)
|
||||
return;
|
||||
return (v.alt ? 1 : 0) | (v.control ? 2 : 0) | (v.meta ? 4 : 0) | (v.shift ? 8 : 0);
|
||||
};
|
||||
export function decodeFxNodeInput(value: FxNodeInput): InputEventWire {
|
||||
try {
|
||||
const item = inspect(value as unknown),
|
||||
kind = item?.values.kind;
|
||||
const base = kind === "focus" ? exact(item, ["kind", "phase"]) : undefined;
|
||||
if (base && (base.phase === "focus" || base.phase === "blur")) return { kind: "focus", phase: base.phase };
|
||||
const outside = kind === "outside-pointer" ? exact(item, ["kind", "button"]) : undefined;
|
||||
if (outside && Number.isInteger(outside.button))
|
||||
return { kind: "outside-pointer", button: outside.button as number };
|
||||
const pointer =
|
||||
kind === "pointer"
|
||||
? exact(item, ["kind", "phase", "pointerId", "pointerType", "position", "button", "buttons", "modifiers"])
|
||||
: undefined,
|
||||
p = pointer && point(pointer.position),
|
||||
m = pointer && modifiers(pointer.modifiers);
|
||||
if (
|
||||
pointer &&
|
||||
["down", "move", "up", "cancel"].includes(String(pointer.phase)) &&
|
||||
Number.isSafeInteger(pointer.pointerId) &&
|
||||
typeof pointer.pointerType === "string" &&
|
||||
pointer.pointerType.length <= 64 &&
|
||||
p &&
|
||||
Number.isInteger(pointer.button) &&
|
||||
Number.isInteger(pointer.buttons) &&
|
||||
m !== undefined
|
||||
)
|
||||
return {
|
||||
kind: "pointer",
|
||||
phase: pointer.phase as "down" | "move" | "up" | "cancel",
|
||||
pointerId: pointer.pointerId as number,
|
||||
pointerType: pointer.pointerType,
|
||||
position: p,
|
||||
button: pointer.button as number,
|
||||
buttons: pointer.buttons as number,
|
||||
modifiers: m,
|
||||
};
|
||||
const wheel = kind === "wheel" ? exact(item, ["kind", "position", "delta", "modifiers"]) : undefined,
|
||||
wp = wheel && point(wheel.position),
|
||||
delta = wheel && point(wheel.delta),
|
||||
wm = wheel && modifiers(wheel.modifiers);
|
||||
if (wheel && wp && delta && wm !== undefined) return { kind: "wheel", position: wp, delta, modifiers: wm };
|
||||
const key = kind === "key" ? exact(item, ["kind", "phase", "key", "code", "repeat", "modifiers"]) : undefined,
|
||||
km = key && modifiers(key.modifiers);
|
||||
if (
|
||||
key &&
|
||||
(key.phase === "down" || key.phase === "up") &&
|
||||
typeof key.key === "string" &&
|
||||
key.key.length <= 256 &&
|
||||
typeof key.code === "string" &&
|
||||
key.code.length <= 256 &&
|
||||
typeof key.repeat === "boolean" &&
|
||||
km !== undefined
|
||||
)
|
||||
return { kind: "key", phase: key.phase, key: key.key, code: key.code, repeat: key.repeat, modifiers: km };
|
||||
} catch {}
|
||||
throw new TypeError("Invalid FxNode input");
|
||||
}
|
||||
export function decodeFxNodeViewport(value: FxNodeViewport): FxNodeViewport {
|
||||
let v: Values | undefined;
|
||||
try {
|
||||
v = data(value as unknown, ["width", "height", "dpr"]);
|
||||
} catch {}
|
||||
if (
|
||||
!v ||
|
||||
typeof v.width !== "number" ||
|
||||
!Number.isFinite(v.width) ||
|
||||
typeof v.height !== "number" ||
|
||||
!Number.isFinite(v.height) ||
|
||||
typeof v.dpr !== "number" ||
|
||||
!Number.isFinite(v.dpr)
|
||||
)
|
||||
throw new TypeError("Invalid FxNode viewport");
|
||||
if (
|
||||
v.width < 0 ||
|
||||
v.width > FXNODE_VIEW_LIMITS.maxLogicalDimension ||
|
||||
v.height < 0 ||
|
||||
v.height > FXNODE_VIEW_LIMITS.maxLogicalDimension ||
|
||||
v.dpr <= 0 ||
|
||||
v.dpr > FXNODE_VIEW_LIMITS.maxDpr ||
|
||||
!fxNodeDevicePixels(v.width, v.height, v.dpr)
|
||||
)
|
||||
throw new RangeError("FxNode viewport is outside supported bounds");
|
||||
return Object.freeze({ width: v.width, height: v.height, dpr: v.dpr });
|
||||
}
|
||||
|
||||
/** Decodes and detaches a camera from an exact, data-only hostile object. */
|
||||
export function decodeFxNodeCamera(value: FxNodeCamera): FxNodeCamera {
|
||||
let v: Values | undefined, center: { x: number; y: number } | undefined;
|
||||
try {
|
||||
v = data(value as unknown, ["center", "zoom"]);
|
||||
center = v && point(v.center);
|
||||
} catch {}
|
||||
if (!v || !center || typeof v.zoom !== "number" || !Number.isFinite(v.zoom))
|
||||
throw new TypeError("Invalid FxNode camera");
|
||||
if (v.zoom < FXNODE_VIEW_LIMITS.minZoom || v.zoom > FXNODE_VIEW_LIMITS.maxZoom)
|
||||
throw new RangeError("FxNode camera is outside supported bounds");
|
||||
return Object.freeze({ center: Object.freeze(center), zoom: v.zoom });
|
||||
}
|
||||
|
||||
export function decodeFxNodeActionOptions(value: unknown): VersionExpectation {
|
||||
if (value === undefined) return { kind: "current" };
|
||||
let item: ReturnType<typeof inspect> | undefined;
|
||||
try {
|
||||
item = inspect(value);
|
||||
} catch {}
|
||||
if (item?.keys.length === 0) return { kind: "current" };
|
||||
const v = exact(item, ["expectedVersion"]);
|
||||
if (!v || !Number.isSafeInteger(v.expectedVersion) || (v.expectedVersion as number) < 0)
|
||||
throw new TypeError("Invalid action options");
|
||||
return { kind: "exact", version: v.expectedVersion as number };
|
||||
}
|
||||
|
||||
export function decodeFxNodeAddNodeParams(value: unknown): AddNodeParams {
|
||||
try {
|
||||
const item = inspect(value);
|
||||
if (
|
||||
item &&
|
||||
item.keys.every((key) => ["typeId", "viewPosition", "nodeId"].includes(key)) &&
|
||||
["typeId", "viewPosition"].every((key) => item.keys.includes(key))
|
||||
) {
|
||||
const v = item.values,
|
||||
p = point(v.viewPosition);
|
||||
if (
|
||||
typeof v.typeId === "string" &&
|
||||
v.typeId &&
|
||||
v.typeId.length <= 128 &&
|
||||
p &&
|
||||
(!item.keys.includes("nodeId") ||
|
||||
v.nodeId === undefined ||
|
||||
(typeof v.nodeId === "string" && !!v.nodeId && v.nodeId.length <= 512))
|
||||
)
|
||||
return { typeId: v.typeId, viewPosition: p, ...(v.nodeId === undefined ? {} : { nodeId: v.nodeId as string }) };
|
||||
}
|
||||
} catch {}
|
||||
throw new TypeError("Invalid add-node parameters");
|
||||
}
|
||||
export function decodeFxNodeResourceAuthorization(value: unknown): FxNodeResourceAuthorization {
|
||||
let v: Values | undefined;
|
||||
try {
|
||||
v = data(value, ["viewId", "token", "graphVersion", "compositionRevision"]);
|
||||
} catch {}
|
||||
if (
|
||||
!v ||
|
||||
typeof v.viewId !== "string" ||
|
||||
!v.viewId ||
|
||||
v.viewId.length > 512 ||
|
||||
typeof v.token !== "string" ||
|
||||
!v.token ||
|
||||
v.token.length > 512 ||
|
||||
!Number.isSafeInteger(v.graphVersion) ||
|
||||
(v.graphVersion as number) < 0 ||
|
||||
!Number.isSafeInteger(v.compositionRevision) ||
|
||||
(v.compositionRevision as number) < 0
|
||||
)
|
||||
throw new TypeError("Invalid resource authorization");
|
||||
return Object.freeze({
|
||||
viewId: v.viewId,
|
||||
token: v.token,
|
||||
graphVersion: v.graphVersion as number,
|
||||
compositionRevision: v.compositionRevision as number,
|
||||
});
|
||||
}
|
||||
export function decodeFxNodeResourceData(value: unknown): FxNodeResourceData {
|
||||
let v: Values | undefined, length: number | undefined;
|
||||
try {
|
||||
v = data(value, ["name", "mime", "bytes"]);
|
||||
if (
|
||||
v &&
|
||||
typeof v.name === "string" &&
|
||||
v.name &&
|
||||
v.name.length <= 255 &&
|
||||
!/[\u0000-\u001f\u007f]/.test(v.name) &&
|
||||
typeof v.mime === "string" &&
|
||||
v.mime.length <= 128
|
||||
)
|
||||
length = Object.getOwnPropertyDescriptor(ArrayBuffer.prototype, "byteLength")!.get!.call(v.bytes) as number;
|
||||
} catch {}
|
||||
if (!v || length === undefined) throw new TypeError("Invalid resource data");
|
||||
if (length === 0 || length > 32 * 1024 * 1024) throw new RangeError("Resource data is outside supported bounds");
|
||||
return Object.freeze({ name: v.name as string, mime: v.mime as string, bytes: v.bytes as ArrayBuffer });
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/** DOM-independent keyboard modifiers. */
|
||||
export interface FxNodeModifiers {
|
||||
readonly alt: boolean;
|
||||
readonly control: boolean;
|
||||
readonly meta: boolean;
|
||||
readonly shift: boolean;
|
||||
}
|
||||
|
||||
export type FxNodeInput =
|
||||
| {
|
||||
readonly kind: "pointer";
|
||||
readonly phase: "down" | "move" | "up" | "cancel";
|
||||
readonly pointerId: number;
|
||||
readonly pointerType: string;
|
||||
readonly position: { readonly x: number; readonly y: number };
|
||||
readonly button: number;
|
||||
readonly buttons: number;
|
||||
readonly modifiers: FxNodeModifiers;
|
||||
}
|
||||
| {
|
||||
readonly kind: "wheel";
|
||||
readonly position: { readonly x: number; readonly y: number };
|
||||
readonly delta: { readonly x: number; readonly y: number };
|
||||
readonly modifiers: FxNodeModifiers;
|
||||
}
|
||||
| {
|
||||
readonly kind: "key";
|
||||
readonly phase: "down" | "up";
|
||||
readonly key: string;
|
||||
readonly code: string;
|
||||
readonly repeat: boolean;
|
||||
readonly modifiers: FxNodeModifiers;
|
||||
}
|
||||
| { readonly kind: "focus"; readonly phase: "focus" | "blur" }
|
||||
| { readonly kind: "outside-pointer"; readonly button: number };
|
||||
|
||||
export interface FxNodeViewport {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly dpr: number;
|
||||
}
|
||||
/** DOM-independent camera state in graph coordinates. */
|
||||
export interface FxNodeCamera {
|
||||
readonly center: { readonly x: number; readonly y: number };
|
||||
readonly zoom: number;
|
||||
}
|
||||
export interface AddNodeParams {
|
||||
readonly typeId: string;
|
||||
readonly viewPosition: { readonly x: number; readonly y: number };
|
||||
readonly nodeId?: string;
|
||||
}
|
||||
export interface FxNodeActionOptions {
|
||||
readonly expectedVersion?: number;
|
||||
}
|
||||
export interface FxNodeSelectionSnapshot {
|
||||
readonly nodeCount: number;
|
||||
readonly linkCount: number;
|
||||
readonly canRemove: boolean;
|
||||
readonly mute:
|
||||
| { readonly enabled: false }
|
||||
| { readonly enabled: true; readonly state: "all-muted" | "all-unmuted" | "mixed" };
|
||||
}
|
||||
export interface FxNodeAddNodeMenuRequest {
|
||||
readonly kind: "add-node-menu";
|
||||
readonly viewPosition: { readonly x: number; readonly y: number };
|
||||
readonly compositionRevision: number;
|
||||
}
|
||||
export interface FxNodeResourceAuthorization {
|
||||
readonly viewId: string;
|
||||
readonly token: string;
|
||||
readonly graphVersion: number;
|
||||
readonly compositionRevision: number;
|
||||
}
|
||||
export interface FxNodeImageResourceDescriptor {
|
||||
readonly id: string;
|
||||
readonly kind: "image";
|
||||
readonly title: string;
|
||||
readonly openTitle: string;
|
||||
readonly accept: readonly string[];
|
||||
readonly maxBytes: number;
|
||||
readonly maxWidth: number;
|
||||
readonly maxHeight: number;
|
||||
readonly maxPixels: number;
|
||||
}
|
||||
export interface FxNodeResourceOpenRequest {
|
||||
readonly kind: "resource-open";
|
||||
readonly authorization: FxNodeResourceAuthorization;
|
||||
readonly resource: FxNodeImageResourceDescriptor;
|
||||
}
|
||||
export interface FxNodeResourceData {
|
||||
readonly name: string;
|
||||
readonly mime: string;
|
||||
readonly bytes: ArrayBuffer;
|
||||
}
|
||||
export type FxNodeHostRequest = FxNodeAddNodeMenuRequest | FxNodeResourceOpenRequest;
|
||||
export interface FxNodeHostSnapshot {
|
||||
readonly compositionRevision: number;
|
||||
readonly colorPickerOpen: boolean;
|
||||
readonly selection: FxNodeSelectionSnapshot;
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import type { InputEventWire } from "./protocol.js";
|
||||
|
||||
export type PointerMoveWire = Extract<InputEventWire, { kind: "pointer" }> & { phase: "move" };
|
||||
export interface PointerLaneSnapshot {
|
||||
readonly sequence: number;
|
||||
readonly hostGeneration: number;
|
||||
readonly event: PointerMoveWire;
|
||||
}
|
||||
|
||||
const WORDS = 16;
|
||||
const STAMP = 0;
|
||||
const FENCE = 1;
|
||||
const SEQUENCE = 2;
|
||||
const X_LOW = 3;
|
||||
const X_HIGH = 4;
|
||||
const Y_LOW = 5;
|
||||
const Y_HIGH = 6;
|
||||
const POINTER_ID = 7;
|
||||
const POINTER_TYPE = 8;
|
||||
const BUTTON = 9;
|
||||
const BUTTONS = 10;
|
||||
const MODIFIERS = 11;
|
||||
const HOST_GENERATION_LOW = 12;
|
||||
const HOST_GENERATION_HIGH = 13;
|
||||
const UINT32 = 0x1_0000_0000;
|
||||
const scratch = new DataView(new ArrayBuffer(8));
|
||||
const views = new WeakMap<SharedArrayBuffer, Int32Array>();
|
||||
const wordsFor = (buffer: SharedArrayBuffer): Int32Array => {
|
||||
const existing = views.get(buffer);
|
||||
if (existing) return existing;
|
||||
const words = new Int32Array(buffer);
|
||||
views.set(buffer, words);
|
||||
return words;
|
||||
};
|
||||
|
||||
const pointerTypes = ["", "mouse", "pen", "touch"] as const;
|
||||
const pointerTypeCode = (value: string): number => pointerTypes.indexOf(value as (typeof pointerTypes)[number]);
|
||||
const writeFloat = (words: Int32Array, low: number, high: number, value: number): void => {
|
||||
scratch.setFloat64(0, value, true);
|
||||
Atomics.store(words, low, scratch.getInt32(0, true));
|
||||
Atomics.store(words, high, scratch.getInt32(4, true));
|
||||
};
|
||||
const readFloat = (words: Int32Array, low: number, high: number): number => {
|
||||
scratch.setInt32(0, Atomics.load(words, low), true);
|
||||
scratch.setInt32(4, Atomics.load(words, high), true);
|
||||
return scratch.getFloat64(0, true);
|
||||
};
|
||||
|
||||
export function supportsPointerLane(): boolean {
|
||||
return (
|
||||
globalThis.crossOriginIsolated === true && typeof SharedArrayBuffer === "function" && typeof Atomics === "object"
|
||||
);
|
||||
}
|
||||
|
||||
export function createPointerLane(): SharedArrayBuffer {
|
||||
return new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * WORDS);
|
||||
}
|
||||
export function pointerLaneFence(buffer: SharedArrayBuffer): number {
|
||||
return Atomics.load(wordsFor(buffer), FENCE);
|
||||
}
|
||||
export function advancePointerLaneFence(buffer: SharedArrayBuffer): number {
|
||||
return (Atomics.add(wordsFor(buffer), FENCE, 1) + 1) | 0;
|
||||
}
|
||||
|
||||
export function publishPointerMove(
|
||||
buffer: SharedArrayBuffer,
|
||||
event: PointerMoveWire,
|
||||
hostGeneration: number,
|
||||
): number | undefined {
|
||||
const type = pointerTypeCode(event.pointerType);
|
||||
if (type < 1 || !Number.isSafeInteger(hostGeneration) || hostGeneration < 0) return undefined;
|
||||
const words = wordsFor(buffer);
|
||||
const writing = (Atomics.load(words, STAMP) + 1) | 1;
|
||||
Atomics.store(words, STAMP, writing);
|
||||
const sequence = (Atomics.load(words, SEQUENCE) + 1) | 0;
|
||||
Atomics.store(words, SEQUENCE, sequence);
|
||||
writeFloat(words, X_LOW, X_HIGH, event.position.x);
|
||||
writeFloat(words, Y_LOW, Y_HIGH, event.position.y);
|
||||
Atomics.store(words, POINTER_ID, event.pointerId);
|
||||
Atomics.store(words, POINTER_TYPE, type);
|
||||
Atomics.store(words, BUTTON, event.button);
|
||||
Atomics.store(words, BUTTONS, event.buttons);
|
||||
Atomics.store(words, MODIFIERS, event.modifiers);
|
||||
Atomics.store(words, HOST_GENERATION_LOW, hostGeneration % UINT32);
|
||||
Atomics.store(words, HOST_GENERATION_HIGH, Math.floor(hostGeneration / UINT32));
|
||||
Atomics.store(words, STAMP, (writing + 1) & ~1);
|
||||
return sequence;
|
||||
}
|
||||
|
||||
export function readPointerMove(buffer: SharedArrayBuffer, consumedSequence?: number): PointerLaneSnapshot | undefined {
|
||||
const words = wordsFor(buffer);
|
||||
const before = Atomics.load(words, STAMP);
|
||||
if ((before & 1) !== 0) return undefined;
|
||||
const sequence = Atomics.load(words, SEQUENCE);
|
||||
if (sequence === 0 || sequence === consumedSequence) return undefined;
|
||||
const x = readFloat(words, X_LOW, X_HIGH);
|
||||
const y = readFloat(words, Y_LOW, Y_HIGH);
|
||||
const pointerId = Atomics.load(words, POINTER_ID);
|
||||
const pointerType = pointerTypes[Atomics.load(words, POINTER_TYPE)];
|
||||
const button = Atomics.load(words, BUTTON);
|
||||
const buttons = Atomics.load(words, BUTTONS);
|
||||
const modifiers = Atomics.load(words, MODIFIERS);
|
||||
const hostGeneration =
|
||||
(Atomics.load(words, HOST_GENERATION_LOW) >>> 0) + Atomics.load(words, HOST_GENERATION_HIGH) * UINT32;
|
||||
const after = Atomics.load(words, STAMP);
|
||||
if (before !== after || (after & 1) !== 0 || !pointerType || !Number.isFinite(x) || !Number.isFinite(y))
|
||||
return undefined;
|
||||
if (!Number.isSafeInteger(hostGeneration) || hostGeneration < 0) return undefined;
|
||||
return {
|
||||
sequence,
|
||||
hostGeneration,
|
||||
event: { kind: "pointer", phase: "move", pointerId, pointerType, position: { x, y }, button, buttons, modifiers },
|
||||
};
|
||||
}
|
||||
+833
@@ -0,0 +1,833 @@
|
||||
import type { Command, FxNodeSaveData } from "../commands/types.js";
|
||||
import type { GraphLayoutV2, GraphSnapshot } from "../core/types.js";
|
||||
import type {
|
||||
MutationEnvelope as BoundMutationEnvelope,
|
||||
SnapshotEnvelope as BoundSnapshotEnvelope,
|
||||
} from "../composition/bound-engine.js";
|
||||
import type { FxNodeCompositionData } from "../composition/types.js";
|
||||
import type { PointerLaneSnapshot } from "./pointer-lane.js";
|
||||
import { FXNODE_COMPOSITION_LIMITS } from "../composition/validate.js";
|
||||
import { validCommand } from "../commands/validate.js";
|
||||
import type { FxNodeCamera, FxNodeSelectionSnapshot, FxNodeViewport } from "./host-types.js";
|
||||
import { fxNodeDevicePixels, FXNODE_VIEW_LIMITS } from "./view-limits.js";
|
||||
|
||||
export const PROTOCOL_VERSION = 3 as const;
|
||||
export type ViewId = string;
|
||||
export type Camera = FxNodeCamera;
|
||||
export type VersionExpectation = { readonly kind: "current" } | { readonly kind: "exact"; readonly version: number };
|
||||
export type CompositionRevisionExpectation =
|
||||
| { readonly kind: "current" }
|
||||
| { readonly kind: "exact"; readonly revision: number };
|
||||
export type CompositionUpdateWire =
|
||||
| { readonly kind: "composition.load"; readonly composition: unknown }
|
||||
| { readonly kind: "theme.set"; readonly theme: unknown }
|
||||
| { readonly kind: "header-styles.set"; readonly styles: unknown }
|
||||
| { readonly kind: "compatibility.set"; readonly compatibility: unknown }
|
||||
| { readonly kind: "socket.compose"; readonly id: string; readonly definition: unknown }
|
||||
| { readonly kind: "socket.remove"; readonly id: string }
|
||||
| { readonly kind: "node.compose"; readonly id: string; readonly definition: unknown }
|
||||
| { readonly kind: "node.remove"; readonly id: string };
|
||||
export type CompositionChange =
|
||||
| { readonly kind: "composition.load" }
|
||||
| { readonly kind: "theme.set" }
|
||||
| { readonly kind: "header-styles.set" }
|
||||
| { readonly kind: "compatibility.set" }
|
||||
| { readonly kind: "socket.compose" | "socket.remove" | "node.compose" | "node.remove"; readonly id: string };
|
||||
export type CompositionReceipt =
|
||||
| {
|
||||
readonly status: "committed";
|
||||
readonly revision: number;
|
||||
readonly graphVersion: number;
|
||||
readonly graphChanged: boolean;
|
||||
readonly historyReset: true;
|
||||
}
|
||||
| {
|
||||
readonly status: "noop";
|
||||
readonly revision: number;
|
||||
readonly graphVersion: number;
|
||||
readonly graphChanged: false;
|
||||
readonly historyReset: false;
|
||||
};
|
||||
export interface CompositionChangeEnvelope {
|
||||
readonly baseRevision: number;
|
||||
readonly revision: number;
|
||||
readonly change: CompositionChange;
|
||||
readonly baseGraphVersion: number;
|
||||
readonly graphVersion: number;
|
||||
readonly graphChanged: boolean;
|
||||
readonly historyReset: true;
|
||||
}
|
||||
export type WorkerRequest =
|
||||
| {
|
||||
protocol: typeof PROTOCOL_VERSION;
|
||||
type: "init";
|
||||
id: string;
|
||||
applicationId: unknown;
|
||||
applicationVersion: unknown;
|
||||
resources: unknown;
|
||||
historyLimit: number;
|
||||
}
|
||||
| {
|
||||
protocol: typeof PROTOCOL_VERSION;
|
||||
type: "view.attach";
|
||||
id: string;
|
||||
viewId: ViewId;
|
||||
viewport: Viewport;
|
||||
camera: Camera;
|
||||
pointerLane?: SharedArrayBuffer;
|
||||
}
|
||||
| { protocol: typeof PROTOCOL_VERSION; type: "view.detach"; id: string; viewId: string }
|
||||
| { protocol: typeof PROTOCOL_VERSION; type: "command"; id: string; command: Command; expected: VersionExpectation }
|
||||
| { protocol: typeof PROTOCOL_VERSION; type: "load"; id: string; data: unknown; expected: VersionExpectation }
|
||||
| { protocol: typeof PROTOCOL_VERSION; type: "state.get" | "save" | "save.data"; id: string }
|
||||
| { protocol: typeof PROTOCOL_VERSION; type: "state.set"; id: string; state: unknown; expected: VersionExpectation }
|
||||
| {
|
||||
protocol: typeof PROTOCOL_VERSION;
|
||||
type: "composition.update";
|
||||
id: string;
|
||||
expected: CompositionRevisionExpectation;
|
||||
update: CompositionUpdateWire;
|
||||
}
|
||||
| {
|
||||
protocol: typeof PROTOCOL_VERSION;
|
||||
type: "view.viewport";
|
||||
id: string;
|
||||
viewId: string;
|
||||
viewport: Viewport;
|
||||
expectedSurfaceGeneration: number;
|
||||
hostGeneration: number;
|
||||
pointerFence?: PointerFence;
|
||||
}
|
||||
| {
|
||||
protocol: typeof PROTOCOL_VERSION;
|
||||
type: "view.render";
|
||||
viewId: string;
|
||||
renderId: number;
|
||||
hostGeneration: number;
|
||||
pointerFence?: PointerFence;
|
||||
}
|
||||
| {
|
||||
protocol: typeof PROTOCOL_VERSION;
|
||||
type: "view.input";
|
||||
viewId: string;
|
||||
event: InputEventWire;
|
||||
hostGeneration: number;
|
||||
pointerFence?: PointerFence;
|
||||
nodeMenuRequestId?: string;
|
||||
resourceOpenRequestId?: string;
|
||||
}
|
||||
| {
|
||||
protocol: typeof PROTOCOL_VERSION;
|
||||
type: "view.node.add";
|
||||
viewId: string;
|
||||
id: string;
|
||||
nodeId: string;
|
||||
typeId: string;
|
||||
viewPosition: { x: number; y: number };
|
||||
expected: VersionExpectation;
|
||||
pointerFence?: PointerFence;
|
||||
}
|
||||
| {
|
||||
protocol: typeof PROTOCOL_VERSION;
|
||||
type: "view.selection.remove";
|
||||
viewId: string;
|
||||
id: string;
|
||||
expected: VersionExpectation;
|
||||
pointerFence?: PointerFence;
|
||||
}
|
||||
| {
|
||||
protocol: typeof PROTOCOL_VERSION;
|
||||
type: "view.selection.mute";
|
||||
viewId: string;
|
||||
id: string;
|
||||
value: boolean;
|
||||
expected: VersionExpectation;
|
||||
pointerFence?: PointerFence;
|
||||
}
|
||||
| {
|
||||
protocol: typeof PROTOCOL_VERSION;
|
||||
type: "view.resource.set";
|
||||
viewId: string;
|
||||
id: string;
|
||||
authorization: { viewId: string; token: string; graphVersion: number; compositionRevision: number };
|
||||
resource: { name: string; mime: string; bytes: ArrayBuffer };
|
||||
expected: VersionExpectation;
|
||||
pointerFence?: PointerFence;
|
||||
}
|
||||
| { protocol: typeof PROTOCOL_VERSION; type: "view.pointer.flush"; viewId: string; pointerFence: PointerFence }
|
||||
| { protocol: typeof PROTOCOL_VERSION; type: "view.frame.consumed"; viewId: string; frameId: number }
|
||||
| { protocol: typeof PROTOCOL_VERSION; type: "dispose" };
|
||||
export type Viewport = FxNodeViewport;
|
||||
export interface PointerFence {
|
||||
readonly generation: number;
|
||||
readonly before?: PointerLaneSnapshot;
|
||||
}
|
||||
export type InputEventWire =
|
||||
| {
|
||||
kind: "pointer";
|
||||
phase: "down" | "move" | "up" | "cancel";
|
||||
pointerId: number;
|
||||
pointerType: string;
|
||||
position: { x: number; y: number };
|
||||
button: number;
|
||||
buttons: number;
|
||||
modifiers: number;
|
||||
}
|
||||
| { kind: "wheel"; position: { x: number; y: number }; delta: { x: number; y: number }; modifiers: number }
|
||||
| { kind: "key"; phase: "down" | "up"; key: string; code: string; repeat: boolean; modifiers: number }
|
||||
| { kind: "focus"; phase: "focus" | "blur" }
|
||||
| { kind: "outside-pointer"; button: number };
|
||||
export interface HostInteractionSnapshot {
|
||||
readonly colorPickerOpen: boolean;
|
||||
}
|
||||
export type WorkerMessage =
|
||||
| {
|
||||
protocol: typeof PROTOCOL_VERSION;
|
||||
type: "response";
|
||||
id: string;
|
||||
ok: true;
|
||||
value?:
|
||||
| GraphSnapshot
|
||||
| GraphLayoutV2
|
||||
| FxNodeSaveData
|
||||
| { status: "committed" | "noop"; version: number }
|
||||
| CompositionReceipt;
|
||||
}
|
||||
| {
|
||||
protocol: typeof PROTOCOL_VERSION;
|
||||
type: "response";
|
||||
id: string;
|
||||
ok: false;
|
||||
error: { code: string; message: string; path?: string; issues?: unknown };
|
||||
}
|
||||
| { protocol: typeof PROTOCOL_VERSION; type: "mutation"; envelope: BoundMutationEnvelope<FxNodeCompositionData> }
|
||||
| {
|
||||
protocol: typeof PROTOCOL_VERSION;
|
||||
type: "snapshot.event";
|
||||
envelope: BoundSnapshotEnvelope<FxNodeCompositionData>;
|
||||
}
|
||||
| { protocol: typeof PROTOCOL_VERSION; type: "composition.event"; envelope: CompositionChangeEnvelope }
|
||||
| {
|
||||
protocol: typeof PROTOCOL_VERSION;
|
||||
type: "view.selection.host";
|
||||
viewId: string;
|
||||
projection: FxNodeSelectionSnapshot;
|
||||
}
|
||||
| {
|
||||
protocol: typeof PROTOCOL_VERSION;
|
||||
type: "view.frame";
|
||||
viewId: string;
|
||||
bitmap: ImageBitmap;
|
||||
renderId: number;
|
||||
frameId: number;
|
||||
hostGeneration: number;
|
||||
surfaceGeneration: number;
|
||||
deviceWidth: number;
|
||||
deviceHeight: number;
|
||||
host: HostInteractionSnapshot;
|
||||
}
|
||||
| { protocol: typeof PROTOCOL_VERSION; type: "view.node-menu.result"; viewId: string; requestId: string; open: false }
|
||||
| {
|
||||
protocol: typeof PROTOCOL_VERSION;
|
||||
type: "view.node-menu.result";
|
||||
viewId: string;
|
||||
requestId: string;
|
||||
open: true;
|
||||
compositionRevision: number;
|
||||
viewPosition: { x: number; y: number };
|
||||
}
|
||||
| {
|
||||
protocol: typeof PROTOCOL_VERSION;
|
||||
type: "view.resource.open";
|
||||
viewId: string;
|
||||
requestId: string;
|
||||
authorization: { viewId: string; token: string; graphVersion: number; compositionRevision: number };
|
||||
resource: {
|
||||
id: string;
|
||||
kind: "image";
|
||||
title: string;
|
||||
openTitle: string;
|
||||
accept: readonly string[];
|
||||
maxBytes: number;
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
maxPixels: number;
|
||||
};
|
||||
}
|
||||
| { protocol: typeof PROTOCOL_VERSION; type: "fatal"; error: { code: string; message: string } };
|
||||
|
||||
const record = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
const exact = (v: Record<string, unknown>, keys: readonly string[]): boolean =>
|
||||
Object.keys(v).length === keys.length && keys.every((k) => Object.hasOwn(v, k));
|
||||
const nonnegativeSafeInteger = (value: unknown): value is number =>
|
||||
Number.isSafeInteger(value) && (value as number) >= 0;
|
||||
const forbiddenCompositionIds = new Set(["__proto__", "prototype", "constructor"]);
|
||||
const compositionId = (value: unknown): value is string =>
|
||||
typeof value === "string" &&
|
||||
value.length > 0 &&
|
||||
value.length <= FXNODE_COMPOSITION_LIMITS.maxIdLength &&
|
||||
!/[\u0000-\u001f\u007f]/.test(value) &&
|
||||
!forbiddenCompositionIds.has(value);
|
||||
function validCompositionExpectation(value: unknown): value is CompositionRevisionExpectation {
|
||||
return (
|
||||
record(value) &&
|
||||
((value.kind === "current" && exact(value, ["kind"])) ||
|
||||
(value.kind === "exact" && exact(value, ["kind", "revision"]) && nonnegativeSafeInteger(value.revision)))
|
||||
);
|
||||
}
|
||||
function validCompositionUpdate(value: unknown): value is CompositionUpdateWire {
|
||||
if (!record(value)) return false;
|
||||
if (value.kind === "composition.load") return exact(value, ["kind", "composition"]);
|
||||
if (value.kind === "theme.set") return exact(value, ["kind", "theme"]);
|
||||
if (value.kind === "header-styles.set") return exact(value, ["kind", "styles"]);
|
||||
if (value.kind === "compatibility.set") return exact(value, ["kind", "compatibility"]);
|
||||
if (value.kind === "socket.compose" || value.kind === "node.compose")
|
||||
return exact(value, ["kind", "id", "definition"]) && compositionId(value.id);
|
||||
if (value.kind === "socket.remove" || value.kind === "node.remove")
|
||||
return exact(value, ["kind", "id"]) && compositionId(value.id);
|
||||
return false;
|
||||
}
|
||||
function validCompositionChange(value: unknown): value is CompositionChange {
|
||||
if (!record(value)) return false;
|
||||
if (
|
||||
value.kind === "theme.set" ||
|
||||
value.kind === "header-styles.set" ||
|
||||
value.kind === "composition.load" ||
|
||||
value.kind === "compatibility.set"
|
||||
)
|
||||
return exact(value, ["kind"]);
|
||||
return (
|
||||
(value.kind === "socket.compose" ||
|
||||
value.kind === "socket.remove" ||
|
||||
value.kind === "node.compose" ||
|
||||
value.kind === "node.remove") &&
|
||||
exact(value, ["kind", "id"]) &&
|
||||
compositionId(value.id)
|
||||
);
|
||||
}
|
||||
function validCompositionReceiptUnsafe(value: unknown): value is CompositionReceipt {
|
||||
if (
|
||||
!record(value) ||
|
||||
!exact(value, ["status", "revision", "graphVersion", "graphChanged", "historyReset"]) ||
|
||||
!nonnegativeSafeInteger(value.revision) ||
|
||||
!nonnegativeSafeInteger(value.graphVersion)
|
||||
)
|
||||
return false;
|
||||
if (value.status === "committed")
|
||||
return value.revision > 0 && typeof value.graphChanged === "boolean" && value.historyReset === true;
|
||||
return value.status === "noop" && value.graphChanged === false && value.historyReset === false;
|
||||
}
|
||||
export function validCompositionReceipt(value: unknown): value is CompositionReceipt {
|
||||
try {
|
||||
return validCompositionReceiptUnsafe(value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export function validCommandReceipt(
|
||||
value: unknown,
|
||||
): value is { readonly status: "committed" | "noop"; readonly version: number } {
|
||||
try {
|
||||
return (
|
||||
record(value) &&
|
||||
exact(value, ["status", "version"]) &&
|
||||
(value.status === "committed" || value.status === "noop") &&
|
||||
nonnegativeSafeInteger(value.version)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function validCompositionEnvelope(value: unknown): value is CompositionChangeEnvelope {
|
||||
return (
|
||||
record(value) &&
|
||||
exact(value, [
|
||||
"baseRevision",
|
||||
"revision",
|
||||
"change",
|
||||
"baseGraphVersion",
|
||||
"graphVersion",
|
||||
"graphChanged",
|
||||
"historyReset",
|
||||
]) &&
|
||||
nonnegativeSafeInteger(value.baseRevision) &&
|
||||
nonnegativeSafeInteger(value.revision) &&
|
||||
value.revision === value.baseRevision + 1 &&
|
||||
nonnegativeSafeInteger(value.baseGraphVersion) &&
|
||||
nonnegativeSafeInteger(value.graphVersion) &&
|
||||
typeof value.graphChanged === "boolean" &&
|
||||
value.historyReset === true &&
|
||||
validCompositionChange(value.change) &&
|
||||
value.graphVersion === value.baseGraphVersion + (value.graphChanged ? 1 : 0)
|
||||
);
|
||||
}
|
||||
const boundedString = (value: unknown, max: number, nonempty = false): value is string =>
|
||||
typeof value === "string" && (!nonempty || value.length > 0) && value.length <= max;
|
||||
function validHostResourcePolicy(
|
||||
value: unknown,
|
||||
): value is Extract<WorkerMessage, { type: "view.resource.open" }>["resource"] {
|
||||
if (
|
||||
!record(value) ||
|
||||
!exact(value, ["id", "kind", "title", "openTitle", "accept", "maxBytes", "maxWidth", "maxHeight", "maxPixels"]) ||
|
||||
!compositionId(value.id) ||
|
||||
value.kind !== "image" ||
|
||||
!boundedString(value.title, FXNODE_COMPOSITION_LIMITS.maxTitleLength) ||
|
||||
!boundedString(value.openTitle, FXNODE_COMPOSITION_LIMITS.maxTitleLength) ||
|
||||
!Array.isArray(value.accept)
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
!nonnegativeSafeInteger(value.maxBytes) ||
|
||||
value.maxBytes === 0 ||
|
||||
value.maxBytes > FXNODE_COMPOSITION_LIMITS.maxImageBytes ||
|
||||
!nonnegativeSafeInteger(value.maxWidth) ||
|
||||
value.maxWidth === 0 ||
|
||||
value.maxWidth > FXNODE_COMPOSITION_LIMITS.maxImageDimension ||
|
||||
!nonnegativeSafeInteger(value.maxHeight) ||
|
||||
value.maxHeight === 0 ||
|
||||
value.maxHeight > FXNODE_COMPOSITION_LIMITS.maxImageDimension ||
|
||||
!nonnegativeSafeInteger(value.maxPixels) ||
|
||||
value.maxPixels === 0 ||
|
||||
value.maxPixels > FXNODE_COMPOSITION_LIMITS.maxImagePixels ||
|
||||
value.maxPixels > value.maxWidth * value.maxHeight
|
||||
)
|
||||
return false;
|
||||
const accepted = new Set<string>();
|
||||
for (const item of value.accept) {
|
||||
if (!boundedString(item, FXNODE_COMPOSITION_LIMITS.maxKeywordLength, true) || accepted.has(item)) return false;
|
||||
accepted.add(item);
|
||||
}
|
||||
return value.accept.length > 0;
|
||||
}
|
||||
function validViewportUnsafe(v: unknown): v is Viewport {
|
||||
return (
|
||||
record(v) &&
|
||||
exact(v, ["width", "height", "dpr"]) &&
|
||||
typeof v.width === "number" &&
|
||||
Number.isFinite(v.width) &&
|
||||
v.width >= 0 &&
|
||||
v.width <= FXNODE_VIEW_LIMITS.maxLogicalDimension &&
|
||||
typeof v.height === "number" &&
|
||||
Number.isFinite(v.height) &&
|
||||
v.height >= 0 &&
|
||||
v.height <= FXNODE_VIEW_LIMITS.maxLogicalDimension &&
|
||||
typeof v.dpr === "number" &&
|
||||
Number.isFinite(v.dpr) &&
|
||||
v.dpr > 0 &&
|
||||
v.dpr <= FXNODE_VIEW_LIMITS.maxDpr &&
|
||||
!!fxNodeDevicePixels(v.width, v.height, v.dpr)
|
||||
);
|
||||
}
|
||||
export function validViewport(v: unknown): v is Viewport {
|
||||
try {
|
||||
return validViewportUnsafe(v);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function validCameraUnsafe(v: unknown): v is FxNodeCamera {
|
||||
return (
|
||||
record(v) &&
|
||||
exact(v, ["center", "zoom"]) &&
|
||||
finitePoint(v.center) &&
|
||||
typeof v.zoom === "number" &&
|
||||
Number.isFinite(v.zoom) &&
|
||||
v.zoom >= FXNODE_VIEW_LIMITS.minZoom &&
|
||||
v.zoom <= FXNODE_VIEW_LIMITS.maxZoom
|
||||
);
|
||||
}
|
||||
/** Total camera validator for worker-bound hostile values. */
|
||||
export function validCamera(v: unknown): v is FxNodeCamera {
|
||||
try {
|
||||
return validCameraUnsafe(v);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function validRequestUnsafe(v: unknown): v is WorkerRequest {
|
||||
if (!record(v) || v.protocol !== PROTOCOL_VERSION || typeof v.type !== "string") return false;
|
||||
if (v.type.startsWith("view.") && !id(v.viewId)) return false;
|
||||
if (v.type === "init")
|
||||
return (
|
||||
Object.keys(v).every((k) =>
|
||||
["protocol", "type", "id", "applicationId", "applicationVersion", "resources", "historyLimit"].includes(k),
|
||||
) &&
|
||||
["protocol", "type", "id", "applicationId", "applicationVersion", "resources", "historyLimit"].every((k) =>
|
||||
Object.hasOwn(v, k),
|
||||
) &&
|
||||
id(v.id) &&
|
||||
typeof v.applicationId === "string" &&
|
||||
Number.isSafeInteger(v.applicationVersion) &&
|
||||
Number.isSafeInteger(v.historyLimit) &&
|
||||
(v.historyLimit as number) >= 0 &&
|
||||
(v.historyLimit as number) <= 1000
|
||||
);
|
||||
if (v.type === "view.attach")
|
||||
return (
|
||||
Object.keys(v).every((key) =>
|
||||
["protocol", "type", "id", "viewId", "viewport", "camera", "pointerLane"].includes(key),
|
||||
) &&
|
||||
["protocol", "type", "id", "viewId", "viewport", "camera"].every((key) => Object.hasOwn(v, key)) &&
|
||||
id(v.id) &&
|
||||
validViewport(v.viewport) &&
|
||||
validCamera(v.camera) &&
|
||||
(v.pointerLane === undefined ||
|
||||
(typeof SharedArrayBuffer === "function" && v.pointerLane instanceof SharedArrayBuffer))
|
||||
);
|
||||
if (v.type === "view.detach") return exact(v, ["protocol", "type", "id", "viewId"]) && id(v.id);
|
||||
if (v.type === "command")
|
||||
return (
|
||||
exact(v, ["protocol", "type", "id", "command", "expected"]) &&
|
||||
id(v.id) &&
|
||||
validCommand(v.command) &&
|
||||
validExpectation(v.expected)
|
||||
);
|
||||
if (v.type === "load")
|
||||
return exact(v, ["protocol", "type", "id", "data", "expected"]) && id(v.id) && validExpectation(v.expected);
|
||||
if (v.type === "state.get" || v.type === "save" || v.type === "save.data")
|
||||
return exact(v, ["protocol", "type", "id"]) && id(v.id);
|
||||
// State is deliberately opaque here. The composition-bound worker authority decodes it.
|
||||
if (v.type === "state.set")
|
||||
return exact(v, ["protocol", "type", "id", "state", "expected"]) && id(v.id) && validExpectation(v.expected);
|
||||
if (v.type === "composition.update")
|
||||
return (
|
||||
exact(v, ["protocol", "type", "id", "expected", "update"]) &&
|
||||
id(v.id) &&
|
||||
validCompositionExpectation(v.expected) &&
|
||||
validCompositionUpdate(v.update)
|
||||
);
|
||||
if (v.type === "view.viewport")
|
||||
return (
|
||||
Object.keys(v).every((key) =>
|
||||
[
|
||||
"protocol",
|
||||
"type",
|
||||
"id",
|
||||
"viewId",
|
||||
"viewport",
|
||||
"expectedSurfaceGeneration",
|
||||
"hostGeneration",
|
||||
"pointerFence",
|
||||
].includes(key),
|
||||
) &&
|
||||
["protocol", "type", "id", "viewId", "viewport", "expectedSurfaceGeneration", "hostGeneration"].every((key) =>
|
||||
Object.hasOwn(v, key),
|
||||
) &&
|
||||
id(v.id) &&
|
||||
validViewport(v.viewport) &&
|
||||
nonnegativeSafeInteger(v.expectedSurfaceGeneration) &&
|
||||
nonnegativeSafeInteger(v.hostGeneration) &&
|
||||
(v.pointerFence === undefined || validPointerFence(v.pointerFence))
|
||||
);
|
||||
if (v.type === "view.render")
|
||||
return (
|
||||
Object.keys(v).every((key) =>
|
||||
["protocol", "type", "viewId", "renderId", "hostGeneration", "pointerFence"].includes(key),
|
||||
) &&
|
||||
["protocol", "type", "viewId", "renderId", "hostGeneration"].every((key) => Object.hasOwn(v, key)) &&
|
||||
nonnegativeSafeInteger(v.renderId) &&
|
||||
nonnegativeSafeInteger(v.hostGeneration) &&
|
||||
(v.pointerFence === undefined || validPointerFence(v.pointerFence))
|
||||
);
|
||||
if (v.type === "view.frame.consumed")
|
||||
return exact(v, ["protocol", "type", "viewId", "frameId"]) && nonnegativeSafeInteger(v.frameId);
|
||||
if (v.type === "dispose") return exact(v, ["protocol", "type"]);
|
||||
if (v.type === "view.pointer.flush")
|
||||
return exact(v, ["protocol", "type", "viewId", "pointerFence"]) && validPointerFence(v.pointerFence);
|
||||
if (v.type === "view.node.add")
|
||||
return (
|
||||
Object.keys(v).every((k) =>
|
||||
["protocol", "type", "viewId", "id", "nodeId", "typeId", "viewPosition", "expected", "pointerFence"].includes(
|
||||
k,
|
||||
),
|
||||
) &&
|
||||
["protocol", "type", "viewId", "id", "nodeId", "typeId", "viewPosition", "expected"].every((k) =>
|
||||
Object.hasOwn(v, k),
|
||||
) &&
|
||||
id(v.id) &&
|
||||
id(v.nodeId) &&
|
||||
nodeTypeId(v.typeId) &&
|
||||
finitePoint(v.viewPosition) &&
|
||||
validExpectation(v.expected) &&
|
||||
(v.pointerFence === undefined || validPointerFence(v.pointerFence))
|
||||
);
|
||||
if (v.type === "view.selection.remove")
|
||||
return (
|
||||
Object.keys(v).every((k) => ["protocol", "type", "viewId", "id", "expected", "pointerFence"].includes(k)) &&
|
||||
["protocol", "type", "viewId", "id", "expected"].every((k) => Object.hasOwn(v, k)) &&
|
||||
id(v.id) &&
|
||||
validExpectation(v.expected) &&
|
||||
(v.pointerFence === undefined || validPointerFence(v.pointerFence))
|
||||
);
|
||||
if (v.type === "view.selection.mute")
|
||||
return (
|
||||
Object.keys(v).every((k) =>
|
||||
["protocol", "type", "viewId", "id", "value", "expected", "pointerFence"].includes(k),
|
||||
) &&
|
||||
["protocol", "type", "viewId", "id", "value", "expected"].every((k) => Object.hasOwn(v, k)) &&
|
||||
id(v.id) &&
|
||||
typeof v.value === "boolean" &&
|
||||
validExpectation(v.expected) &&
|
||||
(v.pointerFence === undefined || validPointerFence(v.pointerFence))
|
||||
);
|
||||
if (v.type === "view.resource.set")
|
||||
return (
|
||||
Object.keys(v).every((k) =>
|
||||
["protocol", "type", "viewId", "id", "authorization", "resource", "expected", "pointerFence"].includes(k),
|
||||
) &&
|
||||
["protocol", "type", "viewId", "id", "authorization", "resource", "expected"].every((k) => Object.hasOwn(v, k)) &&
|
||||
id(v.id) &&
|
||||
record(v.authorization) &&
|
||||
exact(v.authorization, ["viewId", "token", "graphVersion", "compositionRevision"]) &&
|
||||
id(v.authorization.viewId) &&
|
||||
id(v.authorization.token) &&
|
||||
nonnegativeSafeInteger(v.authorization.graphVersion) &&
|
||||
nonnegativeSafeInteger(v.authorization.compositionRevision) &&
|
||||
record(v.resource) &&
|
||||
exact(v.resource, ["name", "mime", "bytes"]) &&
|
||||
typeof v.resource.name === "string" &&
|
||||
v.resource.name.length > 0 &&
|
||||
v.resource.name.length <= 255 &&
|
||||
!/[\u0000-\u001f\u007f]/.test(v.resource.name) &&
|
||||
typeof v.resource.mime === "string" &&
|
||||
v.resource.mime.length <= 128 &&
|
||||
v.resource.bytes instanceof ArrayBuffer &&
|
||||
v.resource.bytes.byteLength > 0 &&
|
||||
v.resource.bytes.byteLength <= 32 * 1024 * 1024 &&
|
||||
validExpectation(v.expected) &&
|
||||
(v.pointerFence === undefined || validPointerFence(v.pointerFence))
|
||||
);
|
||||
return (
|
||||
v.type === "view.input" &&
|
||||
Object.keys(v).every((k) =>
|
||||
[
|
||||
"protocol",
|
||||
"type",
|
||||
"viewId",
|
||||
"event",
|
||||
"hostGeneration",
|
||||
"pointerFence",
|
||||
"nodeMenuRequestId",
|
||||
"resourceOpenRequestId",
|
||||
].includes(k),
|
||||
) &&
|
||||
["protocol", "type", "viewId", "event", "hostGeneration"].every((k) => Object.hasOwn(v, k)) &&
|
||||
validInput(v.event) &&
|
||||
nonnegativeSafeInteger(v.hostGeneration) &&
|
||||
(v.pointerFence === undefined || validPointerFence(v.pointerFence)) &&
|
||||
(v.nodeMenuRequestId === undefined || id(v.nodeMenuRequestId)) &&
|
||||
(v.resourceOpenRequestId === undefined || id(v.resourceOpenRequestId))
|
||||
);
|
||||
}
|
||||
/** Total across hostile objects (including proxies with throwing traps). */
|
||||
export function validRequest(v: unknown): v is WorkerRequest {
|
||||
try {
|
||||
return validRequestUnsafe(v);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const finitePoint = (v: unknown): boolean =>
|
||||
record(v) &&
|
||||
exact(v, ["x", "y"]) &&
|
||||
typeof v.x === "number" &&
|
||||
Number.isFinite(v.x) &&
|
||||
typeof v.y === "number" &&
|
||||
Number.isFinite(v.y);
|
||||
function validInput(v: unknown): v is InputEventWire {
|
||||
if (!record(v)) return false;
|
||||
if (v.kind === "outside-pointer") return exact(v, ["kind", "button"]) && Number.isInteger(v.button);
|
||||
if (v.kind === "pointer")
|
||||
return (
|
||||
exact(v, ["kind", "phase", "pointerId", "pointerType", "position", "button", "buttons", "modifiers"]) &&
|
||||
["down", "move", "up", "cancel"].includes(String(v.phase)) &&
|
||||
Number.isSafeInteger(v.pointerId) &&
|
||||
typeof v.pointerType === "string" &&
|
||||
finitePoint(v.position) &&
|
||||
Number.isInteger(v.button) &&
|
||||
Number.isInteger(v.buttons) &&
|
||||
Number.isInteger(v.modifiers)
|
||||
);
|
||||
if (v.kind === "wheel")
|
||||
return (
|
||||
exact(v, ["kind", "position", "delta", "modifiers"]) &&
|
||||
finitePoint(v.position) &&
|
||||
finitePoint(v.delta) &&
|
||||
Number.isInteger(v.modifiers)
|
||||
);
|
||||
if (v.kind === "key")
|
||||
return (
|
||||
exact(v, ["kind", "phase", "key", "code", "repeat", "modifiers"]) &&
|
||||
["down", "up"].includes(String(v.phase)) &&
|
||||
typeof v.key === "string" &&
|
||||
typeof v.code === "string" &&
|
||||
typeof v.repeat === "boolean" &&
|
||||
Number.isInteger(v.modifiers)
|
||||
);
|
||||
return v.kind === "focus" && exact(v, ["kind", "phase"]) && (v.phase === "focus" || v.phase === "blur");
|
||||
}
|
||||
function validPointerFence(v: unknown): v is PointerFence {
|
||||
return (
|
||||
record(v) &&
|
||||
Object.keys(v).every((k) => ["generation", "before"].includes(k)) &&
|
||||
Object.hasOwn(v, "generation") &&
|
||||
Number.isInteger(v.generation) &&
|
||||
(v.before === undefined ||
|
||||
(record(v.before) &&
|
||||
exact(v.before, ["sequence", "hostGeneration", "event"]) &&
|
||||
Number.isInteger(v.before.sequence) &&
|
||||
nonnegativeSafeInteger(v.before.hostGeneration) &&
|
||||
validInput(v.before.event) &&
|
||||
record(v.before.event) &&
|
||||
v.before.event.kind === "pointer" &&
|
||||
v.before.event.phase === "move"))
|
||||
);
|
||||
}
|
||||
const id = (v: unknown): boolean => typeof v === "string" && v.length > 0 && v.length <= 512;
|
||||
const nodeTypeId = (v: unknown): boolean =>
|
||||
typeof v === "string" && v.length > 0 && v.length <= FXNODE_COMPOSITION_LIMITS.maxIdLength;
|
||||
function validExpectation(v: unknown): v is VersionExpectation {
|
||||
return (
|
||||
record(v) &&
|
||||
((v.kind === "current" && exact(v, ["kind"])) ||
|
||||
(v.kind === "exact" &&
|
||||
exact(v, ["kind", "version"]) &&
|
||||
Number.isSafeInteger(v.version) &&
|
||||
(v.version as number) >= 0))
|
||||
);
|
||||
}
|
||||
function validWorkerMessageUnsafe(v: unknown): v is WorkerMessage {
|
||||
if (!record(v) || v.protocol !== PROTOCOL_VERSION || typeof v.type !== "string") return false;
|
||||
if (v.type.startsWith("view.") && !id(v.viewId)) return false;
|
||||
if (v.type === "view.frame")
|
||||
return (
|
||||
exact(v, [
|
||||
"protocol",
|
||||
"type",
|
||||
"viewId",
|
||||
"bitmap",
|
||||
"renderId",
|
||||
"frameId",
|
||||
"hostGeneration",
|
||||
"surfaceGeneration",
|
||||
"deviceWidth",
|
||||
"deviceHeight",
|
||||
"host",
|
||||
]) &&
|
||||
typeof ImageBitmap !== "undefined" &&
|
||||
v.bitmap instanceof ImageBitmap &&
|
||||
nonnegativeSafeInteger(v.renderId) &&
|
||||
nonnegativeSafeInteger(v.frameId) &&
|
||||
nonnegativeSafeInteger(v.hostGeneration) &&
|
||||
nonnegativeSafeInteger(v.surfaceGeneration) &&
|
||||
Number.isSafeInteger(v.deviceWidth) &&
|
||||
(v.deviceWidth as number) > 0 &&
|
||||
(v.deviceWidth as number) <= FXNODE_VIEW_LIMITS.maxDeviceDimension &&
|
||||
Number.isSafeInteger(v.deviceHeight) &&
|
||||
(v.deviceHeight as number) > 0 &&
|
||||
(v.deviceHeight as number) <= FXNODE_VIEW_LIMITS.maxDeviceDimension &&
|
||||
(v.deviceWidth as number) * (v.deviceHeight as number) <= FXNODE_VIEW_LIMITS.maxDevicePixelsPerView &&
|
||||
v.bitmap.width === v.deviceWidth &&
|
||||
v.bitmap.height === v.deviceHeight &&
|
||||
validHostSnapshot(v.host)
|
||||
);
|
||||
if (v.type === "mutation")
|
||||
return exact(v, ["protocol", "type", "envelope"]) && record(v.envelope) && Number.isSafeInteger(v.envelope.version);
|
||||
if (v.type === "snapshot.event")
|
||||
return exact(v, ["protocol", "type", "envelope"]) && record(v.envelope) && Number.isSafeInteger(v.envelope.version);
|
||||
if (v.type === "composition.event")
|
||||
return exact(v, ["protocol", "type", "envelope"]) && validCompositionEnvelope(v.envelope);
|
||||
if (v.type === "view.selection.host")
|
||||
return exact(v, ["protocol", "type", "viewId", "projection"]) && validSelectionProjection(v.projection);
|
||||
if (v.type === "view.node-menu.result")
|
||||
return v.open === false
|
||||
? exact(v, ["protocol", "type", "viewId", "requestId", "open"]) && id(v.requestId)
|
||||
: v.open === true &&
|
||||
exact(v, ["protocol", "type", "viewId", "requestId", "open", "compositionRevision", "viewPosition"]) &&
|
||||
id(v.requestId) &&
|
||||
nonnegativeSafeInteger(v.compositionRevision) &&
|
||||
finitePoint(v.viewPosition);
|
||||
if (v.type === "view.resource.open")
|
||||
return (
|
||||
exact(v, ["protocol", "type", "viewId", "requestId", "authorization", "resource"]) &&
|
||||
id(v.requestId) &&
|
||||
record(v.authorization) &&
|
||||
exact(v.authorization, ["viewId", "token", "graphVersion", "compositionRevision"]) &&
|
||||
id(v.authorization.viewId) &&
|
||||
id(v.authorization.token) &&
|
||||
nonnegativeSafeInteger(v.authorization.graphVersion) &&
|
||||
nonnegativeSafeInteger(v.authorization.compositionRevision) &&
|
||||
validHostResourcePolicy(v.resource)
|
||||
);
|
||||
if (v.type === "fatal")
|
||||
return (
|
||||
exact(v, ["protocol", "type", "error"]) &&
|
||||
record(v.error) &&
|
||||
exact(v.error, ["code", "message"]) &&
|
||||
typeof v.error.code === "string" &&
|
||||
typeof v.error.message === "string"
|
||||
);
|
||||
return (
|
||||
v.type === "response" &&
|
||||
id(v.id) &&
|
||||
typeof v.ok === "boolean" &&
|
||||
(v.ok
|
||||
? exact(v, ["protocol", "type", "id", "ok"]) || exact(v, ["protocol", "type", "id", "ok", "value"])
|
||||
: exact(v, ["protocol", "type", "id", "ok", "error"]) && validError(v.error))
|
||||
);
|
||||
}
|
||||
/** Total across hostile objects (including proxies with throwing traps). */
|
||||
export function validWorkerMessage(v: unknown): v is WorkerMessage {
|
||||
try {
|
||||
return validWorkerMessageUnsafe(v);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function validError(v: unknown): boolean {
|
||||
return (
|
||||
record(v) &&
|
||||
Object.hasOwn(v, "code") &&
|
||||
Object.hasOwn(v, "message") &&
|
||||
Object.keys(v).every((k) => ["code", "message", "path", "issues"].includes(k)) &&
|
||||
typeof v.code === "string" &&
|
||||
v.code.length <= 128 &&
|
||||
typeof v.message === "string" &&
|
||||
v.message.length <= 2048 &&
|
||||
(v.path === undefined || (Object.hasOwn(v, "path") && typeof v.path === "string" && v.path.length <= 512)) &&
|
||||
(v.issues === undefined ||
|
||||
(Object.hasOwn(v, "issues") &&
|
||||
Array.isArray(v.issues) &&
|
||||
v.issues.length <= 100 &&
|
||||
v.issues.every(
|
||||
(issue) =>
|
||||
record(issue) &&
|
||||
exact(issue, ["code", "path", "message"]) &&
|
||||
typeof issue.code === "string" &&
|
||||
issue.code.length <= 128 &&
|
||||
typeof issue.path === "string" &&
|
||||
issue.path.length <= 512 &&
|
||||
typeof issue.message === "string" &&
|
||||
issue.message.length <= 1024,
|
||||
)))
|
||||
);
|
||||
}
|
||||
function validHostSnapshot(v: unknown): v is HostInteractionSnapshot {
|
||||
return record(v) && exact(v, ["colorPickerOpen"]) && typeof v.colorPickerOpen === "boolean";
|
||||
}
|
||||
function validSelectionProjection(v: unknown): v is FxNodeSelectionSnapshot {
|
||||
if (
|
||||
!record(v) ||
|
||||
!exact(v, ["nodeCount", "linkCount", "canRemove", "mute"]) ||
|
||||
!nonnegativeSafeInteger(v.nodeCount) ||
|
||||
!nonnegativeSafeInteger(v.linkCount) ||
|
||||
v.canRemove !== (v.nodeCount > 0 || v.linkCount > 0) ||
|
||||
!record(v.mute)
|
||||
)
|
||||
return false;
|
||||
return v.mute.enabled === false
|
||||
? exact(v.mute, ["enabled"])
|
||||
: v.mute.enabled === true &&
|
||||
exact(v.mute, ["enabled", "state"]) &&
|
||||
["all-muted", "all-unmuted", "mixed"].includes(String(v.mute.state));
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
export const FXNODE_VIEW_LIMITS = Object.freeze({
|
||||
maxViews: 16,
|
||||
maxLogicalDimension: 8192,
|
||||
maxDpr: 4,
|
||||
maxDeviceDimension: 8192,
|
||||
maxDevicePixelsPerView: 16_777_216,
|
||||
maxActiveDevicePixels: 16_777_216,
|
||||
maxAtlasDimension: 8192,
|
||||
maxAtlasPixels: 16_777_216,
|
||||
maxInFlightDevicePixels: 16_777_216,
|
||||
minZoom: 0.1,
|
||||
maxZoom: 4,
|
||||
});
|
||||
|
||||
/** Returns the actual rounded backing-store size, or undefined when unsupported. */
|
||||
export function fxNodeDevicePixels(
|
||||
width: number,
|
||||
height: number,
|
||||
dpr: number,
|
||||
): Readonly<{ width: number; height: number }> | undefined {
|
||||
if (
|
||||
!Number.isFinite(width) ||
|
||||
!Number.isFinite(height) ||
|
||||
!Number.isFinite(dpr) ||
|
||||
width < 0 ||
|
||||
height < 0 ||
|
||||
dpr <= 0 ||
|
||||
width > FXNODE_VIEW_LIMITS.maxLogicalDimension ||
|
||||
height > FXNODE_VIEW_LIMITS.maxLogicalDimension ||
|
||||
dpr > FXNODE_VIEW_LIMITS.maxDpr
|
||||
)
|
||||
return;
|
||||
const deviceWidth = Math.max(1, Math.round(width * dpr)),
|
||||
deviceHeight = Math.max(1, Math.round(height * dpr));
|
||||
if (
|
||||
!Number.isSafeInteger(deviceWidth) ||
|
||||
!Number.isSafeInteger(deviceHeight) ||
|
||||
deviceWidth > FXNODE_VIEW_LIMITS.maxDeviceDimension ||
|
||||
deviceHeight > FXNODE_VIEW_LIMITS.maxDeviceDimension ||
|
||||
deviceWidth * deviceHeight > FXNODE_VIEW_LIMITS.maxDevicePixelsPerView
|
||||
)
|
||||
return;
|
||||
return Object.freeze({ width: deviceWidth, height: deviceHeight });
|
||||
}
|
||||
Vendored
+67
@@ -0,0 +1,67 @@
|
||||
export type Rgb = readonly [number, number, number];
|
||||
export type Rgba = readonly [number, number, number, number];
|
||||
export interface Oklab {
|
||||
readonly l: number;
|
||||
readonly a: number;
|
||||
readonly b: number;
|
||||
}
|
||||
export interface Oklch {
|
||||
readonly l: number;
|
||||
readonly c: number;
|
||||
readonly h: number;
|
||||
}
|
||||
const tau = Math.PI * 2;
|
||||
const decode = (value: number) =>
|
||||
Math.abs(value) <= 0.04045 ? value / 12.92 : Math.sign(value) * ((Math.abs(value) + 0.055) / 1.055) ** 2.4;
|
||||
const encode = (value: number) =>
|
||||
Math.abs(value) <= 0.0031308 ? 12.92 * value : Math.sign(value) * (1.055 * Math.abs(value) ** (1 / 2.4) - 0.055);
|
||||
export const normalizeHue = (value: number) => ((value % tau) + tau) % tau;
|
||||
export function srgbToOklab(rgb: Rgb): Oklab {
|
||||
const r = decode(rgb[0]),
|
||||
g = decode(rgb[1]),
|
||||
b = decode(rgb[2]),
|
||||
l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b),
|
||||
m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b),
|
||||
s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
||||
return {
|
||||
l: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
||||
a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
||||
b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
||||
};
|
||||
}
|
||||
export function oklabToLinearSrgb({ l, a, b }: Oklab): Rgb {
|
||||
const x = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3,
|
||||
y = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3,
|
||||
z = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
||||
return [
|
||||
4.0767416621 * x - 3.3077115913 * y + 0.2309699292 * z,
|
||||
-1.2684380046 * x + 2.6097574011 * y - 0.3413193965 * z,
|
||||
-0.0041960863 * x - 0.7034186147 * y + 1.707614701 * z,
|
||||
];
|
||||
}
|
||||
export function oklabToOklch(value: Oklab, fallbackHue = 0): Oklch {
|
||||
const c = Math.hypot(value.a, value.b);
|
||||
return { l: value.l, c, h: c <= 4e-6 ? normalizeHue(fallbackHue) : normalizeHue(Math.atan2(value.b, value.a)) };
|
||||
}
|
||||
export const oklchToOklab = ({ l, c, h }: Oklch): Oklab => ({ l, a: c * Math.cos(h), b: c * Math.sin(h) });
|
||||
const inLinearGamut = (rgb: Rgb) => rgb.every((value) => Number.isFinite(value) && value >= -1e-9 && value <= 1 + 1e-9);
|
||||
export const isInSrgbGamut = (color: Oklch) => inLinearGamut(oklabToLinearSrgb(oklchToOklab(color)));
|
||||
export function maxSrgbChroma(l: number, h: number): number {
|
||||
if (l <= 0 || l >= 1) return 0;
|
||||
let low = 0,
|
||||
high = 0.4;
|
||||
while (high < 2 && isInSrgbGamut({ l, c: high, h })) high *= 2;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const middle = (low + high) / 2;
|
||||
if (isInSrgbGamut({ l, c: middle, h })) low = middle;
|
||||
else high = middle;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
export function mapOklchToSrgb(color: Oklch): Rgb {
|
||||
if (color.l <= 0) return [0, 0, 0];
|
||||
if (color.l >= 1) return [1, 1, 1];
|
||||
const mapped = { ...color, c: Math.min(Math.max(0, color.c), maxSrgbChroma(color.l, color.h)) },
|
||||
linear = oklabToLinearSrgb(oklchToOklab(mapped));
|
||||
return linear.map((value) => Math.min(1, Math.max(0, encode(value)))) as unknown as Rgb;
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/** Maximum number of commands in one atomic batch. */
|
||||
export const FXNODE_BATCH_LIMIT = 256;
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
import type { FxNodeCompositionData, NodeTypeId } from "../composition/types.js";
|
||||
import {
|
||||
PERSISTENCE_LIMITS,
|
||||
type DecodeResult as BoundDecodeResult,
|
||||
type ValidationIssue as BoundValidationIssue,
|
||||
} from "../composition/bound-document.js";
|
||||
import { saveCompositionCompatibility } from "../composition/save-compatibility.js";
|
||||
import { FXNODE_COMPOSITION_LIMITS, validateFxNodeComposition } from "../composition/validate.js";
|
||||
import { admitStructuredData, deepFreeze, isRecord } from "../core/json.js";
|
||||
import type { GraphDocument, GraphLayoutV2 } from "../core/types.js";
|
||||
import type { CompatibleFxNodeSaveData } from "./types.js";
|
||||
import { validFxNodeReplayCommand } from "./validate.js";
|
||||
|
||||
export const FXNODE_SAVE_DATA_LIMITS = Object.freeze({
|
||||
maxCommands: 1_000,
|
||||
maxAtomicCommands: 10_000,
|
||||
maxValues: 100_000,
|
||||
maxStringCodeUnits: 1_048_576,
|
||||
maxDepth: 50,
|
||||
maxIssues: 100,
|
||||
});
|
||||
const SAVE_ENVELOPE_OVERHEAD = 256;
|
||||
export type FxNodeSaveDataDecodeResult<C extends FxNodeCompositionData = FxNodeCompositionData> =
|
||||
| { readonly ok: true; readonly value: CompatibleFxNodeSaveData<C>; readonly baseline: GraphDocument<C> }
|
||||
| { readonly ok: false; readonly issues: readonly BoundValidationIssue[] };
|
||||
const exact = (value: Record<string, unknown>, keys: readonly string[]) =>
|
||||
Object.keys(value).length === keys.length && keys.every((k) => Object.hasOwn(value, k));
|
||||
const issue = (code: string, path: string, message: string): readonly BoundValidationIssue[] =>
|
||||
deepFreeze([{ code, path, message }]);
|
||||
const prefix = (path: string) => `/baseline${path === "/" ? "" : path.startsWith("/") ? path : `/${path}`}`;
|
||||
|
||||
/** Save-decoder-only bridge for durable command logs that embedded the schema-1 menu catalog. */
|
||||
const normalizeSavedComposition = (input: unknown): unknown => {
|
||||
if (
|
||||
!isRecord(input) ||
|
||||
input.schemaVersion !== 1 ||
|
||||
!Object.hasOwn(input, "menuGroups") ||
|
||||
!isRecord(input.menuGroups) ||
|
||||
!isRecord(input.nodes)
|
||||
)
|
||||
return input;
|
||||
for (const group of Object.values(input.menuGroups))
|
||||
if (
|
||||
!isRecord(group) ||
|
||||
!exact(group, ["title", "order"]) ||
|
||||
typeof group.title !== "string" ||
|
||||
group.title.length === 0 ||
|
||||
group.title.length > FXNODE_COMPOSITION_LIMITS.maxTitleLength ||
|
||||
!Number.isSafeInteger(group.order)
|
||||
)
|
||||
return input;
|
||||
for (const definition of Object.values(input.nodes)) {
|
||||
if (!isRecord(definition) || !Object.hasOwn(definition, "menu") || !isRecord(definition.menu)) return input;
|
||||
const menu = definition.menu;
|
||||
if (menu.kind === "hidden") {
|
||||
if (!exact(menu, ["kind"])) return input;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
menu.kind !== "entry" ||
|
||||
!exact(menu, ["kind", "group", "order", "keywords"]) ||
|
||||
typeof menu.group !== "string" ||
|
||||
!Object.hasOwn(input.menuGroups, menu.group) ||
|
||||
!Number.isSafeInteger(menu.order) ||
|
||||
!Array.isArray(menu.keywords) ||
|
||||
menu.keywords.some(
|
||||
(keyword) => typeof keyword !== "string" || keyword.length > FXNODE_COMPOSITION_LIMITS.maxKeywordLength,
|
||||
)
|
||||
)
|
||||
return input;
|
||||
}
|
||||
const { menuGroups: _menuGroups, ...composition } = input;
|
||||
return {
|
||||
...composition,
|
||||
schemaVersion: 2,
|
||||
nodes: Object.fromEntries(
|
||||
Object.entries(input.nodes).map(([id, definition]) => {
|
||||
if (!isRecord(definition)) return [id, definition];
|
||||
const { menu: _menu, ...node } = definition;
|
||||
return [id, node];
|
||||
}),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
/** Bounded, total decoder for an admitted command-log save envelope. */
|
||||
export function decodeFxNodeSaveData<C extends FxNodeCompositionData>(
|
||||
source: unknown,
|
||||
composition: { readonly source: C },
|
||||
decodeBaseline: (value: unknown) => BoundDecodeResult<C>,
|
||||
): FxNodeSaveDataDecodeResult<C> {
|
||||
try {
|
||||
const admitted = admitStructuredData(source, {
|
||||
maxValues:
|
||||
PERSISTENCE_LIMITS.maxValues +
|
||||
FXNODE_SAVE_DATA_LIMITS.maxValues +
|
||||
FXNODE_COMPOSITION_LIMITS.maxValues +
|
||||
SAVE_ENVELOPE_OVERHEAD,
|
||||
maxStringCodeUnits:
|
||||
PERSISTENCE_LIMITS.maxStringCodeUnits +
|
||||
FXNODE_SAVE_DATA_LIMITS.maxStringCodeUnits +
|
||||
FXNODE_COMPOSITION_LIMITS.maxStringCodeUnits +
|
||||
SAVE_ENVELOPE_OVERHEAD,
|
||||
maxDepth:
|
||||
Math.max(PERSISTENCE_LIMITS.maxDepth, FXNODE_SAVE_DATA_LIMITS.maxDepth, FXNODE_COMPOSITION_LIMITS.maxDepth) + 2,
|
||||
maxIssues: FXNODE_SAVE_DATA_LIMITS.maxIssues,
|
||||
});
|
||||
if (!admitted.ok) return { ok: false, issues: deepFreeze(admitted.issues) };
|
||||
const value = admitted.value;
|
||||
if (!isRecord(value) || value.kind !== "fxnode.command-log")
|
||||
return { ok: false, issues: issue("save.shape", "/", "Invalid FxNodeSaveData envelope") };
|
||||
if (value.schemaVersion === 1)
|
||||
return {
|
||||
ok: false,
|
||||
issues: issue(
|
||||
"save.schema.unsupported",
|
||||
"/schemaVersion",
|
||||
"FxNodeSaveData schema version 1 is no longer supported",
|
||||
),
|
||||
};
|
||||
if (Number.isSafeInteger(value.schemaVersion) && Number(value.schemaVersion) > 2)
|
||||
return {
|
||||
ok: false,
|
||||
issues: issue(
|
||||
"save.schema.future",
|
||||
"/schemaVersion",
|
||||
"FxNodeSaveData schema version is newer than this runtime",
|
||||
),
|
||||
};
|
||||
if (!exact(value, ["kind", "schemaVersion", "composition", "baseline", "commands"]) || value.schemaVersion !== 2)
|
||||
return { ok: false, issues: issue("save.shape", "/", "Invalid FxNodeSaveData envelope") };
|
||||
const normalizedComposition = normalizeSavedComposition(value.composition);
|
||||
const savedComposition = validateFxNodeComposition(normalizedComposition);
|
||||
if (!savedComposition.ok)
|
||||
return {
|
||||
ok: false,
|
||||
issues: deepFreeze([
|
||||
{ code: "save.composition.invalid", path: "/composition", message: "Saved composition is invalid" },
|
||||
...savedComposition.issues.slice(0, 99).map((x) => ({ ...x, path: `/composition${x.path}` })),
|
||||
]),
|
||||
};
|
||||
if (!Array.isArray(value.commands))
|
||||
return { ok: false, issues: issue("save.commands", "/commands", "Commands must be an array") };
|
||||
if (value.commands.length > FXNODE_SAVE_DATA_LIMITS.maxCommands)
|
||||
return { ok: false, issues: issue("limit.commands", "/commands", "Command limit exceeded") };
|
||||
const admittedCommands = admitStructuredData(value.commands, FXNODE_SAVE_DATA_LIMITS);
|
||||
if (!admittedCommands.ok)
|
||||
return {
|
||||
ok: false,
|
||||
issues: deepFreeze(admittedCommands.issues.map((x) => ({ ...x, path: `/commands${x.path}` }))),
|
||||
};
|
||||
if (!isRecord(value.baseline) || value.baseline.schemaVersion !== 2)
|
||||
return {
|
||||
ok: false,
|
||||
issues: issue("baseline.schema", "/baseline/schemaVersion", "Baseline must be a GraphLayoutV2 record"),
|
||||
};
|
||||
const admittedBaseline = admitStructuredData(value.baseline, PERSISTENCE_LIMITS);
|
||||
if (!admittedBaseline.ok)
|
||||
return {
|
||||
ok: false,
|
||||
issues: deepFreeze(admittedBaseline.issues.map((x) => ({ ...x, path: `/baseline${x.path}` }))),
|
||||
};
|
||||
const compatibility = saveCompositionCompatibility(
|
||||
savedComposition.value,
|
||||
composition.source,
|
||||
value.baseline as unknown as GraphLayoutV2,
|
||||
);
|
||||
if (compatibility.length) return { ok: false, issues: compatibility };
|
||||
let atomic = 0;
|
||||
for (let i = 0; i < value.commands.length; i++) {
|
||||
const command = value.commands[i];
|
||||
if (!validFxNodeReplayCommand<C>(command, (id) => Object.hasOwn(savedComposition.value.nodes, id)))
|
||||
return {
|
||||
ok: false,
|
||||
issues: issue(
|
||||
"command.invalid",
|
||||
`/commands/${i}`,
|
||||
"Invalid replay command or node type is absent from saved composition",
|
||||
),
|
||||
};
|
||||
atomic += command.type === "batch" ? command.commands.length : 1;
|
||||
if (atomic > FXNODE_SAVE_DATA_LIMITS.maxAtomicCommands)
|
||||
return { ok: false, issues: issue("limit.atomic-commands", `/commands/${i}`, "Atomic command limit exceeded") };
|
||||
}
|
||||
const decoded = decodeBaseline(value.baseline);
|
||||
if (!decoded.ok)
|
||||
return {
|
||||
ok: false,
|
||||
issues: deepFreeze(
|
||||
decoded.issues.slice(0, FXNODE_SAVE_DATA_LIMITS.maxIssues).map((x) => ({ ...x, path: prefix(x.path) })),
|
||||
),
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
value: deepFreeze({ ...value, composition: savedComposition.value } as unknown as CompatibleFxNodeSaveData<C>),
|
||||
baseline: decoded.value,
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, issues: issue("save.decode", "/", "Save data could not be decoded") };
|
||||
}
|
||||
}
|
||||
Vendored
+85
@@ -0,0 +1,85 @@
|
||||
import type { FxNodeCompositionData, NodeTypeId } from "../composition/types.js";
|
||||
import type {
|
||||
CommandId,
|
||||
GraphLayoutV2,
|
||||
GraphLink,
|
||||
LinkId,
|
||||
NodeId,
|
||||
ParameterValue,
|
||||
SocketId,
|
||||
Vec2,
|
||||
} from "../core/types.js";
|
||||
|
||||
export type Command<C extends FxNodeCompositionData = FxNodeCompositionData> =
|
||||
| { readonly type: "batch"; readonly commands: readonly BatchCommand<C>[] }
|
||||
| {
|
||||
readonly type: "node.add";
|
||||
readonly nodeId: NodeId;
|
||||
readonly nodeType: NodeTypeId<C>;
|
||||
readonly position: Vec2;
|
||||
readonly parentId?: NodeId;
|
||||
}
|
||||
| { readonly type: "node.remove"; readonly id: NodeId }
|
||||
| { readonly type: "node.move"; readonly id: NodeId; readonly position: Vec2 }
|
||||
| { readonly type: "node.resize"; readonly id: NodeId; readonly size: Vec2 }
|
||||
| { readonly type: "node.label"; readonly id: NodeId; readonly label: string }
|
||||
| { readonly type: "node.parameter"; readonly id: NodeId; readonly key: string; readonly value: ParameterValue }
|
||||
| { readonly type: "node.parameter-reset"; readonly id: NodeId; readonly key: string }
|
||||
| {
|
||||
readonly type: "node.socket-default";
|
||||
readonly id: NodeId;
|
||||
readonly socketId: SocketId;
|
||||
readonly value: ParameterValue;
|
||||
}
|
||||
| { readonly type: "node.socket-default-reset"; readonly id: NodeId; readonly socketId: SocketId }
|
||||
| { readonly type: "node.mute" | "node.collapse"; readonly id: NodeId; readonly value: boolean }
|
||||
| { readonly type: "node.parent"; readonly id: NodeId; readonly parentId: NodeId | null }
|
||||
| { readonly type: "link.add"; readonly link: GraphLink }
|
||||
| { readonly type: "link.remove"; readonly id: LinkId }
|
||||
| { readonly type: "link.mute"; readonly id: LinkId; readonly value: boolean }
|
||||
| { readonly type: "link.replace"; readonly removeId: LinkId; readonly link: GraphLink }
|
||||
| { readonly type: "undo" }
|
||||
| { readonly type: "redo" };
|
||||
/** Atomic, deliberately non-recursive operations used by completed gestures. */
|
||||
export type BatchCommand<C extends FxNodeCompositionData = FxNodeCompositionData> = Extract<
|
||||
Command<C>,
|
||||
{
|
||||
readonly type:
|
||||
| "node.move"
|
||||
| "node.resize"
|
||||
| "node.remove"
|
||||
| "node.mute"
|
||||
| "node.collapse"
|
||||
| "node.parent"
|
||||
| "link.remove"
|
||||
| "link.mute";
|
||||
}
|
||||
>;
|
||||
/** A forward-only command suitable for durable replay. */
|
||||
export type FxNodeReplayCommand<C extends FxNodeCompositionData = FxNodeCompositionData> = Exclude<
|
||||
Command<C>,
|
||||
{ readonly type: "undo" | "redo" }
|
||||
>;
|
||||
export interface FxNodeSaveData<C extends FxNodeCompositionData = FxNodeCompositionData> {
|
||||
readonly kind: "fxnode.command-log";
|
||||
readonly schemaVersion: 2;
|
||||
readonly composition: C;
|
||||
readonly baseline: GraphLayoutV2;
|
||||
readonly commands: readonly FxNodeReplayCommand<C>[];
|
||||
}
|
||||
/** A decoded save whose embedded historical composition was compatibility-checked against C. */
|
||||
export interface CompatibleFxNodeSaveData<C extends FxNodeCompositionData = FxNodeCompositionData>
|
||||
extends Omit<FxNodeSaveData<C>, "composition"> {
|
||||
readonly composition: FxNodeCompositionData;
|
||||
}
|
||||
export interface CommandRequest<C extends FxNodeCompositionData = FxNodeCompositionData> {
|
||||
readonly commandId: CommandId;
|
||||
readonly expectedVersion: number;
|
||||
readonly source: "api" | "gesture";
|
||||
readonly command: Command<C>;
|
||||
}
|
||||
export interface CommandError {
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
readonly path?: string;
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import { FXNODE_COMPOSITION_LIMITS } from "../composition/validate.js";
|
||||
import { isJson } from "../core/json.js";
|
||||
import type { FxNodeCompositionData, NodeTypeId } from "../composition/types.js";
|
||||
import type { GraphLink, ParameterValue } from "../core/types.js";
|
||||
import type { Command, FxNodeReplayCommand } from "./types.js";
|
||||
import { FXNODE_BATCH_LIMIT } from "./limits.js";
|
||||
|
||||
const record = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
const exact = (v: Record<string, unknown>, keys: readonly string[]) =>
|
||||
Object.keys(v).length === keys.length && keys.every((k) => Object.hasOwn(v, k));
|
||||
const finitePoint = (v: unknown) =>
|
||||
record(v) &&
|
||||
exact(v, ["x", "y"]) &&
|
||||
typeof v.x === "number" &&
|
||||
Number.isFinite(v.x) &&
|
||||
typeof v.y === "number" &&
|
||||
Number.isFinite(v.y);
|
||||
const id = (v: unknown) => typeof v === "string" && v.length > 0 && v.length <= 512;
|
||||
const nodeTypeId = (v: unknown) =>
|
||||
typeof v === "string" && v.length > 0 && v.length <= FXNODE_COMPOSITION_LIMITS.maxIdLength;
|
||||
const parameter = (v: unknown): v is ParameterValue =>
|
||||
record(v) &&
|
||||
exact(v, ["kind", "value"]) &&
|
||||
(v.kind === "number"
|
||||
? typeof v.value === "number" && Number.isFinite(v.value)
|
||||
: v.kind === "boolean"
|
||||
? typeof v.value === "boolean"
|
||||
: v.kind === "string"
|
||||
? typeof v.value === "string"
|
||||
: v.kind === "vector"
|
||||
? Array.isArray(v.value) &&
|
||||
v.value.length === 3 &&
|
||||
v.value.every((x) => typeof x === "number" && Number.isFinite(x))
|
||||
: v.kind === "color"
|
||||
? Array.isArray(v.value) &&
|
||||
v.value.length === 4 &&
|
||||
v.value.every((x) => typeof x === "number" && Number.isFinite(x))
|
||||
: v.kind === "json" && isJson(v.value));
|
||||
const graphLink = (v: unknown): v is GraphLink =>
|
||||
record(v) &&
|
||||
exact(v, ["id", "fromNodeId", "fromSocketId", "toNodeId", "toSocketId", "muted", "extensions"]) &&
|
||||
id(v.id) &&
|
||||
id(v.fromNodeId) &&
|
||||
id(v.fromSocketId) &&
|
||||
id(v.toNodeId) &&
|
||||
id(v.toSocketId) &&
|
||||
typeof v.muted === "boolean" &&
|
||||
record(v.extensions) &&
|
||||
isJson(v.extensions);
|
||||
function validCommandUnsafe(v: unknown, nested = false): v is Command {
|
||||
if (!record(v) || typeof v.type !== "string") return false;
|
||||
if (v.type === "batch")
|
||||
return (
|
||||
!nested &&
|
||||
exact(v, ["type", "commands"]) &&
|
||||
Array.isArray(v.commands) &&
|
||||
v.commands.length <= FXNODE_BATCH_LIMIT &&
|
||||
v.commands.every(
|
||||
(item) =>
|
||||
validCommandUnsafe(item, true) &&
|
||||
[
|
||||
"node.move",
|
||||
"node.resize",
|
||||
"node.remove",
|
||||
"node.mute",
|
||||
"node.collapse",
|
||||
"node.parent",
|
||||
"link.remove",
|
||||
"link.mute",
|
||||
].includes((item as { type: string }).type),
|
||||
)
|
||||
);
|
||||
if (v.type === "undo" || v.type === "redo") return !nested && exact(v, ["type"]);
|
||||
if (v.type === "node.remove" || v.type === "link.remove") return exact(v, ["type", "id"]) && id(v.id);
|
||||
if (v.type === "node.move" || v.type === "node.resize")
|
||||
return (
|
||||
exact(v, ["type", "id", v.type === "node.move" ? "position" : "size"]) &&
|
||||
id(v.id) &&
|
||||
finitePoint(v[v.type === "node.move" ? "position" : "size"])
|
||||
);
|
||||
if (v.type === "node.mute" || v.type === "node.collapse" || v.type === "link.mute")
|
||||
return exact(v, ["type", "id", "value"]) && id(v.id) && typeof v.value === "boolean";
|
||||
if (v.type === "node.parent")
|
||||
return exact(v, ["type", "id", "parentId"]) && id(v.id) && (v.parentId === null || id(v.parentId));
|
||||
if (nested) return false;
|
||||
if (v.type === "node.label") return exact(v, ["type", "id", "label"]) && id(v.id) && typeof v.label === "string";
|
||||
if (v.type === "node.add")
|
||||
return (
|
||||
Object.keys(v).every((k) => ["type", "nodeId", "nodeType", "position", "parentId"].includes(k)) &&
|
||||
["type", "nodeId", "nodeType", "position"].every((k) => Object.hasOwn(v, k)) &&
|
||||
id(v.nodeId) &&
|
||||
id(v.nodeType) &&
|
||||
finitePoint(v.position) &&
|
||||
(v.parentId === undefined || id(v.parentId))
|
||||
);
|
||||
if (v.type === "link.add") return exact(v, ["type", "link"]) && record(v.link);
|
||||
if (v.type === "link.replace") return exact(v, ["type", "removeId", "link"]) && id(v.removeId) && record(v.link);
|
||||
if (v.type === "node.parameter")
|
||||
return exact(v, ["type", "id", "key", "value"]) && id(v.id) && typeof v.key === "string" && record(v.value);
|
||||
if (v.type === "node.parameter-reset")
|
||||
return exact(v, ["type", "id", "key"]) && id(v.id) && typeof v.key === "string";
|
||||
if (v.type === "node.socket-default-reset") return exact(v, ["type", "id", "socketId"]) && id(v.id) && id(v.socketId);
|
||||
return (
|
||||
v.type === "node.socket-default" &&
|
||||
exact(v, ["type", "id", "socketId", "value"]) &&
|
||||
id(v.id) &&
|
||||
id(v.socketId) &&
|
||||
record(v.value)
|
||||
);
|
||||
}
|
||||
/** Total across hostile objects (including proxies with throwing traps). */
|
||||
export function validCommand(v: unknown): v is Command {
|
||||
try {
|
||||
return validCommandUnsafe(v);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export type ReplayNodeTypeAuthority<C extends FxNodeCompositionData> =
|
||||
| { readonly has: (id: NodeTypeId<C>) => boolean }
|
||||
| ((id: NodeTypeId<C>) => boolean);
|
||||
/** Total, strict forward-only persisted-command validator. Live wire validation remains deliberately compatible. */
|
||||
export function validFxNodeReplayCommand<C extends FxNodeCompositionData>(
|
||||
v: unknown,
|
||||
nodeTypes?: ReplayNodeTypeAuthority<C>,
|
||||
): v is FxNodeReplayCommand<C> {
|
||||
try {
|
||||
if (!validCommandUnsafe(v) || v.type === "undo" || v.type === "redo") return false;
|
||||
if (v.type === "node.add")
|
||||
return (
|
||||
nodeTypeId(v.nodeType) &&
|
||||
(!nodeTypes ||
|
||||
(typeof nodeTypes === "function"
|
||||
? nodeTypes(v.nodeType as NodeTypeId<C>)
|
||||
: nodeTypes.has(v.nodeType as NodeTypeId<C>)))
|
||||
);
|
||||
if (v.type === "link.add" || v.type === "link.replace") return graphLink(v.link);
|
||||
if (v.type === "node.parameter" || v.type === "node.socket-default") return parameter(v.value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+845
@@ -0,0 +1,845 @@
|
||||
import {
|
||||
admitStructuredData,
|
||||
canonicalJsonEqual,
|
||||
canonicalize,
|
||||
cloneJson,
|
||||
deepFreeze,
|
||||
isJson,
|
||||
isRecord,
|
||||
nullRecord,
|
||||
} from "../core/json.js";
|
||||
import {
|
||||
graphId,
|
||||
linkId,
|
||||
nodeId,
|
||||
socketId,
|
||||
type GraphDocument,
|
||||
type GraphLayoutV2,
|
||||
type GraphLink,
|
||||
type GraphNode,
|
||||
type GraphState,
|
||||
type JsonValue,
|
||||
type ParameterValue,
|
||||
type Socket,
|
||||
} from "../core/types.js";
|
||||
import { migrateColorRamp } from "../widgets/color-ramp.js";
|
||||
import type {
|
||||
CompiledFxNodeComposition,
|
||||
FxNodeCompositionData,
|
||||
FxNodeDefinition,
|
||||
FxNodeMigrationStep,
|
||||
NodeTypeId,
|
||||
} from "./types.js";
|
||||
import { matchesFxNodeValueSchema } from "./value-matcher.js";
|
||||
import { initialNodeSize } from "../layout/node-dimensions.js";
|
||||
|
||||
/** A persistence or graph validation problem, with a stable code and JSON-pointer path. */
|
||||
export interface ValidationIssue {
|
||||
readonly code: string;
|
||||
readonly path: string;
|
||||
readonly message: string;
|
||||
}
|
||||
/** Result of decoding durable graph data under a compiled composition. */
|
||||
export type DecodeResult<C extends FxNodeCompositionData> =
|
||||
| { readonly ok: true; readonly value: GraphDocument<C> }
|
||||
| { readonly ok: false; readonly issues: readonly ValidationIssue[] };
|
||||
|
||||
type BoundValidationIssue = ValidationIssue;
|
||||
type BoundDecodeResult<C extends FxNodeCompositionData> = DecodeResult<C>;
|
||||
|
||||
export function graphStateFromDocument<C extends FxNodeCompositionData>(document: GraphDocument<C>): GraphState<C> {
|
||||
return deepFreeze({
|
||||
graphId: document.graphId,
|
||||
catalogVersion: document.catalogVersion,
|
||||
nodes: Object.values(document.nodes)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
links: Object.values(document.links)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
metadata: document.metadata,
|
||||
});
|
||||
}
|
||||
export const PERSISTENCE_LIMITS = Object.freeze({
|
||||
maxIssues: 100,
|
||||
maxNodes: 10_000,
|
||||
maxLinks: 20_000,
|
||||
maxSocketsPerNode: 256,
|
||||
maxParametersPerNode: 512,
|
||||
maxValues: 2_000_000,
|
||||
maxStringCodeUnits: 8_388_608,
|
||||
maxDepth: 50,
|
||||
maxMigrationExecutions: 1_000_000,
|
||||
});
|
||||
const finite = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
||||
const validString = (v: unknown, max = 512): v is string =>
|
||||
typeof v === "string" && v.length > 0 && v.length <= max && !/[\u0000-\u001f\u007f]/u.test(v);
|
||||
const forbidden = new Set([
|
||||
"selection",
|
||||
"selected",
|
||||
"camera",
|
||||
"hover",
|
||||
"hovered",
|
||||
"runtimeVersion",
|
||||
"history",
|
||||
"undo",
|
||||
"redo",
|
||||
"session",
|
||||
"layout",
|
||||
]);
|
||||
const pointer = (...p: string[]) => `/${p.map((x) => x.replaceAll("~", "~0").replaceAll("/", "~1")).join("/")}`;
|
||||
const parameter = (v: unknown): v is ParameterValue =>
|
||||
isRecord(v) &&
|
||||
Object.keys(v).every((k) => k === "kind" || k === "value") &&
|
||||
(v.kind === "number"
|
||||
? finite(v.value)
|
||||
: v.kind === "boolean"
|
||||
? typeof v.value === "boolean"
|
||||
: v.kind === "string"
|
||||
? typeof v.value === "string"
|
||||
: v.kind === "vector"
|
||||
? Array.isArray(v.value) && v.value.length === 3 && v.value.every(finite)
|
||||
: v.kind === "color"
|
||||
? Array.isArray(v.value) && v.value.length === 4 && v.value.every(finite)
|
||||
: v.kind === "json" && isJson(v.value));
|
||||
|
||||
const admit = (input: unknown) => admitStructuredData(input, PERSISTENCE_LIMITS);
|
||||
const STATE_ADMISSION_LIMITS = {
|
||||
...PERSISTENCE_LIMITS,
|
||||
maxValues: PERSISTENCE_LIMITS.maxValues + PERSISTENCE_LIMITS.maxNodes + 1,
|
||||
maxStringCodeUnits:
|
||||
PERSISTENCE_LIMITS.maxStringCodeUnits + PERSISTENCE_LIMITS.maxNodes * "known".length + "version".length,
|
||||
};
|
||||
function transient(v: unknown, path: string, out: BoundValidationIssue[], depth = 0): void {
|
||||
if (depth > 50 || out.length >= 100) return;
|
||||
if (Array.isArray(v)) {
|
||||
v.forEach((x, i) => transient(x, `${path}/${i}`, out, depth + 1));
|
||||
return;
|
||||
}
|
||||
if (!isRecord(v)) return;
|
||||
for (const [k, x] of Object.entries(v)) {
|
||||
if (forbidden.has(k))
|
||||
out.push({
|
||||
code: "field.transient",
|
||||
path: `${path}/${k}`,
|
||||
message: "Transient field is forbidden",
|
||||
});
|
||||
transient(x, `${path}/${k}`, out, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates document operations whose sole definition authority is one compiled composition. */
|
||||
export function bindDocument<C extends FxNodeCompositionData>(compiled: CompiledFxNodeComposition<C>) {
|
||||
const wildcards = new Set<string>(compiled.compatibility.wildcardInputTypes),
|
||||
definition = (id: string) =>
|
||||
(compiled.nodes as { get(key: string): unknown }).get(id) as
|
||||
| (FxNodeDefinition & { readonly typeId: NodeTypeId<C> })
|
||||
| undefined;
|
||||
const accepts = (s: { direction: string; type: string }) =>
|
||||
s.direction === "input"
|
||||
? ((
|
||||
compiled.socketTypes as {
|
||||
get(key: string): { acceptsFrom: readonly string[] } | undefined;
|
||||
}
|
||||
).get(s.type)?.acceptsFrom ?? [])
|
||||
: [];
|
||||
const socketFor = (id: string, key: string, s: FxNodeDefinition["sockets"][string]): Socket =>
|
||||
deepFreeze({
|
||||
id: socketId(`${id}:${key}`),
|
||||
key,
|
||||
label: s.title,
|
||||
direction: s.direction,
|
||||
dataType: s.type,
|
||||
accepts: cloneJson(accepts(s)),
|
||||
maxIncomingLinks: s.maxIncomingLinks,
|
||||
visible: s.visible,
|
||||
...(s.value ? { defaultValue: cloneJson(s.value.default) } : {}),
|
||||
});
|
||||
const materializeNode = (
|
||||
id: string,
|
||||
typeId: NodeTypeId<C>,
|
||||
position = { x: 0, y: 0 },
|
||||
parentId?: string,
|
||||
): GraphNode<C> => {
|
||||
const d = definition(typeId);
|
||||
if (!d) throw new TypeError(`Unknown composition type: ${typeId}`);
|
||||
const parameters = nullRecord(Object.entries(d.parameters).map(([k, s]) => [k, cloneJson(s.default)]));
|
||||
const sockets = Object.entries(d.sockets).map(([k, s]) => socketFor(id, k, s));
|
||||
const materialized: Pick<GraphNode, "label" | "parameters" | "sockets"> = { label: d.title, parameters, sockets };
|
||||
return deepFreeze({
|
||||
id: nodeId(id),
|
||||
typeId,
|
||||
typeVersion: d.version,
|
||||
known: true,
|
||||
position: cloneJson(position),
|
||||
size: initialNodeSize(d, materialized),
|
||||
label: d.title,
|
||||
parameters,
|
||||
sockets: deepFreeze(sockets),
|
||||
muted: false,
|
||||
collapsed: false,
|
||||
...(parentId === undefined ? {} : { parentId: nodeId(parentId) }),
|
||||
extensions: nullRecord<JsonValue>(),
|
||||
}) as GraphNode<C>;
|
||||
};
|
||||
const exact = (node: GraphNode<C>, d: FxNodeDefinition): boolean => {
|
||||
const exactKeys = (value: object, allowed: readonly string[]) => {
|
||||
const keys = Object.keys(value);
|
||||
return keys.length === allowed.length && keys.every((key) => allowed.includes(key));
|
||||
};
|
||||
if (
|
||||
!exactKeys(node, [
|
||||
"id",
|
||||
"typeId",
|
||||
"typeVersion",
|
||||
"known",
|
||||
"position",
|
||||
"size",
|
||||
"label",
|
||||
"parameters",
|
||||
"sockets",
|
||||
"muted",
|
||||
"collapsed",
|
||||
...(Object.hasOwn(node, "parentId") ? ["parentId"] : []),
|
||||
"extensions",
|
||||
]) ||
|
||||
!exactKeys(node.position, ["x", "y"]) ||
|
||||
!exactKeys(node.size, ["x", "y"])
|
||||
)
|
||||
return false;
|
||||
const ps = Object.entries(d.parameters);
|
||||
if (
|
||||
Object.keys(node.parameters).length !== ps.length ||
|
||||
!ps.every(([k, s]) => {
|
||||
const value = node.parameters[k];
|
||||
return isRecord(value) && exactKeys(value, ["kind", "value"]) && matchesFxNodeValueSchema(s, value);
|
||||
})
|
||||
)
|
||||
return false;
|
||||
const ss = Object.entries(d.sockets);
|
||||
return (
|
||||
node.sockets.length === ss.length &&
|
||||
ss.every(([key, s], i) => {
|
||||
const a = node.sockets[i];
|
||||
return (
|
||||
!!a &&
|
||||
a.id === `${node.id}:${key}` &&
|
||||
a.key === key &&
|
||||
a.label === s.title &&
|
||||
a.direction === s.direction &&
|
||||
a.dataType === s.type &&
|
||||
a.maxIncomingLinks === s.maxIncomingLinks &&
|
||||
a.visible === s.visible &&
|
||||
JSON.stringify(a.accepts) === JSON.stringify(accepts(s)) &&
|
||||
exactKeys(a, [
|
||||
"id",
|
||||
"key",
|
||||
"label",
|
||||
"direction",
|
||||
"dataType",
|
||||
"accepts",
|
||||
"maxIncomingLinks",
|
||||
...(s.value ? ["defaultValue"] : []),
|
||||
"visible",
|
||||
]) &&
|
||||
(s.value
|
||||
? isRecord(a.defaultValue) &&
|
||||
exactKeys(a.defaultValue, ["kind", "value"]) &&
|
||||
matchesFxNodeValueSchema(s.value, a.defaultValue)
|
||||
: a.defaultValue === undefined)
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
const compatible = (
|
||||
from: Pick<Socket, "direction" | "dataType">,
|
||||
to: Pick<Socket, "direction" | "dataType" | "accepts">,
|
||||
) =>
|
||||
from.direction === "output" &&
|
||||
to.direction === "input" &&
|
||||
(wildcards.has(to.dataType) || to.accepts.includes(from.dataType));
|
||||
const validateDocument = (document: GraphDocument<C>): readonly BoundValidationIssue[] => {
|
||||
const issues: BoundValidationIssue[] = [],
|
||||
incoming = new Map<string, number>();
|
||||
for (const l of Object.values(document.links))
|
||||
if (!l.muted) incoming.set(l.toSocketId, (incoming.get(l.toSocketId) ?? 0) + 1);
|
||||
if (document.catalogVersion !== compiled.version)
|
||||
issues.push({
|
||||
code: "catalog.version",
|
||||
path: "/catalogVersion",
|
||||
message: "Document version does not match composition",
|
||||
});
|
||||
for (const [key, n] of Object.entries(document.nodes)) {
|
||||
if (key !== n.id)
|
||||
issues.push({
|
||||
code: "id.mismatch",
|
||||
path: pointer("nodes", key),
|
||||
message: "Node key and id differ",
|
||||
});
|
||||
if (![n.position.x, n.position.y, n.size.x, n.size.y].every(finite) || n.size.x <= 0 || n.size.y <= 0)
|
||||
issues.push({
|
||||
code: "number.invalid",
|
||||
path: pointer("nodes", key),
|
||||
message: "Invalid geometry",
|
||||
});
|
||||
if (n.known) {
|
||||
const d = definition(n.typeId);
|
||||
if (!d || n.typeVersion !== d.version)
|
||||
issues.push({
|
||||
code: "catalog.version",
|
||||
path: pointer("nodes", key),
|
||||
message: "Unsupported definition version",
|
||||
});
|
||||
else if (!exact(n, d))
|
||||
issues.push({
|
||||
code: "catalog.invalid",
|
||||
path: pointer("nodes", key),
|
||||
message: "Known node does not exactly match definition",
|
||||
});
|
||||
}
|
||||
if (n.parentId) {
|
||||
const p = document.nodes[n.parentId];
|
||||
if (!p || (p.known && definition(p.typeId)?.behavior !== "frame"))
|
||||
issues.push({
|
||||
code: "parent.frame",
|
||||
path: pointer("nodes", key, "parentId"),
|
||||
message: "Parent must be a frame",
|
||||
});
|
||||
const seen = new Set([n.id]);
|
||||
let q = p;
|
||||
while (q) {
|
||||
if (seen.has(q.id)) {
|
||||
issues.push({
|
||||
code: "parent.cycle",
|
||||
path: pointer("nodes", key, "parentId"),
|
||||
message: "Parent cycle",
|
||||
});
|
||||
break;
|
||||
}
|
||||
seen.add(q.id);
|
||||
q = q.parentId ? document.nodes[q.parentId] : undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [key, l] of Object.entries(document.links)) {
|
||||
if (key !== l.id)
|
||||
issues.push({
|
||||
code: "id.mismatch",
|
||||
path: pointer("links", key),
|
||||
message: "Link key and id differ",
|
||||
});
|
||||
const from = document.nodes[l.fromNodeId]?.sockets.find((s) => s.id === l.fromSocketId),
|
||||
to = document.nodes[l.toNodeId]?.sockets.find((s) => s.id === l.toSocketId);
|
||||
if (!from || !to) {
|
||||
issues.push({
|
||||
code: "link.endpoint",
|
||||
path: pointer("links", key),
|
||||
message: "Endpoint does not exist",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!compatible(from, to))
|
||||
issues.push({
|
||||
code: "link.type",
|
||||
path: pointer("links", key),
|
||||
message: "Incompatible socket endpoints",
|
||||
});
|
||||
if ((incoming.get(to.id) ?? 0) > to.maxIncomingLinks)
|
||||
issues.push({
|
||||
code: "link.limit",
|
||||
path: pointer("links", key),
|
||||
message: "Too many incoming links",
|
||||
});
|
||||
}
|
||||
return deepFreeze(issues.slice(0, 100));
|
||||
};
|
||||
const decodeAdmittedGraphDocumentUnsafe = (source: unknown): BoundDecodeResult<C> => {
|
||||
let input = source;
|
||||
const issues: BoundValidationIssue[] = [];
|
||||
if (isRecord(input) && Number.isSafeInteger(input.schemaVersion) && Number(input.schemaVersion) > 2)
|
||||
return {
|
||||
ok: false,
|
||||
issues: [
|
||||
{
|
||||
code: "schema.future",
|
||||
path: "/schemaVersion",
|
||||
message: "Unsupported future schema version",
|
||||
},
|
||||
],
|
||||
};
|
||||
if (isRecord(input) && input.schemaVersion === 1)
|
||||
input = {
|
||||
...input,
|
||||
schemaVersion: 2,
|
||||
links: Array.isArray(input.links)
|
||||
? input.links.map((x) => (isRecord(x) ? { ...x, muted: false } : x))
|
||||
: input.links,
|
||||
};
|
||||
if (isRecord(input)) for (const [k, v] of Object.entries(input)) if (k !== "nodes") transient(v, `/${k}`, issues);
|
||||
if (
|
||||
!isRecord(input) ||
|
||||
input.schemaVersion !== 2 ||
|
||||
!validString(input.graphId) ||
|
||||
!Number.isSafeInteger(input.catalogVersion) ||
|
||||
Number(input.catalogVersion) <= 0 ||
|
||||
!Array.isArray(input.nodes) ||
|
||||
!Array.isArray(input.links) ||
|
||||
!isRecord(input.metadata) ||
|
||||
!isJson(input.metadata)
|
||||
)
|
||||
return {
|
||||
ok: false,
|
||||
issues: [...issues, { code: "decode.shape", path: "/", message: "Invalid GraphLayoutV2" }],
|
||||
};
|
||||
if (input.nodes.length > PERSISTENCE_LIMITS.maxNodes || input.links.length > PERSISTENCE_LIMITS.maxLinks)
|
||||
return {
|
||||
ok: false,
|
||||
issues: [
|
||||
{
|
||||
code: "limit.collection",
|
||||
path: input.nodes.length > PERSISTENCE_LIMITS.maxNodes ? "/nodes" : "/links",
|
||||
message: "Document collection limit exceeded",
|
||||
},
|
||||
],
|
||||
};
|
||||
const rawLinks = input.links as unknown[],
|
||||
nodes = new Map<string, GraphNode<C>>(),
|
||||
staged = new Map<string, { oldId: string; newId: string }[]>();
|
||||
let executions = 0;
|
||||
for (let i = 0; i < input.nodes.length; i++) {
|
||||
const original = input.nodes[i];
|
||||
if (
|
||||
!isRecord(original) ||
|
||||
!validString(original.id) ||
|
||||
!validString(original.typeId, 128) ||
|
||||
!Number.isSafeInteger(original.typeVersion) ||
|
||||
Number(original.typeVersion) <= 0 ||
|
||||
!isRecord(original.position) ||
|
||||
!finite(original.position.x) ||
|
||||
!finite(original.position.y) ||
|
||||
!isRecord(original.size) ||
|
||||
!finite(original.size.x) ||
|
||||
!finite(original.size.y) ||
|
||||
Number(original.size.x) <= 0 ||
|
||||
Number(original.size.y) <= 0 ||
|
||||
typeof original.label !== "string" ||
|
||||
original.label.length > 512 ||
|
||||
!isRecord(original.parameters) ||
|
||||
!isJson(original.parameters) ||
|
||||
Object.keys(original.parameters).length > PERSISTENCE_LIMITS.maxParametersPerNode ||
|
||||
!Array.isArray(original.sockets) ||
|
||||
original.sockets.length > PERSISTENCE_LIMITS.maxSocketsPerNode ||
|
||||
typeof original.muted !== "boolean" ||
|
||||
typeof original.collapsed !== "boolean" ||
|
||||
(original.parentId !== undefined && !validString(original.parentId)) ||
|
||||
!isRecord(original.extensions) ||
|
||||
!isJson(original.extensions) ||
|
||||
Object.hasOwn(original, "known")
|
||||
) {
|
||||
issues.push({
|
||||
code: "decode.node",
|
||||
path: pointer("nodes", String(i)),
|
||||
message: "Invalid node",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (nodes.has(original.id)) {
|
||||
issues.push({
|
||||
code: "id.duplicate",
|
||||
path: pointer("nodes", String(i), "id"),
|
||||
message: "Duplicate node id",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const parseSockets = (r: Record<string, unknown>) => {
|
||||
const out: Socket[] = [],
|
||||
ids = new Set<string>();
|
||||
for (const x of r.sockets as unknown[]) {
|
||||
if (
|
||||
!isRecord(x) ||
|
||||
!validString(x.id) ||
|
||||
!validString(x.key, 128) ||
|
||||
typeof x.label !== "string" ||
|
||||
x.label.length > 512 ||
|
||||
(x.direction !== "input" && x.direction !== "output") ||
|
||||
!validString(x.dataType, 128) ||
|
||||
!Array.isArray(x.accepts) ||
|
||||
!x.accepts.every((a) => validString(a, 128)) ||
|
||||
new Set(x.accepts).size !== x.accepts.length ||
|
||||
!Number.isSafeInteger(x.maxIncomingLinks) ||
|
||||
Number(x.maxIncomingLinks) < 0 ||
|
||||
typeof x.visible !== "boolean" ||
|
||||
(x.defaultValue !== undefined && x.defaultValue !== null && !parameter(x.defaultValue)) ||
|
||||
(x.metadata !== undefined && (!isRecord(x.metadata) || !isJson(x.metadata))) ||
|
||||
ids.has(x.id)
|
||||
)
|
||||
return;
|
||||
ids.add(x.id);
|
||||
out.push(x as unknown as Socket);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const originalSockets = parseSockets(original);
|
||||
if (!originalSockets) {
|
||||
issues.push({
|
||||
code: "decode.socket",
|
||||
path: pointer("nodes", String(i), "sockets"),
|
||||
message: "Invalid socket",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let draft = structuredClone(original),
|
||||
rewrites: { oldId: string; newId: string }[] = [],
|
||||
soft = false;
|
||||
const d = definition(original.typeId);
|
||||
if (d && Number(original.typeVersion) < d.version) {
|
||||
const byFrom = new Map(d.migrations.map((m) => [m.fromVersion, m]));
|
||||
while (Number(draft.typeVersion) < d.version) {
|
||||
const edge = byFrom.get(Number(draft.typeVersion));
|
||||
if (!edge) {
|
||||
soft = true;
|
||||
break;
|
||||
}
|
||||
const next = structuredClone(draft),
|
||||
edgeRewrites: { oldId: string; newId: string }[] = [];
|
||||
for (const step of edge.steps) {
|
||||
if (++executions > PERSISTENCE_LIMITS.maxMigrationExecutions)
|
||||
return {
|
||||
ok: false,
|
||||
issues: [
|
||||
{
|
||||
code: "limit.migrations",
|
||||
path: "/nodes",
|
||||
message: "Migration execution limit exceeded",
|
||||
},
|
||||
],
|
||||
};
|
||||
if (!run(step, next, d, edgeRewrites)) {
|
||||
soft = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (soft) break;
|
||||
next.typeVersion = edge.toVersion;
|
||||
draft = next;
|
||||
rewrites.push(...edgeRewrites);
|
||||
}
|
||||
}
|
||||
if (soft) {
|
||||
draft = structuredClone(original);
|
||||
rewrites = [];
|
||||
}
|
||||
const sockets = parseSockets(draft);
|
||||
if (!sockets) {
|
||||
issues.push({
|
||||
code: "decode.socket",
|
||||
path: pointer("nodes", String(i), "sockets"),
|
||||
message: "Invalid socket",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const base = {
|
||||
...draft,
|
||||
id: nodeId(String(draft.id)),
|
||||
typeId: String(draft.typeId),
|
||||
typeVersion: Number(draft.typeVersion),
|
||||
sockets,
|
||||
...(typeof draft.parentId === "string" ? { parentId: nodeId(draft.parentId) } : {}),
|
||||
};
|
||||
let n = cloneJson({ ...base, known: false }) as unknown as GraphNode<C>;
|
||||
if (d && !soft && Number(draft.typeVersion) === d.version) {
|
||||
transient(draft, pointer("nodes", String(i)), issues);
|
||||
const candidate = cloneJson({
|
||||
...base,
|
||||
known: true,
|
||||
typeId: d.typeId,
|
||||
}) as unknown as GraphNode<C>;
|
||||
if (exact(candidate, d)) n = candidate;
|
||||
else if (Number(original.typeVersion) === d.version) {
|
||||
issues.push({
|
||||
code: "catalog.invalid",
|
||||
path: pointer("nodes", String(i)),
|
||||
message: "Known node does not exactly match definition",
|
||||
});
|
||||
continue;
|
||||
} else {
|
||||
n = cloneJson({
|
||||
...original,
|
||||
sockets: originalSockets,
|
||||
known: false,
|
||||
}) as unknown as GraphNode<C>;
|
||||
rewrites = [];
|
||||
}
|
||||
} else rewrites = [];
|
||||
nodes.set(String(original.id), n);
|
||||
if (n.known && rewrites.length) staged.set(n.id, rewrites);
|
||||
}
|
||||
const linkCandidate = structuredClone(rawLinks);
|
||||
for (const r of linkCandidate)
|
||||
if (isRecord(r)) {
|
||||
for (const rw of staged.get(String(r.fromNodeId)) ?? [])
|
||||
if (r.fromSocketId === rw.oldId) r.fromSocketId = rw.newId;
|
||||
for (const rw of staged.get(String(r.toNodeId)) ?? []) if (r.toSocketId === rw.oldId) r.toSocketId = rw.newId;
|
||||
}
|
||||
const links = new Map<string, GraphLink>();
|
||||
for (const r of linkCandidate) {
|
||||
if (
|
||||
!isRecord(r) ||
|
||||
!validString(r.id) ||
|
||||
!validString(r.fromNodeId) ||
|
||||
!validString(r.fromSocketId) ||
|
||||
!validString(r.toNodeId) ||
|
||||
!validString(r.toSocketId) ||
|
||||
typeof r.muted !== "boolean" ||
|
||||
!isRecord(r.extensions) ||
|
||||
!isJson(r.extensions) ||
|
||||
links.has(r.id)
|
||||
) {
|
||||
issues.push({
|
||||
code: "decode.link",
|
||||
path: "/links",
|
||||
message: "Invalid link",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
links.set(
|
||||
r.id,
|
||||
cloneJson({
|
||||
...r,
|
||||
id: linkId(r.id),
|
||||
fromNodeId: nodeId(r.fromNodeId),
|
||||
fromSocketId: socketId(r.fromSocketId),
|
||||
toNodeId: nodeId(r.toNodeId),
|
||||
toSocketId: socketId(r.toSocketId),
|
||||
}) as GraphLink,
|
||||
);
|
||||
}
|
||||
if (issues.length) return { ok: false, issues: deepFreeze(issues.slice(0, 100)) };
|
||||
const document = deepFreeze({
|
||||
schemaVersion: 2 as const,
|
||||
graphId: graphId(input.graphId),
|
||||
catalogVersion: compiled.version,
|
||||
nodes: nullRecord(nodes),
|
||||
links: nullRecord(links),
|
||||
metadata: cloneJson(input.metadata) as Readonly<Record<string, JsonValue>>,
|
||||
});
|
||||
const semantic = validateDocument(document);
|
||||
return semantic.length ? { ok: false, issues: semantic } : { ok: true, value: document };
|
||||
function run(
|
||||
step: FxNodeMigrationStep,
|
||||
draft: Record<string, unknown>,
|
||||
d: FxNodeDefinition,
|
||||
rewrites: { oldId: string; newId: string }[],
|
||||
): boolean {
|
||||
if (!isRecord(draft.parameters) || !Array.isArray(draft.sockets) || !draft.sockets.every(isRecord)) return false;
|
||||
const params = draft.parameters,
|
||||
sockets = draft.sockets;
|
||||
if (step.kind === "materialize-missing" && step.target === "parameter") {
|
||||
const def = d.parameters[step.key];
|
||||
if (!def) return false;
|
||||
if (!Object.hasOwn(params, step.key)) params[step.key] = structuredClone(def.default);
|
||||
return true;
|
||||
}
|
||||
if (step.kind === "materialize-missing") {
|
||||
if (sockets.some((s) => s.key === step.key)) return true;
|
||||
const id = `${String(draft.id)}:${step.key}`;
|
||||
if (sockets.some((s) => s.id === id)) return false;
|
||||
const def = d.sockets[step.key];
|
||||
if (!def) return false;
|
||||
const ordinal = Object.keys(d.sockets).indexOf(step.key),
|
||||
created = structuredClone(socketFor(String(draft.id), step.key, def));
|
||||
sockets.splice(Math.min(ordinal, sockets.length), 0, created as unknown as Record<string, unknown>);
|
||||
return true;
|
||||
}
|
||||
if (step.kind === "migrate-parameter") {
|
||||
const migrated = migrateColorRamp(params[step.parameter]);
|
||||
if (!migrated) return false;
|
||||
params[step.parameter] = {
|
||||
kind: "json",
|
||||
value: structuredClone(migrated),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
if (step.kind === "rename-parameter") {
|
||||
if (!Object.hasOwn(params, step.from) || Object.hasOwn(params, step.to)) return false;
|
||||
params[step.to] = params[step.from];
|
||||
delete params[step.from];
|
||||
return true;
|
||||
}
|
||||
const matches = sockets.filter((s) => s.key === step.from);
|
||||
if (matches.length !== 1 || sockets.some((s) => s.key === step.to)) return false;
|
||||
const old = matches[0]!,
|
||||
newId = `${String(draft.id)}:${step.to}`;
|
||||
if (sockets.some((s) => s !== old && s.id === newId)) return false;
|
||||
for (const l of rawLinks)
|
||||
if (
|
||||
isRecord(l) &&
|
||||
((l.fromNodeId === draft.id && l.fromSocketId === newId) ||
|
||||
(l.toNodeId === draft.id && l.toSocketId === newId))
|
||||
)
|
||||
return false;
|
||||
if (!validString(old.id)) return false;
|
||||
const oldId = old.id;
|
||||
old.key = step.to;
|
||||
old.id = newId;
|
||||
rewrites.push({ oldId, newId });
|
||||
return true;
|
||||
}
|
||||
};
|
||||
const decodeGraphDocumentUnsafe = (source: unknown): BoundDecodeResult<C> => {
|
||||
const admitted = admit(source);
|
||||
return admitted.ok
|
||||
? decodeAdmittedGraphDocumentUnsafe(admitted.value)
|
||||
: { ok: false, issues: deepFreeze(admitted.issues) };
|
||||
};
|
||||
const decodeGraphDocument = (source: unknown): BoundDecodeResult<C> => {
|
||||
try {
|
||||
return decodeGraphDocumentUnsafe(source);
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
issues: [
|
||||
{
|
||||
code: "decode.failure",
|
||||
path: "/",
|
||||
message: "Document could not be decoded",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
};
|
||||
const decodeGraphState = (source: unknown): BoundDecodeResult<C> => {
|
||||
try {
|
||||
const admitted = admitStructuredData(source, STATE_ADMISSION_LIMITS);
|
||||
if (!admitted.ok) return { ok: false, issues: deepFreeze(admitted.issues) };
|
||||
const input = admitted.value;
|
||||
const shape = () => ({
|
||||
ok: false as const,
|
||||
issues: deepFreeze([{ code: "state.shape", path: "/", message: "Invalid GraphState" }]),
|
||||
});
|
||||
if (!isRecord(input)) return shape();
|
||||
const keys = Object.keys(input),
|
||||
allowed = ["version", "graphId", "catalogVersion", "nodes", "links", "metadata"];
|
||||
if (
|
||||
!["graphId", "catalogVersion", "nodes", "links", "metadata"].every((k) => Object.hasOwn(input, k)) ||
|
||||
keys.some((k) => !allowed.includes(k)) ||
|
||||
!validString(input.graphId) ||
|
||||
!Number.isSafeInteger(input.catalogVersion) ||
|
||||
Number(input.catalogVersion) <= 0 ||
|
||||
!Array.isArray(input.nodes) ||
|
||||
!Array.isArray(input.links) ||
|
||||
!isRecord(input.metadata) ||
|
||||
!isJson(input.metadata)
|
||||
)
|
||||
return shape();
|
||||
if (input.nodes.length > PERSISTENCE_LIMITS.maxNodes)
|
||||
return {
|
||||
ok: false,
|
||||
issues: deepFreeze([{ code: "limit.nodes", path: "/nodes", message: "Node limit exceeded" }]),
|
||||
};
|
||||
if (input.links.length > PERSISTENCE_LIMITS.maxLinks)
|
||||
return {
|
||||
ok: false,
|
||||
issues: deepFreeze([{ code: "limit.links", path: "/links", message: "Link limit exceeded" }]),
|
||||
};
|
||||
const hasVersion = Object.hasOwn(input, "version"),
|
||||
durableValues = admitted.metrics.values - input.nodes.length - (hasVersion ? 1 : 0) + 1,
|
||||
durableStrings =
|
||||
admitted.metrics.stringCodeUnits -
|
||||
input.nodes.length * "known".length -
|
||||
(hasVersion ? "version".length : 0) +
|
||||
"schemaVersion".length;
|
||||
if (durableValues > PERSISTENCE_LIMITS.maxValues)
|
||||
return {
|
||||
ok: false,
|
||||
issues: deepFreeze([{ code: "limit.values", path: "/", message: "Document exceeds inspected value limit" }]),
|
||||
};
|
||||
if (durableStrings > PERSISTENCE_LIMITS.maxStringCodeUnits)
|
||||
return {
|
||||
ok: false,
|
||||
issues: deepFreeze([{ code: "limit.strings", path: "/", message: "Document exceeds string limit" }]),
|
||||
};
|
||||
const nodes: unknown[] = [];
|
||||
for (const node of input.nodes) {
|
||||
if (!isRecord(node) || !Object.hasOwn(node, "known") || typeof node.known !== "boolean")
|
||||
return {
|
||||
ok: false,
|
||||
issues: deepFreeze([
|
||||
{ code: "state.inexact", path: "/nodes", message: "GraphState is not exact for this composition" },
|
||||
]),
|
||||
};
|
||||
const { known: _, ...durable } = node;
|
||||
nodes.push(durable);
|
||||
}
|
||||
const requested = {
|
||||
graphId: input.graphId,
|
||||
catalogVersion: input.catalogVersion,
|
||||
nodes: input.nodes,
|
||||
links: input.links,
|
||||
metadata: input.metadata,
|
||||
};
|
||||
const decoded = decodeAdmittedGraphDocumentUnsafe({
|
||||
schemaVersion: 2,
|
||||
graphId: input.graphId,
|
||||
catalogVersion: input.catalogVersion,
|
||||
nodes,
|
||||
links: input.links,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
if (!decoded.ok) return decoded;
|
||||
return canonicalJsonEqual(requested, graphStateFromDocument(decoded.value))
|
||||
? decoded
|
||||
: {
|
||||
ok: false,
|
||||
issues: deepFreeze([
|
||||
{ code: "state.inexact", path: "/", message: "GraphState is not exact for this composition" },
|
||||
]),
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, issues: [{ code: "state.shape", path: "/", message: "Invalid GraphState" }] };
|
||||
}
|
||||
};
|
||||
const save = (document: GraphDocument<C>): GraphLayoutV2 =>
|
||||
deepFreeze({
|
||||
schemaVersion: 2,
|
||||
graphId: document.graphId,
|
||||
catalogVersion: document.catalogVersion,
|
||||
nodes: Object.values(document.nodes)
|
||||
.map(({ known: _, parentId, ...node }) => ({ ...node, ...(parentId === undefined ? {} : { parentId }) }))
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
links: Object.values(document.links)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
metadata: document.metadata,
|
||||
});
|
||||
const serializeGraphDocument = (d: GraphDocument<C>) => JSON.stringify(canonicalize(save(d)));
|
||||
const parseGraphDocument = (text: string): BoundDecodeResult<C> => {
|
||||
try {
|
||||
return decodeGraphDocument(JSON.parse(text));
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
issues: [{ code: "decode.json", path: "/", message: "Invalid JSON" }],
|
||||
};
|
||||
}
|
||||
};
|
||||
return Object.freeze({
|
||||
emptyDocument: (id = "graph"): GraphDocument<C> =>
|
||||
deepFreeze({
|
||||
schemaVersion: 2,
|
||||
graphId: graphId(id),
|
||||
catalogVersion: compiled.version,
|
||||
nodes: nullRecord(),
|
||||
links: nullRecord(),
|
||||
metadata: nullRecord(),
|
||||
}),
|
||||
materializeNode,
|
||||
validateDocument,
|
||||
decodeGraphDocument,
|
||||
decodeGraphState,
|
||||
parseGraphDocument,
|
||||
save,
|
||||
serializeGraphDocument,
|
||||
socketsCompatible: compatible,
|
||||
});
|
||||
}
|
||||
+597
@@ -0,0 +1,597 @@
|
||||
import type {
|
||||
Command,
|
||||
CommandError,
|
||||
CommandRequest,
|
||||
CompatibleFxNodeSaveData,
|
||||
FxNodeReplayCommand,
|
||||
} from "../commands/types.js";
|
||||
import { decodeFxNodeSaveData } from "../commands/save-data.js";
|
||||
import { validFxNodeReplayCommand } from "../commands/validate.js";
|
||||
import { FXNODE_BATCH_LIMIT } from "../commands/limits.js";
|
||||
import { canonicalJsonEqual, cloneJson, deepFreeze } from "../core/json.js";
|
||||
import type { CommandId, GraphDocument, GraphLayoutV2, GraphNode, GraphSnapshot, GraphState } from "../core/types.js";
|
||||
import { invert, type Mutation } from "../engine/mutations.js";
|
||||
import { reduceMutations } from "../engine/reducer.js";
|
||||
import {
|
||||
bindDocument,
|
||||
graphStateFromDocument,
|
||||
type DecodeResult as BoundDecodeResult,
|
||||
type ValidationIssue as BoundValidationIssue,
|
||||
} from "./bound-document.js";
|
||||
import type { CompiledFxNodeComposition, FxNodeCompositionData, FxNodeDefinition } from "./types.js";
|
||||
import { matchesFxNodeValueSchema } from "./value-matcher.js";
|
||||
|
||||
/** Immutable engine state owned by one composition-bound headless runtime. */
|
||||
export interface EngineState<C extends FxNodeCompositionData = FxNodeCompositionData> {
|
||||
readonly version: number;
|
||||
readonly document: GraphDocument<C>;
|
||||
readonly undo: readonly { readonly forward: readonly Mutation<C>[]; readonly inverse: readonly Mutation<C>[] }[];
|
||||
readonly redo: readonly { readonly forward: readonly Mutation<C>[]; readonly inverse: readonly Mutation<C>[] }[];
|
||||
readonly historyLimit: number;
|
||||
}
|
||||
/** A committed, versioned mutation batch. */
|
||||
export interface MutationEnvelope<C extends FxNodeCompositionData = FxNodeCompositionData> {
|
||||
readonly baseVersion: number;
|
||||
readonly version: number;
|
||||
readonly commandId: CommandId;
|
||||
readonly cause: "api" | "gesture" | "undo" | "redo" | "load" | "composition";
|
||||
readonly mutations: readonly Mutation<C>[];
|
||||
}
|
||||
/** A versioned immutable graph snapshot. */
|
||||
export interface SnapshotEnvelope<C extends FxNodeCompositionData = FxNodeCompositionData> {
|
||||
readonly version: number;
|
||||
readonly snapshot: GraphSnapshot<C>;
|
||||
}
|
||||
/** Result of applying a command or state replacement. */
|
||||
export type TransitionResult<C extends FxNodeCompositionData = FxNodeCompositionData> =
|
||||
| {
|
||||
readonly status: "committed";
|
||||
readonly state: EngineState<C>;
|
||||
readonly mutationEnvelope: MutationEnvelope<C>;
|
||||
readonly snapshotEnvelope: SnapshotEnvelope<C>;
|
||||
}
|
||||
| { readonly status: "noop"; readonly state: EngineState<C> }
|
||||
| { readonly status: "rejected"; readonly state: EngineState<C>; readonly error: CommandError };
|
||||
export interface StateReplacementRequest<C extends FxNodeCompositionData = FxNodeCompositionData> {
|
||||
readonly commandId: CommandId;
|
||||
readonly expectedVersion: number;
|
||||
readonly target: GraphState<C>;
|
||||
}
|
||||
/** Result of loading durable graph data. */
|
||||
export type LoadResult<C extends FxNodeCompositionData = FxNodeCompositionData> =
|
||||
| {
|
||||
readonly ok: true;
|
||||
readonly state: EngineState<C>;
|
||||
readonly mutationEnvelope: MutationEnvelope<C>;
|
||||
readonly snapshotEnvelope: SnapshotEnvelope<C>;
|
||||
}
|
||||
| { readonly ok: false; readonly state: EngineState<C>; readonly issues: readonly BoundValidationIssue[] };
|
||||
export type ReplayResult<C extends FxNodeCompositionData = FxNodeCompositionData> =
|
||||
| {
|
||||
readonly ok: true;
|
||||
readonly status: "committed";
|
||||
readonly state: EngineState<C>;
|
||||
readonly mutationEnvelope: MutationEnvelope<C>;
|
||||
readonly snapshotEnvelope: SnapshotEnvelope<C>;
|
||||
readonly saveData: CompatibleFxNodeSaveData<C>;
|
||||
}
|
||||
| {
|
||||
readonly ok: true;
|
||||
readonly status: "noop";
|
||||
readonly state: EngineState<C>;
|
||||
readonly saveData: CompatibleFxNodeSaveData<C>;
|
||||
}
|
||||
| { readonly ok: false; readonly state: EngineState<C>; readonly issues: readonly BoundValidationIssue[] };
|
||||
type BoundEngineState<C extends FxNodeCompositionData = FxNodeCompositionData> = EngineState<C>;
|
||||
type BoundMutationEnvelope<C extends FxNodeCompositionData = FxNodeCompositionData> = MutationEnvelope<C>;
|
||||
type BoundSnapshotEnvelope<C extends FxNodeCompositionData = FxNodeCompositionData> = SnapshotEnvelope<C>;
|
||||
type BoundTransitionResult<C extends FxNodeCompositionData = FxNodeCompositionData> = TransitionResult<C>;
|
||||
type BoundStateReplacementRequest<C extends FxNodeCompositionData = FxNodeCompositionData> = StateReplacementRequest<C>;
|
||||
type BoundLoadResult<C extends FxNodeCompositionData = FxNodeCompositionData> = LoadResult<C>;
|
||||
type BoundReplayResult<C extends FxNodeCompositionData = FxNodeCompositionData> = ReplayResult<C>;
|
||||
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b),
|
||||
reject = (code: string, message: string): CommandError => ({ code, message });
|
||||
function snapshotState<C extends FxNodeCompositionData>(state: BoundEngineState<C>): GraphSnapshot<C> {
|
||||
return deepFreeze({ version: state.version, ...graphStateFromDocument(state.document) });
|
||||
}
|
||||
export interface BoundRebindCodec<C extends FxNodeCompositionData = FxNodeCompositionData> {
|
||||
readonly save: (document: GraphDocument<C>) => GraphLayoutV2;
|
||||
readonly decodeGraphDocument: (value: unknown) => BoundDecodeResult<C>;
|
||||
}
|
||||
export interface BoundAuthorityRebindOptions {
|
||||
readonly commandId: CommandId;
|
||||
readonly removedNodeTypes?: ReadonlySet<string>;
|
||||
}
|
||||
export type BoundAuthorityRebindResult<C extends FxNodeCompositionData = FxNodeCompositionData> =
|
||||
| { readonly ok: false; readonly state: BoundEngineState<C>; readonly issues: readonly BoundValidationIssue[] }
|
||||
| { readonly ok: true; readonly graphChanged: false; readonly state: BoundEngineState<C> }
|
||||
| {
|
||||
readonly ok: true;
|
||||
readonly graphChanged: true;
|
||||
readonly state: BoundEngineState<C>;
|
||||
readonly mutationEnvelope: BoundMutationEnvelope<C>;
|
||||
readonly snapshotEnvelope: BoundSnapshotEnvelope<C>;
|
||||
};
|
||||
const pointerToken = (value: string) => value.replaceAll("~", "~0").replaceAll("/", "~1");
|
||||
export function rebindBoundEngineAuthority<C extends FxNodeCompositionData>(
|
||||
state: BoundEngineState<C>,
|
||||
current: BoundRebindCodec<C>,
|
||||
candidate: BoundRebindCodec<C>,
|
||||
options: BoundAuthorityRebindOptions,
|
||||
): BoundAuthorityRebindResult<C> {
|
||||
const decoded = candidate.decodeGraphDocument(current.save(state.document));
|
||||
if (!decoded.ok) return { ok: false, state, issues: decoded.issues };
|
||||
const document = decoded.value,
|
||||
demotions: BoundValidationIssue[] = [];
|
||||
for (const before of Object.values(state.document.nodes)) {
|
||||
if (!before.known) continue;
|
||||
const after = document.nodes[before.id];
|
||||
const explicitlyRemoved =
|
||||
options.removedNodeTypes?.has(before.typeId) === true && after?.known === false && after.typeId === before.typeId;
|
||||
if (!after?.known && !explicitlyRemoved && demotions.length < 100)
|
||||
demotions.push({
|
||||
code: "composition.node-demotion",
|
||||
path: `/nodes/${pointerToken(before.id)}/known`,
|
||||
message: `Known node type "${before.typeId}" would become unknown`,
|
||||
});
|
||||
}
|
||||
if (demotions.length) return { ok: false, state, issues: deepFreeze(demotions) };
|
||||
const graphChanged = !canonicalJsonEqual(state.document, document);
|
||||
if (graphChanged && state.version >= Number.MAX_SAFE_INTEGER)
|
||||
return {
|
||||
ok: false,
|
||||
state,
|
||||
issues: deepFreeze([{ code: "version.overflow", path: "/", message: "Version exhausted" }]),
|
||||
};
|
||||
const next: BoundEngineState<C> = deepFreeze({
|
||||
version: graphChanged ? state.version + 1 : state.version,
|
||||
document,
|
||||
undo: [],
|
||||
redo: [],
|
||||
historyLimit: state.historyLimit,
|
||||
});
|
||||
if (!graphChanged) return { ok: true, graphChanged: false, state: next };
|
||||
const mutationEnvelope: BoundMutationEnvelope<C> = deepFreeze({
|
||||
baseVersion: state.version,
|
||||
version: next.version,
|
||||
commandId: options.commandId,
|
||||
cause: "composition",
|
||||
mutations: [{ kind: "document.replaced", before: state.document, after: document }],
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
graphChanged: true,
|
||||
state: next,
|
||||
mutationEnvelope,
|
||||
snapshotEnvelope: deepFreeze({ version: next.version, snapshot: snapshotState(next) }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Creates engine operations permanently closed over one compiled composition. */
|
||||
export function bindEngine<C extends FxNodeCompositionData>(compiled: CompiledFxNodeComposition<C>) {
|
||||
const docs = bindDocument(compiled),
|
||||
definition = (id: string) =>
|
||||
(compiled.nodes as { get(key: string): unknown }).get(id) as FxNodeDefinition | undefined;
|
||||
const snapshot = (s: BoundEngineState<C>): GraphSnapshot<C> => snapshotState(s);
|
||||
const persisted = (document: GraphDocument<C>) => docs.decodeGraphDocument(docs.save(document));
|
||||
const createEngine = (document: GraphDocument<C>, historyLimit = 100): BoundEngineState<C> => {
|
||||
if (!Number.isSafeInteger(historyLimit) || historyLimit < 0)
|
||||
throw new RangeError("historyLimit must be a finite nonnegative integer");
|
||||
const issue = docs.validateDocument(document)[0];
|
||||
if (issue) throw new TypeError(`Invalid initial document: ${issue.code}`);
|
||||
const closed = persisted(document);
|
||||
if (!closed.ok)
|
||||
throw new TypeError(`Initial document is not persistable: ${closed.issues[0]?.code ?? "persistence.invalid"}`);
|
||||
return deepFreeze({ version: 0, document: closed.value, undo: [], redo: [], historyLimit });
|
||||
};
|
||||
const plan = (document: GraphDocument<C>, command: Command<C>): readonly Mutation<C>[] | CommandError | null => {
|
||||
if (command.type === "batch") {
|
||||
if (!command.commands.length) return null;
|
||||
if (command.commands.length > FXNODE_BATCH_LIMIT)
|
||||
return reject("batch.limit", `Batch exceeds ${FXNODE_BATCH_LIMIT} commands`);
|
||||
let draft = document,
|
||||
all: Mutation<C>[] = [];
|
||||
for (const c of command.commands) {
|
||||
const r = plan(draft, c);
|
||||
if (r === null) continue;
|
||||
if (!Array.isArray(r)) return r as CommandError;
|
||||
all.push(...r);
|
||||
draft = reduceMutations(draft, r);
|
||||
}
|
||||
return all.length ? all : null;
|
||||
}
|
||||
if (command.type === "node.add") {
|
||||
if (document.nodes[command.nodeId]) return reject("node.duplicate", "Node id already exists");
|
||||
const d = definition(command.nodeType);
|
||||
if (!d) return reject("node.type-unknown", "Node type is not declared");
|
||||
if (command.parentId) {
|
||||
const p = document.nodes[command.parentId];
|
||||
if (!p || !p.known || definition(p.typeId)?.behavior !== "frame")
|
||||
return reject("parent.frame", "Parent must be an existing known frame");
|
||||
}
|
||||
return [
|
||||
{
|
||||
kind: "node.set",
|
||||
id: command.nodeId,
|
||||
before: null,
|
||||
after: docs.materializeNode(command.nodeId, command.nodeType, command.position, command.parentId),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (command.type === "node.remove") {
|
||||
const n = document.nodes[command.id];
|
||||
if (!n) return reject("node.missing", "Node does not exist");
|
||||
return [
|
||||
{ kind: "node.set", id: n.id, before: n, after: null },
|
||||
...Object.values(document.links)
|
||||
.filter((l) => l.fromNodeId === n.id || l.toNodeId === n.id)
|
||||
.map((l) => ({ kind: "link.set" as const, id: l.id, before: l, after: null })),
|
||||
...Object.values(document.nodes)
|
||||
.filter((x) => x.parentId === n.id)
|
||||
.map((x) => {
|
||||
const { parentId: _, ...unparented } = x;
|
||||
return { kind: "node.set" as const, id: x.id, before: x, after: deepFreeze(unparented) };
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (command.type === "link.add") {
|
||||
if (document.links[command.link.id]) return reject("link.duplicate", "Link id already exists");
|
||||
if (
|
||||
Object.values(document.links).some(
|
||||
(l) => l.fromSocketId === command.link.fromSocketId && l.toSocketId === command.link.toSocketId,
|
||||
)
|
||||
)
|
||||
return reject("link.endpoint-duplicate", "Endpoints are already linked");
|
||||
return [{ kind: "link.set", id: command.link.id, before: null, after: cloneJson(command.link) }];
|
||||
}
|
||||
if (command.type === "link.remove") {
|
||||
const l = document.links[command.id];
|
||||
return l
|
||||
? [{ kind: "link.set", id: l.id, before: l, after: null }]
|
||||
: reject("link.missing", "Link does not exist");
|
||||
}
|
||||
if (command.type === "link.mute") {
|
||||
const l = document.links[command.id];
|
||||
if (!l) return reject("link.missing", "Link does not exist");
|
||||
return l.muted === command.value
|
||||
? null
|
||||
: [{ kind: "link.set", id: l.id, before: l, after: deepFreeze({ ...l, muted: command.value }) }];
|
||||
}
|
||||
if (command.type === "link.replace") {
|
||||
const old = document.links[command.removeId];
|
||||
if (!old) return reject("link.missing", "Replacement target does not exist");
|
||||
if (command.link.toSocketId !== old.toSocketId)
|
||||
return reject("link.replace-target", "Replacement must keep target socket");
|
||||
if (command.link.id !== old.id && document.links[command.link.id])
|
||||
return reject("link.duplicate", "Replacement id collides");
|
||||
return [
|
||||
{ kind: "link.set", id: old.id, before: old, after: null },
|
||||
{ kind: "link.set", id: command.link.id, before: null, after: cloneJson(command.link) },
|
||||
];
|
||||
}
|
||||
if (command.type === "undo" || command.type === "redo") return null;
|
||||
const node = document.nodes[command.id];
|
||||
if (!node) return reject("node.missing", "Node does not exist");
|
||||
let after: GraphNode<C>;
|
||||
switch (command.type) {
|
||||
case "node.move":
|
||||
if (same(node.position, command.position)) return null;
|
||||
after = { ...node, position: cloneJson(command.position) };
|
||||
break;
|
||||
case "node.resize":
|
||||
if (command.size.x <= 0 || command.size.y <= 0) return reject("size.nonpositive", "Node size must be positive");
|
||||
if (same(node.size, command.size)) return null;
|
||||
after = { ...node, size: cloneJson(command.size) };
|
||||
break;
|
||||
case "node.label":
|
||||
if (node.label === command.label) return null;
|
||||
after = { ...node, label: command.label };
|
||||
break;
|
||||
case "node.mute":
|
||||
if (node.muted === command.value) return null;
|
||||
after = { ...node, muted: command.value };
|
||||
break;
|
||||
case "node.collapse":
|
||||
if (node.collapsed === command.value) return null;
|
||||
after = { ...node, collapsed: command.value };
|
||||
break;
|
||||
case "node.parent": {
|
||||
if (node.parentId === (command.parentId ?? undefined)) return null;
|
||||
const world = (n: GraphNode<C>) => {
|
||||
let x = n.position.x,
|
||||
y = n.position.y,
|
||||
p = n.parentId ? document.nodes[n.parentId] : undefined;
|
||||
while (p) {
|
||||
x += p.position.x;
|
||||
y += p.position.y;
|
||||
p = p.parentId ? document.nodes[p.parentId] : undefined;
|
||||
}
|
||||
return { x, y };
|
||||
};
|
||||
const p = command.parentId ? document.nodes[command.parentId] : undefined;
|
||||
if (command.parentId && (!p || !p.known || definition(p.typeId)?.behavior !== "frame"))
|
||||
return reject("parent.frame", "Parent must be an existing known frame");
|
||||
const origin = p ? world(p) : { x: 0, y: 0 },
|
||||
current = world(node),
|
||||
position = { x: current.x - origin.x, y: current.y - origin.y };
|
||||
if (command.parentId) after = { ...node, position, parentId: command.parentId };
|
||||
else {
|
||||
const { parentId: _, ...unparented } = node;
|
||||
after = { ...unparented, position };
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "node.parameter":
|
||||
case "node.parameter-reset": {
|
||||
if (!node.known) return reject("node.unknown-readonly", "Unknown node parameters are read-only");
|
||||
const schema = definition(node.typeId)?.parameters[command.key];
|
||||
if (!schema) return reject("parameter.unknown", "Parameter is not declared");
|
||||
const value = command.type === "node.parameter-reset" ? schema.default : command.value;
|
||||
if (!matchesFxNodeValueSchema(schema, value))
|
||||
return reject("parameter.invalid", "Parameter does not match its schema");
|
||||
if (same(node.parameters[command.key], value)) return null;
|
||||
after = { ...node, parameters: deepFreeze({ ...node.parameters, [command.key]: cloneJson(value) }) };
|
||||
break;
|
||||
}
|
||||
case "node.socket-default":
|
||||
case "node.socket-default-reset": {
|
||||
if (!node.known) return reject("node.unknown-readonly", "Unknown node defaults are read-only");
|
||||
const index = node.sockets.findIndex((s) => s.id === command.socketId);
|
||||
if (index < 0) return reject("socket.missing", "Socket does not exist");
|
||||
const key = node.sockets[index]!.key,
|
||||
schema = definition(node.typeId)?.sockets[key]?.value,
|
||||
value = schema && (command.type === "node.socket-default-reset" ? schema.default : command.value);
|
||||
if (!schema || !matchesFxNodeValueSchema(schema, value))
|
||||
return reject("socket.default-invalid", "Socket has no editable value schema or value is invalid");
|
||||
if (same(node.sockets[index]!.defaultValue, value)) return null;
|
||||
after = {
|
||||
...node,
|
||||
sockets: node.sockets.map((s, i) => (i === index ? deepFreeze({ ...s, defaultValue: cloneJson(value) }) : s)),
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
return [{ kind: "node.set", id: node.id, before: node, after: deepFreeze(after) }];
|
||||
};
|
||||
const transition = (state: BoundEngineState<C>, request: CommandRequest<C>): BoundTransitionResult<C> => {
|
||||
if (request.expectedVersion !== state.version)
|
||||
return { status: "rejected", state, error: reject("version.stale", "Expected version does not match") };
|
||||
if (state.version >= Number.MAX_SAFE_INTEGER)
|
||||
return { status: "rejected", state, error: reject("version.overflow", "Version exhausted") };
|
||||
const cause: BoundMutationEnvelope<C>["cause"] =
|
||||
request.command.type === "undo" ? "undo" : request.command.type === "redo" ? "redo" : request.source;
|
||||
let mutations: readonly Mutation<C>[],
|
||||
undo = state.undo,
|
||||
redo = state.redo;
|
||||
if (cause === "undo" || cause === "redo") {
|
||||
const entry = (cause === "undo" ? state.undo : state.redo).at(-1);
|
||||
if (!entry) return { status: "noop", state };
|
||||
mutations = cause === "undo" ? entry.inverse : entry.forward;
|
||||
undo = cause === "undo" ? state.undo.slice(0, -1) : [...state.undo, entry];
|
||||
redo = cause === "redo" ? state.redo.slice(0, -1) : [...state.redo, entry];
|
||||
} else {
|
||||
const r = plan(state.document, request.command);
|
||||
if (r === null) return { status: "noop", state };
|
||||
if (!Array.isArray(r)) return { status: "rejected", state, error: r as CommandError };
|
||||
mutations = r;
|
||||
}
|
||||
const document = reduceMutations(state.document, mutations) as GraphDocument<C>;
|
||||
const issue = docs.validateDocument(document)[0];
|
||||
if (issue)
|
||||
return { status: "rejected", state, error: { code: issue.code, message: issue.message, path: issue.path } };
|
||||
const closed = persisted(document);
|
||||
if (!closed.ok) {
|
||||
const persistenceIssue = closed.issues[0];
|
||||
return {
|
||||
status: "rejected",
|
||||
state,
|
||||
error: {
|
||||
code: persistenceIssue?.code ?? "persistence.invalid",
|
||||
message: persistenceIssue?.message ?? "Candidate state is not persistable",
|
||||
...(persistenceIssue?.path === undefined ? {} : { path: persistenceIssue.path }),
|
||||
},
|
||||
};
|
||||
}
|
||||
const persistedDocument = canonicalJsonEqual(document, closed.value) ? document : closed.value;
|
||||
if (cause === "api" || cause === "gesture") {
|
||||
const entry = deepFreeze({ forward: mutations, inverse: invert(mutations) });
|
||||
undo = state.historyLimit === 0 ? [] : [...state.undo, entry].slice(-state.historyLimit);
|
||||
redo = [];
|
||||
}
|
||||
const next = deepFreeze({ ...state, version: state.version + 1, document: persistedDocument, undo, redo }),
|
||||
mutationEnvelope = deepFreeze({
|
||||
baseVersion: state.version,
|
||||
version: next.version,
|
||||
commandId: request.commandId,
|
||||
cause,
|
||||
mutations,
|
||||
});
|
||||
return {
|
||||
status: "committed",
|
||||
state: next,
|
||||
mutationEnvelope,
|
||||
snapshotEnvelope: deepFreeze({ version: next.version, snapshot: snapshot(next) }),
|
||||
};
|
||||
};
|
||||
const replaceState = (
|
||||
state: BoundEngineState<C>,
|
||||
request: BoundStateReplacementRequest<C>,
|
||||
): BoundTransitionResult<C> => {
|
||||
if (request.expectedVersion !== state.version)
|
||||
return { status: "rejected", state, error: reject("version.stale", "Expected version does not match") };
|
||||
const decoded = docs.decodeGraphState(request.target);
|
||||
if (!decoded.ok) {
|
||||
const issue = decoded.issues[0];
|
||||
return {
|
||||
status: "rejected",
|
||||
state,
|
||||
error: {
|
||||
code: issue?.code ?? "state.shape",
|
||||
message: issue?.message ?? "Invalid GraphState",
|
||||
...(issue?.path === undefined ? {} : { path: issue.path }),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (canonicalJsonEqual(state.document, decoded.value)) return { status: "noop", state };
|
||||
if (state.version >= Number.MAX_SAFE_INTEGER)
|
||||
return { status: "rejected", state, error: reject("version.overflow", "Version exhausted") };
|
||||
const mutations = deepFreeze([
|
||||
{ kind: "document.replaced" as const, before: state.document, after: decoded.value },
|
||||
]),
|
||||
entry = deepFreeze({ forward: mutations, inverse: invert(mutations) }),
|
||||
undo = state.historyLimit === 0 ? [] : [...state.undo, entry].slice(-state.historyLimit),
|
||||
next = deepFreeze({ ...state, version: state.version + 1, document: decoded.value, undo, redo: [] }),
|
||||
mutationEnvelope = deepFreeze({
|
||||
baseVersion: state.version,
|
||||
version: next.version,
|
||||
commandId: request.commandId,
|
||||
cause: "api" as const,
|
||||
mutations,
|
||||
});
|
||||
return {
|
||||
status: "committed",
|
||||
state: next,
|
||||
mutationEnvelope,
|
||||
snapshotEnvelope: deepFreeze({ version: next.version, snapshot: snapshot(next) }),
|
||||
};
|
||||
};
|
||||
const load = (
|
||||
state: BoundEngineState<C>,
|
||||
value: unknown,
|
||||
expectedVersion = state.version,
|
||||
id = "load" as CommandId,
|
||||
): BoundLoadResult<C> => {
|
||||
if (expectedVersion !== state.version)
|
||||
return {
|
||||
ok: false,
|
||||
state,
|
||||
issues: [{ code: "version.stale", path: "/", message: "Expected version does not match" }],
|
||||
};
|
||||
const decoded = docs.decodeGraphDocument(value);
|
||||
if (!decoded.ok) return { ok: false, state, issues: decoded.issues };
|
||||
if (state.version >= Number.MAX_SAFE_INTEGER)
|
||||
return { ok: false, state, issues: [{ code: "version.overflow", path: "/", message: "Version exhausted" }] };
|
||||
const next = deepFreeze({ ...state, version: state.version + 1, document: decoded.value, undo: [], redo: [] }),
|
||||
mutationEnvelope = deepFreeze({
|
||||
baseVersion: state.version,
|
||||
version: next.version,
|
||||
commandId: id,
|
||||
cause: "load" as const,
|
||||
mutations: [{ kind: "document.replaced" as const, before: state.document, after: decoded.value }],
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
state: next,
|
||||
mutationEnvelope,
|
||||
snapshotEnvelope: deepFreeze({ version: next.version, snapshot: snapshot(next) }),
|
||||
};
|
||||
};
|
||||
const replaySaveData = (
|
||||
state: BoundEngineState<C>,
|
||||
value: unknown,
|
||||
expectedVersion = state.version,
|
||||
id = "load" as CommandId,
|
||||
): BoundReplayResult<C> => {
|
||||
if (expectedVersion !== state.version)
|
||||
return {
|
||||
ok: false,
|
||||
state,
|
||||
issues: [{ code: "version.stale", path: "/", message: "Expected version does not match" }],
|
||||
};
|
||||
const decoded = decodeFxNodeSaveData(value, compiled, docs.decodeGraphDocument);
|
||||
if (!decoded.ok) return { ok: false, state, issues: decoded.issues };
|
||||
let staged: BoundEngineState<C> = deepFreeze({
|
||||
version: 0,
|
||||
document: decoded.baseline,
|
||||
undo: [],
|
||||
redo: [],
|
||||
historyLimit: state.historyLimit,
|
||||
});
|
||||
for (let index = 0; index < decoded.value.commands.length; index++) {
|
||||
const result = transition(staged, {
|
||||
commandId: id,
|
||||
expectedVersion: staged.version,
|
||||
source: "api",
|
||||
command: decoded.value.commands[index]!,
|
||||
});
|
||||
if (result.status !== "committed")
|
||||
return {
|
||||
ok: false,
|
||||
state,
|
||||
issues: [
|
||||
result.status === "noop"
|
||||
? { code: "replay.noop", path: `/commands/${index}`, message: "Replay command did not commit" }
|
||||
: {
|
||||
code: result.error.code,
|
||||
path: `/commands/${index}${result.error.path ?? ""}`,
|
||||
message: result.error.message,
|
||||
},
|
||||
],
|
||||
};
|
||||
staged = result.state;
|
||||
}
|
||||
const graphChanged = !canonicalJsonEqual(state.document, staged.document);
|
||||
if (graphChanged && state.version >= Number.MAX_SAFE_INTEGER)
|
||||
return { ok: false, state, issues: [{ code: "version.overflow", path: "/", message: "Version exhausted" }] };
|
||||
const next: BoundEngineState<C> = deepFreeze({
|
||||
version: graphChanged ? state.version + 1 : state.version,
|
||||
document: graphChanged ? staged.document : state.document,
|
||||
undo: staged.undo,
|
||||
redo: [],
|
||||
historyLimit: state.historyLimit,
|
||||
});
|
||||
if (!graphChanged) return { ok: true, status: "noop", state: next, saveData: decoded.value };
|
||||
const mutationEnvelope: BoundMutationEnvelope<C> = deepFreeze({
|
||||
baseVersion: state.version,
|
||||
version: next.version,
|
||||
commandId: id,
|
||||
cause: "load",
|
||||
mutations: [{ kind: "document.replaced", before: state.document, after: next.document }],
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
status: "committed",
|
||||
state: next,
|
||||
mutationEnvelope,
|
||||
snapshotEnvelope: deepFreeze({ version: next.version, snapshot: snapshot(next) }),
|
||||
saveData: decoded.value,
|
||||
};
|
||||
};
|
||||
/** Trusted worker-only journal check under this already compiled authority. */
|
||||
const validateReplayJournal = (
|
||||
baseline: GraphLayoutV2,
|
||||
commands: readonly FxNodeReplayCommand<C>[],
|
||||
historyLimit = 100,
|
||||
): boolean => {
|
||||
const decoded = docs.decodeGraphDocument(baseline);
|
||||
if (!decoded.ok) return false;
|
||||
let staged: BoundEngineState<C> = deepFreeze({
|
||||
version: 0,
|
||||
document: decoded.value,
|
||||
undo: [],
|
||||
redo: [],
|
||||
historyLimit,
|
||||
});
|
||||
for (const command of commands) {
|
||||
if (!validFxNodeReplayCommand(command, compiled.nodes)) return false;
|
||||
const result = transition(staged, {
|
||||
commandId: "journal" as CommandId,
|
||||
expectedVersion: staged.version,
|
||||
source: "api",
|
||||
command,
|
||||
});
|
||||
if (result.status !== "committed") return false;
|
||||
staged = result.state;
|
||||
}
|
||||
return persisted(staged.document).ok;
|
||||
};
|
||||
return Object.freeze({
|
||||
createEngine,
|
||||
transition,
|
||||
replaceState,
|
||||
load,
|
||||
replaySaveData,
|
||||
validateReplayJournal,
|
||||
getState: snapshot,
|
||||
});
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { deepFreeze } from "../core/json.js";
|
||||
import { validateFxNodeComposition, type FxNodeCompositionIssue } from "./validate.js";
|
||||
import { effectiveImageResourcePolicy } from "./resource-policy.js";
|
||||
import type { CompiledFxNodeComposition, FxNodeCompositionData, FxNodeReadonlyMap, FxNodeTheme } from "./types.js";
|
||||
import type { ReferenceCheck } from "./references.js";
|
||||
|
||||
export class FxNodeCompositionError extends TypeError {
|
||||
constructor(readonly issues: readonly FxNodeCompositionIssue[]) {
|
||||
super(`Invalid fxnode composition (${issues.length} issue${issues.length === 1 ? "" : "s"})`);
|
||||
this.name = "FxNodeCompositionError";
|
||||
}
|
||||
}
|
||||
export const DEFAULT_FXNODE_THEME = {
|
||||
background: "#000000",
|
||||
grid: "#33363c",
|
||||
frame: "#30343a80",
|
||||
frameHeader: "#59616c",
|
||||
body: "#35383e",
|
||||
control: "#24272b",
|
||||
controlFill: "#4775b8",
|
||||
controlEditing: "#181a1d",
|
||||
textSelection: "#4775b8",
|
||||
outline: "#111216",
|
||||
text: "#e5e5e5",
|
||||
muted: "#a5a8ad",
|
||||
shadow: "#00000088",
|
||||
nodeSelected: "#ed5700",
|
||||
nodeActive: "#ffffff",
|
||||
unknownHeader: "#555b64",
|
||||
unknownSocket: "#999999",
|
||||
linkMuted: "#d94b4b",
|
||||
knifeMuted: "#e85b5b",
|
||||
emphasis: "#ffffff",
|
||||
focus: "#f5a623",
|
||||
editOutline: "#666a70",
|
||||
resize: "#8b8e95",
|
||||
muteOverlay: "#14141459",
|
||||
boxSelectionFill: "#f5a6231f",
|
||||
checkerLight: "#aaaaaa",
|
||||
checkerDark: "#777777",
|
||||
widgetBorder: "#111216",
|
||||
rampBorder: "#111111",
|
||||
resourceBackground: "#202228",
|
||||
} as const satisfies FxNodeTheme;
|
||||
function facade<K, V>(entries: readonly (readonly [K, V])[]): FxNodeReadonlyMap<K, V> {
|
||||
const map = new Map<K, V>(entries);
|
||||
return Object.freeze({
|
||||
get size() {
|
||||
return map.size;
|
||||
},
|
||||
get: (key: K) => map.get(key),
|
||||
has: (key: K) => map.has(key),
|
||||
keys: () => map.keys(),
|
||||
values: () => map.values(),
|
||||
entries: () => map.entries(),
|
||||
forEach: (callback: (value: V, key: K) => void) => map.forEach((value, key) => callback(value, key)),
|
||||
[Symbol.iterator]: () => map[Symbol.iterator](),
|
||||
});
|
||||
}
|
||||
export function compileFxNodeComposition<const C extends FxNodeCompositionData>(
|
||||
composition: C & ReferenceCheck<C>,
|
||||
): CompiledFxNodeComposition<C> {
|
||||
const checked = validateFxNodeComposition(composition);
|
||||
if (!checked.ok) throw new FxNodeCompositionError(checked.issues);
|
||||
let source: C;
|
||||
try {
|
||||
source = deepFreeze(structuredClone(checked.value)) as unknown as C;
|
||||
} catch {
|
||||
throw new FxNodeCompositionError(
|
||||
Object.freeze([Object.freeze({ code: "data.clone", path: "", message: "composition could not be cloned" })]),
|
||||
);
|
||||
}
|
||||
const raw = source as C & { id: string; version: number; theme: unknown };
|
||||
const withIds = (record: Record<string, unknown>) =>
|
||||
Object.entries(record).map(([id, value]) => [id, deepFreeze({ ...(value as object), id })] as const);
|
||||
const nodeEntries = Object.entries(raw.nodes as Record<string, unknown>).map(
|
||||
([typeId, value]) => [typeId, deepFreeze({ ...(value as object), typeId })] as const,
|
||||
);
|
||||
const resourceEntries = Object.entries(
|
||||
raw.resources as Record<string, import("./types.js").FxNodeImageResourceDefinition>,
|
||||
).map(([id, value]) => [id, deepFreeze({ ...effectiveImageResourcePolicy(value), id })] as const);
|
||||
return Object.freeze({
|
||||
source,
|
||||
id: raw.id,
|
||||
version: raw.version,
|
||||
compatibility: source.compatibility,
|
||||
theme: raw.theme,
|
||||
nodes: facade(nodeEntries),
|
||||
socketTypes: facade(withIds(raw.socketTypes as Record<string, unknown>)),
|
||||
styles: facade(withIds(raw.nodeStyles as Record<string, unknown>)),
|
||||
resources: facade(resourceEntries),
|
||||
}) as unknown as CompiledFxNodeComposition<C>;
|
||||
}
|
||||
export function createInitialFxNodeComposition(
|
||||
applicationId: string,
|
||||
applicationVersion: number,
|
||||
resources: FxNodeCompositionData["resources"],
|
||||
): CompiledFxNodeComposition<FxNodeCompositionData> {
|
||||
return compileFxNodeComposition({
|
||||
schemaVersion: 2,
|
||||
id: applicationId,
|
||||
version: applicationVersion,
|
||||
resources,
|
||||
nodeStyles: {},
|
||||
compatibility: { wildcardInputTypes: [] as readonly never[] },
|
||||
socketTypes: {},
|
||||
nodes: {},
|
||||
theme: DEFAULT_FXNODE_THEME,
|
||||
}) as unknown as CompiledFxNodeComposition<FxNodeCompositionData>;
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import type { NodeReferenceCheck } from "./references.js";
|
||||
import type {
|
||||
FxNodeCompositionData,
|
||||
FxNodeDefinition,
|
||||
FxNodeSocketTypeDefinition,
|
||||
FxNodeStyleDefinition,
|
||||
FxNodeTheme,
|
||||
} from "./types.js";
|
||||
|
||||
/** @inline */
|
||||
type ReplaceProperty<T, K extends keyof T, V> = { readonly [P in keyof T]: P extends K ? V : T[P] };
|
||||
/** @inline */
|
||||
type PutProperty<T, K extends PropertyKey, V> = {
|
||||
readonly [P in keyof T | K]: P extends K ? V : P extends keyof T ? T[P] : never;
|
||||
};
|
||||
type EntryValue<E, K extends PropertyKey> = E extends readonly [K, infer V] ? V : never;
|
||||
type PutEntries<T, E extends readonly [string, unknown]> = {
|
||||
readonly [P in keyof T | E[0]]: P extends E[0] ? EntryValue<E, P> : P extends keyof T ? T[P] : never;
|
||||
};
|
||||
export type NodeCompositionEntry = readonly [id: string, definition: FxNodeDefinition];
|
||||
export type SocketCompositionEntry = readonly [id: string, definition: FxNodeSocketTypeDefinition];
|
||||
export type ComposedNode<C extends FxNodeCompositionData, I extends string, D extends FxNodeDefinition> = {
|
||||
readonly [P in keyof C]: P extends "nodes"
|
||||
? { readonly [N in keyof C["nodes"] | I]: N extends I ? D : N extends keyof C["nodes"] ? C["nodes"][N] : never }
|
||||
: C[P];
|
||||
};
|
||||
export type ComposedNodes<C extends FxNodeCompositionData, E extends NodeCompositionEntry> = ReplaceProperty<
|
||||
C,
|
||||
"nodes",
|
||||
PutEntries<C["nodes"], E>
|
||||
>;
|
||||
export type ComposedSocket<
|
||||
C extends Pick<FxNodeCompositionData, "socketTypes">,
|
||||
I extends string,
|
||||
D extends FxNodeSocketTypeDefinition,
|
||||
> = {
|
||||
readonly [P in keyof C]: P extends "socketTypes"
|
||||
? {
|
||||
readonly [S in keyof C["socketTypes"] | I]: S extends I
|
||||
? D
|
||||
: S extends keyof C["socketTypes"]
|
||||
? C["socketTypes"][S]
|
||||
: never;
|
||||
}
|
||||
: C[P];
|
||||
};
|
||||
export type ComposedSockets<
|
||||
C extends Pick<FxNodeCompositionData, "socketTypes">,
|
||||
E extends SocketCompositionEntry,
|
||||
> = ReplaceProperty<C, "socketTypes", PutEntries<C["socketTypes"], E>>;
|
||||
/** @inline */
|
||||
type ThemeTarget = Omit<FxNodeCompositionData, "theme"> & { readonly theme?: FxNodeTheme };
|
||||
export type Themed<C, T> = {
|
||||
readonly [P in keyof C | "theme"]: P extends "theme" ? T : P extends keyof C ? C[P] : never;
|
||||
};
|
||||
export type RemovedSocket<
|
||||
C extends Pick<FxNodeCompositionData, "socketTypes">,
|
||||
I extends Extract<keyof C["socketTypes"], string>,
|
||||
> = { readonly [P in keyof C]: P extends "socketTypes" ? Omit<C["socketTypes"], I> : C[P] };
|
||||
export type RemovedNode<C extends Pick<FxNodeCompositionData, "nodes">, I extends Extract<keyof C["nodes"], string>> = {
|
||||
readonly [P in keyof C]: P extends "nodes" ? Omit<C["nodes"], I> : C[P];
|
||||
};
|
||||
|
||||
export function setTheme<const C extends ThemeTarget, const T extends FxNodeTheme>(
|
||||
composition: C,
|
||||
theme: T,
|
||||
): Themed<C, T>;
|
||||
export function setTheme(composition: ThemeTarget, theme: FxNodeTheme): FxNodeCompositionData {
|
||||
return { ...composition, theme };
|
||||
}
|
||||
export type HeaderStyled<C, S> = { readonly [P in keyof C]: P extends "nodeStyles" ? S : C[P] };
|
||||
export function setHeaderStyles<
|
||||
const C extends Pick<FxNodeCompositionData, "nodeStyles">,
|
||||
const S extends Readonly<Record<string, FxNodeStyleDefinition>>,
|
||||
>(composition: C, styles: S): HeaderStyled<C, S>;
|
||||
export function setHeaderStyles(
|
||||
composition: Pick<FxNodeCompositionData, "nodeStyles">,
|
||||
styles: Readonly<Record<string, FxNodeStyleDefinition>>,
|
||||
): Pick<FxNodeCompositionData, "nodeStyles"> {
|
||||
return { ...composition, nodeStyles: styles };
|
||||
}
|
||||
export function composeSocket<
|
||||
const C extends Pick<FxNodeCompositionData, "socketTypes">,
|
||||
const I extends string,
|
||||
const D extends FxNodeSocketTypeDefinition<Extract<keyof NoInfer<C>["socketTypes"], string> | I>,
|
||||
>(composition: C, id: I, definition: D): ComposedSocket<C, I, D>;
|
||||
export function composeSocket(
|
||||
composition: Pick<FxNodeCompositionData, "socketTypes">,
|
||||
id: string,
|
||||
definition: FxNodeSocketTypeDefinition,
|
||||
): Pick<FxNodeCompositionData, "socketTypes"> {
|
||||
return { ...composition, socketTypes: { ...composition.socketTypes, [id]: definition } };
|
||||
}
|
||||
export function removeSocket<
|
||||
const C extends Pick<FxNodeCompositionData, "socketTypes">,
|
||||
const I extends Extract<keyof C["socketTypes"], string>,
|
||||
>(composition: C, id: I): RemovedSocket<C, I>;
|
||||
export function removeSocket(
|
||||
composition: Pick<FxNodeCompositionData, "socketTypes">,
|
||||
id: string,
|
||||
): Pick<FxNodeCompositionData, "socketTypes"> {
|
||||
const { [id]: _, ...socketTypes } = composition.socketTypes;
|
||||
return { ...composition, socketTypes };
|
||||
}
|
||||
export function composeNode<
|
||||
const D extends FxNodeDefinition,
|
||||
const C extends FxNodeCompositionData,
|
||||
const I extends string,
|
||||
>(composition: C, id: I, definition: D & NodeReferenceCheck<NoInfer<C>, D>): ComposedNode<C, I, D>;
|
||||
export function composeNode(
|
||||
composition: FxNodeCompositionData,
|
||||
id: string,
|
||||
definition: FxNodeDefinition,
|
||||
): FxNodeCompositionData {
|
||||
return { ...composition, nodes: { ...composition.nodes, [id]: definition } };
|
||||
}
|
||||
export function removeNode<
|
||||
const C extends Pick<FxNodeCompositionData, "nodes">,
|
||||
const I extends Extract<keyof C["nodes"], string>,
|
||||
>(composition: C, id: I): RemovedNode<C, I>;
|
||||
export function removeNode(
|
||||
composition: Pick<FxNodeCompositionData, "nodes">,
|
||||
id: string,
|
||||
): Pick<FxNodeCompositionData, "nodes"> {
|
||||
const { [id]: _, ...nodes } = composition.nodes;
|
||||
return { ...composition, nodes };
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/** Static composition authoring, validation, and compilation. */
|
||||
export * from "./types.js";
|
||||
export { setTheme, setHeaderStyles, composeSocket, removeSocket, composeNode, removeNode } from "./compose.js";
|
||||
export type { ComposedNode, ComposedSocket, Themed, RemovedSocket, RemovedNode, HeaderStyled } from "./compose.js";
|
||||
export { FXNODE_COMPOSITION_LIMITS, validateFxNodeComposition } from "./validate.js";
|
||||
export type { FxNodeCompositionIssue, FxNodeCompositionValidation } from "./validate.js";
|
||||
export {
|
||||
compileFxNodeComposition,
|
||||
createInitialFxNodeComposition,
|
||||
DEFAULT_FXNODE_THEME,
|
||||
FxNodeCompositionError,
|
||||
} from "./compile.js";
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import type { FxNodeComposition, FxNodeCompositionData, FxNodeMigrationStep, FxNodeUiRow } from "./types.js";
|
||||
|
||||
type K<T> = Extract<keyof T, string>;
|
||||
type Visibility<P> =
|
||||
| { parameter: P; equals: string | number | boolean }
|
||||
| { parameter: P; in: readonly (string | number | boolean)[] }
|
||||
| { all: readonly Visibility<P>[] }
|
||||
| { any: readonly Visibility<P>[] };
|
||||
type Vis<R, P> = R extends { visibleWhen: infer V } ? Omit<R, "visibleWhen"> & { visibleWhen: V & Visibility<P> } : R;
|
||||
type Row<R, P, S, Resources> = R extends { kind: "parameter" | "widget" | "resource"; parameter: unknown }
|
||||
? Vis<
|
||||
Omit<R, "parameter" | "resource"> & { parameter: P } & (R extends { kind: "resource" }
|
||||
? { resource: Resources }
|
||||
: unknown),
|
||||
P
|
||||
>
|
||||
: R extends { kind: "socket"; socket: unknown }
|
||||
? Vis<Omit<R, "socket"> & { socket: S }, P>
|
||||
: R extends { kind: "hidden"; target: "parameter" }
|
||||
? Omit<R, "parameter"> & { parameter: P }
|
||||
: R extends { kind: "hidden"; target: "socket" }
|
||||
? Omit<R, "socket"> & { socket: S }
|
||||
: R extends { widget: "grading-wheels"; bindings: infer B extends readonly unknown[] }
|
||||
? Vis<Omit<R, "bindings"> & { bindings: { [I in keyof B]: B[I] & { scalar: P; color: P } } }, P>
|
||||
: Vis<R, P>;
|
||||
/** @inline */
|
||||
type Migration<M, P extends string, S extends string> = M extends { steps: infer Steps extends readonly unknown[] }
|
||||
? M & {
|
||||
readonly fromVersion: number;
|
||||
readonly toVersion: number;
|
||||
readonly steps: readonly (Steps[number] & FxNodeMigrationStep<P, S>)[];
|
||||
}
|
||||
: never;
|
||||
export type NodeReferenceCheck<
|
||||
C extends FxNodeCompositionData,
|
||||
D extends {
|
||||
parameters: object;
|
||||
sockets: object;
|
||||
ui: readonly unknown[];
|
||||
muteBypass: readonly unknown[];
|
||||
migrations: readonly { steps: readonly unknown[] }[];
|
||||
},
|
||||
> = D &
|
||||
Omit<FxNodeComposition["nodes"][string], "style" | "parameters" | "sockets" | "ui" | "muteBypass" | "migrations"> & {
|
||||
readonly defaultSize?: never;
|
||||
readonly style: K<C["nodeStyles"]>;
|
||||
readonly parameters: {
|
||||
readonly [P in keyof D["parameters"]]: D["parameters"][P] &
|
||||
FxNodeComposition["nodes"][string]["parameters"][string];
|
||||
};
|
||||
readonly sockets: {
|
||||
readonly [S in keyof D["sockets"]]: D["sockets"][S] &
|
||||
FxNodeComposition["nodes"][string]["sockets"][string] & { readonly type: K<C["socketTypes"]> };
|
||||
};
|
||||
readonly ui: readonly (D["ui"][number] &
|
||||
FxNodeUiRow<K<D["parameters"]>, K<D["sockets"]>, K<C["resources"]>> &
|
||||
Row<D["ui"][number], K<D["parameters"]>, K<D["sockets"]>, K<C["resources"]>>)[];
|
||||
readonly muteBypass: readonly (readonly [K<D["sockets"]>, K<D["sockets"]>])[];
|
||||
readonly migrations: readonly Migration<D["migrations"][number], K<D["parameters"]>, K<D["sockets"]>>[];
|
||||
};
|
||||
export type ReferenceCheck<C extends FxNodeCompositionData> = {
|
||||
readonly schemaVersion: 2;
|
||||
readonly id: string;
|
||||
readonly version: number;
|
||||
readonly compatibility: { readonly wildcardInputTypes: readonly K<C["socketTypes"]>[] };
|
||||
readonly theme: FxNodeComposition["theme"];
|
||||
readonly socketTypes: {
|
||||
readonly [T in keyof C["socketTypes"]]: C["socketTypes"][T] &
|
||||
FxNodeComposition["socketTypes"][string] & { readonly acceptsFrom: readonly K<C["socketTypes"]>[] };
|
||||
};
|
||||
readonly nodeStyles: {
|
||||
readonly [T in keyof C["nodeStyles"]]: C["nodeStyles"][T] & FxNodeComposition["nodeStyles"][string];
|
||||
};
|
||||
readonly resources: {
|
||||
readonly [T in keyof C["resources"]]: C["resources"][T] & FxNodeComposition["resources"][string];
|
||||
};
|
||||
readonly nodes: {
|
||||
readonly [N in keyof C["nodes"]]: C["nodes"][N] extends infer D extends {
|
||||
parameters: object;
|
||||
sockets: object;
|
||||
ui: readonly unknown[];
|
||||
muteBypass: readonly unknown[];
|
||||
migrations: readonly { steps: readonly unknown[] }[];
|
||||
}
|
||||
? NodeReferenceCheck<C, D>
|
||||
: never;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { FxNodeImageResourceDefinition } from "./types.js";
|
||||
|
||||
export const FXNODE_IMAGE_HARD_LIMITS = Object.freeze({
|
||||
maxBytes: 33_554_432,
|
||||
maxWidth: 8192,
|
||||
maxHeight: 8192,
|
||||
maxPixels: 16_777_216,
|
||||
});
|
||||
|
||||
export function effectiveImageResourcePolicy(policy: FxNodeImageResourceDefinition): FxNodeImageResourceDefinition {
|
||||
const maxWidth = Math.min(policy.maxWidth, FXNODE_IMAGE_HARD_LIMITS.maxWidth),
|
||||
maxHeight = Math.min(policy.maxHeight, FXNODE_IMAGE_HARD_LIMITS.maxHeight);
|
||||
return Object.freeze({
|
||||
...policy,
|
||||
maxBytes: Math.min(policy.maxBytes, FXNODE_IMAGE_HARD_LIMITS.maxBytes),
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
maxPixels: Math.min(policy.maxPixels, FXNODE_IMAGE_HARD_LIMITS.maxPixels, maxWidth * maxHeight),
|
||||
});
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import { canonicalJsonEqual, deepFreeze, isRecord } from "../core/json.js";
|
||||
import type { GraphLayoutV2 } from "../core/types.js";
|
||||
import { bindDocument, type ValidationIssue as BoundValidationIssue } from "./bound-document.js";
|
||||
import { compileFxNodeComposition } from "./compile.js";
|
||||
import type { FxNodeCompositionData, FxNodeDefinition, FxNodeValueSchema } from "./types.js";
|
||||
|
||||
const token = (s: string) => s.replaceAll("~", "~0").replaceAll("/", "~1");
|
||||
const equal = (a: unknown, b: unknown) => canonicalJsonEqual(a, b);
|
||||
const hardSchema = (s: FxNodeValueSchema) =>
|
||||
Object.fromEntries(Object.entries(s).filter(([k]) => !["softMin", "softMax", "step", "precision"].includes(k)));
|
||||
const semanticSocket = (s: FxNodeDefinition["sockets"][string]) => ({
|
||||
title: s.title,
|
||||
direction: s.direction,
|
||||
type: s.type,
|
||||
maxIncomingLinks: s.maxIncomingLinks,
|
||||
visible: s.visible,
|
||||
value: s.value === null ? null : hardSchema(s.value),
|
||||
});
|
||||
const semanticNode = (n: FxNodeDefinition) => ({
|
||||
version: n.version,
|
||||
behavior: n.behavior,
|
||||
parameters: Object.fromEntries(Object.entries(n.parameters).map(([k, v]) => [k, hardSchema(v)])),
|
||||
sockets: Object.fromEntries(Object.entries(n.sockets).map(([k, v]) => [k, semanticSocket(v)])),
|
||||
muteBypass: n.muteBypass,
|
||||
migrations: n.migrations,
|
||||
});
|
||||
const push = (issues: BoundValidationIssue[], value: BoundValidationIssue) => {
|
||||
if (issues.length < 99) issues.push(value);
|
||||
};
|
||||
const missing = (
|
||||
saved: Record<string, unknown>,
|
||||
current: Record<string, unknown>,
|
||||
base: string,
|
||||
issues: BoundValidationIssue[],
|
||||
) => {
|
||||
for (const id of Object.keys(saved))
|
||||
if (!Object.hasOwn(current, id))
|
||||
push(issues, {
|
||||
code: "composition.definition-missing",
|
||||
path: `/composition/${base}/${token(id)}`,
|
||||
message: `Current composition is missing saved ${base} definition "${id}"`,
|
||||
});
|
||||
};
|
||||
|
||||
/** Checks that current authority conservatively preserves every replay-relevant saved meaning. */
|
||||
export function saveCompositionCompatibility(
|
||||
saved: FxNodeCompositionData,
|
||||
current: FxNodeCompositionData,
|
||||
baseline: GraphLayoutV2,
|
||||
): readonly BoundValidationIssue[] {
|
||||
const leaves: BoundValidationIssue[] = [];
|
||||
if (saved.id !== current.id)
|
||||
push(leaves, {
|
||||
code: "composition.id",
|
||||
path: "/composition/id",
|
||||
message: `Saved composition id "${saved.id}" does not match current id "${current.id}"`,
|
||||
});
|
||||
for (const key of ["socketTypes", "nodeStyles", "resources", "nodes"] as const)
|
||||
missing(saved[key], current[key], key, leaves);
|
||||
if (!equal(saved.compatibility.wildcardInputTypes, current.compatibility.wildcardInputTypes))
|
||||
push(leaves, {
|
||||
code: "composition.wildcard",
|
||||
path: "/composition/compatibility/wildcardInputTypes",
|
||||
message: "Wildcard input types changed",
|
||||
});
|
||||
for (const id of Object.keys(saved.socketTypes)) {
|
||||
const a = saved.socketTypes[id],
|
||||
b = current.socketTypes[id];
|
||||
if (a && b && !equal(a.acceptsFrom, b.acceptsFrom))
|
||||
push(leaves, {
|
||||
code: "composition.socket-semantic",
|
||||
path: `/composition/socketTypes/${token(id)}/acceptsFrom`,
|
||||
message: `Socket type "${id}" compatibility changed`,
|
||||
});
|
||||
}
|
||||
let aDocs: ReturnType<typeof bindDocument> | undefined, bDocs: ReturnType<typeof bindDocument> | undefined;
|
||||
try {
|
||||
aDocs = bindDocument(compileFxNodeComposition(saved));
|
||||
bDocs = bindDocument(compileFxNodeComposition(current));
|
||||
} catch {
|
||||
/* saved/current are normally compiled/validated by callers */
|
||||
}
|
||||
for (const id of Object.keys(saved.nodes)) {
|
||||
const a = saved.nodes[id],
|
||||
b = current.nodes[id];
|
||||
if (!a || !b) continue;
|
||||
const path = `/composition/nodes/${token(id)}`;
|
||||
if (!equal(semanticNode(a), semanticNode(b)))
|
||||
push(leaves, { code: "composition.node-semantic", path, message: `Node type "${id}" replay semantics changed` });
|
||||
else if (
|
||||
aDocs &&
|
||||
bDocs &&
|
||||
!equal(aDocs.materializeNode("compat-probe", id), bDocs.materializeNode("compat-probe", id))
|
||||
)
|
||||
push(leaves, {
|
||||
code: "composition.node-materialization",
|
||||
path,
|
||||
message: `Node type "${id}" materializes differently`,
|
||||
});
|
||||
}
|
||||
if (Array.isArray(baseline.nodes))
|
||||
for (let i = 0; i < baseline.nodes.length; i++) {
|
||||
const node = baseline.nodes[i];
|
||||
if (
|
||||
isRecord(node) &&
|
||||
typeof node.typeId === "string" &&
|
||||
!Object.hasOwn(saved.nodes, node.typeId) &&
|
||||
Object.hasOwn(current.nodes, node.typeId)
|
||||
)
|
||||
push(leaves, {
|
||||
code: "composition.opaque-promotion",
|
||||
path: `/baseline/nodes/${i}/typeId`,
|
||||
message: `Opaque baseline node type "${node.typeId}" would be promoted by the current composition`,
|
||||
});
|
||||
}
|
||||
if (!leaves.length) return deepFreeze([]);
|
||||
return deepFreeze([
|
||||
{
|
||||
code: "composition.incompatible",
|
||||
path: "/composition",
|
||||
message: "Current composition is not a conservative semantic superset of the saved composition",
|
||||
},
|
||||
...leaves,
|
||||
]);
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
export interface FxNodeCompositionData {
|
||||
readonly schemaVersion: 2;
|
||||
readonly id: string;
|
||||
readonly version: number;
|
||||
readonly compatibility: { readonly wildcardInputTypes: readonly string[] };
|
||||
readonly socketTypes: Readonly<Record<string, FxNodeSocketTypeDefinition>>;
|
||||
readonly theme: FxNodeTheme;
|
||||
readonly nodeStyles: Readonly<Record<string, FxNodeStyleDefinition>>;
|
||||
readonly resources: Readonly<Record<string, FxNodeResourceDefinition>>;
|
||||
readonly nodes: Readonly<Record<string, FxNodeDefinition>>;
|
||||
}
|
||||
/** An authoring seed which may omit the theme until passed through setTheme. */
|
||||
export type FxNodeCompositionSeed = Omit<FxNodeCompositionData, "theme"> & { readonly theme?: never };
|
||||
|
||||
export type NodeTypeId<C extends FxNodeCompositionData = FxNodeCompositionData> =
|
||||
string extends Extract<keyof C["nodes"], string> ? string : Extract<keyof C["nodes"], string>;
|
||||
export type SocketTypeId<C extends FxNodeCompositionData = FxNodeCompositionData> =
|
||||
string extends Extract<keyof C["socketTypes"], string> ? string : Extract<keyof C["socketTypes"], string>;
|
||||
export type NodeStyleId<C extends FxNodeCompositionData = FxNodeCompositionData> =
|
||||
string extends Extract<keyof C["nodeStyles"], string> ? string : Extract<keyof C["nodeStyles"], string>;
|
||||
export type ResourceId<C extends FxNodeCompositionData = FxNodeCompositionData> =
|
||||
string extends Extract<keyof C["resources"], string> ? string : Extract<keyof C["resources"], string>;
|
||||
export type NodeParameterId<C extends FxNodeCompositionData, N extends NodeTypeId<C>> = N extends keyof C["nodes"]
|
||||
? C["nodes"][N] extends { readonly parameters: infer P }
|
||||
? Extract<keyof P, string>
|
||||
: string
|
||||
: string;
|
||||
export type NodeSocketId<C extends FxNodeCompositionData, N extends NodeTypeId<C>> = N extends keyof C["nodes"]
|
||||
? C["nodes"][N] extends { readonly sockets: infer S }
|
||||
? Extract<keyof S, string>
|
||||
: string
|
||||
: string;
|
||||
export type AnyNodeParameterId<C extends FxNodeCompositionData = FxNodeCompositionData> =
|
||||
NodeTypeId<C> extends infer N ? (N extends NodeTypeId<C> ? NodeParameterId<C, N> : never) : never;
|
||||
export type AnyNodeSocketId<C extends FxNodeCompositionData = FxNodeCompositionData> =
|
||||
NodeTypeId<C> extends infer N ? (N extends NodeTypeId<C> ? NodeSocketId<C, N> : never) : never;
|
||||
|
||||
export type FxNodeHexColor = `#${string}`;
|
||||
export type FxNodeJsonValue =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| readonly FxNodeJsonValue[]
|
||||
| { readonly [key: string]: FxNodeJsonValue };
|
||||
export type FxNodeParameterValue =
|
||||
| { readonly kind: "number"; readonly value: number }
|
||||
| { readonly kind: "string"; readonly value: string }
|
||||
| { readonly kind: "boolean"; readonly value: boolean }
|
||||
| { readonly kind: "vector"; readonly value: readonly [number, number, number] }
|
||||
| { readonly kind: "color"; readonly value: readonly [number, number, number, number] }
|
||||
| { readonly kind: "json"; readonly value: FxNodeJsonValue };
|
||||
/** @inline */
|
||||
/** @inline */
|
||||
type Bounds = {
|
||||
readonly minimum?: number;
|
||||
readonly maximum?: number;
|
||||
readonly softMin?: number;
|
||||
readonly softMax?: number;
|
||||
readonly step?: number;
|
||||
};
|
||||
export type FxNodeValueSchema =
|
||||
| (Bounds & {
|
||||
readonly type: "number";
|
||||
readonly default: Extract<FxNodeParameterValue, { kind: "number" }>;
|
||||
readonly integer?: boolean;
|
||||
readonly precision?: number;
|
||||
})
|
||||
| {
|
||||
readonly type: "string";
|
||||
readonly default: Extract<FxNodeParameterValue, { kind: "string" }>;
|
||||
readonly enum?: readonly string[];
|
||||
}
|
||||
| { readonly type: "boolean"; readonly default: Extract<FxNodeParameterValue, { kind: "boolean" }> }
|
||||
| (Bounds & { readonly type: "vector"; readonly default: Extract<FxNodeParameterValue, { kind: "vector" }> })
|
||||
| (Bounds & { readonly type: "color"; readonly default: Extract<FxNodeParameterValue, { kind: "color" }> })
|
||||
| {
|
||||
readonly type: "json";
|
||||
readonly codec?: "color-ramp/v1";
|
||||
readonly default: Extract<FxNodeParameterValue, { kind: "json" }>;
|
||||
};
|
||||
|
||||
export type FxNodeVisibility<P extends string = string> =
|
||||
| { readonly parameter: P; readonly equals: string | number | boolean }
|
||||
| { readonly parameter: P; readonly in: readonly (string | number | boolean)[] }
|
||||
| { readonly all: readonly FxNodeVisibility<P>[] }
|
||||
| { readonly any: readonly FxNodeVisibility<P>[] };
|
||||
export interface FxNodeSocketTypeDefinition<S extends string = string> {
|
||||
readonly title: string;
|
||||
readonly color: FxNodeHexColor;
|
||||
readonly acceptsFrom: readonly S[];
|
||||
}
|
||||
export interface FxNodeStyleDefinition {
|
||||
readonly header: FxNodeHexColor;
|
||||
}
|
||||
export interface FxNodeImageResourceDefinition {
|
||||
readonly kind: "image";
|
||||
readonly title: string;
|
||||
readonly openTitle: string;
|
||||
readonly accept: readonly string[];
|
||||
readonly referencePrefix: string;
|
||||
readonly maxBytes: number;
|
||||
readonly maxWidth: number;
|
||||
readonly maxHeight: number;
|
||||
readonly maxPixels: number;
|
||||
}
|
||||
export type FxNodeResourceDefinition = FxNodeImageResourceDefinition;
|
||||
export interface FxNodeSocketDefinition<S extends string = string> {
|
||||
readonly title: string;
|
||||
readonly direction: "input" | "output";
|
||||
readonly type: S;
|
||||
readonly maxIncomingLinks: number;
|
||||
readonly visible: boolean;
|
||||
readonly value: FxNodeValueSchema | null;
|
||||
readonly showValue: boolean;
|
||||
}
|
||||
export interface FxNodeGradingBinding<P extends string = string> {
|
||||
readonly title: string;
|
||||
readonly scalar: P;
|
||||
readonly color: P;
|
||||
}
|
||||
export type FxNodeUiRow<P extends string = string, S extends string = string, R extends string = string> =
|
||||
| {
|
||||
readonly kind: "parameter";
|
||||
readonly parameter: P;
|
||||
readonly title?: string;
|
||||
readonly visibleWhen?: FxNodeVisibility<P>;
|
||||
}
|
||||
| { readonly kind: "socket"; readonly socket: S; readonly title?: string; readonly visibleWhen?: FxNodeVisibility<P> }
|
||||
| {
|
||||
readonly kind: "widget";
|
||||
readonly widget: "color-ramp";
|
||||
readonly parameter: P;
|
||||
readonly title?: string;
|
||||
readonly visibleWhen?: FxNodeVisibility<P>;
|
||||
}
|
||||
| {
|
||||
readonly kind: "widget";
|
||||
readonly widget: "grading-wheels";
|
||||
readonly bindings: readonly [FxNodeGradingBinding<P>, FxNodeGradingBinding<P>, FxNodeGradingBinding<P>];
|
||||
readonly visibleWhen?: FxNodeVisibility<P>;
|
||||
}
|
||||
| {
|
||||
readonly kind: "resource";
|
||||
readonly resource: R;
|
||||
readonly parameter: P;
|
||||
readonly title?: string;
|
||||
readonly openTitle?: string;
|
||||
readonly visibleWhen?: FxNodeVisibility<P>;
|
||||
}
|
||||
| {
|
||||
readonly kind: "text";
|
||||
readonly variant: "header" | "category" | "section" | "panel" | "placeholder";
|
||||
readonly title: string;
|
||||
readonly visibleWhen?: FxNodeVisibility<P>;
|
||||
}
|
||||
| { readonly kind: "hidden"; readonly target: "parameter"; readonly parameter: P }
|
||||
| { readonly kind: "hidden"; readonly target: "socket"; readonly socket: S };
|
||||
export type FxNodeMigrationStep<P extends string = string, S extends string = string> =
|
||||
| { readonly kind: "materialize-missing"; readonly target: "parameter"; readonly key: P }
|
||||
| { readonly kind: "materialize-missing"; readonly target: "socket"; readonly key: S }
|
||||
| { readonly kind: "migrate-parameter"; readonly parameter: P; readonly codec: "color-ramp/legacy-stops" }
|
||||
| { readonly kind: "rename-parameter"; readonly from: string; readonly to: P }
|
||||
| { readonly kind: "rename-socket"; readonly from: string; readonly to: S };
|
||||
export interface FxNodeMigration<P extends string = string, S extends string = string> {
|
||||
readonly fromVersion: number;
|
||||
readonly toVersion: number;
|
||||
readonly steps: readonly FxNodeMigrationStep<P, S>[];
|
||||
}
|
||||
export interface FxNodeDefinition {
|
||||
readonly version: number;
|
||||
readonly title: string;
|
||||
readonly behavior: "standard" | "frame" | "reroute";
|
||||
readonly style: string;
|
||||
readonly parameters: Readonly<Record<string, FxNodeValueSchema>>;
|
||||
readonly sockets: Readonly<Record<string, FxNodeSocketDefinition>>;
|
||||
readonly ui: readonly FxNodeUiRow[];
|
||||
readonly muteBypass: readonly (readonly [string, string])[];
|
||||
readonly migrations: readonly FxNodeMigration[];
|
||||
}
|
||||
export interface FxNodeTheme {
|
||||
readonly background: FxNodeHexColor;
|
||||
readonly grid: FxNodeHexColor;
|
||||
readonly frame: FxNodeHexColor;
|
||||
readonly frameHeader: FxNodeHexColor;
|
||||
readonly body: FxNodeHexColor;
|
||||
readonly control: FxNodeHexColor;
|
||||
readonly controlFill: FxNodeHexColor;
|
||||
readonly controlEditing: FxNodeHexColor;
|
||||
readonly textSelection: FxNodeHexColor;
|
||||
readonly outline: FxNodeHexColor;
|
||||
readonly text: FxNodeHexColor;
|
||||
readonly muted: FxNodeHexColor;
|
||||
readonly shadow: FxNodeHexColor;
|
||||
readonly nodeSelected: FxNodeHexColor;
|
||||
readonly nodeActive: FxNodeHexColor;
|
||||
readonly unknownHeader: FxNodeHexColor;
|
||||
readonly unknownSocket: FxNodeHexColor;
|
||||
readonly linkMuted: FxNodeHexColor;
|
||||
readonly knifeMuted: FxNodeHexColor;
|
||||
readonly emphasis: FxNodeHexColor;
|
||||
readonly focus: FxNodeHexColor;
|
||||
readonly editOutline: FxNodeHexColor;
|
||||
readonly resize: FxNodeHexColor;
|
||||
readonly muteOverlay: FxNodeHexColor;
|
||||
readonly boxSelectionFill: FxNodeHexColor;
|
||||
readonly checkerLight: FxNodeHexColor;
|
||||
readonly checkerDark: FxNodeHexColor;
|
||||
readonly widgetBorder: FxNodeHexColor;
|
||||
readonly rampBorder: FxNodeHexColor;
|
||||
readonly resourceBackground: FxNodeHexColor;
|
||||
}
|
||||
export interface FxNodeComposition extends FxNodeCompositionData {
|
||||
readonly schemaVersion: 2;
|
||||
readonly compatibility: { readonly wildcardInputTypes: readonly string[] };
|
||||
readonly theme: FxNodeTheme;
|
||||
readonly socketTypes: Readonly<Record<string, FxNodeSocketTypeDefinition>>;
|
||||
readonly nodeStyles: Readonly<Record<string, FxNodeStyleDefinition>>;
|
||||
readonly resources: Readonly<Record<string, FxNodeResourceDefinition>>;
|
||||
readonly nodes: Readonly<Record<string, FxNodeDefinition>>;
|
||||
}
|
||||
export interface FxNodeReadonlyMap<K, V> extends Iterable<readonly [K, V]> {
|
||||
readonly size: number;
|
||||
get(key: K): V | undefined;
|
||||
has(key: K): boolean;
|
||||
keys(): IterableIterator<K>;
|
||||
values(): IterableIterator<V>;
|
||||
entries(): IterableIterator<[K, V]>;
|
||||
forEach(callback: (value: V, key: K) => void): void;
|
||||
[Symbol.iterator](): IterableIterator<[K, V]>;
|
||||
}
|
||||
/** @inline */
|
||||
/** @inline */
|
||||
type Values<T> = T[keyof T];
|
||||
export type CompiledNode<C extends FxNodeCompositionData, N extends NodeTypeId<C>> = (N extends keyof C["nodes"]
|
||||
? C["nodes"][N]
|
||||
: never) & { readonly typeId: N };
|
||||
export type CompiledResource<C extends FxNodeCompositionData, R extends ResourceId<C>> = Omit<
|
||||
C["resources"][R],
|
||||
"maxBytes" | "maxWidth" | "maxHeight" | "maxPixels"
|
||||
> &
|
||||
Pick<FxNodeImageResourceDefinition, "maxBytes" | "maxWidth" | "maxHeight" | "maxPixels"> & { readonly id: R };
|
||||
export interface CompiledFxNodeComposition<C extends FxNodeCompositionData = FxNodeComposition> {
|
||||
readonly source: C;
|
||||
readonly id: C["id"];
|
||||
readonly version: C["version"];
|
||||
readonly compatibility: C["compatibility"];
|
||||
readonly theme: C["theme"];
|
||||
readonly nodes: FxNodeReadonlyMap<NodeTypeId<C>, Values<{ [N in NodeTypeId<C>]: CompiledNode<C, N> }>>;
|
||||
readonly socketTypes: FxNodeReadonlyMap<
|
||||
SocketTypeId<C>,
|
||||
Values<{ [S in SocketTypeId<C>]: C["socketTypes"][S] & { readonly id: S } }>
|
||||
>;
|
||||
readonly styles: FxNodeReadonlyMap<
|
||||
NodeStyleId<C>,
|
||||
Values<{ [S in NodeStyleId<C>]: C["nodeStyles"][S] & { readonly id: S } }>
|
||||
>;
|
||||
readonly resources: FxNodeReadonlyMap<ResourceId<C>, Values<{ [R in ResourceId<C>]: CompiledResource<C, R> }>>;
|
||||
}
|
||||
+751
@@ -0,0 +1,751 @@
|
||||
import { isColorRamp } from "../widgets/color-ramp.js";
|
||||
import type { FxNodeComposition, FxNodeValueSchema, FxNodeVisibility } from "./types.js";
|
||||
|
||||
export const FXNODE_COMPOSITION_LIMITS = Object.freeze({
|
||||
maxIssues: 100,
|
||||
maxDepth: 64,
|
||||
maxValues: 100_000,
|
||||
maxStringCodeUnits: 1_048_576,
|
||||
maxIdLength: 128,
|
||||
maxTitleLength: 256,
|
||||
maxKeywordLength: 64,
|
||||
maxSearchLength: 4096,
|
||||
maxNodes: 512,
|
||||
maxSocketTypes: 128,
|
||||
maxStyles: 128,
|
||||
maxResources: 64,
|
||||
maxParametersPerNode: 128,
|
||||
maxSocketsPerNode: 64,
|
||||
maxUiRowsPerNode: 256,
|
||||
maxEnumValues: 256,
|
||||
maxMigrationsPerNode: 64,
|
||||
maxMigrationSteps: 128,
|
||||
maxVisibilityDepth: 16,
|
||||
maxVisibilityNodes: 256,
|
||||
maxImageBytes: 33_554_432,
|
||||
maxImageDimension: 8192,
|
||||
maxImagePixels: 16_777_216,
|
||||
});
|
||||
export interface FxNodeCompositionIssue {
|
||||
readonly code: string;
|
||||
readonly path: string;
|
||||
readonly message: string;
|
||||
}
|
||||
export type FxNodeCompositionValidation =
|
||||
| { readonly ok: true; readonly value: FxNodeComposition }
|
||||
| { readonly ok: false; readonly issues: readonly FxNodeCompositionIssue[] };
|
||||
const forbidden = new Set(["__proto__", "prototype", "constructor"]);
|
||||
const esc = (s: string) => s.replaceAll("~", "~0").replaceAll("/", "~1");
|
||||
const plain = (v: unknown): v is Record<string, unknown> =>
|
||||
v !== null &&
|
||||
typeof v === "object" &&
|
||||
!Array.isArray(v) &&
|
||||
(Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
|
||||
|
||||
export function validateFxNodeComposition(input: unknown): FxNodeCompositionValidation {
|
||||
const issues: FxNodeCompositionIssue[] = [];
|
||||
const add = (path: string, message: string, code = "shape.invalid") => {
|
||||
if (issues.length < FXNODE_COMPOSITION_LIMITS.maxIssues) issues.push(Object.freeze({ code, path, message }));
|
||||
};
|
||||
const seen = new WeakSet<object>();
|
||||
let count = 0,
|
||||
bytes = 0;
|
||||
let terminal = false;
|
||||
const stringBudget = (value: string, path: string) => {
|
||||
bytes += value.length;
|
||||
if (bytes <= FXNODE_COMPOSITION_LIMITS.maxStringCodeUnits) return true;
|
||||
add(path, "composition exceeds string limit", "limit.strings");
|
||||
terminal = true;
|
||||
return false;
|
||||
};
|
||||
const inspect = (v: unknown, p: string, d: number): boolean => {
|
||||
try {
|
||||
if (terminal) return false;
|
||||
if (++count > FXNODE_COMPOSITION_LIMITS.maxValues) {
|
||||
add(p, "composition exceeds value limit", "limit.values");
|
||||
terminal = true;
|
||||
return false;
|
||||
}
|
||||
if (d > FXNODE_COMPOSITION_LIMITS.maxDepth) {
|
||||
add(p, "composition exceeds depth limit", "limit.depth");
|
||||
terminal = true;
|
||||
return false;
|
||||
}
|
||||
if (typeof v === "string") {
|
||||
return stringBudget(v, p);
|
||||
}
|
||||
if (v === null || typeof v === "boolean") return true;
|
||||
if (typeof v === "number") {
|
||||
if (!Number.isFinite(v)) add(p, "must be finite", "value.finite");
|
||||
return true;
|
||||
}
|
||||
if (typeof v !== "object") {
|
||||
add(p, "unsupported value", "data.type");
|
||||
return false;
|
||||
}
|
||||
if (seen.has(v)) {
|
||||
add(p, "shared or cyclic value", "data.identity");
|
||||
return false;
|
||||
}
|
||||
seen.add(v);
|
||||
if (Array.isArray(v)) {
|
||||
if (Object.getPrototypeOf(v) !== Array.prototype) add(p, "array must be plain", "data.array");
|
||||
const ds = Object.getOwnPropertyDescriptors(v);
|
||||
const ld = Object.getOwnPropertyDescriptor(v, "length");
|
||||
const length = ld && "value" in ld ? ld.value : undefined;
|
||||
if (!Number.isSafeInteger(length)) {
|
||||
add(p, "invalid array length", "data.array");
|
||||
return false;
|
||||
}
|
||||
if (Number(length) > FXNODE_COMPOSITION_LIMITS.maxValues) {
|
||||
add(p, "array exceeds value limit", "limit.values");
|
||||
terminal = true;
|
||||
return false;
|
||||
}
|
||||
for (const key of Reflect.ownKeys(ds)) {
|
||||
if (key === "length") continue;
|
||||
if (typeof key === "string" && !stringBudget(key, p)) return false;
|
||||
if (typeof key !== "string" || !/^(0|[1-9]\d*)$/.test(key) || Number(key) >= Number(length)) {
|
||||
add(p, "extra array own property", "data.array");
|
||||
continue;
|
||||
}
|
||||
const descriptor = ds[key]!;
|
||||
if (!descriptor.enumerable || !("value" in descriptor)) {
|
||||
add(`${p}/${esc(key)}`, "array elements must be enumerable data properties", "data.inspect");
|
||||
continue;
|
||||
}
|
||||
inspect(descriptor.value, `${p}/${key}`, d + 1);
|
||||
}
|
||||
for (let i = 0; i < Number(length) && !terminal; i++)
|
||||
if (!Object.hasOwn(ds, String(i))) add(`${p}/${i}`, "sparse arrays are not allowed", "data.array");
|
||||
return true;
|
||||
}
|
||||
const proto = Object.getPrototypeOf(v);
|
||||
if (proto !== Object.prototype && proto !== null) {
|
||||
add(p, "object must be an ordinary record", "data.type");
|
||||
return false;
|
||||
}
|
||||
for (const k of Reflect.ownKeys(v)) {
|
||||
if (typeof k !== "string") {
|
||||
add(p, "symbol properties are not allowed", "data.symbol");
|
||||
continue;
|
||||
}
|
||||
if (!stringBudget(k, p)) return false;
|
||||
const q = `${p}/${esc(k)}`,
|
||||
descriptor = Object.getOwnPropertyDescriptor(v, k);
|
||||
if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) {
|
||||
add(q, "record properties must be enumerable data properties", "data.inspect");
|
||||
continue;
|
||||
}
|
||||
inspect(descriptor.value, q, d + 1);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
add(p, "value could not be inspected", "data.inspect");
|
||||
terminal = true;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
inspect(input, "", 0);
|
||||
if (issues.length) return Object.freeze({ ok: false, issues: Object.freeze(issues) });
|
||||
let cloned: unknown;
|
||||
try {
|
||||
cloned = structuredClone(input);
|
||||
} catch {
|
||||
add("", "value could not be cloned", "data.clone");
|
||||
return Object.freeze({ ok: false, issues: Object.freeze(issues) });
|
||||
}
|
||||
input = cloned;
|
||||
if (!plain(input)) {
|
||||
add("", "must be a plain object");
|
||||
return Object.freeze({ ok: false, issues: Object.freeze(issues) });
|
||||
}
|
||||
const known = (o: Record<string, unknown>, keys: readonly string[], p: string) => {
|
||||
for (const k of Object.keys(o)) {
|
||||
if (issues.length >= FXNODE_COMPOSITION_LIMITS.maxIssues) return;
|
||||
if (!keys.includes(k)) add(`${p}/${esc(k)}`, "unknown property", "shape.unknown");
|
||||
}
|
||||
};
|
||||
const exact = (o: Record<string, unknown>, keys: readonly string[], p: string) => {
|
||||
known(o, keys, p);
|
||||
for (const k of keys) {
|
||||
if (issues.length >= FXNODE_COMPOSITION_LIMITS.maxIssues) return;
|
||||
if (!Object.hasOwn(o, k)) add(`${p}/${k}`, "missing property", "shape.missing");
|
||||
}
|
||||
};
|
||||
const id = (v: unknown, p: string) => {
|
||||
if (typeof v !== "string" || !v || v.length > 128 || /[\x00-\x1f\x7f]/.test(v) || forbidden.has(v))
|
||||
add(p, "invalid ID");
|
||||
};
|
||||
const str = (v: unknown, p: string, max: number = FXNODE_COMPOSITION_LIMITS.maxTitleLength) => {
|
||||
if (typeof v !== "string" || v.length > max) add(p, "invalid string", "shape.string");
|
||||
};
|
||||
const integer = (v: unknown, p: string, min = 0) => {
|
||||
if (!Number.isSafeInteger(v) || Number(v) < min) add(p, "invalid integer", "shape.integer");
|
||||
};
|
||||
const record = (
|
||||
v: unknown,
|
||||
p: string,
|
||||
max: number,
|
||||
visit: (x: Record<string, unknown>, q: string, key: string) => void,
|
||||
) => {
|
||||
if (!plain(v)) {
|
||||
add(p, "must be a record", "shape.record");
|
||||
return;
|
||||
}
|
||||
const es = Object.entries(v);
|
||||
if (es.length > max) add(p, "too many entries", "limit.collection");
|
||||
for (const [k, x] of es) {
|
||||
if (issues.length >= FXNODE_COMPOSITION_LIMITS.maxIssues) return;
|
||||
id(k, `${p}/${esc(k)}`);
|
||||
if (plain(x)) visit(x, `${p}/${esc(k)}`, k);
|
||||
else add(`${p}/${esc(k)}`, "must be an object", "shape.object");
|
||||
}
|
||||
};
|
||||
exact(
|
||||
input,
|
||||
["schemaVersion", "id", "version", "compatibility", "socketTypes", "nodeStyles", "resources", "theme", "nodes"],
|
||||
"",
|
||||
);
|
||||
if (input.schemaVersion !== 2) add("/schemaVersion", "must equal 2");
|
||||
id(input.id, "/id");
|
||||
integer(input.version, "/version", 1);
|
||||
const socketIds = new Set(Object.keys(plain(input.socketTypes) ? input.socketTypes : {})),
|
||||
styleIds = new Set(Object.keys(plain(input.nodeStyles) ? input.nodeStyles : {})),
|
||||
resourceIds = new Set(Object.keys(plain(input.resources) ? input.resources : {}));
|
||||
if (!plain(input.compatibility)) add("/compatibility", "must be an object");
|
||||
else {
|
||||
exact(input.compatibility, ["wildcardInputTypes"], "/compatibility");
|
||||
const wildcards = input.compatibility.wildcardInputTypes;
|
||||
if (!Array.isArray(wildcards)) add("/compatibility/wildcardInputTypes", "must be an array");
|
||||
else {
|
||||
const seen = new Set<unknown>();
|
||||
wildcards.forEach((value, index) => {
|
||||
id(value, `/compatibility/wildcardInputTypes/${index}`);
|
||||
if (typeof value === "string" && !socketIds.has(value))
|
||||
add(`/compatibility/wildcardInputTypes/${index}`, "unknown socket type", "reference.socketType");
|
||||
if (seen.has(value))
|
||||
add(`/compatibility/wildcardInputTypes/${index}`, "duplicate socket type", "value.duplicate");
|
||||
seen.add(value);
|
||||
});
|
||||
}
|
||||
}
|
||||
const color = (v: unknown, p: string) => {
|
||||
if (typeof v !== "string" || !/^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(v)) add(p, "invalid color");
|
||||
};
|
||||
record(input.socketTypes, "/socketTypes", 128, (x, p) => {
|
||||
exact(x, ["title", "color", "acceptsFrom"], p);
|
||||
str(x.title, p + "/title");
|
||||
color(x.color, p + "/color");
|
||||
if (!Array.isArray(x.acceptsFrom)) add(p + "/acceptsFrom", "must be an array");
|
||||
else {
|
||||
const seen = new Set<unknown>();
|
||||
x.acceptsFrom.forEach((v, i) => {
|
||||
id(v, `${p}/acceptsFrom/${i}`);
|
||||
if (seen.has(v)) add(`${p}/acceptsFrom/${i}`, "duplicate socket type", "value.duplicate");
|
||||
seen.add(v);
|
||||
if (typeof v === "string" && !socketIds.has(v))
|
||||
add(`${p}/acceptsFrom/${i}`, "unknown socket type", "reference.socketType");
|
||||
});
|
||||
}
|
||||
});
|
||||
record(input.nodeStyles, "/nodeStyles", 128, (x, p) => {
|
||||
exact(x, ["header"], p);
|
||||
color(x.header, p + "/header");
|
||||
});
|
||||
record(input.resources, "/resources", FXNODE_COMPOSITION_LIMITS.maxResources, (x, p) => {
|
||||
exact(
|
||||
x,
|
||||
["kind", "title", "openTitle", "accept", "referencePrefix", "maxBytes", "maxWidth", "maxHeight", "maxPixels"],
|
||||
p,
|
||||
);
|
||||
if (x.kind !== "image") add(p + "/kind", "must equal image", "shape.literal");
|
||||
str(x.title, p + "/title");
|
||||
str(x.openTitle, p + "/openTitle");
|
||||
if (
|
||||
typeof x.referencePrefix !== "string" ||
|
||||
!x.referencePrefix ||
|
||||
x.referencePrefix.length > 128 ||
|
||||
/[\x00-\x1f\x7f]/.test(x.referencePrefix) ||
|
||||
!x.referencePrefix.endsWith(":")
|
||||
)
|
||||
add(p + "/referencePrefix", "invalid reference prefix", "shape.string");
|
||||
if (
|
||||
!Array.isArray(x.accept) ||
|
||||
!x.accept.length ||
|
||||
x.accept.some((v) => typeof v !== "string" || !v || v.length > FXNODE_COMPOSITION_LIMITS.maxKeywordLength)
|
||||
)
|
||||
add(p + "/accept", "invalid accepted media array", "shape.array");
|
||||
else {
|
||||
const s = new Set(x.accept);
|
||||
if (s.size !== x.accept.length) add(p + "/accept", "duplicate media type", "value.duplicate");
|
||||
}
|
||||
for (const k of ["maxBytes", "maxWidth", "maxHeight", "maxPixels"] as const)
|
||||
if (!Number.isSafeInteger(x[k]) || Number(x[k]) < 1)
|
||||
add(`${p}/${k}`, "must be a positive safe integer", "limit.resource");
|
||||
});
|
||||
const themeKeys = [
|
||||
"background",
|
||||
"grid",
|
||||
"frame",
|
||||
"frameHeader",
|
||||
"body",
|
||||
"control",
|
||||
"controlFill",
|
||||
"controlEditing",
|
||||
"textSelection",
|
||||
"outline",
|
||||
"text",
|
||||
"muted",
|
||||
"shadow",
|
||||
"nodeSelected",
|
||||
"nodeActive",
|
||||
"unknownHeader",
|
||||
"unknownSocket",
|
||||
"linkMuted",
|
||||
"knifeMuted",
|
||||
"emphasis",
|
||||
"focus",
|
||||
"editOutline",
|
||||
"resize",
|
||||
"muteOverlay",
|
||||
"boxSelectionFill",
|
||||
"checkerLight",
|
||||
"checkerDark",
|
||||
"widgetBorder",
|
||||
"rampBorder",
|
||||
"resourceBackground",
|
||||
] as const;
|
||||
if (!plain(input.theme)) add("/theme", "must be an object");
|
||||
else {
|
||||
exact(input.theme, themeKeys, "/theme");
|
||||
for (const k of themeKeys) color(input.theme[k], `/theme/${k}`);
|
||||
}
|
||||
const schema = (x: unknown, p: string) => {
|
||||
if (!plain(x)) {
|
||||
add(p, "must be a schema", "schema.shape");
|
||||
return;
|
||||
}
|
||||
const t = x.type;
|
||||
if (typeof t !== "string" || !["number", "string", "boolean", "vector", "color", "json"].includes(t))
|
||||
add(p + "/type", "unknown schema type", "schema.type");
|
||||
const allowed =
|
||||
t === "number"
|
||||
? ["type", "default", "integer", "minimum", "maximum", "softMin", "softMax", "step", "precision"]
|
||||
: t === "string"
|
||||
? ["type", "default", "enum"]
|
||||
: t === "vector" || t === "color"
|
||||
? ["type", "default", "minimum", "maximum", "softMin", "softMax", "step"]
|
||||
: t === "json"
|
||||
? ["type", "default", "codec"]
|
||||
: ["type", "default"];
|
||||
exact(
|
||||
x,
|
||||
allowed.filter((k) => k === "type" || k === "default" || Object.hasOwn(x, k)),
|
||||
p,
|
||||
);
|
||||
if (t === "number" && x.integer !== undefined && typeof x.integer !== "boolean")
|
||||
add(p + "/integer", "integer must be boolean", "schema.integer");
|
||||
const d = x.default;
|
||||
if (!plain(d) || d.kind !== t) {
|
||||
add(p + "/default", "tagged default does not match schema", "schema.default");
|
||||
} else {
|
||||
exact(d, ["kind", "value"], p + "/default");
|
||||
const val = d.value;
|
||||
if (t === "number" && (!Number.isFinite(val) || (x.integer === true && !Number.isSafeInteger(val))))
|
||||
add(p + "/default/value", "invalid number", "schema.default");
|
||||
if (t === "string" && typeof val !== "string") add(p + "/default/value", "invalid string", "schema.default");
|
||||
if (t === "boolean" && typeof val !== "boolean") add(p + "/default/value", "invalid boolean", "schema.default");
|
||||
if (t === "vector" || t === "color") {
|
||||
const n = t === "vector" ? 3 : 4;
|
||||
if (!Array.isArray(val) || val.length !== n || val.some((v) => !Number.isFinite(v)))
|
||||
add(p + "/default/value", "invalid components", "schema.default");
|
||||
else if (
|
||||
val.some(
|
||||
(v) => (typeof x.minimum === "number" && v < x.minimum) || (typeof x.maximum === "number" && v > x.maximum),
|
||||
)
|
||||
)
|
||||
add(p + "/default/value", "component outside bounds", "schema.bounds");
|
||||
}
|
||||
if (t === "json" && x.codec === "color-ramp/v1" && !isColorRamp(val))
|
||||
add(p + "/default/value", "invalid color ramp", "schema.codec");
|
||||
if (
|
||||
typeof val === "number" &&
|
||||
((typeof x.minimum === "number" && val < x.minimum) || (typeof x.maximum === "number" && val > x.maximum))
|
||||
)
|
||||
add(p + "/default/value", "default outside bounds", "schema.bounds");
|
||||
}
|
||||
for (const k of ["minimum", "maximum", "softMin", "softMax"] as const)
|
||||
if (x[k] !== undefined && !Number.isFinite(x[k])) add(`${p}/${k}`, "must be finite", "schema.bounds");
|
||||
if (typeof x.minimum === "number" && typeof x.maximum === "number" && x.minimum > x.maximum)
|
||||
add(p + "/minimum", "minimum exceeds maximum", "schema.bounds");
|
||||
if (typeof x.softMin === "number" && typeof x.softMax === "number" && x.softMin > x.softMax)
|
||||
add(p + "/softMin", "soft minimum exceeds soft maximum", "schema.bounds");
|
||||
if (typeof x.softMin === "number" && typeof x.minimum === "number" && x.softMin < x.minimum)
|
||||
add(p + "/softMin", "soft minimum is outside hard bounds", "schema.bounds");
|
||||
if (typeof x.softMax === "number" && typeof x.maximum === "number" && x.softMax > x.maximum)
|
||||
add(p + "/softMax", "soft maximum is outside hard bounds", "schema.bounds");
|
||||
if (x.step !== undefined && (!Number.isFinite(x.step) || Number(x.step) <= 0))
|
||||
add(p + "/step", "step must be positive", "schema.step");
|
||||
if (
|
||||
x.precision !== undefined &&
|
||||
(!Number.isSafeInteger(x.precision) || Number(x.precision) < 0 || Number(x.precision) > 20)
|
||||
)
|
||||
add(p + "/precision", "invalid precision", "schema.precision");
|
||||
if (t === "json" && x.codec !== undefined && x.codec !== "color-ramp/v1")
|
||||
add(p + "/codec", "unknown codec", "schema.codec");
|
||||
if (x.enum !== undefined) {
|
||||
if (!Array.isArray(x.enum) || !x.enum.length || x.enum.some((v) => typeof v !== "string"))
|
||||
add(p + "/enum", "invalid enum", "schema.enum");
|
||||
else {
|
||||
if (x.enum.length > FXNODE_COMPOSITION_LIMITS.maxEnumValues)
|
||||
add(p + "/enum", "too many enum values", "limit.enum");
|
||||
if (new Set(x.enum).size !== x.enum.length) add(p + "/enum", "duplicate enum value", "value.duplicate");
|
||||
if (plain(d) && typeof d.value === "string" && !x.enum.includes(d.value))
|
||||
add(p + "/default/value", "default is not an enum member", "schema.enum");
|
||||
}
|
||||
}
|
||||
};
|
||||
const primitive = (s: Record<string, unknown> | undefined, v: unknown) =>
|
||||
s?.type === "number"
|
||||
? typeof v === "number" && Number.isFinite(v)
|
||||
: s?.type === "string"
|
||||
? typeof v === "string" && (!Array.isArray(s.enum) || s.enum.includes(v))
|
||||
: s?.type === "boolean"
|
||||
? typeof v === "boolean"
|
||||
: false;
|
||||
const visibility = (
|
||||
v: unknown,
|
||||
p: string,
|
||||
paramDefs: Record<string, unknown>,
|
||||
depth: number,
|
||||
state: { n: number },
|
||||
) => {
|
||||
if (
|
||||
++state.n > FXNODE_COMPOSITION_LIMITS.maxVisibilityNodes ||
|
||||
depth > FXNODE_COMPOSITION_LIMITS.maxVisibilityDepth
|
||||
) {
|
||||
add(p, "visibility expression too complex", "limit.visibility");
|
||||
return;
|
||||
}
|
||||
if (!plain(v)) {
|
||||
add(p, "invalid visibility expression", "ui.visibility");
|
||||
return;
|
||||
}
|
||||
if ("parameter" in v) {
|
||||
const hasEquals = Object.hasOwn(v, "equals"),
|
||||
hasIn = Object.hasOwn(v, "in");
|
||||
if (hasEquals === hasIn) {
|
||||
add(p, "visibility requires exactly equals or in", "ui.visibility");
|
||||
return;
|
||||
}
|
||||
exact(v, ["parameter", hasEquals ? "equals" : "in"], p);
|
||||
const def = typeof v.parameter === "string" && plain(paramDefs[v.parameter]) ? paramDefs[v.parameter] : undefined;
|
||||
if (!def) add(p + "/parameter", "unknown parameter", "reference.parameter");
|
||||
const values = hasEquals ? [v.equals] : v.in;
|
||||
if (!hasEquals && (!Array.isArray(values) || !values.length)) {
|
||||
add(p + "/in", "must be a nonempty array", "ui.visibility");
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(values))
|
||||
values.forEach((z, i) => {
|
||||
if (!primitive(def as Record<string, unknown> | undefined, z))
|
||||
add(`${p}/${hasEquals ? "equals" : `in/${i}`}`, "comparison does not match schema", "ui.visibility");
|
||||
});
|
||||
return;
|
||||
}
|
||||
const hasAll = Object.hasOwn(v, "all"),
|
||||
hasAny = Object.hasOwn(v, "any");
|
||||
if (hasAll === hasAny) {
|
||||
add(p, "visibility requires exactly all or any", "ui.visibility");
|
||||
return;
|
||||
}
|
||||
const op = hasAll ? "all" : "any";
|
||||
exact(v, [op], p);
|
||||
if (!Array.isArray(v[op]) || v[op].length === 0) add(p + `/${op}`, "must be a nonempty array", "ui.visibility");
|
||||
else
|
||||
for (let i = 0; i < v[op].length && issues.length < FXNODE_COMPOSITION_LIMITS.maxIssues; i++)
|
||||
visibility(v[op][i], `${p}/${op}/${i}`, paramDefs, depth + 1, state);
|
||||
};
|
||||
record(input.nodes, "/nodes", FXNODE_COMPOSITION_LIMITS.maxNodes, (node, p) => {
|
||||
exact(
|
||||
node,
|
||||
["version", "title", "behavior", "style", "parameters", "sockets", "ui", "muteBypass", "migrations"],
|
||||
p,
|
||||
);
|
||||
integer(node.version, p + "/version", 1);
|
||||
str(node.title, p + "/title");
|
||||
if (typeof node.behavior !== "string" || !["standard", "frame", "reroute"].includes(node.behavior))
|
||||
add(p + "/behavior", "invalid behavior", "shape.literal");
|
||||
if (typeof node.style !== "string" || !styleIds.has(node.style))
|
||||
add(p + "/style", "unknown node style", "reference.style");
|
||||
const paramDefs = plain(node.parameters) ? node.parameters : {};
|
||||
const socketDefs = plain(node.sockets) ? node.sockets : {};
|
||||
const params = new Set(Object.keys(paramDefs)),
|
||||
sockets = new Set(Object.keys(socketDefs));
|
||||
record(node.parameters, p + "/parameters", FXNODE_COMPOSITION_LIMITS.maxParametersPerNode, (x, q) => schema(x, q));
|
||||
record(node.sockets, p + "/sockets", FXNODE_COMPOSITION_LIMITS.maxSocketsPerNode, (x, q) => {
|
||||
exact(x, ["title", "direction", "type", "maxIncomingLinks", "visible", "value", "showValue"], q);
|
||||
str(x.title, q + "/title");
|
||||
if (typeof x.direction !== "string" || !["input", "output"].includes(x.direction))
|
||||
add(q + "/direction", "invalid direction", "socket.direction");
|
||||
if (typeof x.type !== "string" || !socketIds.has(x.type))
|
||||
add(q + "/type", "unknown socket type", "reference.socketType");
|
||||
integer(x.maxIncomingLinks, q + "/maxIncomingLinks");
|
||||
if (typeof x.visible !== "boolean" || typeof x.showValue !== "boolean")
|
||||
add(q, "visibility flags must be boolean", "socket.flags");
|
||||
if (x.value !== null) schema(x.value, q + "/value");
|
||||
if (x.direction === "input" && Number(x.maxIncomingLinks) <= 0)
|
||||
add(q + "/maxIncomingLinks", "input requires a positive limit", "socket.links");
|
||||
if (x.direction === "output" && (x.maxIncomingLinks !== 0 || x.value !== null || x.showValue !== false))
|
||||
add(q, "output payload must be null and hidden", "socket.output");
|
||||
if (x.showValue === true && x.value === null) add(q + "/value", "shown sockets require a value", "socket.value");
|
||||
});
|
||||
const placements = new Map<string, string>(),
|
||||
visState = { n: 0 };
|
||||
const place = (key: string, q: string) => {
|
||||
const old = placements.get(key);
|
||||
if (old) add(q, "duplicate or conflicting UI placement", "ui.placement");
|
||||
else placements.set(key, q);
|
||||
};
|
||||
if (!Array.isArray(node.ui)) add(p + "/ui", "must be an array", "ui.shape");
|
||||
else {
|
||||
if (node.ui.length > FXNODE_COMPOSITION_LIMITS.maxUiRowsPerNode) add(p + "/ui", "too many rows", "limit.ui");
|
||||
for (let i = 0; i < node.ui.length && issues.length < FXNODE_COMPOSITION_LIMITS.maxIssues; i++) {
|
||||
const r = node.ui[i],
|
||||
q = `${p}/ui/${i}`;
|
||||
if (!plain(r)) {
|
||||
add(q, "invalid UI row", "ui.shape");
|
||||
continue;
|
||||
}
|
||||
const common = r.visibleWhen !== undefined ? ["visibleWhen"] : [];
|
||||
let req: string[] = [];
|
||||
if (r.kind === "parameter") req = ["kind", "parameter", ...("title" in r ? ["title"] : []), ...common];
|
||||
else if (r.kind === "socket") req = ["kind", "socket", ...("title" in r ? ["title"] : []), ...common];
|
||||
else if (r.kind === "widget" && r.widget === "color-ramp")
|
||||
req = ["kind", "widget", "parameter", ...("title" in r ? ["title"] : []), ...common];
|
||||
else if (r.kind === "widget" && r.widget === "grading-wheels") req = ["kind", "widget", "bindings", ...common];
|
||||
else if (r.kind === "widget") {
|
||||
add(q + "/widget", "unknown widget", "ui.widget");
|
||||
continue;
|
||||
} else if (r.kind === "resource")
|
||||
req = [
|
||||
"kind",
|
||||
"resource",
|
||||
"parameter",
|
||||
...("title" in r ? ["title"] : []),
|
||||
...("openTitle" in r ? ["openTitle"] : []),
|
||||
...common,
|
||||
];
|
||||
else if (r.kind === "text") req = ["kind", "variant", "title", ...common];
|
||||
else if (r.kind === "hidden" && (r.target === "parameter" || r.target === "socket"))
|
||||
req = ["kind", "target", r.target === "socket" ? "socket" : "parameter"];
|
||||
else {
|
||||
if (r.kind === "hidden") add(q + "/target", "invalid hidden target", "ui.target");
|
||||
else add(q + "/kind", "unknown UI variant", "ui.kind");
|
||||
continue;
|
||||
}
|
||||
exact(r, req, q);
|
||||
if ("title" in r) str(r.title, q + "/title");
|
||||
if ("openTitle" in r) str(r.openTitle, q + "/openTitle");
|
||||
if (
|
||||
r.kind === "text" &&
|
||||
(typeof r.variant !== "string" ||
|
||||
!["header", "category", "section", "panel", "placeholder"].includes(r.variant))
|
||||
)
|
||||
add(q + "/variant", "invalid text variant", "ui.text");
|
||||
for (const field of ["parameter", "socket", "resource"])
|
||||
if (req.includes(field) && typeof r[field] !== "string")
|
||||
add(q + `/${field}`, "must be a string", "shape.string");
|
||||
if (typeof r.parameter === "string") {
|
||||
if (!params.has(r.parameter)) add(q + "/parameter", "unknown parameter", "reference.parameter");
|
||||
else if (r.kind !== "hidden" || r.target === "parameter") place(`p:${r.parameter}`, q);
|
||||
}
|
||||
if (typeof r.socket === "string") {
|
||||
if (!sockets.has(r.socket)) add(q + "/socket", "unknown socket", "reference.socket");
|
||||
else place(`s:${r.socket}`, q);
|
||||
}
|
||||
if (typeof r.resource === "string" && !resourceIds.has(r.resource))
|
||||
add(q + "/resource", "unknown resource", "reference.resource");
|
||||
if (r.kind === "widget" && r.widget === "color-ramp") {
|
||||
const d = plain(paramDefs[String(r.parameter)])
|
||||
? (paramDefs[String(r.parameter)] as Record<string, unknown>)
|
||||
: undefined;
|
||||
if (d?.type !== "json" || d.codec !== "color-ramp/v1")
|
||||
add(q + "/parameter", "color ramp requires matching codec", "ui.widget");
|
||||
}
|
||||
if (r.kind === "resource") {
|
||||
const d = plain(paramDefs[String(r.parameter)])
|
||||
? (paramDefs[String(r.parameter)] as Record<string, unknown>)
|
||||
: undefined;
|
||||
if (d?.type !== "string") add(q + "/parameter", "resource requires string schema", "ui.resource");
|
||||
}
|
||||
if (r.widget === "grading-wheels") {
|
||||
if (!Array.isArray(r.bindings) || r.bindings.length !== 3)
|
||||
add(q + "/bindings", "grading wheels require exactly 3 bindings", "ui.widget");
|
||||
if (Array.isArray(r.bindings))
|
||||
r.bindings.forEach((b, j) => {
|
||||
const z = `${q}/bindings/${j}`;
|
||||
if (!plain(b)) {
|
||||
add(z, "invalid binding", "ui.widget");
|
||||
return;
|
||||
}
|
||||
exact(b, ["title", "scalar", "color"], z);
|
||||
str(b.title, z + "/title");
|
||||
const sd = plain(paramDefs[String(b.scalar)])
|
||||
? (paramDefs[String(b.scalar)] as Record<string, unknown>)
|
||||
: undefined,
|
||||
cd = plain(paramDefs[String(b.color)])
|
||||
? (paramDefs[String(b.color)] as Record<string, unknown>)
|
||||
: undefined;
|
||||
if (sd?.type !== "number") add(z + "/scalar", "scalar requires number schema", "ui.widget");
|
||||
if (cd?.type !== "color") add(z + "/color", "color requires color schema", "ui.widget");
|
||||
if (b.scalar === b.color) add(z, "bindings must differ", "ui.widget");
|
||||
if (typeof b.scalar === "string" && params.has(b.scalar)) place(`p:${b.scalar}`, z);
|
||||
if (typeof b.color === "string" && params.has(b.color)) place(`p:${b.color}`, z);
|
||||
});
|
||||
}
|
||||
if (r.visibleWhen !== undefined) visibility(r.visibleWhen, q + "/visibleWhen", paramDefs, 0, visState);
|
||||
}
|
||||
}
|
||||
for (const key of params)
|
||||
if (!placements.has(`p:${key}`))
|
||||
add(`${p}/parameters/${esc(key)}`, "parameter has no UI placement", "ui.placement");
|
||||
for (const key of sockets)
|
||||
if (!placements.has(`s:${key}`)) add(`${p}/sockets/${esc(key)}`, "socket has no UI placement", "ui.placement");
|
||||
if (!Array.isArray(node.muteBypass)) add(p + "/muteBypass", "must be an array", "shape.array");
|
||||
else {
|
||||
const pairs = new Set<string>();
|
||||
node.muteBypass.forEach((b, i) => {
|
||||
const q = `${p}/muteBypass/${i}`;
|
||||
if (!Array.isArray(b) || b.length !== 2 || b.some((x) => typeof x !== "string" || !sockets.has(x)))
|
||||
add(q, "invalid socket bypass", "reference.socket");
|
||||
else {
|
||||
const pair = `${b[0]}\0${b[1]}`;
|
||||
if (pairs.has(pair)) add(q, "duplicate bypass", "value.duplicate");
|
||||
pairs.add(pair);
|
||||
const a = socketDefs[b[0]] as Record<string, unknown>,
|
||||
z = socketDefs[b[1]] as Record<string, unknown>,
|
||||
dest =
|
||||
plain(input.socketTypes) && plain(input.socketTypes[String(a?.type)])
|
||||
? (input.socketTypes[String(a?.type)] as Record<string, unknown>)
|
||||
: undefined;
|
||||
if (a?.direction !== "input" || z?.direction !== "output")
|
||||
add(q, "bypass direction is invalid", "socket.bypass");
|
||||
else if (
|
||||
!Array.isArray(dest?.acceptsFrom) ||
|
||||
(!(
|
||||
plain(input.compatibility) &&
|
||||
Array.isArray(input.compatibility.wildcardInputTypes) &&
|
||||
input.compatibility.wildcardInputTypes.includes(a.type)
|
||||
) &&
|
||||
!dest.acceptsFrom.includes(z.type))
|
||||
)
|
||||
add(q, "incompatible bypass", "socket.compatibility");
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!Array.isArray(node.migrations)) add(p + "/migrations", "must be an array", "migration.shape");
|
||||
else {
|
||||
if (node.migrations.length > FXNODE_COMPOSITION_LIMITS.maxMigrationsPerNode)
|
||||
add(p + "/migrations", "too many migrations", "limit.migrations");
|
||||
const outgoing = new Set<unknown>();
|
||||
node.migrations.forEach((m, i) => {
|
||||
const q = `${p}/migrations/${i}`;
|
||||
if (!plain(m)) {
|
||||
add(q, "invalid migration", "migration.shape");
|
||||
return;
|
||||
}
|
||||
exact(m, ["fromVersion", "toVersion", "steps"], q);
|
||||
integer(m.fromVersion, q + "/fromVersion", 1);
|
||||
integer(m.toVersion, q + "/toVersion", 1);
|
||||
if (outgoing.has(m.fromVersion)) add(q + "/fromVersion", "duplicate outgoing migration", "migration.outgoing");
|
||||
outgoing.add(m.fromVersion);
|
||||
if (Number(m.fromVersion) >= Number(m.toVersion) || Number(m.toVersion) > Number(node.version))
|
||||
add(q, "invalid migration version edge", "migration.edge");
|
||||
if (!Array.isArray(m.steps)) add(q + "/steps", "must be array", "migration.shape");
|
||||
else {
|
||||
const renameSources = new Set<string>();
|
||||
const renameDestinations = new Set<string>();
|
||||
const parameterWriters = new Set<string>();
|
||||
const socketWriters = new Set<string>();
|
||||
const materializedParameters = new Set<string>();
|
||||
if (m.steps.length > FXNODE_COMPOSITION_LIMITS.maxMigrationSteps)
|
||||
add(q + "/steps", "too many steps", "limit.migrations");
|
||||
m.steps.forEach((s, j) => {
|
||||
if (!plain(s)) {
|
||||
add(`${q}/steps/${j}`, "invalid step", "migration.shape");
|
||||
return;
|
||||
}
|
||||
const props =
|
||||
s.kind === "rename-parameter" || s.kind === "rename-socket"
|
||||
? ["kind", "from", "to"]
|
||||
: s.kind === "materialize-missing"
|
||||
? ["kind", "target", "key"]
|
||||
: s.kind === "migrate-parameter"
|
||||
? ["kind", "parameter", "codec"]
|
||||
: [];
|
||||
const z = `${q}/steps/${j}`;
|
||||
if (!props.length) {
|
||||
add(z + "/kind", "unknown migration step", "migration.kind");
|
||||
return;
|
||||
}
|
||||
exact(s, props, z);
|
||||
if (s.kind === "rename-parameter" || s.kind === "rename-socket") {
|
||||
const domain = s.kind === "rename-parameter" ? "parameter" : "socket";
|
||||
const source = `${domain}:${String(s.from)}`;
|
||||
const destination = `${domain}:${String(s.to)}`;
|
||||
if (s.from === s.to || renameSources.has(source) || renameDestinations.has(destination))
|
||||
add(z + "/from", "conflicting rename source or destination", "migration.rename");
|
||||
renameSources.add(source);
|
||||
renameDestinations.add(destination);
|
||||
const writers = domain === "parameter" ? parameterWriters : socketWriters;
|
||||
if (writers.has(String(s.to)))
|
||||
add(z + "/to", "multiple migration steps write this target", "migration.write");
|
||||
writers.add(String(s.to));
|
||||
id(s.from, z + "/from");
|
||||
const targets = s.kind === "rename-parameter" ? params : sockets;
|
||||
if (typeof s.to !== "string" || !targets.has(s.to))
|
||||
add(z + "/to", "unknown current target", "reference.migrationTarget");
|
||||
} else if (s.kind === "materialize-missing") {
|
||||
const targets = s.target === "parameter" ? params : s.target === "socket" ? sockets : null;
|
||||
if (!targets) add(z + "/target", "invalid target", "migration.target");
|
||||
else if (typeof s.key !== "string" || !targets.has(s.key))
|
||||
add(z + "/key", "unknown current target", "reference.migrationTarget");
|
||||
else {
|
||||
const writers = s.target === "parameter" ? parameterWriters : socketWriters;
|
||||
if (writers.has(s.key))
|
||||
add(z + "/key", "multiple migration steps write this target", "migration.write");
|
||||
writers.add(s.key);
|
||||
if (s.target === "parameter") materializedParameters.add(s.key);
|
||||
}
|
||||
} else {
|
||||
if (typeof s.parameter === "string") {
|
||||
if (parameterWriters.has(s.parameter) && !materializedParameters.has(s.parameter))
|
||||
add(z + "/parameter", "multiple migration steps write this target", "migration.write");
|
||||
parameterWriters.add(s.parameter);
|
||||
}
|
||||
if (typeof s.parameter !== "string" || !params.has(s.parameter))
|
||||
add(z + "/parameter", "unknown parameter", "reference.parameter");
|
||||
else {
|
||||
const target = plain(paramDefs[s.parameter])
|
||||
? (paramDefs[s.parameter] as Record<string, unknown>)
|
||||
: undefined;
|
||||
if (target?.type !== "json" || target.codec !== "color-ramp/v1")
|
||||
add(z + "/parameter", "migration requires a color-ramp/v1 json target", "migration.codec");
|
||||
}
|
||||
if (s.codec !== "color-ramp/legacy-stops")
|
||||
add(z + "/codec", "unknown migration codec", "migration.codec");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return issues.length
|
||||
? Object.freeze({ ok: false as const, issues: Object.freeze(issues) })
|
||||
: Object.freeze({
|
||||
ok: true as const,
|
||||
value: input as unknown as FxNodeComposition,
|
||||
});
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { isColorRamp } from "../widgets/color-ramp.js";
|
||||
import { isJson, isRecord } from "../core/json.js";
|
||||
import type { FxNodeValueSchema } from "./types.js";
|
||||
|
||||
const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
|
||||
/** Matches a persisted tagged value against a composition-owned value schema. */
|
||||
export function matchesFxNodeValueSchema(schema: FxNodeValueSchema, value: unknown): boolean {
|
||||
if (!isRecord(value) || value.kind !== schema.type || !("value" in value)) return false;
|
||||
const v = value.value;
|
||||
if (schema.type === "number")
|
||||
return (
|
||||
finite(v) &&
|
||||
(!schema.integer || Number.isSafeInteger(v)) &&
|
||||
(schema.minimum === undefined || v >= schema.minimum) &&
|
||||
(schema.maximum === undefined || v <= schema.maximum)
|
||||
);
|
||||
if (schema.type === "string") return typeof v === "string" && (!schema.enum || schema.enum.includes(v));
|
||||
if (schema.type === "boolean") return typeof v === "boolean";
|
||||
if (schema.type === "vector" || schema.type === "color") {
|
||||
const n = schema.type === "vector" ? 3 : 4;
|
||||
return (
|
||||
Array.isArray(v) &&
|
||||
v.length === n &&
|
||||
v.every(
|
||||
(x) =>
|
||||
finite(x) &&
|
||||
(schema.minimum === undefined || x >= schema.minimum) &&
|
||||
(schema.maximum === undefined || x <= schema.maximum),
|
||||
)
|
||||
);
|
||||
}
|
||||
return schema.codec === "color-ramp/v1" ? isColorRamp(v) : isJson(v);
|
||||
}
|
||||
Vendored
+188
@@ -0,0 +1,188 @@
|
||||
import type { JsonValue } from "./types.js";
|
||||
|
||||
export interface StructuredDataLimits {
|
||||
readonly maxValues: number;
|
||||
readonly maxStringCodeUnits: number;
|
||||
readonly maxDepth: number;
|
||||
readonly maxIssues: number;
|
||||
}
|
||||
export interface StructuredDataIssue {
|
||||
readonly code: string;
|
||||
readonly path: string;
|
||||
readonly message: string;
|
||||
}
|
||||
export interface StructuredDataMetrics {
|
||||
readonly values: number;
|
||||
readonly stringCodeUnits: number;
|
||||
readonly depth: number;
|
||||
}
|
||||
export type StructuredDataAdmissionResult =
|
||||
| { readonly ok: true; readonly value: unknown; readonly metrics: StructuredDataMetrics }
|
||||
| { readonly ok: false; readonly issues: readonly StructuredDataIssue[] };
|
||||
|
||||
/** Inspects hostile input without invoking accessors and returns a detached structured clone. */
|
||||
export function admitStructuredData(input: unknown, limits: StructuredDataLimits): StructuredDataAdmissionResult {
|
||||
const issues: StructuredDataIssue[] = [],
|
||||
seen = new WeakSet<object>();
|
||||
let values = 0,
|
||||
strings = 0,
|
||||
maxDepth = 0,
|
||||
terminal = false;
|
||||
const add = (code: string, path: string, message: string) => {
|
||||
if (issues.length < limits.maxIssues) issues.push({ code, path, message });
|
||||
};
|
||||
const walk = (v: unknown, path: string, depth: number): void => {
|
||||
try {
|
||||
maxDepth = Math.max(maxDepth, depth);
|
||||
if (terminal) return;
|
||||
if (++values > limits.maxValues) {
|
||||
add("limit.values", path, "Document exceeds inspected value limit");
|
||||
terminal = true;
|
||||
return;
|
||||
}
|
||||
if (depth > limits.maxDepth) {
|
||||
add("limit.depth", path, "Document exceeds JSON depth limit");
|
||||
terminal = true;
|
||||
return;
|
||||
}
|
||||
if (typeof v === "string") {
|
||||
strings += v.length;
|
||||
if (strings > limits.maxStringCodeUnits) {
|
||||
add("limit.strings", path, "Document exceeds string limit");
|
||||
terminal = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (v === null || typeof v === "boolean") return;
|
||||
if (typeof v === "number") {
|
||||
if (!Number.isFinite(v)) add("value.finite", path, "Numbers must be finite");
|
||||
return;
|
||||
}
|
||||
if (typeof v !== "object") {
|
||||
add("data.type", path, "Unsupported value");
|
||||
return;
|
||||
}
|
||||
if (seen.has(v)) {
|
||||
add("data.identity", path, "Shared or cyclic identity is unsupported");
|
||||
return;
|
||||
}
|
||||
seen.add(v);
|
||||
if (Array.isArray(v)) {
|
||||
if (Object.getPrototypeOf(v) !== Array.prototype) add("data.array", path, "Array must be ordinary");
|
||||
const ds = Object.getOwnPropertyDescriptors(v),
|
||||
ld = Object.getOwnPropertyDescriptor(v, "length"),
|
||||
length = ld && "value" in ld ? ld.value : undefined;
|
||||
if (!Number.isSafeInteger(length)) {
|
||||
add("data.array", path, "Invalid array length");
|
||||
return;
|
||||
}
|
||||
if (Number(length) > limits.maxValues - values) {
|
||||
add("limit.values", path, "Document exceeds inspected value limit");
|
||||
terminal = true;
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < Number(length); i++) {
|
||||
if (terminal) break;
|
||||
const d = ds[String(i)];
|
||||
if (!d) {
|
||||
add("data.array", `${path}/${i}`, "Sparse arrays are unsupported");
|
||||
continue;
|
||||
}
|
||||
if (!d.enumerable || !("value" in d)) {
|
||||
add("data.inspect", `${path}/${i}`, "Element must be an enumerable data property");
|
||||
continue;
|
||||
}
|
||||
walk(d.value, `${path}/${i}`, depth + 1);
|
||||
}
|
||||
for (const k of Reflect.ownKeys(ds)) {
|
||||
if (terminal) break;
|
||||
if (k !== "length" && !(typeof k === "string" && /^(0|[1-9]\d*)$/.test(k) && Number(k) < Number(length)))
|
||||
add(typeof k === "symbol" ? "data.symbol" : "data.array", path, "Extra array properties are unsupported");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const proto = Object.getPrototypeOf(v);
|
||||
if (proto !== Object.prototype && proto !== null) {
|
||||
add("data.type", path, "Object must be an ordinary record");
|
||||
return;
|
||||
}
|
||||
for (const k of Reflect.ownKeys(v)) {
|
||||
if (typeof k !== "string") {
|
||||
add("data.symbol", path, "Symbol properties are unsupported");
|
||||
continue;
|
||||
}
|
||||
strings += k.length;
|
||||
if (strings > limits.maxStringCodeUnits) {
|
||||
add("limit.strings", path, "Document exceeds string limit");
|
||||
terminal = true;
|
||||
return;
|
||||
}
|
||||
const d = Object.getOwnPropertyDescriptor(v, k);
|
||||
if (!d || !d.enumerable || !("value" in d)) {
|
||||
add("data.inspect", `${path}/${k}`, "Property must be enumerable data");
|
||||
continue;
|
||||
}
|
||||
walk(d.value, `${path}/${k}`, depth + 1);
|
||||
}
|
||||
} catch {
|
||||
add("data.inspect", path, "Value could not be inspected");
|
||||
terminal = true;
|
||||
}
|
||||
};
|
||||
walk(input, "", 0);
|
||||
if (issues.length) return { ok: false, issues };
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
value: structuredClone(input),
|
||||
metrics: Object.freeze({ values, stringCodeUnits: strings, depth: maxDepth }),
|
||||
};
|
||||
} catch {
|
||||
return { ok: false, issues: [{ code: "data.clone", path: "/", message: "Document could not be cloned" }] };
|
||||
}
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function isJson(value: unknown, depth = 0): value is JsonValue {
|
||||
if (depth > 50) return false;
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
||||
if (typeof value === "number") return Number.isFinite(value);
|
||||
if (Array.isArray(value)) return value.every((item) => isJson(item, depth + 1));
|
||||
return isRecord(value) && Object.values(value).every((item) => isJson(item, depth + 1));
|
||||
}
|
||||
|
||||
export function deepFreeze<T>(value: T): T {
|
||||
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
|
||||
for (const child of Object.values(value)) deepFreeze(child);
|
||||
Object.freeze(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function cloneJson<T>(value: T): T {
|
||||
return deepFreeze(structuredClone(value));
|
||||
}
|
||||
|
||||
export function nullRecord<T>(entries: Iterable<readonly [string, T]> = []): Readonly<Record<string, T>> {
|
||||
const result = Object.create(null) as Record<string, T>;
|
||||
for (const [key, value] of entries) result[key] = value;
|
||||
return Object.freeze(result);
|
||||
}
|
||||
|
||||
export function canonicalize(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(canonicalize);
|
||||
if (isRecord(value)) {
|
||||
const result = Object.create(null) as Record<string, unknown>;
|
||||
for (const key of Object.keys(value).sort()) result[key] = canonicalize(value[key]);
|
||||
return result;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Canonical structural equality for admitted JSON-shaped values. */
|
||||
export function canonicalJsonEqual(left: unknown, right: unknown): boolean {
|
||||
return JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right));
|
||||
}
|
||||
Vendored
+113
@@ -0,0 +1,113 @@
|
||||
import type { FxNodeCompositionData, NodeTypeId } from "../composition/types.js";
|
||||
|
||||
declare const brand: unique symbol;
|
||||
export type NodeId = string & { readonly [brand]: "NodeId" };
|
||||
export type LinkId = string & { readonly [brand]: "LinkId" };
|
||||
export type SocketId = string & { readonly [brand]: "SocketId" };
|
||||
export type CommandId = string & { readonly [brand]: "CommandId" };
|
||||
export type GraphId = string & { readonly [brand]: "GraphId" };
|
||||
export type JsonValue = null | boolean | number | string | readonly JsonValue[] | { readonly [key: string]: JsonValue };
|
||||
export interface Vec2 {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
}
|
||||
export type SocketDataType = string;
|
||||
export type ParameterValue =
|
||||
| { readonly kind: "number"; readonly value: number }
|
||||
| { readonly kind: "boolean"; readonly value: boolean }
|
||||
| { readonly kind: "string"; readonly value: string }
|
||||
| { readonly kind: "vector"; readonly value: readonly [number, number, number] }
|
||||
| { readonly kind: "color"; readonly value: readonly [number, number, number, number] }
|
||||
| { readonly kind: "json"; readonly value: JsonValue };
|
||||
|
||||
export interface Socket {
|
||||
readonly id: SocketId;
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly direction: "input" | "output";
|
||||
readonly dataType: SocketDataType;
|
||||
readonly accepts: readonly SocketDataType[];
|
||||
readonly maxIncomingLinks: number;
|
||||
readonly defaultValue?: ParameterValue | null | undefined;
|
||||
readonly visible: boolean;
|
||||
readonly metadata?: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
export interface NodeBase {
|
||||
readonly id: NodeId;
|
||||
readonly typeId: string;
|
||||
readonly typeVersion: number;
|
||||
/** Upper-left origin. Child positions are local to their parent frame. +Y is up. */
|
||||
readonly position: Vec2;
|
||||
/** Positive logical dimensions. */
|
||||
readonly size: Vec2;
|
||||
readonly label: string;
|
||||
readonly parameters: Readonly<Record<string, ParameterValue | JsonValue>>;
|
||||
readonly sockets: readonly Socket[];
|
||||
readonly muted: boolean;
|
||||
readonly collapsed: boolean;
|
||||
readonly parentId?: NodeId | undefined;
|
||||
readonly extensions: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
export interface KnownNode<C extends FxNodeCompositionData = FxNodeCompositionData> extends NodeBase {
|
||||
readonly known: true;
|
||||
readonly typeId: NodeTypeId<C>;
|
||||
readonly parameters: Readonly<Record<string, ParameterValue>>;
|
||||
}
|
||||
export interface UnknownNode extends NodeBase {
|
||||
readonly known: false;
|
||||
}
|
||||
export type GraphNode<C extends FxNodeCompositionData = FxNodeCompositionData> = KnownNode<C> | UnknownNode;
|
||||
export interface GraphLink {
|
||||
readonly id: LinkId;
|
||||
readonly fromNodeId: NodeId;
|
||||
readonly fromSocketId: SocketId;
|
||||
readonly toNodeId: NodeId;
|
||||
readonly toSocketId: SocketId;
|
||||
readonly muted: boolean;
|
||||
readonly extensions: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
export interface GraphDocument<C extends FxNodeCompositionData = FxNodeCompositionData> {
|
||||
readonly schemaVersion: 2;
|
||||
readonly graphId: GraphId;
|
||||
readonly catalogVersion: number;
|
||||
readonly nodes: Readonly<Record<string, GraphNode<C>>>;
|
||||
readonly links: Readonly<Record<string, GraphLink>>;
|
||||
readonly metadata: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
export interface GraphLayoutNodeV1 extends Omit<NodeBase, "known"> {
|
||||
readonly parameters: Readonly<Record<string, ParameterValue | JsonValue>>;
|
||||
}
|
||||
export interface GraphLinkV1 extends Omit<GraphLink, "muted"> {}
|
||||
export interface GraphLayoutV1 {
|
||||
readonly schemaVersion: 1;
|
||||
readonly graphId: GraphId;
|
||||
readonly catalogVersion: number;
|
||||
readonly nodes: readonly GraphLayoutNodeV1[];
|
||||
readonly links: readonly GraphLinkV1[];
|
||||
readonly metadata: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
export interface GraphLayoutV2 {
|
||||
readonly schemaVersion: 2;
|
||||
readonly graphId: GraphId;
|
||||
readonly catalogVersion: number;
|
||||
readonly nodes: readonly GraphLayoutNodeV1[];
|
||||
readonly links: readonly GraphLink[];
|
||||
readonly metadata: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
export interface GraphState<C extends FxNodeCompositionData = FxNodeCompositionData> {
|
||||
readonly graphId: GraphId;
|
||||
readonly catalogVersion: number;
|
||||
readonly nodes: readonly GraphNode<C>[];
|
||||
readonly links: readonly GraphLink[];
|
||||
readonly metadata: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
export interface GraphSnapshot<C extends FxNodeCompositionData = FxNodeCompositionData> extends GraphState<C> {
|
||||
readonly version: number;
|
||||
}
|
||||
|
||||
export const nodeId = (value: string): NodeId => value as NodeId;
|
||||
export const linkId = (value: string): LinkId => value as LinkId;
|
||||
export const socketId = (value: string): SocketId => value as SocketId;
|
||||
export const commandId = (value: string): CommandId => value as CommandId;
|
||||
export const graphId = (value: string): GraphId => value as GraphId;
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import type { FxNodeCompositionData } from "../composition/types.js";
|
||||
import type { GraphDocument, GraphLink, GraphNode, LinkId, NodeId } from "../core/types.js";
|
||||
|
||||
export type Mutation<C extends FxNodeCompositionData = FxNodeCompositionData> =
|
||||
| {
|
||||
readonly kind: "node.set";
|
||||
readonly id: NodeId;
|
||||
readonly before: GraphNode<C> | null;
|
||||
readonly after: GraphNode<C> | null;
|
||||
}
|
||||
| {
|
||||
readonly kind: "link.set";
|
||||
readonly id: LinkId;
|
||||
readonly before: GraphLink | null;
|
||||
readonly after: GraphLink | null;
|
||||
}
|
||||
| { readonly kind: "document.replaced"; readonly before: GraphDocument<C>; readonly after: GraphDocument<C> };
|
||||
export function invert<C extends FxNodeCompositionData>(mutations: readonly Mutation<C>[]): readonly Mutation<C>[] {
|
||||
return mutations
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((mutation) =>
|
||||
mutation.kind === "node.set"
|
||||
? { kind: "node.set", id: mutation.id, before: mutation.after, after: mutation.before }
|
||||
: mutation.kind === "link.set"
|
||||
? { kind: "link.set", id: mutation.id, before: mutation.after, after: mutation.before }
|
||||
: { kind: "document.replaced", before: mutation.after, after: mutation.before },
|
||||
);
|
||||
}
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
import { deepFreeze, nullRecord } from "../core/json.js";
|
||||
import type { FxNodeCompositionData } from "../composition/types.js";
|
||||
import type { GraphDocument } from "../core/types.js";
|
||||
import type { Mutation } from "./mutations.js";
|
||||
|
||||
export function reduceMutations<C extends FxNodeCompositionData>(
|
||||
document: GraphDocument<C>,
|
||||
mutations: readonly Mutation<C>[],
|
||||
): GraphDocument<C> {
|
||||
let nodes = document.nodes;
|
||||
let links = document.links;
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.kind === "document.replaced") return mutation.after;
|
||||
if (mutation.kind === "node.set")
|
||||
nodes = nullRecord(
|
||||
Object.entries(nodes)
|
||||
.filter(([key]) => key !== mutation.id)
|
||||
.concat(mutation.after ? [[mutation.id, mutation.after]] : []),
|
||||
);
|
||||
else
|
||||
links = nullRecord(
|
||||
Object.entries(links)
|
||||
.filter(([key]) => key !== mutation.id)
|
||||
.concat(mutation.after ? [[mutation.id, mutation.after]] : []),
|
||||
);
|
||||
}
|
||||
return deepFreeze({ ...document, nodes, links });
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import { compileFxNodeComposition } from "./composition/compile.js";
|
||||
import { bindDocument } from "./composition/bound-document.js";
|
||||
import { bindEngine } from "./composition/bound-engine.js";
|
||||
import type { CompiledFxNodeComposition, FxNodeCompositionData, NodeTypeId } from "./composition/types.js";
|
||||
import type { ReferenceCheck } from "./composition/references.js";
|
||||
import type { CommandId, GraphDocument, GraphLayoutV2, GraphNode, GraphSnapshot, Socket, Vec2 } from "./core/types.js";
|
||||
import type { CommandRequest, FxNodeReplayCommand } from "./commands/types.js";
|
||||
import type { DecodeResult, ValidationIssue } from "./composition/bound-document.js";
|
||||
import type {
|
||||
EngineState,
|
||||
LoadResult,
|
||||
ReplayResult,
|
||||
StateReplacementRequest,
|
||||
TransitionResult,
|
||||
} from "./composition/bound-engine.js";
|
||||
|
||||
/** Explicit immutable document and engine operations bound to one composition authority. */
|
||||
export interface FxNodeHeadless<C extends FxNodeCompositionData> {
|
||||
/** Creates an empty immutable document for this composition. */
|
||||
emptyDocument(id?: string): GraphDocument<C>;
|
||||
/** Materializes a known node using its definition's defaults. */
|
||||
materializeNode(id: string, typeId: NodeTypeId<C>, position?: Vec2, parentId?: string): GraphNode<C>;
|
||||
/** Validates an immutable document without changing it. */
|
||||
validateDocument(document: GraphDocument<C>): readonly ValidationIssue[];
|
||||
/** Decodes and validates durable graph data under this composition. */
|
||||
decodeGraphDocument(source: unknown): DecodeResult<C>;
|
||||
/** Decodes a graph-state shaped value into a validated document. */
|
||||
decodeGraphState(source: unknown): DecodeResult<C>;
|
||||
/** Parses JSON text and decodes it as a graph document. */
|
||||
parseGraphDocument(text: string): DecodeResult<C>;
|
||||
/** Converts a validated document to its stable, durable layout form. */
|
||||
save(document: GraphDocument<C>): GraphLayoutV2;
|
||||
/** Serializes the stable durable layout as canonical JSON. */
|
||||
serializeGraphDocument(document: GraphDocument<C>): string;
|
||||
socketsCompatible(
|
||||
from: Pick<Socket, "direction" | "dataType">,
|
||||
to: Pick<Socket, "direction" | "dataType" | "accepts">,
|
||||
): boolean;
|
||||
/** Creates version-zero engine state; history retains 100 entries by default, or the supplied nonnegative limit. */
|
||||
createEngine(document: GraphDocument<C>, historyLimit?: number): EngineState<C>;
|
||||
/** Immutably applies a command. Committed results increment version; no-op and rejected results retain state/version. */
|
||||
transition(state: EngineState<C>, request: CommandRequest<C>): TransitionResult<C>;
|
||||
/** Atomically replaces graph state when `expectedVersion` matches; invalid or stale requests are rejected unchanged. */
|
||||
replaceState(state: EngineState<C>, request: StateReplacementRequest<C>): TransitionResult<C>;
|
||||
/** Atomically decodes compatible durable data and replaces state; failures preserve the original state. */
|
||||
load(state: EngineState<C>, value: unknown, expectedVersion?: number, id?: CommandId): LoadResult<C>;
|
||||
/** Validates compatibility and atomically replays save data; any invalid command preserves the original state. */
|
||||
replaySaveData(state: EngineState<C>, value: unknown, expectedVersion?: number, id?: CommandId): ReplayResult<C>;
|
||||
validateReplayJournal(
|
||||
baseline: GraphLayoutV2,
|
||||
commands: readonly FxNodeReplayCommand<C>[],
|
||||
historyLimit?: number,
|
||||
): boolean;
|
||||
/** Returns a deeply immutable snapshot carrying the engine state's current version. */
|
||||
getState(state: EngineState<C>): GraphSnapshot<C>;
|
||||
}
|
||||
/** @internal Binds an already compiled authority without recompiling it. */
|
||||
export function bindFxNodeHeadless<C extends FxNodeCompositionData>(
|
||||
compiled: CompiledFxNodeComposition<C>,
|
||||
): FxNodeHeadless<C> {
|
||||
return Object.freeze({ ...bindDocument(compiled), ...bindEngine(compiled) });
|
||||
}
|
||||
|
||||
/** Creates an isolated composition-bound document and engine runtime. The composition is compiled exactly once. */
|
||||
export function createFxNodeHeadless<const C extends FxNodeCompositionData>(
|
||||
composition: C & ReferenceCheck<C>,
|
||||
): FxNodeHeadless<C> {
|
||||
const compiled = compileFxNodeComposition(composition);
|
||||
return bindFxNodeHeadless(compiled);
|
||||
}
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Composition-bound graph and command execution without browser or worker resources.
|
||||
* @module fxnode/headless
|
||||
*/
|
||||
export * from "./core/types.js";
|
||||
export type {
|
||||
Command,
|
||||
BatchCommand,
|
||||
FxNodeReplayCommand,
|
||||
FxNodeSaveData,
|
||||
CompatibleFxNodeSaveData,
|
||||
CommandRequest,
|
||||
CommandError,
|
||||
} from "./commands/types.js";
|
||||
export { FXNODE_SAVE_DATA_LIMITS } from "./commands/save-data.js";
|
||||
export type { Mutation } from "./engine/mutations.js";
|
||||
export type { DecodeResult, ValidationIssue } from "./composition/bound-document.js";
|
||||
export type {
|
||||
EngineState,
|
||||
LoadResult,
|
||||
ReplayResult,
|
||||
MutationEnvelope,
|
||||
SnapshotEnvelope,
|
||||
StateReplacementRequest,
|
||||
TransitionResult,
|
||||
} from "./composition/bound-engine.js";
|
||||
export * from "./composition/index.js";
|
||||
export type { FxNodeHeadless } from "./headless-runtime.js";
|
||||
export { createFxNodeHeadless } from "./headless-runtime.js";
|
||||
Vendored
+30
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Browser client and static composition API for fxnode.
|
||||
*
|
||||
* The root client owns graph/composition authority and its worker until {@link FxNode.destroy}.
|
||||
* Canvases and host interaction belong to independently attached {@link FxNodeView} handles.
|
||||
* @module fxnode
|
||||
*/
|
||||
export * from "./browser/client.js";
|
||||
export type {
|
||||
FxNodeModifiers,
|
||||
FxNodeInput,
|
||||
FxNodeViewport,
|
||||
FxNodeCamera,
|
||||
FxNodeHostSnapshot,
|
||||
AddNodeParams,
|
||||
FxNodeActionOptions,
|
||||
FxNodeSelectionSnapshot,
|
||||
FxNodeAddNodeMenuRequest,
|
||||
FxNodeResourceAuthorization,
|
||||
FxNodeImageResourceDescriptor,
|
||||
FxNodeResourceOpenRequest,
|
||||
FxNodeResourceData,
|
||||
FxNodeHostRequest,
|
||||
} from "./browser/host-types.js";
|
||||
export { FXNODE_VIEW_LIMITS, fxNodeDevicePixels } from "./browser/view-limits.js";
|
||||
export * from "./core/types.js";
|
||||
export type { Command, BatchCommand, FxNodeReplayCommand, FxNodeSaveData } from "./commands/types.js";
|
||||
export type { Mutation } from "./engine/mutations.js";
|
||||
export type { MutationEnvelope, SnapshotEnvelope } from "./composition/bound-engine.js";
|
||||
export * from "./composition/index.js";
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import type { ColorPickerLayout, Rect } from "./types.js";
|
||||
import type { Vec2 } from "../core/types.js";
|
||||
export function layoutColorPicker(anchor: Rect, viewport: Vec2): ColorPickerLayout {
|
||||
const padding = 10,
|
||||
gap = 8,
|
||||
planeSize = 176,
|
||||
strip = 18,
|
||||
width = 250,
|
||||
height = 326;
|
||||
let x = anchor.x + anchor.width + 8,
|
||||
y = anchor.y;
|
||||
if (x + width > viewport.x - 8) x = anchor.x - width - 8;
|
||||
if (y + height > viewport.y - 8) y = viewport.y - height - 8;
|
||||
x = Math.max(8, x);
|
||||
y = Math.max(8, y);
|
||||
const fields = (count: number, yy: number) =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
x: x + padding + (i * (width - padding * 2 + 4)) / count,
|
||||
y: yy,
|
||||
width: (width - padding * 2 - (count - 1) * 4) / count,
|
||||
height: 22,
|
||||
}));
|
||||
return {
|
||||
bounds: { x, y, width, height },
|
||||
confirm: { x: x + 6, y: y + 4, width: 24, height: 24 },
|
||||
plane: { x: x + padding, y: y + 32, width: planeSize, height: planeSize },
|
||||
lightness: { x: x + padding + planeSize + gap, y: y + 32, width: strip, height: planeSize },
|
||||
alpha: { x: x + padding + planeSize + gap + strip + gap, y: y + 32, width: strip, height: planeSize },
|
||||
rgba: fields(4, y + 220) as unknown as ColorPickerLayout["rgba"],
|
||||
hsv: fields(3, y + 250) as unknown as ColorPickerLayout["hsv"],
|
||||
hex: { x: x + padding, y: y + 280, width: width - padding * 2, height: 24 },
|
||||
};
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
export const GEOMETRY = Object.freeze({
|
||||
unit: 20,
|
||||
row: 24,
|
||||
parameterRow: 26,
|
||||
header: 24,
|
||||
half: 12,
|
||||
gap: 4,
|
||||
corner: 7,
|
||||
socket: 5,
|
||||
margin: 24,
|
||||
resize: 4,
|
||||
grid: 20,
|
||||
linkSamples: 12,
|
||||
initialNodeWidth: 140,
|
||||
initialNodeHeight: 100,
|
||||
initialFrameWidth: 300,
|
||||
minWidth: 100,
|
||||
frameMinimum: 100,
|
||||
maxWidth: 700,
|
||||
frameMargin: 30,
|
||||
reroute: 5,
|
||||
});
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
import type { Vec2 } from "../core/types.js";
|
||||
import type { Rect, ViewTransform } from "./types.js";
|
||||
|
||||
export const worldToView = (p: Vec2, t: ViewTransform): Vec2 => ({
|
||||
x: (p.x - t.center.x) * t.zoom + t.viewport.x / 2,
|
||||
y: (t.center.y - p.y) * t.zoom + t.viewport.y / 2,
|
||||
});
|
||||
export const viewToWorld = (p: Vec2, t: ViewTransform): Vec2 => ({
|
||||
x: (p.x - t.viewport.x / 2) / t.zoom + t.center.x,
|
||||
y: t.center.y - (p.y - t.viewport.y / 2) / t.zoom,
|
||||
});
|
||||
export const viewToDevice = (p: Vec2, t: ViewTransform): Vec2 => ({ x: p.x * t.dpr, y: p.y * t.dpr });
|
||||
export const deviceToView = (p: Vec2, t: ViewTransform): Vec2 => ({ x: p.x / t.dpr, y: p.y / t.dpr });
|
||||
export const intersects = (a: Rect, b: Rect, overscan = 0): boolean =>
|
||||
a.x + a.width >= b.x - overscan &&
|
||||
a.x <= b.x + b.width + overscan &&
|
||||
a.y - a.height <= b.y + overscan &&
|
||||
a.y >= b.y - b.height - overscan;
|
||||
export function bounds(points: readonly Vec2[]): Rect {
|
||||
const xs = points.map((p) => p.x);
|
||||
const ys = points.map((p) => p.y);
|
||||
const minX = Math.min(...xs),
|
||||
maxX = Math.max(...xs),
|
||||
minY = Math.min(...ys),
|
||||
maxY = Math.max(...ys);
|
||||
return { x: minX, y: maxY, width: maxX - minX, height: maxY - minY };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { GraphDocument, LinkId, NodeId } from "../core/types.js";
|
||||
import type { CompiledFxNodeComposition, FxNodeCompositionData } from "../composition/types.js";
|
||||
import { GEOMETRY as G } from "./constants.js";
|
||||
import { buildLayoutScene, createLayoutView } from "./layout-graph.js";
|
||||
import { LooseQuadtree } from "./spatial-index.js";
|
||||
import type { LayoutScene, LayoutView, Rect, ViewTransform } from "./types.js";
|
||||
|
||||
const box = (r: Rect) => ({ minX: r.x, minY: r.y - r.height, maxX: r.x + r.width, maxY: r.y });
|
||||
export interface LayoutStoreMetrics {
|
||||
persistentRebuilds: number;
|
||||
indexBuildMs: number;
|
||||
indexQueries: number;
|
||||
indexQueryMs: number;
|
||||
}
|
||||
export class IndexedLayoutStore<C extends FxNodeCompositionData> {
|
||||
scene: LayoutScene;
|
||||
readonly nodeIndex = new LooseQuadtree<NodeId>();
|
||||
readonly linkIndex = new LooseQuadtree<LinkId>();
|
||||
readonly metrics: LayoutStoreMetrics = { persistentRebuilds: 0, indexBuildMs: 0, indexQueries: 0, indexQueryMs: 0 };
|
||||
constructor(
|
||||
readonly compiled: CompiledFxNodeComposition<C>,
|
||||
document: GraphDocument<C>,
|
||||
) {
|
||||
this.scene = buildLayoutScene(compiled, document);
|
||||
this.rebuild(document);
|
||||
}
|
||||
rebuild(document: GraphDocument<C>): void {
|
||||
const start = performance.now();
|
||||
this.scene = buildLayoutScene(this.compiled, document);
|
||||
this.nodeIndex.clear();
|
||||
this.linkIndex.clear();
|
||||
for (const [id, n] of this.scene.nodes) this.nodeIndex.insert(id, id, box(n.bounds));
|
||||
for (const [id, l] of this.scene.links) this.linkIndex.insert(id, id, box(l.bounds));
|
||||
this.metrics.persistentRebuilds++;
|
||||
this.metrics.indexBuildMs += performance.now() - start;
|
||||
}
|
||||
view(transform: ViewTransform): LayoutView {
|
||||
const start = performance.now(),
|
||||
w = transform.viewport.x / transform.zoom / 2 + G.margin,
|
||||
h = transform.viewport.y / transform.zoom / 2 + G.margin,
|
||||
q = {
|
||||
minX: transform.center.x - w,
|
||||
minY: transform.center.y - h,
|
||||
maxX: transform.center.x + w,
|
||||
maxY: transform.center.y + h,
|
||||
};
|
||||
const nodes = this.nodeIndex.query(q),
|
||||
links = this.linkIndex.query(q);
|
||||
this.metrics.indexQueries++;
|
||||
this.metrics.indexQueryMs += performance.now() - start;
|
||||
return createLayoutView(this.scene, transform, nodes, links);
|
||||
}
|
||||
}
|
||||
+588
@@ -0,0 +1,588 @@
|
||||
import type { GraphDocument, GraphNode, LinkId, NodeId, SocketId, Vec2 } from "../core/types.js";
|
||||
import type {
|
||||
CompiledFxNodeComposition,
|
||||
FxNodeCompositionData,
|
||||
FxNodeDefinition,
|
||||
FxNodeUiRow,
|
||||
FxNodeValueSchema,
|
||||
} from "../composition/types.js";
|
||||
import { GEOMETRY as G } from "./constants.js";
|
||||
import { bounds, intersects } from "./geometry.js";
|
||||
import type {
|
||||
LayoutControl,
|
||||
LayoutLink,
|
||||
LayoutNode,
|
||||
LayoutNumericField,
|
||||
LayoutRow,
|
||||
LayoutScene,
|
||||
LayoutSnapshot,
|
||||
LayoutSocket,
|
||||
LayoutSubfield,
|
||||
LayoutView,
|
||||
Rect,
|
||||
ViewTransform,
|
||||
} from "./types.js";
|
||||
import { effectivelyMutedLinks } from "./link-mute.js";
|
||||
import { minimumNodeSize, nodeRowUnits, visibleNodeItems } from "./node-dimensions.js";
|
||||
|
||||
const title = (value: string): string => value.replace(/-/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
function controlKind(schema: FxNodeValueSchema | undefined): LayoutControl["kind"] {
|
||||
if (!schema) return "readonly-json";
|
||||
if (schema.type === "string" && schema.enum) return "enum";
|
||||
return schema.type === "json" ? "readonly-json" : schema.type;
|
||||
}
|
||||
function makeSubfields(bounds: Rect, type: FxNodeValueSchema["type"] | undefined): readonly LayoutSubfield[] {
|
||||
const labels = type === "vector" ? (["X", "Y", "Z"] as const) : [];
|
||||
const gutter = 3;
|
||||
const width = labels.length ? (bounds.width - gutter * (labels.length - 1)) / labels.length : 0;
|
||||
return labels.map((label, index) => ({
|
||||
index,
|
||||
label,
|
||||
bounds: { x: bounds.x + index * (width + gutter), y: bounds.y, width, height: bounds.height },
|
||||
}));
|
||||
}
|
||||
function makeNumericFields(
|
||||
bounds: Rect,
|
||||
schema: FxNodeValueSchema | undefined,
|
||||
subfields: readonly LayoutSubfield[],
|
||||
): readonly LayoutNumericField[] {
|
||||
const fields = schema?.type === "number" ? [{ index: 0, bounds }] : schema?.type === "vector" ? subfields : [];
|
||||
const minimum =
|
||||
schema?.type === "number"
|
||||
? (schema.softMin ?? schema.minimum)
|
||||
: schema?.type === "vector"
|
||||
? schema.minimum
|
||||
: undefined;
|
||||
const maximum =
|
||||
schema?.type === "number"
|
||||
? (schema.softMax ?? schema.maximum)
|
||||
: schema?.type === "vector"
|
||||
? schema.maximum
|
||||
: undefined;
|
||||
const range =
|
||||
Number.isFinite(minimum) && Number.isFinite(maximum) && maximum! > minimum!
|
||||
? { minimum: minimum!, maximum: maximum! }
|
||||
: undefined;
|
||||
return fields.map((field) => {
|
||||
const arrow = Math.min(7, field.bounds.width * 0.14);
|
||||
return {
|
||||
component: field.index,
|
||||
bounds: field.bounds,
|
||||
decrement: { ...field.bounds, width: arrow },
|
||||
value: { ...field.bounds, x: field.bounds.x + arrow, width: Math.max(0, field.bounds.width - arrow * 2) },
|
||||
increment: { ...field.bounds, x: field.bounds.x + field.bounds.width - arrow, width: arrow },
|
||||
...(range ? { range } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
function cubic(a: Vec2, b: Vec2): readonly Vec2[] {
|
||||
const dx = Math.max(40, Math.abs(b.x - a.x) * 0.5);
|
||||
return Array.from({ length: G.linkSamples + 1 }, (_, index) => {
|
||||
const t = index / G.linkSamples,
|
||||
u = 1 - t;
|
||||
return {
|
||||
x: u ** 3 * a.x + 3 * u ** 2 * t * (a.x + dx) + 3 * u * t ** 2 * (b.x - dx) + t ** 3 * b.x,
|
||||
y: u ** 3 * a.y + 3 * u ** 2 * t * a.y + 3 * u * t ** 2 * b.y + t ** 3 * b.y,
|
||||
};
|
||||
});
|
||||
}
|
||||
function cubicBounds(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2) {
|
||||
const points = [p0, p3];
|
||||
for (const axis of ["x", "y"] as const) {
|
||||
const a = -p0[axis] + 3 * p1[axis] - 3 * p2[axis] + p3[axis],
|
||||
b = 2 * (p0[axis] - 2 * p1[axis] + p2[axis]),
|
||||
c = p1[axis] - p0[axis],
|
||||
d = b * b - 4 * a * c;
|
||||
const roots =
|
||||
Math.abs(a) < 1e-12
|
||||
? Math.abs(b) < 1e-12
|
||||
? []
|
||||
: [-c / b]
|
||||
: d < 0
|
||||
? []
|
||||
: [(-b + Math.sqrt(d)) / (2 * a), (-b - Math.sqrt(d)) / (2 * a)];
|
||||
for (const t of roots)
|
||||
if (t > 0 && t < 1) {
|
||||
const u = 1 - t;
|
||||
points.push({
|
||||
x: u ** 3 * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t ** 3 * p3.x,
|
||||
y: u ** 3 * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t ** 3 * p3.y,
|
||||
});
|
||||
}
|
||||
}
|
||||
return bounds(points);
|
||||
}
|
||||
export function buildLayoutScene<C extends FxNodeCompositionData>(
|
||||
compiled: CompiledFxNodeComposition<C>,
|
||||
document: GraphDocument<C>,
|
||||
): LayoutScene {
|
||||
const nodes = new Map<NodeId, LayoutNode>();
|
||||
const sockets = new Map<SocketId, LayoutSocket>();
|
||||
const links = new Map<LinkId, LayoutLink>();
|
||||
const controls = new Map<string, LayoutControl>();
|
||||
const sorted = Object.values(document.nodes).sort((a, b) => a.id.localeCompare(b.id));
|
||||
const effectiveMuted = effectivelyMutedLinks(compiled, document);
|
||||
const linksBySocket = new Map<string, LinkId[]>();
|
||||
for (const link of Object.values(document.links))
|
||||
if (!effectiveMuted.has(link.id))
|
||||
for (const id of [link.fromSocketId, link.toSocketId]) {
|
||||
const list = linksBySocket.get(id) ?? [];
|
||||
list.push(link.id);
|
||||
linksBySocket.set(id, list);
|
||||
}
|
||||
const childrenByParent = new Map<string, GraphNode[]>();
|
||||
for (const node of sorted)
|
||||
if (node.parentId) {
|
||||
const list = childrenByParent.get(node.parentId) ?? [];
|
||||
list.push(node);
|
||||
childrenByParent.set(node.parentId, list);
|
||||
}
|
||||
const origins = new Map<string, Vec2>(),
|
||||
depths = new Map<string, number>();
|
||||
const resolve = (node: GraphNode): Vec2 => {
|
||||
const known = origins.get(node.id);
|
||||
if (known) return known;
|
||||
const parent = node.parentId ? document.nodes[node.parentId] : undefined,
|
||||
p = parent ? resolve(parent) : { x: 0, y: 0 };
|
||||
depths.set(node.id, parent ? depths.get(parent.id)! + 1 : 0);
|
||||
const at = { x: p.x + node.position.x, y: p.y + node.position.y };
|
||||
origins.set(node.id, at);
|
||||
return at;
|
||||
};
|
||||
for (const node of sorted) {
|
||||
const at = resolve(node),
|
||||
descriptor = node.known ? (compiled.nodes.get(node.typeId) as FxNodeDefinition | undefined) : undefined;
|
||||
if (node.known && !descriptor) throw new Error(`Missing compiled node definition: ${node.typeId}`);
|
||||
const kind = descriptor?.behavior === "frame" ? "frame" : descriptor?.behavior === "reroute" ? "reroute" : "node";
|
||||
const descriptorSockets = new Map(Object.entries(descriptor?.sockets ?? {}));
|
||||
const visibleSockets = node.sockets.filter((socket) => {
|
||||
const row = descriptor?.ui.find(
|
||||
(row) =>
|
||||
(row.kind === "socket" || (row.kind === "hidden" && row.target === "socket")) && row.socket === socket.key,
|
||||
);
|
||||
return (
|
||||
socket.visible && (!descriptor || (row?.kind === "socket" && visibleNodeItems(descriptor, node).includes(row)))
|
||||
);
|
||||
});
|
||||
const ui: readonly FxNodeUiRow[] = descriptor?.ui ?? [
|
||||
...Object.keys(node.parameters)
|
||||
.sort()
|
||||
.map((parameter) => ({ kind: "parameter" as const, parameter })),
|
||||
...node.sockets.map((socket) => ({ kind: "socket" as const, socket: socket.key })),
|
||||
];
|
||||
const expandedItems = kind === "node" ? (descriptor ? visibleNodeItems(descriptor, node) : ui) : [];
|
||||
const visibleItems = node.collapsed ? [] : expandedItems;
|
||||
const contentHeight =
|
||||
kind === "frame"
|
||||
? Math.max(G.frameMinimum, node.size.y)
|
||||
: kind === "reroute"
|
||||
? G.reroute * 2
|
||||
: node.collapsed
|
||||
? G.header
|
||||
: G.header + visibleItems.reduce((sum, item) => sum + nodeRowUnits(item), 0) * G.row + G.gap;
|
||||
const calculated = descriptor ? minimumNodeSize(descriptor, node) : { x: G.minWidth, y: contentHeight };
|
||||
const minimumSize = { x: calculated.x, y: kind === "node" && node.collapsed ? G.header : calculated.y };
|
||||
const width =
|
||||
kind === "reroute"
|
||||
? G.reroute * 2
|
||||
: kind === "frame"
|
||||
? Math.max(minimumSize.x, node.size.x)
|
||||
: Math.min(G.maxWidth, Math.max(minimumSize.x, node.size.x));
|
||||
const height = kind === "node" && !node.collapsed ? Math.max(contentHeight, node.size.y) : contentHeight;
|
||||
const nodeBounds = { x: at.x, y: at.y, width, height };
|
||||
const rowBySocket = new Map<string, number>();
|
||||
let socketRowOffset = 0;
|
||||
for (const item of visibleItems) {
|
||||
if (item.kind === "socket") rowBySocket.set(item.socket, socketRowOffset);
|
||||
socketRowOffset += nodeRowUnits(item);
|
||||
}
|
||||
const layoutSockets: LayoutSocket[] = visibleSockets.map((socket) => {
|
||||
const linkIds = linksBySocket.get(socket.id) ?? [];
|
||||
const linked = linkIds.length > 0;
|
||||
const row = rowBySocket.get(socket.key) ?? 0;
|
||||
const placement = descriptor?.ui.find((item) => item.kind === "socket" && item.socket === socket.key);
|
||||
const socketType = descriptor ? compiled.socketTypes.get(socket.dataType as never) : undefined;
|
||||
if (descriptor && !socketType) throw new Error(`Missing compiled socket type: ${socket.dataType}`);
|
||||
return {
|
||||
id: socket.id,
|
||||
nodeId: node.id,
|
||||
label: placement?.kind === "socket" ? (placement.title ?? title(socket.label)) : title(socket.label),
|
||||
dataType: socket.dataType as LayoutSocket["dataType"],
|
||||
color: socketType?.color ?? compiled.theme.unknownSocket,
|
||||
wildcardInput:
|
||||
socket.direction === "input" && compiled.compatibility.wildcardInputTypes.includes(socket.dataType),
|
||||
direction: socket.direction,
|
||||
accepts: socket.accepts,
|
||||
capacity: socket.maxIncomingLinks,
|
||||
linkIds,
|
||||
linked,
|
||||
anchor:
|
||||
kind === "reroute"
|
||||
? { x: at.x + G.reroute, y: at.y - G.reroute }
|
||||
: {
|
||||
x: at.x + (socket.direction === "output" ? width : 0),
|
||||
y: at.y - (node.collapsed ? G.half : G.header + G.half + row * G.row),
|
||||
},
|
||||
};
|
||||
});
|
||||
for (const socket of layoutSockets) sockets.set(socket.id, socket);
|
||||
const rows: LayoutRow[] = [];
|
||||
let rowOffset = 0;
|
||||
for (const item of visibleItems) {
|
||||
const units = nodeRowUnits(item);
|
||||
const rowBounds: Rect = { x: at.x, y: at.y - G.header - rowOffset * G.row, width, height: units * G.row };
|
||||
if (item.kind === "text") {
|
||||
rows.push({ kind: item.variant, label: item.title, units, bounds: rowBounds });
|
||||
rowOffset += units;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
item.kind === "parameter" ||
|
||||
item.kind === "resource" ||
|
||||
(item.kind === "widget" && item.widget === "color-ramp")
|
||||
) {
|
||||
const key = item.parameter,
|
||||
schema = descriptor?.parameters[key],
|
||||
ramp = item.kind === "widget";
|
||||
const id = `${node.id}:parameter:${key}`;
|
||||
const controlBounds =
|
||||
ramp || schema?.type === "number"
|
||||
? { x: at.x + 10, y: rowBounds.y - 3, width: width - 20, height: ramp ? units * G.row - 6 : G.row - 6 }
|
||||
: { x: at.x + width * 0.42, y: rowBounds.y - 3, width: width * 0.53, height: G.row - 6 };
|
||||
const subfields = makeSubfields(controlBounds, schema?.type);
|
||||
const rampBounds = ramp
|
||||
? {
|
||||
toolbar: { x: controlBounds.x, y: controlBounds.y, width: controlBounds.width, height: 20 },
|
||||
mode: { x: controlBounds.x, y: controlBounds.y - 22, width: controlBounds.width * 0.3, height: 20 },
|
||||
interpolation: {
|
||||
x: controlBounds.x + controlBounds.width * 0.31,
|
||||
y: controlBounds.y - 22,
|
||||
width: controlBounds.width * 0.4,
|
||||
height: 20,
|
||||
},
|
||||
hue: {
|
||||
x: controlBounds.x + controlBounds.width * 0.72,
|
||||
y: controlBounds.y - 22,
|
||||
width: controlBounds.width * 0.28,
|
||||
height: 20,
|
||||
},
|
||||
gradient: {
|
||||
x: controlBounds.x + 8,
|
||||
y: controlBounds.y - 46,
|
||||
width: controlBounds.width - 16,
|
||||
height: 28,
|
||||
},
|
||||
handles: { x: controlBounds.x + 8, y: controlBounds.y - 74, width: controlBounds.width - 16, height: 28 },
|
||||
selector: { x: controlBounds.x, y: controlBounds.y - 104, width: controlBounds.width * 0.25, height: 20 },
|
||||
position: {
|
||||
x: controlBounds.x + controlBounds.width * 0.27,
|
||||
y: controlBounds.y - 104,
|
||||
width: controlBounds.width * 0.35,
|
||||
height: 20,
|
||||
},
|
||||
color: { x: controlBounds.x, y: controlBounds.y - 126, width: controlBounds.width, height: 20 },
|
||||
}
|
||||
: undefined;
|
||||
const resourceBounds =
|
||||
item.kind === "resource"
|
||||
? {
|
||||
preview: { x: at.x + 10, y: rowBounds.y - 4, width: width - 20, height: units * G.row - 34 },
|
||||
open: { x: at.x + 10, y: rowBounds.y - units * G.row + 26, width: width - 20, height: 22 },
|
||||
}
|
||||
: undefined;
|
||||
const resource = item.kind === "resource" ? compiled.resources.get(item.resource as never) : undefined;
|
||||
if (item.kind === "resource" && !resource) throw new Error(`Missing compiled resource: ${item.resource}`);
|
||||
const control: LayoutControl = {
|
||||
id,
|
||||
nodeId: node.id,
|
||||
source: descriptor ? "parameter" : "unknown",
|
||||
key,
|
||||
label: ("title" in item ? item.title : undefined) ?? resource?.title ?? title(key),
|
||||
kind: item.kind === "resource" ? "resource" : ramp ? "color-ramp" : controlKind(schema),
|
||||
value: node.parameters[key],
|
||||
...(schema ? { schema } : {}),
|
||||
...(resource && item.kind === "resource"
|
||||
? {
|
||||
resourceId: item.resource,
|
||||
resourceReferencePrefix: resource.referencePrefix,
|
||||
openTitle: item.openTitle ?? resource.openTitle,
|
||||
}
|
||||
: {}),
|
||||
linked: false,
|
||||
bounds: item.kind === "resource" ? resourceBounds!.open : controlBounds,
|
||||
subfields,
|
||||
numericFields: makeNumericFields(controlBounds, schema, subfields),
|
||||
...(rampBounds ? { rampBounds } : {}),
|
||||
...(resourceBounds ? { resourceBounds } : {}),
|
||||
};
|
||||
controls.set(id, control);
|
||||
rows.push({ kind: "control", controlId: id, units, bounds: rowBounds });
|
||||
} else if (item.kind === "widget" && item.widget === "grading-wheels") {
|
||||
const gap = 10,
|
||||
padding = 10,
|
||||
columnWidth = (width - padding * 2 - gap * 2) / 3;
|
||||
const wheels = item.bindings.map((wheel, index) => {
|
||||
const x = at.x + padding + index * (columnWidth + gap),
|
||||
scalarSchema = descriptor?.parameters[wheel.scalar],
|
||||
colorSchema = descriptor?.parameters[wheel.color],
|
||||
scalarId = `${node.id}:parameter:${wheel.scalar}`,
|
||||
colorId = `${node.id}:parameter:${wheel.color}`,
|
||||
labelBounds = { x, y: rowBounds.y - 4, width: columnWidth, height: 18 },
|
||||
plane = { x: x + 2, y: rowBounds.y - 25, width: columnWidth - 24, height: columnWidth - 24 },
|
||||
lightness = { x: x + columnWidth - 18, y: rowBounds.y - 25, width: 14, height: columnWidth - 24 },
|
||||
scalarBounds = {
|
||||
x: x + 2,
|
||||
y: rowBounds.y - 25 - (columnWidth - 24) - 10,
|
||||
width: columnWidth - 6,
|
||||
height: 18,
|
||||
},
|
||||
colorBounds = {
|
||||
x: plane.x,
|
||||
y: plane.y,
|
||||
width: lightness.x + lightness.width - plane.x,
|
||||
height: plane.height,
|
||||
};
|
||||
controls.set(scalarId, {
|
||||
id: scalarId,
|
||||
nodeId: node.id,
|
||||
source: "parameter",
|
||||
key: wheel.scalar,
|
||||
label: wheel.title,
|
||||
kind: controlKind(scalarSchema),
|
||||
value: node.parameters[wheel.scalar],
|
||||
...(scalarSchema ? { schema: scalarSchema } : {}),
|
||||
linked: false,
|
||||
bounds: scalarBounds,
|
||||
subfields: [],
|
||||
numericFields: makeNumericFields(scalarBounds, scalarSchema, []),
|
||||
});
|
||||
controls.set(colorId, {
|
||||
id: colorId,
|
||||
nodeId: node.id,
|
||||
source: "parameter",
|
||||
key: wheel.color,
|
||||
label: wheel.title,
|
||||
kind: controlKind(colorSchema),
|
||||
value: node.parameters[wheel.color],
|
||||
...(colorSchema ? { schema: colorSchema } : {}),
|
||||
linked: false,
|
||||
bounds: colorBounds,
|
||||
subfields: [],
|
||||
numericFields: [],
|
||||
colorWheelBounds: { plane, lightness },
|
||||
});
|
||||
return { label: wheel.title, labelBounds, scalarControlId: scalarId, colorControlId: colorId };
|
||||
});
|
||||
rows.push({
|
||||
kind: "grading-wheels",
|
||||
wheels: wheels as unknown as Extract<LayoutRow, { kind: "grading-wheels" }>["wheels"],
|
||||
units,
|
||||
bounds: rowBounds,
|
||||
});
|
||||
} else if (item.kind === "socket") {
|
||||
const raw = node.sockets.find((socket) => socket.key === item.socket);
|
||||
const socket = raw && sockets.get(raw.id);
|
||||
if (!raw || !socket) continue;
|
||||
const socketDescriptor = descriptorSockets.get(item.socket),
|
||||
schema = socketDescriptor?.showValue ? (socketDescriptor.value ?? undefined) : undefined;
|
||||
let controlId: string | undefined;
|
||||
if (schema && socket.direction === "input") {
|
||||
controlId = `${node.id}:socket:${socket.id}`;
|
||||
const controlBounds =
|
||||
schema.type === "number"
|
||||
? { x: at.x + 12, y: rowBounds.y - 3, width: width - 24, height: G.row - 6 }
|
||||
: { x: at.x + width * 0.42, y: rowBounds.y - 3, width: width * 0.53, height: G.row - 6 };
|
||||
const subfields = makeSubfields(controlBounds, schema.type);
|
||||
controls.set(controlId, {
|
||||
id: controlId,
|
||||
nodeId: node.id,
|
||||
source: "socket-default",
|
||||
key: socket.id,
|
||||
label: item.title ?? socket.label,
|
||||
kind: controlKind(schema),
|
||||
value: raw.defaultValue,
|
||||
schema,
|
||||
linked: socket.linked,
|
||||
bounds: controlBounds,
|
||||
subfields,
|
||||
numericFields: makeNumericFields(controlBounds, schema, subfields),
|
||||
});
|
||||
}
|
||||
rows.push({
|
||||
kind: "socket",
|
||||
socketId: socket.id,
|
||||
...(controlId ? { controlId } : {}),
|
||||
units: 1,
|
||||
bounds: rowBounds,
|
||||
});
|
||||
}
|
||||
rowOffset += units;
|
||||
}
|
||||
if (kind === "reroute" && layoutSockets[0]) {
|
||||
rows.push({ kind: "socket", socketId: layoutSockets[0].id, units: 1, bounds: nodeBounds });
|
||||
}
|
||||
const byKey = new Map(node.sockets.map((s) => [s.key, s.id]));
|
||||
const bypasses = node.muted
|
||||
? (descriptor?.muteBypass ?? []).flatMap(([a, b]) => {
|
||||
const from = byKey.get(a),
|
||||
to = byKey.get(b),
|
||||
aa = from && sockets.get(from)?.anchor,
|
||||
bb = to && sockets.get(to)?.anchor;
|
||||
return aa && bb ? [{ from: aa, to: bb }] : [];
|
||||
})
|
||||
: [];
|
||||
const style = descriptor && compiled.styles.get(descriptor.style as never);
|
||||
if (descriptor && !style) throw new Error(`Missing compiled style: ${descriptor.style}`);
|
||||
nodes.set(node.id, {
|
||||
id: node.id,
|
||||
...(node.parentId ? { parentId: node.parentId } : {}),
|
||||
typeId: node.typeId,
|
||||
label: node.label,
|
||||
...(descriptor ? { styleId: descriptor.style } : {}),
|
||||
headerColor: style?.header ?? compiled.theme.unknownHeader,
|
||||
kind,
|
||||
localPosition: node.position,
|
||||
worldPosition: at,
|
||||
authoredSize: node.size,
|
||||
minimumSize,
|
||||
bounds: nodeBounds,
|
||||
header: { x: at.x, y: at.y, width, height: kind === "reroute" ? 0 : G.header },
|
||||
collapseHitRect: { x: at.x, y: at.y, width: 14, height: G.header },
|
||||
resizeHitRect: {
|
||||
x: at.x + width - G.resize,
|
||||
y: at.y - height + G.resize,
|
||||
width: G.resize * 2,
|
||||
height: G.resize * 2,
|
||||
},
|
||||
collapsed: node.collapsed,
|
||||
muted: node.muted,
|
||||
visible: true,
|
||||
rows,
|
||||
bypasses,
|
||||
} satisfies LayoutNode);
|
||||
}
|
||||
// Frames are behind all regular nodes. Their authored size is expanded to contain direct children.
|
||||
for (const frame of sorted
|
||||
.filter((node) => nodes.get(node.id)?.kind === "frame")
|
||||
.sort((a, b) => depths.get(b.id)! - depths.get(a.id)!)) {
|
||||
const children = (childrenByParent.get(frame.id) ?? []).map((node) => nodes.get(node.id) as LayoutNode);
|
||||
if (children.length) {
|
||||
const childBounds = bounds(
|
||||
children.flatMap((child) => [
|
||||
{ x: child.bounds.x, y: child.bounds.y },
|
||||
{ x: child.bounds.x + child.bounds.width, y: child.bounds.y - child.bounds.height },
|
||||
]),
|
||||
);
|
||||
const current = nodes.get(frame.id) as LayoutNode;
|
||||
const fitted = {
|
||||
x: Math.min(current.bounds.x, childBounds.x - G.frameMargin),
|
||||
y: Math.max(current.bounds.y, childBounds.y + G.frameMargin),
|
||||
width:
|
||||
Math.max(current.bounds.x + current.bounds.width, childBounds.x + childBounds.width + G.frameMargin) -
|
||||
Math.min(current.bounds.x, childBounds.x - G.frameMargin),
|
||||
height:
|
||||
Math.max(current.bounds.y, childBounds.y + G.frameMargin) -
|
||||
Math.min(current.bounds.y - current.bounds.height, childBounds.y - childBounds.height - G.frameMargin),
|
||||
};
|
||||
nodes.set(frame.id, { ...current, bounds: fitted, header: { ...fitted, height: G.header } });
|
||||
}
|
||||
}
|
||||
for (const link of Object.values(document.links).sort((a, b) => a.id.localeCompare(b.id))) {
|
||||
const from = sockets.get(link.fromSocketId) as LayoutSocket | undefined,
|
||||
to = sockets.get(link.toSocketId) as LayoutSocket | undefined;
|
||||
if (!from || !to) continue;
|
||||
const points = cubic(from.anchor, to.anchor);
|
||||
const dx = Math.max(40, Math.abs(to.anchor.x - from.anchor.x) * 0.5);
|
||||
const cs = [
|
||||
{ x: from.anchor.x + dx, y: from.anchor.y },
|
||||
{ x: to.anchor.x - dx, y: to.anchor.y },
|
||||
] as const,
|
||||
linkBounds = cubicBounds(from.anchor, cs[0], cs[1], to.anchor);
|
||||
links.set(link.id, {
|
||||
id: link.id,
|
||||
fromNodeId: link.fromNodeId,
|
||||
fromSocketId: link.fromSocketId,
|
||||
toNodeId: link.toNodeId,
|
||||
toSocketId: link.toSocketId,
|
||||
dataType: from.dataType,
|
||||
color: from.color,
|
||||
points,
|
||||
controls: cs,
|
||||
bounds: linkBounds,
|
||||
visible: true,
|
||||
muted: effectiveMuted.has(link.id),
|
||||
} satisfies LayoutLink);
|
||||
}
|
||||
const allBounds = [...nodes.values()].map((node: LayoutNode) => node.bounds);
|
||||
const graphBounds = allBounds.length
|
||||
? bounds(
|
||||
allBounds.flatMap((rect) => [
|
||||
{ x: rect.x, y: rect.y },
|
||||
{ x: rect.x + rect.width, y: rect.y - rect.height },
|
||||
]),
|
||||
)
|
||||
: { x: 0, y: 0, width: 0, height: 0 };
|
||||
const drawOrder = sorted
|
||||
.filter((node) => nodes.get(node.id)?.kind === "frame")
|
||||
.concat(sorted.filter((node) => nodes.get(node.id)?.kind !== "frame"))
|
||||
.map((node) => node.id);
|
||||
return {
|
||||
nodes,
|
||||
sockets,
|
||||
controls,
|
||||
links,
|
||||
drawOrder,
|
||||
graphBounds,
|
||||
nodeRanks: new Map(drawOrder.map((id, i) => [id, i])),
|
||||
linkRanks: new Map([...links.keys()].map((id, i) => [id, i])),
|
||||
};
|
||||
}
|
||||
export function createLayoutView(
|
||||
scene: LayoutScene,
|
||||
transform: ViewTransform,
|
||||
nodeIds: readonly NodeId[] = scene.drawOrder,
|
||||
linkIds: readonly LinkId[] = [...scene.links.keys()],
|
||||
): LayoutView {
|
||||
const viewport = {
|
||||
x: transform.center.x - transform.viewport.x / transform.zoom / 2,
|
||||
y: transform.center.y + transform.viewport.y / transform.zoom / 2,
|
||||
width: transform.viewport.x / transform.zoom,
|
||||
height: transform.viewport.y / transform.zoom,
|
||||
};
|
||||
const ns = nodeIds
|
||||
.filter((id) => {
|
||||
const n = scene.nodes.get(id);
|
||||
return n && intersects(n.bounds, viewport, G.margin);
|
||||
})
|
||||
.sort((a, b) => (scene.nodeRanks.get(a) ?? 0) - (scene.nodeRanks.get(b) ?? 0)),
|
||||
ls = linkIds
|
||||
.filter((id) => {
|
||||
const l = scene.links.get(id);
|
||||
return l && intersects(l.bounds, viewport, G.margin);
|
||||
})
|
||||
.sort((a, b) => (scene.linkRanks.get(a) ?? 0) - (scene.linkRanks.get(b) ?? 0));
|
||||
return {
|
||||
...scene,
|
||||
drawOrder: ns,
|
||||
transform,
|
||||
candidateNodeIds: ns,
|
||||
candidateLinkIds: ls,
|
||||
totalNodes: scene.nodes.size,
|
||||
totalLinks: scene.links.size,
|
||||
};
|
||||
}
|
||||
export function layoutGraph<C extends FxNodeCompositionData>(
|
||||
compiled: CompiledFxNodeComposition<C>,
|
||||
document: GraphDocument<C>,
|
||||
transform: ViewTransform,
|
||||
): LayoutSnapshot {
|
||||
return createLayoutView(buildLayoutScene(compiled, document), transform);
|
||||
}
|
||||
export function applyNodeOrder<T extends LayoutSnapshot>(layout: T, order: readonly NodeId[]): T {
|
||||
const frames = layout.drawOrder.filter((id) => layout.nodes.get(id)?.kind === "frame"),
|
||||
ordinary = layout.drawOrder.filter((id) => layout.nodes.get(id)?.kind !== "frame"),
|
||||
available = new Set(ordinary),
|
||||
promoted = order.filter((id) => available.has(id)),
|
||||
raised = new Set(promoted);
|
||||
return { ...layout, drawOrder: [...frames, ...ordinary.filter((id) => !raised.has(id)), ...promoted] };
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import type { GraphDocument, LinkId } from "../core/types.js";
|
||||
import type { CompiledFxNodeComposition, FxNodeCompositionData } from "../composition/types.js";
|
||||
|
||||
/** Derived reroute mute state. Authored flags are never changed. */
|
||||
export function effectivelyMutedLinks<C extends FxNodeCompositionData>(
|
||||
compiled: CompiledFxNodeComposition<C>,
|
||||
document: GraphDocument<C>,
|
||||
): ReadonlySet<LinkId> {
|
||||
const muted = new Set<LinkId>(
|
||||
Object.values(document.links)
|
||||
.filter((l) => l.muted)
|
||||
.map((l) => l.id),
|
||||
);
|
||||
const reroutes = new Set(
|
||||
Object.values(document.nodes)
|
||||
.filter((n) => n.known && compiled.nodes.get(n.typeId as never)?.behavior === "reroute")
|
||||
.map((n) => n.id),
|
||||
);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const link of Object.values(document.links)) {
|
||||
if (muted.has(link.id) || !reroutes.has(link.fromNodeId)) continue;
|
||||
const incoming = Object.values(document.links).filter((l) => l.toNodeId === link.fromNodeId);
|
||||
if (incoming.length && incoming.every((l) => muted.has(l.id))) {
|
||||
muted.add(link.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return muted;
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
import type { GraphNode, ParameterValue, Vec2 } from "../core/types.js";
|
||||
import type { FxNodeDefinition, FxNodeUiRow, FxNodeValueSchema, FxNodeVisibility } from "../composition/types.js";
|
||||
import { GEOMETRY as G } from "./constants.js";
|
||||
|
||||
const title = (value: string) => value.replace(/-/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
const textWidth = (value: string) => value.length * 6.5;
|
||||
export const nodeRowUnits = (item: FxNodeUiRow): number =>
|
||||
item.kind === "text" && item.variant === "header"
|
||||
? 2
|
||||
: item.kind === "widget"
|
||||
? item.widget === "grading-wheels"
|
||||
? 7
|
||||
: 8
|
||||
: item.kind === "resource"
|
||||
? 4
|
||||
: 1;
|
||||
const controlWidth = (schema: FxNodeValueSchema | undefined, ramp = false) =>
|
||||
!schema
|
||||
? 80
|
||||
: schema.type === "vector"
|
||||
? 180
|
||||
: schema.type === "color"
|
||||
? 80
|
||||
: ramp
|
||||
? 300
|
||||
: schema.type === "string"
|
||||
? Math.max(80, ...(schema.enum ?? []).map((value) => textWidth(value) + 28))
|
||||
: 80;
|
||||
export const visibleWhen = (expression: FxNodeVisibility | undefined, values: GraphNode["parameters"]): boolean =>
|
||||
!expression
|
||||
? true
|
||||
: "all" in expression
|
||||
? expression.all.every((item) => visibleWhen(item, values))
|
||||
: "any" in expression
|
||||
? expression.any.some((item) => visibleWhen(item, values))
|
||||
: "equals" in expression
|
||||
? (values[expression.parameter] as ParameterValue | undefined)?.value === expression.equals
|
||||
: expression.in.includes((values[expression.parameter] as ParameterValue | undefined)?.value as never);
|
||||
export const visibleNodeItems = (
|
||||
definition: FxNodeDefinition,
|
||||
node: Pick<GraphNode, "parameters" | "sockets">,
|
||||
): readonly FxNodeUiRow[] =>
|
||||
definition.ui
|
||||
.filter((item) => item.kind !== "hidden" && visibleWhen(item.visibleWhen, node.parameters))
|
||||
.filter(
|
||||
(item) => item.kind !== "socket" || node.sockets.some((socket) => socket.key === item.socket && socket.visible),
|
||||
);
|
||||
export function minimumNodeSize(
|
||||
definition: FxNodeDefinition,
|
||||
node: Pick<GraphNode, "label" | "parameters" | "sockets">,
|
||||
): Vec2 {
|
||||
if (definition.behavior === "reroute") return { x: 10, y: 10 };
|
||||
if (definition.behavior === "frame") return { x: G.frameMinimum, y: G.frameMinimum };
|
||||
const items = visibleNodeItems(definition, node);
|
||||
let width = Math.max(G.minWidth, textWidth(node.label) + 42);
|
||||
for (const item of items) {
|
||||
const label =
|
||||
"title" in item
|
||||
? item.title
|
||||
: item.kind === "parameter" || item.kind === "resource"
|
||||
? title(item.parameter)
|
||||
: item.kind === "socket"
|
||||
? title(item.socket)
|
||||
: "";
|
||||
if (label) width = Math.max(width, (textWidth(label) + 18) / 0.4);
|
||||
if (item.kind === "parameter" || item.kind === "resource") {
|
||||
const schema = definition.parameters[item.parameter];
|
||||
width = Math.max(
|
||||
width,
|
||||
controlWidth(
|
||||
schema,
|
||||
item.kind === "parameter" &&
|
||||
definition.ui.some(
|
||||
(r) => r.kind === "widget" && r.widget === "color-ramp" && r.parameter === item.parameter,
|
||||
),
|
||||
) / 0.53,
|
||||
);
|
||||
} else if (item.kind === "socket") {
|
||||
const socket = definition.sockets[item.socket];
|
||||
if (socket?.value && socket.showValue) width = Math.max(width, controlWidth(socket.value) / 0.53);
|
||||
} else if (item.kind === "widget") width = Math.max(width, item.widget === "grading-wheels" ? 400 : 320);
|
||||
}
|
||||
return {
|
||||
x: Math.min(G.maxWidth, Math.ceil(width)),
|
||||
y: G.header + items.reduce((sum, item) => sum + nodeRowUnits(item), 0) * G.row + G.gap,
|
||||
};
|
||||
}
|
||||
export function initialNodeSize(
|
||||
definition: FxNodeDefinition,
|
||||
node: Pick<GraphNode, "label" | "parameters" | "sockets">,
|
||||
): Vec2 {
|
||||
if (definition.behavior === "frame") return { x: G.initialFrameWidth, y: G.initialNodeHeight };
|
||||
if (definition.behavior === "reroute") return { x: G.reroute * 2, y: G.reroute * 2 };
|
||||
const minimum = minimumNodeSize(definition, node);
|
||||
return { x: Math.max(G.initialNodeWidth, minimum.x), y: Math.max(G.initialNodeHeight, minimum.y) };
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
export interface SpatialBox {
|
||||
readonly minX: number;
|
||||
readonly minY: number;
|
||||
readonly maxX: number;
|
||||
readonly maxY: number;
|
||||
}
|
||||
export type SpatialRect =
|
||||
| SpatialBox
|
||||
| { readonly x: number; readonly y: number; readonly width: number; readonly height: number };
|
||||
|
||||
export function canonicalBox(rect: SpatialRect): SpatialBox {
|
||||
const values =
|
||||
"minX" in rect
|
||||
? [rect.minX, rect.minY, rect.maxX, rect.maxY]
|
||||
: [rect.x, rect.y, rect.x + rect.width, rect.y + rect.height];
|
||||
if (!values.every(Number.isFinite)) throw new TypeError("Spatial bounds must be finite");
|
||||
return {
|
||||
minX: Math.min(values[0]!, values[2]!),
|
||||
minY: Math.min(values[1]!, values[3]!),
|
||||
maxX: Math.max(values[0]!, values[2]!),
|
||||
maxY: Math.max(values[1]!, values[3]!),
|
||||
};
|
||||
}
|
||||
export const boxesIntersect = (a: SpatialBox, b: SpatialBox): boolean =>
|
||||
a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
|
||||
const contains = (a: SpatialBox, b: SpatialBox): boolean =>
|
||||
a.minX <= b.minX && a.maxX >= b.maxX && a.minY <= b.minY && a.maxY >= b.maxY;
|
||||
|
||||
export interface SpatialIndex<T> {
|
||||
readonly size: number;
|
||||
insert(id: string, value: T, bounds: SpatialRect): void;
|
||||
update(id: string, bounds: SpatialRect, value?: T): void;
|
||||
remove(id: string): T;
|
||||
query(bounds: SpatialRect): readonly T[];
|
||||
clear(): void;
|
||||
}
|
||||
interface Entry<T> {
|
||||
id: string;
|
||||
value: T;
|
||||
box: SpatialBox;
|
||||
}
|
||||
interface Cell<T> {
|
||||
box: SpatialBox;
|
||||
entries: Entry<T>[];
|
||||
children?: [Cell<T>, Cell<T>, Cell<T>, Cell<T>];
|
||||
}
|
||||
|
||||
/** Rectangle-capable loose quadtree. Items live in exactly one cell. */
|
||||
export class LooseQuadtree<T> implements SpatialIndex<T> {
|
||||
private root: Cell<T> = { box: { minX: -1, minY: -1, maxX: 1, maxY: 1 }, entries: [] };
|
||||
private readonly locator = new Map<string, Entry<T>>();
|
||||
constructor(
|
||||
readonly maxEntries = 16,
|
||||
readonly maxDepth = 16,
|
||||
readonly minCellSize = 1,
|
||||
readonly looseness = 2,
|
||||
) {
|
||||
if (
|
||||
!Number.isInteger(maxEntries) ||
|
||||
maxEntries < 1 ||
|
||||
!Number.isInteger(maxDepth) ||
|
||||
maxDepth < 0 ||
|
||||
!(minCellSize > 0) ||
|
||||
looseness < 1
|
||||
)
|
||||
throw new RangeError("Invalid quadtree options");
|
||||
}
|
||||
get size(): number {
|
||||
return this.locator.size;
|
||||
}
|
||||
insert(id: string, value: T, bounds: SpatialRect): void {
|
||||
if (this.locator.has(id)) throw new Error(`Duplicate spatial id: ${id}`);
|
||||
const entry = { id, value, box: canonicalBox(bounds) };
|
||||
this.locator.set(id, entry);
|
||||
this.ensureRoot(entry.box);
|
||||
this.place(this.root, entry, 0);
|
||||
}
|
||||
update(id: string, bounds: SpatialRect, value?: T): void {
|
||||
const old = this.locator.get(id);
|
||||
if (!old) throw new Error(`Unknown spatial id: ${id}`);
|
||||
const nextValue = value === undefined ? old.value : value;
|
||||
this.remove(id);
|
||||
this.insert(id, nextValue, bounds);
|
||||
}
|
||||
remove(id: string): T {
|
||||
const entry = this.locator.get(id);
|
||||
if (!entry) throw new Error(`Unknown spatial id: ${id}`);
|
||||
this.removeCell(this.root, id);
|
||||
this.locator.delete(id);
|
||||
return entry.value;
|
||||
}
|
||||
query(bounds: SpatialRect): readonly T[] {
|
||||
const query = canonicalBox(bounds),
|
||||
found: T[] = [],
|
||||
seen = new Set<string>();
|
||||
const visit = (cell: Cell<T>, root = false): void => {
|
||||
if (!boxesIntersect(root ? cell.box : this.loose(cell.box), query)) return;
|
||||
for (const e of cell.entries)
|
||||
if (!seen.has(e.id) && boxesIntersect(e.box, query)) {
|
||||
seen.add(e.id);
|
||||
found.push(e.value);
|
||||
}
|
||||
cell.children?.forEach((child) => visit(child));
|
||||
};
|
||||
visit(this.root, true);
|
||||
return found;
|
||||
}
|
||||
clear(): void {
|
||||
this.locator.clear();
|
||||
this.root = { box: { minX: -1, minY: -1, maxX: 1, maxY: 1 }, entries: [] };
|
||||
}
|
||||
private ensureRoot(box: SpatialBox): void {
|
||||
if (contains(this.root.box, box)) return;
|
||||
const all = [...this.locator.values()];
|
||||
let minX = Math.min(-1, ...all.map((e) => e.box.minX)),
|
||||
minY = Math.min(-1, ...all.map((e) => e.box.minY)),
|
||||
maxX = Math.max(1, ...all.map((e) => e.box.maxX)),
|
||||
maxY = Math.max(1, ...all.map((e) => e.box.maxY));
|
||||
let size = 2;
|
||||
while (size < Math.max(maxX - minX, maxY - minY)) size *= 2;
|
||||
const cx = (minX + maxX) / 2,
|
||||
cy = (minY + maxY) / 2;
|
||||
this.root = {
|
||||
box: { minX: cx - size / 2, minY: cy - size / 2, maxX: cx + size / 2, maxY: cy + size / 2 },
|
||||
entries: [],
|
||||
};
|
||||
for (const e of all) this.place(this.root, e, 0);
|
||||
}
|
||||
private place(cell: Cell<T>, entry: Entry<T>, depth: number): void {
|
||||
if (depth < this.maxDepth && cell.box.maxX - cell.box.minX > this.minCellSize) {
|
||||
if (!cell.children && cell.entries.length >= this.maxEntries) this.split(cell);
|
||||
const child = cell.children?.find((c) => contains(this.loose(c.box), entry.box));
|
||||
if (child) {
|
||||
this.place(child, entry, depth + 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
cell.entries.push(entry);
|
||||
}
|
||||
private split(cell: Cell<T>): void {
|
||||
const { minX, minY, maxX, maxY } = cell.box,
|
||||
mx = (minX + maxX) / 2,
|
||||
my = (minY + maxY) / 2;
|
||||
cell.children = [
|
||||
{ box: { minX, minY, maxX: mx, maxY: my }, entries: [] },
|
||||
{ box: { minX: mx, minY, maxX, maxY: my }, entries: [] },
|
||||
{ box: { minX, minY: my, maxX: mx, maxY }, entries: [] },
|
||||
{ box: { minX: mx, minY: my, maxX, maxY }, entries: [] },
|
||||
];
|
||||
}
|
||||
private loose(box: SpatialBox): SpatialBox {
|
||||
const x = (box.minX + box.maxX) / 2,
|
||||
y = (box.minY + box.maxY) / 2,
|
||||
w = ((box.maxX - box.minX) * this.looseness) / 2,
|
||||
h = ((box.maxY - box.minY) * this.looseness) / 2;
|
||||
return { minX: x - w, minY: y - h, maxX: x + w, maxY: y + h };
|
||||
}
|
||||
private removeCell(cell: Cell<T>, id: string): boolean {
|
||||
const i = cell.entries.findIndex((e) => e.id === id);
|
||||
if (i >= 0) {
|
||||
cell.entries.splice(i, 1);
|
||||
return true;
|
||||
}
|
||||
return cell.children?.some((c) => this.removeCell(c, id)) ?? false;
|
||||
}
|
||||
}
|
||||
Vendored
+200
@@ -0,0 +1,200 @@
|
||||
import type { LinkId, NodeId, ParameterValue, SocketId, Vec2 } from "../core/types.js";
|
||||
import type { FxNodeHexColor, FxNodeValueSchema } from "../composition/types.js";
|
||||
|
||||
export interface Rect {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
export interface ViewTransform {
|
||||
readonly center: Vec2;
|
||||
readonly zoom: number;
|
||||
readonly viewport: Vec2;
|
||||
readonly dpr: number;
|
||||
}
|
||||
/** View-space (+Y down) modal color-picker geometry. */
|
||||
export interface ColorPickerLayout {
|
||||
readonly bounds: Rect;
|
||||
readonly confirm: Rect;
|
||||
readonly plane: Rect;
|
||||
readonly lightness: Rect;
|
||||
readonly alpha: Rect;
|
||||
readonly rgba: readonly [Rect, Rect, Rect, Rect];
|
||||
readonly hsv: readonly [Rect, Rect, Rect];
|
||||
readonly hex: Rect;
|
||||
}
|
||||
export interface LayoutSocket {
|
||||
readonly id: SocketId;
|
||||
readonly nodeId: NodeId;
|
||||
readonly label: string;
|
||||
readonly dataType: string;
|
||||
readonly color: FxNodeHexColor;
|
||||
readonly wildcardInput: boolean;
|
||||
readonly direction: "input" | "output";
|
||||
readonly accepts: readonly string[];
|
||||
readonly capacity: number;
|
||||
readonly linkIds: readonly LinkId[];
|
||||
readonly anchor: Vec2;
|
||||
readonly linked: boolean;
|
||||
}
|
||||
export type LayoutControlKind =
|
||||
| "number"
|
||||
| "boolean"
|
||||
| "enum"
|
||||
| "string"
|
||||
| "resource"
|
||||
| "vector"
|
||||
| "color"
|
||||
| "color-ramp"
|
||||
| "readonly-json";
|
||||
export interface LayoutSubfield {
|
||||
readonly index: number;
|
||||
readonly label: "X" | "Y" | "Z" | "R" | "G" | "B" | "A";
|
||||
readonly bounds: Rect;
|
||||
}
|
||||
export interface LayoutNumericField {
|
||||
readonly component: number;
|
||||
readonly bounds: Rect;
|
||||
readonly value: Rect;
|
||||
readonly decrement: Rect;
|
||||
readonly increment: Rect;
|
||||
/** Preferred display range for Blender-style proportional fill. */
|
||||
readonly range?: { readonly minimum: number; readonly maximum: number };
|
||||
}
|
||||
export interface LayoutControl {
|
||||
readonly id: string;
|
||||
readonly nodeId: NodeId;
|
||||
readonly source: "parameter" | "socket-default" | "unknown";
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly kind: LayoutControlKind;
|
||||
readonly value: ParameterValue | unknown;
|
||||
readonly schema?: FxNodeValueSchema;
|
||||
readonly resourceId?: string;
|
||||
readonly resourceReferencePrefix?: string;
|
||||
readonly openTitle?: string;
|
||||
readonly linked: boolean;
|
||||
readonly bounds: Rect;
|
||||
readonly subfields: readonly LayoutSubfield[];
|
||||
/** Authoritative numeric value and step-button geometry, in world coordinates. */
|
||||
readonly numericFields: readonly LayoutNumericField[];
|
||||
/** Authoritative Color Ramp sub-control geometry, in world coordinates. */
|
||||
readonly rampBounds?: {
|
||||
readonly toolbar: Rect;
|
||||
readonly mode: Rect;
|
||||
readonly interpolation: Rect;
|
||||
readonly hue: Rect;
|
||||
readonly gradient: Rect;
|
||||
readonly handles: Rect;
|
||||
readonly selector: Rect;
|
||||
readonly position: Rect;
|
||||
readonly color: Rect;
|
||||
};
|
||||
/** Authoritative inline Oklch grading-wheel geometry, in world coordinates. */
|
||||
readonly colorWheelBounds?: { readonly plane: Rect; readonly lightness: Rect };
|
||||
/** Resource thumbnail and chooser button geometry, in world coordinates. */
|
||||
readonly resourceBounds?: { readonly preview: Rect; readonly open: Rect };
|
||||
}
|
||||
export type LayoutRow =
|
||||
| { readonly kind: "control"; readonly controlId: string; readonly units: number; readonly bounds: Rect }
|
||||
| {
|
||||
readonly kind: "grading-wheels";
|
||||
readonly wheels: readonly [
|
||||
{
|
||||
readonly label: string;
|
||||
readonly labelBounds: Rect;
|
||||
readonly scalarControlId: string;
|
||||
readonly colorControlId: string;
|
||||
},
|
||||
{
|
||||
readonly label: string;
|
||||
readonly labelBounds: Rect;
|
||||
readonly scalarControlId: string;
|
||||
readonly colorControlId: string;
|
||||
},
|
||||
{
|
||||
readonly label: string;
|
||||
readonly labelBounds: Rect;
|
||||
readonly scalarControlId: string;
|
||||
readonly colorControlId: string;
|
||||
},
|
||||
];
|
||||
readonly units: number;
|
||||
readonly bounds: Rect;
|
||||
}
|
||||
| {
|
||||
readonly kind: "socket";
|
||||
readonly socketId: SocketId;
|
||||
readonly controlId?: string;
|
||||
readonly units: number;
|
||||
readonly bounds: Rect;
|
||||
}
|
||||
| {
|
||||
readonly kind: "header" | "category" | "section" | "panel" | "placeholder";
|
||||
readonly label: string;
|
||||
readonly units: number;
|
||||
readonly bounds: Rect;
|
||||
};
|
||||
export interface LayoutNode {
|
||||
readonly id: NodeId;
|
||||
readonly parentId?: NodeId;
|
||||
readonly typeId: string;
|
||||
readonly label: string;
|
||||
readonly styleId?: string;
|
||||
readonly headerColor: FxNodeHexColor;
|
||||
readonly kind: "node" | "frame" | "reroute";
|
||||
readonly localPosition: Vec2;
|
||||
readonly worldPosition: Vec2;
|
||||
readonly authoredSize: Vec2;
|
||||
/** Smallest effective expanded size that can display this node's built-in controls at full scale. */
|
||||
readonly minimumSize: Vec2;
|
||||
readonly bounds: Rect;
|
||||
readonly header: Rect;
|
||||
readonly collapseHitRect: Rect;
|
||||
readonly resizeHitRect: Rect;
|
||||
readonly collapsed: boolean;
|
||||
readonly muted: boolean;
|
||||
readonly visible: boolean;
|
||||
readonly rows: readonly LayoutRow[];
|
||||
readonly bypasses: readonly { readonly from: Vec2; readonly to: Vec2 }[];
|
||||
}
|
||||
export interface LayoutLink {
|
||||
readonly id: LinkId;
|
||||
readonly fromNodeId: NodeId;
|
||||
readonly fromSocketId: SocketId;
|
||||
readonly toNodeId: NodeId;
|
||||
readonly toSocketId: SocketId;
|
||||
readonly dataType: string;
|
||||
readonly color: FxNodeHexColor;
|
||||
readonly points: readonly Vec2[];
|
||||
readonly controls: readonly [Vec2, Vec2];
|
||||
readonly bounds: Rect;
|
||||
readonly visible: boolean;
|
||||
readonly muted: boolean;
|
||||
}
|
||||
export interface LayoutSnapshot {
|
||||
readonly nodes: ReadonlyMap<NodeId, LayoutNode>;
|
||||
readonly sockets: ReadonlyMap<SocketId, LayoutSocket>;
|
||||
readonly controls: ReadonlyMap<string, LayoutControl>;
|
||||
readonly links: ReadonlyMap<LinkId, LayoutLink>;
|
||||
readonly drawOrder: readonly NodeId[];
|
||||
readonly graphBounds: Rect;
|
||||
readonly transform: ViewTransform;
|
||||
}
|
||||
export interface LayoutScene extends Omit<LayoutSnapshot, "transform"> {
|
||||
readonly nodeRanks: ReadonlyMap<NodeId, number>;
|
||||
readonly linkRanks: ReadonlyMap<LinkId, number>;
|
||||
}
|
||||
export interface LayoutView extends LayoutSnapshot {
|
||||
readonly candidateNodeIds: readonly NodeId[];
|
||||
readonly candidateLinkIds: readonly LinkId[];
|
||||
readonly totalNodes: number;
|
||||
readonly totalLinks: number;
|
||||
}
|
||||
|
||||
export function layoutSocketsCompatible(from: LayoutSocket, to: LayoutSocket): boolean {
|
||||
return (
|
||||
from.direction === "output" && to.direction === "input" && (to.wildcardInput || to.accepts.includes(from.dataType))
|
||||
);
|
||||
}
|
||||
+804
@@ -0,0 +1,804 @@
|
||||
import { GEOMETRY as G } from "../layout/constants.js";
|
||||
import { worldToView } from "../layout/geometry.js";
|
||||
import type { LinkId, NodeId, ParameterValue, SocketId } from "../core/types.js";
|
||||
import {
|
||||
layoutSocketsCompatible,
|
||||
type LayoutControl,
|
||||
type LayoutSnapshot,
|
||||
type LayoutSocket,
|
||||
type Rect,
|
||||
type ViewTransform,
|
||||
} from "../layout/types.js";
|
||||
import type { FxNodeTheme } from "../composition/types.js";
|
||||
import { isColorRamp, sampleColorRamp } from "../widgets/color-ramp.js";
|
||||
import { oklabToOklch, srgbToOklab } from "../color/oklab.js";
|
||||
import { paintOklchWheel, type DevicePaintTarget } from "./color-picker-renderer.js";
|
||||
|
||||
export interface InteractionRenderState {
|
||||
readonly knife?: {
|
||||
points: readonly { x: number; y: number }[];
|
||||
crossed: ReadonlySet<LinkId>;
|
||||
mode: "remove" | "mute";
|
||||
};
|
||||
}
|
||||
export interface InteractionRenderState {
|
||||
readonly selectedNodes: ReadonlySet<NodeId>;
|
||||
readonly selectedLinks?: ReadonlySet<LinkId>;
|
||||
readonly activeNode?: NodeId;
|
||||
readonly hoverNode?: NodeId;
|
||||
readonly focusedControl?: string;
|
||||
readonly hoveredControl?: string;
|
||||
readonly focusedRampTarget?: string;
|
||||
readonly hoveredRampTarget?: string;
|
||||
readonly activeRampStopByControl?: ReadonlyMap<string, string>;
|
||||
readonly collapseAnimations?: ReadonlyMap<NodeId, { readonly value: number }>;
|
||||
readonly controlEdit?:
|
||||
| { readonly kind: "string"; readonly controlId: string; readonly buffer: string }
|
||||
| {
|
||||
readonly kind: "number";
|
||||
readonly controlId: string;
|
||||
readonly component: number;
|
||||
readonly buffer: string;
|
||||
readonly selectAll: boolean;
|
||||
};
|
||||
readonly box?: { start: { x: number; y: number }; current: { x: number; y: number } };
|
||||
readonly linkDrag?: { from: SocketId; current: { x: number; y: number }; candidate?: SocketId };
|
||||
readonly parentHighlight?: NodeId;
|
||||
}
|
||||
export interface RenderStats {
|
||||
readonly candidateNodes: number;
|
||||
readonly totalNodes: number;
|
||||
readonly paintedNodes: number;
|
||||
readonly candidateLinks: number;
|
||||
readonly totalLinks: number;
|
||||
readonly paintedLinks: number;
|
||||
readonly paintMs: number;
|
||||
}
|
||||
export interface ResourceImage {
|
||||
readonly bitmap: ImageBitmap;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
function valueText(value: unknown): string {
|
||||
const typed = value as ParameterValue | undefined;
|
||||
if (!typed || typeof typed !== "object" || !("kind" in typed)) return "—";
|
||||
if (typed.kind === "number") return typed.value.toFixed(3);
|
||||
if (typed.kind === "vector" || typed.kind === "color")
|
||||
return typed.value.map((component) => component.toFixed(3)).join(" ");
|
||||
if (typed.kind === "json") return "…";
|
||||
return String(typed.value);
|
||||
}
|
||||
function resourceLabel(reference: string, prefix: string | undefined): string {
|
||||
if (!prefix || !reference.startsWith(prefix)) return reference;
|
||||
try {
|
||||
return decodeURIComponent(reference.slice(prefix.length).split(":").at(-1)!);
|
||||
} catch {
|
||||
return reference;
|
||||
}
|
||||
}
|
||||
|
||||
/** Canvas maxWidth scales glyphs but does not constrain them. Clip every label to its owning cell. */
|
||||
function clippedText(
|
||||
context: OffscreenCanvasRenderingContext2D,
|
||||
text: string,
|
||||
rect: Rect,
|
||||
x: number,
|
||||
y: number,
|
||||
padding = 3,
|
||||
): void {
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.rect(rect.x + padding, rect.y, Math.max(0, rect.width - padding * 2), rect.height);
|
||||
context.clip();
|
||||
context.fillText(text, x, y);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function paintColorSwatch(
|
||||
context: OffscreenCanvasRenderingContext2D,
|
||||
rect: Rect,
|
||||
rgba: readonly number[],
|
||||
theme: FxNodeTheme,
|
||||
zoom: number,
|
||||
): void {
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.roundRect(rect.x, rect.y, rect.width, rect.height, 4 * zoom);
|
||||
context.clip();
|
||||
const cell = Math.max(3, 5 * zoom);
|
||||
for (let y = rect.y; y < rect.y + rect.height; y += cell)
|
||||
for (let x = rect.x; x < rect.x + rect.width; x += cell) {
|
||||
context.fillStyle =
|
||||
(Math.floor((x - rect.x) / cell) + Math.floor((y - rect.y) / cell)) % 2
|
||||
? theme.checkerDark
|
||||
: theme.checkerLight;
|
||||
context.fillRect(x, y, cell, cell);
|
||||
}
|
||||
context.fillStyle = `rgba(${(rgba[0] ?? 0) * 255},${(rgba[1] ?? 0) * 255},${(rgba[2] ?? 0) * 255},${rgba[3] ?? 1})`;
|
||||
context.fillRect(rect.x, rect.y, rect.width, rect.height);
|
||||
context.restore();
|
||||
context.beginPath();
|
||||
context.roundRect(
|
||||
rect.x + 0.5 * zoom,
|
||||
rect.y + 0.5 * zoom,
|
||||
Math.max(0, rect.width - zoom),
|
||||
Math.max(0, rect.height - zoom),
|
||||
4 * zoom,
|
||||
);
|
||||
context.strokeStyle = theme.widgetBorder;
|
||||
context.lineWidth = zoom;
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
function paintControl(
|
||||
context: OffscreenCanvasRenderingContext2D,
|
||||
control: LayoutControl,
|
||||
rect: Rect,
|
||||
theme: FxNodeTheme,
|
||||
zoom: number,
|
||||
interaction?: InteractionRenderState,
|
||||
resourceImages?: ReadonlyMap<string, ResourceImage>,
|
||||
): void {
|
||||
const viewRect = (bounds: Rect): Rect => ({
|
||||
x: rect.x + (bounds.x - control.bounds.x) * zoom,
|
||||
y: rect.y + (control.bounds.y - bounds.y) * zoom,
|
||||
width: bounds.width * zoom,
|
||||
height: bounds.height * zoom,
|
||||
});
|
||||
const typedRamp = control.value as { kind?: unknown; value?: unknown };
|
||||
if (control.kind === "color-ramp" && typedRamp?.kind === "json" && isColorRamp(typedRamp.value)) {
|
||||
const bounds = control.rampBounds!,
|
||||
ramp = typedRamp.value,
|
||||
active = ramp.stops.find((s) => s.id === interaction?.activeRampStopByControl?.get(control.id)) ?? ramp.stops[0]!,
|
||||
toolbar = viewRect(bounds.toolbar),
|
||||
menusY = viewRect(bounds.mode).y,
|
||||
gradient = viewRect(bounds.gradient);
|
||||
context.textAlign = "center";
|
||||
const toolbarButtons = [
|
||||
[0, 0.12, "+"],
|
||||
[0.12, 0.24, "−"],
|
||||
[0.24, 0.62, "Flip"],
|
||||
[0.62, 1, "Distribute"],
|
||||
] as const;
|
||||
for (const [start, end, label] of toolbarButtons) {
|
||||
const button = {
|
||||
x: toolbar.x + toolbar.width * start,
|
||||
y: toolbar.y,
|
||||
width: toolbar.width * (end - start) - 1 * zoom,
|
||||
height: toolbar.height,
|
||||
};
|
||||
context.fillStyle = theme.control;
|
||||
context.fillRect(button.x, button.y, button.width, button.height);
|
||||
context.fillStyle = theme.text;
|
||||
clippedText(context, label, button, button.x + button.width / 2, button.y + button.height / 2);
|
||||
}
|
||||
const labels = [ramp.colorMode.toUpperCase(), ramp.interpolation.replace("-", " "), ramp.hueInterpolation];
|
||||
const menuWidths = [0.3, 0.41, 0.29];
|
||||
let x = rect.x;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const w = rect.width * menuWidths[i]!;
|
||||
context.fillStyle = theme.control;
|
||||
context.fillRect(x, menusY, w - 2 * zoom, 20 * zoom);
|
||||
context.fillStyle = theme.text;
|
||||
context.fillText(labels[i]!, x + w / 2, menusY + 10 * zoom);
|
||||
x += w;
|
||||
}
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.rect(gradient.x, gradient.y, gradient.width, gradient.height);
|
||||
context.clip();
|
||||
const cell = 6 * zoom;
|
||||
for (let yy = gradient.y; yy < gradient.y + gradient.height; yy += cell)
|
||||
for (let xx = gradient.x; xx < gradient.x + gradient.width; xx += cell) {
|
||||
context.fillStyle =
|
||||
(Math.floor((xx - gradient.x) / cell) + Math.floor((yy - gradient.y) / cell)) % 2
|
||||
? theme.checkerDark
|
||||
: theme.checkerLight;
|
||||
context.fillRect(
|
||||
xx,
|
||||
yy,
|
||||
Math.min(cell, gradient.x + gradient.width - xx),
|
||||
Math.min(cell, gradient.y + gradient.height - yy),
|
||||
);
|
||||
}
|
||||
const steps = Math.max(2, Math.ceil(gradient.width));
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const c = sampleColorRamp(ramp, i / (steps - 1));
|
||||
context.fillStyle = `rgba(${c[0] * 255},${c[1] * 255},${c[2] * 255},${c[3]})`;
|
||||
context.fillRect(
|
||||
gradient.x + (i * gradient.width) / steps,
|
||||
gradient.y,
|
||||
gradient.width / steps + 1,
|
||||
gradient.height,
|
||||
);
|
||||
}
|
||||
context.restore();
|
||||
context.strokeStyle = theme.rampBorder;
|
||||
context.lineWidth = 1;
|
||||
context.strokeRect(
|
||||
gradient.x + 0.5,
|
||||
gradient.y + 0.5,
|
||||
Math.max(0, gradient.width - 1),
|
||||
Math.max(0, gradient.height - 1),
|
||||
);
|
||||
for (const stop of ramp.stops) {
|
||||
const sx = gradient.x + stop.position * gradient.width,
|
||||
sy = gradient.y + gradient.height;
|
||||
context.beginPath();
|
||||
context.moveTo(sx, sy);
|
||||
context.lineTo(sx - 6 * zoom, sy + 12 * zoom);
|
||||
context.lineTo(sx + 6 * zoom, sy + 12 * zoom);
|
||||
context.closePath();
|
||||
context.fillStyle = `rgb(${stop.color[0] * 255},${stop.color[1] * 255},${stop.color[2] * 255})`;
|
||||
context.fill();
|
||||
context.strokeStyle = stop.id === active.id ? theme.focus : theme.emphasis;
|
||||
context.lineWidth = stop.id === active.id ? 3 : 2;
|
||||
context.stroke();
|
||||
}
|
||||
const detailsY = viewRect(bounds.selector).y,
|
||||
widths = [0.25, 0.35, 0.4],
|
||||
details = [active.id, `Pos ${active.position.toFixed(3)}`, ""];
|
||||
let dx = rect.x;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const w = rect.width * widths[i]!;
|
||||
const cellRect = { x: dx, y: detailsY, width: w - 2 * zoom, height: 20 * zoom };
|
||||
context.fillStyle = theme.control;
|
||||
context.fillRect(cellRect.x, cellRect.y, cellRect.width, cellRect.height);
|
||||
context.fillStyle = theme.text;
|
||||
clippedText(context, details[i]!, cellRect, cellRect.x + cellRect.width / 2, cellRect.y + cellRect.height / 2);
|
||||
dx += w;
|
||||
}
|
||||
const color = viewRect(bounds.color);
|
||||
paintColorSwatch(context, color, active.color, theme, zoom);
|
||||
if (interaction?.focusedControl === control.id) {
|
||||
context.strokeStyle = theme.focus;
|
||||
context.strokeRect(rect.x, detailsY, rect.width, 20 * zoom);
|
||||
}
|
||||
context.textAlign = "left";
|
||||
return;
|
||||
}
|
||||
context.beginPath();
|
||||
context.roundRect(rect.x, rect.y, rect.width, rect.height, 4 * zoom);
|
||||
context.fillStyle = control.linked ? theme.body : theme.control;
|
||||
context.fill();
|
||||
if (interaction?.focusedControl === control.id && !control.numericFields.length) {
|
||||
context.strokeStyle = theme.focus;
|
||||
context.stroke();
|
||||
}
|
||||
context.fillStyle = theme.text;
|
||||
context.textAlign = "center";
|
||||
if (control.kind === "resource") {
|
||||
const typed = control.value as { kind?: unknown; value?: unknown },
|
||||
reference = typed?.kind === "string" && typeof typed.value === "string" ? typed.value : "",
|
||||
image = resourceImages?.get(reference),
|
||||
preview = control.resourceBounds ? viewRect(control.resourceBounds.preview) : undefined;
|
||||
if (preview) {
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.roundRect(preview.x, preview.y, preview.width, preview.height, 4 * zoom);
|
||||
context.clip();
|
||||
context.fillStyle = theme.resourceBackground;
|
||||
context.fillRect(preview.x, preview.y, preview.width, preview.height);
|
||||
if (image) {
|
||||
const scale = Math.min(preview.width / image.bitmap.width, preview.height / image.bitmap.height),
|
||||
width = image.bitmap.width * scale,
|
||||
height = image.bitmap.height * scale;
|
||||
context.drawImage(
|
||||
image.bitmap,
|
||||
preview.x + (preview.width - width) / 2,
|
||||
preview.y + (preview.height - height) / 2,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
} else {
|
||||
context.fillStyle = theme.muted;
|
||||
clippedText(
|
||||
context,
|
||||
reference ? "Image unavailable — reopen" : "No image",
|
||||
preview,
|
||||
preview.x + preview.width / 2,
|
||||
preview.y + preview.height / 2,
|
||||
4 * zoom,
|
||||
);
|
||||
}
|
||||
context.restore();
|
||||
context.strokeStyle = theme.widgetBorder;
|
||||
context.strokeRect(
|
||||
preview.x + 0.5,
|
||||
preview.y + 0.5,
|
||||
Math.max(0, preview.width - 1),
|
||||
Math.max(0, preview.height - 1),
|
||||
);
|
||||
}
|
||||
const label = image?.name || resourceLabel(reference, control.resourceReferencePrefix),
|
||||
open = control.openTitle ?? "Open";
|
||||
clippedText(
|
||||
context,
|
||||
label ? `${label} ${open}…` : `${open} Image…`,
|
||||
rect,
|
||||
rect.x + rect.width / 2,
|
||||
rect.y + rect.height / 2,
|
||||
3 * zoom,
|
||||
);
|
||||
context.textAlign = "left";
|
||||
return;
|
||||
}
|
||||
if (control.kind === "color") {
|
||||
const value = control.value as Extract<ParameterValue, { kind: "color" }>;
|
||||
paintColorSwatch(context, rect, value.value, theme, zoom);
|
||||
if (interaction?.focusedControl === control.id) {
|
||||
context.beginPath();
|
||||
context.roundRect(
|
||||
rect.x + 0.5 * zoom,
|
||||
rect.y + 0.5 * zoom,
|
||||
Math.max(0, rect.width - zoom),
|
||||
Math.max(0, rect.height - zoom),
|
||||
4 * zoom,
|
||||
);
|
||||
context.strokeStyle = theme.focus;
|
||||
context.stroke();
|
||||
}
|
||||
context.textAlign = "left";
|
||||
return;
|
||||
}
|
||||
const edit = interaction?.controlEdit?.controlId === control.id ? interaction.controlEdit : undefined;
|
||||
const text = edit?.kind === "string" ? `${edit.buffer}|` : valueText(control.value);
|
||||
if (control.numericFields.length) {
|
||||
const typed = control.value as Extract<ParameterValue, { kind: "number" | "vector" | "color" }>;
|
||||
for (const field of control.numericFields) {
|
||||
const fieldRect = viewRect(field.bounds),
|
||||
valueRect = viewRect(field.value),
|
||||
decrement = viewRect(field.decrement),
|
||||
increment = viewRect(field.increment),
|
||||
component = typed.kind === "number" ? typed.value : (typed.value[field.component] ?? 0),
|
||||
subfield = control.subfields.find((item) => item.index === field.component),
|
||||
activeEdit = edit?.kind === "number" && edit.component === field.component ? edit : undefined;
|
||||
context.beginPath();
|
||||
context.roundRect(fieldRect.x, fieldRect.y, fieldRect.width, fieldRect.height, 4 * zoom);
|
||||
context.fillStyle = activeEdit ? theme.controlEditing : control.linked ? theme.body : theme.control;
|
||||
context.fill();
|
||||
if (!activeEdit && field.range) {
|
||||
const ratio = Math.max(
|
||||
0,
|
||||
Math.min(1, (component - field.range.minimum) / (field.range.maximum - field.range.minimum)),
|
||||
);
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.roundRect(fieldRect.x, fieldRect.y, fieldRect.width, fieldRect.height, 4 * zoom);
|
||||
context.clip();
|
||||
context.fillStyle = theme.controlFill;
|
||||
context.fillRect(fieldRect.x, fieldRect.y, fieldRect.width * ratio, fieldRect.height);
|
||||
context.restore();
|
||||
}
|
||||
if (activeEdit) {
|
||||
const x = fieldRect.x + 7 * zoom,
|
||||
y = fieldRect.y + fieldRect.height / 2,
|
||||
textWidth = context.measureText(activeEdit.buffer).width;
|
||||
context.textAlign = "left";
|
||||
if (activeEdit.selectAll) {
|
||||
context.fillStyle = theme.textSelection;
|
||||
context.fillRect(
|
||||
x - 2 * zoom,
|
||||
fieldRect.y + 2 * zoom,
|
||||
Math.min(textWidth + 4 * zoom, fieldRect.width - 10 * zoom),
|
||||
fieldRect.height - 4 * zoom,
|
||||
);
|
||||
}
|
||||
context.fillStyle = theme.text;
|
||||
clippedText(context, activeEdit.buffer, fieldRect, x, y, 5 * zoom);
|
||||
if (!activeEdit.selectAll) {
|
||||
const caretX = Math.min(fieldRect.x + fieldRect.width - 5 * zoom, x + textWidth + 1 * zoom);
|
||||
context.fillRect(caretX, fieldRect.y + 4 * zoom, 1 * zoom, fieldRect.height - 8 * zoom);
|
||||
}
|
||||
} else {
|
||||
context.fillStyle = theme.muted;
|
||||
for (const [button, direction] of [
|
||||
[decrement, -1],
|
||||
[increment, 1],
|
||||
] as const) {
|
||||
const cx = button.x + button.width / 2,
|
||||
cy = button.y + button.height / 2,
|
||||
size = Math.min(3 * zoom, button.width * 0.28);
|
||||
context.beginPath();
|
||||
context.moveTo(cx + direction * size, cy);
|
||||
context.lineTo(cx - direction * size, cy - size);
|
||||
context.lineTo(cx - direction * size, cy + size);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
}
|
||||
context.fillStyle = theme.text;
|
||||
context.textAlign = "left";
|
||||
clippedText(
|
||||
context,
|
||||
subfield?.label ?? control.label,
|
||||
valueRect,
|
||||
valueRect.x + 3 * zoom,
|
||||
fieldRect.y + fieldRect.height / 2,
|
||||
2 * zoom,
|
||||
);
|
||||
context.textAlign = "right";
|
||||
clippedText(
|
||||
context,
|
||||
component.toFixed(3),
|
||||
valueRect,
|
||||
valueRect.x + valueRect.width - 3 * zoom,
|
||||
fieldRect.y + fieldRect.height / 2,
|
||||
2 * zoom,
|
||||
);
|
||||
}
|
||||
context.beginPath();
|
||||
context.roundRect(
|
||||
fieldRect.x + 0.5 * zoom,
|
||||
fieldRect.y + 0.5 * zoom,
|
||||
Math.max(0, fieldRect.width - zoom),
|
||||
Math.max(0, fieldRect.height - zoom),
|
||||
4 * zoom,
|
||||
);
|
||||
context.strokeStyle = activeEdit ? theme.editOutline : theme.outline;
|
||||
context.lineWidth = zoom;
|
||||
context.stroke();
|
||||
}
|
||||
} else if (control.subfields.length) {
|
||||
const typed = control.value as Extract<ParameterValue, { kind: "vector" | "color" }>;
|
||||
for (const field of control.subfields) {
|
||||
const fieldRect = {
|
||||
x: rect.x + (field.bounds.x - control.bounds.x) * zoom,
|
||||
y: rect.y,
|
||||
width: field.bounds.width * zoom,
|
||||
height: rect.height,
|
||||
};
|
||||
context.beginPath();
|
||||
context.roundRect(fieldRect.x, fieldRect.y, fieldRect.width, fieldRect.height, 3 * zoom);
|
||||
context.fillStyle = control.linked ? theme.body : theme.control;
|
||||
context.fill();
|
||||
const component = typed.value[field.index] ?? 0;
|
||||
context.fillStyle = theme.text;
|
||||
clippedText(
|
||||
context,
|
||||
`${field.label} ${component.toFixed(3)}`,
|
||||
fieldRect,
|
||||
fieldRect.x + fieldRect.width / 2,
|
||||
rect.y + rect.height / 2,
|
||||
2 * zoom,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
clippedText(context, text, rect, rect.x + rect.width / 2, rect.y + rect.height / 2, 3 * zoom);
|
||||
}
|
||||
context.textAlign = "left";
|
||||
}
|
||||
|
||||
function paintSocket(
|
||||
context: OffscreenCanvasRenderingContext2D,
|
||||
socket: LayoutSocket,
|
||||
theme: FxNodeTheme,
|
||||
zoom: number,
|
||||
transform: ViewTransform,
|
||||
showLabel = true,
|
||||
): void {
|
||||
const point = worldToView(socket.anchor, transform);
|
||||
context.fillStyle = socket.color;
|
||||
context.beginPath();
|
||||
context.arc(point.x, point.y, G.socket * zoom, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
if (showLabel && zoom >= 0.35) {
|
||||
context.fillStyle = theme.text;
|
||||
context.textAlign = socket.direction === "input" ? "left" : "right";
|
||||
context.fillText(socket.label, point.x + (socket.direction === "input" ? 10 : -10) * zoom, point.y);
|
||||
}
|
||||
context.textAlign = "left";
|
||||
}
|
||||
export function renderCanvas(
|
||||
context: OffscreenCanvasRenderingContext2D,
|
||||
snapshot: LayoutSnapshot,
|
||||
theme: FxNodeTheme,
|
||||
interaction?: InteractionRenderState,
|
||||
resourceImages?: ReadonlyMap<string, ResourceImage>,
|
||||
deviceTarget: DevicePaintTarget = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: Math.round(snapshot.transform.viewport.x * snapshot.transform.dpr),
|
||||
height: Math.round(snapshot.transform.viewport.y * snapshot.transform.dpr),
|
||||
},
|
||||
): RenderStats {
|
||||
const started = performance.now();
|
||||
let paintedNodes = 0,
|
||||
paintedLinks = 0;
|
||||
const planned = snapshot as LayoutSnapshot & {
|
||||
candidateLinkIds?: readonly LinkId[];
|
||||
candidateNodeIds?: readonly NodeId[];
|
||||
totalNodes?: number;
|
||||
totalLinks?: number;
|
||||
};
|
||||
const { transform: t } = snapshot;
|
||||
context.setTransform(t.dpr, 0, 0, t.dpr, deviceTarget.x, deviceTarget.y);
|
||||
context.fillStyle = theme.background;
|
||||
context.fillRect(0, 0, t.viewport.x, t.viewport.y);
|
||||
const spacing = G.grid * t.zoom;
|
||||
context.fillStyle = theme.grid;
|
||||
const phase = worldToView({ x: 0, y: 0 }, t);
|
||||
for (let x = ((phase.x % spacing) + spacing) % spacing; x < t.viewport.x; x += spacing)
|
||||
for (let y = ((phase.y % spacing) + spacing) % spacing; y < t.viewport.y; y += spacing) {
|
||||
context.beginPath();
|
||||
context.arc(x, y, t.zoom < 0.7 ? 0.5 : 1, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
const viewRect = (rect: Rect) => {
|
||||
const p = worldToView({ x: rect.x, y: rect.y }, t);
|
||||
return { x: p.x, y: p.y, width: rect.width * t.zoom, height: rect.height * t.zoom };
|
||||
};
|
||||
for (const id of snapshot.drawOrder) {
|
||||
const node = snapshot.nodes.get(id);
|
||||
if (!node?.visible || node.kind !== "frame") continue;
|
||||
const r = viewRect(node.bounds),
|
||||
radius = 10 * t.zoom,
|
||||
h = G.header * t.zoom;
|
||||
context.beginPath();
|
||||
context.roundRect(r.x, r.y, r.width, r.height, radius);
|
||||
context.fillStyle = theme.frame;
|
||||
context.fill();
|
||||
context.strokeStyle = theme.frameHeader;
|
||||
context.lineWidth = 1;
|
||||
context.stroke();
|
||||
context.fillStyle = theme.frameHeader;
|
||||
context.font = `600 ${13 * t.zoom}px sans-serif`;
|
||||
context.textBaseline = "middle";
|
||||
if (t.zoom >= 0.25) context.fillText(node.label, r.x + 10 * t.zoom, r.y + h / 2);
|
||||
context.beginPath();
|
||||
context.moveTo(r.x + 8 * t.zoom, r.y + h);
|
||||
context.lineTo(r.x + r.width - 8 * t.zoom, r.y + h);
|
||||
context.stroke();
|
||||
}
|
||||
context.lineWidth = 2;
|
||||
for (const id of planned.candidateLinkIds ?? snapshot.links.keys()) {
|
||||
const link = snapshot.links.get(id);
|
||||
if (!link?.visible) continue;
|
||||
paintedLinks++;
|
||||
const color = link.muted ? theme.linkMuted : link.color,
|
||||
start = worldToView(link.points[0]!, t),
|
||||
end = worldToView(link.points.at(-1)!, t),
|
||||
c1 = worldToView(link.controls[0], t),
|
||||
c2 = worldToView(link.controls[1], t);
|
||||
const emphasized = interaction?.selectedLinks?.has(link.id) || interaction?.knife?.crossed.has(link.id);
|
||||
context.strokeStyle = emphasized ? theme.emphasis : color;
|
||||
context.lineWidth = emphasized ? 4 : 2;
|
||||
context.beginPath();
|
||||
context.moveTo(start.x, start.y);
|
||||
context.bezierCurveTo(c1.x, c1.y, c2.x, c2.y, end.x, end.y);
|
||||
context.stroke();
|
||||
}
|
||||
for (const id of snapshot.drawOrder) {
|
||||
const node = snapshot.nodes.get(id);
|
||||
if (!node?.visible || node.kind === "frame") continue;
|
||||
if (node.kind === "reroute") {
|
||||
paintedNodes++;
|
||||
const row = node.rows.find((item) => item.kind === "socket"),
|
||||
socket = row?.kind === "socket" ? snapshot.sockets.get(row.socketId) : undefined;
|
||||
if (socket) {
|
||||
const p = worldToView(socket.anchor, t),
|
||||
selected = interaction?.selectedNodes.has(node.id);
|
||||
if (selected) {
|
||||
context.beginPath();
|
||||
context.arc(p.x, p.y, (G.reroute + 5) * t.zoom, 0, Math.PI * 2);
|
||||
context.strokeStyle = node.id === interaction?.activeNode ? theme.nodeActive : theme.nodeSelected;
|
||||
context.lineWidth = 2;
|
||||
context.stroke();
|
||||
}
|
||||
context.fillStyle = socket.color;
|
||||
context.beginPath();
|
||||
context.arc(p.x, p.y, G.reroute * t.zoom, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
paintedNodes++;
|
||||
const r = viewRect(node.bounds),
|
||||
h = G.header * t.zoom;
|
||||
const radius = G.corner * t.zoom;
|
||||
context.shadowColor = theme.shadow;
|
||||
context.shadowBlur = 8;
|
||||
context.shadowOffsetY = 3;
|
||||
context.beginPath();
|
||||
context.roundRect(r.x, r.y, r.width, r.height, radius);
|
||||
context.fillStyle = theme.body;
|
||||
context.fill();
|
||||
context.shadowColor = "transparent";
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.roundRect(r.x, r.y, r.width, r.height, radius);
|
||||
context.clip();
|
||||
context.fillStyle = node.headerColor;
|
||||
context.fillRect(r.x, r.y, r.width, h);
|
||||
context.restore();
|
||||
context.beginPath();
|
||||
context.roundRect(r.x, r.y, r.width, r.height, radius);
|
||||
context.strokeStyle = theme.outline;
|
||||
context.lineWidth = 1;
|
||||
context.stroke();
|
||||
context.fillStyle = theme.text;
|
||||
context.font = `600 ${12 * t.zoom}px sans-serif`;
|
||||
context.textBaseline = "middle";
|
||||
if (t.zoom >= 0.25) context.fillText(node.label, r.x + 22 * t.zoom, r.y + h / 2);
|
||||
const cx = r.x + 10 * t.zoom,
|
||||
cy = r.y + h / 2,
|
||||
collapseAmount = Math.max(
|
||||
0,
|
||||
Math.min(1, interaction?.collapseAnimations?.get(node.id)?.value ?? (node.collapsed ? 1 : 0)),
|
||||
);
|
||||
context.save();
|
||||
context.translate(cx, cy);
|
||||
context.rotate((-Math.PI / 2) * collapseAmount);
|
||||
context.beginPath();
|
||||
context.moveTo(-5 * t.zoom, -2.5 * t.zoom);
|
||||
context.lineTo(0, 2.5 * t.zoom);
|
||||
context.lineTo(5 * t.zoom, -2.5 * t.zoom);
|
||||
context.strokeStyle = "rgba(255,255,255,.75)";
|
||||
context.lineWidth = 2 * t.zoom;
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
context.stroke();
|
||||
context.restore();
|
||||
if (interaction?.selectedNodes.has(node.id)) {
|
||||
context.beginPath();
|
||||
context.roundRect(r.x, r.y, r.width, r.height, radius);
|
||||
context.strokeStyle = node.id === interaction.activeNode ? theme.nodeActive : theme.nodeSelected;
|
||||
context.lineWidth = 2;
|
||||
context.stroke();
|
||||
}
|
||||
context.font = `${11 * t.zoom}px sans-serif`;
|
||||
for (const row of node.rows) {
|
||||
if (
|
||||
row.kind === "header" ||
|
||||
row.kind === "category" ||
|
||||
row.kind === "section" ||
|
||||
row.kind === "panel" ||
|
||||
row.kind === "placeholder"
|
||||
) {
|
||||
const rowRect = viewRect(row.bounds);
|
||||
context.fillStyle = theme.muted;
|
||||
context.fillText(row.label, rowRect.x + 8 * t.zoom, rowRect.y + rowRect.height / 2);
|
||||
} else if (row.kind === "control") {
|
||||
const control = snapshot.controls.get(row.controlId);
|
||||
if (control) {
|
||||
const rowRect = viewRect(row.bounds);
|
||||
if (control.kind !== "resource" && control.numericFields.length !== 1) {
|
||||
context.fillStyle = theme.muted;
|
||||
context.fillText(control.label, rowRect.x + 8 * t.zoom, rowRect.y + rowRect.height / 2);
|
||||
}
|
||||
paintControl(context, control, viewRect(control.bounds), theme, t.zoom, interaction, resourceImages);
|
||||
}
|
||||
} else if (row.kind === "grading-wheels") {
|
||||
for (const wheel of row.wheels) {
|
||||
const scalar = snapshot.controls.get(wheel.scalarControlId),
|
||||
color = snapshot.controls.get(wheel.colorControlId),
|
||||
value = color?.value as { kind?: unknown; value?: readonly number[] };
|
||||
if (!scalar || !color || value?.kind !== "color" || !value.value || !color.colorWheelBounds) continue;
|
||||
const label = viewRect(wheel.labelBounds),
|
||||
plane = viewRect(color.colorWheelBounds.plane),
|
||||
lightness = viewRect(color.colorWheelBounds.lightness),
|
||||
model = oklabToOklch(srgbToOklab([value.value[0] ?? 1, value.value[1] ?? 1, value.value[2] ?? 1]));
|
||||
context.fillStyle = theme.muted;
|
||||
context.textAlign = "center";
|
||||
context.fillText(wheel.label, label.x + label.width / 2, label.y + label.height / 2);
|
||||
paintOklchWheel(
|
||||
context,
|
||||
{ plane, lightness },
|
||||
model,
|
||||
t.dpr,
|
||||
Math.min(0.9, Math.max(0.1, model.l)),
|
||||
deviceTarget,
|
||||
);
|
||||
paintControl(context, scalar, viewRect(scalar.bounds), theme, t.zoom, interaction);
|
||||
context.textAlign = "left";
|
||||
}
|
||||
} else if (row.kind === "socket") {
|
||||
const control = row.controlId ? snapshot.controls.get(row.controlId) : undefined;
|
||||
const socket = snapshot.sockets.get(row.socketId),
|
||||
embedsLabel = !!control && !control.linked && control.numericFields.length === 1;
|
||||
if (socket) paintSocket(context, socket, theme, t.zoom, t, !embedsLabel);
|
||||
if (control && !control.linked)
|
||||
paintControl(context, control, viewRect(control.bounds), theme, t.zoom, interaction);
|
||||
}
|
||||
}
|
||||
if (node.muted) {
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.roundRect(r.x, r.y, r.width, r.height, radius);
|
||||
context.clip();
|
||||
context.fillStyle = theme.muteOverlay;
|
||||
context.fillRect(r.x, r.y, r.width, r.height);
|
||||
context.restore();
|
||||
for (const bypass of node.bypasses) {
|
||||
const a = worldToView(bypass.from, t),
|
||||
b = worldToView(bypass.to, t),
|
||||
dx = Math.max(20, Math.abs(b.x - a.x) * 0.4);
|
||||
context.strokeStyle = theme.linkMuted;
|
||||
context.lineWidth = 3;
|
||||
context.beginPath();
|
||||
context.moveTo(a.x, a.y);
|
||||
context.bezierCurveTo(a.x + dx, a.y, b.x - dx, b.y, b.x, b.y);
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
if (!node.collapsed) {
|
||||
context.beginPath();
|
||||
context.moveTo(r.x + r.width - 9 * t.zoom, r.y + r.height);
|
||||
context.lineTo(r.x + r.width, r.y + r.height - 9 * t.zoom);
|
||||
context.strokeStyle = theme.resize;
|
||||
context.lineWidth = 1;
|
||||
context.stroke();
|
||||
}
|
||||
context.textAlign = "left";
|
||||
}
|
||||
if (interaction?.parentHighlight) {
|
||||
const n = snapshot.nodes.get(interaction.parentHighlight);
|
||||
if (n) {
|
||||
const r = viewRect(n.bounds);
|
||||
context.strokeStyle = theme.focus;
|
||||
context.lineWidth = 3;
|
||||
context.strokeRect(r.x, r.y, r.width, r.height);
|
||||
}
|
||||
}
|
||||
if (interaction?.linkDrag) {
|
||||
const from = [...snapshot.sockets.values()].find((socket) => socket.id === interaction.linkDrag?.from);
|
||||
if (from) {
|
||||
const a = worldToView(from.anchor, t),
|
||||
b = interaction.linkDrag.current,
|
||||
dx = Math.max(40, Math.abs(b.x - a.x) * 0.5);
|
||||
context.strokeStyle = from.color;
|
||||
context.lineWidth = 2;
|
||||
context.beginPath();
|
||||
context.moveTo(a.x, a.y);
|
||||
context.bezierCurveTo(a.x + dx, a.y, b.x - dx, b.y, b.x, b.y);
|
||||
context.stroke();
|
||||
for (const socket of snapshot.sockets.values()) {
|
||||
if (layoutSocketsCompatible(from, socket)) {
|
||||
const p = worldToView(socket.anchor, t);
|
||||
context.beginPath();
|
||||
context.arc(p.x, p.y, socket.id === interaction.linkDrag.candidate ? 10 : 8, 0, Math.PI * 2);
|
||||
context.strokeStyle = socket.id === interaction.linkDrag.candidate ? theme.emphasis : socket.color;
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (interaction?.knife) {
|
||||
const points = interaction.knife.points;
|
||||
context.strokeStyle = interaction.knife.mode === "mute" ? theme.knifeMuted : theme.emphasis;
|
||||
context.lineWidth = 2;
|
||||
context.beginPath();
|
||||
points.forEach((p, i) => (i ? context.lineTo(p.x, p.y) : context.moveTo(p.x, p.y)));
|
||||
context.stroke();
|
||||
const p = points.at(-1);
|
||||
if (p) {
|
||||
context.beginPath();
|
||||
context.moveTo(p.x - 7, p.y - 7);
|
||||
context.lineTo(p.x + 7, p.y + 7);
|
||||
context.moveTo(p.x + 7, p.y - 7);
|
||||
context.lineTo(p.x - 7, p.y + 7);
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
if (interaction?.box) {
|
||||
const a = interaction.box.start,
|
||||
b = interaction.box.current,
|
||||
x = Math.min(a.x, b.x),
|
||||
y = Math.min(a.y, b.y),
|
||||
w = Math.abs(a.x - b.x),
|
||||
h = Math.abs(a.y - b.y);
|
||||
context.fillStyle = theme.boxSelectionFill;
|
||||
context.fillRect(x, y, w, h);
|
||||
context.strokeStyle = theme.focus;
|
||||
context.lineWidth = 1;
|
||||
context.strokeRect(x + 0.5, y + 0.5, w, h);
|
||||
}
|
||||
return {
|
||||
candidateNodes: planned.candidateNodeIds?.length ?? snapshot.drawOrder.length,
|
||||
totalNodes: planned.totalNodes ?? snapshot.nodes.size,
|
||||
paintedNodes,
|
||||
candidateLinks: planned.candidateLinkIds?.length ?? snapshot.links.size,
|
||||
totalLinks: planned.totalLinks ?? snapshot.links.size,
|
||||
paintedLinks,
|
||||
paintMs: performance.now() - started,
|
||||
};
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import type { ColorPickerLayout, Rect } from "../layout/types.js";
|
||||
import { mapOklchToSrgb, maxSrgbChroma, type Oklch, type Rgba } from "../color/oklab.js";
|
||||
|
||||
const cache = new Map<string, ImageData>();
|
||||
export interface DevicePaintTarget {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
function wheel(size: number, l: number): ImageData {
|
||||
const bin = Math.round(l * 32),
|
||||
key = `${size}:${bin}`,
|
||||
known = cache.get(key);
|
||||
if (known) return known;
|
||||
const image = new ImageData(size, size),
|
||||
radius = size / 2,
|
||||
lightness = bin / 32;
|
||||
for (let y = 0; y < size; y++)
|
||||
for (let x = 0; x < size; x++) {
|
||||
const dx = x + 0.5 - radius,
|
||||
dy = radius - y - 0.5,
|
||||
r = Math.hypot(dx, dy) / radius,
|
||||
index = (y * size + x) * 4;
|
||||
if (r > 1) continue;
|
||||
const h = Math.atan2(dy, dx),
|
||||
rgb = mapOklchToSrgb({ l: lightness, c: r * maxSrgbChroma(lightness, h), h });
|
||||
image.data[index] = rgb[0] * 255;
|
||||
image.data[index + 1] = rgb[1] * 255;
|
||||
image.data[index + 2] = rgb[2] * 255;
|
||||
image.data[index + 3] = 255;
|
||||
}
|
||||
cache.set(key, image);
|
||||
while (cache.size > 66) cache.delete(cache.keys().next().value!);
|
||||
return image;
|
||||
}
|
||||
|
||||
export function paintOklchWheel(
|
||||
context: OffscreenCanvasRenderingContext2D,
|
||||
bounds: { plane: Rect; lightness: Rect },
|
||||
model: Oklch,
|
||||
dpr = 1,
|
||||
planeLightness = model.l,
|
||||
target: DevicePaintTarget = { x: 0, y: 0, width: Number.MAX_SAFE_INTEGER, height: Number.MAX_SAFE_INTEGER },
|
||||
): void {
|
||||
const size = Math.max(1, Math.round(bounds.plane.width * dpr)),
|
||||
image = wheel(size, planeLightness),
|
||||
destinationX = target.x + Math.round(bounds.plane.x * dpr),
|
||||
destinationY = target.y + Math.round(bounds.plane.y * dpr),
|
||||
targetRight = target.x + target.width,
|
||||
targetBottom = target.y + target.height;
|
||||
// putImageData ignores transforms and clipping. Copy only each opaque wheel
|
||||
// scanline so transparent corners preserve the picker background and no row
|
||||
// can write into an adjacent atlas slot.
|
||||
for (let y = 0; y < size; y++) {
|
||||
const deviceY = destinationY + y;
|
||||
if (deviceY < target.y || deviceY >= targetBottom) continue;
|
||||
let first = 0,
|
||||
last = size - 1;
|
||||
while (first < size && image.data[(y * size + first) * 4 + 3] === 0) first++;
|
||||
while (last >= first && image.data[(y * size + last) * 4 + 3] === 0) last--;
|
||||
first = Math.max(first, target.x - destinationX);
|
||||
last = Math.min(last, targetRight - destinationX - 1);
|
||||
if (first <= last) context.putImageData(image, destinationX, destinationY, first, y, last - first + 1, 1);
|
||||
}
|
||||
const light = context.createLinearGradient(0, bounds.lightness.y, 0, bounds.lightness.y + bounds.lightness.height);
|
||||
for (let i = 0; i <= 8; i++) {
|
||||
const l = 1 - i / 8,
|
||||
rgb = mapOklchToSrgb({ l, c: model.c, h: model.h });
|
||||
light.addColorStop(i / 8, `rgb(${rgb[0] * 255} ${rgb[1] * 255} ${rgb[2] * 255})`);
|
||||
}
|
||||
context.fillStyle = light;
|
||||
context.fillRect(bounds.lightness.x, bounds.lightness.y, bounds.lightness.width, bounds.lightness.height);
|
||||
const radius = bounds.plane.width / 2,
|
||||
cmax = maxSrgbChroma(planeLightness, model.h),
|
||||
fraction = cmax ? Math.min(1, model.c / cmax) : 0,
|
||||
cx = bounds.plane.x + radius + radius * fraction * Math.cos(model.h),
|
||||
cy = bounds.plane.y + radius - radius * fraction * Math.sin(model.h);
|
||||
context.strokeStyle = "#fff";
|
||||
context.lineWidth = 2;
|
||||
context.beginPath();
|
||||
context.arc(cx, cy, 4, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
const y = bounds.lightness.y + bounds.lightness.height * (1 - model.l);
|
||||
context.strokeStyle = "#fff";
|
||||
context.strokeRect(bounds.lightness.x - 2, y - 2, bounds.lightness.width + 4, 4);
|
||||
}
|
||||
|
||||
export function paintColorPicker(
|
||||
context: OffscreenCanvasRenderingContext2D,
|
||||
layout: ColorPickerLayout,
|
||||
model: Oklch,
|
||||
rgba: Rgba,
|
||||
hsv: readonly number[],
|
||||
edit?: { field: string; index: number; buffer: string; invalid: boolean },
|
||||
dpr = 1,
|
||||
target: DevicePaintTarget = { x: 0, y: 0, width: Number.MAX_SAFE_INTEGER, height: Number.MAX_SAFE_INTEGER },
|
||||
): void {
|
||||
context.save();
|
||||
context.fillStyle = "#181a1f";
|
||||
context.strokeStyle = "#f5a623";
|
||||
context.lineWidth = 1;
|
||||
context.beginPath();
|
||||
context.roundRect(layout.bounds.x, layout.bounds.y, layout.bounds.width, layout.bounds.height, 7);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
paintOklchWheel(context, layout, model, dpr, model.l, target);
|
||||
const alpha = context.createLinearGradient(0, layout.alpha.y, 0, layout.alpha.y + layout.alpha.height);
|
||||
alpha.addColorStop(0, `rgba(${rgba[0] * 255},${rgba[1] * 255},${rgba[2] * 255},1)`);
|
||||
alpha.addColorStop(1, `rgba(${rgba[0] * 255},${rgba[1] * 255},${rgba[2] * 255},0)`);
|
||||
context.fillStyle = alpha;
|
||||
context.fillRect(layout.alpha.x, layout.alpha.y, layout.alpha.width, layout.alpha.height);
|
||||
const y = layout.alpha.y + layout.alpha.height * (1 - rgba[3]);
|
||||
context.strokeStyle = "#fff";
|
||||
context.strokeRect(layout.alpha.x - 2, y - 2, layout.alpha.width + 4, 4);
|
||||
context.font = "12px sans-serif";
|
||||
context.textBaseline = "middle";
|
||||
context.textAlign = "center";
|
||||
context.fillStyle = "#eee";
|
||||
context.fillText("✓", layout.confirm.x + 12, layout.confirm.y + 12);
|
||||
const hex =
|
||||
"#" +
|
||||
rgba
|
||||
.map((v) =>
|
||||
Math.round(Math.max(0, Math.min(1, v)) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0"),
|
||||
)
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
for (const [rects, values, name, labels] of [
|
||||
[layout.rgba, rgba.map((v) => v.toFixed(3)), "rgba", "RGBA"],
|
||||
[layout.hsv, [hsv[0]!.toFixed(1), hsv[1]!.toFixed(3), hsv[2]!.toFixed(3)], "hsv", "HSV"],
|
||||
] as const)
|
||||
rects.forEach((r, i) => {
|
||||
context.fillStyle = "#292c33";
|
||||
context.fillRect(r.x, r.y, r.width, r.height);
|
||||
context.strokeStyle = edit?.field === name && edit.index === i ? (edit.invalid ? "#e55" : "#f5a623") : "#555";
|
||||
context.strokeRect(r.x + 0.5, r.y + 0.5, r.width - 1, r.height - 1);
|
||||
context.fillStyle = "#eee";
|
||||
context.fillText(
|
||||
edit?.field === name && edit.index === i ? edit.buffer : `${labels[i]} ${values[i]}`,
|
||||
r.x + r.width / 2,
|
||||
r.y + r.height / 2,
|
||||
);
|
||||
});
|
||||
context.fillStyle = "#292c33";
|
||||
context.fillRect(layout.hex.x, layout.hex.y, layout.hex.width, layout.hex.height);
|
||||
context.strokeStyle = edit?.field === "hex" ? (edit.invalid ? "#e55" : "#f5a623") : "#555";
|
||||
context.strokeRect(layout.hex.x + 0.5, layout.hex.y + 0.5, layout.hex.width - 1, layout.hex.height - 1);
|
||||
context.fillStyle = "#eee";
|
||||
context.fillText(
|
||||
edit?.field === "hex" ? edit.buffer : `HEX ${hex}`,
|
||||
layout.hex.x + layout.hex.width / 2,
|
||||
layout.hex.y + layout.hex.height / 2,
|
||||
);
|
||||
context.restore();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { REFERENCE_IDS } from "./reference-types.js";
|
||||
import type { ReferenceManifest, Sha256 } from "./reference-types.js";
|
||||
|
||||
const pendingReason = "Capture environment has no Blender 4.5.0 binary or Xvfb display server.";
|
||||
const root = "docs/research/blender-references/4.5.0";
|
||||
|
||||
export const referenceManifest: ReferenceManifest = {
|
||||
schemaVersion: 1,
|
||||
baseline: {
|
||||
blenderVersion: "4.5.0",
|
||||
sourceCommit: "8cb6b388974a817afedf1317ce26f0c75aa5f181",
|
||||
binarySha256: "1188b95cc12321c770b631939f7c25a096910b6f884a990bf9c0f62d52b38aec" as Sha256,
|
||||
manualSnapshot: "f72fe39427bf150242dd6cfdd94d902e535d2286",
|
||||
},
|
||||
references: REFERENCE_IDS.map((id) => ({
|
||||
id,
|
||||
status: "pending",
|
||||
relativePath: `${root}/${id}.png`,
|
||||
reason: pendingReason,
|
||||
})),
|
||||
};
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
export type Sha256 = string & { readonly __sha256: unique symbol };
|
||||
export type ReferenceStatus = "pending" | "captured";
|
||||
|
||||
export interface ResearchBaseline {
|
||||
readonly blenderVersion: "4.5.0";
|
||||
readonly sourceCommit: string;
|
||||
readonly binarySha256: Sha256;
|
||||
readonly manualSnapshot: string;
|
||||
}
|
||||
|
||||
export interface PendingReference {
|
||||
readonly id: ReferenceId;
|
||||
readonly status: "pending";
|
||||
readonly relativePath: string;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
export interface CapturedReference {
|
||||
readonly id: ReferenceId;
|
||||
readonly status: "captured";
|
||||
readonly relativePath: string;
|
||||
readonly sha256: Sha256;
|
||||
readonly capturedAt: string;
|
||||
readonly captureMethod: "self-captured-blender-window";
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
export type ReferenceRecord = PendingReference | CapturedReference;
|
||||
|
||||
export const REFERENCE_IDS = [
|
||||
"shader-basic-linked-near",
|
||||
"shader-basic-linked-far",
|
||||
"shader-selected-active-hover-near",
|
||||
"shader-collapsed-near",
|
||||
"common-frame-reroute-near",
|
||||
"shader-widget-rich-near",
|
||||
"shader-socket-gallery-near",
|
||||
"geometry-socket-gallery-near",
|
||||
] as const;
|
||||
|
||||
export type ReferenceId = (typeof REFERENCE_IDS)[number];
|
||||
|
||||
export interface ReferenceManifest {
|
||||
readonly schemaVersion: 1;
|
||||
readonly baseline: ResearchBaseline;
|
||||
readonly references: readonly ReferenceRecord[];
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Immutable color-ramp model, mutations, migration, and sampling.
|
||||
*
|
||||
* Valid ramps contain 2–32 uniquely identified, position-sorted stops. Mutations
|
||||
* preserve those invariants and clamp finite positions/channels to [0, 1]. `hsl`
|
||||
* is currently identical to `hsv` (it is not HSL). `b-spline` uses the same easing
|
||||
* as `ease`; `cardinal` is a two-stop approximation without neighbourhood spline
|
||||
* evaluation.
|
||||
* @module fxnode/widgets/color-ramp
|
||||
*/
|
||||
export type ColorRampMode = "rgb" | "hsv" | "hsl";
|
||||
export type ColorRampInterpolation = "linear" | "ease" | "constant" | "cardinal" | "b-spline";
|
||||
export type HueInterpolation = "near" | "far" | "clockwise" | "counter-clockwise";
|
||||
export interface ColorRampStop {
|
||||
readonly id: string;
|
||||
readonly position: number;
|
||||
readonly color: readonly [number, number, number, number];
|
||||
}
|
||||
export interface ColorRamp {
|
||||
readonly colorMode: ColorRampMode;
|
||||
readonly interpolation: ColorRampInterpolation;
|
||||
readonly hueInterpolation: HueInterpolation;
|
||||
readonly stops: readonly ColorRampStop[];
|
||||
}
|
||||
const clamp = (n: number) => Math.max(0, Math.min(1, n));
|
||||
export function isColorRamp(value: unknown): value is ColorRamp {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const r = value as ColorRamp;
|
||||
if (
|
||||
!(["rgb", "hsv", "hsl"] as unknown[]).includes(r.colorMode) ||
|
||||
!(["linear", "ease", "constant", "cardinal", "b-spline"] as unknown[]).includes(r.interpolation) ||
|
||||
!(["near", "far", "clockwise", "counter-clockwise"] as unknown[]).includes(r.hueInterpolation) ||
|
||||
!Array.isArray(r.stops) ||
|
||||
r.stops.length < 2 ||
|
||||
r.stops.length > 32
|
||||
)
|
||||
return false;
|
||||
let previous = -Infinity;
|
||||
const ids = new Set<string>();
|
||||
return r.stops.every(
|
||||
(s) =>
|
||||
typeof s?.id === "string" &&
|
||||
s.id.length > 0 &&
|
||||
!ids.has(s.id) &&
|
||||
!!ids.add(s.id) &&
|
||||
Number.isFinite(s.position) &&
|
||||
s.position >= 0 &&
|
||||
s.position <= 1 &&
|
||||
s.position >= previous &&
|
||||
(previous = s.position) >= 0 &&
|
||||
Array.isArray(s.color) &&
|
||||
s.color.length === 4 &&
|
||||
s.color.every((c: number) => Number.isFinite(c) && c >= 0 && c <= 1),
|
||||
);
|
||||
}
|
||||
export function migrateColorRamp(value: unknown): ColorRamp | undefined {
|
||||
const raw =
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
"kind" in value &&
|
||||
(value as { kind?: unknown }).kind === "json" &&
|
||||
"value" in value
|
||||
? (value as unknown as { value: unknown }).value
|
||||
: value;
|
||||
if (isColorRamp(raw)) return raw;
|
||||
if (!Array.isArray(raw) || raw.length < 2 || raw.length > 32) return;
|
||||
const stops = raw.map((s, i) => {
|
||||
const x = s as { id?: unknown; position?: unknown; color?: unknown };
|
||||
return { id: typeof x?.id === "string" && x.id ? x.id : `stop-${i}`, position: x?.position, color: x?.color };
|
||||
});
|
||||
const candidate = { colorMode: "rgb", interpolation: "linear", hueInterpolation: "near", stops };
|
||||
return isColorRamp(candidate) ? candidate : undefined;
|
||||
}
|
||||
const sorted = (r: ColorRamp, stops: readonly ColorRampStop[]): ColorRamp => ({
|
||||
...r,
|
||||
stops: [...stops].sort((a, b) => a.position - b.position),
|
||||
});
|
||||
/** Selects the indexed stop at an exact position, or returns `undefined`. */
|
||||
export const selectRampStop = (r: ColorRamp, position: number, index = 0): string | undefined =>
|
||||
r.stops.filter((s) => s.position === position)[index]?.id;
|
||||
/** Adds a sampled stop, preserving ramp validity; invalid IDs/positions are no-ops. */
|
||||
export function addRampStop(r: ColorRamp, position: number, id: string): ColorRamp {
|
||||
if (!id || !Number.isFinite(position) || r.stops.length >= 32 || r.stops.some((s) => s.id === id)) return r;
|
||||
return sorted(r, [...r.stops, { id, position: clamp(position), color: sampleColorRamp(r, position) }]);
|
||||
}
|
||||
/** Blender's plus inserts halfway between active and its left neighbour (or right neighbour for the first stop). */
|
||||
export function addRampMidpoint(r: ColorRamp, activeId: string, id: string): ColorRamp {
|
||||
const i = r.stops.findIndex((s) => s.id === activeId),
|
||||
a = r.stops[i];
|
||||
if (!a) return r;
|
||||
const other = r.stops[i > 0 ? i - 1 : i + 1];
|
||||
return other ? addRampStop(r, (a.position + other.position) / 2, id) : r;
|
||||
}
|
||||
export function removeRampStop(r: ColorRamp, id: string): ColorRamp {
|
||||
return r.stops.length <= 2 ? r : { ...r, stops: r.stops.filter((s) => s.id !== id) };
|
||||
}
|
||||
export function moveRampStop(r: ColorRamp, id: string, position: number): ColorRamp {
|
||||
if (!Number.isFinite(position)) return r;
|
||||
return sorted(
|
||||
r,
|
||||
r.stops.map((s) => (s.id === id ? { ...s, position: clamp(position) } : s)),
|
||||
);
|
||||
}
|
||||
export function setRampColor(r: ColorRamp, id: string, color: readonly [number, number, number, number]): ColorRamp {
|
||||
if (!color.every(Number.isFinite)) return r;
|
||||
return {
|
||||
...r,
|
||||
stops: r.stops.map((s) =>
|
||||
s.id === id ? { ...s, color: color.map(clamp) as unknown as readonly [number, number, number, number] } : s,
|
||||
),
|
||||
};
|
||||
}
|
||||
export function flipColorRamp(r: ColorRamp): ColorRamp {
|
||||
return sorted(
|
||||
r,
|
||||
r.stops.map((s) => ({ ...s, position: 1 - s.position })),
|
||||
);
|
||||
}
|
||||
export function distributeColorRamp(r: ColorRamp): ColorRamp {
|
||||
const first = r.stops[0]!.position,
|
||||
last = r.stops.at(-1)!.position,
|
||||
n = r.stops.length - 1;
|
||||
return { ...r, stops: r.stops.map((s, i) => ({ ...s, position: first + ((last - first) * i) / n })) };
|
||||
}
|
||||
const rgbToHsv = (c: readonly number[]) => {
|
||||
const [r, g, b] = c,
|
||||
m = Math.max(r!, g!, b!),
|
||||
n = Math.min(r!, g!, b!),
|
||||
d = m - n;
|
||||
let h = 0;
|
||||
if (d) h = m === r ? ((g! - b!) / d + 6) % 6 : m === g ? (b! - r!) / d + 2 : (r! - g!) / d + 4;
|
||||
return [h / 6, m ? d / m : 0, m];
|
||||
};
|
||||
const hsvToRgb = ([h, s, v]: readonly number[]) => {
|
||||
const wrapped = ((h! % 1) + 1) % 1,
|
||||
i = Math.floor(wrapped * 6),
|
||||
f = wrapped * 6 - i,
|
||||
p = v! * (1 - s!),
|
||||
q = v! * (1 - f * s!),
|
||||
t = v! * (1 - (1 - f) * s!);
|
||||
return (
|
||||
[
|
||||
[v, t, p],
|
||||
[q, v, p],
|
||||
[p, v, t],
|
||||
[p, q, v],
|
||||
[t, p, v],
|
||||
[v, p, q],
|
||||
] as number[][]
|
||||
)[i % 6]!;
|
||||
};
|
||||
function hueDelta(a: number, b: number, mode: HueInterpolation) {
|
||||
let d = (((b - a) % 1) + 1) % 1;
|
||||
if (mode === "near" && d > 0.5) d -= 1;
|
||||
if (mode === "far" && d < 0.5) d -= 1;
|
||||
if (mode === "counter-clockwise" && d > 0) d -= 1;
|
||||
return d;
|
||||
}
|
||||
/** Samples a valid ramp; non-finite positions safely sample its first stop. */
|
||||
export function sampleColorRamp(r: ColorRamp, position: number): readonly [number, number, number, number] {
|
||||
if (!Number.isFinite(position)) return r.stops[0]!.color;
|
||||
const p = clamp(position),
|
||||
right = r.stops.findIndex((s) => s.position >= p);
|
||||
if (right <= 0) return r.stops[Math.max(0, right)]!.color;
|
||||
const b = r.stops[right] ?? r.stops.at(-1)!,
|
||||
a = r.stops[right - 1]!,
|
||||
span = b.position - a.position;
|
||||
let t = span ? (p - a.position) / span : 1;
|
||||
if (r.interpolation === "constant") t = 0;
|
||||
else if (r.interpolation === "ease") t = t * t * (3 - 2 * t);
|
||||
else if (r.interpolation === "cardinal") t = t * t * (2 - t);
|
||||
else if (r.interpolation === "b-spline") t = t * t * (3 - 2 * t);
|
||||
let av = [...a.color.slice(0, 3)],
|
||||
bv = [...b.color.slice(0, 3)];
|
||||
if (r.colorMode !== "rgb") {
|
||||
av = rgbToHsv(av);
|
||||
bv = rgbToHsv(bv);
|
||||
av[0] = av[0]! + hueDelta(av[0]!, bv[0]!, r.hueInterpolation) * t;
|
||||
const rgb = hsvToRgb([av[0], av[1]! + (bv[1]! - av[1]!) * t, av[2]! + (bv[2]! - av[2]!) * t]);
|
||||
return [clamp(rgb[0]!), clamp(rgb[1]!), clamp(rgb[2]!), a.color[3] + (b.color[3] - a.color[3]) * t];
|
||||
}
|
||||
return [
|
||||
clamp(av[0]! + (bv[0]! - av[0]!) * t),
|
||||
clamp(av[1]! + (bv[1]! - av[1]!) * t),
|
||||
clamp(av[2]! + (bv[2]! - av[2]!) * t),
|
||||
clamp(a.color[3] + (b.color[3] - a.color[3]) * t),
|
||||
];
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
import { FXNODE_VIEW_LIMITS } from "../browser/view-limits.js";
|
||||
|
||||
export interface AtlasSize {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
export interface AtlasRect extends AtlasSize {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
}
|
||||
export interface AtlasItem extends AtlasSize {
|
||||
readonly id: string;
|
||||
}
|
||||
export interface AtlasLayout extends AtlasSize {
|
||||
readonly items: ReadonlyMap<string, AtlasSize>;
|
||||
readonly regions: ReadonlyMap<string, AtlasRect>;
|
||||
readonly free: readonly AtlasRect[];
|
||||
}
|
||||
export type AtlasPlanKind = "allocated" | "resized-in-place" | "relocated" | "repacked" | "grown";
|
||||
export type AtlasPlan =
|
||||
| {
|
||||
readonly ok: true;
|
||||
readonly kind: AtlasPlanKind;
|
||||
readonly layout: AtlasLayout;
|
||||
readonly movedIds: readonly string[];
|
||||
}
|
||||
| { readonly ok: false; readonly code: "atlas.dimension" | "atlas.capacity" };
|
||||
|
||||
const BLOCK = 256;
|
||||
const area = ({ width, height }: AtlasSize) => width * height;
|
||||
const freeOrder = (left: AtlasRect, right: AtlasRect) =>
|
||||
left.y - right.y || left.x - right.x || left.height - right.height || left.width - right.width;
|
||||
const validItem = ({ width, height }: AtlasSize) =>
|
||||
Number.isSafeInteger(width) &&
|
||||
Number.isSafeInteger(height) &&
|
||||
width > 0 &&
|
||||
height > 0 &&
|
||||
width <= FXNODE_VIEW_LIMITS.maxDeviceDimension &&
|
||||
height <= FXNODE_VIEW_LIMITS.maxDeviceDimension &&
|
||||
width * height <= FXNODE_VIEW_LIMITS.maxDevicePixelsPerView;
|
||||
const freezeRect = (rect: AtlasRect): AtlasRect => Object.freeze(rect);
|
||||
const makeLayout = (
|
||||
width: number,
|
||||
height: number,
|
||||
items: ReadonlyMap<string, AtlasSize>,
|
||||
regions: ReadonlyMap<string, AtlasRect>,
|
||||
free: readonly AtlasRect[],
|
||||
): AtlasLayout =>
|
||||
Object.freeze({
|
||||
width,
|
||||
height,
|
||||
items: new Map(items),
|
||||
regions: new Map(regions),
|
||||
free: Object.freeze(free.map(freezeRect).sort(freeOrder)),
|
||||
});
|
||||
|
||||
function coalesce(rectangles: readonly AtlasRect[]): AtlasRect[] {
|
||||
const result = rectangles.map((rect) => ({ ...rect }));
|
||||
for (;;) {
|
||||
let merged = false;
|
||||
outer: for (let i = 0; i < result.length; i++)
|
||||
for (let j = i + 1; j < result.length; j++) {
|
||||
const a = result[i]!,
|
||||
b = result[j]!;
|
||||
let next: AtlasRect | undefined;
|
||||
if (a.y === b.y && a.height === b.height && (a.x + a.width === b.x || b.x + b.width === a.x))
|
||||
next = { x: Math.min(a.x, b.x), y: a.y, width: a.width + b.width, height: a.height };
|
||||
else if (a.x === b.x && a.width === b.width && (a.y + a.height === b.y || b.y + b.height === a.y))
|
||||
next = { x: a.x, y: Math.min(a.y, b.y), width: a.width, height: a.height + b.height };
|
||||
if (next) {
|
||||
result.splice(j, 1);
|
||||
result.splice(i, 1, next);
|
||||
merged = true;
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
if (!merged) return result.sort(freeOrder);
|
||||
}
|
||||
}
|
||||
|
||||
function insert(layout: AtlasLayout, item: AtlasItem): AtlasLayout | undefined {
|
||||
const candidates = layout.free
|
||||
.map((rect, index) => ({ rect, index }))
|
||||
.filter(({ rect }) => item.width <= rect.width && item.height <= rect.height)
|
||||
.sort(({ rect: left }, { rect: right }) => {
|
||||
const ldw = left.width - item.width,
|
||||
ldh = left.height - item.height,
|
||||
rdw = right.width - item.width,
|
||||
rdh = right.height - item.height;
|
||||
return (
|
||||
area(left) - area(item) - (area(right) - area(item)) ||
|
||||
Math.min(ldw, ldh) - Math.min(rdw, rdh) ||
|
||||
Math.max(ldw, ldh) - Math.max(rdw, rdh) ||
|
||||
freeOrder(left, right)
|
||||
);
|
||||
});
|
||||
const selected = candidates[0];
|
||||
if (!selected) return;
|
||||
const { rect, index } = selected,
|
||||
region = { x: rect.x, y: rect.y, width: item.width, height: item.height },
|
||||
dw = rect.width - item.width,
|
||||
dh = rect.height - item.height,
|
||||
remainder: AtlasRect[] = [];
|
||||
if (dw > dh) {
|
||||
if (dw) remainder.push({ x: rect.x + item.width, y: rect.y, width: dw, height: rect.height });
|
||||
if (dh) remainder.push({ x: rect.x, y: rect.y + item.height, width: item.width, height: dh });
|
||||
} else {
|
||||
if (dh) remainder.push({ x: rect.x, y: rect.y + item.height, width: rect.width, height: dh });
|
||||
if (dw) remainder.push({ x: rect.x + item.width, y: rect.y, width: dw, height: item.height });
|
||||
}
|
||||
const items = new Map(layout.items),
|
||||
regions = new Map(layout.regions),
|
||||
free = layout.free.slice();
|
||||
items.set(item.id, Object.freeze({ width: item.width, height: item.height }));
|
||||
regions.set(item.id, freezeRect(region));
|
||||
free.splice(index, 1, ...remainder);
|
||||
return makeLayout(layout.width, layout.height, items, regions, free);
|
||||
}
|
||||
|
||||
function emptyLayout(width: number, height: number): AtlasLayout {
|
||||
return makeLayout(width, height, new Map(), new Map(), [{ x: 0, y: 0, width, height }]);
|
||||
}
|
||||
function sortedItems(items: ReadonlyMap<string, AtlasSize>): AtlasItem[] {
|
||||
return [...items]
|
||||
.map(([id, size]) => ({ id, ...size }))
|
||||
.sort((left, right) => {
|
||||
const side = Math.max(right.width, right.height) - Math.max(left.width, left.height);
|
||||
return (
|
||||
side ||
|
||||
area(right) - area(left) ||
|
||||
right.height - left.height ||
|
||||
right.width - left.width ||
|
||||
(left.id < right.id ? -1 : left.id > right.id ? 1 : 0)
|
||||
);
|
||||
});
|
||||
}
|
||||
function pack(items: readonly AtlasItem[], width: number, height: number): AtlasLayout | undefined {
|
||||
let layout = emptyLayout(width, height);
|
||||
for (const item of items) {
|
||||
const next = insert(layout, item);
|
||||
if (!next) return;
|
||||
layout = next;
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
function candidates(items: readonly AtlasItem[], minimumArea: number): AtlasSize[] {
|
||||
const largestWidth = Math.max(...items.map((item) => item.width)),
|
||||
largestHeight = Math.max(...items.map((item) => item.height)),
|
||||
result: AtlasSize[] = [];
|
||||
for (let width = BLOCK; width <= FXNODE_VIEW_LIMITS.maxAtlasDimension; width += BLOCK)
|
||||
for (let height = BLOCK; height <= FXNODE_VIEW_LIMITS.maxAtlasDimension; height += BLOCK) {
|
||||
const pixels = width * height;
|
||||
if (
|
||||
width >= largestWidth &&
|
||||
height >= largestHeight &&
|
||||
pixels >= minimumArea &&
|
||||
pixels <= FXNODE_VIEW_LIMITS.maxAtlasPixels
|
||||
)
|
||||
result.push({ width, height });
|
||||
}
|
||||
return result.sort(
|
||||
(left, right) =>
|
||||
area(left) - area(right) ||
|
||||
Math.abs(left.width - left.height) - Math.abs(right.width - right.height) ||
|
||||
left.width - right.width ||
|
||||
left.height - right.height,
|
||||
);
|
||||
}
|
||||
function repack(
|
||||
itemsMap: ReadonlyMap<string, AtlasSize>,
|
||||
current?: AtlasLayout,
|
||||
compact = false,
|
||||
): AtlasLayout | undefined {
|
||||
const items = sortedItems(itemsMap),
|
||||
activeArea = items.reduce((sum, item) => sum + area(item), 0);
|
||||
if (current && !compact) {
|
||||
const same = pack(items, current.width, current.height);
|
||||
if (same) return same;
|
||||
}
|
||||
const floor =
|
||||
current && !compact
|
||||
? Math.min(FXNODE_VIEW_LIMITS.maxAtlasPixels, Math.max(activeArea, area(current) * 2))
|
||||
: activeArea;
|
||||
for (const size of candidates(items, floor)) {
|
||||
const layout = pack(items, size.width, size.height);
|
||||
if (layout) return layout;
|
||||
}
|
||||
}
|
||||
function movedIds(before: AtlasLayout | undefined, after: AtlasLayout): string[] {
|
||||
return [...after.regions]
|
||||
.filter(([id, rect]) => {
|
||||
const previous = before?.regions.get(id);
|
||||
return !previous || previous.x !== rect.x || previous.y !== rect.y;
|
||||
})
|
||||
.map(([id]) => id)
|
||||
.sort();
|
||||
}
|
||||
|
||||
export function planAtlasUpsert(current: AtlasLayout | undefined, item: AtlasItem): AtlasPlan {
|
||||
if (!validItem(item)) return { ok: false, code: "atlas.dimension" };
|
||||
const items = new Map(current?.items);
|
||||
items.set(item.id, { width: item.width, height: item.height });
|
||||
if ([...items.values()].reduce((sum, value) => sum + area(value), 0) > FXNODE_VIEW_LIMITS.maxActiveDevicePixels)
|
||||
return { ok: false, code: "atlas.capacity" };
|
||||
const previousSize = current?.items.get(item.id),
|
||||
previousRegion = current?.regions.get(item.id);
|
||||
if (
|
||||
current &&
|
||||
previousSize &&
|
||||
previousRegion &&
|
||||
item.width <= previousRegion.width &&
|
||||
item.height <= previousRegion.height
|
||||
) {
|
||||
const nextItems = new Map(current.items);
|
||||
nextItems.set(item.id, Object.freeze({ width: item.width, height: item.height }));
|
||||
return {
|
||||
ok: true,
|
||||
kind: "resized-in-place",
|
||||
layout: makeLayout(current.width, current.height, nextItems, current.regions, current.free),
|
||||
movedIds: [],
|
||||
};
|
||||
}
|
||||
let base = current;
|
||||
if (current && previousRegion) base = removeAtlasItem(current, item.id);
|
||||
const incremental = base && insert(base, item);
|
||||
if (incremental)
|
||||
return {
|
||||
ok: true,
|
||||
kind: previousSize ? "relocated" : "allocated",
|
||||
layout: incremental,
|
||||
movedIds: movedIds(current, incremental),
|
||||
};
|
||||
const layout = repack(items, current);
|
||||
if (!layout) return { ok: false, code: "atlas.capacity" };
|
||||
return {
|
||||
ok: true,
|
||||
kind: current && layout.width === current.width && layout.height === current.height ? "repacked" : "grown",
|
||||
layout,
|
||||
movedIds: movedIds(current, layout),
|
||||
};
|
||||
}
|
||||
|
||||
export function removeAtlasItem(current: AtlasLayout, id: string): AtlasLayout | undefined {
|
||||
const region = current.regions.get(id);
|
||||
if (!region) return current;
|
||||
const items = new Map(current.items),
|
||||
regions = new Map(current.regions);
|
||||
items.delete(id);
|
||||
regions.delete(id);
|
||||
if (!items.size) return;
|
||||
return makeLayout(current.width, current.height, items, regions, coalesce([...current.free, region]));
|
||||
}
|
||||
|
||||
export function planAtlasCompaction(current: AtlasLayout): AtlasPlan | undefined {
|
||||
const active = [...current.items.values()].reduce((sum, item) => sum + area(item), 0);
|
||||
if (active / area(current) > 0.25) return;
|
||||
const layout = repack(current.items, current, true);
|
||||
if (!layout || area(layout) > area(current) / 2) return;
|
||||
return { ok: true, kind: "repacked", layout, movedIds: movedIds(current, layout) };
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import type { FxNodeValueSchema } from "../composition/types.js";
|
||||
import type { ParameterValue } from "../core/types.js";
|
||||
import type { LayoutControl } from "../layout/types.js";
|
||||
|
||||
export function clampNumber(value: number, schema: FxNodeValueSchema | undefined): number {
|
||||
if (!schema) return value;
|
||||
if (schema.type !== "number" && schema.type !== "vector" && schema.type !== "color") return value;
|
||||
const minimum = schema.type === "color" ? 0 : schema.minimum;
|
||||
const maximum = schema.type === "color" ? 1 : schema.maximum;
|
||||
return Math.min(maximum ?? Infinity, Math.max(minimum ?? -Infinity, value));
|
||||
}
|
||||
|
||||
export function snapNumber(value: number, schema: FxNodeValueSchema | undefined): number {
|
||||
if (schema?.type !== "number") return value;
|
||||
const step = schema.step ?? (schema.integer ? 1 : undefined);
|
||||
if (!step) return value;
|
||||
const origin = schema.minimum ?? 0;
|
||||
return origin + Math.round((value - origin) / step) * step;
|
||||
}
|
||||
|
||||
export function scrubValue(
|
||||
control: LayoutControl,
|
||||
original: ParameterValue,
|
||||
component: number,
|
||||
deltaPixels: number,
|
||||
fine: boolean,
|
||||
snapping: boolean,
|
||||
): ParameterValue {
|
||||
const scale = fine ? 0.01 : 0.1;
|
||||
const next = (value: number): number => {
|
||||
const changed = value + deltaPixels * scale;
|
||||
const snapped = snapping ? snapNumber(changed, control.schema) : changed;
|
||||
const clamped = clampNumber(snapped, control.schema);
|
||||
return control.schema?.type === "number" && control.schema.integer ? Math.round(clamped) : clamped;
|
||||
};
|
||||
if (original.kind === "number") return { kind: "number", value: next(original.value) };
|
||||
if (original.kind === "vector") {
|
||||
const value: [number, number, number] = [...original.value];
|
||||
value[component] = next(value[component] ?? 0);
|
||||
return { kind: "vector", value };
|
||||
}
|
||||
if (original.kind === "color") {
|
||||
const value: [number, number, number, number] = [...original.value];
|
||||
value[component] = Math.min(1, Math.max(0, next(value[component] ?? 0)));
|
||||
return { kind: "color", value };
|
||||
}
|
||||
return original;
|
||||
}
|
||||
|
||||
export function setNumericComponent(
|
||||
control: LayoutControl,
|
||||
original: ParameterValue,
|
||||
component: number,
|
||||
input: number,
|
||||
): ParameterValue {
|
||||
const clamped = clampNumber(input, control.schema);
|
||||
const next = control.schema?.type === "number" && control.schema.integer ? Math.round(clamped) : clamped;
|
||||
if (original.kind === "number") return { kind: "number", value: next };
|
||||
if (original.kind === "vector") {
|
||||
const value: [number, number, number] = [...original.value];
|
||||
value[component] = next;
|
||||
return { kind: "vector", value };
|
||||
}
|
||||
if (original.kind === "color") {
|
||||
const value: [number, number, number, number] = [...original.value];
|
||||
value[component] = Math.min(1, Math.max(0, next));
|
||||
return { kind: "color", value };
|
||||
}
|
||||
return original;
|
||||
}
|
||||
|
||||
export function numericStep(control: LayoutControl, fine: boolean): number {
|
||||
const base = control.schema?.type === "number" ? (control.schema.step ?? (control.schema.integer ? 1 : 0.1)) : 0.1;
|
||||
return fine && control.schema?.type === "number" && !control.schema.integer ? base / 10 : fine ? base / 10 : base;
|
||||
}
|
||||
|
||||
export function cycleEnum(values: readonly string[], current: string, direction: 1 | -1): string {
|
||||
const index = values.indexOf(current);
|
||||
return values[(Math.max(0, index) + direction + values.length) % values.length] ?? current;
|
||||
}
|
||||
+2689
File diff suppressed because it is too large
Load Diff
+292
@@ -0,0 +1,292 @@
|
||||
import { linkId, type GraphLink, type LinkId, type NodeId, type SocketId, type Vec2 } from "../core/types.js";
|
||||
import type { Command } from "../commands/types.js";
|
||||
import { viewToWorld } from "../layout/geometry.js";
|
||||
import { GEOMETRY as G } from "../layout/constants.js";
|
||||
import {
|
||||
layoutSocketsCompatible,
|
||||
type LayoutControl,
|
||||
type LayoutSnapshot,
|
||||
type Rect,
|
||||
type ViewTransform,
|
||||
} from "../layout/types.js";
|
||||
import { isColorRamp } from "../widgets/color-ramp.js";
|
||||
|
||||
const inRect = (p: Vec2, r: Rect, tolerance = 0): boolean =>
|
||||
p.x >= r.x - tolerance &&
|
||||
p.x <= r.x + r.width + tolerance &&
|
||||
p.y <= r.y + tolerance &&
|
||||
p.y >= r.y - r.height - tolerance;
|
||||
|
||||
export type RampTarget =
|
||||
| "add"
|
||||
| "remove"
|
||||
| "flip"
|
||||
| "distribute"
|
||||
| "mode"
|
||||
| "interpolation"
|
||||
| "hue"
|
||||
| "gradient"
|
||||
| "selector"
|
||||
| "position"
|
||||
| "swatch";
|
||||
export type Hit =
|
||||
| { readonly kind: "color-wheel"; readonly id: string; readonly region: "plane" | "lightness" }
|
||||
| {
|
||||
readonly kind: "ramp";
|
||||
readonly id: string;
|
||||
readonly target: RampTarget | "handle";
|
||||
readonly stopId?: string;
|
||||
readonly position?: number;
|
||||
}
|
||||
| { readonly kind: "control-step"; readonly id: string; readonly component: number; readonly direction: -1 | 1 }
|
||||
| { readonly kind: "resource"; readonly id: string }
|
||||
| { readonly kind: "control"; readonly id: string; readonly component: number }
|
||||
| { readonly kind: "socket"; readonly id: SocketId }
|
||||
| { readonly kind: "collapse" | "resize" | "node" | "frame-header" | "frame-body"; readonly id: NodeId }
|
||||
| { readonly kind: "link"; readonly id: LinkId }
|
||||
| { readonly kind: "canvas" };
|
||||
export function hitRamp(control: LayoutControl, p: Vec2, tolerance = 0): Extract<Hit, { kind: "ramp" }> | undefined {
|
||||
const b = control.rampBounds,
|
||||
v = control.value as { kind?: unknown; value?: unknown };
|
||||
if (control.kind !== "color-ramp" || !b || v.kind !== "json" || !isColorRamp(v.value)) return;
|
||||
const ramp = v.value;
|
||||
if (inRect(p, b.toolbar)) {
|
||||
const x = (p.x - b.toolbar.x) / b.toolbar.width;
|
||||
return {
|
||||
kind: "ramp",
|
||||
id: control.id,
|
||||
target: x < 0.12 ? "add" : x < 0.24 ? "remove" : x < 0.62 ? "flip" : "distribute",
|
||||
};
|
||||
}
|
||||
if (inRect(p, b.mode)) return { kind: "ramp", id: control.id, target: "mode" };
|
||||
if (inRect(p, b.interpolation)) return { kind: "ramp", id: control.id, target: "interpolation" };
|
||||
if (inRect(p, b.hue)) return { kind: "ramp", id: control.id, target: "hue" };
|
||||
if (inRect(p, b.handles, tolerance)) {
|
||||
const position = Math.max(0, Math.min(1, (p.x - b.handles.x) / b.handles.width)),
|
||||
near = ramp.stops
|
||||
.filter((s) => Math.abs(s.position - position) * b.handles.width <= Math.max(7, tolerance))
|
||||
.sort((a, c) => a.position - c.position || a.id.localeCompare(c.id));
|
||||
if (near.length) return { kind: "ramp", id: control.id, target: "handle", stopId: near[0]!.id, position };
|
||||
}
|
||||
if (inRect(p, b.gradient))
|
||||
return {
|
||||
kind: "ramp",
|
||||
id: control.id,
|
||||
target: "gradient",
|
||||
position: Math.max(0, Math.min(1, (p.x - b.gradient.x) / b.gradient.width)),
|
||||
};
|
||||
if (inRect(p, b.selector)) return { kind: "ramp", id: control.id, target: "selector" };
|
||||
if (inRect(p, b.position)) return { kind: "ramp", id: control.id, target: "position" };
|
||||
if (inRect(p, b.color)) return { kind: "ramp", id: control.id, target: "swatch" };
|
||||
}
|
||||
const segmentDistance = (p: Vec2, a: Vec2, b: Vec2): number => {
|
||||
const dx = b.x - a.x,
|
||||
dy = b.y - a.y,
|
||||
l = dx * dx + dy * dy;
|
||||
if (!l) return Math.hypot(p.x - a.x, p.y - a.y);
|
||||
const q = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / l));
|
||||
return Math.hypot(p.x - a.x - q * dx, p.y - a.y - q * dy);
|
||||
};
|
||||
export function hitTest(layout: LayoutSnapshot, view: Vec2, preferredDirection?: "input" | "output"): Hit {
|
||||
const world = viewToWorld(view, layout.transform),
|
||||
tolerance = 7 / layout.transform.zoom;
|
||||
const topNode = layout.drawOrder
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((id) => layout.nodes.get(id))
|
||||
.find((node) => node !== undefined && node.kind !== "frame" && inRect(world, node.bounds));
|
||||
for (const control of [...layout.controls.values()].reverse())
|
||||
if (!control.linked && (!topNode || control.nodeId === topNode.id)) {
|
||||
if (control.kind === "resource") {
|
||||
if (
|
||||
control.resourceBounds &&
|
||||
(inRect(world, control.resourceBounds.preview) || inRect(world, control.resourceBounds.open))
|
||||
)
|
||||
return { kind: "resource", id: control.id };
|
||||
continue;
|
||||
}
|
||||
if (control.colorWheelBounds) {
|
||||
if (inRect(world, control.colorWheelBounds.plane))
|
||||
return { kind: "color-wheel", id: control.id, region: "plane" };
|
||||
if (inRect(world, control.colorWheelBounds.lightness))
|
||||
return { kind: "color-wheel", id: control.id, region: "lightness" };
|
||||
continue;
|
||||
}
|
||||
const ramp = hitRamp(control, world, tolerance);
|
||||
if (ramp) return ramp;
|
||||
for (const field of control.numericFields) {
|
||||
if (inRect(world, field.decrement))
|
||||
return { kind: "control-step", id: control.id, component: field.component, direction: -1 };
|
||||
if (inRect(world, field.increment))
|
||||
return { kind: "control-step", id: control.id, component: field.component, direction: 1 };
|
||||
}
|
||||
if (inRect(world, control.bounds)) {
|
||||
const component = control.subfields.find((field) => inRect(world, field.bounds))?.index ?? 0;
|
||||
return { kind: "control", id: control.id, component };
|
||||
}
|
||||
}
|
||||
for (const id of layout.drawOrder.slice().reverse()) {
|
||||
const node = layout.nodes.get(id);
|
||||
if (node?.kind !== "reroute") continue;
|
||||
const center = { x: node.bounds.x + node.bounds.width / 2, y: node.bounds.y - node.bounds.height / 2 },
|
||||
distance = Math.hypot(world.x - center.x, world.y - center.y),
|
||||
core = Math.max(G.reroute, 7 / layout.transform.zoom),
|
||||
halo = core + 8 / layout.transform.zoom;
|
||||
if (distance > core && distance <= halo) return { kind: "node", id };
|
||||
}
|
||||
const socketHits = [...layout.sockets.values()]
|
||||
.filter(
|
||||
(socket) =>
|
||||
(!topNode || socket.nodeId === topNode.id) &&
|
||||
Math.hypot(world.x - socket.anchor.x, world.y - socket.anchor.y) <= tolerance,
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const an = layout.nodes.get(a.nodeId),
|
||||
bn = layout.nodes.get(b.nodeId),
|
||||
rank = (node: typeof an) => (node ? layout.drawOrder.indexOf(node.id) : -1);
|
||||
return (
|
||||
rank(bn) - rank(an) || Number(b.direction === preferredDirection) - Number(a.direction === preferredDirection)
|
||||
);
|
||||
});
|
||||
if (socketHits[0]) return { kind: "socket", id: socketHits[0].id };
|
||||
const regular = layout.drawOrder
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((id) => layout.nodes.get(id))
|
||||
.filter((n) => n?.kind !== "frame");
|
||||
for (const node of regular)
|
||||
if (node && node.kind === "node" && inRect(world, node.collapseHitRect, tolerance))
|
||||
return { kind: "collapse", id: node.id };
|
||||
for (const node of regular)
|
||||
if (node && node.kind === "node" && !node.collapsed && inRect(world, node.resizeHitRect, tolerance))
|
||||
return { kind: "resize", id: node.id };
|
||||
for (const node of regular) if (node && inRect(world, node.bounds)) return { kind: "node", id: node.id };
|
||||
for (const link of [...layout.links.values()].reverse())
|
||||
if (link.visible && link.points.slice(1).some((p, i) => segmentDistance(world, link.points[i]!, p) <= tolerance))
|
||||
return { kind: "link", id: link.id };
|
||||
for (const id of layout.drawOrder.slice().reverse()) {
|
||||
const n = layout.nodes.get(id);
|
||||
if (n?.kind === "frame" && inRect(world, n.header)) return { kind: "frame-header", id };
|
||||
}
|
||||
for (const id of layout.drawOrder.slice().reverse()) {
|
||||
const n = layout.nodes.get(id);
|
||||
if (n?.kind === "frame" && inRect(world, n.bounds)) return { kind: "frame-body", id };
|
||||
}
|
||||
return { kind: "canvas" };
|
||||
}
|
||||
|
||||
/** Hit order is stable and all tolerances are expressed in view pixels. */
|
||||
export function hitNode(layout: LayoutSnapshot, view: Vec2): NodeId | undefined {
|
||||
const hit = hitTest(layout, view);
|
||||
return hit.kind === "control" ||
|
||||
hit.kind === "control-step" ||
|
||||
hit.kind === "ramp" ||
|
||||
hit.kind === "color-wheel" ||
|
||||
hit.kind === "resource"
|
||||
? layout.controls.get(hit.id)?.nodeId
|
||||
: hit.kind === "socket"
|
||||
? layout.sockets.get(hit.id)?.nodeId
|
||||
: hit.kind === "node" ||
|
||||
hit.kind === "collapse" ||
|
||||
hit.kind === "resize" ||
|
||||
hit.kind === "frame-header" ||
|
||||
hit.kind === "frame-body"
|
||||
? hit.id
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export const boxNodes = (layout: LayoutSnapshot, a: Vec2, b: Vec2): NodeId[] => {
|
||||
const p = viewToWorld(a, layout.transform),
|
||||
q = viewToWorld(b, layout.transform),
|
||||
left = Math.min(p.x, q.x),
|
||||
right = Math.max(p.x, q.x),
|
||||
top = Math.max(p.y, q.y),
|
||||
bottom = Math.min(p.y, q.y);
|
||||
return layout.drawOrder.filter((id) => {
|
||||
const n = layout.nodes.get(id);
|
||||
return (
|
||||
!!n &&
|
||||
n.kind !== "frame" &&
|
||||
n.bounds.x >= left &&
|
||||
n.bounds.x + n.bounds.width <= right &&
|
||||
n.bounds.y <= top &&
|
||||
n.bounds.y - n.bounds.height >= bottom
|
||||
);
|
||||
});
|
||||
};
|
||||
export const compatibleTargets = (layout: LayoutSnapshot, fromId: SocketId) => {
|
||||
const from = layout.sockets.get(fromId);
|
||||
if (!from || from.direction !== "output") return [];
|
||||
return [...layout.sockets.values()].filter((to) => to.nodeId !== from.nodeId && layoutSocketsCompatible(from, to));
|
||||
};
|
||||
export function planLink(
|
||||
layout: LayoutSnapshot,
|
||||
fromId: SocketId,
|
||||
toId: SocketId,
|
||||
newId: LinkId = linkId(`gesture-${fromId}-${toId}`),
|
||||
): Command | undefined {
|
||||
const from = layout.sockets.get(fromId),
|
||||
to = layout.sockets.get(toId);
|
||||
if (!from || !to || !compatibleTargets(layout, fromId).some((s) => s.id === toId)) return;
|
||||
const link: GraphLink = {
|
||||
id: to.linkIds[0] ?? newId,
|
||||
fromNodeId: from.nodeId,
|
||||
fromSocketId: from.id,
|
||||
toNodeId: to.nodeId,
|
||||
toSocketId: to.id,
|
||||
muted: false,
|
||||
extensions: {},
|
||||
};
|
||||
return to.capacity === 1 && to.linkIds.length
|
||||
? { type: "link.replace", removeId: to.linkIds[0]!, link }
|
||||
: { type: "link.add", link };
|
||||
}
|
||||
export const clampResize = (layout: LayoutSnapshot, id: NodeId, world: Vec2): Vec2 | undefined => {
|
||||
const n = layout.nodes.get(id);
|
||||
if (!n || n.kind !== "node") return;
|
||||
return {
|
||||
x: Math.min(G.maxWidth, Math.max(n.minimumSize.x, world.x - n.bounds.x)),
|
||||
y: Math.max(n.minimumSize.y, n.bounds.y - world.y),
|
||||
};
|
||||
};
|
||||
export function frameDropCandidate(layout: LayoutSnapshot, id: NodeId, world: Vec2): NodeId | undefined {
|
||||
const node = layout.nodes.get(id);
|
||||
if (!node) return;
|
||||
const descendants = new Set<NodeId>();
|
||||
for (const n of layout.nodes.values()) {
|
||||
let p = n.parentId;
|
||||
while (p) {
|
||||
if (p === id) {
|
||||
descendants.add(n.id);
|
||||
break;
|
||||
}
|
||||
p = layout.nodes.get(p)?.parentId;
|
||||
}
|
||||
}
|
||||
return [...layout.nodes.values()]
|
||||
.filter((n) => n.kind === "frame" && n.id !== id && !descendants.has(n.id) && inRect(world, n.bounds))
|
||||
.sort((a, b) => a.bounds.width * a.bounds.height - b.bounds.width * b.bounds.height)[0]?.id;
|
||||
}
|
||||
|
||||
export function zoomAt(transform: ViewTransform, cursor: Vec2, deltaY: number): { center: Vec2; zoom: number } {
|
||||
const anchor = viewToWorld(cursor, transform);
|
||||
const zoom = Math.min(4, Math.max(0.1, transform.zoom * Math.exp(-deltaY * 0.0015)));
|
||||
return {
|
||||
zoom,
|
||||
center: {
|
||||
x: anchor.x - (cursor.x - transform.viewport.x / 2) / zoom,
|
||||
y: anchor.y + (cursor.y - transform.viewport.y / 2) / zoom,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function groupRoots(selected: ReadonlySet<NodeId>, layout: LayoutSnapshot): readonly NodeId[] {
|
||||
return [...selected].filter((id) => {
|
||||
let parent = layout.nodes.get(id)?.parentId;
|
||||
while (parent) {
|
||||
if (selected.has(parent)) return false;
|
||||
parent = layout.nodes.get(parent)?.parentId;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
Vendored
+103
@@ -0,0 +1,103 @@
|
||||
import type { Command, CompatibleFxNodeSaveData, FxNodeReplayCommand, FxNodeSaveData } from "../commands/types.js";
|
||||
import { FXNODE_SAVE_DATA_LIMITS } from "../commands/save-data.js";
|
||||
import type { FxNodeCompositionData } from "../composition/types.js";
|
||||
import { admitStructuredData, cloneJson, deepFreeze, type StructuredDataMetrics } from "../core/json.js";
|
||||
import type { GraphLayoutV2 } from "../core/types.js";
|
||||
|
||||
export interface JournalEntry<C extends FxNodeCompositionData = FxNodeCompositionData> {
|
||||
readonly command: FxNodeReplayCommand<C>;
|
||||
readonly metrics: StructuredDataMetrics;
|
||||
readonly atomicCommands: number;
|
||||
}
|
||||
export interface WorkerJournal<C extends FxNodeCompositionData = FxNodeCompositionData> {
|
||||
readonly baseline: GraphLayoutV2;
|
||||
readonly applied: readonly JournalEntry<C>[];
|
||||
readonly redo: readonly JournalEntry<C>[];
|
||||
readonly values: number;
|
||||
readonly stringCodeUnits: number;
|
||||
readonly atomicCommands: number;
|
||||
readonly depth: number;
|
||||
}
|
||||
const metrics = (command: FxNodeReplayCommand): JournalEntry | undefined => {
|
||||
const admitted = admitStructuredData(command, FXNODE_SAVE_DATA_LIMITS);
|
||||
if (!admitted.ok) return;
|
||||
return deepFreeze({
|
||||
command: admitted.value as FxNodeReplayCommand,
|
||||
metrics: admitted.metrics,
|
||||
atomicCommands: command.type === "batch" ? command.commands.length : 1,
|
||||
});
|
||||
};
|
||||
const fold = <C extends FxNodeCompositionData>(
|
||||
baseline: GraphLayoutV2,
|
||||
applied: readonly JournalEntry<C>[],
|
||||
redo: readonly JournalEntry<C>[],
|
||||
): WorkerJournal<C> =>
|
||||
deepFreeze({
|
||||
baseline: cloneJson(baseline),
|
||||
applied: [...applied],
|
||||
redo: [...redo],
|
||||
values: applied.reduce((n, x) => n + x.metrics.values, 0),
|
||||
stringCodeUnits: applied.reduce((n, x) => n + x.metrics.stringCodeUnits, 0),
|
||||
atomicCommands: applied.reduce((n, x) => n + x.atomicCommands, 0),
|
||||
depth: applied.reduce((n, x) => Math.max(n, x.metrics.depth), 0),
|
||||
});
|
||||
export const checkpointJournal = <C extends FxNodeCompositionData>(baseline: GraphLayoutV2): WorkerJournal<C> =>
|
||||
fold(baseline, [], []);
|
||||
export const importJournal = <C extends FxNodeCompositionData>(
|
||||
data: CompatibleFxNodeSaveData<C> | FxNodeSaveData<C>,
|
||||
): WorkerJournal<C> =>
|
||||
fold(
|
||||
data.baseline,
|
||||
data.commands.map((command) => metrics(command) as JournalEntry<C>),
|
||||
[],
|
||||
);
|
||||
export const journalSaveData = <C extends FxNodeCompositionData>(
|
||||
journal: WorkerJournal<C>,
|
||||
composition: C,
|
||||
): FxNodeSaveData<C> =>
|
||||
cloneJson({
|
||||
kind: "fxnode.command-log",
|
||||
schemaVersion: 2,
|
||||
composition,
|
||||
baseline: journal.baseline,
|
||||
commands: journal.applied.map((entry) => entry.command),
|
||||
} as FxNodeSaveData<C>);
|
||||
export function advanceJournal<C extends FxNodeCompositionData>(
|
||||
journal: WorkerJournal<C>,
|
||||
command: Command<C>,
|
||||
candidateBaseline: GraphLayoutV2,
|
||||
strictReplayValid: (baseline: GraphLayoutV2, commands: readonly FxNodeReplayCommand<C>[]) => boolean,
|
||||
): WorkerJournal<C> {
|
||||
let applied = journal.applied,
|
||||
redo = journal.redo;
|
||||
if (command.type === "undo") {
|
||||
const entry = applied.at(-1);
|
||||
if (!entry) return checkpointJournal(candidateBaseline);
|
||||
applied = applied.slice(0, -1);
|
||||
redo = [...redo, entry];
|
||||
} else if (command.type === "redo") {
|
||||
const entry = redo.at(-1);
|
||||
if (!entry) return checkpointJournal(candidateBaseline);
|
||||
applied = [...applied, entry];
|
||||
redo = redo.slice(0, -1);
|
||||
} else {
|
||||
const entry = metrics(command);
|
||||
if (!entry) return checkpointJournal(candidateBaseline);
|
||||
applied = [...applied, entry as JournalEntry<C>];
|
||||
redo = [];
|
||||
}
|
||||
const next = fold(journal.baseline, applied, redo),
|
||||
over =
|
||||
next.applied.length > FXNODE_SAVE_DATA_LIMITS.maxCommands ||
|
||||
next.atomicCommands > FXNODE_SAVE_DATA_LIMITS.maxAtomicCommands ||
|
||||
next.values > FXNODE_SAVE_DATA_LIMITS.maxValues ||
|
||||
next.stringCodeUnits > FXNODE_SAVE_DATA_LIMITS.maxStringCodeUnits ||
|
||||
next.depth > FXNODE_SAVE_DATA_LIMITS.maxDepth;
|
||||
return over ||
|
||||
!strictReplayValid(
|
||||
next.baseline,
|
||||
next.applied.map((entry) => entry.command),
|
||||
)
|
||||
? checkpointJournal(candidateBaseline)
|
||||
: next;
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import type { LinkId, Vec2 } from "../core/types.js";
|
||||
import { worldToView } from "../layout/geometry.js";
|
||||
import type { LayoutSnapshot } from "../layout/types.js";
|
||||
|
||||
export const MAX_KNIFE_POINTS = 256;
|
||||
export function appendKnifePoint(points: readonly Vec2[], point: Vec2, minimumDistance = 2): readonly Vec2[] {
|
||||
const last = points.at(-1);
|
||||
if (last && Math.hypot(last.x - point.x, last.y - point.y) < minimumDistance) return points;
|
||||
return points.length < MAX_KNIFE_POINTS ? [...points, point] : [...points.slice(1), point];
|
||||
}
|
||||
const orient = (a: Vec2, b: Vec2, c: Vec2) => (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
||||
const between = (a: number, b: number, x: number) => x >= Math.min(a, b) - 1e-7 && x <= Math.max(a, b) + 1e-7;
|
||||
export function segmentsIntersect(a: Vec2, b: Vec2, c: Vec2, d: Vec2): boolean {
|
||||
const abC = orient(a, b, c),
|
||||
abD = orient(a, b, d),
|
||||
cdA = orient(c, d, a),
|
||||
cdB = orient(c, d, b);
|
||||
if (((abC > 0 && abD < 0) || (abC < 0 && abD > 0)) && ((cdA > 0 && cdB < 0) || (cdA < 0 && cdB > 0))) return true;
|
||||
return (
|
||||
(Math.abs(abC) < 1e-7 && between(a.x, b.x, c.x) && between(a.y, b.y, c.y)) ||
|
||||
(Math.abs(abD) < 1e-7 && between(a.x, b.x, d.x) && between(a.y, b.y, d.y)) ||
|
||||
(Math.abs(cdA) < 1e-7 && between(c.x, d.x, a.x) && between(c.y, d.y, a.y)) ||
|
||||
(Math.abs(cdB) < 1e-7 && between(c.x, d.x, b.x) && between(c.y, d.y, b.y))
|
||||
);
|
||||
}
|
||||
export function crossedLinks(layout: LayoutSnapshot, path: readonly Vec2[], includeMuted = false): Set<LinkId> {
|
||||
const result = new Set<LinkId>();
|
||||
if (path.length < 2) return result;
|
||||
const planned = layout as LayoutSnapshot & { candidateLinkIds?: readonly LinkId[] };
|
||||
for (const id of planned.candidateLinkIds ?? layout.links.keys()) {
|
||||
const link = layout.links.get(id);
|
||||
if (!link?.visible || (!includeMuted && link.muted)) continue;
|
||||
const samples = link.points.map((p) => worldToView(p, layout.transform));
|
||||
outer: for (let i = 1; i < path.length; i++)
|
||||
for (let j = 1; j < samples.length; j++)
|
||||
if (segmentsIntersect(path[i - 1]!, path[i]!, samples[j - 1]!, samples[j]!)) {
|
||||
result.add(id);
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
export const enum DirtyReason {
|
||||
Scene = 1,
|
||||
Camera = 2,
|
||||
Selection = 4,
|
||||
Preview = 8,
|
||||
Viewport = 16,
|
||||
Barrier = 32,
|
||||
HostInteraction = 64,
|
||||
}
|
||||
export interface SchedulerMetrics {
|
||||
requests: number;
|
||||
coalesced: number;
|
||||
frames: number;
|
||||
maxInFlight: number;
|
||||
staleAcks: number;
|
||||
}
|
||||
type InvalidationTarget = { readonly scheduler: Pick<RenderScheduler, "request"> };
|
||||
|
||||
/** Worker-internal bridge from atlas invalidations to repaint requests. */
|
||||
export function requestViewInvalidations(
|
||||
viewIds: readonly string[],
|
||||
views: ReadonlyMap<string, InvalidationTarget>,
|
||||
): void {
|
||||
for (const viewId of viewIds) views.get(viewId)?.scheduler.request(0, DirtyReason.Viewport);
|
||||
}
|
||||
|
||||
export class RenderScheduler {
|
||||
private dirty = 0;
|
||||
private inFlight: number | undefined;
|
||||
private scheduled = false;
|
||||
private running = false;
|
||||
private latestRenderId = 0;
|
||||
private nextFrameId = 1;
|
||||
private poll = () => {};
|
||||
readonly metrics: SchedulerMetrics = { requests: 0, coalesced: 0, frames: 0, maxInFlight: 0, staleAcks: 0 };
|
||||
constructor(
|
||||
private readonly draw: (frameId: number, renderId: number, reasons: number) => void,
|
||||
private readonly enqueue: (callback: () => void) => void = (callback) => {
|
||||
const raf = (globalThis as { requestAnimationFrame?: (cb: () => void) => void }).requestAnimationFrame;
|
||||
raf ? raf(callback) : setTimeout(callback, 16);
|
||||
},
|
||||
) {}
|
||||
start(poll: () => void = () => {}): void {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
this.poll = poll;
|
||||
this.schedule();
|
||||
}
|
||||
stop(): void {
|
||||
this.running = false;
|
||||
}
|
||||
request(renderId = this.latestRenderId, reason: DirtyReason = DirtyReason.Scene): void {
|
||||
this.metrics.requests++;
|
||||
this.latestRenderId = Math.max(this.latestRenderId, renderId);
|
||||
if (this.dirty || this.inFlight !== undefined) this.metrics.coalesced++;
|
||||
this.dirty |= reason;
|
||||
}
|
||||
consumed(frameId: number): void {
|
||||
if (frameId !== this.inFlight) {
|
||||
this.metrics.staleAcks++;
|
||||
return;
|
||||
}
|
||||
this.inFlight = undefined;
|
||||
}
|
||||
defer(frameId: number, reasons: number): void {
|
||||
if (frameId !== this.inFlight) return;
|
||||
this.inFlight = undefined;
|
||||
this.dirty |= reasons;
|
||||
}
|
||||
private schedule(): void {
|
||||
if (!this.running || this.scheduled) return;
|
||||
this.scheduled = true;
|
||||
this.enqueue(() => {
|
||||
this.scheduled = false;
|
||||
if (!this.running) return;
|
||||
try {
|
||||
this.poll();
|
||||
if (this.dirty && this.inFlight === undefined) {
|
||||
const reasons = this.dirty;
|
||||
this.dirty = 0;
|
||||
const frameId = this.nextFrameId++;
|
||||
this.inFlight = frameId;
|
||||
this.metrics.frames++;
|
||||
this.metrics.maxInFlight = Math.max(this.metrics.maxInFlight, 1);
|
||||
this.draw(frameId, this.latestRenderId, reasons);
|
||||
}
|
||||
} finally {
|
||||
this.schedule();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import type { BatchCommand, Command } from "../commands/types.js";
|
||||
import type { GraphDocument, LinkId, NodeId } from "../core/types.js";
|
||||
|
||||
/** Deterministic removal: selected links not incident to selected nodes, then nodes. */
|
||||
export function planSelectionRemoval(
|
||||
document: GraphDocument,
|
||||
selectedNodes: ReadonlySet<NodeId>,
|
||||
selectedLinks: ReadonlySet<LinkId>,
|
||||
): Command | null {
|
||||
const nodes = [...selectedNodes].filter((id) => document.nodes[id]).sort();
|
||||
const nodeSet = new Set(nodes);
|
||||
const links = [...selectedLinks]
|
||||
.filter((id) => {
|
||||
const link = document.links[id];
|
||||
return !!link && !nodeSet.has(link.fromNodeId) && !nodeSet.has(link.toNodeId);
|
||||
})
|
||||
.sort();
|
||||
const commands: BatchCommand[] = [
|
||||
...links.map((id) => ({ type: "link.remove" as const, id })),
|
||||
...nodes.map((id) => ({ type: "node.remove" as const, id })),
|
||||
];
|
||||
return { type: "batch", commands };
|
||||
}
|
||||
|
||||
/** Only known standard nodes can be muted. Omitted desired state toggles uniformly (mixed => mute all). */
|
||||
export function planSelectionMute(
|
||||
document: GraphDocument,
|
||||
selectedNodes: ReadonlySet<NodeId>,
|
||||
isStandard: (id: NodeId) => boolean,
|
||||
desired?: boolean,
|
||||
): Command | null {
|
||||
const nodes = [...selectedNodes].filter((id) => document.nodes[id]?.known && isStandard(id)).sort();
|
||||
const value = desired ?? nodes.some((id) => !document.nodes[id]!.muted);
|
||||
const commands: BatchCommand[] = nodes
|
||||
.filter((id) => document.nodes[id]!.muted !== value)
|
||||
.map((id) => ({ type: "node.mute", id, value }));
|
||||
return { type: "batch", commands };
|
||||
}
|
||||
Vendored
+151
@@ -0,0 +1,151 @@
|
||||
import type { LinkId, NodeId, ParameterValue, Vec2 } from "../core/types.js";
|
||||
import type { ColorRamp } from "../widgets/color-ramp.js";
|
||||
import type { Oklch, Rgba } from "../color/oklab.js";
|
||||
import type { ColorPickerLayout } from "../layout/types.js";
|
||||
export interface DragSession {
|
||||
readonly pointerId: number;
|
||||
readonly startView: Vec2;
|
||||
readonly startWorld: Vec2;
|
||||
readonly origins: ReadonlyMap<NodeId, Vec2>;
|
||||
moved: boolean;
|
||||
}
|
||||
export interface CollapseAnimation {
|
||||
from: number;
|
||||
to: 0 | 1;
|
||||
value: number;
|
||||
startedAt: number;
|
||||
durationMs: number;
|
||||
}
|
||||
export type ControlEdit =
|
||||
| { kind: "string"; controlId: string; buffer: string }
|
||||
| { kind: "number"; controlId: string; component: number; buffer: string; selectAll: boolean };
|
||||
export interface WorkerSession {
|
||||
knife?: { pointerId: number; points: readonly Vec2[]; crossed: Set<LinkId>; mode: "remove" | "mute" };
|
||||
}
|
||||
export interface WorkerSession {
|
||||
colorPicker?: {
|
||||
layout: ColorPickerLayout;
|
||||
controlId: string;
|
||||
target: { kind: "control" } | { kind: "ramp-stop"; stopId: string; original: ColorRamp };
|
||||
model: Oklch;
|
||||
rgba: Rgba;
|
||||
hsv: readonly [number, number, number];
|
||||
edit?: { field: "rgba" | "hsv" | "hex"; index: number; buffer: string; selectAll: boolean; invalid: boolean };
|
||||
drag?: { pointerId: number; region: "plane" | "lightness" | "alpha" };
|
||||
};
|
||||
}
|
||||
export interface WorkerSession {
|
||||
colorWheel?: {
|
||||
controlId: string;
|
||||
original: ParameterValue;
|
||||
model: Oklch;
|
||||
rgba: Rgba;
|
||||
pointerId: number;
|
||||
region: "plane" | "lightness";
|
||||
bounds: { x: number; y: number; width: number; height: number };
|
||||
};
|
||||
}
|
||||
export interface WorkerSession {
|
||||
cameraCenter: Vec2;
|
||||
zoom: number;
|
||||
selectedNodes: Set<NodeId>;
|
||||
selectedLinks: Set<LinkId>;
|
||||
activeNode?: NodeId;
|
||||
hoverNode?: NodeId;
|
||||
hoveredControl?: string;
|
||||
focusedControl?: string;
|
||||
hoveredRampTarget?: string;
|
||||
focusedRampTarget?: string;
|
||||
activeRampStopByControl: Map<string, string>;
|
||||
collapseAnimations: Map<NodeId, CollapseAnimation>;
|
||||
controlEdit?: ControlEdit;
|
||||
previewValues: Map<string, ParameterValue>;
|
||||
scrub?: {
|
||||
pointerId: number;
|
||||
controlId: string;
|
||||
component: number;
|
||||
startX: number;
|
||||
original: ParameterValue;
|
||||
moved: boolean;
|
||||
};
|
||||
rampDrag?: { pointerId: number; controlId: string; stopId: string; original: ParameterValue };
|
||||
reroutePress?: { pointerId: number; nodeId: NodeId; socketId: import("../core/types.js").SocketId; start: Vec2 };
|
||||
uiOrder: NodeId[];
|
||||
previewPositions: Map<NodeId, Vec2>;
|
||||
previewSizes: Map<NodeId, Vec2>;
|
||||
pointer?: Vec2;
|
||||
drag?: DragSession;
|
||||
modalMove?: Omit<DragSession, "pointerId">;
|
||||
box?: { pointerId: number; start: Vec2; current: Vec2; checkpoint: Set<NodeId>; add: boolean };
|
||||
linkDrag?: {
|
||||
pointerId: number;
|
||||
from: import("../core/types.js").SocketId;
|
||||
current: Vec2;
|
||||
candidate?: import("../core/types.js").SocketId;
|
||||
};
|
||||
resize?: { pointerId: number; id: NodeId };
|
||||
parentHighlight?: NodeId;
|
||||
pan?: { pointerId: number; last: Vec2 };
|
||||
}
|
||||
export const createSession = (
|
||||
camera: { readonly center: Vec2; readonly zoom: number } = {
|
||||
center: { x: 0, y: 0 },
|
||||
zoom: 1,
|
||||
},
|
||||
): WorkerSession => ({
|
||||
cameraCenter: { ...camera.center },
|
||||
zoom: camera.zoom,
|
||||
selectedNodes: new Set(),
|
||||
selectedLinks: new Set(),
|
||||
activeRampStopByControl: new Map(),
|
||||
collapseAnimations: new Map(),
|
||||
uiOrder: [],
|
||||
previewPositions: new Map(),
|
||||
previewSizes: new Map(),
|
||||
previewValues: new Map(),
|
||||
});
|
||||
function resetDocumentTransients(session: WorkerSession): void {
|
||||
delete session.hoverNode;
|
||||
delete session.hoveredControl;
|
||||
delete session.focusedControl;
|
||||
delete session.hoveredRampTarget;
|
||||
delete session.focusedRampTarget;
|
||||
delete session.knife;
|
||||
delete session.drag;
|
||||
delete session.scrub;
|
||||
delete session.rampDrag;
|
||||
delete session.reroutePress;
|
||||
delete session.modalMove;
|
||||
delete session.box;
|
||||
delete session.linkDrag;
|
||||
delete session.resize;
|
||||
delete session.parentHighlight;
|
||||
delete session.pan;
|
||||
delete session.controlEdit;
|
||||
delete session.colorPicker;
|
||||
delete session.colorWheel;
|
||||
session.activeRampStopByControl.clear();
|
||||
session.collapseAnimations.clear();
|
||||
session.previewValues.clear();
|
||||
session.previewPositions.clear();
|
||||
session.previewSizes.clear();
|
||||
}
|
||||
export function resetSessionForGraphReplacement(session: WorkerSession): void {
|
||||
session.selectedNodes.clear();
|
||||
session.selectedLinks.clear();
|
||||
session.uiOrder = [];
|
||||
delete session.activeNode;
|
||||
resetDocumentTransients(session);
|
||||
}
|
||||
export function resetSessionForCompositionRebind(
|
||||
session: WorkerSession,
|
||||
nodeIds: ReadonlySet<NodeId>,
|
||||
linkIds: ReadonlySet<LinkId>,
|
||||
retainedUiOrder: readonly NodeId[],
|
||||
): void {
|
||||
session.selectedNodes = new Set([...session.selectedNodes].filter((id) => nodeIds.has(id)));
|
||||
session.selectedLinks = new Set([...session.selectedLinks].filter((id) => linkIds.has(id)));
|
||||
session.uiOrder = [...retainedUiOrder];
|
||||
if (session.activeNode && !nodeIds.has(session.activeNode)) delete session.activeNode;
|
||||
resetDocumentTransients(session);
|
||||
}
|
||||
+428
@@ -0,0 +1,428 @@
|
||||
import {
|
||||
planAtlasCompaction,
|
||||
planAtlasUpsert,
|
||||
removeAtlasItem,
|
||||
type AtlasLayout,
|
||||
type AtlasRect,
|
||||
type AtlasSize,
|
||||
} from "./atlas-allocator.js";
|
||||
|
||||
export type AtlasErrorCode =
|
||||
| "atlas.dimension"
|
||||
| "atlas.capacity"
|
||||
| "atlas.create"
|
||||
| "atlas.context"
|
||||
| "atlas.crop"
|
||||
| "atlas.context-lost";
|
||||
export class ViewAtlasError extends Error {
|
||||
override readonly name = "ViewAtlasError";
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: AtlasErrorCode,
|
||||
readonly fatal = false,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options);
|
||||
}
|
||||
}
|
||||
export interface ViewAtlasSlot extends AtlasRect {
|
||||
readonly viewId: string;
|
||||
readonly slotGeneration: number;
|
||||
}
|
||||
export interface ViewAtlasSurface {
|
||||
readonly canvas: OffscreenCanvas;
|
||||
readonly context: OffscreenCanvasRenderingContext2D;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly atlasGeneration: number;
|
||||
readonly allocationEpoch: number;
|
||||
}
|
||||
export interface ViewAtlasMutation {
|
||||
readonly slot: ViewAtlasSlot;
|
||||
readonly movedViewIds: readonly string[];
|
||||
readonly invalidatedViewIds: readonly string[];
|
||||
readonly atlasGeneration: number;
|
||||
readonly allocationEpoch: number;
|
||||
}
|
||||
export interface ViewAtlasDetachResult {
|
||||
readonly invalidatedViewIds: readonly string[];
|
||||
}
|
||||
export interface ViewAtlasPaintTarget {
|
||||
readonly context: OffscreenCanvasRenderingContext2D;
|
||||
readonly deviceX: number;
|
||||
readonly deviceY: number;
|
||||
readonly deviceWidth: number;
|
||||
readonly deviceHeight: number;
|
||||
}
|
||||
export interface ViewAtlasPlatform {
|
||||
createCanvas(width: number, height: number): OffscreenCanvas;
|
||||
createBitmap(canvas: OffscreenCanvas, x: number, y: number, width: number, height: number): Promise<ImageBitmap>;
|
||||
}
|
||||
const browserPlatform: ViewAtlasPlatform = {
|
||||
createCanvas: (width, height) => new OffscreenCanvas(width, height),
|
||||
createBitmap: (canvas, x, y, width, height) => createImageBitmap(canvas, x, y, width, height),
|
||||
};
|
||||
|
||||
export class ViewAtlasManager {
|
||||
private layout: AtlasLayout | undefined;
|
||||
private canvas: OffscreenCanvas | undefined;
|
||||
private context: OffscreenCanvasRenderingContext2D | undefined;
|
||||
private atlasGeneration = 0;
|
||||
private allocationEpoch = 0;
|
||||
private readonly slotGenerations = new Map<string, number>();
|
||||
private queue: Promise<void> = Promise.resolve();
|
||||
private disposed = false;
|
||||
private lifecycle = 0;
|
||||
private lost = false;
|
||||
private surfaceTransition = false;
|
||||
private terminalError: ViewAtlasError | undefined;
|
||||
private contextLossEpoch = 0;
|
||||
private readonly contextLost = (event: Event) => {
|
||||
if ("preventDefault" in event) event.preventDefault();
|
||||
this.contextLossEpoch++;
|
||||
this.lost = true;
|
||||
};
|
||||
private readonly contextRestored = (event: Event) => {
|
||||
const canvas = this.canvas;
|
||||
if (!canvas || event.currentTarget !== canvas) return;
|
||||
const lifecycle = this.lifecycle,
|
||||
lossEpoch = this.contextLossEpoch;
|
||||
void this.serial(async () => {
|
||||
if (this.canvas !== canvas || lossEpoch !== this.contextLossEpoch) return;
|
||||
const context = this.getContext(canvas);
|
||||
await this.probe(canvas);
|
||||
this.ensureLifecycle(lifecycle);
|
||||
if (this.canvas !== canvas || lossEpoch !== this.contextLossEpoch) return;
|
||||
this.context = context;
|
||||
this.lost = false;
|
||||
this.atlasGeneration++;
|
||||
this.allocationEpoch++;
|
||||
for (const id of this.layout?.items.keys() ?? []) this.bumpSlot(id);
|
||||
}).catch((cause) => {
|
||||
if (
|
||||
this.disposed ||
|
||||
lifecycle !== this.lifecycle ||
|
||||
this.canvas !== canvas ||
|
||||
lossEpoch !== this.contextLossEpoch
|
||||
)
|
||||
return;
|
||||
const error = new ViewAtlasError("Unable to restore the view atlas", "atlas.context-lost", true, { cause });
|
||||
this.terminalError = error;
|
||||
this.onFatal(error);
|
||||
});
|
||||
};
|
||||
constructor(
|
||||
private readonly platform: ViewAtlasPlatform = browserPlatform,
|
||||
private readonly onFatal: (error: ViewAtlasError) => void = () => {},
|
||||
) {}
|
||||
|
||||
surface(): ViewAtlasSurface | undefined {
|
||||
if (!this.canvas || !this.context || this.lost || this.surfaceTransition || this.terminalError) return;
|
||||
return Object.freeze({
|
||||
canvas: this.canvas,
|
||||
context: this.context,
|
||||
width: this.canvas.width,
|
||||
height: this.canvas.height,
|
||||
atlasGeneration: this.atlasGeneration,
|
||||
allocationEpoch: this.allocationEpoch,
|
||||
});
|
||||
}
|
||||
slot(viewId: string): ViewAtlasSlot | undefined {
|
||||
const region = this.layout?.regions.get(viewId),
|
||||
size = this.layout?.items.get(viewId),
|
||||
generation = this.slotGenerations.get(viewId);
|
||||
if (!region || !size || generation === undefined) return;
|
||||
return Object.freeze({
|
||||
viewId,
|
||||
x: region.x,
|
||||
y: region.y,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
slotGeneration: generation,
|
||||
});
|
||||
}
|
||||
attach(viewId: string, size: AtlasSize): Promise<ViewAtlasMutation> {
|
||||
return this.mutate(viewId, size, false);
|
||||
}
|
||||
resize(viewId: string, size: AtlasSize): Promise<ViewAtlasMutation> {
|
||||
return this.mutate(viewId, size, true);
|
||||
}
|
||||
renderAndCrop(
|
||||
viewId: string,
|
||||
expected: AtlasSize,
|
||||
paint: (target: ViewAtlasPaintTarget) => void,
|
||||
): Promise<ImageBitmap | undefined> {
|
||||
return this.serial(async () => {
|
||||
const canvas = this.canvas,
|
||||
context = this.context,
|
||||
slot = this.slot(viewId),
|
||||
lifecycle = this.lifecycle,
|
||||
atlasGeneration = this.atlasGeneration,
|
||||
lossEpoch = this.contextLossEpoch;
|
||||
if (
|
||||
!canvas ||
|
||||
!context ||
|
||||
!slot ||
|
||||
this.lost ||
|
||||
this.surfaceTransition ||
|
||||
slot.width !== expected.width ||
|
||||
slot.height !== expected.height
|
||||
)
|
||||
return;
|
||||
context.save();
|
||||
try {
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.beginPath();
|
||||
context.rect(slot.x, slot.y, slot.width, slot.height);
|
||||
context.clip();
|
||||
context.clearRect(slot.x, slot.y, slot.width, slot.height);
|
||||
paint({
|
||||
context,
|
||||
deviceX: slot.x,
|
||||
deviceY: slot.y,
|
||||
deviceWidth: slot.width,
|
||||
deviceHeight: slot.height,
|
||||
});
|
||||
} finally {
|
||||
context.restore();
|
||||
}
|
||||
const bitmap = await this.platform.createBitmap(canvas, slot.x, slot.y, slot.width, slot.height);
|
||||
const current = this.slot(viewId);
|
||||
if (
|
||||
this.disposed ||
|
||||
lifecycle !== this.lifecycle ||
|
||||
this.canvas !== canvas ||
|
||||
this.context !== context ||
|
||||
this.lost ||
|
||||
lossEpoch !== this.contextLossEpoch ||
|
||||
atlasGeneration !== this.atlasGeneration ||
|
||||
!current ||
|
||||
current.slotGeneration !== slot.slotGeneration ||
|
||||
bitmap.width !== expected.width ||
|
||||
bitmap.height !== expected.height
|
||||
) {
|
||||
bitmap.close();
|
||||
return;
|
||||
}
|
||||
return bitmap;
|
||||
});
|
||||
}
|
||||
detach(viewId: string): Promise<ViewAtlasDetachResult> {
|
||||
return this.serial(async () => {
|
||||
const lifecycle = this.lifecycle;
|
||||
if (!this.layout?.items.has(viewId)) return Object.freeze({ invalidatedViewIds: Object.freeze([]) });
|
||||
const previous = this.layout,
|
||||
next = removeAtlasItem(previous, viewId);
|
||||
this.slotGenerations.delete(viewId);
|
||||
this.allocationEpoch++;
|
||||
if (!next) {
|
||||
this.unregisterContextEvents(this.canvas);
|
||||
this.layout = this.canvas = this.context = undefined;
|
||||
this.lost = false;
|
||||
this.lifecycle++;
|
||||
this.atlasGeneration++;
|
||||
return Object.freeze({ invalidatedViewIds: Object.freeze([]) });
|
||||
}
|
||||
this.layout = next;
|
||||
const compact = planAtlasCompaction(next);
|
||||
if (compact?.ok && (compact.layout.width !== next.width || compact.layout.height !== next.height))
|
||||
try {
|
||||
this.surfaceTransition = true;
|
||||
await this.resizeSurface(compact.layout.width, compact.layout.height);
|
||||
this.layout = compact.layout;
|
||||
for (const id of compact.layout.items.keys()) this.bumpSlot(id);
|
||||
this.ensureLifecycle(lifecycle);
|
||||
return Object.freeze({
|
||||
invalidatedViewIds: Object.freeze([...compact.layout.items.keys()].sort()),
|
||||
});
|
||||
} catch (error) {
|
||||
this.ensureLifecycle(lifecycle);
|
||||
this.layout = next;
|
||||
if (error instanceof ViewAtlasError && error.fatal) throw error;
|
||||
for (const id of next.items.keys()) this.bumpSlot(id);
|
||||
return Object.freeze({ invalidatedViewIds: Object.freeze([...next.items.keys()].sort()) });
|
||||
} finally {
|
||||
this.surfaceTransition = false;
|
||||
}
|
||||
this.ensureLifecycle(lifecycle);
|
||||
return Object.freeze({ invalidatedViewIds: Object.freeze([]) });
|
||||
});
|
||||
}
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.lifecycle++;
|
||||
this.unregisterContextEvents(this.canvas);
|
||||
this.layout = this.canvas = this.context = undefined;
|
||||
this.slotGenerations.clear();
|
||||
this.allocationEpoch++;
|
||||
this.atlasGeneration++;
|
||||
}
|
||||
private mutate(viewId: string, size: AtlasSize, mustExist: boolean): Promise<ViewAtlasMutation> {
|
||||
return this.serial(async () => {
|
||||
const lifecycle = this.lifecycle;
|
||||
if (mustExist && !this.layout?.items.has(viewId))
|
||||
throw new ViewAtlasError("View is not allocated in the atlas", "atlas.capacity");
|
||||
if (!mustExist && this.layout?.items.has(viewId))
|
||||
throw new ViewAtlasError("View is already allocated in the atlas", "atlas.capacity");
|
||||
const previous = this.layout,
|
||||
plan = planAtlasUpsert(previous, { id: viewId, ...size });
|
||||
if (!plan.ok)
|
||||
throw new ViewAtlasError(
|
||||
plan.code === "atlas.dimension" ? "View dimensions exceed atlas limits" : "View atlas capacity exceeded",
|
||||
plan.code,
|
||||
);
|
||||
const resized =
|
||||
!this.canvas || this.canvas.width !== plan.layout.width || this.canvas.height !== plan.layout.height;
|
||||
if (resized) this.surfaceTransition = true;
|
||||
try {
|
||||
if (resized) await this.resizeSurface(plan.layout.width, plan.layout.height);
|
||||
this.ensureLifecycle(lifecycle);
|
||||
this.layout = plan.layout;
|
||||
this.allocationEpoch++;
|
||||
const changed = new Set(plan.movedIds);
|
||||
if (
|
||||
!previous ||
|
||||
previous.items.get(viewId)?.width !== size.width ||
|
||||
previous.items.get(viewId)?.height !== size.height
|
||||
)
|
||||
changed.add(viewId);
|
||||
if (resized) for (const id of plan.layout.items.keys()) changed.add(id);
|
||||
for (const id of changed) this.bumpSlot(id);
|
||||
const slot = this.slot(viewId)!;
|
||||
return Object.freeze({
|
||||
slot,
|
||||
movedViewIds: Object.freeze(plan.movedIds.slice()),
|
||||
invalidatedViewIds: Object.freeze([...changed].sort()),
|
||||
atlasGeneration: this.atlasGeneration,
|
||||
allocationEpoch: this.allocationEpoch,
|
||||
});
|
||||
} finally {
|
||||
if (resized) this.surfaceTransition = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
private bumpSlot(viewId: string): void {
|
||||
this.slotGenerations.set(viewId, (this.slotGenerations.get(viewId) ?? 0) + 1);
|
||||
}
|
||||
private async resizeSurface(width: number, height: number): Promise<void> {
|
||||
const lifecycle = this.lifecycle;
|
||||
if (!this.canvas) {
|
||||
let canvas: OffscreenCanvas;
|
||||
try {
|
||||
canvas = this.platform.createCanvas(width, height);
|
||||
} catch (cause) {
|
||||
throw new ViewAtlasError("Unable to create the view atlas", "atlas.create", false, { cause });
|
||||
}
|
||||
if (canvas.width !== width || canvas.height !== height)
|
||||
throw new ViewAtlasError("Atlas dimensions were not accepted", "atlas.create");
|
||||
this.registerContextEvents(canvas);
|
||||
const lossEpoch = this.contextLossEpoch;
|
||||
try {
|
||||
const context = this.getContext(canvas);
|
||||
await this.probe(canvas);
|
||||
this.ensureLifecycle(lifecycle);
|
||||
if (lossEpoch !== this.contextLossEpoch || this.lost)
|
||||
throw new ViewAtlasError("View atlas context was lost during creation", "atlas.context-lost");
|
||||
this.canvas = canvas;
|
||||
this.context = context;
|
||||
this.atlasGeneration++;
|
||||
return;
|
||||
} catch (error) {
|
||||
this.unregisterContextEvents(canvas);
|
||||
this.lost = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const canvas = this.canvas,
|
||||
oldWidth = canvas.width,
|
||||
oldHeight = canvas.height,
|
||||
lossEpoch = this.contextLossEpoch;
|
||||
try {
|
||||
try {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
} catch (cause) {
|
||||
throw new ViewAtlasError("Unable to resize the view atlas", "atlas.create", false, { cause });
|
||||
}
|
||||
if (canvas.width !== width || canvas.height !== height)
|
||||
throw new ViewAtlasError("Atlas dimensions were not accepted", "atlas.create");
|
||||
const context = this.getContext(canvas);
|
||||
await this.probe(canvas);
|
||||
this.ensureLifecycle(lifecycle);
|
||||
if (lossEpoch !== this.contextLossEpoch || this.lost)
|
||||
throw new ViewAtlasError("View atlas context was lost while resizing", "atlas.context-lost");
|
||||
this.context = context;
|
||||
this.atlasGeneration++;
|
||||
} catch (cause) {
|
||||
if (this.disposed || lifecycle !== this.lifecycle) throw cause;
|
||||
try {
|
||||
canvas.width = oldWidth;
|
||||
canvas.height = oldHeight;
|
||||
if (canvas.width !== oldWidth || canvas.height !== oldHeight)
|
||||
throw new ViewAtlasError("Atlas dimensions could not be restored", "atlas.context-lost", true);
|
||||
this.context = this.getContext(canvas);
|
||||
await this.probe(canvas);
|
||||
this.ensureLifecycle(lifecycle);
|
||||
this.atlasGeneration++;
|
||||
// Resizing clears the canvas even when the old dimensions are restored.
|
||||
// Make every surviving slot stale so callers cannot reuse its old pixels.
|
||||
for (const id of this.layout?.items.keys() ?? []) this.bumpSlot(id);
|
||||
} catch (restoreCause) {
|
||||
this.unregisterContextEvents(canvas);
|
||||
this.canvas = this.context = undefined;
|
||||
throw new ViewAtlasError("Unable to restore the view atlas", "atlas.context-lost", true, {
|
||||
cause: restoreCause,
|
||||
});
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
private getContext(canvas: OffscreenCanvas): OffscreenCanvasRenderingContext2D {
|
||||
let context: OffscreenCanvasRenderingContext2D | null;
|
||||
try {
|
||||
context = canvas.getContext("2d");
|
||||
} catch (cause) {
|
||||
throw new ViewAtlasError("Unable to create the atlas 2D context", "atlas.context", false, { cause });
|
||||
}
|
||||
if (!context) throw new ViewAtlasError("Atlas 2D context is unavailable", "atlas.context");
|
||||
return context;
|
||||
}
|
||||
private async probe(canvas: OffscreenCanvas): Promise<void> {
|
||||
let bitmap: ImageBitmap;
|
||||
try {
|
||||
bitmap = await this.platform.createBitmap(canvas, 0, 0, 1, 1);
|
||||
} catch (cause) {
|
||||
throw new ViewAtlasError("Cropped atlas bitmaps are unavailable", "atlas.crop", false, { cause });
|
||||
}
|
||||
try {
|
||||
if (bitmap.width !== 1 || bitmap.height !== 1 || typeof bitmap.close !== "function")
|
||||
throw new ViewAtlasError("Cropped atlas bitmap capability is invalid", "atlas.crop");
|
||||
} finally {
|
||||
bitmap.close?.();
|
||||
}
|
||||
}
|
||||
private ensureLifecycle(lifecycle: number): void {
|
||||
if (this.disposed || lifecycle !== this.lifecycle)
|
||||
throw new ViewAtlasError("View atlas operation was cancelled", "atlas.context-lost", true);
|
||||
}
|
||||
private registerContextEvents(canvas: OffscreenCanvas): void {
|
||||
canvas.addEventListener?.("contextlost", this.contextLost);
|
||||
canvas.addEventListener?.("contextrestored", this.contextRestored);
|
||||
}
|
||||
private unregisterContextEvents(canvas: OffscreenCanvas | undefined): void {
|
||||
canvas?.removeEventListener?.("contextlost", this.contextLost);
|
||||
canvas?.removeEventListener?.("contextrestored", this.contextRestored);
|
||||
}
|
||||
private serial<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.queue.then(() => {
|
||||
if (this.terminalError) throw this.terminalError;
|
||||
if (this.disposed) throw new ViewAtlasError("View atlas has been disposed", "atlas.context-lost", true);
|
||||
return operation();
|
||||
});
|
||||
this.queue = result.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user