Files
effector/CLAUDE.md
T
SiandClaude Opus 5.5 1b30a079e6 Add Tile swap, a video effect built from the source images
Tile swap lays tiles cut from the photographs over the canvas as Tiles
does, in a grid, in bricks or scattered, and swaps each one for another
cut at a moment of its own, so the mosaic keeps changing piece by piece.
The layout stays put for the whole clip; what a tile shows depends only
on the tile and how many times it has swapped, counted round the clip, so
it holds still between swaps and the clip loops without a jump. On the
command line it is -e tileswap with --tileswap-rate and the rest.

VideoEffect learns a second kind: an effect that answers needsSources()
writes compose() instead of animate(), and the base then takes no crop,
only the one seed, so it sees the photographs when it leads the chain and
the stage before it otherwise. Transforms render exactly as before. The
tile cutting and painting move out of tiles.cpp into tile_plan.hpp for
both effects to share; Tiles renders byte for byte as before. Version
0.8.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 19:22:26 +02:00

316 lines
18 KiB
Markdown

# 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<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`.
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'
`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.
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.
### 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
`<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
`LoadFailure`s instead.
## Reference
`python-reference/` is the original Python prototype, kept only for comparison.
Do not modify it or import from it.