From e2252ecd3019b8995d13fb5ad16fac18a53e971f Mon Sep 17 00:00:00 2001 From: Si Date: Fri, 18 Sep 2026 19:31:09 +0200 Subject: [PATCH] Add an image overlay effect 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 --- CLAUDE.md | 10 +- CMakeLists.txt | 1 + src/cli/options.cpp | 3 + src/core/effect.cpp | 1 + src/core/image.cpp | 14 ++- src/core/image.hpp | 6 +- src/core/params.cpp | 14 +++ src/core/params.hpp | 8 +- src/core/project.cpp | 13 +++ src/core/registry.cpp | 2 + src/effects/overlay.cpp | 220 ++++++++++++++++++++++++++++++++++++++++ src/effects/overlay.hpp | 45 ++++++++ src/gui/window.cpp | 60 ++++++++++- src/gui/window.hpp | 4 + tests/core_tests.cpp | 127 +++++++++++++++++++++++ 15 files changed, 520 insertions(+), 8 deletions(-) create mode 100644 src/effects/overlay.cpp create mode 100644 src/effects/overlay.hpp diff --git a/CLAUDE.md b/CLAUDE.md index 68143f4..e896136 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,10 +41,12 @@ env -u WAYLAND_DISPLAY GDK_BACKEND=x11 DISPLAY=:79 build/effector ... `Effect` (`src/core/effect.hpp`) exposes `params()` returning `std::vector` (`src/core/params.hpp`). Those specs are the *only* thing the GUI and CLI know -about an effect: `src/gui/window.cpp` builds SpinButtons/Switches/DropDowns from -them and `src/cli/options.cpp` generates flags from them (`strip_height` → -`--strip-height`). Never add effect-specific branches to the GUI or the parser — -add a `ParamSpec` instead. Values travel as `ParamValues` (a +about an effect: `src/gui/window.cpp` builds SpinButtons/Switches/DropDowns/file +choosers from them and `src/cli/options.cpp` generates flags from them +(`strip_height` → `--strip-height`). Never add effect-specific branches to the +GUI or the parser — add a `ParamSpec` instead. A `Path` param names a file on +disk; like a source path it is written relative to the project folder. Values +travel as `ParamValues` (a `map>`), read back with `paramInt`/`paramDouble`/`paramBool`/`paramChoice`. diff --git a/CMakeLists.txt b/CMakeLists.txt index f47c058..9c3f44e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -30,6 +30,7 @@ add_library(effector_core STATIC src/effects/blur.cpp src/effects/grain.cpp src/effects/monochrome.cpp + src/effects/overlay.cpp src/effects/scanlines.cpp ) target_include_directories(effector_core PUBLIC src) diff --git a/src/cli/options.cpp b/src/cli/options.cpp index 6b24f41..1f61745 100644 --- a/src/cli/options.cpp +++ b/src/cli/options.cpp @@ -110,6 +110,9 @@ Options parseOptions(int argc, char** argv) { } break; } + case ParamSpec::Type::Path: + options.params[spec->key] = requireValue(argc, argv, i, arg); + break; case ParamSpec::Type::Choice: { const std::string text = requireValue(argc, argv, i, arg); bool valid = false; diff --git a/src/core/effect.cpp b/src/core/effect.cpp index 0d5fa62..bebdda6 100644 --- a/src/core/effect.cpp +++ b/src/core/effect.cpp @@ -16,6 +16,7 @@ ParamValues Effect::defaults() const { values[spec.key] = spec.defaultValue != 0.0; break; case ParamSpec::Type::Choice: + case ParamSpec::Type::Path: values[spec.key] = spec.defaultChoice; break; } diff --git a/src/core/image.cpp b/src/core/image.cpp index 44ee3a0..73321b8 100644 --- a/src/core/image.cpp +++ b/src/core/image.cpp @@ -43,7 +43,7 @@ Image::Image(int width, int height) pixels_(static_cast(std::max(width, 0)) * 3 * static_cast(std::max(height, 0))) {} -Image Image::load(const std::string& path) { +Image Image::load(const std::string& path, std::vector* alphaOut) { GError* error = nullptr; PixbufPtr pixbuf = wrap(gdk_pixbuf_new_from_file(path.c_str(), &error)); if (!pixbuf) throw std::runtime_error(takeError(error)); @@ -59,11 +59,23 @@ Image Image::load(const std::string& path) { const guint8* src = gdk_pixbuf_get_pixels(pixbuf.get()); Image image(width, height); + if (alphaOut != nullptr) { + alphaOut->assign(static_cast(std::max(width, 0)) * + static_cast(std::max(height, 0)), + 255); + } for (int y = 0; y < height; ++y) { const guint8* in = src + static_cast(y) * srcStride; std::uint8_t* out = image.row(y); if (channels == 3) { std::memcpy(out, in, static_cast(width) * 3); + } else if (alphaOut != nullptr) { + // Kept apart rather than flattened: the caller does the compositing. + std::uint8_t* alpha = alphaOut->data() + static_cast(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) { diff --git a/src/core/image.hpp b/src/core/image.hpp index 8ed43ea..6885135 100644 --- a/src/core/image.hpp +++ b/src/core/image.hpp @@ -34,7 +34,11 @@ public: // 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); + // Transparency is flattened onto white, as a viewer would show it, unless + // `alphaOut` is given: it then receives one byte per pixel (255 throughout + // for a file without an alpha channel) and the colours are left untouched, + // which is what an effect compositing the image needs. + static Image load(const std::string& path, std::vector* alphaOut = nullptr); // Encodes by file extension; png is used when the extension is unknown. void save(const std::string& path) const; diff --git a/src/core/params.cpp b/src/core/params.cpp index 5e7e5c2..10e6910 100644 --- a/src/core/params.cpp +++ b/src/core/params.cpp @@ -63,6 +63,15 @@ ParamSpec ParamSpec::makeChoice(std::string key, std::string label, std::string return spec; } +ParamSpec ParamSpec::makePath(std::string key, std::string label, std::string help) { + ParamSpec spec; + spec.type = Type::Path; + spec.key = std::move(key); + spec.label = std::move(label); + spec.help = std::move(help); + return spec; +} + namespace { const ParamValue* find(const ParamValues& values, const std::string& key) { @@ -99,6 +108,11 @@ 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) { + return paramText(values, key, fallback); +} + +std::string paramText(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(value)) return *v; } diff --git a/src/core/params.hpp b/src/core/params.hpp index fa7f526..b9d03d3 100644 --- a/src/core/params.hpp +++ b/src/core/params.hpp @@ -10,7 +10,7 @@ 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 }; + enum class Type { Int, Double, Bool, Choice, Path }; Type type = Type::Int; std::string key; @@ -35,6 +35,10 @@ struct ParamSpec { std::string help = {}); static ParamSpec makeChoice(std::string key, std::string label, std::string defaultChoice, std::vector choices, std::string help = {}); + // A path to a file on disk, empty by default: a chooser in the interface, a + // flag taking any string on the command line, written relative to the + // project folder so that a folder still opens after it is moved. + static ParamSpec makePath(std::string key, std::string label, std::string help = {}); }; using ParamValue = std::variant; @@ -45,5 +49,7 @@ double paramDouble(const ParamValues& values, const std::string& key, double fal 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); +std::string paramText(const ParamValues& values, const std::string& key, + const std::string& fallback); } // namespace effector diff --git a/src/core/project.cpp b/src/core/project.cpp index 2712ab5..f30303c 100644 --- a/src/core/project.cpp +++ b/src/core/project.cpp @@ -348,6 +348,13 @@ std::string projectToJson(const Project& project, const std::string& directory) case ParamSpec::Type::Choice: out << quote(paramChoice(stage.values, spec.key, spec.defaultChoice)); break; + case ParamSpec::Type::Path: { + // Written relative to the project folder, exactly as the + // sources are, so a folder still opens once it is moved. + const auto path = paramText(stage.values, spec.key, ""); + out << quote(path.empty() ? path : relativeTo(path, directory)); + break; + } } } out << (specs.empty() ? "}" : "\n }") << "\n }"; @@ -418,6 +425,12 @@ Project projectFromJson(const std::string& text, const std::string& directory) { case ParamSpec::Type::Double: if (value->kind == Json::Kind::Number) stage.values[spec.key] = value->number; break; + case ParamSpec::Type::Path: + if (value->kind == Json::Kind::String) { + stage.values[spec.key] = + value->text.empty() ? value->text : resolve(value->text, directory); + } + break; case ParamSpec::Type::Choice: for (const auto& choice : spec.choices) { if (value->kind == Json::Kind::String && value->text == choice) { diff --git a/src/core/registry.cpp b/src/core/registry.cpp index efd0358..25e0df5 100644 --- a/src/core/registry.cpp +++ b/src/core/registry.cpp @@ -3,6 +3,7 @@ #include "effects/blur.hpp" #include "effects/grain.hpp" #include "effects/monochrome.hpp" +#include "effects/overlay.hpp" #include "effects/scanlines.hpp" namespace effector { @@ -14,6 +15,7 @@ const std::vector& effects() { std::make_shared(), std::make_shared(), std::make_shared(), + std::make_shared(), }; return registry; } diff --git a/src/effects/overlay.cpp b/src/effects/overlay.cpp new file mode 100644 index 0000000..4900fb4 --- /dev/null +++ b/src/effects/overlay.cpp @@ -0,0 +1,220 @@ +#include "effects/overlay.hpp" + +#include +#include +#include + +#include "core/parallel.hpp" +#include "effects/source_fill.hpp" + +namespace effector { + +const std::vector& OverlayEffect::params() const { + static const std::vector specs = { + ParamSpec::makePath("overlay_image", "Image", + "The picture laid over the input: a logo, a texture, a frame."), + ParamSpec::makeChoice("overlay_fit", "Fitting", "fit", + {"fit", "fill", "stretch", "original", "tile"}, + "fit keeps the proportions and shows the whole image; fill keeps " + "them and covers the canvas, cropping what does not fit; stretch " + "distorts it to the canvas exactly; original leaves it at its own " + "size; tile repeats it at its own size over the whole canvas."), + ParamSpec::makeDouble("overlay_scale", "Scale", 1.0, 0.05, 8.0, + "Resizes the fitted image (the tile, for tiling) by this factor.", + 0.05), + ParamSpec::makeBool("overlay_center", "Centre", true, + "Places the image in the middle of the canvas rather than at its top " + "left corner; the offset is counted from there either way."), + ParamSpec::makeInt("overlay_offset_x", "Offset X", 0, -20000, 20000, + "Moves the image right by this many pixels (left when negative)."), + ParamSpec::makeInt("overlay_offset_y", "Offset Y", 0, -20000, 20000, + "Moves the image down by this many pixels (up when negative)."), + ParamSpec::makeDouble("overlay_opacity", "Opacity", 1.0, 0.0, 1.0, + "How strongly the image is mixed over the input; its own " + "transparency is taken into account as well."), + }; + return specs; +} + +namespace { + +// Where the overlay lands on the canvas, in canvas pixels. +struct Placement { + int x = 0; + int y = 0; + int width = 1; + int height = 1; +}; + +Placement placeOverlay(const std::string& fit, double scale, bool centre, int offsetX, int offsetY, + int srcWidth, int srcHeight, int width, int height) { + double drawWidth = srcWidth; + double drawHeight = srcHeight; + if (fit == "stretch") { + drawWidth = width; + drawHeight = height; + } else if (fit == "fit" || fit == "fill") { + const double byWidth = static_cast(width) / srcWidth; + const double byHeight = static_cast(height) / srcHeight; + const double factor = fit == "fit" ? std::min(byWidth, byHeight) : std::max(byWidth, byHeight); + drawWidth = srcWidth * factor; + drawHeight = srcHeight * factor; + } + // "original" and "tile" keep the file's own size; the scale below applies + // to every mode, so a fitted image can still be nudged a little larger. + + Placement placement; + placement.width = std::max(1, static_cast(std::lround(drawWidth * scale))); + placement.height = std::max(1, static_cast(std::lround(drawHeight * scale))); + placement.x = centre ? (width - placement.width) / 2 : 0; + placement.y = centre ? (height - placement.height) / 2 : 0; + placement.x += offsetX; + placement.y += offsetY; + return placement; +} + +int wrapIndex(int value, int limit) { + const int wrapped = value % limit; + return wrapped < 0 ? wrapped + limit : wrapped; +} + +// Bilinear sample of the overlay at a point in its own pixel coordinates, +// clamped at the edges or wrapped around when the image is being tiled. +void sampleOverlay(const Image& src, const std::uint8_t* alpha, double u, double v, bool wrap, + int rgb[3], int& outAlpha) { + const int w = src.width(); + const int h = src.height(); + const double fu = std::floor(u); + const double fv = std::floor(v); + const int u0 = static_cast(fu); + const int v0 = static_cast(fv); + const double du = u - fu; + const double dv = v - fv; + + int xs[2]; + int ys[2]; + for (int i = 0; i < 2; ++i) { + xs[i] = wrap ? wrapIndex(u0 + i, w) : std::clamp(u0 + i, 0, w - 1); + ys[i] = wrap ? wrapIndex(v0 + i, h) : std::clamp(v0 + i, 0, h - 1); + } + + double accum[3] = {0.0, 0.0, 0.0}; + double accumAlpha = 0.0; + for (int j = 0; j < 2; ++j) { + const double wy = j == 0 ? 1.0 - dv : dv; + const std::uint8_t* row = src.row(ys[j]); + const std::uint8_t* alphaRow = alpha + static_cast(ys[j]) * w; + for (int i = 0; i < 2; ++i) { + const double weight = wy * (i == 0 ? 1.0 - du : du); + const std::uint8_t* px = row + static_cast(xs[i]) * 3; + const double alphaWeight = weight * alphaRow[xs[i]]; + // Colour weighted by alpha: a transparent pixel in a PNG is usually + // black, and averaging it in unweighted draws a dark fringe around + // everything the image cuts out. + for (int c = 0; c < 3; ++c) accum[c] += px[c] * alphaWeight; + accumAlpha += alphaWeight; + } + } + outAlpha = std::clamp(static_cast(accumAlpha + 0.5), 0, 255); + for (int c = 0; c < 3; ++c) { + rgb[c] = accumAlpha <= 0.0 ? 0 + : std::clamp(static_cast(accum[c] / accumAlpha + 0.5), 0, + 255); + } +} + +} // namespace + +void OverlayEffect::loadOverlay(const std::string& path, Image& image, + std::vector& alpha) const { + std::lock_guard lock(cacheMutex_); + if (cachedPath_ != path || cached_.empty()) { + std::vector loadedAlpha; + Image loaded; + try { + loaded = Image::load(path, &loadedAlpha); + } catch (const std::exception& error) { + throw EffectError("Cannot read the overlay image '" + path + "': " + error.what()); + } + if (loaded.empty()) throw EffectError("The overlay image '" + path + "' is empty."); + cached_ = std::move(loaded); + cachedAlpha_ = std::move(loadedAlpha); + cachedPath_ = path; + } + image = cached_; + alpha = cachedAlpha_; +} + +Image OverlayEffect::render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const { + if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels."); + + Image canvas(width, height); + if (!sources.empty()) { + // Drawn before any threading, so that a seed always gives the same image. + const std::size_t index = rng.below(sources.size() - 1); + const Image& src = sources[index]; + const int sx = static_cast(rng.inRange(0, std::max(0, src.width() - width))); + const int sy = static_cast(rng.inRange(0, std::max(0, src.height() - height))); + fillFromSource(canvas, src, sx, sy); + } + + const std::string path = paramText(values, "overlay_image", ""); + const double opacity = std::clamp(paramDouble(values, "overlay_opacity", 1.0), 0.0, 1.0); + // No image chosen yet is the state the interface starts in, not an error: + // the stage simply passes its input through. + if (path.empty() || opacity <= 0.0) return canvas; + + Image overlay; + std::vector alpha; + loadOverlay(path, overlay, alpha); + + const std::string fit = paramChoice(values, "overlay_fit", "fit"); + const Placement place = placeOverlay( + fit, std::clamp(paramDouble(values, "overlay_scale", 1.0), 0.05, 8.0), + paramBool(values, "overlay_center", true), + static_cast(paramInt(values, "overlay_offset_x", 0)), + static_cast(paramInt(values, "overlay_offset_y", 0)), overlay.width(), + overlay.height(), width, height); + + const bool tiling = fit == "tile"; + // Source pixels per canvas pixel, the same both ways for every mode but + // stretch, where the two axes are scaled apart on purpose. + const double stepX = static_cast(overlay.width()) / place.width; + const double stepY = static_cast(overlay.height()) / place.height; + + const int firstRow = tiling ? 0 : std::max(0, place.y); + const int lastRow = tiling ? height : std::min(height, place.y + place.height); + const int firstCol = tiling ? 0 : std::max(0, place.x); + const int lastCol = tiling ? width : std::min(width, place.x + place.width); + if (firstRow >= lastRow || firstCol >= lastCol) return canvas; // entirely off the canvas + + const int mix = static_cast(opacity * 255.0 + 0.5); + const std::uint8_t* alphaData = alpha.data(); + + parallelFor(static_cast(lastRow - firstRow), [&](std::size_t begin, + std::size_t end) { + int rgb[3]; + int pixelAlpha = 255; + for (std::size_t i = begin; i < end; ++i) { + const int y = firstRow + static_cast(i); + const double v = (y - place.y + 0.5) * stepY - 0.5; + std::uint8_t* out = canvas.row(y); + for (int x = firstCol; x < lastCol; ++x) { + const double u = (x - place.x + 0.5) * stepX - 0.5; + sampleOverlay(overlay, alphaData, u, v, tiling, rgb, pixelAlpha); + + const int weight = pixelAlpha * mix / 255; + if (weight == 0) continue; + std::uint8_t* px = out + static_cast(x) * 3; + for (int c = 0; c < 3; ++c) { + px[c] = static_cast(px[c] + (rgb[c] - px[c]) * weight / 255); + } + } + } + }); + + return canvas; +} + +} // namespace effector diff --git a/src/effects/overlay.hpp b/src/effects/overlay.hpp new file mode 100644 index 0000000..42caad6 --- /dev/null +++ b/src/effects/overlay.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include + +#include "core/effect.hpp" + +namespace effector { + +// Lays one picture from disk over its input: a logo, a texture, a frame. The +// overlay is fitted to the canvas in the chosen way, placed from the top left +// or from the centre, nudged by an offset and mixed in by its opacity. Like the +// blur it needs no source images of its own, so it can lead a chain; given +// sources it fills the canvas from a randomly chosen one first. +class OverlayEffect : public Effect { +public: + std::string name() const override { return "overlay"; } + std::string title() const override { return "Image overlay"; } + std::string description() const override { + return "Lays a single image from disk over its input, fitted to the canvas, centred or " + "not, nudged by an offset and mixed in by its opacity."; + } + const std::vector& params() const override; + bool needsSources() const override { return false; } + + Image render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const override; + +private: + // The same file is re-read on every param change otherwise, and a render is + // a keystroke away in the interface. Renders can overlap, so it is guarded. + // Returns the decoded file and its alpha, reading it only when the path + // differs from the one already held. + void loadOverlay(const std::string& path, Image& image, + std::vector& alpha) const; + + mutable std::mutex cacheMutex_; + mutable std::string cachedPath_; + mutable Image cached_; + mutable std::vector cachedAlpha_; +}; + +} // namespace effector diff --git a/src/gui/window.cpp b/src/gui/window.cpp index c46a393..87656a9 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -293,6 +293,28 @@ void EffectorWindow::buildParamWidgets() { widget = std::move(spin); break; } + case ParamSpec::Type::Path: { + // An entry holding the path, with a chooser beside it; the + // entry is what paramValues() reads back. + auto box = std::make_unique(Gtk::Orientation::HORIZONTAL, 6); + auto* entry = Gtk::make_managed(); + entry->set_hexpand(true); + entry->set_text(paramText(stage.values, spec.key, "")); + entry->set_placeholder_text("No image chosen"); + entry->signal_activate().connect( + sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); + auto* choose = Gtk::make_managed("Choose…"); + const int index = selectedStage_; + const std::string key = spec.key; + const std::string title = spec.label; + choose->signal_clicked().connect( + [this, index, key, title] { onChoosePath(index, key, title); }); + box->append(*entry); + box->append(*choose); + paramWidgets_.push_back({spec, entry}); + widget = std::move(box); + break; + } case ParamSpec::Type::Choice: { std::vector choices(spec.choices.begin(), spec.choices.end()); auto dropdown = std::make_unique(); @@ -311,7 +333,9 @@ void EffectorWindow::buildParamWidgets() { widget->set_hexpand(true); paramGrid_.attach(*label, 0, row); paramGrid_.attach(*widget, 1, row); - paramWidgets_.push_back({spec, widget.get()}); + // A path attaches a box but reads back from the entry inside it, which + // it has registered itself. + if (spec.type != ParamSpec::Type::Path) paramWidgets_.push_back({spec, widget.get()}); ownedWidgets_.push_back(std::move(label)); ownedWidgets_.push_back(std::move(widget)); ++row; @@ -333,6 +357,10 @@ ParamValues EffectorWindow::paramValues() const { case ParamSpec::Type::Double: values[entry.spec.key] = static_cast(entry.widget)->get_value(); break; + case ParamSpec::Type::Path: + values[entry.spec.key] = + std::string(static_cast(entry.widget)->get_text()); + break; case ParamSpec::Type::Choice: { const auto index = static_cast(entry.widget)->get_selected(); if (index < entry.spec.choices.size()) values[entry.spec.key] = entry.spec.choices[index]; @@ -809,6 +837,36 @@ void EffectorWindow::onParamChanged() { requestRender(); } +void EffectorWindow::onChoosePath(int stage, const std::string& key, const std::string& label) { + auto dialog = Gtk::FileDialog::create(); + dialog->set_title("Select the " + label); + + auto filter = Gtk::FileFilter::create(); + filter->set_name("Images"); + for (const char* pattern : kImagePatterns) filter->add_pattern(pattern); + auto filters = Gio::ListStore::create(); + filters->append(filter); + dialog->set_filters(filters); + dialog->set_default_filter(filter); + + dialog->open(*this, [this, dialog, stage, key](const Glib::RefPtr& result) { + std::string path; + try { + const auto file = dialog->open_finish(result); + if (file) path = file->get_path(); + } catch (const Glib::Error&) { + return; // cancelled + } + if (path.empty() || stage < 0 || static_cast(stage) >= stages_.size()) return; + // The widgets may be showing another stage by now: store what they hold + // first, then write the path into the stage that asked for it. + storeParams(); + stages_[static_cast(stage)].values[key] = path; + if (stage == selectedStage_) buildParamWidgets(); + requestRender(); + }); +} + void EffectorWindow::onSeedEntered() { try { seed_ = std::stoull(seedEntry_.get_text()); diff --git a/src/gui/window.hpp b/src/gui/window.hpp index 99731e5..d57d6c4 100644 --- a/src/gui/window.hpp +++ b/src/gui/window.hpp @@ -75,6 +75,10 @@ private: void updateTitle(); void onParamChanged(); + // The chooser behind a Path param. The chosen file goes straight into the + // stage's values rather than into the widget, which may well have been + // rebuilt by the time the dialog comes back. + void onChoosePath(int stage, const std::string& key, const std::string& label); void onSeedEntered(); void onRecreate(); void onSave(); diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index ea4ed87..4ccffdf 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -1,5 +1,6 @@ // Small self-contained checks for the pieces that have no GUI dependency. +#include #include #include #include @@ -20,6 +21,7 @@ #include "effects/blur.hpp" #include "effects/grain.hpp" #include "effects/monochrome.hpp" +#include "effects/overlay.hpp" #include "effects/scanlines.hpp" namespace { @@ -116,6 +118,24 @@ effector::Image renderMono(const std::vector& sources, int widt return effect.render(sources, width, height, rng, merged); } +effector::Image renderOverlay(const std::vector& sources, int width, int height, + const effector::ParamValues& values) { + effector::OverlayEffect effect; + effector::Rng rng(7); + auto merged = effect.defaults(); + for (const auto& [key, value] : values) merged[key] = value; + return effect.render(sources, width, height, rng, merged); +} + +// The three channels of one pixel, to read a placement off directly. +std::array pixel(const effector::Image& image, int x, int y) { + const auto* px = image.row(y) + static_cast(x) * 3; + return {px[0], px[1], px[2]}; +} + +bool isRed(const std::array& px) { return px[0] > 200 && px[1] < 60 && px[2] < 60; } +bool isBlue(const std::array& px) { return px[2] > 200 && px[0] < 60 && px[1] < 60; } + // A flat field of one colour, to read the channel weighting off directly. effector::Image solid(int width, int height, std::uint8_t r, std::uint8_t g, std::uint8_t b) { effector::Image image(width, height); @@ -404,6 +424,113 @@ int main() { check(allBlack, "without sources monochrome renders an empty canvas"); } + std::cout << "overlay\n"; + { + const auto dir = std::filesystem::temp_directory_path() / "effector_test_overlay"; + std::filesystem::create_directories(dir); + const auto logo = (dir / "logo.png").string(); + solid(40, 20, 255, 0, 0).save(logo); // wider than it is tall, to tell the fittings apart + + const std::vector blue = {solid(100, 100, 0, 0, 255)}; + const ParamValues at = {{"overlay_image", logo}, {"overlay_fit", std::string("original")}}; + + const auto centred = renderOverlay(blue, 100, 100, at); + check(isRed(pixel(centred, 50, 50)), "the overlay lands in the middle of the canvas"); + check(isBlue(pixel(centred, 2, 2)), "and leaves the rest of the input alone"); + check(isRed(pixel(centred, 30, 40)) && isBlue(pixel(centred, 29, 40)), + "centred, a 40x20 image starts 30 pixels in"); + + ParamValues corner = at; + corner["overlay_center"] = false; + const auto topLeft = renderOverlay(blue, 100, 100, corner); + check(isRed(pixel(topLeft, 0, 0)) && isBlue(pixel(topLeft, 41, 0)), + "without centring it sits in the top left corner"); + + ParamValues nudged = corner; + nudged["overlay_offset_x"] = 10L; + nudged["overlay_offset_y"] = 5L; + const auto moved = renderOverlay(blue, 100, 100, nudged); + check(isRed(pixel(moved, 10, 5)) && isBlue(pixel(moved, 9, 5)) && isBlue(pixel(moved, 10, 4)), + "the offset moves it right and down"); + + ParamValues back = at; + back["overlay_offset_x"] = -25L; + const auto half = renderOverlay(blue, 100, 100, back); + check(isRed(pixel(half, 5, 50)) && isBlue(pixel(half, 96, 50)), + "a negative offset moves it off the left edge, and it is clipped"); + + // Fitting: fit shows the whole image and keeps its proportions, so a + // 40x20 logo becomes 100x50 with a band of input above and below; fill + // and stretch and tile all cover the canvas. + ParamValues fitted = at; + fitted["overlay_fit"] = std::string("fit"); + const auto fit = renderOverlay(blue, 100, 100, fitted); + check(isBlue(pixel(fit, 50, 10)) && isRed(pixel(fit, 50, 50)) && isRed(pixel(fit, 1, 50)), + "fit shows the whole image, in its own proportions"); + + for (const char* mode : {"fill", "stretch", "tile"}) { + ParamValues covering = at; + covering["overlay_fit"] = std::string(mode); + const auto image = renderOverlay(blue, 100, 100, covering); + bool everywhere = true; + for (int y = 0; y < 100; y += 7) { + for (int x = 0; x < 100; x += 7) everywhere = everywhere && isRed(pixel(image, x, y)); + } + check(everywhere, std::string(mode) + " covers the whole canvas"); + } + + // Scale resizes what the fitting worked out: half of 100x50 is 50x25. + ParamValues scaled = fitted; + scaled["overlay_scale"] = 0.5; + const auto smaller = renderOverlay(blue, 100, 100, scaled); + check(isRed(pixel(smaller, 50, 50)) && isBlue(pixel(smaller, 20, 50)), + "the scale shrinks the fitted image about its centre"); + + // Opacity mixes over the input rather than replacing it. + ParamValues faint = at; + faint["overlay_opacity"] = 0.5; + const auto mixed = pixel(renderOverlay(blue, 100, 100, faint), 50, 50); + check(mixed[0] > 100 && mixed[0] < 155 && mixed[2] > 100 && mixed[2] < 155, + "half opacity sits between the overlay and the input"); + check(sameBytes(renderOverlay(blue, 100, 100, {{"overlay_image", logo}, + {"overlay_opacity", 0.0}}), + solid(100, 100, 0, 0, 255)), + "zero opacity leaves the input untouched"); + + // No image chosen is the state the interface starts in: a pass through. + check(sameBytes(renderOverlay(blue, 100, 100, {}), solid(100, 100, 0, 0, 255)), + "with no image chosen the stage passes its input through"); + + // Like blur and monochrome it needs no sources of its own. + check(!OverlayEffect().needsSources(), "the overlay can lead a chain"); + const auto alone = renderOverlay({}, 60, 40, {{"overlay_image", logo}, + {"overlay_fit", std::string("stretch")}}); + check(alone.width() == 60 && isRed(pixel(alone, 30, 20)), + "without sources it draws on an empty canvas"); + + bool threw = false; + try { + renderOverlay(blue, 100, 100, {{"overlay_image", std::string("/nope/missing.png")}}); + } catch (const EffectError&) { + threw = true; + } + check(threw, "a missing overlay image is reported"); + + // The path travels with the project, written relative to its folder. + Project project; + project.width = 100; + project.height = 100; + project.stages.push_back({effectByName("overlay"), {{"overlay_image", logo}}}); + const auto json = projectToJson(project, dir.string()); + check(json.find("\"logo.png\"") != std::string::npos, + "an overlay image inside the project folder is written relative"); + const auto reread = projectFromJson(json, dir.string()); + check(paramText(reread.stages[0].values, "overlay_image", "") == logo, + "and comes back as the same file"); + + std::filesystem::remove_all(dir); + } + std::cout << "chain\n"; { std::vector sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};