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:
@@ -0,0 +1,7 @@
|
||||
build/
|
||||
build-*/
|
||||
compile_commands.json
|
||||
.cache/
|
||||
__pycache__/
|
||||
*.o
|
||||
*.a
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
"""effector - a pluggable image effects app."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,5 @@
|
||||
import sys
|
||||
|
||||
from .cli import main
|
||||
|
||||
sys.exit(main())
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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]])
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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*"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user