feat: add variadic render graph logic nodes

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-29 10:03:06 +00:00
co-authored by heaust
parent 0e866596c0
commit 90406ae18f
34 changed files with 1031 additions and 273 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test";
for (const example of ["minimal", "color-balance", "live-composition", "multi-view"])
for (const example of ["minimal", "color-balance", "live-composition", "logic-nodes", "multi-view"])
test(`${example} documentation image`, async ({ page }) => {
await page.goto(`/examples/${example}/`);
await page.evaluate(
+34 -3
View File
@@ -4,6 +4,7 @@ const examples = [
{ path: "minimal", nodeId: "value", typeId: "example.minimal.value" },
{ path: "color-balance", nodeId: "color-balance", typeId: "fxnode.compositor.color-balance" },
{ path: "live-composition", nodeId: "live-node", typeId: "example.live.parameter" },
{ path: "logic-nodes", nodeId: "and", typeId: "example.logic.and" },
] as const;
function capturePageErrors(page: Page): Error[] {
@@ -14,11 +15,11 @@ function capturePageErrors(page: Page): Error[] {
test("gallery links every standalone application and loads its images", async ({ page }) => {
await page.goto("/examples/");
await expect(page.locator(".gallery a")).toHaveCount(5);
await expect(page.locator(".gallery a")).toHaveCount(6);
expect(
await page.locator(".gallery a").evaluateAll((links) => links.map((link) => link.getAttribute("href"))),
).toEqual(["./minimal/", "./color-balance/", "./live-composition/", "./multi-view/", "./blender/"]);
await expect(page.locator(".gallery img")).toHaveCount(4);
).toEqual(["./minimal/", "./color-balance/", "./live-composition/", "./logic-nodes/", "./multi-view/", "./blender/"]);
await expect(page.locator(".gallery img")).toHaveCount(5);
expect(
await page
.locator(".gallery img")
@@ -144,6 +145,36 @@ test("multi-view keeps view input and selection local while graph changes fan ou
).toEqual({ root: null, views: 0 });
});
test("logic nodes accept five links through one input and application evaluation follows snapshots", async ({
page,
}) => {
const errors = capturePageErrors(page);
await page.goto("/examples/logic-nodes/");
await page.evaluate(() => window.fxnodeStandalone.ready);
const initial = await page.evaluate(async () => {
const state = await window.fxnodeStandalone.root!.getState();
return {
incoming: state.links.filter((link) => link.toSocketId === "and:inputs").length,
labels: [...document.querySelectorAll<HTMLElement>("#results span")].map((item) => item.textContent),
};
});
expect(initial.incoming).toBe(5);
expect(initial.labels).toContain("AND: false");
await page.evaluate(async () => {
const root = window.fxnodeStandalone.root!;
const source = (await root.getState()).nodes.find((node) => node.id === "c")!;
return root.dispatch({
type: "node.parameter",
id: source.id,
key: "value",
value: { kind: "boolean", value: true },
});
});
await expect(page.locator("#results span").filter({ hasText: "AND:" })).toHaveText("AND: true");
expect(errors).toEqual([]);
});
for (const example of examples) {
test(`${example.path} renders its known node and cleans up on pagehide`, async ({ page }) => {
const errors = capturePageErrors(page);
+1 -1
View File
@@ -18,7 +18,7 @@ test("examples server explicitly loads the repository Vite config", async () =>
test("standalone examples import library types and values only through the public entrypoint", async () => {
const root = new URL("../examples/", import.meta.url);
const directories = ["shared", "minimal", "color-balance", "live-composition"];
const directories = ["shared", "minimal", "color-balance", "live-composition", "logic-nodes"];
const files: string[] = [];
async function collect(directory: string): Promise<void> {
for (const entry of await readdir(new URL(directory, root), { withFileTypes: true })) {
+74 -5
View File
@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js";
import { commandId, nodeId } from "@lib/core/types.js";
import { commandId, linkId, nodeId } from "@lib/core/types.js";
import { layoutGraph as genericLayoutGraph } from "@lib/layout/layout-graph.js";
import { viewToWorld, worldToView } from "@lib/layout/geometry.js";
const layoutGraph = (document: any, transform: Parameters<typeof genericLayoutGraph>[2]) =>
@@ -163,8 +163,8 @@ test("all catalog types lay out deterministically", () => {
"fxnode.geometry.join-geometry",
"node",
"geometry",
["socket:input:geometry:1", "socket:output:result:1"],
[175, 76],
["socket:input:geometry:2", "socket:output:result:1"],
[175, 100],
[175, 100],
],
[
@@ -207,8 +207,8 @@ test("all catalog types lay out deterministically", () => {
"fxnode.common.group-output",
"node",
"output",
["control:string:interfaceName:1", "socket:input:input:1"],
[257, 76],
["control:string:interfaceName:1", "socket:input:input:2"],
[257, 100],
[257, 100],
],
[
@@ -448,6 +448,75 @@ test("expanded, collapsed, reroute and links have pinned geometry", () => {
assert.equal(snapshot.controls.get("math:socket:math:a")?.linked, true, "linked inputs hide controls");
});
test("multi-input sockets use pill hit bounds and stable per-link anchors", () => {
const join = materializeNode("join", "fxnode.geometry.join-geometry", { x: 160, y: 80 });
const sources = [0, 1, 2].map((index) =>
materializeNode(`cube-${index}`, "fxnode.geometry.mesh-cube", { x: -180, y: 180 - index * 160 }),
);
const linkIds = ["z-random", "a-random", "m-random"] as const;
const links = Object.fromEntries(
sources.map((source, index) => {
const id = linkIds[index]!;
return [
id,
{
id,
fromNodeId: source.id,
fromSocketId: source.sockets.find((socket) => socket.key === "mesh")!.id,
toNodeId: join.id,
toSocketId: join.sockets.find((socket) => socket.key === "geometry")!.id,
muted: false,
extensions: {},
},
];
}),
);
const snapshot = layoutGraph(
{
schemaVersion: 2,
graphId: "multi-input",
catalogVersion: 1,
nodes: Object.fromEntries([join, ...sources].map((node) => [node.id, node])),
links,
metadata: {},
},
transform,
);
const socket = snapshot.sockets.get(join.sockets.find((candidate) => candidate.key === "geometry")!.id)!;
assert.equal(socket.shape, "multi-input");
assert.deepEqual(socket.bounds, {
x: socket.anchor.x - 5,
y: socket.anchor.y + 20,
width: 10,
height: 40,
});
assert.equal(socket.linkAnchors.size, 3);
assert.equal(new Set([...socket.linkAnchors.values()].map((anchor) => anchor.y)).size, 3);
assert.ok(socket.linkAnchors.get(linkId("z-random"))!.y > socket.linkAnchors.get(linkId("a-random"))!.y);
assert.ok(socket.linkAnchors.get(linkId("a-random"))!.y > socket.linkAnchors.get(linkId("m-random"))!.y);
for (const [id, anchor] of socket.linkAnchors) {
assert.deepEqual(snapshot.links.get(id)!.points.at(-1), anchor);
}
assert.deepEqual(hitTest(snapshot, worldToView({ x: socket.anchor.x, y: socket.bounds!.y - 2 }, transform)), {
kind: "socket",
id: socket.id,
});
const collapsed = layoutGraph(
{
schemaVersion: 2,
graphId: "multi-input-collapsed",
catalogVersion: 1,
nodes: { join: { ...join, collapsed: true } },
links: {},
metadata: {},
},
transform,
).sockets.get(socket.id)!;
assert.equal(collapsed.shape, "circle");
assert.equal(collapsed.bounds, undefined);
});
test("frames have labelled fitted bounds around parent-local children", () => {
const frame = {
...materializeNode("frame", "fxnode.common.frame", { x: -200, y: 200 }),