This is a series of posts on building imgplex, best read in order:
Part 1 - The why, what, and how of imgplex
Part 2 - Getting things up and running
Part 3 - The node definition system
Part 4 - Executing the node graph, making it fast
Part 5 - Two graphs in one
Part 6 - Multiple inputs and outputs, processing images as sets
Part 7 - The small, measured optimizations beneath the big ones
Two graphs in one
The previous post talked about the node graph execution. So far these posts have talked about the node graph as if it does one thing: push images through a chain of ImageMagick operations. But there’s a second graph living inside the same canvas, and it never touches an image at all.
If you’ve used Substance Designer or Blender’s geometry nodes, this will feel familiar. Some nodes carry the actual thing being processed - the image, the mesh, the texture. Other nodes just compute values: a number, a comparison, a bit of math whose result feeds into a parameter somewhere else. Both live in the same graph and connect with the same wires, but only one of them is doing the heavy lifting.
imgplex has exactly this split. There are image nodes - anything with an image or
mask port, executed as ImageMagick operations - and pure-value nodes - math,
logic, and constants that get evaluated in JavaScript and never spawn a single
magick process. The thing that lets these two coexist cleanly on one canvas is that
every port is typed.
Typed wires
Every port in the graph has a type, and every wire connecting two ports has to respect it. You can’t wire a color into a slider that expects a number - the canvas checks the connection as you make it and simply refuses the ones that don’t fit.
This is the same idea as a shader graph refusing to plug a float3 into a float
input. Types kill off a whole category of mistakes before they can happen, and they
make a dense graph readable at a glance. The validation covers a few cases: a port
can’t connect to itself, a connection that would form a cycle is rejected, and the
core types - image, number, boolean, string, path - only connect to compatible ports.
There’s also an any type for nodes that are type-agnostic; once one wire lands on
an any port, its siblings on that node are constrained to match, so a generic node
can’t end up with a nonsensical mix of input types.
To make all of this legible, each type has its own wire color:
- image - orange (the main pixel pipeline)
- mask - purple (a grayscale image used as a mask)
- path - mint green (a filesystem path)
- number - cyan
- string - green
- bool - yellow
- color - pink
- vector2 / vector3 / vector4 - amber / indigo / teal
When you drag a wire out of a port, the preview wire is already tinted with the source type’s color, so before you’ve connected anything you can see what kind of value is flowing. In a busy graph this ends up being the fastest way to trace what connects to what - you just follow the color. The palette isn’t arbitrary either; each color was checked for AAA contrast against the dark node background, to ensure easy legibility.
The pure-value graph
Pure-value nodes are the ones with no image ports at all - their input and output lists are empty. Instead of pixels they deal in numbers, strings, booleans, and vectors. They come in a few families:
- Value nodes - a constant. A Float node outputs a number you set; a Color node outputs a color. These are the leaves of the value graph.
- Math nodes - Add, Subtract, Multiply, Divide, Power, Lerp. Ordinary arithmetic on wired-in values.
- Vector nodes - build a vector from components, split one back apart, dot product, length, normalize.
- Logic nodes - AND, OR, NOT, comparisons, and a Branch node that picks between two values based on a condition.
- Properties nodes - these read a fact about the current image (its width, height, name, file size, bit depth) and output it as a value.
That last family is the bridge between the two graphs. A Properties node doesn’t process the image - it reads a fact about it and hands that fact to the value graph as a plain number or string.
How the two graphs connect
The connection point is parameters. Every editable parameter on an image node - the
sigma on a Blur, the angle on a Rotate - exposes an input handle on its left
side. That handle accepts a wire from any compatible value output.
So you can do things like: read an image’s width with a Dimensions node, halve it with a Math node, and wire the result straight into a Crop node’s parameter. The crop now adapts to each image’s size on its own, with no fixed numbers anywhere in the graph.
When the pipeline runs, it evaluates the whole value graph first, in dependency order, so that by the time an image node executes, every one of its parameters is already a concrete resolved value. The value graph is essentially a precomputation pass that front-loads all the arithmetic before any ImageMagick work begins - which is exactly why the image side of the engine can stay so simple. Every image node just needs its parameters handed to it as finished values; it never has to know they came from a chain of math nodes.
Three ways a node computes
Early on, every node was just a command_template - a fixed ImageMagick argument
string with {{placeholder}} slots filled in from parameter values. That covers a
surprising number of nodes: a Blur is -blur 0x{{sigma}} and nothing more.
But plain substitution hits a wall fast. What if a flag should only appear when a checkbox is on? What if two parameters need combining with a bit of math first? What if the node computes a value and never touches an image at all? So the model grew into three ways to define what a node does:
command_template- the simple case. A fixed argument string with placeholders, no logic. Most image nodes are this.command_js- for image nodes that need logic. A small JavaScript snippet that receives the resolved parameters and returns an array of ImageMagick arguments. This is what lets a node conditionally add a flag, do arithmetic on a parameter, or format a value correctly before handing it tomagick.compute_js- the same idea for pure-value nodes. Instead of returning ImageMagick arguments it returns computed output values, which is how a custom math or logic node does its work without ever spawning a process.
All three are still just fields in a JSON file. Adding a node with conditional logic doesn’t mean touching the app’s source - you write the snippet inside the node definition, drop the file in, and it hot-reloads like any other node.
It’s worth being honest about what those snippets are, though. command_js and
compute_js are real JavaScript, and they run in the app’s main process - the
privileged side that can touch the filesystem and spawn processes. That power is the
point, but it’s also a responsibility. Keeping executable logic inside node
definitions is fine, because those are part of the app or files you’ve deliberately
added. Workflow files are a different matter: they’re built to be shared and
double-click-opened, which makes their contents untrusted input. Drawing a hard line
so that executable code can only ever come from a trusted node definition, and never
be smuggled in through a shared workflow, turned out to be worth a whole post of its
own - that one’s coming later in the series.
The handful of genuinely complex nodes - the ones that split an image into four
channel outputs, or resize with a mode switch - still use hardcoded executors in the
app’s TypeScript. Multiple output ports and variable port shapes are the two things
JSON alone can’t express. But a lot of behavior I initially assumed would need real
code turned out to be expressible as a JSON node with a small command_js snippet.
Data-driven niceties
Two smaller things fall out of this typed, data-driven approach, and both make the node library feel less mechanical.
-
The first is conditional Inspector UI. A node can declare visibility rules in its JSON - show this parameter row only when that dropdown has a particular value. A Resize node in “percent” mode shows a scale slider; in “pixels” mode it shows a pixel-count box instead. This used to be hardcoded per node; moving it into a declarative rule means any node can opt into it without a line of application code.
-
The second is search. Node definitions can list alternate names, so typing “sharpen” or “blur” finds the right node even when its formal label is something you wouldn’t have guessed. It’s a tiny feature, but it’s the difference between a library you browse and one you can actually search.
Where this leaves the architecture
The two-graph model - a typed value graph feeding parameters into an image processing graph - is the part of the design I’m happiest with. It keeps the image pipeline dead simple, because every image node only ever sees finished parameter values, while making the graph itself genuinely programmable. Complex adaptive behavior emerges from wiring simple typed nodes together, rather than from any one node being clever.
It also happens to be why the workflow builder runs in a browser with no ImageMagick at all. The value graph is pure JavaScript, so you can build and wire an entire workflow in the web version and only need the real backend when it’s time to actually process pixels.
Next post in this series: Building imgplex: part 6 - Multiple inputs and outputs, processing images as sets