Effector: C++/CMake image effects app

Port of the Python prototype to C++20 with a CMake build:

- effector_core: GUI-free static library (effects, image IO via gdk-pixbuf,
  declarative param specs, registry, portable seeded RNG, thread helper)
- scanlines effect: plans strips from the seeded RNG, then copies pixels in
  parallel with memcpy and doubling horizontal tiling
- gtkmm 4 interface: native file dialogs, widgets generated from the param
  specs, coalesced background renders, preview as a GDK memory texture
- CLI: flags generated from the effect registry, headless rendering with no
  display required
- ctest suite covering the RNG, registry, effect geometry and image round trip

The Python prototype is kept under python-reference/.
This commit is contained in:
hans
2026-09-18 14:28:13 +02:00
commit 925a719da0
36 changed files with 2859 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
build/
build-*/
compile_commands.json
.cache/
__pycache__/
*.o
*.a
+52
View File
@@ -0,0 +1,52 @@
cmake_minimum_required(VERSION 3.20)
project(effector VERSION 0.1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
set(CMAKE_CXX_FLAGS_RELEASE "-O3 -DNDEBUG")
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTKMM REQUIRED IMPORTED_TARGET gtkmm-4.0)
pkg_check_modules(GDKPIXBUF REQUIRED IMPORTED_TARGET gdk-pixbuf-2.0)
find_package(Threads REQUIRED)
# --- core: effects and image handling, no GUI ---------------------------------
add_library(effector_core STATIC
src/core/effect.cpp
src/core/image.cpp
src/core/params.cpp
src/core/registry.cpp
src/core/rng.cpp
src/effects/scanlines.cpp
)
target_include_directories(effector_core PUBLIC src)
# The core is GUI-free: it only needs gdk-pixbuf for decoding and encoding,
# so headless rendering works without a display.
target_link_libraries(effector_core PUBLIC PkgConfig::GDKPIXBUF Threads::Threads)
target_compile_options(effector_core PRIVATE -Wall -Wextra -Wpedantic)
# --- application --------------------------------------------------------------
add_executable(effector
src/main.cpp
src/cli/options.cpp
src/gui/app.cpp
src/gui/window.cpp
)
target_link_libraries(effector PRIVATE effector_core PkgConfig::GTKMM)
target_compile_options(effector PRIVATE -Wall -Wextra -Wpedantic)
install(TARGETS effector RUNTIME DESTINATION bin)
# --- tests --------------------------------------------------------------------
include(CTest)
if(BUILD_TESTING)
add_subdirectory(tests)
endif()
+115
View File
@@ -0,0 +1,115 @@
# 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`.
```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`.
## Run
```bash
build/effector # GTK interface
build/effector --list-effects
build/effector --help
```
Headless rendering, no display needed:
```bash
build/effector -o out.png -s 1920x1080 \
--strip-height 8 --jitter 4 --seed 42 photo1.jpg photo2.png
```
Any option can also be passed when launching the interface; it becomes the
starting state (images, canvas size, effect settings, seed).
## 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.
+3
View File
@@ -0,0 +1,3 @@
"""effector - a pluggable image effects app."""
__version__ = "0.1.0"
+5
View File
@@ -0,0 +1,5 @@
import sys
from .cli import main
sys.exit(main())
+110
View File
@@ -0,0 +1,110 @@
"""Command line entry point: launches the GUI, or renders headlessly."""
from __future__ import annotations
import argparse
import random
import sys
from .effects import DEFAULT_EFFECT, EffectError, get_effect, list_effects, load_sources
from .params import BoolParam, ChoiceParam, FloatParam, IntParam
def parse_size(text: str) -> tuple[int, int]:
for sep in ("x", "X", "*", ","):
if sep in text:
a, _, b = text.partition(sep)
try:
w, h = int(a), int(b)
except ValueError:
break
if w > 0 and h > 0:
return w, h
break
raise argparse.ArgumentTypeError(f"invalid size {text!r}, expected e.g. 1920x1080")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="effector",
description="Image effects app. Runs the GTK interface unless --output is given.",
)
parser.add_argument("images", nargs="*", help="source image files")
parser.add_argument(
"-e", "--effect", default=DEFAULT_EFFECT, choices=sorted(e.name for e in list_effects())
)
parser.add_argument("-o", "--output", help="render headlessly to this file and exit")
parser.add_argument(
"-s", "--size", type=parse_size, default=(1920, 1080), help="canvas size, e.g. 1920x1080"
)
parser.add_argument("--seed", type=int, help="random seed (default: random)")
parser.add_argument(
"--list-effects", action="store_true", help="list available effects and exit"
)
# Effect-specific flags, generated from the declarative param specs. Every
# effect's params share one namespace; defaults stay None so that the
# effect's own default applies when a flag is not given.
seen: set[str] = set()
for cls in list_effects():
group = parser.add_argument_group(f"{cls.title} options")
for p in cls.params:
if p.key in seen:
continue
seen.add(p.key)
help_text = p.help or p.label
if isinstance(p, BoolParam):
group.add_argument(p.flag, dest=p.key, action="store_true", default=None,
help=help_text)
group.add_argument("--no-" + p.flag[2:], dest=p.key, action="store_false",
default=None, help=argparse.SUPPRESS)
elif isinstance(p, IntParam):
group.add_argument(p.flag, dest=p.key, type=int, default=None, help=help_text)
elif isinstance(p, FloatParam):
group.add_argument(p.flag, dest=p.key, type=float, default=None, help=help_text)
elif isinstance(p, ChoiceParam):
group.add_argument(p.flag, dest=p.key, choices=list(p.choices), default=None,
help=help_text)
return parser
def render_headless(args) -> int:
effect = get_effect(args.effect)
images, failures = load_sources(args.images)
for path, message in failures:
print(f"effector: skipping {path}: {message}", file=sys.stderr)
if not images:
print("effector: no usable source images", file=sys.stderr)
return 2
seed = args.seed if args.seed is not None else random.randrange(2**32)
width, height = args.size
values = {p.key: getattr(args, p.key, None) for p in effect.params}
image = effect.render(images, width, height, random.Random(seed), **values)
image.save(args.output)
print(f"{args.output} {width}x{height} effect={effect.name} seed={seed}")
return 0
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.list_effects:
for cls in list_effects():
print(f"{cls.name}\t{cls.title} - {cls.description}")
return 0
try:
if args.output:
return render_headless(args)
from .gui.app import run_gui
return run_gui(args)
except EffectError as exc:
print(f"effector: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,39 @@
"""Effect registry.
To add an effect: drop a module in this package, import its class below and add
it to ``_EFFECT_CLASSES``. The GUI and CLI pick it up automatically.
"""
from __future__ import annotations
from .base import Effect, EffectError, load_sources
from .scanlines import ScanlinesEffect
_EFFECT_CLASSES: tuple[type[Effect], ...] = (ScanlinesEffect,)
REGISTRY: dict[str, type[Effect]] = {cls.name: cls for cls in _EFFECT_CLASSES}
DEFAULT_EFFECT = ScanlinesEffect.name
def list_effects() -> list[type[Effect]]:
return list(_EFFECT_CLASSES)
def get_effect(name: str) -> Effect:
try:
return REGISTRY[name]()
except KeyError:
known = ", ".join(sorted(REGISTRY))
raise EffectError(f"Unknown effect {name!r} (available: {known})") from None
__all__ = [
"REGISTRY",
"DEFAULT_EFFECT",
"Effect",
"EffectError",
"get_effect",
"list_effects",
"load_sources",
]
+75
View File
@@ -0,0 +1,75 @@
"""Effect plug-in contract and source-image loading."""
from __future__ import annotations
import random
from abc import ABC, abstractmethod
from pathlib import Path
from PIL import Image, ImageOps
from ..params import Param
class EffectError(Exception):
"""Raised when an effect cannot produce an image from the given input."""
class Effect(ABC):
"""Base class for all effects.
Subclasses set ``name``/``title``/``description``/``params`` and implement
:meth:`render`. Canvas size and the random source are common to every
effect; ``params`` covers only the effect-specific knobs.
"""
name: str = ""
title: str = ""
description: str = ""
params: tuple[Param, ...] = ()
@classmethod
def defaults(cls) -> dict:
return {p.key: p.default for p in cls.params}
@classmethod
def clean(cls, values: dict) -> dict:
"""Fill in missing keys with defaults and drop unknown ones."""
merged = cls.defaults()
for p in cls.params:
if values.get(p.key) is not None:
merged[p.key] = values[p.key]
return merged
@abstractmethod
def render(
self,
sources: list[Image.Image],
width: int,
height: int,
rng: random.Random,
**values,
) -> Image.Image:
"""Return a new ``width`` x ``height`` RGB image built from ``sources``."""
def load_sources(paths) -> tuple[list[Image.Image], list[tuple[Path, str]]]:
"""Load images as fully decoded RGB.
Returns ``(images, failures)`` where each failure is ``(path, message)``;
unreadable files are skipped rather than aborting the whole batch.
"""
images: list[Image.Image] = []
failures: list[tuple[Path, str]] = []
for raw in paths:
path = Path(raw)
try:
with Image.open(path) as im:
im = ImageOps.exif_transpose(im)
im = im.convert("RGB")
im.load()
im.filename = str(path)
images.append(im)
except Exception as exc: # noqa: BLE001 - report, don't crash the batch
failures.append((path, str(exc)))
return images, failures
@@ -0,0 +1,88 @@
"""Scanlines: fill a canvas with horizontal strips cut from random sources."""
from __future__ import annotations
import random
from PIL import Image
from ..params import BoolParam, IntParam
from .base import Effect, EffectError
class ScanlinesEffect(Effect):
name = "scanlines"
title = "Scanlines"
description = (
"Stacks horizontal strips cut at random positions from the source "
"images, line by line, until the canvas is full."
)
params = (
IntParam(
"strip_height",
"Strip height",
default=8,
minimum=1,
maximum=512,
help="Height in pixels of each extracted line.",
),
IntParam(
"jitter",
"Height jitter",
default=0,
minimum=0,
maximum=256,
help="Random +/- variation added to the strip height (0 = uniform).",
),
BoolParam(
"keep_order",
"Cycle images in order",
default=False,
help="Take strips from the images in order instead of picking randomly.",
),
)
def render(
self,
sources: list[Image.Image],
width: int,
height: int,
rng: random.Random,
**values,
) -> Image.Image:
if not sources:
raise EffectError("No source images.")
if width < 1 or height < 1:
raise EffectError("Canvas size must be at least 1x1 pixels.")
opts = self.clean(values)
strip_height = max(1, int(opts["strip_height"]))
jitter = max(0, int(opts["jitter"]))
keep_order = bool(opts["keep_order"])
canvas = Image.new("RGB", (width, height))
y = 0
index = 0
while y < height:
if keep_order:
src = sources[index % len(sources)]
index += 1
else:
src = rng.choice(sources)
h = strip_height
if jitter:
h += rng.randint(-jitter, jitter)
h = max(1, min(h, height - y, src.height))
sy = rng.randint(0, src.height - h)
sx = rng.randint(0, max(0, src.width - width))
strip = src.crop((sx, sy, min(sx + width, src.width), sy + h))
x = 0
while x < width:
canvas.paste(strip, (x, y))
x += strip.width
y += h
return canvas
+30
View File
@@ -0,0 +1,30 @@
"""GTK application wrapper."""
from __future__ import annotations
import sys
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk # noqa: E402
from .window import EffectorWindow # noqa: E402
class EffectorApp(Gtk.Application):
def __init__(self, initial=None):
super().__init__(application_id="org.effector.Effector")
self.initial = initial
def do_activate(self):
window = self.props.active_window
if window is None:
window = EffectorWindow(self, self.initial)
window.present()
def run_gui(args=None) -> int:
# argv is handled by our own parser, so keep GTK out of it.
return EffectorApp(args).run([sys.argv[0]])
+430
View File
@@ -0,0 +1,430 @@
"""Main window: sources, parameters, preview, recreate and save."""
from __future__ import annotations
import random
import threading
from pathlib import Path
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Gdk", "4.0")
from gi.repository import Gdk, GLib, Gio, Gtk, Pango # noqa: E402
from ..effects import EffectError, get_effect, list_effects, load_sources # noqa: E402
from ..params import BoolParam, ChoiceParam, FloatParam, IntParam # noqa: E402
IMAGE_SUFFIXES = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff", ".gif", ".ppm")
def pil_to_texture(image) -> Gdk.Texture:
"""Wrap a PIL RGB image as a GDK texture without touching the disk."""
data = GLib.Bytes.new(image.tobytes())
return Gdk.MemoryTexture.new(
image.width, image.height, Gdk.MemoryFormat.R8G8B8, data, image.width * 3
)
class EffectorWindow(Gtk.ApplicationWindow):
def __init__(self, app, initial=None):
super().__init__(application=app, title="Effector")
self.set_default_size(1180, 760)
self.sources: list = [] # loaded PIL images
self.source_paths: list[Path] = []
self.result = None # full-resolution PIL result
self.seed = random.randrange(2**32)
self.param_widgets: dict = {}
self._render_token = 0
self._suspend_render = True
self.effects = list_effects()
self.effect = get_effect(self.effects[0].name)
self._build_ui()
if initial is not None:
self._apply_initial(initial)
self._suspend_render = False
if self.sources:
self.render()
# ---------------------------------------------------------------- UI ---
def _build_ui(self):
header = Gtk.HeaderBar()
self.set_titlebar(header)
add_button = Gtk.Button(icon_name="list-add-symbolic", tooltip_text="Add images")
add_button.connect("clicked", self.on_add_images)
header.pack_start(add_button)
self.effect_dropdown = Gtk.DropDown.new_from_strings([e.title for e in self.effects])
self.effect_dropdown.set_tooltip_text("Effect")
self.effect_dropdown.connect("notify::selected", self.on_effect_changed)
header.pack_start(self.effect_dropdown)
self.save_button = Gtk.Button(label="Save As…")
self.save_button.connect("clicked", self.on_save)
self.save_button.set_sensitive(False)
header.pack_end(self.save_button)
self.recreate_button = Gtk.Button(icon_name="view-refresh-symbolic",
tooltip_text="Recreate with a new seed")
self.recreate_button.connect("clicked", self.on_recreate)
header.pack_end(self.recreate_button)
root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
self.set_child(root)
self.banner = Gtk.Label(xalign=0, wrap=True, visible=False)
self.banner.add_css_class("error")
self.banner.set_margin_top(6)
self.banner.set_margin_bottom(6)
self.banner.set_margin_start(12)
self.banner.set_margin_end(12)
root.append(self.banner)
paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL, vexpand=True)
paned.set_position(320)
paned.set_shrink_start_child(False)
root.append(paned)
paned.set_start_child(self._build_sidebar())
paned.set_end_child(self._build_preview())
def _build_sidebar(self) -> Gtk.Widget:
sidebar = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
sidebar.set_margin_top(12)
sidebar.set_margin_bottom(12)
sidebar.set_margin_start(12)
sidebar.set_margin_end(12)
sidebar.append(self._section_label("Source images"))
self.source_list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
self.source_list.add_css_class("boxed-list")
self.source_list.set_placeholder(
Gtk.Label(label="No images yet", margin_top=18, margin_bottom=18)
)
scroller = Gtk.ScrolledWindow(vexpand=True)
scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
scroller.set_child(self.source_list)
scroller.set_size_request(-1, 160)
sidebar.append(scroller)
sidebar.append(self._section_label("Canvas"))
grid = Gtk.Grid(column_spacing=8, row_spacing=6)
self.width_spin = Gtk.SpinButton.new_with_range(1, 20000, 1)
self.width_spin.set_value(1920)
self.height_spin = Gtk.SpinButton.new_with_range(1, 20000, 1)
self.height_spin.set_value(1080)
for spin in (self.width_spin, self.height_spin):
spin.set_hexpand(True)
spin.connect("value-changed", self.on_param_changed)
grid.attach(Gtk.Label(label="Width", xalign=0), 0, 0, 1, 1)
grid.attach(self.width_spin, 1, 0, 1, 1)
grid.attach(Gtk.Label(label="Height", xalign=0), 0, 1, 1, 1)
grid.attach(self.height_spin, 1, 1, 1, 1)
sidebar.append(grid)
self.params_label = self._section_label("Effect settings")
sidebar.append(self.params_label)
self.params_box = Gtk.Grid(column_spacing=8, row_spacing=6)
sidebar.append(self.params_box)
self._build_param_widgets()
seed_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
seed_box.append(Gtk.Label(label="Seed", xalign=0))
self.seed_entry = Gtk.Entry(hexpand=True)
self.seed_entry.set_text(str(self.seed))
self.seed_entry.connect("activate", self.on_seed_entered)
seed_box.append(self.seed_entry)
sidebar.append(seed_box)
return sidebar
def _build_preview(self) -> Gtk.Widget:
self.picture = Gtk.Picture()
self.picture.set_can_shrink(True)
self.picture.set_content_fit(Gtk.ContentFit.CONTAIN)
self.placeholder = Gtk.Label(
label="Add source images to start.", wrap=True, justify=Gtk.Justification.CENTER
)
self.placeholder.add_css_class("dim-label")
self.spinner = Gtk.Spinner(halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER)
self.preview_stack = Gtk.Stack()
self.preview_stack.add_named(self.placeholder, "placeholder")
self.preview_stack.add_named(self.picture, "picture")
self.preview_stack.set_visible_child_name("placeholder")
overlay = Gtk.Overlay(hexpand=True, vexpand=True)
overlay.set_child(self.preview_stack)
overlay.add_overlay(self.spinner)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
box.append(overlay)
self.status = Gtk.Label(xalign=0.5, margin_top=6, margin_bottom=8)
self.status.add_css_class("dim-label")
box.append(self.status)
return box
@staticmethod
def _section_label(text: str) -> Gtk.Label:
label = Gtk.Label(label=text, xalign=0)
label.add_css_class("heading")
return label
def _build_param_widgets(self):
while (child := self.params_box.get_first_child()) is not None:
self.params_box.remove(child)
self.param_widgets.clear()
for row, p in enumerate(self.effect.params):
label = Gtk.Label(label=p.label, xalign=0)
if p.help:
label.set_tooltip_text(p.help)
if isinstance(p, BoolParam):
widget = Gtk.Switch(halign=Gtk.Align.START)
widget.set_active(bool(p.default))
widget.connect("notify::active", self.on_param_changed)
elif isinstance(p, IntParam):
widget = Gtk.SpinButton.new_with_range(p.minimum, p.maximum, p.step)
widget.set_value(p.default)
widget.connect("value-changed", self.on_param_changed)
elif isinstance(p, FloatParam):
widget = Gtk.SpinButton.new_with_range(p.minimum, p.maximum, p.step)
widget.set_digits(p.digits)
widget.set_value(p.default)
widget.connect("value-changed", self.on_param_changed)
elif isinstance(p, ChoiceParam):
widget = Gtk.DropDown.new_from_strings(list(p.choices))
if p.default in p.choices:
widget.set_selected(p.choices.index(p.default))
widget.connect("notify::selected", self.on_param_changed)
else:
continue
widget.set_hexpand(True)
self.params_box.attach(label, 0, row, 1, 1)
self.params_box.attach(widget, 1, row, 1, 1)
self.param_widgets[p.key] = (p, widget)
def _param_values(self) -> dict:
values = {}
for key, (p, widget) in self.param_widgets.items():
if isinstance(p, BoolParam):
values[key] = widget.get_active()
elif isinstance(p, IntParam):
values[key] = int(widget.get_value())
elif isinstance(p, FloatParam):
values[key] = widget.get_value()
elif isinstance(p, ChoiceParam):
values[key] = p.choices[widget.get_selected()]
return values
def _apply_initial(self, args):
if getattr(args, "effect", None):
for i, cls in enumerate(self.effects):
if cls.name == args.effect:
self.effect_dropdown.set_selected(i)
break
if getattr(args, "size", None):
self.width_spin.set_value(args.size[0])
self.height_spin.set_value(args.size[1])
if getattr(args, "seed", None) is not None:
self.seed = int(args.seed)
self.seed_entry.set_text(str(self.seed))
for key, (_p, widget) in self.param_widgets.items():
value = getattr(args, key, None)
if value is None:
continue
if isinstance(widget, Gtk.Switch):
widget.set_active(bool(value))
elif isinstance(widget, Gtk.SpinButton):
widget.set_value(value)
elif isinstance(widget, Gtk.DropDown):
choices = list(self.param_widgets[key][0].choices)
if value in choices:
widget.set_selected(choices.index(value))
if getattr(args, "images", None):
self.add_paths(args.images)
# ------------------------------------------------------------ sources ---
def add_paths(self, paths):
images, failures = load_sources(paths)
loaded_paths = [Path(getattr(im, "filename", "")) for im in images]
self.sources.extend(images)
self.source_paths.extend(loaded_paths)
for path in loaded_paths:
self._append_source_row(path)
if failures:
names = ", ".join(p.name for p, _ in failures)
self.show_error(f"Could not read: {names}")
elif images:
self.clear_error()
if images and not self._suspend_render:
self.render()
def _append_source_row(self, path: Path):
row = Gtk.ListBoxRow()
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
box.set_margin_top(4)
box.set_margin_bottom(4)
box.set_margin_start(8)
box.set_margin_end(4)
label = Gtk.Label(label=path.name, xalign=0, hexpand=True,
ellipsize=Pango.EllipsizeMode.END)
label.set_tooltip_text(str(path))
box.append(label)
remove = Gtk.Button(icon_name="window-close-symbolic")
remove.add_css_class("flat")
remove.connect("clicked", self.on_remove_source, row)
box.append(remove)
row.set_child(box)
self.source_list.append(row)
def on_remove_source(self, _button, row):
index = row.get_index()
if 0 <= index < len(self.sources):
self.sources.pop(index)
self.source_paths.pop(index)
self.source_list.remove(row)
if self.sources:
self.render()
else:
self.result = None
self.save_button.set_sensitive(False)
self.preview_stack.set_visible_child_name("placeholder")
self.status.set_text("")
def on_add_images(self, _button):
dialog = Gtk.FileDialog(title="Select source images")
image_filter = Gtk.FileFilter()
image_filter.set_name("Images")
for suffix in IMAGE_SUFFIXES:
image_filter.add_pattern("*" + suffix)
image_filter.add_pattern("*" + suffix.upper())
filters = Gio.ListStore.new(Gtk.FileFilter)
filters.append(image_filter)
dialog.set_filters(filters)
dialog.set_default_filter(image_filter)
dialog.open_multiple(self, None, self._on_images_chosen)
def _on_images_chosen(self, dialog, result):
try:
files = dialog.open_multiple_finish(result)
except GLib.Error:
return # cancelled
paths = [files.get_item(i).get_path() for i in range(files.get_n_items())]
self.add_paths([p for p in paths if p])
# ------------------------------------------------------------ rendering ---
def on_effect_changed(self, *_args):
self.effect = get_effect(self.effects[self.effect_dropdown.get_selected()].name)
self._build_param_widgets()
if self.sources:
self.render()
def on_param_changed(self, *_args):
if not self._suspend_render and self.sources:
self.render()
def on_seed_entered(self, entry):
try:
self.seed = int(entry.get_text().strip())
except ValueError:
entry.set_text(str(self.seed))
return
if self.sources:
self.render()
def on_recreate(self, _button):
self.seed = random.randrange(2**32)
self.seed_entry.set_text(str(self.seed))
if self.sources:
self.render()
else:
self.show_error("Add at least one source image first.")
def render(self):
if not self.sources:
return
self._render_token += 1
token = self._render_token
effect = self.effect
sources = list(self.sources)
width = int(self.width_spin.get_value())
height = int(self.height_spin.get_value())
seed = self.seed
values = self._param_values()
self.spinner.start()
self.recreate_button.set_sensitive(False)
def work():
try:
image = effect.render(sources, width, height, random.Random(seed), **values)
GLib.idle_add(self._render_done, token, image, None)
except Exception as exc: # noqa: BLE001 - surfaced in the UI
GLib.idle_add(self._render_done, token, None, exc)
threading.Thread(target=work, daemon=True).start()
def _render_done(self, token, image, error):
if token != self._render_token:
return GLib.SOURCE_REMOVE
self.spinner.stop()
self.recreate_button.set_sensitive(True)
if error is not None:
self.result = None
self.save_button.set_sensitive(False)
self.show_error(str(error) if isinstance(error, EffectError) else f"Render failed: {error}")
return GLib.SOURCE_REMOVE
self.clear_error()
self.result = image
self.picture.set_paintable(pil_to_texture(image))
self.preview_stack.set_visible_child_name("picture")
self.save_button.set_sensitive(True)
self.status.set_text(
f"{self.effect.title} · {image.width}×{image.height} · "
f"{len(self.sources)} source{'s' if len(self.sources) != 1 else ''} · seed {self.seed}"
)
return GLib.SOURCE_REMOVE
# --------------------------------------------------------------- saving ---
def on_save(self, _button):
if self.result is None:
return
dialog = Gtk.FileDialog(title="Save image")
dialog.set_initial_name(f"effector-{self.effect.name}-{self.seed}.png")
dialog.save(self, None, self._on_save_chosen)
def _on_save_chosen(self, dialog, result):
try:
file = dialog.save_finish(result)
except GLib.Error:
return # cancelled
path = Path(file.get_path())
if not path.suffix:
path = path.with_suffix(".png")
try:
image = self.result
if path.suffix.lower() in (".jpg", ".jpeg"):
image.save(path, quality=95)
else:
image.save(path)
except Exception as exc: # noqa: BLE001 - surfaced in the UI
self.show_error(f"Could not save: {exc}")
return
self.clear_error()
self.status.set_text(f"Saved {path}")
# --------------------------------------------------------------- errors ---
def show_error(self, message: str):
self.banner.set_text(message)
self.banner.set_visible(True)
def clear_error(self):
self.banner.set_visible(False)
+50
View File
@@ -0,0 +1,50 @@
"""Declarative parameter specs.
An effect describes its knobs with these small dataclasses; the GUI turns them
into widgets and the CLI turns them into argparse flags, so neither has to know
anything about a particular effect.
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Param:
key: str
label: str
default: object
help: str = ""
@property
def flag(self) -> str:
return "--" + self.key.replace("_", "-")
@dataclass(frozen=True)
class IntParam(Param):
default: int = 0
minimum: int = 0
maximum: int = 1_000_000
step: int = 1
@dataclass(frozen=True)
class FloatParam(Param):
default: float = 0.0
minimum: float = 0.0
maximum: float = 1.0
step: float = 0.05
digits: int = 2
@dataclass(frozen=True)
class BoolParam(Param):
default: bool = False
@dataclass(frozen=True)
class ChoiceParam(Param):
default: str = ""
choices: tuple[str, ...] = field(default_factory=tuple)
+16
View File
@@ -0,0 +1,16 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "effector"
version = "0.1.0"
description = "A pluggable image effects app with a GTK4 interface and a CLI"
requires-python = ">=3.10"
dependencies = ["pillow>=9", "pygobject>=3.42"]
[project.scripts]
effector = "effector.cli:main"
[tool.setuptools.packages.find]
include = ["effector*"]
+150
View File
@@ -0,0 +1,150 @@
#include "cli/options.hpp"
#include <charconv>
#include <cstring>
#include <sstream>
#include "core/effect.hpp"
#include "core/registry.hpp"
namespace effector {
namespace {
bool parseLong(const std::string& text, long& out) {
const char* begin = text.data();
const char* end = begin + text.size();
const auto result = std::from_chars(begin, end, out);
return result.ec == std::errc{} && result.ptr == end;
}
bool parseSize(const std::string& text, int& width, int& height) {
const auto sep = text.find_first_of("xX*,");
if (sep == std::string::npos) return false;
long w = 0;
long h = 0;
if (!parseLong(text.substr(0, sep), w) || !parseLong(text.substr(sep + 1), h)) return false;
if (w <= 0 || h <= 0) return false;
width = static_cast<int>(w);
height = static_cast<int>(h);
return true;
}
// Every effect's params share one flag namespace, exactly as in the interface.
const ParamSpec* findSpec(const std::string& flag) {
for (const auto& effect : effects()) {
for (const auto& spec : effect->params()) {
if (spec.flag() == flag) return &spec;
}
}
return nullptr;
}
std::string requireValue(int argc, char** argv, int& i, const std::string& flag) {
if (i + 1 >= argc) throw EffectError(flag + " needs a value");
return argv[++i];
}
} // namespace
Options parseOptions(int argc, char** argv) {
Options options;
options.effect = defaultEffectName();
for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i];
if (arg == "-h" || arg == "--help") {
options.showHelp = true;
} else if (arg == "--list-effects") {
options.listEffects = true;
} else if (arg == "-e" || arg == "--effect") {
options.effect = requireValue(argc, argv, i, arg);
effectByName(options.effect); // validate now, fail early
} else if (arg == "-o" || arg == "--output") {
options.output = requireValue(argc, argv, i, arg);
} else if (arg == "-s" || arg == "--size") {
const std::string value = requireValue(argc, argv, i, arg);
if (!parseSize(value, options.width, options.height)) {
throw EffectError("invalid size '" + value + "', expected e.g. 1920x1080");
}
} else if (arg == "--seed") {
const std::string value = requireValue(argc, argv, i, arg);
long seed = 0;
if (!parseLong(value, seed)) throw EffectError("invalid seed '" + value + "'");
options.seed = static_cast<std::uint64_t>(seed);
} else if (arg.rfind("--no-", 0) == 0) {
const std::string flag = "--" + arg.substr(5);
const ParamSpec* spec = findSpec(flag);
if (spec == nullptr || spec->type != ParamSpec::Type::Bool) {
throw EffectError("unknown option '" + arg + "'");
}
options.params[spec->key] = false;
} else if (arg.rfind("--", 0) == 0) {
const ParamSpec* spec = findSpec(arg);
if (spec == nullptr) throw EffectError("unknown option '" + arg + "'");
switch (spec->type) {
case ParamSpec::Type::Bool:
options.params[spec->key] = true;
break;
case ParamSpec::Type::Int: {
long value = 0;
const std::string text = requireValue(argc, argv, i, arg);
if (!parseLong(text, value)) {
throw EffectError(arg + ": '" + text + "' is not a whole number");
}
options.params[spec->key] = value;
break;
}
case ParamSpec::Type::Double: {
const std::string text = requireValue(argc, argv, i, arg);
try {
options.params[spec->key] = std::stod(text);
} catch (const std::exception&) {
throw EffectError(arg + ": '" + text + "' is not a number");
}
break;
}
case ParamSpec::Type::Choice: {
const std::string text = requireValue(argc, argv, i, arg);
bool valid = false;
for (const auto& choice : spec->choices) valid = valid || choice == text;
if (!valid) throw EffectError(arg + ": unknown value '" + text + "'");
options.params[spec->key] = text;
break;
}
}
} else if (arg.rfind("-", 0) == 0 && arg.size() > 1) {
throw EffectError("unknown option '" + arg + "'");
} else {
options.images.push_back(arg);
}
}
return options;
}
std::string usage(const char* programName) {
std::ostringstream out;
out << "Usage: " << programName << " [options] [image ...]\n\n"
<< "Image effects app. Runs the GTK interface unless --output is given.\n\n"
<< "Options:\n"
<< " -e, --effect NAME effect to apply (default: " << defaultEffectName() << ")\n"
<< " -o, --output FILE render headlessly to this file and exit\n"
<< " -s, --size WxH canvas size (default: 1920x1080)\n"
<< " --seed N random seed (default: random)\n"
<< " --list-effects list available effects and exit\n"
<< " -h, --help show this help and exit\n";
for (const auto& effect : effects()) {
out << "\n" << effect->title() << " options:\n";
for (const auto& spec : effect->params()) {
std::string flag = spec.flag();
if (spec.type != ParamSpec::Type::Bool) flag += " VALUE";
out << " " << flag;
if (flag.size() < 20) out << std::string(20 - flag.size(), ' ');
out << " " << (spec.help.empty() ? spec.label : spec.help) << "\n";
}
}
return out.str();
}
} // namespace effector
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include "core/params.hpp"
namespace effector {
struct Options {
std::vector<std::string> images;
std::string effect;
std::string output; // empty -> launch the interface
int width = 1920;
int height = 1080;
std::optional<std::uint64_t> seed;
ParamValues params; // only the keys the user actually passed
bool listEffects = false;
bool showHelp = false;
};
// Throws EffectError on bad input. Effect flags are generated from the
// registry, so a new effect gets its flags without touching this file.
Options parseOptions(int argc, char** argv);
std::string usage(const char* programName);
} // namespace effector
+26
View File
@@ -0,0 +1,26 @@
#include "core/effect.hpp"
namespace effector {
ParamValues Effect::defaults() const {
ParamValues values;
for (const auto& spec : params()) {
switch (spec.type) {
case ParamSpec::Type::Int:
values[spec.key] = static_cast<long>(spec.defaultValue);
break;
case ParamSpec::Type::Double:
values[spec.key] = spec.defaultValue;
break;
case ParamSpec::Type::Bool:
values[spec.key] = spec.defaultValue != 0.0;
break;
case ParamSpec::Type::Choice:
values[spec.key] = spec.defaultChoice;
break;
}
}
return values;
}
} // namespace effector
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <memory>
#include <stdexcept>
#include <string>
#include <vector>
#include "core/image.hpp"
#include "core/params.hpp"
#include "core/rng.hpp"
namespace effector {
class EffectError : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
// Base class for every effect. Canvas size and the random source are common to
// all of them; params() covers only the effect-specific knobs.
class Effect {
public:
virtual ~Effect() = default;
virtual std::string name() const = 0;
virtual std::string title() const = 0;
virtual std::string description() const = 0;
virtual const std::vector<ParamSpec>& params() const = 0;
virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const = 0;
ParamValues defaults() const;
};
using EffectPtr = std::shared_ptr<const Effect>;
} // namespace effector
+140
View File
@@ -0,0 +1,140 @@
#include "core/image.hpp"
#include <gdk-pixbuf/gdk-pixbuf.h>
#include "core/parallel.hpp"
#include <algorithm>
#include <cctype>
#include <cstring>
#include <memory>
#include <mutex>
#include <stdexcept>
namespace effector {
namespace {
// The plain C pixbuf API is used on purpose: it needs no gtkmm wrapper
// registration and no display, so the core renders headlessly.
using PixbufPtr = std::unique_ptr<GdkPixbuf, decltype(&g_object_unref)>;
PixbufPtr wrap(GdkPixbuf* pixbuf) { return PixbufPtr(pixbuf, &g_object_unref); }
std::string takeError(GError* error) {
std::string message = error != nullptr && error->message != nullptr ? error->message
: "unknown error";
if (error != nullptr) g_error_free(error);
return message;
}
std::string lowerExtension(const std::string& path) {
const auto dot = path.find_last_of('.');
if (dot == std::string::npos) return {};
std::string ext = path.substr(dot + 1);
std::transform(ext.begin(), ext.end(), ext.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return ext;
}
} // namespace
Image::Image(int width, int height)
: width_(width), height_(height),
pixels_(static_cast<std::size_t>(std::max(width, 0)) * 3 *
static_cast<std::size_t>(std::max(height, 0))) {}
Image Image::load(const std::string& path) {
GError* error = nullptr;
PixbufPtr pixbuf = wrap(gdk_pixbuf_new_from_file(path.c_str(), &error));
if (!pixbuf) throw std::runtime_error(takeError(error));
if (GdkPixbuf* oriented = gdk_pixbuf_apply_embedded_orientation(pixbuf.get())) {
pixbuf = wrap(oriented);
}
const int width = gdk_pixbuf_get_width(pixbuf.get());
const int height = gdk_pixbuf_get_height(pixbuf.get());
const int channels = gdk_pixbuf_get_n_channels(pixbuf.get());
const int srcStride = gdk_pixbuf_get_rowstride(pixbuf.get());
const guint8* src = gdk_pixbuf_get_pixels(pixbuf.get());
Image image(width, height);
for (int y = 0; y < height; ++y) {
const guint8* in = src + static_cast<std::size_t>(y) * srcStride;
std::uint8_t* out = image.row(y);
if (channels == 3) {
std::memcpy(out, in, static_cast<std::size_t>(width) * 3);
} else {
// Flatten alpha onto white, matching what a viewer would show.
for (int x = 0; x < width; ++x) {
const int a = in[x * channels + 3];
for (int c = 0; c < 3; ++c) {
const int value = in[x * channels + c] * a + 255 * (255 - a);
out[x * 3 + c] = static_cast<std::uint8_t>((value + 127) / 255);
}
}
}
}
image.setPath(path);
return image;
}
void Image::save(const std::string& path) const {
if (empty()) throw std::runtime_error("nothing to save");
PixbufPtr pixbuf = wrap(gdk_pixbuf_new(GDK_COLORSPACE_RGB, FALSE, 8, width_, height_));
if (!pixbuf) throw std::runtime_error("out of memory");
const int dstStride = gdk_pixbuf_get_rowstride(pixbuf.get());
guint8* dst = gdk_pixbuf_get_pixels(pixbuf.get());
for (int y = 0; y < height_; ++y) {
std::memcpy(dst + static_cast<std::size_t>(y) * dstStride, row(y), stride());
}
const std::string ext = lowerExtension(path);
std::string type = "png";
if (ext == "jpg" || ext == "jpeg") type = "jpeg";
else if (ext == "webp") type = "webp";
else if (ext == "tif" || ext == "tiff") type = "tiff";
else if (ext == "bmp") type = "bmp";
GError* error = nullptr;
const gboolean saved =
type == "jpeg"
? gdk_pixbuf_save(pixbuf.get(), path.c_str(), type.c_str(), &error, "quality", "95",
nullptr)
: gdk_pixbuf_save(pixbuf.get(), path.c_str(), type.c_str(), &error, nullptr);
if (saved == FALSE) throw std::runtime_error(takeError(error));
}
std::vector<Image> loadImages(const std::vector<std::string>& paths,
std::vector<LoadFailure>& failures) {
// Decoding is the dominant cost of a headless run, and the files are
// independent, so they are decoded in parallel. Slots keep the result in
// the order the paths were given.
std::vector<Image> slots(paths.size());
std::vector<std::string> errors(paths.size());
parallelFor(paths.size(), [&](std::size_t begin, std::size_t end) {
for (std::size_t i = begin; i < end; ++i) {
try {
slots[i] = Image::load(paths[i]);
} catch (const std::exception& error) {
errors[i] = error.what();
}
}
}, 1);
std::vector<Image> images;
images.reserve(paths.size());
for (std::size_t i = 0; i < paths.size(); ++i) {
if (errors[i].empty()) {
images.push_back(std::move(slots[i]));
} else {
failures.push_back({paths[i], errors[i]});
}
}
return images;
}
} // namespace effector
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
namespace effector {
// Tightly packed 8-bit RGB. Row stride is always width * 3, which lets the
// effects work with plain memcpy and hands GDK a buffer it can upload as is.
class Image {
public:
Image() = default;
Image(int width, int height);
int width() const noexcept { return width_; }
int height() const noexcept { return height_; }
std::size_t stride() const noexcept { return static_cast<std::size_t>(width_) * 3; }
bool empty() const noexcept { return width_ <= 0 || height_ <= 0; }
std::uint8_t* row(int y) noexcept { return pixels_.data() + static_cast<std::size_t>(y) * stride(); }
const std::uint8_t* row(int y) const noexcept {
return pixels_.data() + static_cast<std::size_t>(y) * stride();
}
std::uint8_t* data() noexcept { return pixels_.data(); }
const std::uint8_t* data() const noexcept { return pixels_.data(); }
std::size_t byteSize() const noexcept { return pixels_.size(); }
const std::string& path() const noexcept { return path_; }
void setPath(std::string path) { path_ = std::move(path); }
// Decodes via GdkPixbuf (PNG, JPEG, WebP, TIFF, BMP, ...), applying the
// embedded EXIF orientation. Throws std::runtime_error on failure.
static Image load(const std::string& path);
// Encodes by file extension; png is used when the extension is unknown.
void save(const std::string& path) const;
private:
int width_ = 0;
int height_ = 0;
std::vector<std::uint8_t> pixels_;
std::string path_;
};
struct LoadFailure {
std::string path;
std::string message;
};
// Loads every path, collecting rather than throwing on individual failures.
std::vector<Image> loadImages(const std::vector<std::string>& paths,
std::vector<LoadFailure>& failures);
} // namespace effector
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <algorithm>
#include <cstddef>
#include <functional>
#include <thread>
#include <vector>
namespace effector {
// Splits [0, count) into contiguous chunks across the hardware threads and runs
// `work(begin, end)` on each. `grain` is the smallest number of items worth
// giving a thread of its own; below that the work runs inline.
template <typename Work>
void parallelFor(std::size_t count, Work&& work, std::size_t grain = 16) {
if (count == 0) return;
unsigned hardware = std::thread::hardware_concurrency();
if (hardware == 0) hardware = 1;
const std::size_t workers =
std::min<std::size_t>(hardware, (count + grain - 1) / std::max<std::size_t>(grain, 1));
if (workers <= 1) {
work(std::size_t{0}, count);
return;
}
std::vector<std::thread> threads;
threads.reserve(workers - 1);
const std::size_t chunk = (count + workers - 1) / workers;
for (std::size_t w = 1; w < workers; ++w) {
const std::size_t begin = std::min(count, w * chunk);
const std::size_t end = std::min(count, begin + chunk);
if (begin >= end) break;
threads.emplace_back([&work, begin, end] { work(begin, end); });
}
work(std::size_t{0}, std::min(count, chunk));
for (auto& thread : threads) thread.join();
}
} // namespace effector
+108
View File
@@ -0,0 +1,108 @@
#include "core/params.hpp"
#include <algorithm>
namespace effector {
std::string ParamSpec::flag() const {
std::string flag = "--" + key;
std::replace(flag.begin(), flag.end(), '_', '-');
return flag;
}
ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum,
long maximum, std::string help, long step) {
ParamSpec spec;
spec.type = Type::Int;
spec.key = std::move(key);
spec.label = std::move(label);
spec.help = std::move(help);
spec.defaultValue = static_cast<double>(defaultValue);
spec.minimum = static_cast<double>(minimum);
spec.maximum = static_cast<double>(maximum);
spec.step = static_cast<double>(step);
return spec;
}
ParamSpec ParamSpec::makeDouble(std::string key, std::string label, double defaultValue,
double minimum, double maximum, std::string help, double step,
int digits) {
ParamSpec spec;
spec.type = Type::Double;
spec.key = std::move(key);
spec.label = std::move(label);
spec.help = std::move(help);
spec.defaultValue = defaultValue;
spec.minimum = minimum;
spec.maximum = maximum;
spec.step = step;
spec.digits = digits;
return spec;
}
ParamSpec ParamSpec::makeBool(std::string key, std::string label, bool defaultValue,
std::string help) {
ParamSpec spec;
spec.type = Type::Bool;
spec.key = std::move(key);
spec.label = std::move(label);
spec.help = std::move(help);
spec.defaultValue = defaultValue ? 1.0 : 0.0;
return spec;
}
ParamSpec ParamSpec::makeChoice(std::string key, std::string label, std::string defaultChoice,
std::vector<std::string> choices, std::string help) {
ParamSpec spec;
spec.type = Type::Choice;
spec.key = std::move(key);
spec.label = std::move(label);
spec.help = std::move(help);
spec.defaultChoice = std::move(defaultChoice);
spec.choices = std::move(choices);
return spec;
}
namespace {
const ParamValue* find(const ParamValues& values, const std::string& key) {
const auto it = values.find(key);
return it == values.end() ? nullptr : &it->second;
}
} // namespace
long paramInt(const ParamValues& values, const std::string& key, long fallback) {
if (const auto* value = find(values, key)) {
if (const auto* v = std::get_if<long>(value)) return *v;
if (const auto* v = std::get_if<double>(value)) return static_cast<long>(*v);
if (const auto* v = std::get_if<bool>(value)) return *v ? 1 : 0;
}
return fallback;
}
double paramDouble(const ParamValues& values, const std::string& key, double fallback) {
if (const auto* value = find(values, key)) {
if (const auto* v = std::get_if<double>(value)) return *v;
if (const auto* v = std::get_if<long>(value)) return static_cast<double>(*v);
}
return fallback;
}
bool paramBool(const ParamValues& values, const std::string& key, bool fallback) {
if (const auto* value = find(values, key)) {
if (const auto* v = std::get_if<bool>(value)) return *v;
if (const auto* v = std::get_if<long>(value)) return *v != 0;
}
return fallback;
}
std::string paramChoice(const ParamValues& values, const std::string& key,
const std::string& fallback) {
if (const auto* value = find(values, key)) {
if (const auto* v = std::get_if<std::string>(value)) return *v;
}
return fallback;
}
} // namespace effector
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <map>
#include <string>
#include <variant>
#include <vector>
namespace effector {
// Declarative description of one effect knob. The GUI turns these into widgets
// and the CLI turns them into flags, so neither knows about any effect.
struct ParamSpec {
enum class Type { Int, Double, Bool, Choice };
Type type = Type::Int;
std::string key;
std::string label;
std::string help;
double defaultValue = 0.0;
double minimum = 0.0;
double maximum = 0.0;
double step = 1.0;
int digits = 2;
std::string defaultChoice;
std::vector<std::string> choices;
std::string flag() const; // "strip_height" -> "--strip-height"
static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum,
long maximum, std::string help = {}, long step = 1);
static ParamSpec makeDouble(std::string key, std::string label, double defaultValue,
double minimum, double maximum, std::string help = {},
double step = 0.05, int digits = 2);
static ParamSpec makeBool(std::string key, std::string label, bool defaultValue,
std::string help = {});
static ParamSpec makeChoice(std::string key, std::string label, std::string defaultChoice,
std::vector<std::string> choices, std::string help = {});
};
using ParamValue = std::variant<long, double, bool, std::string>;
using ParamValues = std::map<std::string, ParamValue>;
long paramInt(const ParamValues& values, const std::string& key, long fallback);
double paramDouble(const ParamValues& values, const std::string& key, double fallback);
bool paramBool(const ParamValues& values, const std::string& key, bool fallback);
std::string paramChoice(const ParamValues& values, const std::string& key,
const std::string& fallback);
} // namespace effector
+29
View File
@@ -0,0 +1,29 @@
#include "core/registry.hpp"
#include "effects/scanlines.hpp"
namespace effector {
// To add an effect: include its header and add one line here.
const std::vector<EffectPtr>& effects() {
static const std::vector<EffectPtr> registry = {
std::make_shared<ScanlinesEffect>(),
};
return registry;
}
EffectPtr effectByName(const std::string& name) {
for (const auto& effect : effects()) {
if (effect->name() == name) return effect;
}
std::string known;
for (const auto& effect : effects()) {
if (!known.empty()) known += ", ";
known += effect->name();
}
throw EffectError("Unknown effect '" + name + "' (available: " + known + ")");
}
std::string defaultEffectName() { return effects().front()->name(); }
} // namespace effector
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <string>
#include <vector>
#include "core/effect.hpp"
namespace effector {
// Every effect the app knows about, in menu order.
const std::vector<EffectPtr>& effects();
// Throws EffectError when the name is unknown.
EffectPtr effectByName(const std::string& name);
std::string defaultEffectName();
} // namespace effector
+12
View File
@@ -0,0 +1,12 @@
#include "core/rng.hpp"
#include <random>
namespace effector {
std::uint64_t Rng::randomSeed() {
std::random_device rd;
return (static_cast<std::uint64_t>(rd()) << 32) ^ rd();
}
} // namespace effector
+63
View File
@@ -0,0 +1,63 @@
#pragma once
#include <cstdint>
namespace effector {
// xoshiro256** seeded through splitmix64. Rolled by hand rather than taken from
// <random> so that a given seed produces the same image on every platform and
// standard library.
class Rng {
public:
explicit Rng(std::uint64_t seed) noexcept {
for (auto& s : state_) s = splitmix(seed);
}
std::uint64_t next() noexcept {
const std::uint64_t result = rotl(state_[1] * 5, 7) * 9;
const std::uint64_t t = state_[1] << 17;
state_[2] ^= state_[0];
state_[3] ^= state_[1];
state_[1] ^= state_[2];
state_[0] ^= state_[3];
state_[2] ^= t;
state_[3] = rotl(state_[3], 45);
return result;
}
// Uniform in [0, bound] inclusive, unbiased (Lemire with rejection).
std::uint64_t below(std::uint64_t bound) noexcept {
if (bound == 0) return 0;
const std::uint64_t range = bound + 1;
const std::uint64_t limit = range == 0 ? 0 : (~std::uint64_t{0} / range) * range;
std::uint64_t value;
do {
value = next();
} while (limit != 0 && value >= limit);
return value % range;
}
// Uniform in [lo, hi] inclusive.
long inRange(long lo, long hi) noexcept {
if (hi <= lo) return lo;
return lo + static_cast<long>(below(static_cast<std::uint64_t>(hi - lo)));
}
static std::uint64_t randomSeed();
private:
static std::uint64_t rotl(std::uint64_t x, int k) noexcept {
return (x << k) | (x >> (64 - k));
}
static std::uint64_t splitmix(std::uint64_t& seed) noexcept {
std::uint64_t z = (seed += 0x9e3779b97f4a7c15ULL);
z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9ULL;
z = (z ^ (z >> 27)) * 0x94d049bb133111ebULL;
return z ^ (z >> 31);
}
std::uint64_t state_[4]{};
};
} // namespace effector
+98
View File
@@ -0,0 +1,98 @@
#include "effects/scanlines.hpp"
#include <algorithm>
#include <cstring>
#include <thread>
#include <vector>
#include "core/parallel.hpp"
namespace effector {
const std::vector<ParamSpec>& ScanlinesEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeInt("strip_height", "Strip height", 8, 1, 512,
"Height in pixels of each extracted line."),
ParamSpec::makeInt("jitter", "Height jitter", 0, 0, 256,
"Random +/- variation added to the strip height (0 = uniform)."),
ParamSpec::makeBool("keep_order", "Cycle images in order", false,
"Take strips from the images in order instead of picking randomly."),
};
return specs;
}
namespace {
struct Strip {
int dstY;
int height;
int sourceIndex;
int sx;
int sy;
};
} // namespace
Image ScanlinesEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
if (sources.empty()) throw EffectError("No source images.");
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
const int stripHeight = std::max(1L, paramInt(values, "strip_height", 8));
const int jitter = static_cast<int>(std::max(0L, paramInt(values, "jitter", 0)));
const bool keepOrder = paramBool(values, "keep_order", false);
// The plan is drawn sequentially so that a seed always gives the same
// layout; the pixel copying afterwards is what gets parallelised.
std::vector<Strip> plan;
plan.reserve(static_cast<std::size_t>(height / stripHeight) + 8);
int y = 0;
std::size_t index = 0;
while (y < height) {
const int sourceIndex =
keepOrder ? static_cast<int>(index++ % sources.size())
: static_cast<int>(rng.below(sources.size() - 1));
const Image& src = sources[static_cast<std::size_t>(sourceIndex)];
int h = stripHeight;
if (jitter > 0) h += static_cast<int>(rng.inRange(-jitter, jitter));
h = std::max(1, std::min({h, height - y, src.height()}));
const int sy = static_cast<int>(rng.inRange(0, src.height() - h));
const int sx = static_cast<int>(rng.inRange(0, std::max(0, src.width() - width)));
plan.push_back({y, h, sourceIndex, sx, sy});
y += h;
}
Image canvas(width, height);
parallelFor(plan.size(), [&](std::size_t begin, std::size_t end) {
for (std::size_t i = begin; i < end; ++i) {
const Strip& strip = plan[i];
const Image& src = sources[static_cast<std::size_t>(strip.sourceIndex)];
const int copyWidth = std::min(width, src.width() - strip.sx);
const std::size_t copyBytes = static_cast<std::size_t>(copyWidth) * 3;
for (int line = 0; line < strip.height; ++line) {
const std::uint8_t* in = src.row(strip.sy + line) + static_cast<std::size_t>(strip.sx) * 3;
std::uint8_t* out = canvas.row(strip.dstY + line);
std::memcpy(out, in, copyBytes);
// Tile horizontally when the source is narrower than the canvas,
// doubling the filled span on each pass.
std::size_t filled = copyBytes;
const std::size_t total = static_cast<std::size_t>(width) * 3;
while (filled < total) {
const std::size_t chunk = std::min(filled, total - filled);
std::memcpy(out + filled, out, chunk);
filled += chunk;
}
}
}
});
return canvas;
}
} // namespace effector
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include "core/effect.hpp"
namespace effector {
// Fills the canvas top to bottom with horizontal strips cut at random positions
// from the source images.
class ScanlinesEffect : public Effect {
public:
std::string name() const override { return "scanlines"; }
std::string title() const override { return "Scanlines"; }
std::string description() const override {
return "Stacks horizontal strips cut at random positions from the source images, "
"line by line, until the canvas is full.";
}
const std::vector<ParamSpec>& params() const override;
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+19
View File
@@ -0,0 +1,19 @@
#include "gui/app.hpp"
#include <gtkmm.h>
#include "gui/window.hpp"
namespace effector {
int runGui(const Options& initial, int argc, char** argv) {
// Our own parser already consumed argv, so GTK must not see it.
(void)argc;
(void)argv;
auto app = Gtk::Application::create("org.effector.Effector",
Gio::Application::Flags::NON_UNIQUE);
return app->make_window_and_run<EffectorWindow>(0, nullptr, initial);
}
} // namespace effector
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include "cli/options.hpp"
namespace effector {
// Runs the GTK interface with `initial` as the starting state.
int runGui(const Options& initial, int argc, char** argv);
} // namespace effector
+603
View File
@@ -0,0 +1,603 @@
#include "gui/window.hpp"
#include <chrono>
#include <filesystem>
#include <sstream>
#include "core/registry.hpp"
#include "core/rng.hpp"
namespace effector {
namespace {
const char* const kImagePatterns[] = {"*.png", "*.jpg", "*.jpeg", "*.webp", "*.bmp",
"*.tif", "*.tiff", "*.gif", "*.ppm", "*.PNG",
"*.JPG", "*.JPEG", "*.WEBP"};
Glib::RefPtr<Gdk::Texture> toTexture(const Image& image) {
auto bytes = Glib::Bytes::create(image.data(), image.byteSize());
return Gdk::MemoryTexture::create(image.width(), image.height(), Gdk::MemoryTexture::Format::R8G8B8,
bytes, image.stride());
}
Gtk::Label* sectionLabel(const char* text) {
auto* label = Gtk::make_managed<Gtk::Label>(text);
label->set_xalign(0.0F);
label->add_css_class("heading");
return label;
}
} // namespace
EffectorWindow::EffectorWindow(const Options& initial) {
set_title("Effector");
set_default_size(1180, 760);
effects_ = effects();
effect_ = effects_.front();
seed_ = Rng::randomSeed();
renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished));
buildUi();
applyInitial(initial);
suspendRender_ = false;
if (!sources_->empty()) startRender();
}
EffectorWindow::~EffectorWindow() {
debounce_.disconnect();
if (worker_.joinable()) worker_.join();
}
// ------------------------------------------------------------------- UI ---
void EffectorWindow::buildUi() {
auto* header = Gtk::make_managed<Gtk::HeaderBar>();
set_titlebar(*header);
auto* addButton = Gtk::make_managed<Gtk::Button>();
addButton->set_icon_name("list-add-symbolic");
addButton->set_tooltip_text("Add images");
addButton->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onAddImages));
header->pack_start(*addButton);
std::vector<Glib::ustring> titles;
for (const auto& effect : effects_) titles.push_back(effect->title());
effectDropdown_.set_model(Gtk::StringList::create(titles));
effectDropdown_.set_tooltip_text("Effect");
effectDropdown_.property_selected().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onEffectChanged));
header->pack_start(effectDropdown_);
saveButton_.set_sensitive(false);
saveButton_.signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onSave));
header->pack_end(saveButton_);
recreateButton_.set_icon_name("view-refresh-symbolic");
recreateButton_.set_tooltip_text("Recreate with a new seed");
recreateButton_.signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onRecreate));
header->pack_end(recreateButton_);
auto* root = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL);
set_child(*root);
banner_.set_xalign(0.0F);
banner_.set_wrap(true);
banner_.set_visible(false);
banner_.add_css_class("error");
banner_.set_margin(8);
root->append(banner_);
auto* paned = Gtk::make_managed<Gtk::Paned>(Gtk::Orientation::HORIZONTAL);
paned->set_vexpand(true);
paned->set_position(330);
paned->set_shrink_start_child(false);
paned->set_start_child(buildSidebar());
paned->set_end_child(buildPreview());
root->append(*paned);
}
Gtk::Widget& EffectorWindow::buildSidebar() {
auto* sidebar = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
sidebar->set_margin(12);
sidebar->append(*sectionLabel("Source images"));
sourceList_.set_selection_mode(Gtk::SelectionMode::NONE);
sourceList_.add_css_class("boxed-list");
auto* empty = Gtk::make_managed<Gtk::Label>("No images yet");
empty->set_margin_top(18);
empty->set_margin_bottom(18);
sourceList_.set_placeholder(*empty);
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scroller->set_child(sourceList_);
scroller->set_size_request(-1, 170);
scroller->set_vexpand(true);
sidebar->append(*scroller);
sidebar->append(*sectionLabel("Canvas"));
auto* grid = Gtk::make_managed<Gtk::Grid>();
grid->set_column_spacing(8);
grid->set_row_spacing(6);
widthSpin_.set_range(1, 20000);
widthSpin_.set_increments(1, 100);
widthSpin_.set_value(1920);
heightSpin_.set_range(1, 20000);
heightSpin_.set_increments(1, 100);
heightSpin_.set_value(1080);
for (auto* spin : {&widthSpin_, &heightSpin_}) {
spin->set_hexpand(true);
spin->signal_value_changed().connect(sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
}
auto* widthLabel = Gtk::make_managed<Gtk::Label>("Width");
widthLabel->set_xalign(0.0F);
auto* heightLabel = Gtk::make_managed<Gtk::Label>("Height");
heightLabel->set_xalign(0.0F);
grid->attach(*widthLabel, 0, 0);
grid->attach(widthSpin_, 1, 0);
grid->attach(*heightLabel, 0, 1);
grid->attach(heightSpin_, 1, 1);
sidebar->append(*grid);
sidebar->append(*sectionLabel("Effect settings"));
paramGrid_.set_column_spacing(8);
paramGrid_.set_row_spacing(6);
sidebar->append(paramGrid_);
buildParamWidgets();
auto* seedBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
auto* seedLabel = Gtk::make_managed<Gtk::Label>("Seed");
seedLabel->set_xalign(0.0F);
seedBox->append(*seedLabel);
seedEntry_.set_hexpand(true);
seedEntry_.set_text(std::to_string(seed_));
seedEntry_.signal_activate().connect(sigc::mem_fun(*this, &EffectorWindow::onSeedEntered));
seedBox->append(seedEntry_);
sidebar->append(*seedBox);
return *sidebar;
}
Gtk::Widget& EffectorWindow::buildPreview() {
picture_.set_can_shrink(true);
picture_.set_content_fit(Gtk::ContentFit::CONTAIN);
placeholder_.set_text("Add source images to start.");
placeholder_.set_wrap(true);
placeholder_.add_css_class("dim-label");
previewStack_.add(placeholder_, "placeholder");
previewStack_.add(picture_, "picture");
previewStack_.set_visible_child("placeholder");
spinner_.set_halign(Gtk::Align::CENTER);
spinner_.set_valign(Gtk::Align::CENTER);
auto* overlay = Gtk::make_managed<Gtk::Overlay>();
overlay->set_hexpand(true);
overlay->set_vexpand(true);
overlay->set_child(previewStack_);
overlay->add_overlay(spinner_);
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL);
box->append(*overlay);
status_.set_margin_top(6);
status_.set_margin_bottom(8);
status_.add_css_class("dim-label");
box->append(status_);
return *box;
}
void EffectorWindow::buildParamWidgets() {
while (auto* child = paramGrid_.get_first_child()) paramGrid_.remove(*child);
paramWidgets_.clear();
ownedWidgets_.clear();
int row = 0;
for (const auto& spec : effect_->params()) {
auto label = std::make_unique<Gtk::Label>(spec.label);
label->set_xalign(0.0F);
if (!spec.help.empty()) label->set_tooltip_text(spec.help);
std::unique_ptr<Gtk::Widget> widget;
switch (spec.type) {
case ParamSpec::Type::Bool: {
auto toggle = std::make_unique<Gtk::Switch>();
toggle->set_halign(Gtk::Align::START);
toggle->set_active(spec.defaultValue != 0.0);
toggle->property_active().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(toggle);
break;
}
case ParamSpec::Type::Int:
case ParamSpec::Type::Double: {
auto spin = std::make_unique<Gtk::SpinButton>();
spin->set_range(spec.minimum, spec.maximum);
spin->set_increments(spec.step, spec.step * 10);
spin->set_digits(spec.type == ParamSpec::Type::Int ? 0 : spec.digits);
spin->set_value(spec.defaultValue);
spin->signal_value_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(spin);
break;
}
case ParamSpec::Type::Choice: {
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
auto dropdown = std::make_unique<Gtk::DropDown>();
dropdown->set_model(Gtk::StringList::create(choices));
for (std::size_t i = 0; i < spec.choices.size(); ++i) {
if (spec.choices[i] == spec.defaultChoice) dropdown->set_selected(i);
}
dropdown->property_selected().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(dropdown);
break;
}
}
widget->set_hexpand(true);
paramGrid_.attach(*label, 0, row);
paramGrid_.attach(*widget, 1, row);
paramWidgets_.push_back({spec, widget.get()});
ownedWidgets_.push_back(std::move(label));
ownedWidgets_.push_back(std::move(widget));
++row;
}
}
ParamValues EffectorWindow::paramValues() const {
ParamValues values = effect_->defaults();
for (const auto& entry : paramWidgets_) {
switch (entry.spec.type) {
case ParamSpec::Type::Bool:
values[entry.spec.key] = static_cast<Gtk::Switch*>(entry.widget)->get_active();
break;
case ParamSpec::Type::Int:
values[entry.spec.key] =
static_cast<long>(static_cast<Gtk::SpinButton*>(entry.widget)->get_value());
break;
case ParamSpec::Type::Double:
values[entry.spec.key] = static_cast<Gtk::SpinButton*>(entry.widget)->get_value();
break;
case ParamSpec::Type::Choice: {
const auto index = static_cast<Gtk::DropDown*>(entry.widget)->get_selected();
if (index < entry.spec.choices.size()) values[entry.spec.key] = entry.spec.choices[index];
break;
}
}
}
return values;
}
void EffectorWindow::applyInitial(const Options& initial) {
for (std::size_t i = 0; i < effects_.size(); ++i) {
if (effects_[i]->name() == initial.effect) effectDropdown_.set_selected(i);
}
widthSpin_.set_value(initial.width);
heightSpin_.set_value(initial.height);
if (initial.seed) {
seed_ = *initial.seed;
seedEntry_.set_text(std::to_string(seed_));
}
for (const auto& entry : paramWidgets_) {
const auto it = initial.params.find(entry.spec.key);
if (it == initial.params.end()) continue;
switch (entry.spec.type) {
case ParamSpec::Type::Bool:
static_cast<Gtk::Switch*>(entry.widget)
->set_active(paramBool(initial.params, entry.spec.key, false));
break;
case ParamSpec::Type::Int:
static_cast<Gtk::SpinButton*>(entry.widget)
->set_value(static_cast<double>(paramInt(initial.params, entry.spec.key, 0)));
break;
case ParamSpec::Type::Double:
static_cast<Gtk::SpinButton*>(entry.widget)
->set_value(paramDouble(initial.params, entry.spec.key, 0.0));
break;
case ParamSpec::Type::Choice: {
const auto value = paramChoice(initial.params, entry.spec.key, entry.spec.defaultChoice);
for (std::size_t i = 0; i < entry.spec.choices.size(); ++i) {
if (entry.spec.choices[i] == value) {
static_cast<Gtk::DropDown*>(entry.widget)->set_selected(i);
}
}
break;
}
}
}
if (!initial.images.empty()) addPaths(initial.images);
}
// -------------------------------------------------------------- sources ---
// Replaces the source snapshot, moving the images across when nothing else
// holds the old list (the common case) and copying only if a render is reading it.
template <typename Mutate>
void EffectorWindow::mutateSources(Mutate&& mutate) {
auto next = std::make_shared<SourceList>();
if (sources_.use_count() == 1) {
*next = std::move(const_cast<SourceList&>(*sources_));
} else {
*next = *sources_;
}
mutate(*next);
sources_ = std::move(next);
}
void EffectorWindow::addPaths(const std::vector<std::string>& paths) {
std::vector<LoadFailure> failures;
auto images = loadImages(paths, failures);
for (const auto& image : images) appendSourceRow(image.path());
mutateSources([&images](SourceList& list) {
for (auto& image : images) list.push_back(std::move(image));
});
if (!failures.empty()) {
std::string names;
for (const auto& failure : failures) {
if (!names.empty()) names += ", ";
names += std::filesystem::path(failure.path).filename().string();
}
showError("Could not read: " + names);
} else if (!images.empty()) {
clearError();
}
requestRender();
}
void EffectorWindow::appendSourceRow(const std::string& path) {
auto* row = Gtk::make_managed<Gtk::ListBoxRow>();
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
box->set_margin_top(4);
box->set_margin_bottom(4);
box->set_margin_start(8);
box->set_margin_end(4);
auto* label = Gtk::make_managed<Gtk::Label>(std::filesystem::path(path).filename().string());
label->set_xalign(0.0F);
label->set_hexpand(true);
label->set_ellipsize(Pango::EllipsizeMode::END);
label->set_tooltip_text(path);
box->append(*label);
auto* remove = Gtk::make_managed<Gtk::Button>();
remove->set_icon_name("window-close-symbolic");
remove->add_css_class("flat");
remove->signal_clicked().connect([this, row] { removeSource(row); });
box->append(*remove);
row->set_child(*box);
sourceList_.append(*row);
}
void EffectorWindow::removeSource(Gtk::ListBoxRow* row) {
const int index = row->get_index();
mutateSources([index](SourceList& list) {
if (index >= 0 && static_cast<std::size_t>(index) < list.size()) {
list.erase(list.begin() + index);
}
});
sourceList_.remove(*row);
if (!sources_->empty()) {
requestRender();
} else {
result_ = Image();
saveButton_.set_sensitive(false);
previewStack_.set_visible_child("placeholder");
status_.set_text("");
}
}
void EffectorWindow::onAddImages() {
auto dialog = Gtk::FileDialog::create();
dialog->set_title("Select source images");
auto filter = Gtk::FileFilter::create();
filter->set_name("Images");
for (const char* pattern : kImagePatterns) filter->add_pattern(pattern);
auto filters = Gio::ListStore<Gtk::FileFilter>::create();
filters->append(filter);
dialog->set_filters(filters);
dialog->set_default_filter(filter);
dialog->open_multiple(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
onImagesChosen(result, dialog);
});
}
void EffectorWindow::onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& dialog) {
std::vector<std::string> paths;
try {
for (const auto& file : dialog->open_multiple_finish(result)) {
if (file) paths.push_back(file->get_path());
}
} catch (const Glib::Error&) {
return; // cancelled
}
if (!paths.empty()) addPaths(paths);
}
// ------------------------------------------------------------ rendering ---
void EffectorWindow::onEffectChanged() {
const auto index = effectDropdown_.get_selected();
if (index >= effects_.size()) return;
effect_ = effects_[index];
buildParamWidgets();
requestRender();
}
void EffectorWindow::onParamChanged() { requestRender(); }
void EffectorWindow::onSeedEntered() {
try {
seed_ = std::stoull(seedEntry_.get_text());
} catch (const std::exception&) {
seedEntry_.set_text(std::to_string(seed_));
return;
}
requestRender();
}
void EffectorWindow::onRecreate() {
seed_ = Rng::randomSeed();
seedEntry_.set_text(std::to_string(seed_));
if (sources_->empty()) {
showError("Add at least one source image first.");
return;
}
requestRender();
}
void EffectorWindow::requestRender() {
if (suspendRender_ || sources_->empty()) return;
// Spin buttons fire on every step while they are held down; coalesce those
// into one render instead of queueing a job per tick.
debounce_.disconnect();
debounce_ = Glib::signal_timeout().connect(
[this] {
startRender();
return false;
},
120);
}
void EffectorWindow::startRender() {
if (sources_->empty()) return;
if (rendering_) {
staleResult_ = true; // a fresh render follows this one
return;
}
rendering_ = true;
const auto sources = sources_;
const auto effect = effect_;
const int width = static_cast<int>(widthSpin_.get_value());
const int height = static_cast<int>(heightSpin_.get_value());
const std::uint64_t seed = seed_;
const ParamValues values = paramValues();
spinner_.start();
recreateButton_.set_sensitive(false);
worker_ = std::thread([this, sources, effect, width, height, seed, values] {
Image image;
std::string error;
const auto start = std::chrono::steady_clock::now();
try {
Rng rng(seed);
image = effect->render(*sources, width, height, rng, values);
} catch (const std::exception& exception) {
error = exception.what();
}
const auto millis = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - start)
.count();
{
std::lock_guard<std::mutex> lock(resultMutex_);
pendingImage_ = std::move(image);
pendingError_ = std::move(error);
pendingMillis_ = millis;
}
renderDone_.emit();
});
}
void EffectorWindow::onRenderFinished() {
if (worker_.joinable()) worker_.join();
rendering_ = false;
Image image;
std::string error;
double millis = 0.0;
{
std::lock_guard<std::mutex> lock(resultMutex_);
image = std::move(pendingImage_);
error = pendingError_;
millis = pendingMillis_;
}
if (staleResult_) { // settings changed while this one was running
staleResult_ = false;
startRender();
return;
}
spinner_.stop();
recreateButton_.set_sensitive(true);
if (!error.empty()) {
result_ = Image();
saveButton_.set_sensitive(false);
showError(error);
return;
}
clearError();
result_ = std::move(image);
picture_.set_paintable(toTexture(result_));
previewStack_.set_visible_child("picture");
saveButton_.set_sensitive(true);
std::ostringstream status;
status.setf(std::ios::fixed);
status.precision(1);
status << effect_->title() << " · " << result_.width() << "×" << result_.height() << " · "
<< sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · seed "
<< seed_ << " · " << millis << " ms";
status_.set_text(status.str());
}
// --------------------------------------------------------------- saving ---
void EffectorWindow::onSave() {
if (result_.empty()) return;
auto dialog = Gtk::FileDialog::create();
dialog->set_title("Save image");
dialog->set_initial_name("effector-" + effect_->name() + "-" + std::to_string(seed_) + ".png");
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
onSaveChosen(result, dialog);
});
}
void EffectorWindow::onSaveChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& dialog) {
std::filesystem::path path;
try {
const auto file = dialog->save_finish(result);
if (!file) return;
path = file->get_path();
} catch (const Glib::Error&) {
return; // cancelled
}
if (path.extension().empty()) path.replace_extension(".png");
try {
result_.save(path.string());
} catch (const std::exception& error) {
showError(std::string("Could not save: ") + error.what());
return;
}
clearError();
status_.set_text("Saved " + path.string());
}
// --------------------------------------------------------------- errors ---
void EffectorWindow::showError(const std::string& message) {
banner_.set_text(message);
banner_.set_visible(true);
}
void EffectorWindow::clearError() { banner_.set_visible(false); }
} // namespace effector
+103
View File
@@ -0,0 +1,103 @@
#pragma once
#include <gtkmm.h>
#include <cstdint>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
#include <vector>
#include "cli/options.hpp"
#include "core/effect.hpp"
#include "core/image.hpp"
namespace effector {
class EffectorWindow : public Gtk::ApplicationWindow {
public:
explicit EffectorWindow(const Options& initial);
~EffectorWindow() override;
private:
void buildUi();
Gtk::Widget& buildSidebar();
Gtk::Widget& buildPreview();
void buildParamWidgets();
ParamValues paramValues() const;
void applyInitial(const Options& initial);
void requestRender(); // coalesced: restarts the short debounce timer
void startRender(); // starts a worker, or marks the result stale
void onAddImages();
void onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& dialog);
void addPaths(const std::vector<std::string>& paths);
void appendSourceRow(const std::string& path);
void removeSource(Gtk::ListBoxRow* row);
void onEffectChanged();
void onParamChanged();
void onSeedEntered();
void onRecreate();
void onSave();
void onSaveChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& dialog);
void onRenderFinished(); // runs on the main thread via the dispatcher
// Sources are handed to the worker as an immutable snapshot, so adding or
// removing an image while a render is in flight cannot race with it.
using SourceList = std::vector<Image>;
template <typename Mutate>
void mutateSources(Mutate&& mutate);
void showError(const std::string& message);
void clearError();
// Model
std::shared_ptr<const SourceList> sources_ = std::make_shared<SourceList>();
std::vector<EffectPtr> effects_;
EffectPtr effect_;
Image result_;
std::uint64_t seed_ = 0;
bool suspendRender_ = true;
// Render job
Glib::Dispatcher renderDone_;
std::thread worker_;
std::mutex resultMutex_;
bool rendering_ = false;
bool staleResult_ = false;
sigc::connection debounce_;
Image pendingImage_;
std::string pendingError_;
double pendingMillis_ = 0.0;
// Widgets
Gtk::Label banner_;
Gtk::ListBox sourceList_;
Gtk::SpinButton widthSpin_;
Gtk::SpinButton heightSpin_;
Gtk::Grid paramGrid_;
Gtk::Entry seedEntry_;
Gtk::DropDown effectDropdown_;
Gtk::Button recreateButton_{"Recreate"};
Gtk::Button saveButton_{"Save As…"};
Gtk::Picture picture_;
Gtk::Stack previewStack_;
Gtk::Label placeholder_;
Gtk::Spinner spinner_;
Gtk::Label status_;
struct ParamWidget {
ParamSpec spec;
Gtk::Widget* widget = nullptr;
};
std::vector<ParamWidget> paramWidgets_;
std::vector<std::unique_ptr<Gtk::Widget>> ownedWidgets_;
};
} // namespace effector
+70
View File
@@ -0,0 +1,70 @@
#include <chrono>
#include <cstdio>
#include <iomanip>
#include <iostream>
#include "cli/options.hpp"
#include "core/effect.hpp"
#include "core/image.hpp"
#include "core/registry.hpp"
#include "core/rng.hpp"
#include "gui/app.hpp"
namespace {
int renderHeadless(const effector::Options& options) {
const auto effect = effector::effectByName(options.effect);
std::vector<effector::LoadFailure> failures;
auto sources = effector::loadImages(options.images, failures);
for (const auto& failure : failures) {
std::cerr << "effector: skipping " << failure.path << ": " << failure.message << "\n";
}
if (sources.empty()) {
std::cerr << "effector: no usable source images\n";
return 2;
}
const std::uint64_t seed = options.seed.value_or(effector::Rng::randomSeed());
effector::Rng rng(seed);
auto values = effect->defaults();
for (const auto& [key, value] : options.params) values[key] = value;
const auto start = std::chrono::steady_clock::now();
const auto image = effect->render(sources, options.width, options.height, rng, values);
const auto renderMillis =
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
image.save(options.output);
std::cout << options.output << " " << options.width << "x" << options.height
<< " effect=" << effect->name() << " seed=" << seed << " render="
<< std::fixed << std::setprecision(1) << renderMillis << " ms\n";
return 0;
}
} // namespace
int main(int argc, char** argv) {
try {
const auto options = effector::parseOptions(argc, argv);
if (options.showHelp) {
std::cout << effector::usage(argv[0]);
return 0;
}
if (options.listEffects) {
for (const auto& effect : effector::effects()) {
std::cout << effect->name() << "\t" << effect->title() << " - "
<< effect->description() << "\n";
}
return 0;
}
if (!options.output.empty()) return renderHeadless(options);
return effector::runGui(options, argc, argv);
} catch (const std::exception& error) {
std::cerr << "effector: " << error.what() << "\n";
return 1;
}
}
+5
View File
@@ -0,0 +1,5 @@
add_executable(effector_tests core_tests.cpp)
target_link_libraries(effector_tests PRIVATE effector_core)
target_compile_options(effector_tests PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME core COMMAND effector_tests)
+149
View File
@@ -0,0 +1,149 @@
// Small self-contained checks for the pieces that have no GUI dependency.
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <iostream>
#include <string>
#include <vector>
#include "core/registry.hpp"
#include "core/rng.hpp"
#include "effects/scanlines.hpp"
namespace {
int failures = 0;
void check(bool condition, const std::string& what) {
if (condition) {
std::cout << " ok " << what << "\n";
} else {
std::cout << " FAIL " << what << "\n";
++failures;
}
}
effector::Image gradient(int width, int height, int shade) {
effector::Image image(width, height);
for (int y = 0; y < height; ++y) {
auto* row = image.row(y);
for (int x = 0; x < width; ++x) {
row[x * 3 + 0] = static_cast<std::uint8_t>((x + shade) & 0xFF);
row[x * 3 + 1] = static_cast<std::uint8_t>((y + shade) & 0xFF);
row[x * 3 + 2] = static_cast<std::uint8_t>(shade);
}
}
return image;
}
bool sameBytes(const effector::Image& a, const effector::Image& b) {
if (a.width() != b.width() || a.height() != b.height()) return false;
return std::equal(a.data(), a.data() + a.byteSize(), b.data());
}
effector::Image render(const std::vector<effector::Image>& sources, int width, int height,
std::uint64_t seed, const effector::ParamValues& values) {
effector::ScanlinesEffect effect;
effector::Rng rng(seed);
auto merged = effect.defaults();
for (const auto& [key, value] : values) merged[key] = value;
return effect.render(sources, width, height, rng, merged);
}
} // namespace
int main() {
using namespace effector;
std::cout << "rng\n";
{
Rng a(42);
Rng b(42);
bool identical = true;
for (int i = 0; i < 1000; ++i) identical = identical && a.next() == b.next();
check(identical, "same seed yields the same stream");
Rng c(7);
bool inBounds = true;
for (int i = 0; i < 10000; ++i) {
const long value = c.inRange(-4, 9);
inBounds = inBounds && value >= -4 && value <= 9;
}
check(inBounds, "inRange stays within bounds");
}
std::cout << "registry\n";
{
check(!effects().empty(), "registry is populated");
check(effectByName("scanlines")->title() == "Scanlines", "lookup by name");
bool threw = false;
try {
effectByName("nope");
} catch (const EffectError&) {
threw = true;
}
check(threw, "unknown effect throws");
}
std::cout << "scanlines\n";
{
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90),
gradient(300, 200, 200)};
const auto first = render(sources, 800, 600, 1, {{"strip_height", 6L}, {"jitter", 3L}});
const auto again = render(sources, 800, 600, 1, {{"strip_height", 6L}, {"jitter", 3L}});
const auto other = render(sources, 800, 600, 2, {{"strip_height", 6L}, {"jitter", 3L}});
check(first.width() == 800 && first.height() == 600, "canvas has the requested size");
check(sameBytes(first, again), "same seed reproduces the image");
check(!sameBytes(first, other), "a different seed changes the image");
const auto thin = render(sources, 400, 300, 7, {{"strip_height", 1L}});
check(thin.width() == 400 && thin.height() == 300, "strip height of 1");
// Canvas wider and taller than the only source: exercises tiling and the
// clamp of the strip height to the source height.
std::vector<Image> small = {gradient(300, 200, 5)};
const auto tiled = render(small, 1000, 700, 9, {{"strip_height", 400L}, {"jitter", 50L}});
check(tiled.width() == 1000 && tiled.height() == 700, "tiling a narrow source");
bool tiles = true;
for (int y = 0; y < tiled.height(); ++y) {
const auto* row = tiled.row(y);
for (int c = 0; c < 3; ++c) tiles = tiles && row[c] == row[300 * 3 + c];
}
check(tiles, "tiled columns repeat with the source width");
const auto ordered = render(sources, 500, 500, 3, {{"strip_height", 20L}, {"keep_order", true}});
check(ordered.height() == 500, "keep_order fills the canvas");
bool threw = false;
try {
render({}, 100, 100, 1, {});
} catch (const EffectError&) {
threw = true;
}
check(threw, "no sources throws");
}
std::cout << "image io\n";
{
const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png";
const auto original = gradient(64, 48, 3);
original.save(path.string());
const auto reloaded = Image::load(path.string());
check(sameBytes(original, reloaded), "png round trip is lossless");
std::filesystem::remove(path);
bool threw = false;
try {
Image::load("/definitely/not/here.png");
} catch (const std::exception&) {
threw = true;
}
check(threw, "missing file throws");
}
std::cout << (failures == 0 ? "\nall checks passed\n" : "\n" + std::to_string(failures) + " failed\n");
return failures == 0 ? 0 : 1;
}