Files
effector/README.md
T
SiandClaude Opus 5.5 cdaadffc89 Split effects into image and video effects, with VHS tracking as the first video effect
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>
2026-09-26 19:03:39 +02:00

206 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<version>` 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 -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](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
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<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
}
};
```
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.