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,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*"]
|
||||
Reference in New Issue
Block a user