Effector: C++/CMake image effects app

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

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

The Python prototype is kept under python-reference/.
This commit is contained in:
hans
2026-09-18 14:28:13 +02:00
commit 925a719da0
36 changed files with 2859 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
#include "cli/options.hpp"
#include <charconv>
#include <cstring>
#include <sstream>
#include "core/effect.hpp"
#include "core/registry.hpp"
namespace effector {
namespace {
bool parseLong(const std::string& text, long& out) {
const char* begin = text.data();
const char* end = begin + text.size();
const auto result = std::from_chars(begin, end, out);
return result.ec == std::errc{} && result.ptr == end;
}
bool parseSize(const std::string& text, int& width, int& height) {
const auto sep = text.find_first_of("xX*,");
if (sep == std::string::npos) return false;
long w = 0;
long h = 0;
if (!parseLong(text.substr(0, sep), w) || !parseLong(text.substr(sep + 1), h)) return false;
if (w <= 0 || h <= 0) return false;
width = static_cast<int>(w);
height = static_cast<int>(h);
return true;
}
// Every effect's params share one flag namespace, exactly as in the interface.
const ParamSpec* findSpec(const std::string& flag) {
for (const auto& effect : effects()) {
for (const auto& spec : effect->params()) {
if (spec.flag() == flag) return &spec;
}
}
return nullptr;
}
std::string requireValue(int argc, char** argv, int& i, const std::string& flag) {
if (i + 1 >= argc) throw EffectError(flag + " needs a value");
return argv[++i];
}
} // namespace
Options parseOptions(int argc, char** argv) {
Options options;
options.effect = defaultEffectName();
for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i];
if (arg == "-h" || arg == "--help") {
options.showHelp = true;
} else if (arg == "--list-effects") {
options.listEffects = true;
} else if (arg == "-e" || arg == "--effect") {
options.effect = requireValue(argc, argv, i, arg);
effectByName(options.effect); // validate now, fail early
} else if (arg == "-o" || arg == "--output") {
options.output = requireValue(argc, argv, i, arg);
} else if (arg == "-s" || arg == "--size") {
const std::string value = requireValue(argc, argv, i, arg);
if (!parseSize(value, options.width, options.height)) {
throw EffectError("invalid size '" + value + "', expected e.g. 1920x1080");
}
} else if (arg == "--seed") {
const std::string value = requireValue(argc, argv, i, arg);
long seed = 0;
if (!parseLong(value, seed)) throw EffectError("invalid seed '" + value + "'");
options.seed = static_cast<std::uint64_t>(seed);
} else if (arg.rfind("--no-", 0) == 0) {
const std::string flag = "--" + arg.substr(5);
const ParamSpec* spec = findSpec(flag);
if (spec == nullptr || spec->type != ParamSpec::Type::Bool) {
throw EffectError("unknown option '" + arg + "'");
}
options.params[spec->key] = false;
} else if (arg.rfind("--", 0) == 0) {
const ParamSpec* spec = findSpec(arg);
if (spec == nullptr) throw EffectError("unknown option '" + arg + "'");
switch (spec->type) {
case ParamSpec::Type::Bool:
options.params[spec->key] = true;
break;
case ParamSpec::Type::Int: {
long value = 0;
const std::string text = requireValue(argc, argv, i, arg);
if (!parseLong(text, value)) {
throw EffectError(arg + ": '" + text + "' is not a whole number");
}
options.params[spec->key] = value;
break;
}
case ParamSpec::Type::Double: {
const std::string text = requireValue(argc, argv, i, arg);
try {
options.params[spec->key] = std::stod(text);
} catch (const std::exception&) {
throw EffectError(arg + ": '" + text + "' is not a number");
}
break;
}
case ParamSpec::Type::Choice: {
const std::string text = requireValue(argc, argv, i, arg);
bool valid = false;
for (const auto& choice : spec->choices) valid = valid || choice == text;
if (!valid) throw EffectError(arg + ": unknown value '" + text + "'");
options.params[spec->key] = text;
break;
}
}
} else if (arg.rfind("-", 0) == 0 && arg.size() > 1) {
throw EffectError("unknown option '" + arg + "'");
} else {
options.images.push_back(arg);
}
}
return options;
}
std::string usage(const char* programName) {
std::ostringstream out;
out << "Usage: " << programName << " [options] [image ...]\n\n"
<< "Image effects app. Runs the GTK interface unless --output is given.\n\n"
<< "Options:\n"
<< " -e, --effect NAME effect to apply (default: " << defaultEffectName() << ")\n"
<< " -o, --output FILE render headlessly to this file and exit\n"
<< " -s, --size WxH canvas size (default: 1920x1080)\n"
<< " --seed N random seed (default: random)\n"
<< " --list-effects list available effects and exit\n"
<< " -h, --help show this help and exit\n";
for (const auto& effect : effects()) {
out << "\n" << effect->title() << " options:\n";
for (const auto& spec : effect->params()) {
std::string flag = spec.flag();
if (spec.type != ParamSpec::Type::Bool) flag += " VALUE";
out << " " << flag;
if (flag.size() < 20) out << std::string(20 - flag.size(), ' ');
out << " " << (spec.help.empty() ? spec.label : spec.help) << "\n";
}
}
return out.str();
}
} // namespace effector
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include "core/params.hpp"
namespace effector {
struct Options {
std::vector<std::string> images;
std::string effect;
std::string output; // empty -> launch the interface
int width = 1920;
int height = 1080;
std::optional<std::uint64_t> seed;
ParamValues params; // only the keys the user actually passed
bool listEffects = false;
bool showHelp = false;
};
// Throws EffectError on bad input. Effect flags are generated from the
// registry, so a new effect gets its flags without touching this file.
Options parseOptions(int argc, char** argv);
std::string usage(const char* programName);
} // namespace effector