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
+26
View File
@@ -0,0 +1,26 @@
#include "core/effect.hpp"
namespace effector {
ParamValues Effect::defaults() const {
ParamValues values;
for (const auto& spec : params()) {
switch (spec.type) {
case ParamSpec::Type::Int:
values[spec.key] = static_cast<long>(spec.defaultValue);
break;
case ParamSpec::Type::Double:
values[spec.key] = spec.defaultValue;
break;
case ParamSpec::Type::Bool:
values[spec.key] = spec.defaultValue != 0.0;
break;
case ParamSpec::Type::Choice:
values[spec.key] = spec.defaultChoice;
break;
}
}
return values;
}
} // namespace effector
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <memory>
#include <stdexcept>
#include <string>
#include <vector>
#include "core/image.hpp"
#include "core/params.hpp"
#include "core/rng.hpp"
namespace effector {
class EffectError : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
// Base class for every effect. Canvas size and the random source are common to
// all of them; params() covers only the effect-specific knobs.
class Effect {
public:
virtual ~Effect() = default;
virtual std::string name() const = 0;
virtual std::string title() const = 0;
virtual std::string description() const = 0;
virtual const std::vector<ParamSpec>& params() const = 0;
virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const = 0;
ParamValues defaults() const;
};
using EffectPtr = std::shared_ptr<const Effect>;
} // namespace effector
+140
View File
@@ -0,0 +1,140 @@
#include "core/image.hpp"
#include <gdk-pixbuf/gdk-pixbuf.h>
#include "core/parallel.hpp"
#include <algorithm>
#include <cctype>
#include <cstring>
#include <memory>
#include <mutex>
#include <stdexcept>
namespace effector {
namespace {
// The plain C pixbuf API is used on purpose: it needs no gtkmm wrapper
// registration and no display, so the core renders headlessly.
using PixbufPtr = std::unique_ptr<GdkPixbuf, decltype(&g_object_unref)>;
PixbufPtr wrap(GdkPixbuf* pixbuf) { return PixbufPtr(pixbuf, &g_object_unref); }
std::string takeError(GError* error) {
std::string message = error != nullptr && error->message != nullptr ? error->message
: "unknown error";
if (error != nullptr) g_error_free(error);
return message;
}
std::string lowerExtension(const std::string& path) {
const auto dot = path.find_last_of('.');
if (dot == std::string::npos) return {};
std::string ext = path.substr(dot + 1);
std::transform(ext.begin(), ext.end(), ext.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return ext;
}
} // namespace
Image::Image(int width, int height)
: width_(width), height_(height),
pixels_(static_cast<std::size_t>(std::max(width, 0)) * 3 *
static_cast<std::size_t>(std::max(height, 0))) {}
Image Image::load(const std::string& path) {
GError* error = nullptr;
PixbufPtr pixbuf = wrap(gdk_pixbuf_new_from_file(path.c_str(), &error));
if (!pixbuf) throw std::runtime_error(takeError(error));
if (GdkPixbuf* oriented = gdk_pixbuf_apply_embedded_orientation(pixbuf.get())) {
pixbuf = wrap(oriented);
}
const int width = gdk_pixbuf_get_width(pixbuf.get());
const int height = gdk_pixbuf_get_height(pixbuf.get());
const int channels = gdk_pixbuf_get_n_channels(pixbuf.get());
const int srcStride = gdk_pixbuf_get_rowstride(pixbuf.get());
const guint8* src = gdk_pixbuf_get_pixels(pixbuf.get());
Image image(width, height);
for (int y = 0; y < height; ++y) {
const guint8* in = src + static_cast<std::size_t>(y) * srcStride;
std::uint8_t* out = image.row(y);
if (channels == 3) {
std::memcpy(out, in, static_cast<std::size_t>(width) * 3);
} else {
// Flatten alpha onto white, matching what a viewer would show.
for (int x = 0; x < width; ++x) {
const int a = in[x * channels + 3];
for (int c = 0; c < 3; ++c) {
const int value = in[x * channels + c] * a + 255 * (255 - a);
out[x * 3 + c] = static_cast<std::uint8_t>((value + 127) / 255);
}
}
}
}
image.setPath(path);
return image;
}
void Image::save(const std::string& path) const {
if (empty()) throw std::runtime_error("nothing to save");
PixbufPtr pixbuf = wrap(gdk_pixbuf_new(GDK_COLORSPACE_RGB, FALSE, 8, width_, height_));
if (!pixbuf) throw std::runtime_error("out of memory");
const int dstStride = gdk_pixbuf_get_rowstride(pixbuf.get());
guint8* dst = gdk_pixbuf_get_pixels(pixbuf.get());
for (int y = 0; y < height_; ++y) {
std::memcpy(dst + static_cast<std::size_t>(y) * dstStride, row(y), stride());
}
const std::string ext = lowerExtension(path);
std::string type = "png";
if (ext == "jpg" || ext == "jpeg") type = "jpeg";
else if (ext == "webp") type = "webp";
else if (ext == "tif" || ext == "tiff") type = "tiff";
else if (ext == "bmp") type = "bmp";
GError* error = nullptr;
const gboolean saved =
type == "jpeg"
? gdk_pixbuf_save(pixbuf.get(), path.c_str(), type.c_str(), &error, "quality", "95",
nullptr)
: gdk_pixbuf_save(pixbuf.get(), path.c_str(), type.c_str(), &error, nullptr);
if (saved == FALSE) throw std::runtime_error(takeError(error));
}
std::vector<Image> loadImages(const std::vector<std::string>& paths,
std::vector<LoadFailure>& failures) {
// Decoding is the dominant cost of a headless run, and the files are
// independent, so they are decoded in parallel. Slots keep the result in
// the order the paths were given.
std::vector<Image> slots(paths.size());
std::vector<std::string> errors(paths.size());
parallelFor(paths.size(), [&](std::size_t begin, std::size_t end) {
for (std::size_t i = begin; i < end; ++i) {
try {
slots[i] = Image::load(paths[i]);
} catch (const std::exception& error) {
errors[i] = error.what();
}
}
}, 1);
std::vector<Image> images;
images.reserve(paths.size());
for (std::size_t i = 0; i < paths.size(); ++i) {
if (errors[i].empty()) {
images.push_back(std::move(slots[i]));
} else {
failures.push_back({paths[i], errors[i]});
}
}
return images;
}
} // namespace effector
+56
View File
@@ -0,0 +1,56 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
namespace effector {
// Tightly packed 8-bit RGB. Row stride is always width * 3, which lets the
// effects work with plain memcpy and hands GDK a buffer it can upload as is.
class Image {
public:
Image() = default;
Image(int width, int height);
int width() const noexcept { return width_; }
int height() const noexcept { return height_; }
std::size_t stride() const noexcept { return static_cast<std::size_t>(width_) * 3; }
bool empty() const noexcept { return width_ <= 0 || height_ <= 0; }
std::uint8_t* row(int y) noexcept { return pixels_.data() + static_cast<std::size_t>(y) * stride(); }
const std::uint8_t* row(int y) const noexcept {
return pixels_.data() + static_cast<std::size_t>(y) * stride();
}
std::uint8_t* data() noexcept { return pixels_.data(); }
const std::uint8_t* data() const noexcept { return pixels_.data(); }
std::size_t byteSize() const noexcept { return pixels_.size(); }
const std::string& path() const noexcept { return path_; }
void setPath(std::string path) { path_ = std::move(path); }
// Decodes via GdkPixbuf (PNG, JPEG, WebP, TIFF, BMP, ...), applying the
// embedded EXIF orientation. Throws std::runtime_error on failure.
static Image load(const std::string& path);
// Encodes by file extension; png is used when the extension is unknown.
void save(const std::string& path) const;
private:
int width_ = 0;
int height_ = 0;
std::vector<std::uint8_t> pixels_;
std::string path_;
};
struct LoadFailure {
std::string path;
std::string message;
};
// Loads every path, collecting rather than throwing on individual failures.
std::vector<Image> loadImages(const std::vector<std::string>& paths,
std::vector<LoadFailure>& failures);
} // namespace effector
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <algorithm>
#include <cstddef>
#include <functional>
#include <thread>
#include <vector>
namespace effector {
// Splits [0, count) into contiguous chunks across the hardware threads and runs
// `work(begin, end)` on each. `grain` is the smallest number of items worth
// giving a thread of its own; below that the work runs inline.
template <typename Work>
void parallelFor(std::size_t count, Work&& work, std::size_t grain = 16) {
if (count == 0) return;
unsigned hardware = std::thread::hardware_concurrency();
if (hardware == 0) hardware = 1;
const std::size_t workers =
std::min<std::size_t>(hardware, (count + grain - 1) / std::max<std::size_t>(grain, 1));
if (workers <= 1) {
work(std::size_t{0}, count);
return;
}
std::vector<std::thread> threads;
threads.reserve(workers - 1);
const std::size_t chunk = (count + workers - 1) / workers;
for (std::size_t w = 1; w < workers; ++w) {
const std::size_t begin = std::min(count, w * chunk);
const std::size_t end = std::min(count, begin + chunk);
if (begin >= end) break;
threads.emplace_back([&work, begin, end] { work(begin, end); });
}
work(std::size_t{0}, std::min(count, chunk));
for (auto& thread : threads) thread.join();
}
} // namespace effector
+108
View File
@@ -0,0 +1,108 @@
#include "core/params.hpp"
#include <algorithm>
namespace effector {
std::string ParamSpec::flag() const {
std::string flag = "--" + key;
std::replace(flag.begin(), flag.end(), '_', '-');
return flag;
}
ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum,
long maximum, std::string help, long step) {
ParamSpec spec;
spec.type = Type::Int;
spec.key = std::move(key);
spec.label = std::move(label);
spec.help = std::move(help);
spec.defaultValue = static_cast<double>(defaultValue);
spec.minimum = static_cast<double>(minimum);
spec.maximum = static_cast<double>(maximum);
spec.step = static_cast<double>(step);
return spec;
}
ParamSpec ParamSpec::makeDouble(std::string key, std::string label, double defaultValue,
double minimum, double maximum, std::string help, double step,
int digits) {
ParamSpec spec;
spec.type = Type::Double;
spec.key = std::move(key);
spec.label = std::move(label);
spec.help = std::move(help);
spec.defaultValue = defaultValue;
spec.minimum = minimum;
spec.maximum = maximum;
spec.step = step;
spec.digits = digits;
return spec;
}
ParamSpec ParamSpec::makeBool(std::string key, std::string label, bool defaultValue,
std::string help) {
ParamSpec spec;
spec.type = Type::Bool;
spec.key = std::move(key);
spec.label = std::move(label);
spec.help = std::move(help);
spec.defaultValue = defaultValue ? 1.0 : 0.0;
return spec;
}
ParamSpec ParamSpec::makeChoice(std::string key, std::string label, std::string defaultChoice,
std::vector<std::string> choices, std::string help) {
ParamSpec spec;
spec.type = Type::Choice;
spec.key = std::move(key);
spec.label = std::move(label);
spec.help = std::move(help);
spec.defaultChoice = std::move(defaultChoice);
spec.choices = std::move(choices);
return spec;
}
namespace {
const ParamValue* find(const ParamValues& values, const std::string& key) {
const auto it = values.find(key);
return it == values.end() ? nullptr : &it->second;
}
} // namespace
long paramInt(const ParamValues& values, const std::string& key, long fallback) {
if (const auto* value = find(values, key)) {
if (const auto* v = std::get_if<long>(value)) return *v;
if (const auto* v = std::get_if<double>(value)) return static_cast<long>(*v);
if (const auto* v = std::get_if<bool>(value)) return *v ? 1 : 0;
}
return fallback;
}
double paramDouble(const ParamValues& values, const std::string& key, double fallback) {
if (const auto* value = find(values, key)) {
if (const auto* v = std::get_if<double>(value)) return *v;
if (const auto* v = std::get_if<long>(value)) return static_cast<double>(*v);
}
return fallback;
}
bool paramBool(const ParamValues& values, const std::string& key, bool fallback) {
if (const auto* value = find(values, key)) {
if (const auto* v = std::get_if<bool>(value)) return *v;
if (const auto* v = std::get_if<long>(value)) return *v != 0;
}
return fallback;
}
std::string paramChoice(const ParamValues& values, const std::string& key,
const std::string& fallback) {
if (const auto* value = find(values, key)) {
if (const auto* v = std::get_if<std::string>(value)) return *v;
}
return fallback;
}
} // namespace effector
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <map>
#include <string>
#include <variant>
#include <vector>
namespace effector {
// Declarative description of one effect knob. The GUI turns these into widgets
// and the CLI turns them into flags, so neither knows about any effect.
struct ParamSpec {
enum class Type { Int, Double, Bool, Choice };
Type type = Type::Int;
std::string key;
std::string label;
std::string help;
double defaultValue = 0.0;
double minimum = 0.0;
double maximum = 0.0;
double step = 1.0;
int digits = 2;
std::string defaultChoice;
std::vector<std::string> choices;
std::string flag() const; // "strip_height" -> "--strip-height"
static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum,
long maximum, std::string help = {}, long step = 1);
static ParamSpec makeDouble(std::string key, std::string label, double defaultValue,
double minimum, double maximum, std::string help = {},
double step = 0.05, int digits = 2);
static ParamSpec makeBool(std::string key, std::string label, bool defaultValue,
std::string help = {});
static ParamSpec makeChoice(std::string key, std::string label, std::string defaultChoice,
std::vector<std::string> choices, std::string help = {});
};
using ParamValue = std::variant<long, double, bool, std::string>;
using ParamValues = std::map<std::string, ParamValue>;
long paramInt(const ParamValues& values, const std::string& key, long fallback);
double paramDouble(const ParamValues& values, const std::string& key, double fallback);
bool paramBool(const ParamValues& values, const std::string& key, bool fallback);
std::string paramChoice(const ParamValues& values, const std::string& key,
const std::string& fallback);
} // namespace effector
+29
View File
@@ -0,0 +1,29 @@
#include "core/registry.hpp"
#include "effects/scanlines.hpp"
namespace effector {
// To add an effect: include its header and add one line here.
const std::vector<EffectPtr>& effects() {
static const std::vector<EffectPtr> registry = {
std::make_shared<ScanlinesEffect>(),
};
return registry;
}
EffectPtr effectByName(const std::string& name) {
for (const auto& effect : effects()) {
if (effect->name() == name) return effect;
}
std::string known;
for (const auto& effect : effects()) {
if (!known.empty()) known += ", ";
known += effect->name();
}
throw EffectError("Unknown effect '" + name + "' (available: " + known + ")");
}
std::string defaultEffectName() { return effects().front()->name(); }
} // namespace effector
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <string>
#include <vector>
#include "core/effect.hpp"
namespace effector {
// Every effect the app knows about, in menu order.
const std::vector<EffectPtr>& effects();
// Throws EffectError when the name is unknown.
EffectPtr effectByName(const std::string& name);
std::string defaultEffectName();
} // namespace effector
+12
View File
@@ -0,0 +1,12 @@
#include "core/rng.hpp"
#include <random>
namespace effector {
std::uint64_t Rng::randomSeed() {
std::random_device rd;
return (static_cast<std::uint64_t>(rd()) << 32) ^ rd();
}
} // namespace effector
+63
View File
@@ -0,0 +1,63 @@
#pragma once
#include <cstdint>
namespace effector {
// xoshiro256** seeded through splitmix64. Rolled by hand rather than taken from
// <random> so that a given seed produces the same image on every platform and
// standard library.
class Rng {
public:
explicit Rng(std::uint64_t seed) noexcept {
for (auto& s : state_) s = splitmix(seed);
}
std::uint64_t next() noexcept {
const std::uint64_t result = rotl(state_[1] * 5, 7) * 9;
const std::uint64_t t = state_[1] << 17;
state_[2] ^= state_[0];
state_[3] ^= state_[1];
state_[1] ^= state_[2];
state_[0] ^= state_[3];
state_[2] ^= t;
state_[3] = rotl(state_[3], 45);
return result;
}
// Uniform in [0, bound] inclusive, unbiased (Lemire with rejection).
std::uint64_t below(std::uint64_t bound) noexcept {
if (bound == 0) return 0;
const std::uint64_t range = bound + 1;
const std::uint64_t limit = range == 0 ? 0 : (~std::uint64_t{0} / range) * range;
std::uint64_t value;
do {
value = next();
} while (limit != 0 && value >= limit);
return value % range;
}
// Uniform in [lo, hi] inclusive.
long inRange(long lo, long hi) noexcept {
if (hi <= lo) return lo;
return lo + static_cast<long>(below(static_cast<std::uint64_t>(hi - lo)));
}
static std::uint64_t randomSeed();
private:
static std::uint64_t rotl(std::uint64_t x, int k) noexcept {
return (x << k) | (x >> (64 - k));
}
static std::uint64_t splitmix(std::uint64_t& seed) noexcept {
std::uint64_t z = (seed += 0x9e3779b97f4a7c15ULL);
z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9ULL;
z = (z ^ (z >> 27)) * 0x94d049bb133111ebULL;
return z ^ (z >> 31);
}
std::uint64_t state_[4]{};
};
} // namespace effector
+98
View File
@@ -0,0 +1,98 @@
#include "effects/scanlines.hpp"
#include <algorithm>
#include <cstring>
#include <thread>
#include <vector>
#include "core/parallel.hpp"
namespace effector {
const std::vector<ParamSpec>& ScanlinesEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeInt("strip_height", "Strip height", 8, 1, 512,
"Height in pixels of each extracted line."),
ParamSpec::makeInt("jitter", "Height jitter", 0, 0, 256,
"Random +/- variation added to the strip height (0 = uniform)."),
ParamSpec::makeBool("keep_order", "Cycle images in order", false,
"Take strips from the images in order instead of picking randomly."),
};
return specs;
}
namespace {
struct Strip {
int dstY;
int height;
int sourceIndex;
int sx;
int sy;
};
} // namespace
Image ScanlinesEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
if (sources.empty()) throw EffectError("No source images.");
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
const int stripHeight = std::max(1L, paramInt(values, "strip_height", 8));
const int jitter = static_cast<int>(std::max(0L, paramInt(values, "jitter", 0)));
const bool keepOrder = paramBool(values, "keep_order", false);
// The plan is drawn sequentially so that a seed always gives the same
// layout; the pixel copying afterwards is what gets parallelised.
std::vector<Strip> plan;
plan.reserve(static_cast<std::size_t>(height / stripHeight) + 8);
int y = 0;
std::size_t index = 0;
while (y < height) {
const int sourceIndex =
keepOrder ? static_cast<int>(index++ % sources.size())
: static_cast<int>(rng.below(sources.size() - 1));
const Image& src = sources[static_cast<std::size_t>(sourceIndex)];
int h = stripHeight;
if (jitter > 0) h += static_cast<int>(rng.inRange(-jitter, jitter));
h = std::max(1, std::min({h, height - y, src.height()}));
const int sy = static_cast<int>(rng.inRange(0, src.height() - h));
const int sx = static_cast<int>(rng.inRange(0, std::max(0, src.width() - width)));
plan.push_back({y, h, sourceIndex, sx, sy});
y += h;
}
Image canvas(width, height);
parallelFor(plan.size(), [&](std::size_t begin, std::size_t end) {
for (std::size_t i = begin; i < end; ++i) {
const Strip& strip = plan[i];
const Image& src = sources[static_cast<std::size_t>(strip.sourceIndex)];
const int copyWidth = std::min(width, src.width() - strip.sx);
const std::size_t copyBytes = static_cast<std::size_t>(copyWidth) * 3;
for (int line = 0; line < strip.height; ++line) {
const std::uint8_t* in = src.row(strip.sy + line) + static_cast<std::size_t>(strip.sx) * 3;
std::uint8_t* out = canvas.row(strip.dstY + line);
std::memcpy(out, in, copyBytes);
// Tile horizontally when the source is narrower than the canvas,
// doubling the filled span on each pass.
std::size_t filled = copyBytes;
const std::size_t total = static_cast<std::size_t>(width) * 3;
while (filled < total) {
const std::size_t chunk = std::min(filled, total - filled);
std::memcpy(out + filled, out, chunk);
filled += chunk;
}
}
}
});
return canvas;
}
} // namespace effector
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include "core/effect.hpp"
namespace effector {
// Fills the canvas top to bottom with horizontal strips cut at random positions
// from the source images.
class ScanlinesEffect : public Effect {
public:
std::string name() const override { return "scanlines"; }
std::string title() const override { return "Scanlines"; }
std::string description() const override {
return "Stacks horizontal strips cut at random positions from the source images, "
"line by line, until the canvas is full.";
}
const std::vector<ParamSpec>& params() const override;
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+19
View File
@@ -0,0 +1,19 @@
#include "gui/app.hpp"
#include <gtkmm.h>
#include "gui/window.hpp"
namespace effector {
int runGui(const Options& initial, int argc, char** argv) {
// Our own parser already consumed argv, so GTK must not see it.
(void)argc;
(void)argv;
auto app = Gtk::Application::create("org.effector.Effector",
Gio::Application::Flags::NON_UNIQUE);
return app->make_window_and_run<EffectorWindow>(0, nullptr, initial);
}
} // namespace effector
+10
View File
@@ -0,0 +1,10 @@
#pragma once
#include "cli/options.hpp"
namespace effector {
// Runs the GTK interface with `initial` as the starting state.
int runGui(const Options& initial, int argc, char** argv);
} // namespace effector
+603
View File
@@ -0,0 +1,603 @@
#include "gui/window.hpp"
#include <chrono>
#include <filesystem>
#include <sstream>
#include "core/registry.hpp"
#include "core/rng.hpp"
namespace effector {
namespace {
const char* const kImagePatterns[] = {"*.png", "*.jpg", "*.jpeg", "*.webp", "*.bmp",
"*.tif", "*.tiff", "*.gif", "*.ppm", "*.PNG",
"*.JPG", "*.JPEG", "*.WEBP"};
Glib::RefPtr<Gdk::Texture> toTexture(const Image& image) {
auto bytes = Glib::Bytes::create(image.data(), image.byteSize());
return Gdk::MemoryTexture::create(image.width(), image.height(), Gdk::MemoryTexture::Format::R8G8B8,
bytes, image.stride());
}
Gtk::Label* sectionLabel(const char* text) {
auto* label = Gtk::make_managed<Gtk::Label>(text);
label->set_xalign(0.0F);
label->add_css_class("heading");
return label;
}
} // namespace
EffectorWindow::EffectorWindow(const Options& initial) {
set_title("Effector");
set_default_size(1180, 760);
effects_ = effects();
effect_ = effects_.front();
seed_ = Rng::randomSeed();
renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished));
buildUi();
applyInitial(initial);
suspendRender_ = false;
if (!sources_->empty()) startRender();
}
EffectorWindow::~EffectorWindow() {
debounce_.disconnect();
if (worker_.joinable()) worker_.join();
}
// ------------------------------------------------------------------- UI ---
void EffectorWindow::buildUi() {
auto* header = Gtk::make_managed<Gtk::HeaderBar>();
set_titlebar(*header);
auto* addButton = Gtk::make_managed<Gtk::Button>();
addButton->set_icon_name("list-add-symbolic");
addButton->set_tooltip_text("Add images");
addButton->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onAddImages));
header->pack_start(*addButton);
std::vector<Glib::ustring> titles;
for (const auto& effect : effects_) titles.push_back(effect->title());
effectDropdown_.set_model(Gtk::StringList::create(titles));
effectDropdown_.set_tooltip_text("Effect");
effectDropdown_.property_selected().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onEffectChanged));
header->pack_start(effectDropdown_);
saveButton_.set_sensitive(false);
saveButton_.signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onSave));
header->pack_end(saveButton_);
recreateButton_.set_icon_name("view-refresh-symbolic");
recreateButton_.set_tooltip_text("Recreate with a new seed");
recreateButton_.signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onRecreate));
header->pack_end(recreateButton_);
auto* root = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL);
set_child(*root);
banner_.set_xalign(0.0F);
banner_.set_wrap(true);
banner_.set_visible(false);
banner_.add_css_class("error");
banner_.set_margin(8);
root->append(banner_);
auto* paned = Gtk::make_managed<Gtk::Paned>(Gtk::Orientation::HORIZONTAL);
paned->set_vexpand(true);
paned->set_position(330);
paned->set_shrink_start_child(false);
paned->set_start_child(buildSidebar());
paned->set_end_child(buildPreview());
root->append(*paned);
}
Gtk::Widget& EffectorWindow::buildSidebar() {
auto* sidebar = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
sidebar->set_margin(12);
sidebar->append(*sectionLabel("Source images"));
sourceList_.set_selection_mode(Gtk::SelectionMode::NONE);
sourceList_.add_css_class("boxed-list");
auto* empty = Gtk::make_managed<Gtk::Label>("No images yet");
empty->set_margin_top(18);
empty->set_margin_bottom(18);
sourceList_.set_placeholder(*empty);
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scroller->set_child(sourceList_);
scroller->set_size_request(-1, 170);
scroller->set_vexpand(true);
sidebar->append(*scroller);
sidebar->append(*sectionLabel("Canvas"));
auto* grid = Gtk::make_managed<Gtk::Grid>();
grid->set_column_spacing(8);
grid->set_row_spacing(6);
widthSpin_.set_range(1, 20000);
widthSpin_.set_increments(1, 100);
widthSpin_.set_value(1920);
heightSpin_.set_range(1, 20000);
heightSpin_.set_increments(1, 100);
heightSpin_.set_value(1080);
for (auto* spin : {&widthSpin_, &heightSpin_}) {
spin->set_hexpand(true);
spin->signal_value_changed().connect(sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
}
auto* widthLabel = Gtk::make_managed<Gtk::Label>("Width");
widthLabel->set_xalign(0.0F);
auto* heightLabel = Gtk::make_managed<Gtk::Label>("Height");
heightLabel->set_xalign(0.0F);
grid->attach(*widthLabel, 0, 0);
grid->attach(widthSpin_, 1, 0);
grid->attach(*heightLabel, 0, 1);
grid->attach(heightSpin_, 1, 1);
sidebar->append(*grid);
sidebar->append(*sectionLabel("Effect settings"));
paramGrid_.set_column_spacing(8);
paramGrid_.set_row_spacing(6);
sidebar->append(paramGrid_);
buildParamWidgets();
auto* seedBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
auto* seedLabel = Gtk::make_managed<Gtk::Label>("Seed");
seedLabel->set_xalign(0.0F);
seedBox->append(*seedLabel);
seedEntry_.set_hexpand(true);
seedEntry_.set_text(std::to_string(seed_));
seedEntry_.signal_activate().connect(sigc::mem_fun(*this, &EffectorWindow::onSeedEntered));
seedBox->append(seedEntry_);
sidebar->append(*seedBox);
return *sidebar;
}
Gtk::Widget& EffectorWindow::buildPreview() {
picture_.set_can_shrink(true);
picture_.set_content_fit(Gtk::ContentFit::CONTAIN);
placeholder_.set_text("Add source images to start.");
placeholder_.set_wrap(true);
placeholder_.add_css_class("dim-label");
previewStack_.add(placeholder_, "placeholder");
previewStack_.add(picture_, "picture");
previewStack_.set_visible_child("placeholder");
spinner_.set_halign(Gtk::Align::CENTER);
spinner_.set_valign(Gtk::Align::CENTER);
auto* overlay = Gtk::make_managed<Gtk::Overlay>();
overlay->set_hexpand(true);
overlay->set_vexpand(true);
overlay->set_child(previewStack_);
overlay->add_overlay(spinner_);
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL);
box->append(*overlay);
status_.set_margin_top(6);
status_.set_margin_bottom(8);
status_.add_css_class("dim-label");
box->append(status_);
return *box;
}
void EffectorWindow::buildParamWidgets() {
while (auto* child = paramGrid_.get_first_child()) paramGrid_.remove(*child);
paramWidgets_.clear();
ownedWidgets_.clear();
int row = 0;
for (const auto& spec : effect_->params()) {
auto label = std::make_unique<Gtk::Label>(spec.label);
label->set_xalign(0.0F);
if (!spec.help.empty()) label->set_tooltip_text(spec.help);
std::unique_ptr<Gtk::Widget> widget;
switch (spec.type) {
case ParamSpec::Type::Bool: {
auto toggle = std::make_unique<Gtk::Switch>();
toggle->set_halign(Gtk::Align::START);
toggle->set_active(spec.defaultValue != 0.0);
toggle->property_active().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(toggle);
break;
}
case ParamSpec::Type::Int:
case ParamSpec::Type::Double: {
auto spin = std::make_unique<Gtk::SpinButton>();
spin->set_range(spec.minimum, spec.maximum);
spin->set_increments(spec.step, spec.step * 10);
spin->set_digits(spec.type == ParamSpec::Type::Int ? 0 : spec.digits);
spin->set_value(spec.defaultValue);
spin->signal_value_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(spin);
break;
}
case ParamSpec::Type::Choice: {
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
auto dropdown = std::make_unique<Gtk::DropDown>();
dropdown->set_model(Gtk::StringList::create(choices));
for (std::size_t i = 0; i < spec.choices.size(); ++i) {
if (spec.choices[i] == spec.defaultChoice) dropdown->set_selected(i);
}
dropdown->property_selected().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(dropdown);
break;
}
}
widget->set_hexpand(true);
paramGrid_.attach(*label, 0, row);
paramGrid_.attach(*widget, 1, row);
paramWidgets_.push_back({spec, widget.get()});
ownedWidgets_.push_back(std::move(label));
ownedWidgets_.push_back(std::move(widget));
++row;
}
}
ParamValues EffectorWindow::paramValues() const {
ParamValues values = effect_->defaults();
for (const auto& entry : paramWidgets_) {
switch (entry.spec.type) {
case ParamSpec::Type::Bool:
values[entry.spec.key] = static_cast<Gtk::Switch*>(entry.widget)->get_active();
break;
case ParamSpec::Type::Int:
values[entry.spec.key] =
static_cast<long>(static_cast<Gtk::SpinButton*>(entry.widget)->get_value());
break;
case ParamSpec::Type::Double:
values[entry.spec.key] = static_cast<Gtk::SpinButton*>(entry.widget)->get_value();
break;
case ParamSpec::Type::Choice: {
const auto index = static_cast<Gtk::DropDown*>(entry.widget)->get_selected();
if (index < entry.spec.choices.size()) values[entry.spec.key] = entry.spec.choices[index];
break;
}
}
}
return values;
}
void EffectorWindow::applyInitial(const Options& initial) {
for (std::size_t i = 0; i < effects_.size(); ++i) {
if (effects_[i]->name() == initial.effect) effectDropdown_.set_selected(i);
}
widthSpin_.set_value(initial.width);
heightSpin_.set_value(initial.height);
if (initial.seed) {
seed_ = *initial.seed;
seedEntry_.set_text(std::to_string(seed_));
}
for (const auto& entry : paramWidgets_) {
const auto it = initial.params.find(entry.spec.key);
if (it == initial.params.end()) continue;
switch (entry.spec.type) {
case ParamSpec::Type::Bool:
static_cast<Gtk::Switch*>(entry.widget)
->set_active(paramBool(initial.params, entry.spec.key, false));
break;
case ParamSpec::Type::Int:
static_cast<Gtk::SpinButton*>(entry.widget)
->set_value(static_cast<double>(paramInt(initial.params, entry.spec.key, 0)));
break;
case ParamSpec::Type::Double:
static_cast<Gtk::SpinButton*>(entry.widget)
->set_value(paramDouble(initial.params, entry.spec.key, 0.0));
break;
case ParamSpec::Type::Choice: {
const auto value = paramChoice(initial.params, entry.spec.key, entry.spec.defaultChoice);
for (std::size_t i = 0; i < entry.spec.choices.size(); ++i) {
if (entry.spec.choices[i] == value) {
static_cast<Gtk::DropDown*>(entry.widget)->set_selected(i);
}
}
break;
}
}
}
if (!initial.images.empty()) addPaths(initial.images);
}
// -------------------------------------------------------------- sources ---
// Replaces the source snapshot, moving the images across when nothing else
// holds the old list (the common case) and copying only if a render is reading it.
template <typename Mutate>
void EffectorWindow::mutateSources(Mutate&& mutate) {
auto next = std::make_shared<SourceList>();
if (sources_.use_count() == 1) {
*next = std::move(const_cast<SourceList&>(*sources_));
} else {
*next = *sources_;
}
mutate(*next);
sources_ = std::move(next);
}
void EffectorWindow::addPaths(const std::vector<std::string>& paths) {
std::vector<LoadFailure> failures;
auto images = loadImages(paths, failures);
for (const auto& image : images) appendSourceRow(image.path());
mutateSources([&images](SourceList& list) {
for (auto& image : images) list.push_back(std::move(image));
});
if (!failures.empty()) {
std::string names;
for (const auto& failure : failures) {
if (!names.empty()) names += ", ";
names += std::filesystem::path(failure.path).filename().string();
}
showError("Could not read: " + names);
} else if (!images.empty()) {
clearError();
}
requestRender();
}
void EffectorWindow::appendSourceRow(const std::string& path) {
auto* row = Gtk::make_managed<Gtk::ListBoxRow>();
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
box->set_margin_top(4);
box->set_margin_bottom(4);
box->set_margin_start(8);
box->set_margin_end(4);
auto* label = Gtk::make_managed<Gtk::Label>(std::filesystem::path(path).filename().string());
label->set_xalign(0.0F);
label->set_hexpand(true);
label->set_ellipsize(Pango::EllipsizeMode::END);
label->set_tooltip_text(path);
box->append(*label);
auto* remove = Gtk::make_managed<Gtk::Button>();
remove->set_icon_name("window-close-symbolic");
remove->add_css_class("flat");
remove->signal_clicked().connect([this, row] { removeSource(row); });
box->append(*remove);
row->set_child(*box);
sourceList_.append(*row);
}
void EffectorWindow::removeSource(Gtk::ListBoxRow* row) {
const int index = row->get_index();
mutateSources([index](SourceList& list) {
if (index >= 0 && static_cast<std::size_t>(index) < list.size()) {
list.erase(list.begin() + index);
}
});
sourceList_.remove(*row);
if (!sources_->empty()) {
requestRender();
} else {
result_ = Image();
saveButton_.set_sensitive(false);
previewStack_.set_visible_child("placeholder");
status_.set_text("");
}
}
void EffectorWindow::onAddImages() {
auto dialog = Gtk::FileDialog::create();
dialog->set_title("Select source images");
auto filter = Gtk::FileFilter::create();
filter->set_name("Images");
for (const char* pattern : kImagePatterns) filter->add_pattern(pattern);
auto filters = Gio::ListStore<Gtk::FileFilter>::create();
filters->append(filter);
dialog->set_filters(filters);
dialog->set_default_filter(filter);
dialog->open_multiple(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
onImagesChosen(result, dialog);
});
}
void EffectorWindow::onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& dialog) {
std::vector<std::string> paths;
try {
for (const auto& file : dialog->open_multiple_finish(result)) {
if (file) paths.push_back(file->get_path());
}
} catch (const Glib::Error&) {
return; // cancelled
}
if (!paths.empty()) addPaths(paths);
}
// ------------------------------------------------------------ rendering ---
void EffectorWindow::onEffectChanged() {
const auto index = effectDropdown_.get_selected();
if (index >= effects_.size()) return;
effect_ = effects_[index];
buildParamWidgets();
requestRender();
}
void EffectorWindow::onParamChanged() { requestRender(); }
void EffectorWindow::onSeedEntered() {
try {
seed_ = std::stoull(seedEntry_.get_text());
} catch (const std::exception&) {
seedEntry_.set_text(std::to_string(seed_));
return;
}
requestRender();
}
void EffectorWindow::onRecreate() {
seed_ = Rng::randomSeed();
seedEntry_.set_text(std::to_string(seed_));
if (sources_->empty()) {
showError("Add at least one source image first.");
return;
}
requestRender();
}
void EffectorWindow::requestRender() {
if (suspendRender_ || sources_->empty()) return;
// Spin buttons fire on every step while they are held down; coalesce those
// into one render instead of queueing a job per tick.
debounce_.disconnect();
debounce_ = Glib::signal_timeout().connect(
[this] {
startRender();
return false;
},
120);
}
void EffectorWindow::startRender() {
if (sources_->empty()) return;
if (rendering_) {
staleResult_ = true; // a fresh render follows this one
return;
}
rendering_ = true;
const auto sources = sources_;
const auto effect = effect_;
const int width = static_cast<int>(widthSpin_.get_value());
const int height = static_cast<int>(heightSpin_.get_value());
const std::uint64_t seed = seed_;
const ParamValues values = paramValues();
spinner_.start();
recreateButton_.set_sensitive(false);
worker_ = std::thread([this, sources, effect, width, height, seed, values] {
Image image;
std::string error;
const auto start = std::chrono::steady_clock::now();
try {
Rng rng(seed);
image = effect->render(*sources, width, height, rng, values);
} catch (const std::exception& exception) {
error = exception.what();
}
const auto millis = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - start)
.count();
{
std::lock_guard<std::mutex> lock(resultMutex_);
pendingImage_ = std::move(image);
pendingError_ = std::move(error);
pendingMillis_ = millis;
}
renderDone_.emit();
});
}
void EffectorWindow::onRenderFinished() {
if (worker_.joinable()) worker_.join();
rendering_ = false;
Image image;
std::string error;
double millis = 0.0;
{
std::lock_guard<std::mutex> lock(resultMutex_);
image = std::move(pendingImage_);
error = pendingError_;
millis = pendingMillis_;
}
if (staleResult_) { // settings changed while this one was running
staleResult_ = false;
startRender();
return;
}
spinner_.stop();
recreateButton_.set_sensitive(true);
if (!error.empty()) {
result_ = Image();
saveButton_.set_sensitive(false);
showError(error);
return;
}
clearError();
result_ = std::move(image);
picture_.set_paintable(toTexture(result_));
previewStack_.set_visible_child("picture");
saveButton_.set_sensitive(true);
std::ostringstream status;
status.setf(std::ios::fixed);
status.precision(1);
status << effect_->title() << " · " << result_.width() << "×" << result_.height() << " · "
<< sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · seed "
<< seed_ << " · " << millis << " ms";
status_.set_text(status.str());
}
// --------------------------------------------------------------- saving ---
void EffectorWindow::onSave() {
if (result_.empty()) return;
auto dialog = Gtk::FileDialog::create();
dialog->set_title("Save image");
dialog->set_initial_name("effector-" + effect_->name() + "-" + std::to_string(seed_) + ".png");
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
onSaveChosen(result, dialog);
});
}
void EffectorWindow::onSaveChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& dialog) {
std::filesystem::path path;
try {
const auto file = dialog->save_finish(result);
if (!file) return;
path = file->get_path();
} catch (const Glib::Error&) {
return; // cancelled
}
if (path.extension().empty()) path.replace_extension(".png");
try {
result_.save(path.string());
} catch (const std::exception& error) {
showError(std::string("Could not save: ") + error.what());
return;
}
clearError();
status_.set_text("Saved " + path.string());
}
// --------------------------------------------------------------- errors ---
void EffectorWindow::showError(const std::string& message) {
banner_.set_text(message);
banner_.set_visible(true);
}
void EffectorWindow::clearError() { banner_.set_visible(false); }
} // namespace effector
+103
View File
@@ -0,0 +1,103 @@
#pragma once
#include <gtkmm.h>
#include <cstdint>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
#include <vector>
#include "cli/options.hpp"
#include "core/effect.hpp"
#include "core/image.hpp"
namespace effector {
class EffectorWindow : public Gtk::ApplicationWindow {
public:
explicit EffectorWindow(const Options& initial);
~EffectorWindow() override;
private:
void buildUi();
Gtk::Widget& buildSidebar();
Gtk::Widget& buildPreview();
void buildParamWidgets();
ParamValues paramValues() const;
void applyInitial(const Options& initial);
void requestRender(); // coalesced: restarts the short debounce timer
void startRender(); // starts a worker, or marks the result stale
void onAddImages();
void onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& dialog);
void addPaths(const std::vector<std::string>& paths);
void appendSourceRow(const std::string& path);
void removeSource(Gtk::ListBoxRow* row);
void onEffectChanged();
void onParamChanged();
void onSeedEntered();
void onRecreate();
void onSave();
void onSaveChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& dialog);
void onRenderFinished(); // runs on the main thread via the dispatcher
// Sources are handed to the worker as an immutable snapshot, so adding or
// removing an image while a render is in flight cannot race with it.
using SourceList = std::vector<Image>;
template <typename Mutate>
void mutateSources(Mutate&& mutate);
void showError(const std::string& message);
void clearError();
// Model
std::shared_ptr<const SourceList> sources_ = std::make_shared<SourceList>();
std::vector<EffectPtr> effects_;
EffectPtr effect_;
Image result_;
std::uint64_t seed_ = 0;
bool suspendRender_ = true;
// Render job
Glib::Dispatcher renderDone_;
std::thread worker_;
std::mutex resultMutex_;
bool rendering_ = false;
bool staleResult_ = false;
sigc::connection debounce_;
Image pendingImage_;
std::string pendingError_;
double pendingMillis_ = 0.0;
// Widgets
Gtk::Label banner_;
Gtk::ListBox sourceList_;
Gtk::SpinButton widthSpin_;
Gtk::SpinButton heightSpin_;
Gtk::Grid paramGrid_;
Gtk::Entry seedEntry_;
Gtk::DropDown effectDropdown_;
Gtk::Button recreateButton_{"Recreate"};
Gtk::Button saveButton_{"Save As…"};
Gtk::Picture picture_;
Gtk::Stack previewStack_;
Gtk::Label placeholder_;
Gtk::Spinner spinner_;
Gtk::Label status_;
struct ParamWidget {
ParamSpec spec;
Gtk::Widget* widget = nullptr;
};
std::vector<ParamWidget> paramWidgets_;
std::vector<std::unique_ptr<Gtk::Widget>> ownedWidgets_;
};
} // namespace effector
+70
View File
@@ -0,0 +1,70 @@
#include <chrono>
#include <cstdio>
#include <iomanip>
#include <iostream>
#include "cli/options.hpp"
#include "core/effect.hpp"
#include "core/image.hpp"
#include "core/registry.hpp"
#include "core/rng.hpp"
#include "gui/app.hpp"
namespace {
int renderHeadless(const effector::Options& options) {
const auto effect = effector::effectByName(options.effect);
std::vector<effector::LoadFailure> failures;
auto sources = effector::loadImages(options.images, failures);
for (const auto& failure : failures) {
std::cerr << "effector: skipping " << failure.path << ": " << failure.message << "\n";
}
if (sources.empty()) {
std::cerr << "effector: no usable source images\n";
return 2;
}
const std::uint64_t seed = options.seed.value_or(effector::Rng::randomSeed());
effector::Rng rng(seed);
auto values = effect->defaults();
for (const auto& [key, value] : options.params) values[key] = value;
const auto start = std::chrono::steady_clock::now();
const auto image = effect->render(sources, options.width, options.height, rng, values);
const auto renderMillis =
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
image.save(options.output);
std::cout << options.output << " " << options.width << "x" << options.height
<< " effect=" << effect->name() << " seed=" << seed << " render="
<< std::fixed << std::setprecision(1) << renderMillis << " ms\n";
return 0;
}
} // namespace
int main(int argc, char** argv) {
try {
const auto options = effector::parseOptions(argc, argv);
if (options.showHelp) {
std::cout << effector::usage(argv[0]);
return 0;
}
if (options.listEffects) {
for (const auto& effect : effector::effects()) {
std::cout << effect->name() << "\t" << effect->title() << " - "
<< effect->description() << "\n";
}
return 0;
}
if (!options.output.empty()) return renderHeadless(options);
return effector::runGui(options, argc, argv);
} catch (const std::exception& error) {
std::cerr << "effector: " << error.what() << "\n";
return 1;
}
}