Writes a line or several over its input in a chosen family, weight, size and colour, optionally on a box filled with a colour or with a picture from disk. Like the blur and the overlay it needs no sources of its own, so it can lead a chain; given sources it fills the canvas from a randomly chosen one first. Two param types rather than effect-specific branches in the front-ends: Text, free-form text the user types, and Color, a colour written "#rrggbb". Both travel as strings, so the interface gets an entry and a colour button, the command line gets a flag (a colour that does not parse is refused there rather than drawn as black), and a project keeps them across a round trip. The glyphs come from Pango, which draws through Cairo without a display, so the core stays headless — but they are the one thing here that is not byte-identical across machines: a machine without the chosen family renders the substitute its fontconfig picks. Everything the effect is handed, the rng included, is used exactly as every other effect uses it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1042 lines
48 KiB
C++
1042 lines
48 KiB
C++
// Small self-contained checks for the pieces that have no GUI dependency.
|
|
|
|
#include <array>
|
|
#include <atomic>
|
|
#include <clocale>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <iostream>
|
|
#include <locale>
|
|
#include <mutex>
|
|
#include <optional>
|
|
#include <string>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
#include "cli/options.hpp"
|
|
#include "core/chain.hpp"
|
|
#include "core/project.hpp"
|
|
#include "core/registry.hpp"
|
|
#include "core/rng.hpp"
|
|
#include "effects/blur.hpp"
|
|
#include "effects/grain.hpp"
|
|
#include "effects/monochrome.hpp"
|
|
#include "effects/overlay.hpp"
|
|
#include "effects/scanlines.hpp"
|
|
#include "effects/text.hpp"
|
|
|
|
namespace {
|
|
|
|
int failures = 0;
|
|
|
|
void check(bool condition, const std::string& what) {
|
|
if (condition) {
|
|
std::cout << " ok " << what << "\n";
|
|
} else {
|
|
std::cout << " FAIL " << what << "\n";
|
|
++failures;
|
|
}
|
|
}
|
|
|
|
effector::Image gradient(int width, int height, int shade) {
|
|
effector::Image image(width, height);
|
|
for (int y = 0; y < height; ++y) {
|
|
auto* row = image.row(y);
|
|
for (int x = 0; x < width; ++x) {
|
|
row[x * 3 + 0] = static_cast<std::uint8_t>((x + shade) & 0xFF);
|
|
row[x * 3 + 1] = static_cast<std::uint8_t>((y + shade) & 0xFF);
|
|
row[x * 3 + 2] = static_cast<std::uint8_t>(shade);
|
|
}
|
|
}
|
|
return image;
|
|
}
|
|
|
|
// A canvas taken from a project file, written by hand rather than by
|
|
// projectToJson, so a size no writer would produce can be fed to the reader.
|
|
std::string projectWithCanvas(const std::string& width, const std::string& height) {
|
|
return "{\"canvas\": {\"width\": " + width + ", \"height\": " + height +
|
|
"},\n \"chain\": [{\"effect\": \"blur\"}]}";
|
|
}
|
|
|
|
// The canvas a `-s` value ends up as, or nothing when the parser refuses it.
|
|
std::optional<std::pair<int, int>> canvasFromSize(const std::string& size) {
|
|
char program[] = "effector";
|
|
char flag[] = "-s";
|
|
std::vector<char> value(size.begin(), size.end());
|
|
value.push_back('\0');
|
|
char* argv[] = {program, flag, value.data()};
|
|
try {
|
|
const auto options = effector::parseOptions(3, argv);
|
|
return std::pair<int, int>{options.width, options.height};
|
|
} catch (const effector::EffectError&) {
|
|
return std::nullopt;
|
|
}
|
|
}
|
|
|
|
bool sameBytes(const effector::Image& a, const effector::Image& b) {
|
|
if (a.width() != b.width() || a.height() != b.height()) return false;
|
|
return std::equal(a.data(), a.data() + a.byteSize(), b.data());
|
|
}
|
|
|
|
// Alternating black and white columns: any blur along x has to pull these
|
|
// towards mid grey.
|
|
effector::Image stripes(int width, int height) {
|
|
effector::Image image(width, height);
|
|
for (int y = 0; y < height; ++y) {
|
|
auto* row = image.row(y);
|
|
for (int x = 0; x < width; ++x) {
|
|
const std::uint8_t value = (x % 2 == 0) ? 0 : 255;
|
|
row[x * 3 + 0] = row[x * 3 + 1] = row[x * 3 + 2] = value;
|
|
}
|
|
}
|
|
return image;
|
|
}
|
|
|
|
effector::Image renderBlur(const std::vector<effector::Image>& sources, int width, int height,
|
|
std::uint64_t seed, const effector::ParamValues& values) {
|
|
effector::BlurEffect effect;
|
|
effector::Rng rng(seed);
|
|
auto merged = effect.defaults();
|
|
for (const auto& [key, value] : values) merged[key] = value;
|
|
return effect.render(sources, width, height, rng, merged);
|
|
}
|
|
|
|
effector::Image renderGrain(const std::vector<effector::Image>& sources, int width, int height,
|
|
std::uint64_t seed, const effector::ParamValues& values) {
|
|
effector::GrainEffect effect;
|
|
effector::Rng rng(seed);
|
|
auto merged = effect.defaults();
|
|
for (const auto& [key, value] : values) merged[key] = value;
|
|
return effect.render(sources, width, height, rng, merged);
|
|
}
|
|
|
|
effector::Image renderMono(const std::vector<effector::Image>& sources, int width, int height,
|
|
std::uint64_t seed, const effector::ParamValues& values) {
|
|
effector::MonochromeEffect effect;
|
|
effector::Rng rng(seed);
|
|
auto merged = effect.defaults();
|
|
for (const auto& [key, value] : values) merged[key] = value;
|
|
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);
|
|
}
|
|
|
|
effector::Image renderText(const std::vector<effector::Image>& sources, int width, int height,
|
|
const effector::ParamValues& values) {
|
|
effector::TextEffect 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);
|
|
for (int y = 0; y < height; ++y) {
|
|
auto* row = image.row(y);
|
|
for (int x = 0; x < width; ++x) {
|
|
row[x * 3 + 0] = r;
|
|
row[x * 3 + 1] = g;
|
|
row[x * 3 + 2] = b;
|
|
}
|
|
}
|
|
return image;
|
|
}
|
|
|
|
// A flat field of one shade, to measure what the grain does to it.
|
|
effector::Image flat(int width, int height, std::uint8_t shade) {
|
|
effector::Image image(width, height);
|
|
std::fill(image.data(), image.data() + image.byteSize(), shade);
|
|
return image;
|
|
}
|
|
|
|
effector::Image render(const std::vector<effector::Image>& sources, int width, int height,
|
|
std::uint64_t seed, const effector::ParamValues& values) {
|
|
effector::ScanlinesEffect effect;
|
|
effector::Rng rng(seed);
|
|
auto merged = effect.defaults();
|
|
for (const auto& [key, value] : values) merged[key] = value;
|
|
return effect.render(sources, width, height, rng, merged);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
using namespace effector;
|
|
|
|
std::cout << "rng\n";
|
|
{
|
|
Rng a(42);
|
|
Rng b(42);
|
|
bool identical = true;
|
|
for (int i = 0; i < 1000; ++i) identical = identical && a.next() == b.next();
|
|
check(identical, "same seed yields the same stream");
|
|
|
|
Rng c(7);
|
|
bool inBounds = true;
|
|
for (int i = 0; i < 10000; ++i) {
|
|
const long value = c.inRange(-4, 9);
|
|
inBounds = inBounds && value >= -4 && value <= 9;
|
|
}
|
|
check(inBounds, "inRange stays within bounds");
|
|
}
|
|
|
|
std::cout << "registry\n";
|
|
{
|
|
check(!effects().empty(), "registry is populated");
|
|
check(effectByName("scanlines")->title() == "Scanlines", "lookup by name");
|
|
bool threw = false;
|
|
try {
|
|
effectByName("nope");
|
|
} catch (const EffectError&) {
|
|
threw = true;
|
|
}
|
|
check(threw, "unknown effect throws");
|
|
}
|
|
|
|
std::cout << "scanlines\n";
|
|
{
|
|
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90),
|
|
gradient(300, 200, 200)};
|
|
|
|
const auto first = render(sources, 800, 600, 1, {{"strip_height", 6L}, {"jitter", 3L}});
|
|
const auto again = render(sources, 800, 600, 1, {{"strip_height", 6L}, {"jitter", 3L}});
|
|
const auto other = render(sources, 800, 600, 2, {{"strip_height", 6L}, {"jitter", 3L}});
|
|
|
|
check(first.width() == 800 && first.height() == 600, "canvas has the requested size");
|
|
check(sameBytes(first, again), "same seed reproduces the image");
|
|
check(!sameBytes(first, other), "a different seed changes the image");
|
|
|
|
const auto thin = render(sources, 400, 300, 7, {{"strip_height", 1L}});
|
|
check(thin.width() == 400 && thin.height() == 300, "strip height of 1");
|
|
|
|
// Canvas wider and taller than the only source: exercises tiling and the
|
|
// clamp of the strip height to the source height.
|
|
std::vector<Image> small = {gradient(300, 200, 5)};
|
|
const auto tiled = render(small, 1000, 700, 9, {{"strip_height", 400L}, {"jitter", 50L}});
|
|
check(tiled.width() == 1000 && tiled.height() == 700, "tiling a narrow source");
|
|
bool tiles = true;
|
|
for (int y = 0; y < tiled.height(); ++y) {
|
|
const auto* row = tiled.row(y);
|
|
for (int c = 0; c < 3; ++c) tiles = tiles && row[c] == row[300 * 3 + c];
|
|
}
|
|
check(tiles, "tiled columns repeat with the source width");
|
|
|
|
const auto ordered = render(sources, 500, 500, 3, {{"strip_height", 20L}, {"keep_order", true}});
|
|
check(ordered.height() == 500, "keep_order fills the canvas");
|
|
|
|
bool threw = false;
|
|
try {
|
|
render({}, 100, 100, 1, {});
|
|
} catch (const EffectError&) {
|
|
threw = true;
|
|
}
|
|
check(threw, "no sources throws");
|
|
}
|
|
|
|
std::cout << "blur\n";
|
|
{
|
|
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
|
|
|
|
const auto first = renderBlur(sources, 400, 300, 4, {{"radius", 10L}});
|
|
const auto again = renderBlur(sources, 400, 300, 4, {{"radius", 10L}});
|
|
const auto other = renderBlur(sources, 400, 300, 11, {{"radius", 10L}});
|
|
check(first.width() == 400 && first.height() == 300, "canvas has the requested size");
|
|
check(sameBytes(first, again), "same seed reproduces the image");
|
|
check(!sameBytes(first, other), "a different seed changes the image");
|
|
|
|
// A wide blur over one-pixel stripes has to land near mid grey, and only
|
|
// along the axis it is applied to.
|
|
std::vector<Image> striped = {stripes(200, 100)};
|
|
const auto flattened = renderBlur(striped, 200, 100, 1,
|
|
{{"radius", 8L}, {"direction", std::string("horizontal")}});
|
|
// The clamped edges ramp in over radius * passes pixels on each side.
|
|
bool grey = true;
|
|
for (int x = 32; x < 168; ++x) grey = grey && std::abs(flattened.row(50)[x * 3] - 127) <= 2;
|
|
check(grey, "a horizontal blur evens out vertical stripes");
|
|
|
|
const auto untouched = renderBlur(striped, 200, 100, 1,
|
|
{{"radius", 8L}, {"direction", std::string("vertical")}});
|
|
check(untouched.row(50)[0] == 0 && untouched.row(50)[3] == 255,
|
|
"a vertical blur leaves columns alone");
|
|
|
|
// Every column is uniform, so a vertical blur must not change anything.
|
|
const auto vertical = renderBlur(striped, 200, 100, 1,
|
|
{{"radius", 4L}, {"passes", 3L}, {"direction", std::string("vertical")}});
|
|
check(sameBytes(untouched, vertical), "repeated vertical passes stay stable");
|
|
|
|
// Radius larger than the canvas must clamp rather than read out of bounds.
|
|
const auto tiny = renderBlur(striped, 3, 3, 2, {{"radius", 200L}});
|
|
check(tiny.width() == 3 && tiny.height() == 3, "radius wider than the canvas is clamped");
|
|
|
|
const auto pinned = renderBlur(sources, 300, 200, 6, {{"first_source", true}});
|
|
const auto pinnedAgain = renderBlur(sources, 300, 200, 6, {{"first_source", true}});
|
|
check(sameBytes(pinned, pinnedAgain), "first_source is reproducible");
|
|
|
|
// Blur declares that it needs no sources, so with none it softens the
|
|
// empty canvas it starts from instead of refusing to run.
|
|
const auto blank = renderBlur({}, 100, 100, 1, {});
|
|
bool allBlack = blank.width() == 100 && blank.height() == 100;
|
|
for (std::size_t i = 0; i < blank.byteSize(); ++i) allBlack = allBlack && blank.data()[i] == 0;
|
|
check(allBlack, "without sources blur renders an empty canvas");
|
|
}
|
|
|
|
std::cout << "grain\n";
|
|
{
|
|
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
|
|
|
|
const auto first = renderGrain(sources, 400, 300, 4, {});
|
|
const auto again = renderGrain(sources, 400, 300, 4, {});
|
|
const auto other = renderGrain(sources, 400, 300, 11, {});
|
|
check(first.width() == 400 && first.height() == 300, "canvas has the requested size");
|
|
check(sameBytes(first, again), "same seed reproduces the image");
|
|
check(!sameBytes(first, other), "a different seed changes the image");
|
|
|
|
// Mid grey is where the grain is strongest, so it must move, and stay
|
|
// grey while the channels are drawn together.
|
|
std::vector<Image> grey = {flat(200, 100, 128)};
|
|
const auto grained = renderGrain(grey, 200, 100, 3, {{"grain_amount", 0.5}});
|
|
bool moved = false;
|
|
bool neutral = true;
|
|
for (int x = 0; x < 200; ++x) {
|
|
const auto* px = grained.row(50) + x * 3;
|
|
moved = moved || px[0] != 128;
|
|
neutral = neutral && px[0] == px[1] && px[1] == px[2];
|
|
}
|
|
check(moved, "grain moves a flat mid grey");
|
|
check(neutral, "monochrome grain keeps the channels equal");
|
|
|
|
const auto coloured = renderGrain(grey, 200, 100, 3, {{"grain_colour", true}});
|
|
bool split = false;
|
|
for (int x = 0; x < 200; ++x) {
|
|
const auto* px = coloured.row(50) + x * 3;
|
|
split = split || px[0] != px[1] || px[1] != px[2];
|
|
}
|
|
check(split, "coloured grain pulls the channels apart");
|
|
|
|
// With the midtone weighting on, black and white are left alone; with
|
|
// it off the grain lands everywhere.
|
|
const auto blackWeighted = renderGrain({flat(64, 64, 0)}, 64, 64, 2, {});
|
|
bool untouched = true;
|
|
for (std::size_t i = 0; i < blackWeighted.byteSize(); ++i) {
|
|
untouched = untouched && blackWeighted.data()[i] == 0;
|
|
}
|
|
check(untouched, "the midtone weighting leaves black alone");
|
|
|
|
const auto blackFlat =
|
|
renderGrain({flat(64, 64, 0)}, 64, 64, 2, {{"grain_midtones", false}});
|
|
bool lifted = false;
|
|
for (std::size_t i = 0; i < blackFlat.byteSize(); ++i) {
|
|
lifted = lifted || blackFlat.data()[i] != 0;
|
|
}
|
|
check(lifted, "without the weighting grain reaches the shadows");
|
|
|
|
// Zero amount is a pass through, and a grain wider than the canvas must
|
|
// still only read the one cell it has.
|
|
check(sameBytes(renderGrain({flat(32, 32, 128)}, 32, 32, 1, {{"grain_amount", 0.0}}),
|
|
flat(32, 32, 128)),
|
|
"zero amount leaves the image untouched");
|
|
const auto coarse = renderGrain(grey, 40, 30, 7, {{"grain_size", 64L}});
|
|
check(coarse.width() == 40 && coarse.height() == 30,
|
|
"a grain wider than the canvas is clamped");
|
|
|
|
// Like blur, grain needs no sources: with none it grains the empty
|
|
// canvas it starts from instead of refusing to run.
|
|
const auto blank = renderGrain({}, 100, 100, 1, {{"grain_midtones", false}});
|
|
bool speckled = blank.width() == 100 && blank.height() == 100;
|
|
bool any = false;
|
|
for (std::size_t i = 0; i < blank.byteSize(); ++i) any = any || blank.data()[i] != 0;
|
|
check(speckled && any, "without sources grain works on an empty canvas");
|
|
}
|
|
|
|
std::cout << "monochrome\n";
|
|
{
|
|
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
|
|
|
|
const auto first = renderMono(sources, 400, 300, 4, {});
|
|
const auto again = renderMono(sources, 400, 300, 4, {});
|
|
check(first.width() == 400 && first.height() == 300, "canvas has the requested size");
|
|
check(sameBytes(first, again), "same seed reproduces the image");
|
|
|
|
bool grey = true;
|
|
for (std::size_t i = 0; i + 2 < first.byteSize(); i += 3) {
|
|
grey = grey && first.data()[i] == first.data()[i + 1] &&
|
|
first.data()[i + 1] == first.data()[i + 2];
|
|
}
|
|
check(grey, "every pixel comes out neutral grey");
|
|
|
|
// A pure red field: the luminance weighting keeps little of it, the red
|
|
// weighting nearly all, and the blue weighting almost none.
|
|
std::vector<Image> red = {solid(64, 64, 255, 0, 0)};
|
|
const auto viaLuma = renderMono(red, 64, 64, 1, {});
|
|
const auto viaRed = renderMono(red, 64, 64, 1, {{"mono_weighting", std::string("red")}});
|
|
const auto viaBlue = renderMono(red, 64, 64, 1, {{"mono_weighting", std::string("blue")}});
|
|
check(viaLuma.data()[0] > 40 && viaLuma.data()[0] < 70, "luminance darkens a pure red");
|
|
check(viaRed.data()[0] > 190, "the red weighting keeps a pure red light");
|
|
check(viaBlue.data()[0] < 50, "the blue weighting renders a pure red dark");
|
|
|
|
// Contrast works about mid grey: it leaves that alone and pushes the
|
|
// shades either side of it apart.
|
|
const auto midHard =
|
|
renderMono({solid(32, 32, 128, 128, 128)}, 32, 32, 1, {{"mono_contrast", 2.0}});
|
|
check(midHard.data()[0] >= 127 && midHard.data()[0] <= 129, "contrast pivots on mid grey");
|
|
const auto darkFlat = renderMono({solid(32, 32, 80, 80, 80)}, 32, 32, 1, {});
|
|
const auto darkHard =
|
|
renderMono({solid(32, 32, 80, 80, 80)}, 32, 32, 1, {{"mono_contrast", 2.0}});
|
|
check(darkHard.data()[0] < darkFlat.data()[0], "contrast deepens the shadows");
|
|
|
|
// The toners tint the greys, each in its own direction.
|
|
const auto sepia = renderMono({solid(32, 32, 128, 128, 128)}, 32, 32, 1,
|
|
{{"mono_tone", std::string("sepia")}});
|
|
check(sepia.data()[0] > sepia.data()[1] && sepia.data()[1] > sepia.data()[2],
|
|
"sepia warms the greys");
|
|
const auto cyan = renderMono({solid(32, 32, 128, 128, 128)}, 32, 32, 1,
|
|
{{"mono_tone", std::string("cyanotype")}});
|
|
check(cyan.data()[2] > cyan.data()[1] && cyan.data()[1] > cyan.data()[0],
|
|
"the cyanotype toner cools them");
|
|
|
|
// Strength mixes back towards the colour input: zero is a pass through,
|
|
// half sits between the two.
|
|
check(sameBytes(
|
|
renderMono({solid(32, 32, 200, 40, 10)}, 32, 32, 1, {{"mono_strength", 0.0}}),
|
|
solid(32, 32, 200, 40, 10)),
|
|
"zero strength leaves the colours untouched");
|
|
const auto half =
|
|
renderMono({solid(32, 32, 200, 40, 10)}, 32, 32, 1, {{"mono_strength", 0.5}});
|
|
const auto full = renderMono({solid(32, 32, 200, 40, 10)}, 32, 32, 1, {});
|
|
check(half.data()[0] > full.data()[0] && half.data()[0] < 200,
|
|
"half strength sits between the grey and the colour");
|
|
|
|
// Like blur and grain it needs no sources: with none it converts the
|
|
// empty canvas, which stays black.
|
|
const auto blank = renderMono({}, 50, 50, 1, {});
|
|
bool allBlack = blank.width() == 50 && blank.height() == 50;
|
|
for (std::size_t i = 0; i < blank.byteSize(); ++i) {
|
|
allBlack = allBlack && blank.data()[i] == 0;
|
|
}
|
|
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 << "colour params\n";
|
|
{
|
|
std::uint8_t rgb[3] = {0, 0, 0};
|
|
check(parseColor("#ff8800", rgb) && rgb[0] == 255 && rgb[1] == 136 && rgb[2] == 0,
|
|
"a six digit colour is read");
|
|
check(parseColor("00FF80", rgb) && rgb[1] == 255 && rgb[2] == 128,
|
|
"the hash is optional and the case does not matter");
|
|
check(parseColor("#f80", rgb) && rgb[0] == 255 && rgb[1] == 136 && rgb[2] == 0,
|
|
"the three digit shorthand doubles each digit");
|
|
check(!parseColor("#12345", rgb) && !parseColor("#gg0000", rgb) && !parseColor("", rgb),
|
|
"anything else is refused");
|
|
const std::uint8_t written[3] = {1, 171, 255};
|
|
check(formatColor(written) == "#01abff", "a colour is written back as six digits");
|
|
|
|
// A value that does not parse falls back rather than painting
|
|
// something arbitrary.
|
|
std::uint8_t fallback[3] = {0, 0, 0};
|
|
paramColor({{"c", std::string("nonsense")}}, "c", "#102030", fallback);
|
|
check(fallback[0] == 0x10 && fallback[1] == 0x20 && fallback[2] == 0x30,
|
|
"a malformed colour keeps the default");
|
|
|
|
// Both new types survive a project round trip, and the reader drops a
|
|
// colour it cannot read exactly as it drops an unknown choice.
|
|
Project project;
|
|
project.width = 64;
|
|
project.height = 64;
|
|
project.stages.push_back({effectByName("text"),
|
|
{{"text", std::string("a \"quoted\" line")},
|
|
{"text_color", std::string("#ff0080")}}});
|
|
const auto reread = projectFromJson(projectToJson(project, ""), "");
|
|
check(paramText(reread.stages[0].values, "text", "") == "a \"quoted\" line",
|
|
"text with quotes in it survives a project round trip");
|
|
check(paramText(reread.stages[0].values, "text_color", "") == "#ff0080",
|
|
"and so does a colour");
|
|
const auto broken = projectFromJson(
|
|
"{\"chain\": [{\"effect\": \"text\", \"params\": {\"text_color\": \"#zz\"}}]}", "");
|
|
check(paramText(broken.stages[0].values, "text_color", "") == "#ffffff",
|
|
"a colour a project cannot be read keeps the effect's default");
|
|
|
|
// The command line refuses one too, rather than drawing black.
|
|
bool threw = false;
|
|
try {
|
|
const char* argv[] = {"effector", "-e", "text", "--text-color", "purple"};
|
|
parseOptions(5, const_cast<char**>(argv));
|
|
} catch (const EffectError&) {
|
|
threw = true;
|
|
}
|
|
check(threw, "an unparseable colour on the command line is reported");
|
|
}
|
|
|
|
std::cout << "text\n";
|
|
{
|
|
const std::vector<Image> blue = {solid(200, 120, 0, 0, 255)};
|
|
const ParamValues plain = {{"text", std::string("Hi")},
|
|
{"font_size", 60.0},
|
|
{"text_color", std::string("#ff0000")}};
|
|
|
|
// Counting the red pixels is how every placement below is read: the
|
|
// exact glyphs belong to whatever font this machine has.
|
|
const auto countRed = [](const Image& image, int x0, int y0, int x1, int y1) {
|
|
int found = 0;
|
|
for (int y = y0; y < y1; ++y) {
|
|
for (int x = x0; x < x1; ++x) {
|
|
if (isRed(pixel(image, x, y))) ++found;
|
|
}
|
|
}
|
|
return found;
|
|
};
|
|
|
|
const auto centred = renderText(blue, 200, 120, plain);
|
|
check(countRed(centred, 60, 30, 140, 90) > 0, "the text lands in the middle of the canvas");
|
|
check(countRed(centred, 0, 0, 200, 120) == countRed(centred, 50, 20, 150, 100),
|
|
"and nothing is written outside it");
|
|
|
|
ParamValues corner = plain;
|
|
corner["text_center"] = false;
|
|
const auto topLeft = renderText(blue, 200, 120, corner);
|
|
check(countRed(topLeft, 0, 0, 100, 60) > 0 && countRed(topLeft, 100, 60, 200, 120) == 0,
|
|
"uncentred it starts at the top left corner");
|
|
|
|
ParamValues nudged = corner;
|
|
nudged["text_offset_x"] = 100L;
|
|
nudged["text_offset_y"] = 60L;
|
|
const auto moved = renderText(blue, 200, 120, nudged);
|
|
check(countRed(moved, 100, 60, 200, 120) > 0 && countRed(moved, 0, 0, 100, 60) == 0,
|
|
"the offset moves it down and to the right");
|
|
|
|
// Weight and size are the two knobs the glyphs themselves answer to: a
|
|
// bolder or a larger setting covers strictly more of the canvas.
|
|
ParamValues bold = plain;
|
|
bold["font_weight"] = std::string("heavy");
|
|
check(countRed(renderText(blue, 200, 120, bold), 0, 0, 200, 120) >
|
|
countRed(centred, 0, 0, 200, 120),
|
|
"a heavier weight inks more pixels than a normal one");
|
|
ParamValues large = plain;
|
|
large["font_size"] = 90.0;
|
|
check(countRed(renderText(blue, 200, 120, large), 0, 0, 200, 120) >
|
|
countRed(centred, 0, 0, 200, 120),
|
|
"and so does a larger size");
|
|
|
|
// The box is drawn under the text, in a colour or from a picture.
|
|
ParamValues boxed = plain;
|
|
boxed["text_box"] = std::string("colour");
|
|
boxed["box_color"] = std::string("#00ff00");
|
|
boxed["box_padding"] = 10L;
|
|
const auto onBox = renderText(blue, 200, 120, boxed);
|
|
const auto corner_px = pixel(onBox, 2, 2);
|
|
check(corner_px[2] > 200, "the box leaves the canvas outside it alone");
|
|
bool greenSomewhere = false;
|
|
for (int y = 0; y < 120 && !greenSomewhere; ++y) {
|
|
for (int x = 0; x < 200; ++x) {
|
|
const auto px = pixel(onBox, x, y);
|
|
if (px[1] > 200 && px[0] < 60 && px[2] < 60) greenSomewhere = true;
|
|
}
|
|
}
|
|
check(greenSomewhere, "and is filled with the colour it was given");
|
|
check(countRed(onBox, 0, 0, 200, 120) > 0, "the text is drawn over the box");
|
|
|
|
ParamValues faint = boxed;
|
|
faint["box_opacity"] = 0.5;
|
|
const auto half = renderText(blue, 200, 120, faint);
|
|
bool mixed = false;
|
|
for (int y = 0; y < 120 && !mixed; ++y) {
|
|
for (int x = 0; x < 200; ++x) {
|
|
const auto px = pixel(half, x, y);
|
|
// Half the green box over the blue input, and neither of them.
|
|
if (px[1] > 100 && px[1] < 200 && px[2] > 100 && px[2] < 200) mixed = true;
|
|
}
|
|
}
|
|
check(mixed, "half a box opacity sits between the box and the input");
|
|
|
|
const auto dir = std::filesystem::temp_directory_path() / "effector_test_text";
|
|
std::filesystem::create_directories(dir);
|
|
const auto picture = (dir / "fill.png").string();
|
|
solid(40, 40, 255, 255, 0).save(picture);
|
|
ParamValues fromPicture = boxed;
|
|
fromPicture["text_box"] = std::string("image");
|
|
fromPicture["box_image"] = picture;
|
|
const auto onPicture = renderText(blue, 200, 120, fromPicture);
|
|
bool yellowSomewhere = false;
|
|
for (int y = 0; y < 120 && !yellowSomewhere; ++y) {
|
|
for (int x = 0; x < 200; ++x) {
|
|
const auto px = pixel(onPicture, x, y);
|
|
if (px[0] > 200 && px[1] > 200 && px[2] < 60) yellowSomewhere = true;
|
|
}
|
|
}
|
|
check(yellowSomewhere, "a box picture fills the box");
|
|
check(pixel(onPicture, 2, 2)[2] > 200, "and stops at its edge");
|
|
|
|
bool threw = false;
|
|
try {
|
|
ParamValues missing = fromPicture;
|
|
missing["box_image"] = std::string("/nope/missing.png");
|
|
renderText(blue, 200, 120, missing);
|
|
} catch (const EffectError&) {
|
|
threw = true;
|
|
}
|
|
check(threw, "a missing box picture is reported");
|
|
|
|
// Nothing to draw is the state the interface starts in: a pass through.
|
|
check(sameBytes(renderText(blue, 200, 120, {{"text", std::string("")}}),
|
|
solid(200, 120, 0, 0, 255)),
|
|
"with no text typed the stage passes its input through");
|
|
check(sameBytes(renderText(blue, 200, 120, {{"text", std::string("Hi")},
|
|
{"text_opacity", 0.0}}),
|
|
solid(200, 120, 0, 0, 255)),
|
|
"zero opacity leaves the input untouched");
|
|
|
|
// Like blur and overlay it needs no sources of its own, and the same
|
|
// settings render the same bytes twice over.
|
|
check(!TextEffect().needsSources(), "text can lead a chain");
|
|
const auto alone = renderText({}, 200, 120, plain);
|
|
check(alone.width() == 200 && countRed(alone, 0, 0, 200, 120) > 0,
|
|
"without sources it draws on an empty canvas");
|
|
check(sameBytes(renderText(blue, 200, 120, boxed), onBox),
|
|
"the same settings render the same bytes");
|
|
|
|
std::filesystem::remove_all(dir);
|
|
}
|
|
|
|
std::cout << "chain\n";
|
|
{
|
|
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
|
|
|
|
ChainStage scanlines{effectByName("scanlines"), {{"strip_height", 6L}}};
|
|
ChainStage blur{effectByName("blur"), {{"radius", 6L}}};
|
|
|
|
Rng single(5);
|
|
const auto direct = renderChain({scanlines}, sources, 400, 300, single);
|
|
const auto alone = render(sources, 400, 300, 5, {{"strip_height", 6L}});
|
|
check(sameBytes(direct, alone), "a one stage chain matches the effect on its own");
|
|
|
|
Rng a(5);
|
|
Rng b(5);
|
|
const auto cascaded = renderChain({scanlines, blur}, sources, 400, 300, a);
|
|
const auto again = renderChain({scanlines, blur}, sources, 400, 300, b);
|
|
check(cascaded.width() == 400 && cascaded.height() == 300, "the chain keeps the canvas size");
|
|
check(sameBytes(cascaded, again), "the same seed reproduces the cascade");
|
|
check(!sameBytes(cascaded, direct), "a second stage changes the image");
|
|
|
|
// Order matters: blurring scanlines is not the same as scanning a blur.
|
|
Rng forward(5);
|
|
Rng backward(5);
|
|
const auto blurLast = renderChain({scanlines, blur}, sources, 400, 300, forward);
|
|
const auto blurFirst = renderChain({blur, scanlines}, sources, 400, 300, backward);
|
|
check(!sameBytes(blurLast, blurFirst), "the stage order changes the image");
|
|
|
|
check(chainTitle({scanlines, blur}) == "Scanlines \u2192 Blur", "the chain has a readable title");
|
|
check(chainSlug({scanlines, blur}) == "scanlines-blur", "the chain has a file name slug");
|
|
|
|
bool threw = false;
|
|
try {
|
|
Rng rng(1);
|
|
renderChain({}, sources, 100, 100, rng);
|
|
} catch (const EffectError&) {
|
|
threw = true;
|
|
}
|
|
check(threw, "an empty chain throws");
|
|
|
|
// Sources are an effect's own business: scanlines cuts strips out of
|
|
// them, blur only softens what it is handed and runs without any.
|
|
check(effectByName("scanlines")->needsSources(), "scanlines needs source images");
|
|
check(!effectByName("blur")->needsSources(), "blur needs no source images");
|
|
check(chainNeedsSources({scanlines, blur}), "a chain led by scanlines needs sources");
|
|
check(!chainNeedsSources({blur, scanlines}),
|
|
"a chain led by blur needs none, later stages see the stage before them");
|
|
check(!chainNeedsSources({}), "an empty chain needs no sources");
|
|
|
|
Rng bare(5);
|
|
const auto sourceless = renderChain({blur}, {}, 200, 150, bare);
|
|
check(sourceless.width() == 200 && sourceless.height() == 150,
|
|
"blur renders a canvas without sources");
|
|
|
|
bool scanlinesThrew = false;
|
|
try {
|
|
Rng rng(1);
|
|
renderChain({scanlines}, {}, 100, 100, rng);
|
|
} catch (const EffectError&) {
|
|
scanlinesThrew = true;
|
|
}
|
|
check(scanlinesThrew, "scanlines still refuses to run without sources");
|
|
}
|
|
|
|
std::cout << "project\n";
|
|
{
|
|
const auto dir = std::filesystem::temp_directory_path() / "effector_test_project";
|
|
std::filesystem::create_directories(dir);
|
|
const auto source = dir / "source.png";
|
|
gradient(64, 48, 7).save(source.string());
|
|
|
|
Project project;
|
|
project.sources = {source.string(), "/elsewhere/other.png"};
|
|
project.stages.push_back({effectByName("scanlines"), {{"strip_height", 9L}}});
|
|
project.stages.push_back(
|
|
{effectByName("blur"), {{"radius", 3L}, {"direction", std::string("vertical")}}});
|
|
project.width = 640;
|
|
project.height = 400;
|
|
project.seed = 1234567890123ULL;
|
|
|
|
const auto file = dir / "test.json";
|
|
saveProject(file.string(), project);
|
|
const auto reloaded = loadProject(file.string());
|
|
|
|
check(reloaded.width == 640 && reloaded.height == 400, "the canvas survives a round trip");
|
|
check(reloaded.seed && *reloaded.seed == 1234567890123ULL, "the seed survives a round trip");
|
|
check(reloaded.stages.size() == 2 && reloaded.stages[0].effect->name() == "scanlines" &&
|
|
reloaded.stages[1].effect->name() == "blur",
|
|
"the chain keeps its effects and their order");
|
|
check(paramInt(reloaded.stages[0].values, "strip_height", 0) == 9 &&
|
|
paramInt(reloaded.stages[1].values, "radius", 0) == 3 &&
|
|
paramChoice(reloaded.stages[1].values, "direction", "") == "vertical",
|
|
"the stage params survive a round trip");
|
|
check(paramInt(reloaded.stages[0].values, "jitter", -1) ==
|
|
paramInt(effectByName("scanlines")->defaults(), "jitter", -2),
|
|
"params the file leaves out keep their default");
|
|
|
|
// A source next to the project is written relative, so the folder can
|
|
// be moved; anything outside it stays absolute.
|
|
std::ifstream stream(file.string(), std::ios::binary);
|
|
const std::string text((std::istreambuf_iterator<char>(stream)),
|
|
std::istreambuf_iterator<char>());
|
|
check(text.find("\"source.png\"") != std::string::npos, "a nearby source is written relative");
|
|
check(text.find("\"/elsewhere/other.png\"") != std::string::npos,
|
|
"a source elsewhere stays absolute");
|
|
check(reloaded.sources.size() == 2 && reloaded.sources[0] == source.string(),
|
|
"a relative source is resolved against the project");
|
|
|
|
// The point of a project: the same picture comes back out of the file.
|
|
std::vector<Image> sources = {gradient(300, 200, 4)};
|
|
Rng before(*project.seed);
|
|
Rng after(*reloaded.seed);
|
|
const auto original = renderChain(project.stages, sources, project.width, project.height, before);
|
|
const auto restored =
|
|
renderChain(reloaded.stages, sources, reloaded.width, reloaded.height, after);
|
|
check(sameBytes(original, restored), "a reloaded project renders the same image");
|
|
|
|
// Params the effects do not declare are dropped rather than carried.
|
|
const auto stripped = projectFromJson(projectToJson(reloaded));
|
|
check(stripped.stages[0].values.find("radius") == stripped.stages[0].values.end(),
|
|
"a stage only keeps the params its effect declares");
|
|
|
|
int refusals = 0;
|
|
for (const char* bad : {"{\"chain\": [{\"effect\": \"nope\"}]}", "{\"chain\": []}",
|
|
"{\"chain\": [{\"effect\": \"blur\"},]}", "not json at all"}) {
|
|
try {
|
|
projectFromJson(bad);
|
|
} catch (const EffectError&) {
|
|
++refusals;
|
|
}
|
|
}
|
|
check(refusals == 4, "a broken project is refused");
|
|
|
|
bool threw = false;
|
|
try {
|
|
loadProject((dir / "missing.json").string());
|
|
} catch (const EffectError&) {
|
|
threw = true;
|
|
}
|
|
check(threw, "a missing project file throws");
|
|
|
|
// A project is JSON whatever the user's locale is. gtkmm sets both the
|
|
// C and the C++ global locale from the environment, so on a German
|
|
// desktop an unguarded stream writes "0,35" and a grouped "1.920" —
|
|
// which is not JSON, and the project could not be opened again.
|
|
const char* const commaLocales[] = {"de_DE.UTF-8", "de_DE.utf8", "fr_FR.UTF-8",
|
|
"fr_FR.utf8"};
|
|
bool decimalCommaTried = false;
|
|
for (const char* name : commaLocales) {
|
|
if (std::setlocale(LC_ALL, name) == nullptr) continue;
|
|
std::locale previous;
|
|
try {
|
|
previous = std::locale::global(std::locale(name));
|
|
} catch (const std::exception&) {
|
|
continue;
|
|
}
|
|
decimalCommaTried = true;
|
|
|
|
Project wide;
|
|
wide.width = 1920;
|
|
wide.height = 1080;
|
|
wide.seed = 17353667313003709502ULL; // more bits than a double holds
|
|
wide.stages.push_back({effectByName("grain"), {{"grain_amount", 0.35}}});
|
|
|
|
const auto text = projectToJson(wide);
|
|
bool reread = false;
|
|
try {
|
|
const auto back = projectFromJson(text);
|
|
reread = back.width == 1920 && back.height == 1080 &&
|
|
back.seed == wide.seed &&
|
|
paramDouble(back.stages[0].values, "grain_amount", -1.0) == 0.35;
|
|
} catch (const EffectError&) {
|
|
}
|
|
check(text.find("0.35") != std::string::npos &&
|
|
text.find("\"width\": 1920") != std::string::npos,
|
|
std::string("a project is written as JSON under ") + name);
|
|
check(reread, std::string("a project written under ") + name + " opens again");
|
|
|
|
std::locale::global(previous);
|
|
std::setlocale(LC_ALL, "C");
|
|
break;
|
|
}
|
|
if (!decimalCommaTried) {
|
|
std::cout << " skip a decimal-comma locale (none installed)\n";
|
|
}
|
|
|
|
std::filesystem::remove_all(dir);
|
|
}
|
|
|
|
std::cout << "canvas size\n";
|
|
{
|
|
// The sizes the front-ends actually offer make the round trip whole.
|
|
int kept = 0;
|
|
for (const auto [w, h] : {std::pair<int, int>{1, 1},
|
|
{640, 400},
|
|
{1920, 1080},
|
|
{kMaxCanvasSide, kMaxCanvasSide}}) {
|
|
const auto back = projectFromJson(
|
|
projectWithCanvas(std::to_string(w), std::to_string(h)));
|
|
if (back.width == w && back.height == h) ++kept;
|
|
}
|
|
check(kept == 4, "every canvas in range is read back exactly");
|
|
|
|
Project project;
|
|
project.stages.push_back({effectByName("blur"), {}});
|
|
project.width = kMaxCanvasSide;
|
|
project.height = 3;
|
|
const auto written = projectFromJson(projectToJson(project));
|
|
check(written.width == kMaxCanvasSide && written.height == 3,
|
|
"the largest canvas survives a write and a read");
|
|
|
|
// A side that does not fit in an int must not be narrowed into one
|
|
// that does: 4294968296 truncates to a perfectly plausible 1000.
|
|
int refusals = 0;
|
|
for (const char* bad : {"4294968296", "2147483648", "99999999999999999999", "-5", "0",
|
|
"20001", "800.5", "\"800\"", "true"}) {
|
|
try {
|
|
projectFromJson(projectWithCanvas(bad, "600"));
|
|
} catch (const EffectError&) {
|
|
++refusals;
|
|
}
|
|
}
|
|
check(refusals == 9, "a canvas width an int cannot hold is refused, not wrapped");
|
|
|
|
refusals = 0;
|
|
for (const char* bad : {"4294968296", "-5", "0", "20001", "800.5"}) {
|
|
try {
|
|
projectFromJson(projectWithCanvas("800", bad));
|
|
} catch (const EffectError&) {
|
|
++refusals;
|
|
}
|
|
}
|
|
check(refusals == 5, "the same bounds apply to the canvas height");
|
|
|
|
// A canvas the file does not mention keeps the defaults rather than
|
|
// failing: an old project is still readable.
|
|
const auto bare = projectFromJson("{\"chain\": [{\"effect\": \"blur\"}]}");
|
|
check(bare.width == 1920 && bare.height == 1080, "a project with no canvas keeps 1920x1080");
|
|
|
|
// The CLI narrows the same way, from a long rather than a double.
|
|
const auto plain = canvasFromSize("800x600");
|
|
check(plain && plain->first == 800 && plain->second == 600, "-s 800x600 is stored as given");
|
|
const auto big = canvasFromSize(std::to_string(kMaxCanvasSide) + "x" +
|
|
std::to_string(kMaxCanvasSide));
|
|
check(big && big->first == kMaxCanvasSide, "-s takes the largest canvas");
|
|
check(!canvasFromSize("4294968296x600"), "-s refuses a width that does not fit in an int");
|
|
check(!canvasFromSize("2147483648x600"), "-s refuses a width just past INT_MAX");
|
|
check(!canvasFromSize("99999999999999999999x600"), "-s refuses a width past a long");
|
|
check(!canvasFromSize("800x4294968296"), "-s refuses a height that does not fit in an int");
|
|
check(!canvasFromSize("20001x600") && !canvasFromSize("800x20001"),
|
|
"-s refuses a side past the canvas limit");
|
|
check(!canvasFromSize("0x600") && !canvasFromSize("-5x600"),
|
|
"-s refuses a side that is not positive");
|
|
}
|
|
|
|
std::cout << "image io\n";
|
|
{
|
|
const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png";
|
|
const auto original = gradient(64, 48, 3);
|
|
original.save(path.string());
|
|
const auto reloaded = Image::load(path.string());
|
|
check(sameBytes(original, reloaded), "png round trip is lossless");
|
|
std::filesystem::remove(path);
|
|
|
|
bool threw = false;
|
|
try {
|
|
Image::load("/definitely/not/here.png");
|
|
} catch (const std::exception&) {
|
|
threw = true;
|
|
}
|
|
check(threw, "missing file throws");
|
|
}
|
|
|
|
std::cout << "batch loading\n";
|
|
{
|
|
const auto dir = std::filesystem::temp_directory_path() / "effector_test_batch";
|
|
std::filesystem::create_directories(dir);
|
|
std::vector<std::string> paths;
|
|
for (int i = 0; i < 6; ++i) {
|
|
const auto path = dir / ("src" + std::to_string(i) + ".png");
|
|
gradient(32, 24, i + 1).save(path.string());
|
|
paths.push_back(path.string());
|
|
}
|
|
paths.push_back((dir / "missing.png").string());
|
|
|
|
std::atomic<std::size_t> calls{0};
|
|
std::size_t lastTotal = 0;
|
|
std::mutex mutex;
|
|
LoadProgress progress;
|
|
progress.onFile = [&](std::size_t done, std::size_t total, const std::string&) {
|
|
std::lock_guard<std::mutex> lock(mutex);
|
|
++calls;
|
|
lastTotal = total;
|
|
check(done >= 1 && done <= total, "progress step is within the batch");
|
|
};
|
|
|
|
std::vector<LoadFailure> failuresOut;
|
|
const auto images = loadImages(paths, failuresOut, &progress);
|
|
check(images.size() == 6, "every readable image is returned");
|
|
check(failuresOut.size() == 1, "the unreadable one is reported");
|
|
check(calls.load() == paths.size(), "progress fires once per file");
|
|
check(lastTotal == paths.size(), "progress reports the batch size");
|
|
check(images.front().path() == paths.front(), "input order is preserved");
|
|
|
|
std::atomic<bool> cancel{true};
|
|
LoadProgress cancelled;
|
|
cancelled.cancel = &cancel;
|
|
std::vector<LoadFailure> ignored;
|
|
const auto none = loadImages(paths, ignored, &cancelled);
|
|
check(none.empty(), "a cancelled batch loads nothing");
|
|
|
|
std::filesystem::remove_all(dir);
|
|
}
|
|
|
|
std::cout << (failures == 0 ? "\nall checks passed\n" : "\n" + std::to_string(failures) + " failed\n");
|
|
return failures == 0 ? 0 : 1;
|
|
}
|