# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Build and test ```bash 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 core 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`, `video`, `version`) and the `ok`/`FAIL` lines. The `video` section writes real files through `ffmpeg` when it is on the PATH and prints a `skip` line otherwise. 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. The interface has its own binary, `./build/tests/effector_gui_tests` (`tests/gui_tests.cpp`, same `check()` style), which opens the real window and drives it by widget names and tooltips (`chain-list`, `add-effect`, `sources`, "Move up", ...). It needs a display: ctest runs it under `xvfb-run` when that is installed and otherwise reports it skipped (exit 77). To run it by hand, use the Xvfb invocation below. Headless render (no display needed, the fastest end-to-end smoke test): ```bash build/effector -o /tmp/out.png -s 800x600 --strip-height 6 --seed 1 a.jpg b.png ``` ### Versions and the changelog The version is the `project()` line of `CMakeLists.txt`, and nowhere else. CMake writes it, together with the text of `CHANGELOG.md`, into a generated `core/version.hpp` (`kVersion`, `kChangelog`, from `src/core/version.hpp.in`) in the build tree. `--version` prints it, and the header-bar menu shows it under About Effector and the changelog under Changelog. The `version` core test fails unless the newest `## [X.Y.Z]` entry in `CHANGELOG.md` is the current version, so a bump and its changelog entry go in together, and it also fails unless `pkgver` in `packaging/arch/PKGBUILD` matches (that PKGBUILD builds the `vX.Y.Z` tag, so reset `pkgrel=1` on a bump). Each release is an annotated `vX.Y.Z` tag on the commit that made it. When you add a user-visible change, add a line to the newest changelog entry, or start a new entry if that one is already tagged. The Changelog window reads only headings, `- ` bullets (wrapped onto indented lines) and `` `code` ``, so keep the file to those. ### 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: ```bash 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` (`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>`), read back with `paramInt`/`paramDouble`/`paramBool`/`paramChoice`. A param can also be marked `.styled()`, which says a *named style* carries it rather than the stage it sits on: the font and the colour of a text stage, but never its words or its placement. The pool a style comes from is named by `Effect::styleKind()` (empty by default — the effect takes no style; `"text"` for the text effect), and effects answering the same kind share one pool, so a second text-ish effect would slot into the styles that already exist. The front-ends know nothing more than that: the interface builds the style row and the project file writes a style's params from `styleSpecs(kind)` (`src/core/style.cpp`), which is to a style exactly what `params()` is to a stage. 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/.{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` 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. ### A style is a look several stages share `ChainStage::style` names a style, and `stageValues` (`src/core/chain.cpp`) is the one place that resolves one: an effect's defaults, then the stage's own values, then the style's values for the params it carries. A stage always keeps a full set of values of its own beside the style, so switching it to "Custom", dropping the style, or opening the project in a build that does not know the style renders exactly the same picture — a style moves stages together, it never holds the only copy of anything. The interface writes an edited param to both the style and the stage for that reason. `renderChain` takes the pool as a trailing argument; the command line builds no styles and passes none. The interface builds the chain in the sidebar ("Effect chain": add from the menu, reorder by dragging a row or with its arrows, 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 named styles, the chain with its params (and its `"enabled": false` and `"style"`, each written only when there is something to say), canvas and seed — as a small JSON file, with its own minimal reader rather than a JSON dependency. Writing is driven by the effects' `ParamSpec`s, 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. The same leniency covers styles: one of a kind this build does not know, one with no name or a repeated one, and a stage naming a style the file does not hold are each dropped on their own rather than refusing the project. 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. Open Recent lists the projects opened or saved here, kept in GTK's shared `RecentManager` list and told apart from other programs' files by the `effector` application name on each entry; the GUI tests point `XDG_DATA_HOME` at a temporary folder so they never touch the user's list. Opening one replaces the sources, so a decode still in flight is discarded rather than mixed into the new project (`dropLoadedImages_`). ### Video projects A project is a still or a clip: `VideoSettings` (`src/core/video.hpp`: enabled, duration, fps, preset) rides in `Project::video` and `Options::video`, and is written to the project file only when enabled. `videoPresets()` is one table like `canvasFormats()` — a preset names a canvas format plus a length and rate, and like a format it is the presentation, never the truth: the render goes by duration and fps. Adding a preset is a line there. Time reaches an effect as a `FrameTime` (`src/core/effect.hpp`; the default, `fps == 0`, is a still) through `Effect::renderFrame`, which falls back to `render` — an effect that does not move overrides nothing. One that moves overrides `renderFrame` and `animated(values)`, and marks its motion params `.forVideo()`: the interface hides those in a still project (declaratively, like `.styled()`), the CLI still has their flags. A still must render byte for byte what it did before the effect learned to move. `renderFrames` (`src/core/chain.cpp`) renders a clip. Every frame starts from `Rng(seed)`, so a stage that does not move draws the same values every frame and holds still. A moving effect must therefore leave the shared rng in the same state on every frame — the channel split draws its per-frame shake from a generator of its own, seeded by one draw — or everything after it would flicker. The stages before the first `animated()` one are rendered once and their image and rng state reused; the `video` tests assert that frame k is exactly `renderChain(..., FrameTime{k, ...})`, so that shortcut can never change output. The determinism contract covers the frames; the encoded file is whatever the machine's ffmpeg makes of them. `VideoWriter` pipes raw `rgb24` frames into the `ffmpeg` program (`posix_spawnp` with an argv — no shell; `SIGPIPE` ignored; stderr captured for the error message; codec by extension). `cancel()` SIGKILLs it — SIGTERM makes ffmpeg flush its encoder first — and removes the file; a writer destroyed unfinished cancels itself. In the interface, a video render (`startRender`) renders every frame at full size, shrinks each (`Image::scaledToFit`, sized by `clipSide` to a memory budget) and hands them over through `pendingFrames_`/`clipProgress_`; the clip on screen keeps looping until the new one is complete, and `renderCancel_` stops a clip whose settings went stale. Save As… renders again at full size on `exporter_` into a `VideoWriter`, with an `exportWindow_` progress window (its `exportBar_` nulled in `closeExportWindow()`). Under Xvfb the GTK file dialog goes through the desktop portal and may open on the real session instead; set `GDK_DEBUG=no-portals` to keep it in the Xvfb display. ### Two link targets, deliberately `effector_core` (static) holds image IO, params, effects, rng, video writing and links only `gdk-pixbuf-2.0`, `pangocairo` (the text effect's glyphs; Pango and Cairo need no display) + Threads — **no gtkmm, no display**; `ffmpeg` is a program it runs, not a library it links. `effector_gui` (static) adds `src/cli/` and `src/gui/` and links gtkmm, and the `effector` executable is `src/main.cpp` on top of it; the GUI tests link `effector_gui` too. 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 ``, 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` 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 `LoadFailure`s instead. ## Reference `python-reference/` is the original Python prototype, kept only for comparison. Do not modify it or import from it.