renderChain runs a list of stages in order: stage 0 works on the loaded sources, every later one on the output of the stage before it, so blur after scanlines softens the scanlines rather than the photographs. The effects themselves are untouched — none of them knows whether its input came from disk. All stages share one Rng and run in sequence, so a seed still reproduces the cascade byte for byte. The sidebar grows an "Effect chain" list: add a stage from a menu built from the registry, reorder it, remove it, and edit the selected stage's params underneath. Each stage keeps its own values. The CLI takes the same chain from repeated -e, with every flag going to the stage whose effect declares it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
306 lines
12 KiB
C++
306 lines
12 KiB
C++
// Small self-contained checks for the pieces that have no GUI dependency.
|
|
|
|
#include <atomic>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <iostream>
|
|
#include <mutex>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "core/chain.hpp"
|
|
#include "core/registry.hpp"
|
|
#include "core/rng.hpp"
|
|
#include "effects/blur.hpp"
|
|
#include "effects/scanlines.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;
|
|
}
|
|
|
|
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 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");
|
|
|
|
bool threw = false;
|
|
try {
|
|
renderBlur({}, 100, 100, 1, {});
|
|
} catch (const EffectError&) {
|
|
threw = true;
|
|
}
|
|
check(threw, "no sources throws");
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
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;
|
|
}
|