A project can now be a clip: Setup has a Video switch with a length, a frame rate and presets that set the canvas too (Instagram story, reel and feed video, TikTok / YouTube Short, Square loop, Full HD clip). The preview renders the whole clip, shrunk to a memory budget, and loops it with play/pause and a scale; Save As... renders it again at full size and pipes the frames into ffmpeg as .mp4, .mov, .mkv, .webm or .gif, with a progress window that can cancel. --duration, --fps and --video-preset do the same from the command line. Effects learn about time through FrameTime and Effect::renderFrame, which falls back to render, so nothing that holds still changes. Channel split now sways, shakes and rolls a tracking bar down the picture in a video; its motion params are marked forVideo() and hidden in a still project, and a still renders byte for byte as before. renderFrames starts every frame from the same seed and renders the still stages ahead of the first moving one only once. The version moves to 0.6.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
201 lines
8.1 KiB
Markdown
201 lines
8.1 KiB
Markdown
# 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 --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 `<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.
|