A new Font param type (ParamSpec::makeFont) is a font family as the machine names it. The interface shows it as a family-only FontDialogButton; the command line and the project file treat it as a plain string, as they did the Text param it replaces, so existing projects and --font-family keep working. A family the machine lacks is kept as written. Bumps the version to 0.8.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
318 lines
18 KiB
Markdown
318 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. 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`.
|
|
|
|
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.
|