// Small self-contained checks for the pieces that have no GUI dependency. #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "cli/options.hpp" #include "core/canvas.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((x + shade) & 0xFF); row[x * 3 + 1] = static_cast((y + shade) & 0xFF); row[x * 3 + 2] = static_cast(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> canvasFromSize(const std::string& size) { char program[] = "effector"; char flag[] = "-s"; std::vector 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{options.width, options.height}; } catch (const effector::EffectError&) { return std::nullopt; } } // The options a whole command line ends up as, or nothing when the parser // refuses it. std::optional parsedOptions(const std::vector& args) { std::vector> storage; storage.push_back({'e', 'f', 'f', 'e', 'c', 't', 'o', 'r', '\0'}); for (const auto& arg : args) { std::vector buffer(arg.begin(), arg.end()); buffer.push_back('\0'); storage.push_back(std::move(buffer)); } std::vector argv; for (auto& buffer : storage) argv.push_back(buffer.data()); try { return effector::parseOptions(static_cast(argv.size()), argv.data()); } 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& 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& 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& 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& 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& 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& 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 pixel(const effector::Image& image, int x, int y) { const auto* px = image.row(y) + static_cast(x) * 3; return {px[0], px[1], px[2]}; } bool isRed(const std::array& px) { return px[0] > 200 && px[1] < 60 && px[2] < 60; } bool isBlue(const std::array& 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& 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 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 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 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 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 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 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 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 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 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 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 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(argv)); } catch (const EffectError&) { threw = true; } check(threw, "an unparseable colour on the command line is reported"); } std::cout << "text\n"; { const std::vector 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 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"); // A stage switched off is skipped whole: it is handed no image and // draws nothing from the shared rng, so the cascade comes out exactly // as it would have without that stage in it at all. ChainStage blurOff = blur; blurOff.enabled = false; ChainStage scanlinesOff = scanlines; scanlinesOff.enabled = false; Rng skipping(5); const auto skipped = renderChain({scanlines, blurOff}, sources, 400, 300, skipping); check(sameBytes(skipped, direct), "a disabled stage is skipped, its rng draws with it"); check(chainTitle({scanlines, blurOff}) == "Scanlines", "the title names only what ran"); check(chainSlug({scanlines, blurOff}) == "scanlines", "and so does the slug"); // Whoever leads the chain is handed the sources, and that is the first // stage still switched on. check(!chainNeedsSources({scanlinesOff, blur}), "a disabled leading stage leaves the question to the stage behind it"); Rng leading(5); const auto ledByBlur = renderChain({scanlinesOff, blur}, {}, 200, 150, leading); check(sameBytes(ledByBlur, sourceless), "the stage behind it leads instead"); bool allOffThrew = false; try { Rng rng(1); renderChain({scanlinesOff, blurOff}, sources, 100, 100, rng); } catch (const EffectError&) { allOffThrew = true; } check(allOffThrew, "a chain with every stage disabled throws"); // The command line switches a stage off by effect name, the same way // the params pick out the stage they belong to. const auto cli = parsedOptions({"-e", "scanlines", "-e", "blur", "--disable", "blur", "--radius", "4"}); check(cli.has_value(), "--disable is accepted"); const auto cliStages = buildChain(*cli); check(cliStages.size() == 2 && cliStages[0].enabled && !cliStages[1].enabled, "--disable keeps the stage in the chain and switches it off"); check(paramInt(cliStages[1].values, "radius", 0) == 4, "a switched-off stage still takes its flags"); check(!parsedOptions({"-e", "blur", "--disable", "nope"}).has_value(), "--disable refuses an unknown effect"); } 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(stream)), std::istreambuf_iterator()); 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 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"); // A stage switched off keeps its place and its settings in the file and // comes back switched off; nothing is written for the ordinary case. Project toggled = project; toggled.stages[1].enabled = false; const auto offFile = dir / "disabled.json"; saveProject(offFile.string(), toggled); const auto offBack = loadProject(offFile.string()); check(offBack.stages.size() == 2 && offBack.stages[0].enabled && !offBack.stages[1].enabled, "a switched-off stage comes back switched off"); check(paramInt(offBack.stages[1].values, "radius", 0) == 3, "and with the settings it was switched off with"); check(projectToJson(toggled).find("\"enabled\"") != std::string::npos && projectToJson(project).find("\"enabled\"") == std::string::npos, "only a switched-off stage is written out as one"); 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{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 << "canvas formats\n"; { using effector::canvasFormatByKey; using effector::canvasFormats; using effector::canvasFormatSize; // Every format the menu offers must land inside the canvas bounds at // every resolution it can be asked for, or the dropdown would offer a // size the renderer refuses. int usable = 0; for (const auto& format : canvasFormats()) { int width = 0; int height = 0; bool ok = true; for (const int dpi : {effector::kMinDpi, effector::kDefaultDpi, effector::kMaxDpi}) { canvasFormatSize(format, dpi, width, height); ok = ok && width >= kMinCanvasSide && width <= kMaxCanvasSide && height >= kMinCanvasSide && height <= kMaxCanvasSide; } if (ok) ++usable; } check(usable == static_cast(canvasFormats().size()), "every format stays inside the canvas bounds at every dpi"); int unique = 0; for (const auto& format : canvasFormats()) { if (canvasFormatByKey(format.key) == &format) ++unique; } check(unique == static_cast(canvasFormats().size()), "format keys are unique"); check(canvasFormatByKey("") == nullptr && canvasFormatByKey("a6") == nullptr, "an empty or unknown key is a custom canvas"); int width = 0; int height = 0; canvasFormatSize(*canvasFormatByKey("a4"), 300, width, height); check(width == 2480 && height == 3508, "A4 at 300 dpi is 2480x3508"); canvasFormatSize(*canvasFormatByKey("a4"), 600, width, height); check(width == 4961 && height == 7016, "doubling the dpi doubles the sheet"); canvasFormatSize(*canvasFormatByKey("instagram-story"), 600, width, height); check(width == 1080 && height == 1920, "a web format ignores the dpi"); // The CLI: a format decides the canvas whichever side of -s it is on, // and --dpi may come before the format it applies to. const auto sheet = parsedOptions({"--format", "a4", "--dpi", "600"}); check(sheet && sheet->width == 4961 && sheet->height == 7016, "--format a4 --dpi 600 sets the canvas"); const auto reordered = parsedOptions({"--dpi", "600", "--format", "a4"}); check(reordered && reordered->width == 4961, "--dpi may be given before --format"); const auto both = parsedOptions({"-s", "800x600", "--format", "instagram-post"}); check(both && both->width == 1080 && both->height == 1080, "--format overrides -s"); const auto custom = parsedOptions({"-s", "800x600"}); check(custom && custom->format.empty() && custom->width == 800, "-s alone leaves the canvas custom"); check(!parsedOptions({"--format", "a6"}), "--format refuses an unknown format"); check(!parsedOptions({"--dpi", "0"}) && !parsedOptions({"--dpi", "5000"}) && !parsedOptions({"--dpi", "many"}), "--dpi refuses a resolution outside the bounds"); // A project keeps the format, and the format keeps the pixels: a dpi // edited by hand moves the canvas with it. Project sheetProject; sheetProject.stages.push_back({effectByName("blur"), {}}); sheetProject.format = "a4"; sheetProject.dpi = 600; canvasFormatSize(*canvasFormatByKey("a4"), 600, sheetProject.width, sheetProject.height); const auto backProject = projectFromJson(projectToJson(sheetProject)); check(backProject.format == "a4" && backProject.dpi == 600 && backProject.width == 4961, "a print format and its dpi survive a round trip"); const auto retuned = projectFromJson( "{\"canvas\": {\"width\": 800, \"height\": 600, \"format\": \"a4\", " "\"dpi\": 300},\n \"chain\": [{\"effect\": \"blur\"}]}"); check(retuned.width == 2480 && retuned.height == 3508, "a named format decides the pixels over whatever was written beside it"); // A format this build does not know — a newer file, or a typo — is a // custom canvas, so the pixels the file carries are still rendered. const auto unknown = projectFromJson( "{\"canvas\": {\"width\": 800, \"height\": 600, \"format\": \"a6\"},\n" " \"chain\": [{\"effect\": \"blur\"}]}"); check(unknown.format.empty() && unknown.width == 800 && unknown.height == 600, "an unknown format falls back to the canvas the file names"); Project plainProject; plainProject.stages.push_back({effectByName("blur"), {}}); plainProject.width = 800; plainProject.height = 600; const auto plainText = projectToJson(plainProject); check(plainText.find("format") == std::string::npos && plainText.find("dpi") == std::string::npos, "a custom canvas writes no format and no dpi"); const auto webText = projectToJson([] { Project web; web.stages.push_back({effectByName("blur"), {}}); web.format = "hd"; return web; }()); check(webText.find("\"format\": \"hd\"") != std::string::npos && webText.find("dpi") == std::string::npos, "a web format writes no dpi it would never use"); int badDpi = 0; for (const char* bad : {"0", "5000", "300.5", "\"300\""}) { try { projectFromJson(std::string("{\"canvas\": {\"dpi\": ") + bad + "},\n \"chain\": [{\"effect\": \"blur\"}]}"); } catch (const EffectError&) { ++badDpi; } } check(badDpi == 4, "a dpi outside the bounds is refused"); } 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 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 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 lock(mutex); ++calls; lastTotal = total; check(done >= 1 && done <= total, "progress step is within the batch"); }; std::vector 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 cancel{true}; LoadProgress cancelled; cancelled.cancel = &cancel; std::vector 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; }