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
+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