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
+127
View File
@@ -1,5 +1,6 @@
// Small self-contained checks for the pieces that have no GUI dependency.
#include <array>
#include <atomic>
#include <cstdio>
#include <cstdlib>
@@ -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<effector::Image>& sources, int widt
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.
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<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::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};