Files
effector/CLAUDE.md
T
SiandClaude Opus 5 d48a81c665 Offer standard web and print canvas formats
The canvas was two spin buttons, so "an Instagram story" or "A4 at 300 dpi"
was a number the user had to work out. src/core/canvas.cpp now holds one
table of standard sizes both front-ends read: a web format is a fixed pixel
size, a print format 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 the 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, and where one is named it
decides the pixels — so a project saved as A4 reopens as A4 rather than as
whatever it happened to measure.

In the interface a format fills the width and height in and greys them out
rather than hiding them: the size a sheet works out to is worth seeing. A
print format also shows a DPI row, and Custom hands the spins back keeping
the last size, which makes starting from a format and then tweaking it the
ordinary path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:34:17 +02:00

8.1 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.

The interface builds the chain in the sidebar ("Effect chain": add from the menu, reorder, remove, 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. 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, 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_).

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 use parallelFor: files vary wildly in size, so workers pull the next index from a shared std::atomic cursor. It reports through LoadProgress::onFile (called from decoding threads: must be thread-safe and non-blocking) and aborts when LoadProgress::cancel is 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::thread and signal back with a Glib::Dispatcher (loadProgress_/loadFinished_/renderDone_ in src/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, so loadBar_/loadFileLabel_ are raw pointers that must be nulled in closeLoadWindow().
  • Sources are held as an immutable shared_ptr<const SourceList> and edited copy-on-write via mutateSources(), 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.