# Effector An image effects app: a GTK4 interface and a headless CLI over a set of pluggable effects. Effects are self-contained classes that declare their own parameters; the interface builds its widgets and the CLI builds its flags from those declarations, so a new effect needs no UI or CLI code. ## Build Requirements: a C++20 compiler (GCC 13+), CMake 3.20+, `gtkmm-4.0` and `gdk-pixbuf-2.0` development packages. On Arch: `gcc cmake ninja gtkmm-4.0`. Writing videos needs the `ffmpeg` program at run time (on Arch: `ffmpeg`); without it everything else works and the video tests are skipped. ```bash cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release cmake --build build ctest --test-dir build --output-on-failure ``` The binary lands at `build/effector`; `cmake --install build --prefix ~/.local` installs it to `~/.local/bin`. On Arch, `packaging/arch/PKGBUILD` builds the release tagged `v` from this repository and installs it with pacman, desktop entry included: ```bash cd packaging/arch && makepkg -si ``` ## Run ```bash build/effector # GTK interface build/effector --list-effects build/effector --help build/effector --version ``` Headless rendering, no display needed: ```bash build/effector -o out.png -s 1920x1080 \ --strip-height 8 --jitter 4 --seed 42 photo1.jpg photo2.png ``` The canvas is either a size in pixels (`-s 1920x1080`, the Custom entry in the interface) or one of the standard formats, which `--help` lists in full: ```bash build/effector -o post.png --format instagram-post -e tiles build/effector -o sheet.png --format a4 --dpi 300 -e scanlines ``` A web format — the Instagram sizes, Full HD, 4K UHD — is a fixed pixel size. A print format — A5, A4, A3, US Letter — is a sheet of paper, and becomes pixels at `--dpi` (36–1200, 300 by default), so A4 at 300 dpi is 2480×3508. Picking a format in the interface fills the width and height in and greys them out; Custom hands them back, keeping whatever the format last worked out to. A format is saved with the project, so an A4 project reopens as A4 rather than as the pixels it happened to be. ### Video A project can be a video clip instead of a still: switch **Video** on under Setup and give it a length and a frame rate, or pick a preset that sets the canvas too — Instagram story (1080×1920, 15 s), Instagram reel (30 s), Instagram feed video, TikTok / YouTube Short, Square loop (6 s) and Full HD clip. The preview renders the whole clip first, shrunk to fit in memory, and then loops it, with play/pause and a scale to look at any one frame. **Save As…** then writes the video at full size through `ffmpeg`, which has to be installed: `.mp4`, `.mov` and `.mkv` as H.264, `.webm` as VP9, `.gif` as a palette GIF. ```bash build/effector -o story.mp4 --video-preset instagram-story -e channelsplit --seed 1 photo.jpg build/effector -o loop.gif -s 480x480 --duration 3 --fps 15 -e channelsplit photo.jpg ``` `--duration SECONDS` and `--fps N` make any run a video; a preset fills in whatever was not given alongside it. Effects that move over time are video effects; for now that is **Channel split**, which in a video sways its channels, shakes them from frame to frame and rolls a tracking bar down the picture, like a worn tape on an old tube. Its motion params only show in a video project, and a still renders exactly as before. Every frame starts from the same seed, so stages that do not move hold still while one that does moves, and a clip renders frame for frame the same again. Any option can also be passed when launching the interface; it becomes the starting state (images, canvas size, effect settings, seed). Reading a batch of large photographs takes seconds, so it never blocks the interface: decoding runs in the background and a progress window shows how many files are done, which one is being read, and a Cancel button that stops the batch. Small batches finish before the window would be drawn, so it only appears if the decode is still going after 250 ms. On a terminal the headless mode keeps a single `reading n/total` line updated on stderr; redirected output stays clean. ## Versions Releases are listed in [CHANGELOG.md](CHANGELOG.md) and tagged `vX.Y.Z` (`git tag -l 'v*'`). `effector --version` prints the version, and the menu in the interface has Changelog and About Effector. To cut a release: bump the `project()` version in `CMakeLists.txt`, move the changes into a new `## [X.Y.Z] - date` entry at the top of `CHANGELOG.md` (the core tests fail until the two agree), commit, and tag it: `git tag -a vX.Y.Z -m "Effector X.Y.Z"`. ## Effects ### scanlines Fills the canvas top to bottom with horizontal strips cut at random positions from the source images. Strips are taken at native scale, cropped at a random horizontal offset, and tiled when the source is narrower than the canvas. | Parameter | Flag | Meaning | |---|---|---| | `strip_height` | `--strip-height` | Height in pixels of each extracted line | | `jitter` | `--jitter` | Random ± variation of that height (0 = uniform) | | `keep_order` | `--keep-order` | Cycle the images in order instead of picking randomly | The seed makes a result reproducible: same sources, settings and seed give the same image, on any machine — the generator (`src/core/rng.hpp`) is a hand-rolled xoshiro256\*\* rather than ``, whose distributions are not portable. **Recreate** draws a new seed. ## Layout ``` src/core/ image buffer and IO, param specs, effect base, registry, rng, thread helper src/effects/ one file per effect src/cli/ argument parsing (flags generated from the registry) src/gui/ gtkmm 4 interface tests/ core checks, run by ctest python-reference/ the original Python prototype, kept for reference only ``` `effector_core` is a GUI-free static library that links only gdk-pixbuf, so headless rendering never touches GTK or a display. The GTK front end links on top of it. ## Adding an effect 1. Create `src/effects/myeffect.{hpp,cpp}` with a subclass of `Effect`: ```cpp class MyEffect : public Effect { std::string name() const override { return "myeffect"; } std::string title() const override { return "My Effect"; } std::string description() const override { return "..."; } const std::vector& params() const override { static const std::vector specs = { ParamSpec::makeInt("amount", "Amount", 4, 1, 64, "How much."), }; return specs; } Image render(const std::vector& sources, int width, int height, Rng& rng, const ParamValues& values) const override { const long amount = paramInt(values, "amount", 4); ... // return a width x height RGB image } }; ``` 2. Add it to `_EFFECT_CLASSES`-style list in `src/core/registry.cpp` and to the source list in `CMakeLists.txt`. Widgets and flags follow from `params()` automatically. Draw everything random from the `Rng` you are handed so results stay reproducible, and use `parallelFor` (`src/core/parallel.hpp`) for the pixel work. ## Performance notes The render loop first draws the whole strip plan from the seeded generator, then copies the pixels in parallel across the hardware threads; each row is a `memcpy`, and horizontal tiling doubles the filled span per pass. Source images are decoded in parallel too, which is what dominates a headless run. A 7680×4320 canvas at `--strip-height 2` from three multi-megapixel JPEGs renders in ~35 ms, against ~73 ms for the Python prototype; the interface reports the render time of the current preview in its status line. Decoding threads take the next file from a shared cursor rather than a fixed slice of the list, so one very large file cannot stall the files queued behind it and progress advances at a steady rate. ## License Copyright © 2026 Si. Effector is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. It is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; see `LICENSE` for the full text. Images you render with it are yours and are not covered by the license.