Trying a stage out meant removing it and building it again from scratch, settings and all, to see what the picture looked like without it. A stage now carries an `enabled` flag and can simply be switched off: it keeps its place in the chain and everything it was set to. `enabledStages` is the one place that reads the flag, and renderChain, chainNeedsSources, chainTitle and chainSlug all go through it. A disabled stage is skipped whole — handed no image and drawing nothing from the shared rng — so switching one off renders byte for byte what the chain rendered before that stage was added, which is the only thing that makes the switch worth anything while comparing. Whichever stage runs first is the one handed the sources, so disabling a leading scanlines lets blur lead and the chain needs no images at all. The status line and the generated file name follow too: they name what was rendered, not what sits in the sidebar. A chain with every stage disabled throws rather than quietly rendering nothing. In the interface it is a check on the chain row: the row dims, the params heading says "(off)" and the settings stay editable — a stage being worked on is not the same as one being deleted. On the command line it is `--disable NAME`, which picks its stage by effect name exactly as the params do. A project writes `"enabled": false` only for a stage that is off, so existing project files are unchanged and an ordinary one stays uncluttered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8.8 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Build and test
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
ctest --test-dir build --output-on-failure
Everything is warning-clean under -Wall -Wextra -Wpedantic; keep it that way.
The test suite is a single hand-rolled binary (no framework), so there is no
per-test filter: run ./build/tests/effector_tests directly to see the section
headings (rng, registry, scanlines, tiles, blur, chain, project,
image io, batch loading) and the ok/FAIL lines. To iterate on one area, comment
out the other blocks in tests/core_tests.cpp or add a new
std::cout << "section\n" block — checks are plain
check(condition, "description") calls and main() returns the failure count.
Headless render (no display needed, the fastest end-to-end smoke test):
build/effector -o /tmp/out.png -s 800x600 --strip-height 6 --seed 1 a.jpg b.png
Running the GUI from this shell
GTK picks Wayland from an inherited WAYLAND_DISPLAY and will not draw into an
Xvfb display, which silently yields black screenshots. Under Xvfb always use:
env -u WAYLAND_DISPLAY GDK_BACKEND=x11 DISPLAY=:79 build/effector ...
Architecture
The plug-in contract is declarative
Effect (src/core/effect.hpp) exposes params() returning std::vector<ParamSpec>
(src/core/params.hpp). Those specs are the only thing the GUI and CLI know
about an effect: src/gui/window.cpp builds SpinButtons/Switches/DropDowns/file
choosers from them and src/cli/options.cpp generates flags from them
(strip_height → --strip-height). Never add effect-specific branches to the
GUI or the parser — add a ParamSpec instead. A Path param names a file on
disk; like a source path it is written relative to the project folder. A Text
param is free-form text the user types, and a Color param is a colour written
#rrggbb (parseColor/formatColor/paramColor in src/core/params.hpp),
shown as a colour button in the interface. Values
travel as ParamValues (a
map<string, variant<long,double,bool,string>>), read back with
paramInt/paramDouble/paramBool/paramChoice.
Whether an effect works from the loaded photographs is part of the same
contract: Effect::needsSources() (default true, false for blur). Only the
first stage ever sees the sources, so chainNeedsSources (src/core/chain.cpp)
just asks the first stage — a chain led by blur renders with no images loaded at
all, and both front-ends gate on that rather than on "are there any images".
Adding an effect: new src/effects/<name>.{hpp,cpp} subclassing Effect, one
line in the registry list in src/core/registry.cpp, one line in the
effector_core sources in CMakeLists.txt. Nothing else — it shows up in the
CLI, in the chain menu and as a cascade stage on its own.
Effects cascade
Effects are never applied on their own: renderChain (src/core/chain.cpp)
runs a std::vector<ChainStage> in order, handing stage 0 the loaded sources
and every later stage the previous stage's output as its only source. That is
the whole of the cascade — an effect neither knows nor cares whether its input
came from disk. All stages share one Rng and run strictly in sequence, so a
seed still reproduces the cascade byte for byte.
A stage carries a ChainStage::enabled flag, and enabledStages
(src/core/chain.cpp) is the one place that reads it: a disabled stage is
skipped whole — handed no image and drawing nothing from the shared rng — so
switching one off renders exactly what the chain rendered before it was added.
renderChain, chainNeedsSources, chainTitle and chainSlug all go through
it, which is why the status line and the file name never mention a stage that
did not run. A chain with every stage disabled throws rather than rendering
nothing.
The interface builds the chain in the sidebar ("Effect chain": add from the
menu, reorder, remove, switch off with the check on the row, one stage selected
at a time with its params below), the CLI builds it from repeated -e:
-e scanlines --strip-height 6 -e blur --radius 5, with --disable NAME
leaving a stage in place but skipping it. Flags share one namespace across all effects, so buildChain
(src/cli/options.cpp) hands each flag to the stage whose effect declares it —
two effects must not declare the same param key.
The canvas is a format or plain pixels
src/core/canvas.cpp holds the one table of standard sizes both front-ends
offer: web formats are a fixed pixel size, print formats a sheet in millimetres
that becomes pixels at a dpi. Adding a format is a line in canvasFormats() —
the dropdown, --format and --help all walk that table. The pixels stay the
truth everywhere (renderChain never hears about formats); a format is the
presentation of a canvas, carried alongside the pixels in Options and
Project and empty for a custom size. Where a format is named it decides the
pixels, so a project saved as A4 reopens as A4.
Projects are plain JSON
src/core/project.cpp reads and writes the whole setup — source paths, the
chain with its params (and its "enabled": false, written only for a stage
that is switched off), canvas and seed — as a small JSON file, with its own
minimal reader rather than a JSON dependency. Writing is driven by the effects'
ParamSpecs, not by whatever happens to sit in a stage's ParamValues, so a
key an effect does not declare is never written and never read back: an old
project keeps the defaults for params that did not exist when it was saved.
Source paths under the project's own folder are written relative, so a folder
can be moved or shared whole.
The interface opens and saves projects from the header-bar menu (New, Open,
Save, Save As, with the usual Ctrl+N/O/S accelerators); the window title shows
the project name. Opening one replaces the sources, so a decode still in flight
is discarded rather than mixed into the new project (dropLoadedImages_).
Two link targets, deliberately
effector_core (static) holds image IO, params, effects, rng and links only
gdk-pixbuf-2.0, pangocairo (the text effect's glyphs; Pango and Cairo need
no display) + Threads — no gtkmm, no display. The effector executable
adds src/main.cpp, src/cli/, src/gui/ and links gtkmm. Keep GTK out of
src/core/ and src/effects/; the tests link the core alone and rely on this.
Determinism is a contract
Rng (src/core/rng.hpp) is a hand-written xoshiro256** rather than
<random>, because libstdc++ distributions are not portable across
implementations. Same sources + settings + seed must produce byte-identical
output on any machine — the tests assert this. The one thing outside this is the
text effect's glyphs, which are whatever shapes the machine's fonts and
fontconfig give: everything it is handed, the rng included, is still used the
same way. Effects must draw every random
value from the Rng& they are handed, and must not depend on thread scheduling:
the scanlines effect first draws the whole strip plan serially from the rng,
then copies pixels in parallel.
Threading model
parallelFor(src/core/parallel.hpp) splits fixed contiguous chunks across hardware threads — used for pixel work, where items cost the same.loadImages(src/core/image.cpp) deliberately does not useparallelFor: files vary wildly in size, so workers pull the next index from a sharedstd::atomiccursor. It reports throughLoadProgress::onFile(called from decoding threads: must be thread-safe and non-blocking) and aborts whenLoadProgress::cancelis set, in which case the partial result is discarded by the caller, not used.- The GUI never blocks the main thread. Both decoding and rendering run on a
std::threadand signal back with aGlib::Dispatcher(loadProgress_/loadFinished_/renderDone_insrc/gui/window.hpp); the handlers are the only code that touches widgets. Renders are coalesced through a debounce timer (requestRender()→startRender()), and the progress window is created on a 250 ms delay so short batches never flash a dialog.loadWindow_is rebuilt from scratch each time, soloadBar_/loadFileLabel_are raw pointers that must be nulled incloseLoadWindow(). - Sources are held as an immutable
shared_ptr<const SourceList>and edited copy-on-write viamutateSources(), so a render in flight can never race with an add/remove.
Image buffer
Image is tightly packed 8-bit RGB with stride exactly width * 3 — effects
rely on this for memcpy, and GDK uploads the buffer as is. Decoding applies
EXIF orientation. Image::load throws; loadImages collects per-file
LoadFailures instead.
Reference
python-reference/ is the original Python prototype, kept only for comparison.
Do not modify it or import from it.