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/.
111 lines
4.0 KiB
Python
111 lines
4.0 KiB
Python
"""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())
|