feat(web): add an interactive Compositor node canvas
Author Compositor graphs in the web UI with React Flow and execute them locally. The native writer materializes RGB/Exposure/Invert/Blur/ Mix/Alpha Over/Transform/Viewer/Composite nodes into a real CompositorNodeTree, the reader returns node locations and more node types, and save/reopen keeps nodes, links, positions and properties. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import type { CompositorGraphIR } from "./compositor";
|
||||
import type { CompositorGraphIR, CompositorLinkIR, CompositorNodeIR, CompositorNodeType } from "./compositor";
|
||||
|
||||
/** Workbench command payload for the bounded local compositor evaluator. */
|
||||
function clone(graph: CompositorGraphIR): CompositorGraphIR {
|
||||
return structuredClone(graph);
|
||||
}
|
||||
|
||||
/** Commands and pure graph edits for the Compositor canvas. */
|
||||
export const compositorCommands = Object.freeze({
|
||||
evaluate(graph: CompositorGraphIR): CompositorGraphIR {
|
||||
return structuredClone(graph);
|
||||
@@ -8,4 +12,73 @@ export const compositorCommands = Object.freeze({
|
||||
setNodeGraph(sceneId: string, graph: CompositorGraphIR) {
|
||||
return { type: "setCompositorNodeGraph", sceneId, graph } as const;
|
||||
},
|
||||
} as const);
|
||||
createGraph(id: string, name: string): CompositorGraphIR {
|
||||
const composite: CompositorNodeIR = {
|
||||
id: "node-composite", type: "COMPOSITE", name: "Composite",
|
||||
properties: {}, location: { x: 620, y: 80 },
|
||||
};
|
||||
return { schemaVersion: 1, id, name, outputNodeId: composite.id, nodes: [composite], links: [], resources: [] };
|
||||
},
|
||||
addNode(graph: CompositorGraphIR, type: CompositorNodeType, name: string, location?: { x: number; y: number }): CompositorGraphIR {
|
||||
const next = clone(graph);
|
||||
let index = 1;
|
||||
let id = `node-${type.toLowerCase().replaceAll("_", "-")}-${index}`;
|
||||
while (next.nodes.some((node) => node.id === id)) id = `node-${type.toLowerCase().replaceAll("_", "-")}-${++index}`;
|
||||
const node: CompositorNodeIR = {
|
||||
id, type, name, properties: defaultProperties(type),
|
||||
location: location ?? { x: 90 + (next.nodes.length % 4) * 210, y: 60 + Math.floor(next.nodes.length / 4) * 150 },
|
||||
};
|
||||
next.nodes.push(node);
|
||||
return next;
|
||||
},
|
||||
removeNode(graph: CompositorGraphIR, nodeId: string): CompositorGraphIR {
|
||||
const next = clone(graph);
|
||||
if (nodeId === next.outputNodeId) return graph;
|
||||
next.nodes = next.nodes.filter((node) => node.id !== nodeId);
|
||||
next.links = next.links.filter((link) => link.fromNodeId !== nodeId && link.toNodeId !== nodeId);
|
||||
return next;
|
||||
},
|
||||
moveNode(graph: CompositorGraphIR, nodeId: string, location: { x: number; y: number }): CompositorGraphIR {
|
||||
const next = clone(graph);
|
||||
const node = next.nodes.find((candidate) => candidate.id === nodeId);
|
||||
if (node) node.location = { x: location.x, y: location.y };
|
||||
return next;
|
||||
},
|
||||
connect(graph: CompositorGraphIR, link: CompositorLinkIR): CompositorGraphIR {
|
||||
const next = clone(graph);
|
||||
if (!next.nodes.some((node) => node.id === link.fromNodeId) || !next.nodes.some((node) => node.id === link.toNodeId)) return graph;
|
||||
next.links = next.links.filter((candidate) => !(candidate.toNodeId === link.toNodeId && candidate.toSocket === link.toSocket));
|
||||
next.links.push({ ...link });
|
||||
return next;
|
||||
},
|
||||
disconnect(graph: CompositorGraphIR, toNodeId: string, toSocket: string): CompositorGraphIR {
|
||||
const next = clone(graph);
|
||||
next.links = next.links.filter((link) => !(link.toNodeId === toNodeId && link.toSocket === toSocket));
|
||||
return next;
|
||||
},
|
||||
setProperty(graph: CompositorGraphIR, nodeId: string, key: string, value: unknown): CompositorGraphIR {
|
||||
const next = clone(graph);
|
||||
const node = next.nodes.find((candidate) => candidate.id === nodeId);
|
||||
if (node) node.properties = { ...node.properties, [key]: value };
|
||||
return next;
|
||||
},
|
||||
setOutput(graph: CompositorGraphIR, nodeId: string): CompositorGraphIR {
|
||||
const next = clone(graph);
|
||||
if (next.nodes.find((node) => node.id === nodeId)?.type !== "COMPOSITE") return graph;
|
||||
next.outputNodeId = nodeId;
|
||||
return next;
|
||||
},
|
||||
});
|
||||
|
||||
export function defaultProperties(type: CompositorNodeType): Record<string, unknown> {
|
||||
switch (type) {
|
||||
case "CONSTANT_COLOR": return { color: [0.2, 0.45, 0.8, 1] };
|
||||
case "EXPOSURE": return { exposure: 0 };
|
||||
case "INVERT": return {};
|
||||
case "BLUR": return { radius: 4 };
|
||||
case "MIX": return { factor: 0.5 };
|
||||
case "TRANSFORM": return { translateX: 0, translateY: 0, scaleX: 1, scaleY: 1 };
|
||||
case "VIEWER": return {};
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@ export interface CompositorNodeIR {
|
||||
name: string;
|
||||
blenderType?: string;
|
||||
properties: Record<string, unknown>;
|
||||
/** Canvas position in Blender node-space units. */
|
||||
location?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export interface CompositorLinkIR {
|
||||
@@ -279,7 +281,11 @@ export function parseCompositorGraph(value: unknown): CompositorGraphIR {
|
||||
const id = text(item.id, `nodes[${index}].id`);
|
||||
if (nodeIds.has(id)) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `Duplicate node ${id}`);
|
||||
nodeIds.add(id);
|
||||
const node = { id, type: item.type as CompositorNodeType, name: text(item.name, `nodes[${index}].name`), properties: properties(item.properties ?? {}, index), blenderType: item.blenderType === undefined ? undefined : text(item.blenderType, `nodes[${index}].blenderType`) };
|
||||
const node: CompositorNodeIR = { id, type: item.type as CompositorNodeType, name: text(item.name, `nodes[${index}].name`), properties: properties(item.properties ?? {}, index), blenderType: item.blenderType === undefined ? undefined : text(item.blenderType, `nodes[${index}].blenderType`) };
|
||||
if (item.location !== undefined) {
|
||||
if (!record(item.location)) throw new CompositorValidationError("COMPOSITOR_GRAPH_INVALID", `nodes[${index}].location is invalid`);
|
||||
node.location = { x: finite(item.location.x, `nodes[${index}].location.x`, -1_000_000, 1_000_000), y: finite(item.location.y, `nodes[${index}].location.y`, -1_000_000, 1_000_000) };
|
||||
}
|
||||
validateNodeProperties(node, index);
|
||||
return node;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user