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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user