Lays a single picture from disk over its input: fitted, filled, stretched, left at its own size or tiled, centred or pinned to the top left corner, nudged by an offset and mixed in by its opacity. The file it needs is a new kind of param, Path, so the interface shows a chooser and the CLI takes a file name, both without knowing an overlay exists. Projects write it relative to their own folder, as they do a source, and Image::load can now hand back the alpha channel instead of flattening it onto white, so a cut-out PNG keeps its cut-out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
178 lines
6.8 KiB
C++
178 lines
6.8 KiB
C++
#include "core/image.hpp"
|
|
|
|
#include <gdk-pixbuf/gdk-pixbuf.h>
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <cctype>
|
|
#include <cstring>
|
|
#include <memory>
|
|
#include <mutex>
|
|
#include <stdexcept>
|
|
#include <thread>
|
|
|
|
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, std::vector<std::uint8_t>* alphaOut) {
|
|
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);
|
|
if (alphaOut != nullptr) {
|
|
alphaOut->assign(static_cast<std::size_t>(std::max(width, 0)) *
|
|
static_cast<std::size_t>(std::max(height, 0)),
|
|
255);
|
|
}
|
|
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 if (alphaOut != nullptr) {
|
|
// Kept apart rather than flattened: the caller does the compositing.
|
|
std::uint8_t* alpha = alphaOut->data() + static_cast<std::size_t>(y) * width;
|
|
for (int x = 0; x < width; ++x) {
|
|
for (int c = 0; c < 3; ++c) out[x * 3 + c] = in[x * channels + c];
|
|
alpha[x] = in[x * channels + 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,
|
|
const LoadProgress* progress) {
|
|
// Decoding is the dominant cost of a headless run, and the files are
|
|
// independent, so they are decoded in parallel. Threads take the next path
|
|
// from a shared cursor rather than a fixed slice, so one huge file cannot
|
|
// stall the files behind it and progress arrives at a steady rate. Slots
|
|
// keep the result in the order the paths were given.
|
|
std::vector<Image> slots(paths.size());
|
|
std::vector<std::string> errors(paths.size());
|
|
std::atomic<std::size_t> cursor{0};
|
|
std::atomic<std::size_t> done{0};
|
|
|
|
unsigned hardware = std::thread::hardware_concurrency();
|
|
if (hardware == 0) hardware = 1;
|
|
const std::size_t workers = std::min<std::size_t>(hardware, std::max<std::size_t>(paths.size(), 1));
|
|
|
|
const auto decode = [&] {
|
|
for (;;) {
|
|
if (progress != nullptr && progress->cancel != nullptr &&
|
|
progress->cancel->load(std::memory_order_relaxed)) {
|
|
return;
|
|
}
|
|
const std::size_t i = cursor.fetch_add(1, std::memory_order_relaxed);
|
|
if (i >= paths.size()) return;
|
|
try {
|
|
slots[i] = Image::load(paths[i]);
|
|
} catch (const std::exception& error) {
|
|
errors[i] = error.what();
|
|
}
|
|
if (progress != nullptr && progress->onFile) {
|
|
progress->onFile(done.fetch_add(1, std::memory_order_relaxed) + 1, paths.size(),
|
|
paths[i]);
|
|
}
|
|
}
|
|
};
|
|
|
|
std::vector<std::thread> threads;
|
|
threads.reserve(workers > 0 ? workers - 1 : 0);
|
|
for (std::size_t w = 1; w < workers; ++w) threads.emplace_back(decode);
|
|
decode();
|
|
for (auto& thread : threads) thread.join();
|
|
|
|
std::vector<Image> images;
|
|
images.reserve(paths.size());
|
|
for (std::size_t i = 0; i < paths.size(); ++i) {
|
|
if (!errors[i].empty()) {
|
|
failures.push_back({paths[i], errors[i]});
|
|
} else if (!slots[i].empty()) {
|
|
images.push_back(std::move(slots[i]));
|
|
}
|
|
}
|
|
return images;
|
|
}
|
|
|
|
} // namespace effector
|