<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Chandan Singh — Blog</title><description>Personal portfolio and blog</description><link>https://psmyles.com/</link><item><title>Building imgplex: part 7</title><link>https://psmyles.com/blog/building-imgplex-part-7/</link><guid isPermaLink="true">https://psmyles.com/blog/building-imgplex-part-7/</guid><description>The small, measured optimizations beneath the big ones</description><pubDate>Sat, 04 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;This is a series of posts on building imgplex, best read in order:&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-1&quot;&gt;Part 1 - The why, what, and how of imgplex&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-2&quot;&gt;Part 2 - Getting things up and running&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-3&quot;&gt;Part 3 - The node definition system&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-4&quot;&gt;Part 4 - Executing the node graph, making it fast&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-5&quot;&gt;Part 5 - Two graphs in one&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-6&quot;&gt;Part 6 - Multiple inputs and outputs, processing images as sets&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-7&quot;&gt;Part 7 - The small, measured optimizations beneath the big ones&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;the-layer-beneath&quot;&gt;The layer beneath&lt;/h2&gt;
&lt;p&gt;Back in Part 4 I covered the big structural performance decisions - command fusion,
parallel workers, the fast-path/slow-path split. Those are the load-bearing ones, the
choices that decide whether a 2000 image batch takes seconds or minutes. But
underneath them sits a second layer: a pile of small, individually unglamorous
optimizations that each shave a little off, and compound into a lot.&lt;/p&gt;
&lt;p&gt;The thing worth saying about this layer up front is that none of it was guessed. In
game dev you don’t optimize a frame you haven’t captured in a profiler - intuition
about what’s slow is often wrong enough that acting on it blind is how you spend a
day speeding up something that was never the bottleneck. Same rule here. Every trick
below came from actually measuring where the time went, which is why the back half of
this post is about the measuring, not the tricks.&lt;/p&gt;
&lt;h2 id=&quot;miff-stop-compressing-files-youre-about-to-delete&quot;&gt;MIFF: stop compressing files you’re about to delete&lt;/h2&gt;
&lt;p&gt;I touched on this in Part 4, but it’s the cleanest example of the mindset so it’s
worth restating. When a processing chain has to break and write an intermediate
file - because the image branches, or the format changes mid-graph - that file exists
for a few milliseconds before the next stage reads it back and it gets thrown away.&lt;/p&gt;
&lt;p&gt;Writing that throwaway file as a PNG means paying to compress it on the way out and
decompress it on the way in. For a file nobody will ever look at, that compression is
pure waste. Intermediates are now written as MIFF, ImageMagick’s own uncompressed
native format, which skips the encode/decode entirely. Only the final output - the
thing the user actually keeps - gets encoded to the real target format.&lt;/p&gt;
&lt;p&gt;It feels backwards the first time: you’re deliberately writing &lt;em&gt;bigger&lt;/em&gt; files to go
&lt;em&gt;faster&lt;/em&gt;. But it’s the same logic as an intermediate render target in a render
pipeline. You don’t compress a buffer you’re going to read back next stage; the
compression costs more time than the extra bytes ever will.&lt;/p&gt;
&lt;h2 id=&quot;webp-thumbnails-pay-a-cheap-decode-to-save-memory&quot;&gt;WebP thumbnails: pay a cheap decode to save memory&lt;/h2&gt;
&lt;p&gt;The filmstrip can hold thousands of thumbnails at once, and every one of them sits on
disk for as long as the image is loaded in. Generated as PNG, that adds up fast.
Thumbnails are now WebP instead, which is dramatically smaller on disk and in memory
for the same visual quality, at the cost of a slightly more expensive decode - a
trade that’s obviously worth it when you’re holding thousands of them. The thumbnail
resolution is also configurable per workflow, from the Input node’s inspector - 256px
by default. Dense filmstrip on a big monitor and want more detail? Turn it up.
Working with an enormous folder and want imports snappy and memory low? Turn it down.
It’s the same knob as picking a texture resolution - the right answer depends on the
budget you’re working against, so it’s exposed rather than hardcoded. That same
thumbnail is what the preview pipeline reuses as its input, so the number you pick
here sets preview latency too.&lt;/p&gt;
&lt;h2 id=&quot;jpeg-dct-hints-dont-decode-pixels-youre-going-to-throw-away&quot;&gt;JPEG DCT hints: don’t decode pixels you’re going to throw away&lt;/h2&gt;
&lt;p&gt;This is my favorite one because it’s genuinely clever and it’s entirely ImageMagick
doing the work - you just have to ask.&lt;/p&gt;
&lt;p&gt;Making a 256px thumbnail from a 6000px JPEG, the naive path fully decodes all six
thousand pixels of width, then throws away 95% of them in the downscale. But JPEG’s
compression is built on the DCT, and libjpeg’s decoder can run that step at a reduced
internal scale - 1/2, 1/4, 1/8 - decoding straight to a smaller image without ever
reconstructing the full-resolution one. Passing &lt;code&gt;-define jpeg:size=NxN&lt;/code&gt; tells it the
target size so it picks the coarsest scale that still covers what you need.&lt;/p&gt;
&lt;p&gt;For a big JPEG headed for a small thumbnail, that cuts the decode cost substantially,
because you’re skipping most of the decompression rather than doing it and discarding
the result. It’s mip levels, basically - you don’t sample the full-resolution mip to
shade a distant object, and you don’t fully decode a JPEG to make a thumbnail of it.&lt;/p&gt;
&lt;h2 id=&quot;batched-channel-means-draw-call-batching-for-metadata&quot;&gt;Batched channel means: draw-call batching for metadata&lt;/h2&gt;
&lt;p&gt;Some nodes don’t process an image so much as measure it - the mean value of the red
channel, say, or all four channel means to check whether an alpha channel carries
real data. The obvious implementation reads each channel with its own
&lt;code&gt;magick identify&lt;/code&gt; call: four spawns to answer one question, and spawns are the
expensive thing.&lt;/p&gt;
&lt;p&gt;Those reads are now combined. A single &lt;code&gt;magick identify&lt;/code&gt; with a compound format
string pulls all the channel means back at once - up to four spawns collapsed into
one. If you’ve ever batched draw calls, this is exactly that move: the per-call
overhead dwarfs the work inside the call, so you stop making N calls and make one
that does N things.&lt;/p&gt;
&lt;p&gt;There’s a sharper version of the same idea for a specific case. When a channel-split
node’s outputs feed &lt;em&gt;only&lt;/em&gt; mean-value analysis - nobody’s looking at the actual split
channels, they’re just being measured - the four channel image files never get
written at all. The means are gathered directly in a single spawn, and the temp file
I/O for images no one will ever see is skipped entirely. The cheapest work is the
work you prove you never have to do.&lt;/p&gt;
&lt;h2 id=&quot;thread-limits-the-bug-hiding-in-global-state&quot;&gt;Thread limits: the bug hiding in global state&lt;/h2&gt;
&lt;p&gt;Part 4 mentioned that ImageMagick has its own internal multithreading, so running N
worker processes that each grab every core oversubscribes the CPU and makes the whole
batch slower - the fix being to divide the thread budget across the workers so the
total stays sane. That’s the &lt;code&gt;MAGICK_THREAD_LIMIT&lt;/code&gt; environment variable, and setting
it is the easy part.&lt;/p&gt;
&lt;p&gt;The subtle part is &lt;em&gt;how&lt;/em&gt; you set it. The first implementation wrote it into the
process’s shared environment before each spawn. That works fine when one thing runs
at a time. It stops working the moment two things overlap - a preview firing while a
batch is running, thumbnails generating during an import - because they’re all
reading and writing the same global variable, and whoever wrote last wins. One
workload clobbers another’s thread budget and the careful division falls apart.&lt;/p&gt;
&lt;p&gt;The fix was to attach the thread limit to each individual spawn’s own environment
instead of mutating the shared one. Every &lt;code&gt;magick&lt;/code&gt; process now carries its own
budget, and concurrent previews, thumbnails, and batches stop stepping on each other.
It’s the classic shared-mutable-global bug - the kind that looks fine in every
single-threaded test and only misbehaves when things run at once - and the fix is the
classic one too: stop sharing the state.&lt;/p&gt;
&lt;h2 id=&quot;keeping-the-main-process-responsive&quot;&gt;Keeping the main process responsive&lt;/h2&gt;
&lt;p&gt;Throughput is one half of feeling fast; the other half is never freezing. Electron’s
main process runs a single event loop - the same shape as a game’s main thread - and
any synchronous work you put on it blocks everything else until it returns. Stall it
and the UI stops responding, IPC messages queue up behind the stall, and the app
feels locked even though it’s technically busy working.&lt;/p&gt;
&lt;p&gt;Two places were quietly doing exactly that. Importing a folder walked the directory
tree with synchronous recursion - the kind of &lt;code&gt;readdirSync&lt;/code&gt; walk that’s fine on ten
files and janks hard on a deep tree of thousands, because nothing else on the event
loop gets a turn until the entire walk finishes. Converting it to async filesystem
calls lets the scan yield between steps so IPC keeps flowing; the import takes as
long as it takes, but the app stays alive and responsive while it happens.&lt;/p&gt;
&lt;p&gt;Startup housekeeping had the same shape. On launch the app sweeps its temp directory
for intermediate files orphaned by a previous crash - the debris a hard quit leaves
mid-batch - and it now also ages out cached thumbnails and preview files older than
two weeks so the cache folder doesn’t grow without bound. That sweep used to run
synchronously and added its cost directly to launch time; it’s now async, so it
happens in the background and never holds up the window appearing. The still-valid
recent cache is left untouched; only the genuinely stale files go.&lt;/p&gt;
&lt;p&gt;It’s the same discipline as refusing to do a giant synchronous asset load on the game
thread. The work still has to happen - you just don’t let it hold everything else
hostage while it does.&lt;/p&gt;
&lt;h2 id=&quot;you-cant-optimize-what-you-cant-see&quot;&gt;You can’t optimize what you can’t see&lt;/h2&gt;
&lt;p&gt;Here’s the honest part. Every optimization above is small, and several of them are
non-obvious. The JPEG DCT trick and the thread-limit race in particular are the sort
of thing you do not find by staring at code. You find them by measuring, being
surprised, and going to look at why.&lt;/p&gt;
&lt;p&gt;So there’s a timing system built in, toggled from &lt;code&gt;Debug &amp;gt; Enable Performance Timers&lt;/code&gt;
menu option. With it on, every batch breaks its time down by phase: setup, the
ImageMagick startup cost (from kicking off the run to the first image actually being
touched), the per-image &lt;code&gt;magick&lt;/code&gt; time, the file-existence check, and the final copy.
The breakdown is printed to the console after each run and written to a &lt;code&gt;perf.log&lt;/code&gt; in
the output directory, so you can compare runs across code changes instead of trusting
your memory of “that felt faster.”&lt;/p&gt;
&lt;p&gt;When the timers are on, it also asks ImageMagick itself for its &lt;code&gt;-verbose&lt;/code&gt; per-image
stats - format, dimensions, colorspace, file size, elapsed time - and folds those
into the same &lt;code&gt;perf.log&lt;/code&gt;. That’s how you tell the difference between “the batch is
slow” and “the batch is slow &lt;em&gt;because three specific 12,000px PSDs are dominating the
whole run&lt;/em&gt;.” The phase breakdown tells you which stage; the per-image stats tell you
which file. Between them you’re never guessing.&lt;/p&gt;
&lt;p&gt;(One small cross-platform gotcha that cost some time: &lt;code&gt;-verbose&lt;/code&gt; placed as a
per-image option gets silently ignored by some Windows ImageMagick builds. It has to
sit in the global option position, before the input, to be honoured everywhere. The
kind of thing that works on your machine and quietly does nothing on someone else’s.)&lt;/p&gt;
&lt;h2 id=&quot;the-log-window&quot;&gt;The log window&lt;/h2&gt;
&lt;p&gt;The timing system tells you about batches. For everything else there’s a dedicated
log window - &lt;code&gt;Help &amp;gt; View Log&lt;/code&gt; - that streams entries live from the main process with
timestamps and severity levels, in its own window so you’re not squinting at a
terminal behind the app. This is also available in release builds, so you can ask
your artist to send you the log if something didn’t work on their system as expected.&lt;/p&gt;
&lt;p&gt;The reason it’s useful is that the pipeline logs generously. Every &lt;code&gt;magick&lt;/code&gt; spawn
records its arguments, how long it took, and how many bytes it wrote to stdout and
stderr. Batch and import runs log their start and end. Thumbnail generation, node
registry hot-reloads, IPC handler entry points - all of it lands in the same stream.
When a node misbehaves or a batch stalls, the answer is almost always sitting right
there in the log: the exact &lt;code&gt;magick&lt;/code&gt; command that ran, and what it said back. It
turns “it doesn’t work” into “this specific command failed with this specific
message,” which covers 99% of debugging.&lt;/p&gt;
&lt;p&gt;A couple of small practical touches keep it usable rather than overwhelming. The
in-memory log is capped at a thousand entries so a long session doesn’t slowly eat
memory, there’s a clear button to reset the view without reopening the window, and
the window drops the main app’s menu bar since File/Edit/View mean nothing in a log
viewer.&lt;/p&gt;
&lt;h2 id=&quot;the-learnings&quot;&gt;The learnings&lt;/h2&gt;
&lt;p&gt;While this tech stack is new to me, the principles are the same ones I’ve always
worked by, and they come down to two things: measure before you fix, and don’t do
work you’ll only throw away.&lt;/p&gt;
&lt;p&gt;The first is why I’ve leaned on profilers for most of my career. Some of them are
clunky to use, but I love them for the one thing they do - tell you what’s really
happening instead of what you assumed was. So when I sat down to build imgplex I made
performance metrics a core part of the app from the start: a clock on every phase, a
log on every spawn. It’s already paid off in finding and fixing the issues above.&lt;/p&gt;
&lt;p&gt;The second is the thread running through every trick in this post - the cheapest work
is the work you never do. Don’t compress a scratch file, don’t decode pixels you’re
about to discard, don’t spawn four processes for one question, don’t render channels
nobody will look at, don’t block the event loop with work that could yield.&lt;/p&gt;
&lt;h2 id=&quot;the-present-and-the-future&quot;&gt;The present and the future&lt;/h2&gt;
&lt;p&gt;imgplex is in a stable shape now, and a number of people across different game
studios are using it in production. Their feedback, bug reports, and discussion have
been a huge help in improving it. Along the way I’ve researched and learnt a lot
about architecture and about writing code bases that are scalable, performant, and
manageable.&lt;/p&gt;
&lt;p&gt;AI accelerated this many times over - I honestly wouldn’t have attempted a code base
this complex on my own, and definitely not in the small slice of time left over after
my day job.&lt;/p&gt;
&lt;p&gt;Parts of the application have been through big refactors, and the pipeline design has
changed in meaningful ways more than once. All of it led to the current state:
stable, and hopefully easy to extend. It’s in no way done - it’ll keep evolving with
the needs and feedback of the people using it - but it’s already been a genuinely
valuable learning experience, and those lessons have paid off in other tools I’ve
built since.&lt;/p&gt;
&lt;p&gt;If you end up using imgplex and have thoughts about it, I’d love to hear from you -
&lt;a href=&quot;https://psmyles.com/contact&quot;&gt;reach out directly&lt;/a&gt; or on the
&lt;a href=&quot;https://github.com/psmyles/imgplex&quot;&gt;project GitHub repo&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>imgplex</category><category>tooling</category><category>game-dev</category></item><item><title>Standalone E-Ink Picture Frame</title><link>https://psmyles.com/blog/standalone-e-ink-picture-frame/</link><guid isPermaLink="true">https://psmyles.com/blog/standalone-e-ink-picture-frame/</guid><description>A standalone e-ink based picture frame and related tooling</description><pubDate>Mon, 08 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;figure class=&quot;prose-figure&quot; data-astro-cid-qlmbolrp&gt; &lt;img src=&quot;https://psmyles.com/images/blog/standalone-e-ink-picture-frame/header-e-ink.jpg&quot; alt data-astro-cid-qlmbolrp&gt;  &lt;/figure&gt;&lt;/p&gt;
&lt;p&gt;I came across
&lt;a href=&quot;https://style.oversubstance.net/2025/11/seeed-studio-reterminal-e1002-epaper-ikea-rodalm-esphome-home-assistant-digital-art-frame/&quot;&gt;this blog post&lt;/a&gt;
by Guy Sie detailing a Spectra 6 based e-ink display to use as a picture frame. The
premise is quite simple and enticing: a dynamic picture frame that doesn’t look like
a display and can show images relatively well as long as the images are encoded
properly for it. I ended up getting one and set it up to work with Home Assistant.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Disclaimer: The Arduino firmware for this project and parts of Ink Frame Lab has
been developed with help from AI tools. The design decisions, architecture, and
hardware debugging were done manually, with AI assisting primarily in code
generation and iteration.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;its-nice-but&quot;&gt;Its nice, but..&lt;/h2&gt;
&lt;p&gt;After living with the Home Assistant setup for a while, a few friction points became
clear:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It wasn’t standalone.&lt;/strong&gt; The frame needed a Home Assistant instance running
somewhere on the network to serve images. That’s fine for my setup, but I wanted
this to be something I could give as a gift to someone who doesn’t have a Home
Assistant setup or a NAS. A picture frame shouldn’t need infrastructure.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No battery visibility.&lt;/strong&gt; I had to open Home Assistant to check the battery level.
For something that sits on a shelf and sleeps most of the time, I wanted a
glanceable indicator on the display itself.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The image processing workflow was rough.&lt;/strong&gt; There are tools online that can dither
images to the Spectra 6 color palette, but you still have to manually crop and
resize each image before dithering. That’s not something I can ask a non-technical
person to do if they want to add new photos. Also, most web based tools lack batch
image processing and can only process one image at a time - fine for the odd
experiment, but impractical when you want to process a bunch of images in one go.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No way to preview the end result.&lt;/strong&gt; E-ink panels have quite muted colors - for
example white is more of a light bluish grey on the display, and there’s no
backlight, so images look very different depending on ambient lighting. I wanted to
be able to visualize how a processed image would actually look on the panel in the
real world in different lighting conditions before committing to it.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;ideas-and-dead-ends&quot;&gt;Ideas and dead ends&lt;/h2&gt;
&lt;p&gt;My first idea was to expose the SD card as a USB drive when the device is plugged in,
so you could just drag and drop images like a thumb drive. This turned out to be a
hardware dead end: on the reTerminal E1002, the USB-C port is routed through a CH341
UART bridge chip, which can only do serial communication. The ESP32-S3 does have
native USB OTG that could theoretically do mass storage, but those GPIO pins (19/20)
are repurposed for the I2C bus on this board. There’s no way to present a storage
device to the host without physically modifying the PCB.&lt;/p&gt;
&lt;p&gt;The fallback was Wi-Fi. The ESP32-S3 has Wi-Fi built in, so the device could host a
small web server with a drag-and-drop upload page. No app needed, works from any
phone or laptop browser. The question was how to make this accessible to someone who
has never configured a microcontroller. The answer turned out to be using the
device’s own Wi-Fi access point - the frame creates its own network, the e-ink screen
shows the network name, password, and URL in large text, and you just follow the
steps. No router configuration, no IP address hunting.&lt;/p&gt;
&lt;h2 id=&quot;the-solution&quot;&gt;The solution&lt;/h2&gt;
&lt;p&gt;I ended up building two things: custom Arduino firmware for the reTerminal E1002, and
&lt;a href=&quot;https://www.psmyles.com/projects/ink-frame-lab&quot;&gt;Ink Frame Lab&lt;/a&gt; — a browser-based
tool for preparing images for e-ink displays.&lt;/p&gt;
&lt;h3 id=&quot;firmware-for-reterminal-e1002&quot;&gt;Firmware for reTerminal E1002&lt;/h3&gt;
&lt;div class=&quot;image-gallery&quot; data-astro-cid-gjhjmbi3&gt; &lt;div class=&quot;gallery-grid&quot; data-astro-cid-gjhjmbi3&gt; &lt;figure class=&quot;gallery-item&quot; data-astro-cid-gjhjmbi3&gt; &lt;button class=&quot;gallery-thumb-btn&quot; data-index=&quot;0&quot; data-caption=&quot;Web server mode&quot; aria-label=&quot;Web server mode&quot; data-astro-cid-gjhjmbi3&gt; &lt;img src=&quot;https://psmyles.com/images/projects/standalone-e-ink-picture-frame/01.jpeg&quot; alt=&quot;Web server mode&quot; class=&quot;gallery-thumb&quot; loading=&quot;lazy&quot; data-astro-cid-gjhjmbi3&gt; &lt;/button&gt; &lt;figcaption class=&quot;gallery-caption&quot; data-astro-cid-gjhjmbi3&gt;Web server mode&lt;/figcaption&gt; &lt;/figure&gt;&lt;figure class=&quot;gallery-item&quot; data-astro-cid-gjhjmbi3&gt; &lt;button class=&quot;gallery-thumb-btn&quot; data-index=&quot;1&quot; data-caption=&quot;Web server interface&quot; aria-label=&quot;Web server interface&quot; data-astro-cid-gjhjmbi3&gt; &lt;img src=&quot;https://psmyles.com/images/projects/standalone-e-ink-picture-frame/web server.png&quot; alt=&quot;Web server interface&quot; class=&quot;gallery-thumb&quot; loading=&quot;lazy&quot; data-astro-cid-gjhjmbi3&gt; &lt;/button&gt; &lt;figcaption class=&quot;gallery-caption&quot; data-astro-cid-gjhjmbi3&gt;Web server interface&lt;/figcaption&gt; &lt;/figure&gt;&lt;figure class=&quot;gallery-item&quot; data-astro-cid-gjhjmbi3&gt; &lt;button class=&quot;gallery-thumb-btn&quot; data-index=&quot;2&quot; data-caption=&quot;Battery level bar&quot; aria-label=&quot;Battery level bar&quot; data-astro-cid-gjhjmbi3&gt; &lt;img src=&quot;https://psmyles.com/images/projects/standalone-e-ink-picture-frame/03.jpg&quot; alt=&quot;Battery level bar&quot; class=&quot;gallery-thumb&quot; loading=&quot;lazy&quot; data-astro-cid-gjhjmbi3&gt; &lt;/button&gt; &lt;figcaption class=&quot;gallery-caption&quot; data-astro-cid-gjhjmbi3&gt;Battery level bar&lt;/figcaption&gt; &lt;/figure&gt; &lt;/div&gt; &lt;dialog class=&quot;gallery-dialog&quot; data-astro-cid-gjhjmbi3&gt; &lt;button class=&quot;dialog-close&quot; aria-label=&quot;Close&quot; data-astro-cid-gjhjmbi3&gt;&amp;#x2715;&lt;/button&gt; &lt;button class=&quot;dialog-prev&quot; aria-label=&quot;Previous&quot; data-astro-cid-gjhjmbi3&gt;&amp;#x2039;&lt;/button&gt; &lt;div class=&quot;dialog-content&quot; data-astro-cid-gjhjmbi3&gt; &lt;img class=&quot;dialog-img&quot; src=&quot;&quot; alt=&quot;&quot; data-astro-cid-gjhjmbi3&gt; &lt;p class=&quot;dialog-caption&quot; data-astro-cid-gjhjmbi3&gt;&lt;/p&gt; &lt;/div&gt; &lt;button class=&quot;dialog-next&quot; aria-label=&quot;Next&quot; data-astro-cid-gjhjmbi3&gt;&amp;#x203a;&lt;/button&gt; &lt;/dialog&gt; &lt;/div&gt;  
&lt;p&gt;The firmware replaces the stock ESPHome setup with standalone Arduino code that
doesn’t need Wi-Fi or Home Assistant during normal operation. The device reads PNG
images from the SD card, picks one (randomly or sequentially based on a config file),
renders it to the e-ink display with a single-pixel horizontal battery indicator bar
at the bottom, and goes into deep sleep until it’s time to change.&lt;/p&gt;
&lt;p&gt;The interesting engineering challenges were all around the shared SPI bus. The SD
card and e-ink display share the same SPI pins (MOSI, MISO, SCK) with separate chip
selects, which means they can’t talk at the same time. My first approach was to
decode the PNG and draw to the display simultaneously inside GxEPD2’s paged drawing
loop - re-reading the PNG from SD for each page. This worked for the first 40-pixel
strip and then the rest of the screen was white. The display’s SPI context was active
during the page loop, so the SD card reads silently failed.&lt;/p&gt;
&lt;p&gt;The fix was a two-pass approach using the ESP32-S3’s 8MB PSRAM: first, decode the
entire PNG from SD into a 384KB buffer in PSRAM, close the SD card, then initialize
the display and draw from the buffer. This also meant being deliberate about
initialization order - if the display driver sent its init sequence on the shared SPI
bus before the SD card was mounted, the card’s internal SPI state machine would get
confused and reject subsequent mount attempts. Splitting &lt;code&gt;initDisplay()&lt;/code&gt; into a
pin-setup phase and a deferred driver-init phase fixed this, ensuring the SD card
always gets a clean bus.&lt;/p&gt;
&lt;p&gt;Another entertaining bug: the first successful render had all the colors wrong. Green
foliage showed as red, blue sky showed as green. The PNGdec library’s
&lt;code&gt;getLineAsRGB565()&lt;/code&gt; function was being called with &lt;code&gt;PNG_RGB565_BIG_ENDIAN&lt;/code&gt;, which
byte-swaps each pixel for big-endian displays - but the ESP32 is little-endian. The
bit extraction was pulling the wrong channels from each swapped uint16_t. A one line
fix to &lt;code&gt;PNG_RGB565_LITTLE_ENDIAN&lt;/code&gt; and the colors were correct.&lt;/p&gt;
&lt;p&gt;The web server mode is a secondary boot mode activated by holding the green button
during power-on. The e-ink screen shows step-by-step instructions with the Wi-Fi
credentials and URL, and the web interface lets you upload, delete, and manage
photos, configure the rotation interval, and choose between random or sequential
display order. In sequential mode, the two white buttons on the device navigate
forward and backward through the images. When you’re done, the device enters deep
sleep for a couple of seconds and wakes up as a clean cold boot into slideshow mode -
I learned the hard way that &lt;code&gt;ESP.restart()&lt;/code&gt; is a software reset that doesn’t properly
reinitialize the SPI peripheral, so the SD card would fail to mount after every
restart from setup mode.&lt;/p&gt;
&lt;p&gt;As a side effect of running standalone and not needing to maintain a Wi-Fi
connection, battery life improved significantly. My device is set to change images
randomly every 4 hours and loses about 10% over a week.&lt;/p&gt;
&lt;p&gt;Here are the
&lt;a href=&quot;https://github.com/psmyles/ink-frame-lab/blob/main/E1002%20standalone%20firmware/reTerminal_E1002_DigitalFrame.ino&quot;&gt;firmware files&lt;/a&gt;
for reTerminal E1002 and
&lt;a href=&quot;https://github.com/psmyles/ink-frame-lab/blob/main/E1002%20standalone%20firmware/Firmware%20installation%20instructions.md&quot;&gt;installation instructions&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;ink-frame-lab&quot;&gt;Ink Frame Lab&lt;/h3&gt;
&lt;p&gt;The image preparation side of the problem needed its own tool. Existing dithering
tools handle the palette conversion, but none of them solve the full workflow: crop
to the panel’s aspect ratio, resize to 800×480, dither to the Spectra 6 palette, and
preview how it will actually look on the muted, non-backlit display.&lt;/p&gt;
&lt;p&gt;Ink Frame Lab is a browser-based tool that handles all of this. You import images,
crop them with a locked aspect ratio, preview the dithered result, and - the part I’m
most pleased with - inspect it in a 3D view that simulates different lighting
conditions and angles. This matters more than you’d think: an image that looks great
on your monitor can look muddy or washed out on the actual panel, and being able to
preview that before exporting saves a lot of trial and error. Oh, and you can bulk
edit and export images - no need to process images one at a time.&lt;/p&gt;
&lt;div class=&quot;image-gallery&quot; data-astro-cid-gjhjmbi3&gt; &lt;div class=&quot;gallery-grid&quot; data-astro-cid-gjhjmbi3&gt; &lt;figure class=&quot;gallery-item&quot; data-astro-cid-gjhjmbi3&gt; &lt;button class=&quot;gallery-thumb-btn&quot; data-index=&quot;0&quot; data-caption=&quot;Crop view&quot; aria-label=&quot;Crop view&quot; data-astro-cid-gjhjmbi3&gt; &lt;img src=&quot;https://psmyles.com/images/projects/standalone-e-ink-picture-frame/1.webp&quot; alt=&quot;Crop view&quot; class=&quot;gallery-thumb&quot; loading=&quot;lazy&quot; data-astro-cid-gjhjmbi3&gt; &lt;/button&gt; &lt;figcaption class=&quot;gallery-caption&quot; data-astro-cid-gjhjmbi3&gt;Crop view&lt;/figcaption&gt; &lt;/figure&gt;&lt;figure class=&quot;gallery-item&quot; data-astro-cid-gjhjmbi3&gt; &lt;button class=&quot;gallery-thumb-btn&quot; data-index=&quot;1&quot; data-caption=&quot;Processed view&quot; aria-label=&quot;Processed view&quot; data-astro-cid-gjhjmbi3&gt; &lt;img src=&quot;https://psmyles.com/images/projects/standalone-e-ink-picture-frame/2.webp&quot; alt=&quot;Processed view&quot; class=&quot;gallery-thumb&quot; loading=&quot;lazy&quot; data-astro-cid-gjhjmbi3&gt; &lt;/button&gt; &lt;figcaption class=&quot;gallery-caption&quot; data-astro-cid-gjhjmbi3&gt;Processed view&lt;/figcaption&gt; &lt;/figure&gt;&lt;figure class=&quot;gallery-item&quot; data-astro-cid-gjhjmbi3&gt; &lt;button class=&quot;gallery-thumb-btn&quot; data-index=&quot;2&quot; data-caption=&quot;3D view&quot; aria-label=&quot;3D view&quot; data-astro-cid-gjhjmbi3&gt; &lt;img src=&quot;https://psmyles.com/images/projects/standalone-e-ink-picture-frame/3.webp&quot; alt=&quot;3D view&quot; class=&quot;gallery-thumb&quot; loading=&quot;lazy&quot; data-astro-cid-gjhjmbi3&gt; &lt;/button&gt; &lt;figcaption class=&quot;gallery-caption&quot; data-astro-cid-gjhjmbi3&gt;3D view&lt;/figcaption&gt; &lt;/figure&gt; &lt;/div&gt; &lt;dialog class=&quot;gallery-dialog&quot; data-astro-cid-gjhjmbi3&gt; &lt;button class=&quot;dialog-close&quot; aria-label=&quot;Close&quot; data-astro-cid-gjhjmbi3&gt;&amp;#x2715;&lt;/button&gt; &lt;button class=&quot;dialog-prev&quot; aria-label=&quot;Previous&quot; data-astro-cid-gjhjmbi3&gt;&amp;#x2039;&lt;/button&gt; &lt;div class=&quot;dialog-content&quot; data-astro-cid-gjhjmbi3&gt; &lt;img class=&quot;dialog-img&quot; src=&quot;&quot; alt=&quot;&quot; data-astro-cid-gjhjmbi3&gt; &lt;p class=&quot;dialog-caption&quot; data-astro-cid-gjhjmbi3&gt;&lt;/p&gt; &lt;/div&gt; &lt;button class=&quot;dialog-next&quot; aria-label=&quot;Next&quot; data-astro-cid-gjhjmbi3&gt;&amp;#x203a;&lt;/button&gt; &lt;/dialog&gt; &lt;/div&gt;  
&lt;p&gt;You can read more about this tool &lt;a href=&quot;https://psmyles.com/projects/ink-frame-lab&quot;&gt;here&lt;/a&gt; and a functional
web version is &lt;a href=&quot;https://psmyles.github.io/ink-frame-lab/&quot;&gt;here&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id=&quot;credits&quot;&gt;Credits&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://threejs.org/&quot;&gt;&lt;strong&gt;Three.js&lt;/strong&gt;&lt;/a&gt; - 3D rendering, used for the frame viewer, IBL
lighting, and post-processing pipeline (OrbitControls, RoomEnvironment, RGBELoader,
EffectComposer, GTAOPass, OutputPass).&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/GuySie/opendithering&quot;&gt;&lt;strong&gt;OpenDithering&lt;/strong&gt;&lt;/a&gt; - the image
adjustments pipeline (DRC, tone mapping, S-curve, saturation, exposure) was ported
from this project by &lt;a href=&quot;https://style.oversubstance.net/&quot;&gt;Guy Sie&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/paperlesspaper/epdoptimize&quot;&gt;&lt;strong&gt;epdoptimize&lt;/strong&gt;&lt;/a&gt; - inspiration and
references for image processing and measured values&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://stuk.github.io/jszip/&quot;&gt;&lt;strong&gt;JSZip&lt;/strong&gt;&lt;/a&gt; - client-side ZIP archive creation for
the Export ZIP feature.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://rsms.me/inter/&quot;&gt;&lt;strong&gt;Inter&lt;/strong&gt;&lt;/a&gt; - UI typeface by Rasmus Andersson, served via
Google Fonts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dithering algorithms&lt;/strong&gt; - error diffusion kernels (Floyd-Steinberg, Atkinson,
False Floyd-Steinberg, Jarvis-Judice-Ninke, Stucki, Burkes, Sierra-3, Sierra-2,
Sierra-2-4A), ordered Bayer matrix, and random noise dithering are original
implementations of published public-domain techniques.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://polyhaven.com/hdris&quot;&gt;&lt;strong&gt;Polyhaven&lt;/strong&gt;&lt;/a&gt; - images used for image based lighting
in the 3D view.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;future-plans&quot;&gt;Future plans&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Improve the 3D viewer of Ink Frame Lab - the lighting simulation works but could be
more realistic.&lt;/li&gt;
&lt;li&gt;Add more presets for different devices and panel types - the only verified device
preset here is the reTerminal e1002, and I’ve added specs for some other devices by
getting their specs off the internet.&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>hardware</category><category>e-ink</category><category>tooling</category></item><item><title>Building imgplex: part 6</title><link>https://psmyles.com/blog/building-imgplex-part-6/</link><guid isPermaLink="true">https://psmyles.com/blog/building-imgplex-part-6/</guid><description>Multiple inputs and outputs, processing images as sets</description><pubDate>Sun, 31 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;This is a series of posts on building imgplex, best read in order:&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-1&quot;&gt;Part 1 - The why, what, and how of imgplex&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-2&quot;&gt;Part 2 - Getting things up and running&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-3&quot;&gt;Part 3 - The node definition system&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-4&quot;&gt;Part 4 - Executing the node graph, making it fast&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-5&quot;&gt;Part 5 - Two graphs in one&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-6&quot;&gt;Part 6 - Multiple inputs and outputs, processing images as sets&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-7&quot;&gt;Part 7 - The small, measured optimizations beneath the big ones&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;one-in-one-out---until-it-wasnt&quot;&gt;One in, one out - until it wasn’t&lt;/h2&gt;
&lt;p&gt;The earlier posts described the pipeline as if it had a single entrance and a single
exit: images come in, flow through the nodes, and land at the Output node. That was
true for a while, and it was the right place to start. But real image work almost
never fits that shape.&lt;/p&gt;
&lt;p&gt;You want to pull frames from one folder and a set of masks from another. You want the
same processed image written out as a full-resolution PNG &lt;em&gt;and&lt;/em&gt; have its computed
dimensions dumped to a text file &lt;em&gt;and&lt;/em&gt; have the whole batch tiled into a single
contact sheet for review. One source and one destination stops being enough almost
immediately.&lt;/p&gt;
&lt;p&gt;If you’ve built graphs in Substance Designer, you already know the shape this wants
to take. A Substance graph doesn’t have one output - it has a basecolor output, a
normal output, a roughness output, all fed from shared upstream nodes, each producing
a different deliverable from the same network. imgplex ended up in the same place:
any number of inputs, any number of typed outputs, all on one canvas.&lt;/p&gt;
&lt;h2 id=&quot;multiple-inputs&quot;&gt;Multiple inputs&lt;/h2&gt;
&lt;p&gt;The first half of that is straightforward to describe and was a real change under the
hood. Instead of a single implicit source, the canvas supports any number of &lt;strong&gt;Input
nodes&lt;/strong&gt;, and each one owns its own image list and its own filmstrip. Click one Input
node and the filmstrip shows its images; click another and you see that one’s.
They’re independent sources that happen to share a canvas.&lt;/p&gt;
&lt;p&gt;This matters the moment a graph branches. A compositing workflow might take a base
image from one input and an overlay from another. Keeping them as separate nodes,
each with its own queue, means the graph reflects what’s actually happening - two
distinct sources meeting downstream - rather than forcing everything through one
funnel.&lt;/p&gt;
&lt;p&gt;The wrinkle this introduces is that the engine can no longer assume it knows where an
image “came from.” With one input that question is trivial; with several, every
output needs to know which source feeds it. So when a run starts, the engine walks
backwards from each output node - following the wires upstream, hop by hop, until it
reaches an Input node. That trace is what tells it which queue of images to push
through that particular branch of the graph. Multiple inputs and multiple outputs are
really the same feature viewed from two ends: the graph became a many-to-many
network, and the engine has to resolve which end connects to which.&lt;/p&gt;
&lt;h2 id=&quot;multiple-typed-outputs&quot;&gt;Multiple typed outputs&lt;/h2&gt;
&lt;p&gt;The other half is outputs, and this is where the typed-wire system from the last post
pays off again. There are three kinds of output node, and they’re genuinely different
things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Image Output&lt;/strong&gt; - writes processed image files. The one you’d expect.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Text Output&lt;/strong&gt; - writes computed values to a text file. Wire an image’s
dimensions, filename, or any value-graph result into it and it produces a per-image
&lt;code&gt;.txt&lt;/code&gt; record. Useful for generating manifests, sidecar metadata, or just
inspecting what the value graph computed without running a full batch.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Flipbook Output&lt;/strong&gt; - tiles a whole set of images into a single contact sheet.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each is a first-class node on the canvas with its own inspector and its own settings.
Interestingly, this wasn’t the original design - the output started life as a
&lt;em&gt;single&lt;/em&gt; node with a mode switch, and the flipbook was folded in as just another
mode. That worked until it didn’t: each output type accumulated enough of its own
settings that cramming them behind a dropdown on one node got awkward. Splitting them
into separate typed nodes let each one have exactly the inspector it needed, and made
a graph with several outputs readable at a glance - you can see the three
destinations sitting on the canvas instead of having to click into one node to
discover it’s secretly doing three jobs.&lt;/p&gt;
&lt;p&gt;When you run the workflow, every valid output node is processed in turn. One graph,
one Run, several deliverables - the same network feeding a PNG, a manifest, and a
contact sheet in a single pass.&lt;/p&gt;
&lt;p&gt;All three output types share the same “skip existing or overwrite” choice, too. That
started as an Image Output feature and later got extended to the other two, because
re-running a workflow over files that already exist should behave predictably no
matter which kind of output produced them.&lt;/p&gt;
&lt;h2 id=&quot;processing-in-sets&quot;&gt;Processing in sets&lt;/h2&gt;
&lt;p&gt;The features so far treat each image as an independent unit. But a lot of image work
comes in groups that &lt;em&gt;belong together&lt;/em&gt; and have to be processed as a unit: PBR
texture sets (diffuse, normal, channel packed mask), the six faces of a cubemap, a
run of frames that will become a sprite sheet. Processing those one loose image at a
time loses the thing that makes them a set.&lt;/p&gt;
&lt;p&gt;This is what the &lt;strong&gt;Set Input node&lt;/strong&gt; is for. Instead of treating a folder as a flat
list of images, it groups them by a naming convention - a shared prefix or suffix -
so that &lt;code&gt;T_Example_D.png&lt;/code&gt; and &lt;code&gt;T_Example_N.png&lt;/code&gt; are understood as a PBR texture set,
or &lt;code&gt;Frame_01&lt;/code&gt;, &lt;code&gt;Frame_02&lt;/code&gt;, &lt;code&gt;Frame_03&lt;/code&gt; as one flipbook sequence set. The group travels
through the pipeline together as a unit, and nodes that operate on sets (the flipbook
being the obvious one) receive the whole group rather than a single frame.&lt;/p&gt;
&lt;p&gt;For a technical artist this is a familiar mental model. It’s the same grouping you
rely on when a tool ingests a texture set - basecolor, normal, roughness, metallic -
by matching filename suffixes, and treats the four maps as one material rather than
four unrelated images. The naming convention &lt;em&gt;is&lt;/em&gt; the grouping logic, and it’s the
convention art teams already follow.&lt;/p&gt;
&lt;p&gt;The prefix that drives the grouping is itself a wireable port, which is a small thing
with an outsized payoff. Because it’s typed as a string input, you can drive it from
the value graph instead of typing it in by hand - compute a prefix from some other
property and the grouping adapts automatically. It’s the two-graph model from the
previous post showing up exactly where you’d want it.&lt;/p&gt;
&lt;h2 id=&quot;flipbooks&quot;&gt;Flipbooks&lt;/h2&gt;
&lt;p&gt;The Flipbook Output deserves its own mention because the name is not a coincidence.
In real-time VFX, a flipbook is a grid of animation frames packed into one texture
that a shader steps through over time - a staple of particle systems. Tiling a set of
images into a single sheet is the same operation, whether you’re building an actual
flipbook texture for a particle effect or just want a contact sheet to eyeball a
hundred processed images at once.&lt;/p&gt;
&lt;p&gt;The node takes a set, lays the frames out in a grid, and composites them into one
image. The background between and behind tiles is a configurable color, which matters
more than it sounds - a transparent versus a solid background is the difference
between a usable particle flipbook and one with fringing artifacts, and the choice
gets passed straight through to the composite step.&lt;/p&gt;
&lt;h2 id=&quot;running-it-without-nasty-surprises&quot;&gt;Running it without nasty surprises&lt;/h2&gt;
&lt;p&gt;Once a graph can have several inputs and several outputs, “run the workflow” is no
longer a simple instruction. Some outputs might be ready; others might be missing a
required setting or not wired to a source at all. The worst possible behavior here is
a silent partial run - you hit Run, some outputs quietly get skipped, and you don’t
find out until you go looking for files that were never written.&lt;/p&gt;
&lt;p&gt;So a run doesn’t just start. First every output node is validated independently, and
a dialog lists each one with its status and, for any that can’t run, a plain reason
why - this output isn’t connected to an input, that one has no file path set. You see
exactly what will and won’t run before committing to it, rather than discovering the
gaps afterward.&lt;/p&gt;
&lt;p&gt;A couple of related behaviors back this up. Nodes with no path to any output are
skipped entirely during both preview and batch, so a half-wired experiment left
sitting on the canvas doesn’t throw errors or slow anything down - it’s simply inert
until you connect it. And saved workflows carry the app version that wrote them, so
opening a file made by a newer build warns you about the mismatch instead of silently
misreading a format that has since changed. When a run is underway, the progress
modal names the file it’s currently on, so on a long batch you can see where the
pipeline actually is rather than watching a bar inch along with no context.&lt;/p&gt;
&lt;p&gt;None of this is glamorous, but it’s the difference between a tool you trust with a
2000-image job and one you have to babysit.&lt;/p&gt;
&lt;p&gt;Next post in this series:
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-7&quot;&gt;Part 7 - The small, measured optimizations beneath the big ones&lt;/a&gt;&lt;/p&gt;</content:encoded><category>imgplex</category><category>tooling</category><category>game-dev</category></item><item><title>Building imgplex: part 5</title><link>https://psmyles.com/blog/building-imgplex-part-5/</link><guid isPermaLink="true">https://psmyles.com/blog/building-imgplex-part-5/</guid><description>Two graphs in one</description><pubDate>Sun, 24 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;This is a series of posts on building imgplex, best read in order:&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-1&quot;&gt;Part 1 - The why, what, and how of imgplex&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-2&quot;&gt;Part 2 - Getting things up and running&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-3&quot;&gt;Part 3 - The node definition system&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-4&quot;&gt;Part 4 - Executing the node graph, making it fast&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-5&quot;&gt;Part 5 - Two graphs in one&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-6&quot;&gt;Part 6 - Multiple inputs and outputs, processing images as sets&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-7&quot;&gt;Part 7 - The small, measured optimizations beneath the big ones&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;two-graphs-in-one&quot;&gt;Two graphs in one&lt;/h2&gt;
&lt;p&gt;The &lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-4&quot;&gt;previous post&lt;/a&gt; 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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;imgplex has exactly this split. There are &lt;strong&gt;image nodes&lt;/strong&gt; - anything with an image or
mask port, executed as ImageMagick operations - and &lt;strong&gt;pure-value nodes&lt;/strong&gt; - math,
logic, and constants that get evaluated in JavaScript and never spawn a single
&lt;code&gt;magick&lt;/code&gt; process. The thing that lets these two coexist cleanly on one canvas is that
every port is typed.&lt;/p&gt;
&lt;h2 id=&quot;typed-wires&quot;&gt;Typed wires&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;This is the same idea as a shader graph refusing to plug a &lt;code&gt;float3&lt;/code&gt; into a &lt;code&gt;float&lt;/code&gt;
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 &lt;code&gt;any&lt;/code&gt; type for nodes that are type-agnostic; once one wire lands on
an &lt;code&gt;any&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;To make all of this legible, each type has its own wire color:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;image&lt;/strong&gt; - orange (the main pixel pipeline)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;mask&lt;/strong&gt; - purple (a grayscale image used as a mask)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;path&lt;/strong&gt; - mint green (a filesystem path)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;number&lt;/strong&gt; - cyan&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;string&lt;/strong&gt; - green&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;bool&lt;/strong&gt; - yellow&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;color&lt;/strong&gt; - pink&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;vector2 / vector3 / vector4&lt;/strong&gt; - amber / indigo / teal&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2 id=&quot;the-pure-value-graph&quot;&gt;The pure-value graph&lt;/h2&gt;
&lt;p&gt;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:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Value nodes&lt;/strong&gt; - a constant. A Float node outputs a number you set; a Color node
outputs a color. These are the leaves of the value graph.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Math nodes&lt;/strong&gt; - Add, Subtract, Multiply, Divide, Power, Lerp. Ordinary arithmetic
on wired-in values.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vector nodes&lt;/strong&gt; - build a vector from components, split one back apart, dot
product, length, normalize.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Logic nodes&lt;/strong&gt; - AND, OR, NOT, comparisons, and a Branch node that picks between
two values based on a condition.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Properties nodes&lt;/strong&gt; - these read a fact about the current image (its width,
height, name, file size, bit depth) and output it as a value.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2 id=&quot;how-the-two-graphs-connect&quot;&gt;How the two graphs connect&lt;/h2&gt;
&lt;p&gt;The connection point is parameters. Every editable parameter on an image node - the
&lt;code&gt;sigma&lt;/code&gt; on a Blur, the &lt;code&gt;angle&lt;/code&gt; on a Rotate - exposes an input handle on its left
side. That handle accepts a wire from any compatible value output.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2 id=&quot;three-ways-a-node-computes&quot;&gt;Three ways a node computes&lt;/h2&gt;
&lt;p&gt;Early on, every node was just a &lt;code&gt;command_template&lt;/code&gt; - a fixed ImageMagick argument
string with &lt;code&gt;{{placeholder}}&lt;/code&gt; slots filled in from parameter values. That covers a
surprising number of nodes: a Blur is &lt;code&gt;-blur 0x{{sigma}}&lt;/code&gt; and nothing more.&lt;/p&gt;
&lt;p&gt;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:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;command_template&lt;/code&gt;&lt;/strong&gt; - the simple case. A fixed argument string with
placeholders, no logic. Most image nodes are this.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;command_js&lt;/code&gt;&lt;/strong&gt; - 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 to &lt;code&gt;magick&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;compute_js&lt;/code&gt;&lt;/strong&gt; - 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.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;It’s worth being honest about what those snippets are, though. &lt;code&gt;command_js&lt;/code&gt; and
&lt;code&gt;compute_js&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;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 &lt;code&gt;command_js&lt;/code&gt; snippet.&lt;/p&gt;
&lt;h2 id=&quot;data-driven-niceties&quot;&gt;Data-driven niceties&lt;/h2&gt;
&lt;p&gt;Two smaller things fall out of this typed, data-driven approach, and both make the
node library feel less mechanical.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;where-this-leaves-the-architecture&quot;&gt;Where this leaves the architecture&lt;/h2&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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 &lt;a href=&quot;https://psmyles.github.io/imgplex/&quot;&gt;web version&lt;/a&gt; and only need the
real backend when it’s time to actually process pixels.&lt;/p&gt;
&lt;p&gt;Next post in this series:
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-6&quot;&gt;Building imgplex: part 6 - Multiple inputs and outputs, processing images as sets&lt;/a&gt;&lt;/p&gt;</content:encoded><category>game-dev</category><category>imgplex</category><category>tooling</category></item><item><title>Building imgplex: part 4</title><link>https://psmyles.com/blog/building-imgplex-part-4/</link><guid isPermaLink="true">https://psmyles.com/blog/building-imgplex-part-4/</guid><description>Executing the node graph, making it fast</description><pubDate>Sat, 16 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;This is a series of posts on building imgplex, best read in order:&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-1&quot;&gt;Part 1 - The why, what, and how of imgplex&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-2&quot;&gt;Part 2 - Getting things up and running&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-3&quot;&gt;Part 3 - The node definition system&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-4&quot;&gt;Part 4 - Executing the node graph, making it fast&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-5&quot;&gt;Part 5 - Two graphs in one&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-6&quot;&gt;Part 6 - Multiple inputs and outputs, processing images as sets&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-7&quot;&gt;Part 7 - The small, measured optimizations beneath the big ones&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;from-graph-to-commands&quot;&gt;From graph to commands&lt;/h2&gt;
&lt;p&gt;With nodes defined as JSON and a working graph editor as described in
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-3&quot;&gt;part 3&lt;/a&gt;, the next challenge was the pipeline engine:
taking a connected graph of nodes and turning it into actual ImageMagick operations
applied to actual images.&lt;/p&gt;
&lt;p&gt;The first step is always a topological sort. Before executing anything, the engine
needs to know the correct order to process nodes - each node has to run after all of
its inputs are ready. This is the same problem a shader graph or a Houdini network
solves: evaluate the leaf nodes first and work toward the output.
&lt;a href=&quot;https://en.wikipedia.org/wiki/Topological_sorting#Kahn&apos;s_algorithm&quot;&gt;Kahn’s algorithm&lt;/a&gt;
handles the ordering cleanly, and it catches cycles as a side effect - a feedback
loop in the graph is rejected before any processing starts.&lt;/p&gt;
&lt;p&gt;Once the order is established, the engine walks the sorted list and resolves each
node’s parameters. If a parameter has a wire coming into it from another node, the
upstream value wins. If not, the value from the Inspector is used. The pure-value
nodes - floats, math, string constants - are evaluated first, so that by the time an
image node runs, every one of its parameters is already a concrete value.&lt;/p&gt;
&lt;p&gt;For image nodes, those resolved parameters are turned into ImageMagick arguments and
handed to a &lt;code&gt;magick&lt;/code&gt; process. Image in, image out. That’s the whole loop.&lt;/p&gt;
&lt;h2 id=&quot;the-preview-pipeline&quot;&gt;The preview pipeline&lt;/h2&gt;
&lt;p&gt;The preview runs constantly - every parameter edit, every new connection, every time
you click a different image in the filmstrip. The whole point is real-time feedback,
so it has to be fast, and a few things make it fast.&lt;/p&gt;
&lt;p&gt;First, it only does the work it needs to. The graph is trimmed to just the ancestors
of the node you’re currently looking at - nothing downstream of the selected node,
and nothing on an unrelated branch, gets evaluated. And it only runs on the single
image you’ve got selected in the filmstrip, not the whole queue.&lt;/p&gt;
&lt;p&gt;Second, the input is small. Rather than spawning a fresh process to produce a
preview-resolution copy of the source, the preview pipeline reuses the thumbnail that
was already generated when the image was imported - a WebP capped at 256px (user
configurable) on its longest edge. The WebP format was chosen for the thumbnail for
two reasons: it is very fast to write to, and even at 70–80% lossy compression it
looks almost the same as the source image. That thumbnail already exists in the
cache, so the preview starts from it directly and skips a &lt;code&gt;magick&lt;/code&gt; spawn entirely on
every single preview cycle. ImageMagick operations on a 256px image are trivially
cheap compared to a 4K source, and for judging a brightness tweak or a crop the
result is visually identical to the full-res output.&lt;/p&gt;
&lt;p&gt;On top of that, the preview caches each node’s output, keyed by a hash of that node’s
inputs and parameters. When you change something, only that node and its actual
downstream dependents get invalidated and re-run; everything upstream, and every
unrelated branch, serves its cached result immediately. It’s the same principle as
incremental compilation or a shader cache - never recompute what hasn’t changed.
Tweak one node, only that node and the nodes that depend on it re-evaluate. There’s a
80ms debounce on top, so dragging a slider settles before it fires a run rather than
launching a hundred of them a second.&lt;/p&gt;
&lt;h2 id=&quot;the-spawn-cost-problem&quot;&gt;The spawn-cost problem&lt;/h2&gt;
&lt;p&gt;Here’s the thing that shaped most of the batch engine’s design. On Windows, launching
a &lt;code&gt;magick&lt;/code&gt; process costs a fixed overhead no matter how trivial the actual operation
is - and at scale that overhead dominates everything else. Run it once and you won’t
notice. Run it ten thousand times and it’s most of your runtime.&lt;/p&gt;
&lt;p&gt;The naive approach (and the thing I did first) - one &lt;code&gt;magick&lt;/code&gt; spawn per node, per
image - falls apart quickly. A five-node graph over 2000 images is 10,000 process
launches, and the spawn overhead alone would have the computer spending most of its
time starting and stopping processes before touching a single pixel. Getting rid of
that overhead took a few techniques stacked together.&lt;/p&gt;
&lt;p&gt;The first is &lt;strong&gt;command fusion&lt;/strong&gt;. ImageMagick can apply many operations in a single
invocation - a resize, then a brightness adjustment, then a format conversion, all in
one command. So the batch engine doesn’t spawn per node. It walks a chain of
consecutive standard operations and accumulates them lazily into one argument list,
only actually spawning &lt;code&gt;magick&lt;/code&gt; when it hits something that forces a break: a branch
where the image feeds two consumers, or a format change. A long linear chain of nodes
collapses into a single process launch. Even channel splitting, which pulls the R, G,
B, and A channels out as separate images, is done in one &lt;code&gt;magick&lt;/code&gt; call rather than
four.&lt;/p&gt;
&lt;p&gt;The second is about the moments when a chain &lt;em&gt;does&lt;/em&gt; have to break and write an
intermediate file to disk. Those intermediates used to be written as PNG, which means
every break point paid the cost of PNG-compressing the image on the way out and
decompressing it on the way back in - pure overhead for a file that only exists for a
few milliseconds. They’re now written as MIFF, ImageMagick’s own uncompressed native
format, which skips the encode/decode entirely. Only the final output - the thing the
user actually keeps - gets encoded to the real target format.&lt;/p&gt;
&lt;p&gt;The third is &lt;strong&gt;parallelism&lt;/strong&gt;. Instead of processing images one at a time, the batch
runs several concurrently, with the worker count derived from the CPU core count.
Each worker pulls the next image off the queue and processes it independently. The
one subtlety here is that ImageMagick has its &lt;em&gt;own&lt;/em&gt; internal multithreading, so if
you run N workers and let each one spin up a full thread pool, you oversubscribe the
CPU and everything gets slower. The fix is to divide ImageMagick’s thread budget
across the workers so the total stays sensible.&lt;/p&gt;
&lt;h2 id=&quot;fast-path-slow-path&quot;&gt;Fast path, slow path&lt;/h2&gt;
&lt;p&gt;There’s one more optimization worth explaining, because it’s a nice example of
letting the graph’s shape drive the strategy.&lt;/p&gt;
&lt;p&gt;Most of the time, the operations applied to every image are identical - the same
resize, the same adjustment, the same conversion. In that case the engine builds the
operation plan &lt;em&gt;once&lt;/em&gt; and reuses it verbatim for every image in the batch. That’s the
fast path, and it skips per-image parameter evaluation entirely.&lt;/p&gt;
&lt;p&gt;But some nodes read facts about the specific image they’re processing. The moment a
graph contains one of those Properties nodes, the shared plan can’t be reused,
because the plan genuinely differs per image. So the engine detects that case and
switches to a slow path where it re-evaluates per image.&lt;/p&gt;
&lt;p&gt;The interesting part is being precise about what “reading a fact about the image”
actually costs. Some facts - the filename, the path, the file size - come straight
from the filesystem and don’t require decoding the image at all. Others - width,
height, bit depth - need ImageMagick to actually open the pixels. Early on, the
file-size node was mistakenly flagged as needing full image metadata, which meant
every image in a batch spawned a pair of &lt;code&gt;magick identify&lt;/code&gt; processes just to answer a
question the operating system already knew. Fixing that one flag took a 3,720-image
batch from around two minutes down to about two-tenths of a second. The lesson
generalizes: the engine should only pay for the information a node truly needs, and a
surprising amount of what looks like image metadata is really just filesystem
metadata wearing a costume.&lt;/p&gt;
&lt;h2 id=&quot;import-performance&quot;&gt;Import performance&lt;/h2&gt;
&lt;p&gt;Loading a big folder into the filmstrip had the exact same spawn-cost problem -
generating a thumbnail and reading dimensions for every image, one &lt;code&gt;magick&lt;/code&gt; spawn at
a time, is painfully slow for a folder of any real size. The fix had a few parts.&lt;/p&gt;
&lt;p&gt;For the common formats - PNG, JPEG, BMP, WebP, TGA - the image dimensions can be read
straight out of the file header in a handful of bytes, with no process spawn at all.
That covers most of the texture formats that show up in a game dev folder.&lt;/p&gt;
&lt;p&gt;Thumbnails are generated in batches: a single &lt;code&gt;magick&lt;/code&gt; invocation produces thumbnails
for eight images at once, so the launch cost is paid off across the group instead of
paid per image. The thumbnails themselves are WebP, which keeps them small on disk
and in memory. Import also runs with concurrent workers, same idea as the batch
pipeline. And every generated thumbnail is cached to disk, keyed by the source path
and its modification time, so re-importing the same folder in a later session skips
the work entirely and loads straight from cache.&lt;/p&gt;
&lt;p&gt;Together these took importing ~2000 mixed PNG/TGA/JPG/PSD images from around 44
seconds down to about 3 seconds - roughly a 15× speedup. The difference between that
being an interruption and it being instant is the difference between a tool you reach
for and one you avoid.&lt;/p&gt;
&lt;h2 id=&quot;non-fatal-batch-errors&quot;&gt;Non-fatal batch errors&lt;/h2&gt;
&lt;p&gt;One deliberate design decision: errors during a batch are non-fatal. If ImageMagick
chokes on one image - a corrupt file, an unusual format edge case, a weird character
in a path - the batch keeps going. The failure is recorded and shown in a summary
dialog at the end, alongside the counts of images processed and skipped, and written
to a timestamped log next to the output so there’s a permanent record of exactly what
happened.&lt;/p&gt;
&lt;p&gt;This is just how you’d want a batch tool to behave. Halting a 2000-image run because
one file had a problem would be maddening. The summary gives you enough to go and
investigate the failures afterward without ever interrupting the work that succeeded.&lt;/p&gt;
&lt;p&gt;Next post in this series:
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-5&quot;&gt;Building imgplex: part 5 - Two graphs in one&lt;/a&gt;&lt;/p&gt;</content:encoded><category>imgplex</category><category>game-dev</category><category>tooling</category></item><item><title>Building imgplex: part 3</title><link>https://psmyles.com/blog/building-imgplex-part-3/</link><guid isPermaLink="true">https://psmyles.com/blog/building-imgplex-part-3/</guid><description>The node definition system</description><pubDate>Sun, 03 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;This is a series of posts on building imgplex, best read in order:&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-1&quot;&gt;Part 1 - The why, what, and how of imgplex&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-2&quot;&gt;Part 2 - Getting things up and running&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-3&quot;&gt;Part 3 - The node definition system&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-4&quot;&gt;Part 4 - Executing the node graph, making it fast&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-5&quot;&gt;Part 5 - Two graphs in one&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-6&quot;&gt;Part 6 - Multiple inputs and outputs, processing images as sets&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-7&quot;&gt;Part 7 - The small, measured optimizations beneath the big ones&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;At the end of &lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-2&quot;&gt;part 2&lt;/a&gt; I had an Electron window with a
working Svelte Flow canvas and a single node I could drag around - the stack was
wired, but nothing actually did anything yet. The obvious next question was how nodes
should work in the first place. imgplex is a node-based tool on top of ImageMagick,
so the node system is the heart of the whole thing, and the decision I made here
shaped everything that came after.&lt;/p&gt;
&lt;h2 id=&quot;the-node-definition-system&quot;&gt;The node definition system&lt;/h2&gt;
&lt;p&gt;One of the earliest architectural decisions was how to define nodes. The question
was: should nodes be hardcoded in TypeScript, or should they be described by data
that the app loads at runtime?&lt;/p&gt;
&lt;p&gt;The hardcoded approach is simpler to start with. But it means every new node requires
a code change, a recompile, and a new release. For a tool where the node library is
expected to grow - and where users might eventually define their own nodes - that’s a
significant constraint.&lt;/p&gt;
&lt;p&gt;The data-driven approach means each node is described by a JSON file that the app
loads at startup. Adding a new node is as simple as dropping a new file into the
&lt;code&gt;node-definitions/&lt;/code&gt; folder. No recompile required. In development, the registry even
hot-reloads when a file changes, so you can iterate on a node definition and see the
result in the running app immediately.&lt;/p&gt;
&lt;p&gt;If you’ve used Unity’s ScriptableObjects to define data-driven game content - enemy
stats, item definitions, ability configs - this is the same idea. The application
code defines how to interpret the data; the data files define what exists.&lt;/p&gt;
&lt;h2 id=&quot;what-a-node-definition-looks-like&quot;&gt;What a node definition looks like&lt;/h2&gt;
&lt;p&gt;Here’s a simplified example - the Brightness/Contrast node:&lt;/p&gt;
&lt;pre class=&quot;astro-code houston&quot; style=&quot;background-color:#17191e;color:#eef0f9;overflow-x:auto&quot; tabindex=&quot;0&quot; data-language=&quot;json&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;  &amp;quot;id&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;&amp;quot;brightness_contrast&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;  &amp;quot;version&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;&amp;quot;1.0.0&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;  &amp;quot;label&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;&amp;quot;Brightness / Contrast&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;  &amp;quot;description&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;&amp;quot;Adjust image brightness and contrast&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;  &amp;quot;aliases&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: [&lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;&amp;quot;exposure&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;, &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;&amp;quot;levels adjustment&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;  &amp;quot;category&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;&amp;quot;Color&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;  &amp;quot;inputs&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;    {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;      &amp;quot;type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;&amp;quot;image&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;      &amp;quot;label&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;&amp;quot;Input&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;  ],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;  &amp;quot;outputs&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;    {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;      &amp;quot;type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;&amp;quot;image&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;      &amp;quot;label&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;&amp;quot;Output&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;  ],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;  &amp;quot;params&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: [&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;    {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;      &amp;quot;name&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;&amp;quot;brightness&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;      &amp;quot;label&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;&amp;quot;Brightness&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;      &amp;quot;type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;&amp;quot;int&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;      &amp;quot;widget&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;&amp;quot;slider&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;      &amp;quot;default&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;      &amp;quot;min&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;-100&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;      &amp;quot;max&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;100&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;    },&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;    {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;      &amp;quot;name&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;&amp;quot;contrast&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;      &amp;quot;label&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;&amp;quot;Contrast&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;      &amp;quot;type&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;&amp;quot;int&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;      &amp;quot;widget&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;&amp;quot;slider&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;      &amp;quot;default&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;      &amp;quot;min&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;-100&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;      &amp;quot;max&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;100&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;  ],&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#4BF3C8&quot;&gt;  &amp;quot;command_template&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;: &lt;/span&gt;&lt;span style=&quot;color:#FFD493&quot;&gt;&amp;quot;-brightness-contrast {{brightness}}x{{contrast}}&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EEF0F9&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;command_template&lt;/code&gt; field is an ImageMagick command with parameter placeholders in
curly braces. At execution time the pipeline substitutes the actual parameter values
and passes the result to &lt;code&gt;magick&lt;/code&gt;. That’s the entire connection between a node and
the image processing backend for the majority of nodes.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;params&lt;/code&gt; array drives three things simultaneously: the widget rows shown in the
inspector panel, the input handles on the left side of the node card for wiring
values from other nodes, and the CLI export - the same parameter values get
substituted into the command when exporting a shell script.&lt;/p&gt;
&lt;h2 id=&quot;the-param-wire-system&quot;&gt;The param-wire system&lt;/h2&gt;
&lt;p&gt;Each parameter with a writable value exposes an input handle on the left side of the
node. That handle can accept a wire from any compatible output port elsewhere in the
graph.&lt;/p&gt;
&lt;p&gt;This is how pure-value nodes integrate with image processing nodes. A Float node
outputs a constant number. A Math node can add two floats together. The result can be
wired directly into the &lt;code&gt;brightness&lt;/code&gt; param of the Brightness/Contrast node. The
pipeline resolves the full value chain before executing any ImageMagick commands.&lt;/p&gt;
&lt;p&gt;The type system enforces compatibility at connection time - you can’t wire a color
value into a float input. Each type also has a distinct wire color, making it easy to
visually trace what’s flowing where. The color choices were all validated against
WCAG AAA contrast requirements on the dark node background, ensuring text is easily
readable at any point.&lt;/p&gt;
&lt;h2 id=&quot;the-node-registry&quot;&gt;The node registry&lt;/h2&gt;
&lt;p&gt;At startup the main process scans the &lt;code&gt;node-definitions/&lt;/code&gt; folder and loads every JSON
file into a registry. The renderer requests the full list via IPC and uses it to
populate the node library sidebar and to construct node cards in the graph editor.&lt;/p&gt;
&lt;p&gt;The registry watches the folder for changes. Edit a JSON file, save it, and the node
library updates in the running app without a restart. This made iterating on node
definitions very fast - the feedback loop for tweaking parameter ranges or adding a
new node was just a file save away. This also has been extended to
packaged/production builds, so users can add nodes without a restart in the shipped
app.&lt;/p&gt;
&lt;p&gt;The registry also validates definitions on load. Missing required fields, unknown
executor keys, or malformed parameter types are caught early and logged, rather than
surfacing as cryptic runtime errors later.&lt;/p&gt;
&lt;h2 id=&quot;what-this-enabled&quot;&gt;What this enabled&lt;/h2&gt;
&lt;p&gt;The payoff of this approach became clear as the node library grew. Adding the entire
Transform, Colors, and Filters categories was a matter of writing JSON files, not
TypeScript. The logic for rendering node cards, building the inspector, handling wire
connections, and generating CLI output was written once and works for every node
automatically.&lt;/p&gt;
&lt;p&gt;The categories that did require custom TypeScript are the ones with non-trivial
behavior: the Channel Split node forks the image stream into multiple outputs, the
Properties node reads per-image metadata, and so on. Everything else is just data.&lt;/p&gt;
&lt;p&gt;Currently there are over 60 nodes across 12 categories, and the vast majority of them
are pure JSON with a &lt;code&gt;command_template&lt;/code&gt;. That ratio is the whole point of the system.&lt;/p&gt;
&lt;p&gt;The file export formats are defined the same way - each format is just a new JSON
file in the &lt;code&gt;format-definitions/&lt;/code&gt; folder.&lt;/p&gt;
&lt;p&gt;Next post in this series:
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-4&quot;&gt;Building imgplex: part 4 - Executing node graph, making it fast&lt;/a&gt;&lt;/p&gt;</content:encoded><category>game-dev</category><category>imgplex</category><category>tooling</category></item><item><title>Building imgplex: part 2</title><link>https://psmyles.com/blog/building-imgplex-part-2/</link><guid isPermaLink="true">https://psmyles.com/blog/building-imgplex-part-2/</guid><description>Getting things up and running</description><pubDate>Sat, 04 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;This is a series of posts on building imgplex, best read in order:&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-1&quot;&gt;Part 1 - The why, what, and how of imgplex&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-2&quot;&gt;Part 2 - Getting things up and running&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-3&quot;&gt;Part 3 - The node definition system&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-4&quot;&gt;Part 4 - Executing the node graph, making it fast&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-5&quot;&gt;Part 5 - Two graphs in one&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-6&quot;&gt;Part 6 - Multiple inputs and outputs, processing images as sets&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-7&quot;&gt;Part 7 - The small, measured optimizations beneath the big ones&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In &lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-1&quot;&gt;part 1&lt;/a&gt; I covered why I’m building imgplex - a
node-based batch image tool on top of ImageMagick - and how I landed on a stack of
Electron, Vite, Svelte 5, and Svelte Flow. This post is where the plan meets reality:
getting all four of those to actually run together, and setting up an architecture I
wouldn’t have to fight later.&lt;/p&gt;
&lt;h2 id=&quot;setting-up-a-web-dev-environment&quot;&gt;Setting up a web dev environment&lt;/h2&gt;
&lt;p&gt;With the tech stack decided, the first practical challenge was getting Electron,
Vite, Svelte 5, and Svelte Flow all working together in a single project. Each of
these has opinions about how they want to be configured, and they don’t always agree
with each other out of the box.&lt;/p&gt;
&lt;p&gt;This is where Claude came in handy - figuring out all the dependencies and setting up
a development environment. The starting point was &lt;code&gt;electron-vite&lt;/code&gt; - a scaffolding
tool that pre-wires the project structure and build pipeline. Vite here is
essentially the build system and dev server, similar to how Unity handles compilation
and hot-reloading in the editor. &lt;code&gt;electron-vite&lt;/code&gt; extends it to handle Electron’s
requirements: the main process, the preload script, and the renderer all need to be
compiled separately, and &lt;code&gt;electron-vite&lt;/code&gt; sets all of that up so you can focus on
building the actual app.&lt;/p&gt;
&lt;p&gt;The available templates didn’t include Svelte, so I went with the Vanilla template
and added Svelte manually. This was the right call anyway - there was nothing to rip
out, just things to add.&lt;/p&gt;
&lt;p&gt;The first dependency conflict came up immediately: the scaffold shipped with Vite 5,
but the latest Svelte plugin requires Vite 6+. The fix was pinning to an older
version of the plugin that supports Vite 5. Minor, but a preview of the kind of
version-negotiation that comes with assembling a stack from several fast-moving
libraries. Not unlike trying to get a specific version of a Unity package to work
with a specific editor version.&lt;/p&gt;
&lt;h2 id=&quot;the-three-process-mental-model&quot;&gt;The three-process mental model&lt;/h2&gt;
&lt;p&gt;This is the thing most worth understanding early. Electron applications have two
completely separate JavaScript environments that cannot share memory or imports
directly - and getting this wrong wastes a lot of debugging time.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;main process&lt;/strong&gt; is essentially a Node.js application running on your machine. It
can access the filesystem, spawn processes, and use any native library. Think of it
as the backend, or the editor scripts side of things.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;renderer process&lt;/strong&gt; is basically a Chrome browser tab. It renders the UI. It has
no access to the filesystem or native APIs by default. Think of it as the runtime
game side - it only knows what you explicitly give it.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;preload script&lt;/strong&gt; is a small bridge between the two. It runs in a special
privileged context that has access to both environments, and its only job is to
expose a controlled, safe API from the main process to the renderer. In Electron
terms this is called the &lt;code&gt;contextBridge&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;In practice: whenever the UI needs to do something that touches the filesystem or
spawns an ImageMagick process, it calls through IPC (inter-process communication) to
the main process, which does the actual work and sends the result back. The renderer
never touches files directly.&lt;/p&gt;
&lt;p&gt;This is actually a clean architectural separation once you internalize it. The UI
just asks for things, the backend does them. It maps reasonably well to how you’d
separate gameplay logic from engine systems in a well-structured game codebase.&lt;/p&gt;
&lt;h2 id=&quot;folder-structure&quot;&gt;Folder structure&lt;/h2&gt;
&lt;p&gt;Rather than letting the scaffold dictate the structure, I set up the folder layout
from the spec upfront:&lt;/p&gt;
&lt;pre class=&quot;astro-code houston&quot; style=&quot;background-color:#17191e;color:#eef0f9;overflow-x:auto&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;electron/          - main process and preload script&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;src/shared/        - types and constants shared by both processes&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;src/main/          - Node.js business logic (pipeline, registry, IPC handlers)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;src/renderer/      - Svelte application (all the UI)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;node-definitions/  - JSON node descriptor files (loaded at runtime)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;src/shared/&lt;/code&gt; folder is particularly important. Any type that crosses the IPC
boundary - node definitions, graph state, pipeline progress events - is defined there
so both sides of the app stay in sync. TypeScript catches mismatches at compile time,
which saves a lot of runtime debugging.&lt;/p&gt;
&lt;h2 id=&quot;first-working-state&quot;&gt;First working state&lt;/h2&gt;
&lt;p&gt;The milestone for this phase was an Electron window showing a working Svelte Flow
canvas with a node I could drag around. Nothing processed images yet - the goal was
just confirming the full stack was correctly wired and hot-reload was working across
all three bundles.&lt;/p&gt;
&lt;p&gt;Once that was running, the foundation was solid enough to start building real
features on top of.&lt;/p&gt;
&lt;p&gt;Next post in this series:
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-3&quot;&gt;Building imgplex: part 3 - The node definition system&lt;/a&gt;&lt;/p&gt;</content:encoded><category>imgplex</category><category>tooling</category><category>game-dev</category></item><item><title>Building imgplex: part 1</title><link>https://psmyles.com/blog/building-imgplex-part-1/</link><guid isPermaLink="true">https://psmyles.com/blog/building-imgplex-part-1/</guid><description>The why, what, and how of imgplex</description><pubDate>Fri, 20 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;This is a series of posts on building imgplex, best read in order:&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-1&quot;&gt;Part 1 - The why, what, and how of imgplex&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-2&quot;&gt;Part 2 - Getting things up and running&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-3&quot;&gt;Part 3 - The node definition system&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-4&quot;&gt;Part 4 - Executing the node graph, making it fast&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-5&quot;&gt;Part 5 - Two graphs in one&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-6&quot;&gt;Part 6 - Multiple inputs and outputs, processing images as sets&lt;/a&gt;&lt;br/&gt;
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-7&quot;&gt;Part 7 - The small, measured optimizations beneath the big ones&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/psmyles/imgplex&quot;&gt;imgplex&lt;/a&gt; is a node-based batch image workflow
creator and processor. All the processing is handled by ImageMagick, imgplex just
makes the string of commands needed to pass onto ImageMagick.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Disclaimer: imgplex has been developed with help from AI tools. But this isn’t a
‘vibe coded’ project - AI didn’t write all of the code, and a lot of thought,
research, and planning went into development of this application to keep the
development properly planned and organized. A lot of effort has also gone into
keeping the UX and batch processing performance optimal. This series of blog posts
will describe the development process of imgplex.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;the-why&quot;&gt;The why&lt;/h2&gt;
&lt;p&gt;In game development we do a lot of texture processing as part of the content
pipeline, whether it is creating textures or processing textures from asset packs. As
a technical artist I’ve made a bunch of tools of various sorts to automate the
process as much as possible, but the use cases are far too different to be covered
effectively by a few tools.&lt;/p&gt;
&lt;p&gt;While with the advent of AI tools it has become easier to make purpose built tools to
solve a specific problem, it rapidly becomes a jumble of various tools that do just
one job, are scattered, and not documented.&lt;/p&gt;
&lt;p&gt;I’ve used &lt;a href=&quot;https://imagemagick.org&quot;&gt;ImageMagick&lt;/a&gt; as a solution for some of these
problems. It is &lt;a href=&quot;https://imagemagick.org/command-line-options/&quot;&gt;incredibly powerful&lt;/a&gt;,
and you can pipe commands to make complex operations happen in one go. But it has the
limitations of being a command line tool: scary for non-technical people, and no
preview stage before processing. A lot of image processing we do is visual in nature,
and command line tools don’t give interactive preview controls that artists love to
use. I also want something artists can use independently, without needing a technical
person or an AI tool.&lt;/p&gt;
&lt;h2 id=&quot;the-what&quot;&gt;The what&lt;/h2&gt;
&lt;p&gt;I decided to build the tool on top of ImageMagick since it can do literally
everything we need in terms of image processing. The idea was to use a node based
editor to generate ImageMagick commands. Node based editors are common in game
development workflows, we have node based editors for shaders, VFX, and procedural
content that artists are already comfortable with (Shader and VFX graph, Houdini,
Substance Designer, Blender’s geometry nodes etc).&lt;/p&gt;
&lt;p&gt;I also happen to have a lot of experience with image editing and node based tools as
a result of my game development career, and it seemed like a natural fit.&lt;/p&gt;
&lt;p&gt;A node based image processing workflow editor would be easy to extend as well: a node
is just a representation of the ImageMagick command. I also decided that the node
definitions should be kept as JSON files, so that users can add nodes without the
application needing a recompile.&lt;/p&gt;
&lt;p&gt;There’s another advantage too to this approach, since the tool is used to define just
the workflow and not handle image processing, I can run the processing headlessly in
automated workflows: just export the commands from the workflow to a script.&lt;/p&gt;
&lt;h2 id=&quot;the-how&quot;&gt;The how&lt;/h2&gt;
&lt;p&gt;With the plan decided, I started research on the tech stack to make the tool. The
core tension was native performance vs development speed: I wanted to build the tool,
not fight the tooling.&lt;/p&gt;
&lt;p&gt;I evaluated several approaches:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;C++ with Qt was the obvious professional choice: great node editor libraries,
excellent performance. But learning a new language and framework simultaneously
(I’m experienced with C# and Python, but not much with C++) alongside a complex
project was a recipe for never shipping anything.&lt;/li&gt;
&lt;li&gt;Tauri looked promising: lightweight, Rust backend, web frontend. But two concrete
blockers killed it: an IPC bottleneck where serialising image data as strings
benchmarked at ~200ms per 3MB; and the sidecar lifecycle complexity of managing a
separate processing process.&lt;/li&gt;
&lt;li&gt;I gave serious thought to using Godot: GPU shaders for preview would be great. It
was rejected because desktop UI infrastructure (inputs, dropdowns, file dialogs)
would take weeks to build.&lt;/li&gt;
&lt;li&gt;Electron won. It does get criticism for bundle size and memory overhead, but for a
professional tool that will handle gigabytes of image data, a 200MB install and
some extra RAM usage are irrelevant. What matters is: consistent rendering across
platforms, Node.js built in (no sidecar needed), and a mature ecosystem. I could
get something working really quickly and iterate from there.&lt;/li&gt;
&lt;li&gt;For the node graph editor, Svelte Flow beat out LiteGraph.js - the library used by
ComfyUI - because LiteGraph’s original repo hasn’t been maintained in years and
ComfyUI operates off a divergent fork. Svelte Flow is MIT-licensed, actively
maintained, and integrates natively with the UI framework.&lt;/li&gt;
&lt;li&gt;For the UI framework, I chose Svelte 5 over React: less boilerplate, no virtual
DOM, and native integration with Svelte Flow’s &lt;code&gt;$state.raw&lt;/code&gt; requirement for graph
state.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Next post in this series:
&lt;a href=&quot;https://psmyles.com/blog/building-imgplex-part-2&quot;&gt;Building imgplex: part 2 - Getting things up and running&lt;/a&gt;&lt;/p&gt;</content:encoded><category>game-dev</category><category>imgplex</category><category>tooling</category></item></channel></rss>