Effector: C++/CMake image effects app
Port of the Python prototype to C++20 with a CMake build: - effector_core: GUI-free static library (effects, image IO via gdk-pixbuf, declarative param specs, registry, portable seeded RNG, thread helper) - scanlines effect: plans strips from the seeded RNG, then copies pixels in parallel with memcpy and doubling horizontal tiling - gtkmm 4 interface: native file dialogs, widgets generated from the param specs, coalesced background renders, preview as a GDK memory texture - CLI: flags generated from the effect registry, headless rendering with no display required - ctest suite covering the RNG, registry, effect geometry and image round trip The Python prototype is kept under python-reference/.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
add_executable(effector_tests core_tests.cpp)
|
||||
target_link_libraries(effector_tests PRIVATE effector_core)
|
||||
target_compile_options(effector_tests PRIVATE -Wall -Wextra -Wpedantic)
|
||||
|
||||
add_test(NAME core COMMAND effector_tests)
|
||||
@@ -0,0 +1,149 @@
|
||||
// Small self-contained checks for the pieces that have no GUI dependency.
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/registry.hpp"
|
||||
#include "core/rng.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());
|
||||
}
|
||||
|
||||
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 << "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 << (failures == 0 ? "\nall checks passed\n" : "\n" + std::to_string(failures) + " failed\n");
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user