feat: add render graph driven renderer architecture

Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab

Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-07-27 02:53:44 +00:00
co-authored by heaust
parent 8a8369706b
commit d4e8634f67
290 changed files with 48804 additions and 1995 deletions
+132
View File
@@ -0,0 +1,132 @@
import { readFileSync, writeFileSync } from "node:fs";
import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "../test/application.js";
import { graphId, linkId, socketId, type GraphDocument, type GraphLink } from "@lib/core/types.js";
import { nullRecord } from "@lib/core/json.js";
const { decodeGraphDocument, materializeNode, save } = APPLICATION_HEADLESS;
const definitions = [...APPLICATION_COMPILED.nodes.values()];
const positions = { common: 0, shader: -280, geometry: -560, compositor: -840 };
// This visual fixture intentionally pins authored document sizes. Fresh nodes use calculated dimensions.
const authoredSizes: Readonly<Record<string, { readonly x: number; readonly y: number }>> = {
"fxnode.common.frame": { x: 300, y: 100 },
"fxnode.common.reroute": { x: 140, y: 100 },
"fxnode.common.group-input": { x: 180, y: 100 },
"fxnode.common.group-output": { x: 180, y: 100 },
"fxnode.shader.value": { x: 140, y: 100 },
"fxnode.shader.color": { x: 140, y: 100 },
"fxnode.shader.math": { x: 180, y: 100 },
"fxnode.shader.vector-math": { x: 190, y: 100 },
"fxnode.shader.mix": { x: 190, y: 100 },
"fxnode.shader.color-ramp": { x: 320, y: 100 },
"fxnode.shader.texture-coordinate": { x: 190, y: 100 },
"fxnode.shader.noise-texture": { x: 200, y: 100 },
"fxnode.shader.image-texture": { x: 280, y: 100 },
"fxnode.shader.principled-bsdf": { x: 220, y: 100 },
"fxnode.shader.material-output": { x: 190, y: 100 },
"fxnode.geometry.position": { x: 140, y: 100 },
"fxnode.geometry.mesh-cube": { x: 190, y: 100 },
"fxnode.geometry.set-position": { x: 190, y: 100 },
"fxnode.geometry.transform-geometry": { x: 210, y: 100 },
"fxnode.geometry.join-geometry": { x: 180, y: 100 },
"fxnode.compositor.image": { x: 240, y: 100 },
"fxnode.compositor.color-balance": { x: 400, y: 100 },
};
const nodes = definitions.map((definition) => {
const family = definition.typeId.split(".")[1] as keyof typeof positions;
const peers = definitions.filter((item) => item.typeId.split(".")[1] === family);
const materialized = materializeNode(`all-${definition.typeId.replaceAll(".", "-")}`, definition.typeId, {
x: 40 + peers.indexOf(definition) * 240,
y: positions[family],
});
const node = { ...materialized, size: authoredSizes[definition.typeId] ?? materialized.size };
if (definition.typeId === "fxnode.shader.color-ramp")
return {
...node,
parameters: {
ramp: {
kind: "json" as const,
value: {
colorMode: "hsv",
interpolation: "ease",
hueInterpolation: "far",
stops: [
{ id: "black", position: 0, color: [0, 0, 0, 1] },
{ id: "accent", position: 0.38, color: [0.05, 0.35, 1, 1] },
{ id: "white", position: 1, color: [1, 1, 1, 1] },
],
},
},
},
} as typeof node;
if (definition.typeId === "fxnode.shader.noise-texture")
return {
...node,
parameters: {
...node.parameters,
dimensions: { kind: "string" as const, value: "4d" },
noiseType: { kind: "string" as const, value: "hybrid-multifractal" },
},
} as typeof node;
return node;
});
const byType = (type: string) => nodes.find((node) => node.typeId === type)!;
const cube = byType("fxnode.geometry.mesh-cube"),
position = byType("fxnode.geometry.position"),
set = byType("fxnode.geometry.set-position"),
transform = byType("fxnode.geometry.transform-geometry"),
join = byType("fxnode.geometry.join-geometry");
const make = (id: string, from: typeof cube, fromKey: string, to: typeof join, toKey: string): GraphLink => ({
id: linkId(id),
fromNodeId: from.id,
fromSocketId: socketId(`${from.id}:${fromKey}`),
toNodeId: to.id,
toSocketId: socketId(`${to.id}:${toKey}`),
muted: false,
extensions: {},
});
const links = [
make("all-link-position", position, "position", set, "position"),
make("all-link-cube", cube, "mesh", set, "geometry"),
make("all-link-set", set, "result", join, "geometry"),
make("all-link-transform", transform, "result", join, "geometry"),
];
const document: GraphDocument = {
schemaVersion: 2,
graphId: graphId("all-supported"),
catalogVersion: APPLICATION_COMPILED.source.version,
nodes: nullRecord(nodes.map((node) => [node.id, node])),
links: nullRecord(links.map((link) => [link.id, link])),
metadata: nullRecord(),
};
const saved = save(document);
const { schemaVersion: _schemaVersion, ...persistedLayout } = saved;
export const initialLayout = {
...persistedLayout,
nodes: persistedLayout.nodes.map((node) => ({ ...node, known: true })),
};
const path = new URL("../examples/blender/all-supported/initialLayout.json", import.meta.url),
canonical = JSON.stringify(initialLayout, null, 2) + "\n";
if (process.argv.includes("--write")) writeFileSync(path, canonical);
else {
const bytes = readFileSync(path, "utf8"),
actual = JSON.parse(bytes),
decoded = decodeGraphDocument({
...actual,
schemaVersion: 2,
nodes: actual.nodes.map(({ known: _, ...node }: any) => node),
});
if (!decoded.ok) throw new Error(`Initial layout decode failed: ${JSON.stringify(decoded.issues)}`);
const ids = actual.nodes.map((node: { typeId: string }) => node.typeId),
expected = definitions.length;
if (
ids.length !== expected ||
new Set(ids).size !== expected ||
definitions.some((item) => !ids.includes(item.typeId))
)
throw new Error("Fixture must contain each application type exactly once");
if (JSON.stringify(actual) !== JSON.stringify(initialLayout) || bytes !== canonical)
throw new Error("Initial layout is not canonical; run generate:all-supported");
if (actual.links.filter((link: { toNodeId: string }) => link.toNodeId === join.id).length < 2)
throw new Error("Join Geometry needs two incoming links");
console.log("all-supported fixture verified");
}
+79
View File
@@ -0,0 +1,79 @@
import { readFileSync, writeFileSync } from "node:fs";
import { APPLICATION_HEADLESS } from "../test/application.js";
import { APPLICATION_VERSION } from "../examples/blender/nodes/application.js";
import {
graphId,
linkId,
nodeId,
socketId,
type GraphDocument,
type GraphLink,
type GraphNode,
} from "@lib/core/types.js";
import { nullRecord } from "@lib/core/json.js";
const { materializeNode, save } = APPLICATION_HEADLESS;
const node = (
id: string,
typeId: Parameters<typeof materializeNode>[1],
position: { readonly x: number; readonly y: number },
size: { readonly x: number; readonly y: number },
options: { readonly label?: string; readonly collapsed?: boolean; readonly parentId?: string } = {},
): GraphNode => ({
...materializeNode(id, typeId, position),
size,
...(options.label === undefined ? {} : { label: options.label }),
...(options.collapsed === undefined ? {} : { collapsed: options.collapsed }),
...(options.parentId === undefined ? {} : { parentId: nodeId(options.parentId) }),
});
const nodes = [
node("frame", "fxnode.common.frame", { x: -550, y: 250 }, { x: 190, y: 270 }, { label: "Surface Controls" }),
node("value-expanded", "fxnode.shader.value", { x: 30, y: -55 }, { x: 140, y: 100 }, { parentId: "frame" }),
node(
"value-collapsed",
"fxnode.shader.value",
{ x: 30, y: -175 },
{ x: 140, y: 100 },
{ collapsed: true, parentId: "frame" },
),
node("math", "fxnode.shader.math", { x: -300, y: 170 }, { x: 160, y: 110 }),
node("noise", "fxnode.shader.noise-texture", { x: -100, y: 190 }, { x: 165, y: 130 }),
node("reroute", "fxnode.common.reroute", { x: 105, y: 55 }, { x: 140, y: 100 }),
node("principled", "fxnode.shader.principled-bsdf", { x: 165, y: 175 }, { x: 185, y: 125 }),
node("output", "fxnode.shader.material-output", { x: 405, y: 115 }, { x: 155, y: 100 }),
];
const byId = nullRecord(nodes.map((item) => [item.id, item]));
const link = (id: string, fromNodeId: string, fromKey: string, toNodeId: string, toKey: string): GraphLink => ({
id: linkId(id),
fromNodeId: byId[fromNodeId]!.id,
fromSocketId: socketId(`${fromNodeId}:${fromKey}`),
toNodeId: byId[toNodeId]!.id,
toSocketId: socketId(`${toNodeId}:${toKey}`),
muted: false,
extensions: {},
});
const links = [
link("link-1", "value-expanded", "value", "math", "a"),
link("link-2", "value-collapsed", "value", "math", "b"),
link("link-3", "math", "value", "noise", "scale"),
link("link-4", "noise", "factor", "reroute", "input"),
link("link-5", "reroute", "output", "principled", "roughness"),
link("link-6", "principled", "bsdf", "output", "surface"),
];
const document: GraphDocument = {
schemaVersion: 2,
graphId: graphId("phase-4-example"),
catalogVersion: APPLICATION_VERSION,
nodes: byId,
links: nullRecord(links.map((item) => [item.id, item])),
metadata: nullRecord([["description", "Deterministic fxnode browser baseline"]]),
};
const path = new URL("../examples/blender/initialLayout.json", import.meta.url);
const saved = save(document);
const { schemaVersion: _schemaVersion, ...layout } = saved;
const canonical = `${JSON.stringify({ ...layout, nodes: layout.nodes.map((node) => ({ ...node, known: true })) }, null, 2)}\n`;
if (process.argv.includes("--write")) writeFileSync(path, canonical);
else if (readFileSync(path, "utf8") !== canonical)
throw new Error("Blender fixture is not canonical; run npm run generate:blender-fixture");
else console.log("blender fixture verified");
+106
View File
@@ -0,0 +1,106 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Create deterministic, project-authored Blender node-editor reference fixtures."""
import argparse
import sys
from pathlib import Path
import bpy
FIXTURES = (
"shader-basic-linked-near", "shader-basic-linked-far",
"shader-selected-active-hover-near", "shader-collapsed-near",
"common-frame-reroute-near", "shader-widget-rich-near",
"shader-socket-gallery-near", "geometry-socket-gallery-near",
)
def arguments():
parser = argparse.ArgumentParser()
parser.add_argument("--fixture", choices=FIXTURES, required=True)
parser.add_argument("--save", type=Path)
return parser.parse_args(sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [])
def add(nodes, node_type, name, x, y):
node = nodes.new(node_type)
node.name = name
node.label = name
node.location = (x, y)
return node
def shader_tree(fixture):
material = bpy.data.materials.new("fxnode-reference")
material.use_nodes = True
tree = material.node_tree
tree.nodes.clear()
nodes, links = tree.nodes, tree.links
output = add(nodes, "ShaderNodeOutputMaterial", "Material Output", 520, 0)
principled = add(nodes, "ShaderNodeBsdfPrincipled", "Principled BSDF", 160, 0)
noise = add(nodes, "ShaderNodeTexNoise", "Noise Texture", -440, 160)
ramp = add(nodes, "ShaderNodeValToRGB", "Color Ramp", -140, 180)
links.new(noise.outputs["Fac"], ramp.inputs["Fac"])
links.new(ramp.outputs["Color"], principled.inputs["Base Color"])
links.new(principled.outputs["BSDF"], output.inputs["Surface"])
if fixture == "shader-collapsed-near":
noise.hide = True
ramp.hide = True
elif fixture == "common-frame-reroute-near":
frame = add(nodes, "NodeFrame", "Texture controls", -500, 100)
noise.parent = frame
reroute = add(nodes, "NodeReroute", "Reroute", 60, 260)
links.new(ramp.outputs["Color"], reroute.inputs[0])
links.new(reroute.outputs[0], principled.inputs["Base Color"])
elif fixture == "shader-widget-rich-near":
add(nodes, "ShaderNodeMix", "Mix", -140, -240)
add(nodes, "ShaderNodeMath", "Math", 160, -280)
elif fixture == "shader-socket-gallery-near":
add(nodes, "ShaderNodeTexCoord", "Texture Coordinate", -700, -260)
add(nodes, "ShaderNodeVectorMath", "Vector Math", -400, -260)
add(nodes, "ShaderNodeValue", "Value", -100, -320)
add(nodes, "ShaderNodeRGB", "Color", 160, -320)
nodes.active = principled
principled.select = True
return tree
def geometry_tree():
obj = bpy.data.objects.new("fxnode-geometry", bpy.data.meshes.new("fxnode-mesh"))
bpy.context.collection.objects.link(obj)
bpy.context.view_layer.objects.active = obj
modifier = obj.modifiers.new("fxnode-reference", "NODES")
tree = bpy.data.node_groups.new("fxnode-geometry", "GeometryNodeTree")
modifier.node_group = tree
nodes = tree.nodes
for node_type, name, x, y in (
("GeometryNodeInputPosition", "Position", -600, 200),
("GeometryNodeMeshCube", "Mesh Cube", -600, -100),
("GeometryNodeSetPosition", "Set Position", -200, 80),
("GeometryNodeTransform", "Transform Geometry", 160, 80),
("GeometryNodeJoinGeometry", "Join Geometry", 500, 80),
):
add(nodes, node_type, name, x, y)
return tree
def main():
args = arguments()
if bpy.app.version[:2] != (4, 5):
raise RuntimeError(f"Blender 4.5.x required, found {bpy.app.version_string}")
bpy.ops.wm.read_factory_settings(use_empty=True)
tree = geometry_tree() if args.fixture == "geometry-socket-gallery-near" else shader_tree(args.fixture)
for area in bpy.context.screen.areas:
if area.type == "NODE_EDITOR":
area.spaces.active.pin = True
area.spaces.active.geometry_nodes_type = "MODIFIER" if tree.bl_idname == "GeometryNodeTree" else "MODIFIER"
with bpy.context.temp_override(area=area):
bpy.ops.node.view_all()
if args.fixture == "shader-basic-linked-far":
bpy.ops.view2d.zoom_out(zoomfacx=3.0, zoomfacy=3.0)
if args.save:
bpy.ops.wm.save_as_mainfile(filepath=str(args.save.resolve()))
if __name__ == "__main__":
main()
+106
View File
@@ -0,0 +1,106 @@
import { readFileSync, writeFileSync } from "node:fs";
import { APPLICATION_HEADLESS } from "../test/application.js";
import { APPLICATION_VERSION } from "../examples/blender/nodes/application.js";
const { materializeNode } = APPLICATION_HEADLESS;
const plain = (id: string, type: Parameters<typeof materializeNode>[1], x: number, y: number) => {
return materializeNode(id, type, { x, y });
};
const byId = <T extends { readonly id: string }>(items: readonly T[]): T[] =>
[...items].sort((left, right) => left.id.localeCompare(right.id));
const fixtures: Record<string, unknown> = {
"control-test": {
graphId: "control-test",
catalogVersion: APPLICATION_VERSION,
nodes: byId([
plain("value", "fxnode.shader.value", -500, 200),
plain("math", "fxnode.shader.math", -250, 200),
plain("vector", "fxnode.shader.vector-math", 20, 200),
plain("color", "fxnode.shader.color", 300, 200),
plain("group", "fxnode.common.group-input", -100, -100),
]),
links: [
{
id: "value-math",
fromNodeId: "value",
fromSocketId: "value:value",
toNodeId: "math",
toSocketId: "math:a",
muted: false,
extensions: {},
},
],
metadata: {},
},
"link-tools-test": (() => {
const nodes = [
plain("source-a", "fxnode.shader.value", -560, 240),
plain("math-a", "fxnode.shader.math", 180, 240),
plain("source-b", "fxnode.shader.value", -560, 120),
plain("math-b", "fxnode.shader.math", 180, 120),
plain("source-c", "fxnode.shader.value", -560, 0),
plain("math-c", "fxnode.shader.math", 180, 0),
plain("chain-source", "fxnode.shader.value", -560, -100),
plain("reroute", "fxnode.common.reroute", 0, -100),
plain("chain-math", "fxnode.shader.math", 260, -100),
plain("transform", "fxnode.geometry.transform-geometry", -300, -230),
plain("noise", "fxnode.shader.noise-texture", 180, -230),
];
const links = [
["parallel-a", "source-a", "source-a:value", "math-a", "math-a:a"],
["parallel-b", "source-b", "source-b:value", "math-b", "math-b:a"],
["parallel-c", "source-c", "source-c:value", "math-c", "math-c:a"],
["chain-in", "chain-source", "chain-source:value", "reroute", "reroute:input"],
["chain-out", "reroute", "reroute:output", "chain-math", "chain-math:a"],
] as const;
return {
graphId: "link-tools-test",
catalogVersion: APPLICATION_VERSION,
nodes: byId(nodes),
links: byId(
links.map(([id, fromNodeId, fromSocketId, toNodeId, toSocketId]) => ({
id,
fromNodeId,
fromSocketId,
toNodeId,
toSocketId,
muted: false,
extensions: {},
})),
),
metadata: {},
};
})(),
"ramp-test": (() => {
const frame = plain("frame", "fxnode.common.frame", -300, 250),
ramp = plain("ramp", "fxnode.shader.color-ramp", 40, -40),
value = {
colorMode: "rgb",
interpolation: "linear",
hueInterpolation: "near",
stops: [
{ id: "red", position: 0.1, color: [1, 0, 0, 1] },
{ id: "green", position: 0.35, color: [0, 1, 0, 0.8] },
{ id: "blue", position: 0.9, color: [0, 0, 1, 0.6] },
],
};
return {
graphId: "ramp-test",
catalogVersion: APPLICATION_VERSION,
nodes: [
{ ...frame, size: { x: 380, y: 310 } },
{ ...ramp, parentId: "frame", parameters: { ...ramp.parameters, ramp: { kind: "json", value } } },
],
links: [],
metadata: {},
};
})(),
};
for (const [name, fixture] of Object.entries(fixtures)) {
const url = new URL(`../examples/blender/${name}/initialLayout.json`, import.meta.url),
canonical = `${JSON.stringify(fixture, null, 2)}\n`;
if (process.argv.includes("--write")) writeFileSync(url, canonical);
else if (readFileSync(url, "utf8") !== canonical)
throw new Error(`${name} fixture is not canonical; run npm run generate:browser-fixtures`);
}
if (!process.argv.includes("--write")) console.log("browser fixtures verified");
+83
View File
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
const root = new URL("..", import.meta.url).pathname;
const docs = join(root, "docs");
const output = join(root, "docs/reference/generated");
const files = readdirSync(output, { recursive: true }).map(String);
const sidebarFile = files.find((file) => /sidebar.*\.json$/i.test(file));
assert(sidebarFile, "VitePress sidebar JSON was not generated");
const sidebar: unknown = JSON.parse(readFileSync(join(output, sidebarFile), "utf8"));
assert(Array.isArray(sidebar), "sidebar root must be the observed VitePress array structure");
type SidebarItem = { text?: unknown; link?: unknown; items?: unknown };
const sidebarItems: SidebarItem[] = [];
const visit = (items: unknown): void => {
assert(Array.isArray(items), "sidebar items must be arrays");
for (const item of items) {
assert(item && typeof item === "object", "sidebar entries must be objects");
const typed = item as SidebarItem;
sidebarItems.push(typed);
if (typed.items !== undefined) visit(typed.items);
}
};
visit(sidebar);
for (const item of sidebarItems) {
if (item.link === undefined) continue;
assert(typeof item.link === "string", "sidebar links must be strings");
const link = item.link;
const relative = link.replace(/^\/reference\/generated\//, "");
assert(relative !== link, `sidebar link is outside generated reference: ${link}`);
const target = relative.endsWith("/") ? `${relative}index.md` : relative;
assert(existsSync(join(output, target)), `broken sidebar link: ${link}`);
}
const packageModules = sidebarItems
.filter(
(item): item is SidebarItem & { text: string; link: string } =>
typeof item.text === "string" && typeof item.link === "string" && item.link.endsWith("/"),
)
.map(({ text, link }) => ({ text, link }));
assert.deepEqual(packageModules, [
{ text: "fxnode", link: "/reference/generated/fxnode/" },
{ text: "headless", link: "/reference/generated/fxnode/headless/" },
{ text: "color-ramp", link: "/reference/generated/fxnode/widgets/color-ramp/" },
]);
const text = files
.filter((file) => file.endsWith(".md") || file.endsWith(".json"))
.map((file) => readFileSync(join(output, file), "utf8"))
.join("\n");
for (const moduleName of ["fxnode", "fxnode/headless", "fxnode/widgets/color-ramp"])
assert(text.includes(moduleName), `missing documented package module: ${moduleName}`);
for (const denied of ["BoundEngine", "BoundDocument", "bindEngine", "bindDocument", "@lib/"])
assert(!text.includes(denied), `implementation name leaked into docs: ${denied}`);
for (const line of text.split("\n").filter((line) => line.startsWith("Defined in:")))
assert.match(
line,
/^Defined in: \[[^\]]+:\d+\]\(https:\/\/github\.com\/Heaust-ops\/fxnode\/blob\/main\/[A-Za-z0-9._/-]+#L\d+\)$/,
`invalid source location: ${line}`,
);
for (const denied of ["/home/", "file:", "node\\_modules/", "node_modules/"])
assert(!text.includes(denied), `local or dependency path leaked into docs: ${denied}`);
// VitePress treats a relative link outside docs as a page link and can fail to
// report a missing repository source file. Repository TypeScript links must be
// durable GitHub links; relative image links deliberately remain supported.
const markdownFiles = readdirSync(docs, { recursive: true })
.map(String)
.filter((file) => file.endsWith(".md") && !file.startsWith("reference/generated/"));
for (const file of markdownFiles) {
const markdown = readFileSync(join(docs, file), "utf8");
for (const match of markdown.matchAll(/(?<!!)\[[^\]]*\]\(([^)]+\.ts(?:#[^)]*)?)\)/g)) {
const target = match[1]!;
assert(
/^https?:\/\//.test(target),
`${file}: repository TypeScript source link must be an absolute URL: ${target}`,
);
}
}
console.log(
`Docs passed (${files.filter((file) => file.endsWith(".md")).length} generated API pages, ${markdownFiles.length} authored pages; ${sidebarFile})`,
);
+90
View File
@@ -0,0 +1,90 @@
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "../test/application.js";
import { APPLICATION_VERSION } from "../examples/blender/nodes/application.js";
const ids = [...APPLICATION_COMPILED.nodes.keys()];
if (ids.length !== 22 || new Set(ids).size !== ids.length)
throw new Error(`Application composition must contain 22 unique nodes; found ${ids.length}`);
if (APPLICATION_COMPILED.nodes.size !== ids.length)
throw new Error("Compiled application composition coverage differs from source");
if (APPLICATION_COMPILED.version !== APPLICATION_VERSION)
throw new Error("Compiled application version differs from its declared version");
for (const [index, id] of ids.entries())
if (APPLICATION_HEADLESS.materializeNode(`check-${index}`, id).typeId !== id)
throw new Error(`Could not materialize ${id}`);
const root = new URL("..", import.meta.url).pathname,
src = join(root, "src");
if (existsSync(join(src, "catalog"))) throw new Error("Published source must not contain an application catalog");
const productionFiles = readdirSync(src, { recursive: true })
.map(String)
.filter((file) => /\.(?:ts|tsx)$/.test(file));
const productionText = productionFiles.map((file) => readFileSync(join(src, file), "utf8")).join("\n");
for (const forbidden of [
/fxnode\.(?:common|shader|geometry|compositor)\./,
/\b(?:LEGACY_COMPOSITION|BUILTIN_DESCRIPTORS|DESCRIPTOR_REGISTRY|CATALOG_NODE_IDS|BLENDER_DARK_THEME|BuiltinNodeTypeId|RenderTheme)\b/,
/from\s+["'][^"']*examples\//,
])
if (forbidden.test(productionText)) throw new Error(`Concrete application authority leaked into src: ${forbidden}`);
const exampleRoot = join(root, "examples/blender");
const nodeRoot = join(exampleRoot, "nodes");
const categoryIndexes = existsSync(nodeRoot)
? readdirSync(nodeRoot, { recursive: true })
.map(String)
.filter((file) => file.endsWith("/index.ts"))
: [];
if (categoryIndexes.length) throw new Error(`Category helper indexes are forbidden: ${categoryIndexes.join(", ")}`);
if (existsSync(join(exampleRoot, "application-composition.ts")))
throw new Error("Deleted application aggregate was restored");
if (existsSync(join(exampleRoot, "application-runtime.ts")))
throw new Error("Application runtime was restored under example");
for (const browserFile of [
"application-browser.ts",
"control-test/main.ts",
"link-tools-test/main.ts",
"ramp-test/main.ts",
]) {
const text = readFileSync(join(exampleRoot, browserFile), "utf8");
if (/test\/application/.test(text)) throw new Error(`${browserFile} imports the test-only application authority`);
}
for (const externalRoot of ["examples", "test", "tools"])
for (const file of readdirSync(join(root, externalRoot), { recursive: true }).map(String)) {
if (!/\.tsx?$/.test(file)) continue;
const text = readFileSync(join(root, externalRoot, file), "utf8");
if (/(?:from\s*|import\s*\()["'](?:\.\.\/)+src(?:\/|["'])/.test(text))
throw new Error(`${externalRoot}/${file} must import library source through @lib/`);
}
for (const sibling of ["minimal", "color-balance", "live-composition"])
for (const file of readdirSync(join(root, "examples", sibling), { recursive: true }).map(String)) {
if (!/\.tsx?$/.test(file)) continue;
const text = readFileSync(join(root, "examples", sibling, file), "utf8");
if (/blender\/(?:application-browser|main)|test\/application/.test(text))
throw new Error(`examples/${sibling}/${file} imports Blender or test application bootstrap`);
}
const authoringFiles = [
join(root, "test/application.ts"),
join(root, "examples/shared/nodes/color-balance.ts"),
...(existsSync(nodeRoot)
? readdirSync(nodeRoot, { recursive: true })
.map(String)
.filter((file) => file.endsWith(".ts"))
.map((file) => join(nodeRoot, file))
: []),
];
const source = authoringFiles.map((file) => readFileSync(file, "utf8")).join("\n");
for (const forbidden of [
/Object\.fromEntries/,
/\.reduce\s*\(/,
/defaultSize/,
/src\/catalog/,
/src\/render\/theme/,
/BUILTIN_DESCRIPTORS/,
/LEGACY_COMPOSITION/,
/defineFxNodeComposition/,
])
if (forbidden.test(source))
throw new Error(`Application composition is derived through a forbidden adapter: ${forbidden}`);
structuredClone(APPLICATION_COMPILED.source);
console.log(
`application composition: ${ids.length} unique, materializable nodes (version ${APPLICATION_COMPILED.source.version})`,
);
+509
View File
@@ -0,0 +1,509 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import ts from "typescript";
const root = new URL("..", import.meta.url).pathname;
const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
exports: Record<string, unknown>;
};
assert.deepEqual(
Object.keys(packageJson.exports).sort(),
[".", "./headless", "./widgets/color-ramp"].sort(),
"package exports must be exact",
);
const run = (command: string, args: string[], cwd: string) =>
execFileSync(command, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
run("npm", ["run", "build"], root);
const declarationSurfaces = {
"dist/index.d.ts": [
"AddNodeParams",
"AnyNodeParameterId",
"AnyNodeSocketId",
"BatchCommand",
"Command",
"CommandId",
"CommandIntent",
"CommandReceipt",
"CompiledFxNodeComposition",
"CompiledNode",
"CompiledResource",
"ComposedNode",
"ComposedSocket",
"CompositionChange",
"CompositionChangeEnvelope",
"CompositionReceipt",
"CompositionUpdateOptions",
"CreateFxNodeOptions",
"DEFAULT_FXNODE_THEME",
"FXNODE_COMPOSITION_LIMITS",
"FXNODE_VIEW_LIMITS",
"FxNode",
"FxNodeActionOptions",
"FxNodeAddNodeMenuRequest",
"FxNodeCapabilityError",
"FxNodeComposition",
"FxNodeCompositionData",
"FxNodeCompositionError",
"FxNodeCompositionIssue",
"FxNodeCompositionSeed",
"FxNodeCompositionValidation",
"FxNodeDefinition",
"FxNodeDestroyedError",
"FxNodeCamera",
"FxNodeGradingBinding",
"FxNodeHexColor",
"FxNodeHostRequest",
"FxNodeHostSnapshot",
"FxNodeImageResourceDefinition",
"FxNodeImageResourceDescriptor",
"FxNodeInput",
"FxNodeIssue",
"FxNodeJsonValue",
"FxNodeMigration",
"FxNodeMigrationStep",
"FxNodeModifiers",
"FxNodeParameterValue",
"FxNodeProtocolError",
"FxNodeReadonlyMap",
"FxNodeReplayCommand",
"FxNodeResourceAuthorization",
"FxNodeResourceData",
"FxNodeResourceDefinition",
"FxNodeResourceOpenRequest",
"FxNodeSaveData",
"FxNodeSelectionSnapshot",
"FxNodeSocketDefinition",
"FxNodeSocketTypeDefinition",
"FxNodeStyleDefinition",
"FxNodeTheme",
"FxNodeUiRow",
"FxNodeValueSchema",
"FxNodeViewport",
"FxNodeView",
"FxNodeViewOptions",
"FxNodeViewDetachedError",
"FxNodeVisibility",
"FxNodeWorkerError",
"GraphDocument",
"GraphId",
"GraphLayoutNodeV1",
"GraphLayoutV1",
"GraphLayoutV2",
"GraphLink",
"GraphLinkV1",
"GraphNode",
"GraphSnapshot",
"GraphState",
"HeaderStyled",
"JsonValue",
"KnownNode",
"LinkId",
"Mutation",
"MutationEnvelope",
"NodeBase",
"NodeId",
"NodeParameterId",
"NodeSocketId",
"NodeStyleId",
"NodeTypeId",
"ParameterValue",
"RemovedNode",
"RemovedSocket",
"ResourceId",
"SnapshotEnvelope",
"Socket",
"SocketDataType",
"SocketId",
"SocketTypeId",
"Themed",
"UnknownNode",
"Vec2",
"commandId",
"compileFxNodeComposition",
"composeNode",
"composeSocket",
"createFxNode",
"createInitialFxNodeComposition",
"graphId",
"fxNodeDevicePixels",
"linkId",
"nodeId",
"removeNode",
"removeSocket",
"setHeaderStyles",
"setTheme",
"socketId",
"validateFxNodeComposition",
],
"dist/headless.d.ts": [
"AnyNodeParameterId",
"AnyNodeSocketId",
"BatchCommand",
"Command",
"CommandError",
"CommandId",
"CommandRequest",
"CompatibleFxNodeSaveData",
"CompiledFxNodeComposition",
"CompiledNode",
"CompiledResource",
"ComposedNode",
"ComposedSocket",
"DEFAULT_FXNODE_THEME",
"DecodeResult",
"EngineState",
"FXNODE_COMPOSITION_LIMITS",
"FXNODE_SAVE_DATA_LIMITS",
"FxNodeComposition",
"FxNodeCompositionData",
"FxNodeCompositionError",
"FxNodeCompositionIssue",
"FxNodeCompositionSeed",
"FxNodeCompositionValidation",
"FxNodeDefinition",
"FxNodeGradingBinding",
"FxNodeHeadless",
"FxNodeHexColor",
"FxNodeImageResourceDefinition",
"FxNodeJsonValue",
"FxNodeMigration",
"FxNodeMigrationStep",
"FxNodeParameterValue",
"FxNodeReadonlyMap",
"FxNodeReplayCommand",
"FxNodeResourceDefinition",
"FxNodeSaveData",
"FxNodeSocketDefinition",
"FxNodeSocketTypeDefinition",
"FxNodeStyleDefinition",
"FxNodeTheme",
"FxNodeUiRow",
"FxNodeValueSchema",
"FxNodeVisibility",
"GraphDocument",
"GraphId",
"GraphLayoutNodeV1",
"GraphLayoutV1",
"GraphLayoutV2",
"GraphLink",
"GraphLinkV1",
"GraphNode",
"GraphSnapshot",
"GraphState",
"HeaderStyled",
"JsonValue",
"KnownNode",
"LinkId",
"LoadResult",
"Mutation",
"MutationEnvelope",
"NodeBase",
"NodeId",
"NodeParameterId",
"NodeSocketId",
"NodeStyleId",
"NodeTypeId",
"ParameterValue",
"RemovedNode",
"RemovedSocket",
"ReplayResult",
"ResourceId",
"SnapshotEnvelope",
"Socket",
"SocketDataType",
"SocketId",
"SocketTypeId",
"StateReplacementRequest",
"Themed",
"TransitionResult",
"UnknownNode",
"ValidationIssue",
"Vec2",
"commandId",
"compileFxNodeComposition",
"composeNode",
"composeSocket",
"createFxNodeHeadless",
"createInitialFxNodeComposition",
"graphId",
"linkId",
"nodeId",
"removeNode",
"removeSocket",
"setHeaderStyles",
"setTheme",
"socketId",
"validateFxNodeComposition",
],
"dist/widgets/color-ramp.d.ts": [
"ColorRamp",
"ColorRampInterpolation",
"ColorRampMode",
"ColorRampStop",
"HueInterpolation",
"addRampMidpoint",
"addRampStop",
"distributeColorRamp",
"flipColorRamp",
"isColorRamp",
"migrateColorRamp",
"moveRampStop",
"removeRampStop",
"sampleColorRamp",
"selectRampStop",
"setRampColor",
],
} as const;
const declarationProgram = ts.createProgram(
Object.keys(declarationSurfaces).map((file) => join(root, file)),
{
module: ts.ModuleKind.NodeNext,
moduleResolution: ts.ModuleResolutionKind.NodeNext,
},
);
const declarationChecker = declarationProgram.getTypeChecker();
for (const [file, expected] of Object.entries(declarationSurfaces)) {
const source = declarationProgram.getSourceFile(join(root, file));
assert(source, `missing declaration entrypoint: ${file}`);
const moduleSymbol = declarationChecker.getSymbolAtLocation(source);
assert(moduleSymbol, `declaration entrypoint is not a module: ${file}`);
const actual = declarationChecker
.getExportsOfModule(moduleSymbol)
.map((symbol) => symbol.name)
.sort();
assert.deepEqual(actual, [...expected].sort(), `declaration exports changed for ${file}`);
}
const builtFiles = readdirSync(join(root, "dist"), { recursive: true })
.map(String)
.filter((file) => /\.(?:js|d\.ts)$/.test(file));
const builtText = builtFiles.map((file) => readFileSync(join(root, "dist", file), "utf8")).join("\n");
const runtimeSurfaces = {
".": [
"DEFAULT_FXNODE_THEME",
"FXNODE_COMPOSITION_LIMITS",
"FXNODE_VIEW_LIMITS",
"FxNodeCapabilityError",
"FxNodeCompositionError",
"FxNodeDestroyedError",
"FxNodeViewDetachedError",
"FxNodeProtocolError",
"FxNodeWorkerError",
"commandId",
"compileFxNodeComposition",
"composeNode",
"composeSocket",
"createFxNode",
"createInitialFxNodeComposition",
"graphId",
"fxNodeDevicePixels",
"linkId",
"nodeId",
"removeNode",
"removeSocket",
"setHeaderStyles",
"setTheme",
"socketId",
"validateFxNodeComposition",
],
"./headless": [
"DEFAULT_FXNODE_THEME",
"FXNODE_COMPOSITION_LIMITS",
"FXNODE_SAVE_DATA_LIMITS",
"FxNodeCompositionError",
"commandId",
"compileFxNodeComposition",
"composeNode",
"composeSocket",
"createFxNodeHeadless",
"createInitialFxNodeComposition",
"graphId",
"linkId",
"nodeId",
"removeNode",
"removeSocket",
"setHeaderStyles",
"setTheme",
"socketId",
"validateFxNodeComposition",
],
"./widgets/color-ramp": [
"addRampMidpoint",
"addRampStop",
"distributeColorRamp",
"flipColorRamp",
"isColorRamp",
"migrateColorRamp",
"moveRampStop",
"removeRampStop",
"sampleColorRamp",
"selectRampStop",
"setRampColor",
],
} as const;
for (const [entry, expected] of Object.entries(runtimeSurfaces)) {
const target = packageJson.exports[entry] as { import: string };
const actual = Object.keys(await import(join(root, target.import))).sort();
assert.deepEqual(actual, [...expected].sort(), `runtime exports changed for ${entry}`);
}
for (const symbol of [
"LEGACY_COMPOSITION",
"INTERNAL_LEGACY_HEADLESS",
"BUILTIN_DESCRIPTORS",
"CATALOG_NODE_IDS",
"BuiltinNodeTypeId",
"DESCRIPTOR_REGISTRY",
"BLENDER_DARK_THEME",
"getDescriptor",
])
assert(!builtText.includes(symbol), `obsolete concrete symbol built: ${symbol}`);
assert(!builtText.includes("defineFxNodeComposition"), "removed composition identity helper was built");
assert(
!builtFiles.some((file) => /^composition\/define\.(?:js|d\.ts)$/.test(file)),
"stale composition/define output was built",
);
const temporary = mkdtempSync(join(tmpdir(), "fxnode-package-"));
try {
const json = JSON.parse(run("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], root)) as {
files: { path: string }[];
}[];
const files = json[0]?.files.map((item) => item.path) ?? [];
assert(
files.includes("dist/index.js") &&
files.includes("dist/headless.js") &&
files.includes("dist/index.d.ts") &&
files.includes("LICENSE") &&
files.includes("NOTICE.md"),
);
assert(
!files.some((file) => /(^|\/)(\.env|src|test|tools|playwright)(\/|\.|$)/i.test(file)),
`forbidden package file: ${files.join(", ")}`,
);
assert(!files.some((file) => /^docs\//.test(file)), "documentation must not be packed");
assert(
!files.some((file) => /dist\/(catalog|core\/document|engine\/engine|render\/theme)(\/|\.|$)/.test(file)),
"obsolete compatibility module was built",
);
assert(!files.some((file) => /dist\/browser\/add-node-menu\./.test(file)), "host-owned add-node menu was built");
const packed = run("npm", ["pack", "--json", "--ignore-scripts", "--pack-destination", temporary], root);
const tarball = join(temporary, (JSON.parse(packed) as { filename: string }[])[0]!.filename);
const consumer = join(temporary, "consumer");
run("mkdir", [consumer], temporary);
writeFileSync(
join(consumer, "package.json"),
JSON.stringify({
private: true,
type: "module",
dependencies: { fxnode: `file:${tarball}`, typescript: "5.7.2", vite: "6.1.0" },
}),
);
run("npm", ["install", "--ignore-scripts"], consumer);
writeFileSync(
join(consumer, "ramp.mjs"),
"import { isColorRamp } from 'fxnode/widgets/color-ramp'; if (typeof isColorRamp !== 'function') throw new Error('color ramp export missing');\n",
);
run("node", ["ramp.mjs"], consumer);
writeFileSync(
join(consumer, "headless.mjs"),
"import * as fxnode from 'fxnode/headless'; if (!fxnode.createFxNodeHeadless || !fxnode.compileFxNodeComposition || !fxnode.compileFxNodeComposition || fxnode.bindFxNodeHeadless || fxnode.createEngine || fxnode.materializeNode || fxnode.socketsCompatible) throw new Error('headless export boundary invalid');\n",
);
run("node", ["headless.mjs"], consumer);
writeFileSync(
join(consumer, "main.ts"),
`import { createFxNode, compileFxNodeComposition, type CompositionChange, type CompositionChangeEnvelope, type CompositionReceipt, type CompositionUpdateOptions, type FxNode, type FxNodeSaveData, type FxNodeView, type FxNodeViewOptions, type NodeTypeId } from 'fxnode';
import { createFxNodeHeadless } from 'fxnode/headless';
// @ts-expect-error color ramps have a dedicated entrypoint
import { isColorRamp } from 'fxnode';
// @ts-expect-error protocol-only version expectation is not public
import type { VersionExpectation } from 'fxnode';
// @ts-expect-error implementation-bound state names are not public
import type { BoundEngineState } from 'fxnode/headless';
type Equal<A,B> = (<T>()=>T extends A?1:2) extends (<T>()=>T extends B?1:2) ? true : false;
type Expect<T extends true> = T;
const color='#000000' as const, theme={background:color,grid:color,frame:color,frameHeader:color,body:color,control:color,controlFill:color,controlEditing:color,textSelection:color,outline:color,text:color,muted:color,shadow:color,nodeSelected:color,nodeActive:color,unknownHeader:color,unknownSocket:color,linkMuted:color,knifeMuted:color,emphasis:color,focus:color,editOutline:color,resize:color,muteOverlay:color,boxSelectionFill:color,checkerLight:color,checkerDark:color,widgetBorder:color,rampBorder:color,resourceBackground:color};
const composition={schemaVersion:2,id:'consumer',version:1,compatibility:{wildcardInputTypes:[]},theme,socketTypes:{value:{title:'Value',color,acceptsFrom:['value']}},nodeStyles:{basic:{header:color}},resources:{},nodes:{tiny:{version:1,title:'Tiny',behavior:'standard',style:'basic',parameters:{},sockets:{},ui:[],muteBypass:[],migrations:[]}}} as const;
type InferredNode = Expect<Equal<NodeTypeId<typeof composition>,'tiny'>>;
const compiled=compileFxNodeComposition(composition); compiled.nodes.get('tiny');
// @ts-expect-error packed declarations retain exact node IDs
compiled.nodes.get('missing');
const runtime=createFxNodeHeadless(composition); runtime.materializeNode('n','tiny');
// @ts-expect-error complete compilation rejects unknown style references
compileFxNodeComposition({...composition,nodes:{tiny:{...composition.nodes.tiny,style:'missing'}}});
async function liveCompositionSmoke(api:FxNode,options:CompositionUpdateOptions):Promise<void>{
const canvas=null as unknown as HTMLCanvasElement,viewOptions:FxNodeViewOptions={canvas,viewport:{width:1,height:1,dpr:1}};
const view:FxNodeView=await api.attachView(viewOptions);view.id;view.feedInput({kind:'focus',phase:'focus'});await view.detach();
// @ts-expect-error view handles do not own graph authority
view.dispatch({type:'undo'});
// @ts-expect-error root authority does not own presentation input
api.feedInput({kind:'focus',phase:'focus'});
// @ts-expect-error removed copy API
api.copyTo(canvas);
// @ts-expect-error removed mirror API
api.addMirror(canvas);
const saveData:FxNodeSaveData=await api.getSaveData();await api.load(saveData);
const before=await api.getState(),known=before.nodes.find(node=>node.known);if(known){type MutableOutput=Expect<Equal<typeof known.typeId,string>>;void(null as MutableOutput|null);}
const themed=await api.setTheme(theme,{expectedRevision:options.expectedRevision??0});
const receipt:CompositionReceipt=themed;
if(receipt.status==='committed'){const reset:true=receipt.historyReset;void reset;}else{const reset:false=receipt.historyReset,changed:false=receipt.graphChanged;void reset;void changed;}
const socket=await api.composeSocket('dynamic',{title:'Dynamic',color,acceptsFrom:['dynamic']},{expectedRevision:themed.revision});
const node=await api.composeNode('dynamic-node',{version:1,title:'Dynamic',behavior:'standard',style:'basic',parameters:{},sockets:{out:{title:'Out',direction:'output',type:'dynamic',maxIncomingLinks:0,visible:true,value:null,showValue:false}},ui:[{kind:'socket',socket:'out'}],muteBypass:[],migrations:[]},{expectedRevision:socket.revision});
await api.dispatch({type:'node.add',nodeType:'dynamic-node',position:{x:0,y:0}});
const removedNode=await api.removeNode('dynamic-node',{expectedRevision:node.revision});
const removedSocket=await api.removeSocket('dynamic',{expectedRevision:removedNode.revision});void removedSocket;
await api.loadComposition(composition);
api.onCompositionChanges(event=>{const envelope:CompositionChangeEnvelope=event,change:CompositionChange=event.change;envelope.revision;if(change.kind!=='theme.set'&&change.kind!=='header-styles.set'&&change.kind!=='compatibility.set'&&change.kind!=='composition.load')change.id;
// @ts-expect-error composition events do not expose definition payloads
event.change.definition;});
}
async function ownershipSmoke():Promise<void>{
const api:FxNode=await createFxNode({applicationId:'consumer',applicationVersion:1,resources:composition.resources});
const canvas=null as unknown as HTMLCanvasElement,view:FxNodeView=await api.attachView({canvas,viewport:{width:1,height:1,dpr:1}});
view.getHostSnapshot();const offHost=view.subscribeHost(()=>{}),offRequests=view.onHostRequests(()=>{});view.feedInput({kind:'focus',phase:'focus'});view.setViewport({width:2,height:2,dpr:1});
await view.addNode({typeId:'tiny',viewPosition:{x:0,y:0}});await view.removeSelected();await view.setSelectedMuted(false);await view.provideResource({viewId:view.id,token:'token',graphVersion:0,compositionRevision:0},{name:'x',mime:'image/png',bytes:new ArrayBuffer(1)});await view.whenRendered();offHost();offRequests();await view.detach();
await api.getState();api.onSnapshots(()=>{});api.onMutations(()=>{});await api.dispatch({type:'undo'});await api.setTheme(theme);api.onCompositionChanges(()=>{});
// @ts-expect-error views do not own graph snapshots
view.getState();
// @ts-expect-error views do not own composition mutation
view.setTheme(theme);
// @ts-expect-error roots do not own rendering barriers
api.whenRendered();
api.destroy();
}
void runtime; void compiled; void liveCompositionSmoke; void ownershipSmoke; void (null as InferredNode | null);
`,
);
writeFileSync(
join(consumer, "tsconfig.json"),
JSON.stringify({
compilerOptions: {
strict: true,
noEmit: true,
target: "ES2022",
module: "NodeNext",
moduleResolution: "NodeNext",
skipLibCheck: true,
},
include: ["main.ts"],
}),
);
run("npx", ["tsc"], consumer);
writeFileSync(join(consumer, "index.html"), "<script type=module src=/main.ts></script>");
run("npx", ["vite", "build", "--base", "./"], consumer);
const output = readdirSync(join(consumer, "dist", "assets"));
const js = output
.filter((file) => file.endsWith(".js"))
.map((file) => readFileSync(join(consumer, "dist", "assets", file), "utf8"))
.join("\n");
assert(!js.includes('new URL("/assets/'), "worker URL is root-absolute");
const packageIndex = readFileSync(join(consumer, "node_modules", "fxnode", "dist", "index.js"), "utf8");
assert(!packageIndex.includes('new URL("/assets/'), "packed worker URL is root-absolute");
const workerMatch = packageIndex.match(/"assets\/([^"?]*worker[^"?]*\.js)"/);
assert(workerMatch, "packed package has no package-relative worker URL");
const packagedAssets = readdirSync(join(consumer, "node_modules", "fxnode", "dist", "assets"));
assert(packagedAssets.includes(workerMatch[1]!), `worker asset ${workerMatch[1]} does not exist`);
console.log(`package smoke passed (${files.length} files; worker assets/${workerMatch[1]})`);
} finally {
rmSync(temporary, { recursive: true, force: true });
}
+45
View File
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const root = new URL("..", import.meta.url).pathname;
const readme = readFileSync(join(root, "README.md"), "utf8");
const fixture = readFileSync(join(root, "test/readme-snippets.compile.ts"), "utf8");
const links = [...readme.matchAll(/!?\[[^\]]*\]\(([^)]+)\)/g)].map((match) => match[1]!);
for (const link of links) {
if (/^(?:https:\/\/|LICENSE$|NOTICE\.md$)/.test(link)) continue;
assert.fail(`README link is not package-safe: ${link}`);
}
for (const stale of ["docs/reference/generated", "docs/.vitepress", "](docs/", "](examples/", "](src/"]) {
assert(!readme.includes(stale), `README contains generated, VitePress-root, or unpacked path: ${stale}`);
}
for (const required of [
"theme: exampleTheme",
"createFxNodeHeadless({",
"api.composeSocket(...numberSocket)",
"api.composeNode(...valueNode)",
'window.addEventListener("pagehide", cleanup)',
"if (cleaned) created.destroy()",
"api = null",
"host.destroy()",
"const gradingWheelsRow = {",
'satisfies FxNodeDefinition["ui"][number]',
"async function installColorBalance(api: FxNode)",
]) {
assert(readme.includes(required), `README current-API regression: ${required}`);
if (
["theme: exampleTheme", "const gradingWheelsRow = {", "async function installColorBalance(api: FxNode)"].includes(
required,
)
)
assert(
fixture.includes(
required
.replace("const gradingWheelsRow", "export const gradingWheelsRow")
.replace("async function", "export async function"),
),
`compile fixture is not synchronized: ${required}`,
);
}
assert(!readme.includes("Immutable current graph snapshot"), "getState must not claim runtime immutability");
console.log(`README checks passed (${readme.split("\n").length - 1} lines, ${links.length} links).`);
+38
View File
@@ -0,0 +1,38 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { referenceManifest } from "@lib/research/reference-manifest.js";
import { REFERENCE_IDS } from "@lib/research/reference-types.js";
const strict = process.argv.includes("--strict");
const sha256Pattern = /^[a-f0-9]{64}$/u;
const ids = referenceManifest.references.map(({ id }) => id);
if (
ids.length !== REFERENCE_IDS.length ||
new Set(ids).size !== REFERENCE_IDS.length ||
REFERENCE_IDS.some((id) => !ids.includes(id))
) {
throw new Error("Reference manifest must contain each of the eight reference IDs exactly once.");
}
if (!sha256Pattern.test(referenceManifest.baseline.binarySha256)) throw new Error("Invalid baseline binary SHA256.");
let captured = 0;
for (const reference of referenceManifest.references) {
if (reference.status === "pending") {
if (!reference.reason.trim()) throw new Error(`${reference.id}: pending references require a reason.`);
continue;
}
captured++;
if (!sha256Pattern.test(reference.sha256)) throw new Error(`${reference.id}: invalid SHA256.`);
const bytes = await readFile(resolve(reference.relativePath));
const actual = createHash("sha256").update(bytes).digest("hex");
if (actual !== reference.sha256) throw new Error(`${reference.id}: hash mismatch.`);
if (bytes.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a") throw new Error(`${reference.id}: not a PNG.`);
if (bytes.readUInt32BE(16) !== reference.width || bytes.readUInt32BE(20) !== reference.height)
throw new Error(`${reference.id}: dimensions mismatch.`);
}
if (strict && captured !== REFERENCE_IDS.length)
throw new Error(`Strict reference check: ${captured}/8 captured; ${8 - captured} pending.`);
console.log(
`references: manifest valid; ${captured}/8 captured, ${8 - captured}/8 pending${strict ? " (strict)" : ""}`,
);
+46
View File
@@ -0,0 +1,46 @@
import { writeFileSync } from "node:fs";
import { APPLICATION_COMPILED } from "../test/application.js";
import { APPLICATION_HEADLESS } from "../test/application.js";
import type { NodeTypeId } from "@lib/composition/index.js";
import { graphId, linkId, socketId, type GraphDocument } from "@lib/core/types.js";
import { nullRecord } from "@lib/core/json.js";
const { materializeNode, save } = APPLICATION_HEADLESS;
const specs: readonly [string, NodeTypeId<typeof APPLICATION_COMPILED.source>, number, number][] = [
["image-texture", "fxnode.shader.image-texture", -520, 180],
["noise-3d", "fxnode.shader.noise-texture", -220, 180],
["noise-4d", "fxnode.shader.noise-texture", 60, 180],
["color-ramp", "fxnode.shader.color-ramp", 350, 180],
["compositor-image", "fxnode.compositor.image", -340, -260],
["master", "fxnode.compositor.color-balance", 20, -260],
];
const nodes = specs.map(([id, type, x, y]) => {
const node = materializeNode(id, type, { x, y });
if (id === "noise-4d")
return { ...node, parameters: { ...node.parameters, dimensions: { kind: "string" as const, value: "4d" } } };
if (id === "master") return { ...node, label: "Master Color Grading" };
return node;
});
const link = {
id: linkId("compositor-grade"),
fromNodeId: nodes[4]!.id,
fromSocketId: socketId("compositor-image:image"),
toNodeId: nodes[5]!.id,
toSocketId: socketId("master:image"),
muted: false,
extensions: {},
};
const document: GraphDocument = {
schemaVersion: 2,
graphId: graphId("parity"),
catalogVersion: APPLICATION_COMPILED.source.version,
nodes: nullRecord(nodes.map((n) => [n.id, n])),
links: nullRecord([[link.id, link]]),
metadata: nullRecord(),
};
const saved = save(document);
const { schemaVersion: _schemaVersion, ...layout } = saved;
writeFileSync(
new URL("../examples/blender/parity/initialLayout.json", import.meta.url),
JSON.stringify({ ...layout, nodes: layout.nodes.map((node) => ({ ...node, known: true })) }, null, 2) + "\n",
);
+72
View File
@@ -0,0 +1,72 @@
import { performance } from "node:perf_hooks";
import { LooseQuadtree } from "@lib/layout/spatial-index.js";
const seed = 7,
nodes = 5000,
links = 10000,
viewport = { x: 1200, y: 800, dpr: 2 };
const nodeIndex = new LooseQuadtree<number>(),
linkIndex = new LooseQuadtree<number>();
const start = performance.now();
for (let i = 0; i < nodes; i++) {
const x = (i % 100) * 400,
y = Math.floor(i / 100) * 300;
nodeIndex.insert(`n${i}`, i, { minX: x, minY: y - 180, maxX: x + 180, maxY: y });
}
for (let i = 0; i < links; i++) {
const a = i % nodes,
row = Math.floor(a / 100),
b = row * 100 + (((a % 100) + 1 + (i % 7)) % 100),
ax = (a % 100) * 400 + 180,
ay = row * 300 - 90,
bx = (b % 100) * 400,
by = row * 300 - 90;
linkIndex.insert(`l${i}`, i, {
minX: Math.min(ax, bx),
minY: Math.min(ay, by),
maxX: Math.max(ax, bx),
maxY: Math.max(ay, by),
});
}
const buildMs = performance.now() - start,
times: number[] = [],
counts: number[] = [];
for (let i = 0; i < 100; i++) {
const x = (i * 997) % 38000,
y = (i * 431) % 14000,
s = performance.now();
const nc = nodeIndex.query({ minX: x, minY: y - viewport.y, maxX: x + viewport.x, maxY: y }).length,
lc = linkIndex.query({ minX: x, minY: y - viewport.y, maxX: x + viewport.x, maxY: y }).length;
times.push(performance.now() - s);
counts.push(nc + lc);
}
times.sort((a, b) => a - b);
counts.sort((a, b) => a - b);
const p = (a: number[], q: number) => a[Math.floor((a.length - 1) * q)]!;
const result = {
seed,
nodes,
links,
viewport,
buildMs: +buildMs.toFixed(3),
candidateP50: p(counts, 0.5),
candidateP95: p(counts, 0.95),
queryMsP50: +p(times, 0.5).toFixed(3),
queryMsP95: +p(times, 0.95).toFixed(3),
cullingP95: +(1 - p(counts, 0.95) / (nodes + links)).toFixed(4),
};
console.log(JSON.stringify(result));
if (
process.argv.includes("--check") &&
(result.seed !== 7 ||
result.nodes !== 5000 ||
result.links !== 10000 ||
result.viewport.x !== 1200 ||
result.viewport.y !== 800 ||
result.viewport.dpr !== 2 ||
times.length !== 100 ||
result.candidateP50 !== 72 ||
result.candidateP95 !== 75 ||
result.cullingP95 < 0.9)
)
throw new Error(`Phase 7 deterministic workload baseline missed: ${JSON.stringify(result)}`);