The two-dimensional counterpart of scanlines: rectangular tiles cut at random positions from the source images, in three arrangements. A grid draws its column widths and row heights once so the cells stay aligned even when jittered; brick repacks every row on its own so the vertical seams do not meet; scatter fills the canvas from one of the images and drops loose tiles over it. The plan is drawn serially from the rng as scanlines does, but scattered tiles overlap, so they are painted in plan order on one thread: letting threads race for the same pixels would leave the output depending on their scheduling. A source smaller than a tile is repeated to fill the cell, sideways and then downwards, which keeps grid and brick gapless. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1127 lines
52 KiB
C++
1127 lines
52 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"
|
|
#include "effects/tiles.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);
|
|
}
|
|
|
|
effector::Image renderTiles(const std::vector<effector::Image>& sources, int width, int height,
|
|
std::uint64_t seed, const effector::ParamValues& values) {
|
|
effector::TilesEffect 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);
|
|
}
|
|
|
|
// 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 << "tiles\n";
|
|
{
|
|
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90),
|
|
gradient(300, 200, 200)};
|
|
|
|
for (const std::string layout : {"grid", "brick", "scatter"}) {
|
|
const ParamValues values = {{"tile_layout", layout},
|
|
{"tile_width", 70L},
|
|
{"tile_height", 50L},
|
|
{"tile_jitter", 12L}};
|
|
const auto first = renderTiles(sources, 400, 300, 1, values);
|
|
const auto again = renderTiles(sources, 400, 300, 1, values);
|
|
const auto other = renderTiles(sources, 400, 300, 2, values);
|
|
|
|
check(first.width() == 400 && first.height() == 300, layout + " has the requested size");
|
|
check(sameBytes(first, again), layout + ": same seed reproduces the image");
|
|
check(!sameBytes(first, other), layout + ": a different seed changes the image");
|
|
}
|
|
|
|
// With no jitter the grid cells line up: the gradient's red channel
|
|
// counts up with x inside a tile and only breaks at a cell boundary.
|
|
const auto aligned = renderTiles(sources, 400, 300, 5,
|
|
{{"tile_layout", std::string("grid")}, {"tile_width", 100L},
|
|
{"tile_height", 100L}});
|
|
bool onlyAtBoundaries = true;
|
|
for (int y = 0; y < aligned.height(); ++y) {
|
|
const auto* row = aligned.row(y);
|
|
for (int x = 1; x < aligned.width(); ++x) {
|
|
const bool breaks = row[x * 3] != ((row[(x - 1) * 3] + 1) & 0xFF);
|
|
if (breaks && x % 100 != 0) onlyAtBoundaries = false;
|
|
}
|
|
}
|
|
check(onlyAtBoundaries, "grid cells break only on the cell boundaries");
|
|
|
|
// One source smaller than a single tile: the cut is repeated to fill
|
|
// the cell, so the canvas is covered rather than left black.
|
|
std::vector<Image> small = {gradient(60, 40, 200)};
|
|
const auto covered = renderTiles(small, 500, 400, 9,
|
|
{{"tile_layout", std::string("brick")},
|
|
{"tile_width", 200L},
|
|
{"tile_height", 150L}});
|
|
bool filled = true;
|
|
for (int y = 0; y < covered.height(); ++y) {
|
|
const auto* row = covered.row(y);
|
|
for (int x = 0; x < covered.width(); ++x) filled = filled && row[x * 3 + 2] == 200;
|
|
}
|
|
check(filled, "a source smaller than a tile still covers the canvas");
|
|
|
|
const auto tiny = renderTiles(sources, 120, 90, 3,
|
|
{{"tile_width", 1L}, {"tile_height", 1L}});
|
|
check(tiny.width() == 120 && tiny.height() == 90, "tiles one pixel across");
|
|
|
|
const auto ordered = renderTiles(sources, 300, 200, 4,
|
|
{{"tile_width", 40L}, {"tile_keep_order", true}});
|
|
check(ordered.height() == 200, "keep_order fills the canvas");
|
|
|
|
// A single scattered tile leaves the rest of the canvas to the backdrop.
|
|
const auto sparse = renderTiles(small, 400, 300, 6,
|
|
{{"tile_layout", std::string("scatter")}, {"tile_count", 1L}});
|
|
bool backdrop = true;
|
|
for (int y = 0; y < sparse.height(); ++y) {
|
|
const auto* row = sparse.row(y);
|
|
for (int x = 0; x < sparse.width(); ++x) backdrop = backdrop && row[x * 3 + 2] == 200;
|
|
}
|
|
check(backdrop, "scattered tiles land on a filled backdrop");
|
|
|
|
bool threw = false;
|
|
try {
|
|
renderTiles({}, 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;
|
|
}
|