Effect::video() says which kind an effect is. The Add effect menu lists the two kinds in sections of their own, each scrolling on its own, and offers video effects only in a video project; a video stage carries a film icon in the chain and, in a still, is dimmed and skipped like a stage switched off. enabledStages takes whether the render is a video and is the one place that leaves video stages out of a still. --help groups the video effects under a heading of their own, --list-effects marks them, and a video effect given for a still is skipped with a warning. VideoEffect is the base every video effect builds on, and keeps the rules a moving stage has to follow: it takes one value from the shared rng, the same on every frame, and hands the effect steady and per-frame generators and loop-rounded time through a Moment, so a subclass writes only animate(). VHS tracking is the first, and the one to copy: the colour planes sway and shake and a tracking bar rolls down the picture. That motion moves out of channel split, which is an image effect again and renders stills exactly as before; forVideo() goes with it. Version 0.7.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
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<version> from
this repository and installs it with pacman, desktop entry included:
cd packaging/arch && makepkg -si
Run
build/effector # GTK interface
build/effector --list-effects
build/effector --help
build/effector --version
Headless rendering, no display needed:
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:
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.
build/effector -o story.mp4 --video-preset instagram-story -e channelsplit -e tracking --seed 1 photo.jpg
build/effector -o loop.gif -s 480x480 --duration 3 --fps 15 -e tracking --tracking-bar 40 photo.jpg
--duration SECONDS and --fps N make any run a video; a preset fills in
whatever was not given alongside it.
Effects come in two kinds. Image effects make a picture and hold still in a clip. Video effects are motion: the Add effect menu lists them under a heading of their own, and they can only be added once the project is a video. In a still project a video stage is marked as skipped and the render leaves it out, as it would a stage that is switched off. For now there is one video effect, VHS tracking: the colour planes sway and shake, and a tracking bar rolls down the picture, like a worn tape on an old tube. Put it after the image effects it should shake. Every frame starts from the same seed, so the image effects hold still while a video effect moves them, 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 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 <random>, 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
- Create
src/effects/myeffect.{hpp,cpp}with a subclass ofEffect:
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<ParamSpec>& params() const override {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeInt("amount", "Amount", 4, 1, 64, "How much."),
};
return specs;
}
Image render(const std::vector<Image>& 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
}
};
- Add it to
_EFFECT_CLASSES-style list insrc/core/registry.cppand to the source list inCMakeLists.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.