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 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-18 19:31:09 +02:00
co-authored by Claude Opus 5
parent 2a64ab115e
commit e2252ecd30
15 changed files with 520 additions and 8 deletions
+6 -4
View File
@@ -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<ParamSpec>` `Effect` (`src/core/effect.hpp`) exposes `params()` returning `std::vector<ParamSpec>`
(`src/core/params.hpp`). Those specs are the *only* thing the GUI and CLI know (`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 about an effect: `src/gui/window.cpp` builds SpinButtons/Switches/DropDowns/file
them and `src/cli/options.cpp` generates flags from them (`strip_height` → choosers from them and `src/cli/options.cpp` generates flags from them
`--strip-height`). Never add effect-specific branches to the GUI or the parser — (`strip_height` → `--strip-height`). Never add effect-specific branches to the
add a `ParamSpec` instead. Values travel as `ParamValues` (a 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<string, variant<long,double,bool,string>>`), read back with `map<string, variant<long,double,bool,string>>`), read back with
`paramInt`/`paramDouble`/`paramBool`/`paramChoice`. `paramInt`/`paramDouble`/`paramBool`/`paramChoice`.
+1
View File
@@ -30,6 +30,7 @@ add_library(effector_core STATIC
src/effects/blur.cpp src/effects/blur.cpp
src/effects/grain.cpp src/effects/grain.cpp
src/effects/monochrome.cpp src/effects/monochrome.cpp
src/effects/overlay.cpp
src/effects/scanlines.cpp src/effects/scanlines.cpp
) )
target_include_directories(effector_core PUBLIC src) target_include_directories(effector_core PUBLIC src)
+3
View File
@@ -110,6 +110,9 @@ Options parseOptions(int argc, char** argv) {
} }
break; break;
} }
case ParamSpec::Type::Path:
options.params[spec->key] = requireValue(argc, argv, i, arg);
break;
case ParamSpec::Type::Choice: { case ParamSpec::Type::Choice: {
const std::string text = requireValue(argc, argv, i, arg); const std::string text = requireValue(argc, argv, i, arg);
bool valid = false; bool valid = false;
+1
View File
@@ -16,6 +16,7 @@ ParamValues Effect::defaults() const {
values[spec.key] = spec.defaultValue != 0.0; values[spec.key] = spec.defaultValue != 0.0;
break; break;
case ParamSpec::Type::Choice: case ParamSpec::Type::Choice:
case ParamSpec::Type::Path:
values[spec.key] = spec.defaultChoice; values[spec.key] = spec.defaultChoice;
break; break;
} }
+13 -1
View File
@@ -43,7 +43,7 @@ Image::Image(int width, int height)
pixels_(static_cast<std::size_t>(std::max(width, 0)) * 3 * pixels_(static_cast<std::size_t>(std::max(width, 0)) * 3 *
static_cast<std::size_t>(std::max(height, 0))) {} static_cast<std::size_t>(std::max(height, 0))) {}
Image Image::load(const std::string& path) { Image Image::load(const std::string& path, std::vector<std::uint8_t>* alphaOut) {
GError* error = nullptr; GError* error = nullptr;
PixbufPtr pixbuf = wrap(gdk_pixbuf_new_from_file(path.c_str(), &error)); PixbufPtr pixbuf = wrap(gdk_pixbuf_new_from_file(path.c_str(), &error));
if (!pixbuf) throw std::runtime_error(takeError(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()); const guint8* src = gdk_pixbuf_get_pixels(pixbuf.get());
Image image(width, height); 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) { for (int y = 0; y < height; ++y) {
const guint8* in = src + static_cast<std::size_t>(y) * srcStride; const guint8* in = src + static_cast<std::size_t>(y) * srcStride;
std::uint8_t* out = image.row(y); std::uint8_t* out = image.row(y);
if (channels == 3) { if (channels == 3) {
std::memcpy(out, in, static_cast<std::size_t>(width) * 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 { } else {
// Flatten alpha onto white, matching what a viewer would show. // Flatten alpha onto white, matching what a viewer would show.
for (int x = 0; x < width; ++x) { for (int x = 0; x < width; ++x) {
+5 -1
View File
@@ -34,7 +34,11 @@ public:
// Decodes via GdkPixbuf (PNG, JPEG, WebP, TIFF, BMP, ...), applying the // Decodes via GdkPixbuf (PNG, JPEG, WebP, TIFF, BMP, ...), applying the
// embedded EXIF orientation. Throws std::runtime_error on failure. // 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<std::uint8_t>* alphaOut = nullptr);
// Encodes by file extension; png is used when the extension is unknown. // Encodes by file extension; png is used when the extension is unknown.
void save(const std::string& path) const; void save(const std::string& path) const;
+14
View File
@@ -63,6 +63,15 @@ ParamSpec ParamSpec::makeChoice(std::string key, std::string label, std::string
return spec; 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 { namespace {
const ParamValue* find(const ParamValues& values, const std::string& key) { 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, std::string paramChoice(const ParamValues& values, const std::string& key,
const std::string& fallback) { 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* value = find(values, key)) {
if (const auto* v = std::get_if<std::string>(value)) return *v; if (const auto* v = std::get_if<std::string>(value)) return *v;
} }
+7 -1
View File
@@ -10,7 +10,7 @@ namespace effector {
// Declarative description of one effect knob. The GUI turns these into widgets // 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. // and the CLI turns them into flags, so neither knows about any effect.
struct ParamSpec { struct ParamSpec {
enum class Type { Int, Double, Bool, Choice }; enum class Type { Int, Double, Bool, Choice, Path };
Type type = Type::Int; Type type = Type::Int;
std::string key; std::string key;
@@ -35,6 +35,10 @@ struct ParamSpec {
std::string help = {}); std::string help = {});
static ParamSpec makeChoice(std::string key, std::string label, std::string defaultChoice, static ParamSpec makeChoice(std::string key, std::string label, std::string defaultChoice,
std::vector<std::string> choices, std::string help = {}); std::vector<std::string> 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<long, double, bool, std::string>; using ParamValue = std::variant<long, double, bool, std::string>;
@@ -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); bool paramBool(const ParamValues& values, const std::string& key, bool fallback);
std::string paramChoice(const ParamValues& values, const std::string& key, std::string paramChoice(const ParamValues& values, const std::string& key,
const std::string& fallback); const std::string& fallback);
std::string paramText(const ParamValues& values, const std::string& key,
const std::string& fallback);
} // namespace effector } // namespace effector
+13
View File
@@ -348,6 +348,13 @@ std::string projectToJson(const Project& project, const std::string& directory)
case ParamSpec::Type::Choice: case ParamSpec::Type::Choice:
out << quote(paramChoice(stage.values, spec.key, spec.defaultChoice)); out << quote(paramChoice(stage.values, spec.key, spec.defaultChoice));
break; 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 }"; out << (specs.empty() ? "}" : "\n }") << "\n }";
@@ -418,6 +425,12 @@ Project projectFromJson(const std::string& text, const std::string& directory) {
case ParamSpec::Type::Double: case ParamSpec::Type::Double:
if (value->kind == Json::Kind::Number) stage.values[spec.key] = value->number; if (value->kind == Json::Kind::Number) stage.values[spec.key] = value->number;
break; 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: case ParamSpec::Type::Choice:
for (const auto& choice : spec.choices) { for (const auto& choice : spec.choices) {
if (value->kind == Json::Kind::String && value->text == choice) { if (value->kind == Json::Kind::String && value->text == choice) {
+2
View File
@@ -3,6 +3,7 @@
#include "effects/blur.hpp" #include "effects/blur.hpp"
#include "effects/grain.hpp" #include "effects/grain.hpp"
#include "effects/monochrome.hpp" #include "effects/monochrome.hpp"
#include "effects/overlay.hpp"
#include "effects/scanlines.hpp" #include "effects/scanlines.hpp"
namespace effector { namespace effector {
@@ -14,6 +15,7 @@ const std::vector<EffectPtr>& effects() {
std::make_shared<BlurEffect>(), std::make_shared<BlurEffect>(),
std::make_shared<GrainEffect>(), std::make_shared<GrainEffect>(),
std::make_shared<MonochromeEffect>(), std::make_shared<MonochromeEffect>(),
std::make_shared<OverlayEffect>(),
}; };
return registry; return registry;
} }
+220
View File
@@ -0,0 +1,220 @@
#include "effects/overlay.hpp"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include "core/parallel.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& OverlayEffect::params() const {
static const std::vector<ParamSpec> 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<double>(width) / srcWidth;
const double byHeight = static_cast<double>(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<int>(std::lround(drawWidth * scale)));
placement.height = std::max(1, static_cast<int>(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<int>(fu);
const int v0 = static_cast<int>(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<std::size_t>(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<std::size_t>(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<int>(accumAlpha + 0.5), 0, 255);
for (int c = 0; c < 3; ++c) {
rgb[c] = accumAlpha <= 0.0 ? 0
: std::clamp(static_cast<int>(accum[c] / accumAlpha + 0.5), 0,
255);
}
}
} // namespace
void OverlayEffect::loadOverlay(const std::string& path, Image& image,
std::vector<std::uint8_t>& alpha) const {
std::lock_guard<std::mutex> lock(cacheMutex_);
if (cachedPath_ != path || cached_.empty()) {
std::vector<std::uint8_t> 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<Image>& 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<int>(rng.inRange(0, std::max(0, src.width() - width)));
const int sy = static_cast<int>(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<std::uint8_t> 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<int>(paramInt(values, "overlay_offset_x", 0)),
static_cast<int>(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<double>(overlay.width()) / place.width;
const double stepY = static_cast<double>(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<int>(opacity * 255.0 + 0.5);
const std::uint8_t* alphaData = alpha.data();
parallelFor(static_cast<std::size_t>(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<int>(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<std::size_t>(x) * 3;
for (int c = 0; c < 3; ++c) {
px[c] = static_cast<std::uint8_t>(px[c] + (rgb[c] - px[c]) * weight / 255);
}
}
}
});
return canvas;
}
} // namespace effector
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include <cstdint>
#include <mutex>
#include <string>
#include <vector>
#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<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& 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<std::uint8_t>& alpha) const;
mutable std::mutex cacheMutex_;
mutable std::string cachedPath_;
mutable Image cached_;
mutable std::vector<std::uint8_t> cachedAlpha_;
};
} // namespace effector
+59 -1
View File
@@ -293,6 +293,28 @@ void EffectorWindow::buildParamWidgets() {
widget = std::move(spin); widget = std::move(spin);
break; 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::Box>(Gtk::Orientation::HORIZONTAL, 6);
auto* entry = Gtk::make_managed<Gtk::Entry>();
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<Gtk::Button>("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: { case ParamSpec::Type::Choice: {
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end()); std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
auto dropdown = std::make_unique<Gtk::DropDown>(); auto dropdown = std::make_unique<Gtk::DropDown>();
@@ -311,7 +333,9 @@ void EffectorWindow::buildParamWidgets() {
widget->set_hexpand(true); widget->set_hexpand(true);
paramGrid_.attach(*label, 0, row); paramGrid_.attach(*label, 0, row);
paramGrid_.attach(*widget, 1, 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(label));
ownedWidgets_.push_back(std::move(widget)); ownedWidgets_.push_back(std::move(widget));
++row; ++row;
@@ -333,6 +357,10 @@ ParamValues EffectorWindow::paramValues() const {
case ParamSpec::Type::Double: case ParamSpec::Type::Double:
values[entry.spec.key] = static_cast<Gtk::SpinButton*>(entry.widget)->get_value(); values[entry.spec.key] = static_cast<Gtk::SpinButton*>(entry.widget)->get_value();
break; break;
case ParamSpec::Type::Path:
values[entry.spec.key] =
std::string(static_cast<Gtk::Entry*>(entry.widget)->get_text());
break;
case ParamSpec::Type::Choice: { case ParamSpec::Type::Choice: {
const auto index = static_cast<Gtk::DropDown*>(entry.widget)->get_selected(); 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]; if (index < entry.spec.choices.size()) values[entry.spec.key] = entry.spec.choices[index];
@@ -809,6 +837,36 @@ void EffectorWindow::onParamChanged() {
requestRender(); 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<Gtk::FileFilter>::create();
filters->append(filter);
dialog->set_filters(filters);
dialog->set_default_filter(filter);
dialog->open(*this, [this, dialog, stage, key](const Glib::RefPtr<Gio::AsyncResult>& 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<std::size_t>(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<std::size_t>(stage)].values[key] = path;
if (stage == selectedStage_) buildParamWidgets();
requestRender();
});
}
void EffectorWindow::onSeedEntered() { void EffectorWindow::onSeedEntered() {
try { try {
seed_ = std::stoull(seedEntry_.get_text()); seed_ = std::stoull(seedEntry_.get_text());
+4
View File
@@ -75,6 +75,10 @@ private:
void updateTitle(); void updateTitle();
void onParamChanged(); 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 onSeedEntered();
void onRecreate(); void onRecreate();
void onSave(); void onSave();
+127
View File
@@ -1,5 +1,6 @@
// Small self-contained checks for the pieces that have no GUI dependency. // Small self-contained checks for the pieces that have no GUI dependency.
#include <array>
#include <atomic> #include <atomic>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
@@ -20,6 +21,7 @@
#include "effects/blur.hpp" #include "effects/blur.hpp"
#include "effects/grain.hpp" #include "effects/grain.hpp"
#include "effects/monochrome.hpp" #include "effects/monochrome.hpp"
#include "effects/overlay.hpp"
#include "effects/scanlines.hpp" #include "effects/scanlines.hpp"
namespace { namespace {
@@ -116,6 +118,24 @@ effector::Image renderMono(const std::vector<effector::Image>& sources, int widt
return effect.render(sources, width, height, rng, merged); return effect.render(sources, width, height, rng, merged);
} }
effector::Image renderOverlay(const std::vector<effector::Image>& 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<int, 3> pixel(const effector::Image& image, int x, int y) {
const auto* px = image.row(y) + static_cast<std::size_t>(x) * 3;
return {px[0], px[1], px[2]};
}
bool isRed(const std::array<int, 3>& px) { return px[0] > 200 && px[1] < 60 && px[2] < 60; }
bool isBlue(const std::array<int, 3>& px) { return px[2] > 200 && px[0] < 60 && px[1] < 60; }
// A flat field of one colour, to read the channel weighting off directly. // 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 solid(int width, int height, std::uint8_t r, std::uint8_t g, std::uint8_t b) {
effector::Image image(width, height); effector::Image image(width, height);
@@ -404,6 +424,113 @@ int main() {
check(allBlack, "without sources monochrome renders an empty canvas"); 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<Image> 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::cout << "chain\n";
{ {
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)}; std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};