Files
effector/CLAUDE.md
T
simonandClaude Opus 5.5 ef8f728126 Show Tile swap's Swap rate as a slider, and let it go slower
A new ParamSpec::slider() marks an Int or Double param to be shown in
the interface as a Gtk::Scale with its minimum and maximum marked under
the ends, rather than a spin button; the command line and the project
file are unchanged. Swap rate uses it, and now runs from 0 to 5 in
steps of 0.01 (was 0 to 30 in steps of 0.1): with every tile swapping
on its own, even 0.1 swapped the canvas many times a second.

Version 0.8.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:44:27 +02:00

19 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 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):

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:

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. A Font param is a font family named as the machine names it ("DejaVu Serif"), a family-only font chooser in the interface and a plain string everywhere else. Values travel as ParamValues (a map<string, variant<long,double,bool,string>>), read back with paramInt/paramDouble/paramBool/paramChoice.

An Int or Double param marked .slider() is shown in the interface as a slider with its minimum and maximum marked instead of a spin button; nothing else changes.

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/<name>.{hpp,cpp} subclassing Effect (or VideoEffect for one that moves, see "Video projects"), 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.

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' 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. 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_).

Undo and redo (Ctrl+Z, Ctrl+Y / Ctrl+Shift+Z, caught in the capture phase so an entry never undoes its own text instead) keep whole snapshots of the project: captureState is currentProject() plus the sources_ list itself, so stepping back never decodes again, compared by projectToJson. There is nothing to call per edit: requestRender() calls noteChange(), which commits a step once the controls have been still for kHistoryPause; an edit that renders nothing (renaming a style) calls noteChange() itself. New and Open start the history over, after the project's images are in (historyAfterLoad_).

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.

Effects are of two kinds, and Effect::video() says which. An image effect (the default) makes a picture and holds still in a clip; it overrides only render, which Effect::renderFrame falls back to. A video effect is motion and nothing else. enabledStages(stages, video) is where the kinds meet the chain: in a still, a video stage is left out exactly like a disabled one, drawing nothing from the rng. So renderChain (by frame.video()), chainNeedsSources, chainTitle and chainSlug all take whether the render is a video. The interface builds the split from video() alone: two sections in the Add effect menu, with the video section insensitive in a still, a camera-video-symbolic icon on a video stage's row, and the row dimmed while a still skips it. The CLI groups --help the same way and warns when it skips one.

A video effect subclasses VideoEffect (src/effects/video_effect.hpp), not Effect, and a transform writes only animate(input, values, const Moment& at). The base keeps the rules. It takes the input crop and exactly one value from the shared rng, the same on every frame, and hands out randomness through the Moment: at.steady() gives the same draws every frame (phases, directions), at.fresh() gives new draws per frame (shake, noise). It hands out time as at.cycle(perSecond), rounded through loopRate to a whole number of cycles so the clip loops without a jump. TrackingEffect (src/effects/tracking.cpp, "VHS tracking") is the reference: copy its layout (params, a Settings struct, steady draws, time, fresh draws, one parallel pixel pass) for the next one. A video effect that builds its frames out of the sources rather than transforming a crop answers needsSources() and writes compose(sources, width, height, values, at) instead of animate: the base then takes no crop, only the seed, and like an image effect it sees the photographs when it leads the chain and the stage before it otherwise. TileSwapEffect (src/effects/tileswap.cpp, "Tile swap") is the reference for that kind; it shares Tile/planSpans/paintTiles with the tiles effect through src/effects/tile_plan.hpp. Registering it is the same one line in registry.cpp, after the comment that starts the video effects. There is no per-param video flag: motion is a video effect of its own, never extra params on an image effect.

renderFrames (src/core/chain.cpp) renders a clip. Every frame starts from Rng(seed), so an image effect draws the same values every frame and holds still, and because VideoEffect leaves the shared rng in one state, the stages after a video effect hold still too. The stages before the first video effect 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.

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

Memory is checked before it is taken

Running out of memory does not fail cleanly on Linux: the kernel's OOM killer ends the program (and often the terminal or session it runs in), leaving no core dump. So bulk allocations are sized first and refused with an OutOfMemoryError (src/core/memory.hpp) when they would take more than three quarters of availableMemory() — the system's MemAvailable, or less under a cgroup memory.max. loadImages sizes the whole batch from the file headers (imageFilePixels) and refuses it before decoding anything, and holds the decodes it runs at once to what is left; renderChain and renderFrames refuse a canvas at 24 bytes a pixel (13 measured, plus the interface's copy), plus any picture a Path param loads. The interface keeps a refused batch's paths in refusedSources_, so saving the project never drops them; the tests fake a small machine with limitAvailableMemory.

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.