Files
effector/tests/core_tests.cpp
T
SiandClaude Opus 5.5 a7cb2505d7 Add Datamosh, a video effect whose Corrupt blocks break and heal over the clip
Corrupt's block damage moves to effects/corrupt_blocks.hpp so both share it;
Corrupt renders exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 20:41:52 +02:00

2322 lines
116 KiB
C++

// Small self-contained checks for the pieces that have no GUI dependency.
#include <algorithm>
#include <array>
#include <atomic>
#include <clocale>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <locale>
#include <mutex>
#include <optional>
#include <set>
#include <string>
#include <utility>
#include <vector>
#include "cli/options.hpp"
#include "core/canvas.hpp"
#include "core/chain.hpp"
#include "core/memory.hpp"
#include "core/project.hpp"
#include "core/registry.hpp"
#include "core/rng.hpp"
#include "core/style.hpp"
#include "core/version.hpp"
#include "core/video.hpp"
#include "effects/blur.hpp"
#include "effects/channelsplit.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"
#include "effects/video_effect.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;
}
}
// The options a whole command line ends up as, or nothing when the parser
// refuses it.
std::optional<effector::Options> parsedOptions(const std::vector<std::string>& args) {
std::vector<std::vector<char>> storage;
storage.push_back({'e', 'f', 'f', 'e', 'c', 't', 'o', 'r', '\0'});
for (const auto& arg : args) {
std::vector<char> buffer(arg.begin(), arg.end());
buffer.push_back('\0');
storage.push_back(std::move(buffer));
}
std::vector<char*> argv;
for (auto& buffer : storage) argv.push_back(buffer.data());
try {
return effector::parseOptions(static_cast<int>(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<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;
}
// Renders any registered effect by name with its defaults, overridden by
// `values`, from a generator seeded with `seed`.
effector::Image renderNamed(const std::string& name, const std::vector<effector::Image>& sources,
int width, int height, std::uint64_t seed,
const effector::ParamValues& values) {
const auto effect = effector::effectByName(name);
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");
// Flags share one namespace, so a key two effects declared would be
// handed to whichever came first.
std::set<std::string> keys;
bool unique = true;
for (const auto& effect : effects()) {
for (const auto& spec : effect->params()) unique = keys.insert(spec.key).second && unique;
}
check(unique, "no two effects declare the same param key");
}
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");
// Halo: none by default, a shadow moved by its offset, a glow all round.
check(sameBytes(renderOverlay(blue, 100, 100, at),
renderOverlay(blue, 100, 100, [&] {
ParamValues none = at;
none["overlay_halo"] = std::string("none");
return none;
}())),
"no halo is the default");
ParamValues shadowed = at;
shadowed["overlay_halo"] = std::string("shadow");
shadowed["overlay_halo_size"] = 0L;
shadowed["overlay_halo_opacity"] = 1.0;
shadowed["overlay_shadow_x"] = 5L;
shadowed["overlay_shadow_y"] = 5L;
const auto shadow = renderOverlay(blue, 100, 100, shadowed);
const auto dark = pixel(shadow, 72, 62); // past the logo's bottom right corner (70, 60)
check(dark[0] == 0 && dark[1] == 0 && dark[2] == 0,
"a hard shadow is cast down and right by its offset");
check(isBlue(pixel(shadow, 28, 50)) && isRed(pixel(shadow, 50, 50)),
"it leaves the other side alone and stays beneath the image");
shadowed["overlay_halo_size"] = 12L;
const auto soft = pixel(renderOverlay(blue, 100, 100, shadowed), 78, 50);
check(soft[2] > 0 && soft[2] < 255, "a soft shadow fades out past the edge");
ParamValues glowing = at;
glowing["overlay_halo"] = std::string("glow");
const auto glow = renderOverlay(blue, 100, 100, glowing);
const auto left = pixel(glow, 27, 50);
const auto right = pixel(glow, 72, 50);
check(left[0] > 0 && left[0] == right[0] && left[1] == right[1],
"a glow lightens both sides alike");
check(isBlue(pixel(glow, 2, 2)), "and fades out before the far corners");
glowing["overlay_halo_spread"] = 0.5;
check(pixel(renderOverlay(blue, 100, 100, glowing), 27, 50)[0] > left[0],
"spread strengthens the halo near the edge");
ParamValues offCanvas = shadowed;
offCanvas["overlay_halo_size"] = 0L;
offCanvas["overlay_offset_x"] = -70L; // the logo ends just left of the canvas
offCanvas["overlay_shadow_x"] = 20L;
const auto cast = pixel(renderOverlay(blue, 100, 100, offCanvas), 5, 50);
check(cast[2] == 0, "an image off the canvas still casts its shadow onto it");
// 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");
// A wide line turned a quarter stands upright: it reaches above and
// below the band it filled unturned, and no longer out to the sides.
ParamValues wide = plain;
wide["text"] = std::string("HHHHHH");
wide["font_size"] = 40.0;
const auto flat = renderText(blue, 200, 120, wide);
ParamValues turned = wide;
turned["text_rotation"] = 90.0;
const auto upright = renderText(blue, 200, 120, turned);
check(countRed(flat, 0, 0, 200, 25) == 0 && countRed(upright, 0, 0, 200, 25) > 0,
"rotation turns the text about its centre");
check(countRed(flat, 0, 0, 50, 120) > 0 && countRed(upright, 0, 0, 50, 120) == 0,
"and takes it in from the sides");
// 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");
// A text stage is named after its words, on one line.
check(TextEffect().label({{"text", std::string(" Two\nlines ")}}) == "Text (Two lines)",
"a text stage is labelled with its words");
check(TextEffect().label({{"text", std::string(" \n")}}) == "Text",
"a text stage with no words is labelled with the title alone");
check(effectByName("blur")->label({}) == "Blur", "other effects are labelled with their title");
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 << "text styles\n";
{
const std::vector<Image> blue = {solid(200, 120, 0, 0, 255)};
const auto countRed = [](const Image& image) {
int found = 0;
for (int y = 0; y < image.height(); ++y) {
for (int x = 0; x < image.width(); ++x) {
if (isRed(pixel(image, x, y))) ++found;
}
}
return found;
};
const auto specs = styleSpecs("text");
const auto carries = [&specs](const std::string& key) {
return std::any_of(specs.begin(), specs.end(),
[&key](const ParamSpec& spec) { return spec.key == key; });
};
check(specs.size() == 11, "a text style carries eleven params");
check(carries("font_family") && carries("font_weight") && carries("font_italic") &&
carries("font_size") && carries("text_color") && carries("text_align") &&
carries("text_opacity") && carries("text_box") && carries("box_color") &&
carries("box_padding") && carries("box_opacity"),
"the look is what it carries");
check(!carries("text") && !carries("text_wrap") && !carries("box_image") &&
!carries("text_center") && !carries("text_offset_x") && !carries("text_offset_y") &&
!carries("text_rotation"),
"the words and the placement stay on the stage");
check(styleSpecs("").empty() && styleSpecs("nosuchkind").empty(),
"a kind no effect claims carries nothing");
check(paramDouble(styleDefaults("text"), "font_size", 0.0) == 96.0 &&
styleDefaults("text").size() == specs.size(),
"a fresh style starts at the defaults the effect declares");
StyleSet styles = {{"text", "Title", styleDefaults("text")}};
styles[0].values["font_size"] = 40.0;
styles[0].values["text_color"] = std::string("#ff0000");
check(findStyle(styles, "text", "Title") != nullptr &&
findStyle(styles, "text", "Caption") == nullptr &&
findStyle(styles, "other", "Title") == nullptr,
"a style is found by its kind and its name");
const ChainStage own{effectByName("text"),
{{"text", std::string("Hi")},
{"font_size", 12.0},
{"text_color", std::string("#ff0000")}},
true,
""};
ChainStage follows = own;
follows.style = "Title";
check(paramDouble(stageValues(own, styles), "font_size", 0.0) == 12.0,
"a stage on its own keeps the settings it was given");
check(paramDouble(stageValues(follows, styles), "font_size", 0.0) == 40.0,
"a stage following a style takes the style's");
check(paramText(stageValues(follows, styles), "text", "") == "Hi",
"and keeps its own words either way");
Rng ownRng(3);
Rng followRng(3);
const auto small = renderChain({own}, blue, 200, 120, ownRng, styles);
const auto large = renderChain({follows}, blue, 200, 120, followRng, styles);
check(countRed(small) > 0 && countRed(large) > countRed(small),
"the style is what actually reaches the render");
// The whole point: two stages, one style, one look in two places.
ChainStage left = follows;
left.values["text_offset_x"] = -40L;
ChainStage right = follows;
right.values["text_offset_x"] = 40L;
Rng leftRng(3);
Rng rightRng(3);
const auto leftImage = renderChain({left}, blue, 200, 120, leftRng, styles);
const auto rightImage = renderChain({right}, blue, 200, 120, rightRng, styles);
check(countRed(leftImage) == countRed(large) && countRed(rightImage) == countRed(large),
"two stages following one style ink the same glyphs");
check(!sameBytes(leftImage, rightImage), "each in its own place");
StyleSet edited = styles;
edited[0].values["font_size"] = 20.0;
Rng editedLeft(3);
Rng editedRight(3);
const auto afterLeft = renderChain({left}, blue, 200, 120, editedLeft, edited);
const auto afterRight = renderChain({right}, blue, 200, 120, editedRight, edited);
check(countRed(afterLeft) < countRed(leftImage) &&
countRed(afterRight) < countRed(rightImage) &&
countRed(afterLeft) == countRed(afterRight),
"editing the style moves every stage that follows it");
ChainStage missing = follows;
missing.style = "Nowhere";
Rng missingRng(3);
Rng bareRng(3);
check(sameBytes(renderChain({missing}, blue, 200, 120, missingRng, styles),
renderChain({own}, blue, 200, 120, bareRng)),
"naming a style that is not there renders as standing alone");
Rng noStyleRng(3);
Rng noPoolRng(3);
check(sameBytes(renderChain({follows}, blue, 200, 120, noStyleRng, {}),
renderChain({own}, blue, 200, 120, noPoolRng)),
"and so does a chain handed no styles at all");
}
std::cout << "chaos transforms\n";
{
const std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
const std::vector<Image> input = {gradient(400, 300, 40)};
// What every one of them must do, and what switches each one off.
const std::vector<std::pair<std::string, ParamValues>> transforms = {
{"slitshift", {{"shift_max", 0L}}},
{"channelsplit", {{"split_amount", 0L}}},
{"pixelsort", {{"sort_line_chance", 0.0}}},
{"corrupt", {{"corrupt_rate", 0.0}}},
{"shatter",
{{"shatter_displace", 0L}, {"shatter_rotate", 0.0}, {"shatter_zoom", 0.0},
{"shatter_crack_width", 0L}}},
{"warp", {{"warp_strength", 0L}}},
{"bitcrush", {{"crush_coverage", 0.0}}},
{"feedback", {{"echo_fade", 0.0}}},
};
for (const auto& [name, off] : transforms) {
check(!effectByName(name)->needsSources(), name + " needs no source images");
const auto first = renderNamed(name, sources, 400, 300, 4, {});
const auto again = renderNamed(name, sources, 400, 300, 4, {});
const auto other = renderNamed(name, sources, 400, 300, 11, {});
check(first.width() == 400 && first.height() == 300,
name + " renders the requested size");
check(sameBytes(first, again), name + " reproduces itself from a seed");
check(!sameBytes(first, other), name + " changes with the seed");
check(!sameBytes(renderNamed(name, input, 400, 300, 4, {}), input[0]),
name + " changes its input");
check(sameBytes(renderNamed(name, input, 400, 300, 4, off), input[0]),
name + " switched right down leaves its input alone");
const auto blank = renderNamed(name, {}, 120, 80, 1, {});
check(blank.width() == 120 && blank.height() == 80,
name + " renders without sources");
ChainStage scanlines{effectByName("scanlines"), {{"strip_height", 6L}}};
ChainStage stage{effectByName(name), {}};
Rng a(9);
Rng b(9);
check(sameBytes(renderChain({scanlines, stage}, sources, 300, 200, a),
renderChain({scanlines, stage}, sources, 300, 200, b)),
name + " cascades reproducibly");
check(!chainNeedsSources({stage, scanlines}, false),
"a chain led by " + name + " needs no sources");
// A canvas one pixel wide or high must not trip any of them up.
check(renderNamed(name, {}, 1, 1, 2, {}).width() == 1 &&
renderNamed(name, input, 1, 57, 2, {}).height() == 57 &&
renderNamed(name, input, 57, 1, 2, {}).width() == 57,
name + " copes with a sliver of a canvas");
}
// Wrapped slit shifts only rotate each row, so every row keeps its
// pixels, just not their places.
const auto wrapped = renderNamed("slitshift", input, 400, 300, 5, {{"shift_bands", 200L}});
bool rotated = true;
for (int y = 0; y < 300; ++y) {
std::vector<std::uint8_t> before(input[0].row(y), input[0].row(y) + 1200);
std::vector<std::uint8_t> after(wrapped.row(y), wrapped.row(y) + 1200);
std::sort(before.begin(), before.end());
std::sort(after.begin(), after.end());
rotated = rotated && before == after;
}
check(rotated, "a wrapped slit shift keeps every row's pixels");
const auto upright = renderNamed("slitshift", input, 400, 300, 5,
{{"shift_axis", std::string("vertical")}});
check(!sameBytes(upright, wrapped), "the vertical axis tears columns instead");
// Moving a plane of one colour moves nothing that can be seen.
const auto split = renderNamed("channelsplit", {solid(80, 60, 200, 90, 30)}, 80, 60, 3,
{{"split_mode", std::string("per_band")},
{"split_band_height", 7L}});
check(sameBytes(split, solid(80, 60, 200, 90, 30)), "a channel split keeps a flat colour");
const auto rc = renderNamed("channelsplit", input, 400, 300, 3,
{{"split_channels", std::string("red_cyan")}});
check(pixel(rc, 200, 150)[2] == pixel(input[0], 200, 150)[2],
"red_cyan leaves blue where a flat plane has it");
// With the thresholds wide open every row comes out sorted.
const auto sorted = renderNamed("pixelsort", {stripes(64, 16)}, 64, 16, 1,
{{"sort_low", 0.0}, {"sort_high", 1.0}});
bool ordered = true;
for (int y = 0; y < 16; ++y) {
for (int x = 1; x < 64; ++x) ordered = ordered && pixel(sorted, x - 1, y)[0] <= pixel(sorted, x, y)[0];
}
check(ordered, "an open pixel sort orders every row");
const auto downward = renderNamed("pixelsort", {stripes(64, 16)}, 64, 16, 1,
{{"sort_low", 0.0}, {"sort_high", 1.0},
{"sort_direction", std::string("vertical")}});
check(sameBytes(downward, stripes(64, 16)), "sorting uniform columns changes nothing");
// Every block repeated is the first block smeared across the row.
const auto smeared = renderNamed("corrupt", input, 64, 32, 2,
{{"corrupt_rate", 1.0},
{"corrupt_block", 16L},
{"corrupt_kinds", std::string("repeat")}});
bool periodic = true;
for (int y = 0; y < 32; ++y) {
for (int x = 16; x < 64; ++x) periodic = periodic && pixel(smeared, x, y) == pixel(smeared, x - 16, y);
}
check(periodic, "repeated blocks copy the block to their left");
const auto bled = renderNamed("corrupt", input, 400, 300, 2,
{{"corrupt_rate", 0.02}, {"corrupt_bleed", true}});
check(!sameBytes(bled, renderNamed("corrupt", input, 400, 300, 2, {{"corrupt_rate", 0.02}})),
"bleeding carries the damage further");
const auto cracked = renderNamed("shatter", {flat(200, 150, 128)}, 200, 150, 6,
{{"shatter_crack_colour", std::string("#ff0000")}});
bool red = false;
for (int y = 0; y < 150; ++y) {
for (int x = 0; x < 200; ++x) red = red || isRed(pixel(cracked, x, y));
}
check(red, "shatter draws its cracks in the crack colour");
check(!sameBytes(renderNamed("warp", input, 400, 300, 4, {{"warp_mode", std::string("swirl")}}),
renderNamed("warp", input, 400, 300, 4, {})),
"swirl flows differently from a plain push");
const auto poster = renderNamed("bitcrush", input, 400, 300, 3,
{{"crush_op", std::string("posterize")},
{"crush_coverage", 1.0}});
bool coarse = true;
for (std::size_t i = 0; i < poster.byteSize(); ++i) coarse = coarse && poster.data()[i] % 8 == 0;
check(coarse, "posterising three bits leaves multiples of eight");
const auto swapped = renderNamed("bitcrush", {solid(40, 40, 250, 120, 5)}, 40, 40, 3,
{{"crush_op", std::string("channel_swap")},
{"crush_coverage", 1.0}});
check(pixel(swapped, 20, 20) != std::array<int, 3>{250, 120, 5},
"a channel swap never leaves the order as it was");
// A shrunk, fully opaque echo lands the image's corner inside the
// canvas, while the border outside every echo stays the input's.
const auto tunnel = renderNamed("feedback", input, 400, 300, 1,
{{"echo_scale", 0.5}, {"echo_rotate", 0.0},
{"echo_drift", 0L}, {"echo_fade", 1.0}});
check(pixel(tunnel, 0, 0) == pixel(input[0], 0, 0), "the border outside the echoes is the input");
// Halfway to the corner reads the corner of the image at half size.
const auto near = pixel(tunnel, 100, 75);
check(std::abs(near[0] - pixel(input[0], 0, 0)[0]) < 4 &&
std::abs(near[1] - pixel(input[0], 0, 0)[1]) < 4,
"the echoes nest inside it");
}
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}, false) == "Scanlines \u2192 Blur", "the chain has a readable title");
check(chainSlug({scanlines, blur}, false) == "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}, false), "a chain led by scanlines needs sources");
check(!chainNeedsSources({blur, scanlines}, false),
"a chain led by blur needs none, later stages see the stage before them");
check(!chainNeedsSources({}, false), "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}, false) == "Scanlines", "the title names only what ran");
check(chainSlug({scanlines, blurOff}, false) == "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}, false),
"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 base = (dir / "city.png").string();
std::filesystem::remove(dir / "city-1.png");
std::filesystem::remove(dir / "city-2.png");
check(nextExportPath(base) == (dir / "city-1.png").string(),
"the next export after a plain name counts from 1");
check(nextExportPath((dir / "city-1.png").string()) == (dir / "city-2.png").string(),
"the next export counts on from the last number");
std::ofstream((dir / "city-1.png").string()) << "x";
check(nextExportPath(base) == (dir / "city-2.png").string(),
"the next export skips a name already on disk");
std::filesystem::remove(dir / "city-1.png");
check(nextExportPath((dir / "a-b.mp4").string()) == (dir / "a-b-1.mp4").string(),
"a dash before letters is not a count");
}
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");
// 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");
// Styles ride along with the chain, and the stages come back pointing
// at the ones they followed.
Project stylish;
stylish.styles = {{"text", "Title", styleDefaults("text")},
{"text", "Caption", styleDefaults("text")}};
stylish.styles[0].values["font_size"] = 120.0;
stylish.styles[0].values["text_color"] = std::string("#ff8800");
stylish.styles[1].values["font_family"] = std::string("Serif");
stylish.stages.push_back(
{effectByName("text"), {{"text", std::string("One")}}, true, "Title"});
stylish.stages.push_back(
{effectByName("text"), {{"text", std::string("Two")}}, true, "Title"});
stylish.stages.push_back(
{effectByName("text"), {{"text", std::string("Three")}}, true, ""});
const auto styleFile = dir / "styles.json";
saveProject(styleFile.string(), stylish);
const auto styleBack = loadProject(styleFile.string());
check(styleBack.styles.size() == 2 && styleBack.styles[0].name == "Title" &&
styleBack.styles[1].name == "Caption" && styleBack.styles[0].kind == "text",
"the styles survive a round trip");
check(paramDouble(styleBack.styles[0].values, "font_size", 0.0) == 120.0 &&
paramText(styleBack.styles[0].values, "text_color", "") == "#ff8800" &&
paramText(styleBack.styles[1].values, "font_family", "") == "Serif",
"and so do the values they carry");
check(styleBack.stages[0].style == "Title" && styleBack.stages[1].style == "Title" &&
styleBack.stages[2].style.empty(),
"and which stage follows which");
check(paramDouble(stageValues(styleBack.stages[0], styleBack.styles), "font_size", 0.0) ==
120.0 &&
paramDouble(stageValues(styleBack.stages[2], styleBack.styles), "font_size",
0.0) == 96.0,
"a reloaded project resolves its styles as it did");
check(projectToJson(stylish).find("\"styles\"") != std::string::npos &&
projectToJson(project).find("\"styles\"") == std::string::npos &&
projectToJson(project).find("\"style\"") == std::string::npos,
"a project with no styles writes nothing about them");
// Everything that can be wrong with a style is dropped on its own,
// leaving a project that still opens and still renders.
const auto lenient = projectFromJson(R"({
"styles": [
{"kind": "nosuchkind", "name": "Ghost", "params": {}},
{"kind": "text", "name": "", "params": {}},
{"kind": "text", "name": "Title", "params": {"font_size": 60, "nonsense": 3}},
{"kind": "text", "name": "Title", "params": {"font_size": 12}}
],
"chain": [
{"effect": "text", "style": "Title", "params": {"text": "Kept"}},
{"effect": "text", "style": "Gone", "params": {"font_size": 30}},
{"effect": "blur", "style": "Title", "params": {}}
]
})");
check(lenient.styles.size() == 1 && lenient.styles[0].name == "Title",
"an unknown kind, a nameless style and a repeated name are dropped");
check(paramDouble(lenient.styles[0].values, "font_size", 0.0) == 60.0 &&
lenient.styles[0].values.find("nonsense") == lenient.styles[0].values.end(),
"a style only keeps the params its kind declares, and the first of a name wins");
check(lenient.stages[0].style == "Title" && lenient.stages[1].style.empty(),
"a stage naming a style that is not there is cut loose");
check(lenient.stages[2].style.empty(),
"and so is one whose effect takes no style at all");
check(paramDouble(stageValues(lenient.stages[1], lenient.styles), "font_size", 0.0) == 30.0,
"which leaves it rendering the values written beside it");
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 << "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<int>(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<int>(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<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 << "memory\n";
{
check(availableMemory() > 0, "the free memory can be read");
check(formatBytes(std::size_t{5} << 29) == "2.5 GB" && formatBytes(std::size_t{380} << 20) == "380 MB",
"sizes are written in GB and MB");
const auto dir = std::filesystem::temp_directory_path() / "effector_test_memory";
std::filesystem::create_directories(dir);
std::vector<std::string> paths;
for (int i = 0; i < 3; ++i) {
const auto path = dir / ("src" + std::to_string(i) + ".png");
gradient(400, 300, i + 1).save(path.string());
paths.push_back(path.string());
}
check(imageFilePixels(paths[0]) == 400u * 300 && imageFilePixels((dir / "missing.png").string()) == 0,
"an image is sized from its header");
// Three 400 x 300 images hold 1 MB decoded; with 1 MB free they do not fit.
limitAvailableMemory(std::size_t{1} << 20);
std::atomic<int> decoded{0};
LoadProgress counting;
counting.onFile = [&](std::size_t, std::size_t, const std::string&) { ++decoded; };
std::vector<LoadFailure> failures;
bool refused = false;
std::string message;
try {
loadImages(paths, failures, &counting);
} catch (const OutOfMemoryError& error) {
refused = true;
message = error.what();
}
check(refused, "a batch too large for the free memory is refused");
check(decoded.load() == 0, "before a single file is decoded");
check(message.find("Loading these 3 images needs about") == 0 && message.find("with 1 MB free") != std::string::npos,
"and the message says how much is needed and how much is free");
const std::vector<ChainStage> scanlines = {{effectByName("scanlines"), {}}};
const std::vector<Image> small = {gradient(64, 64, 7)};
bool renderRefused = false;
try {
Rng rng(1);
renderChain(scanlines, small, 4000, 4000, rng);
} catch (const OutOfMemoryError&) {
renderRefused = true;
}
check(renderRefused, "a render too large for the free memory is refused");
bool videoRefused = false;
try {
VideoSettings clip;
clip.enabled = true;
renderFrames(scanlines, small, 4000, 4000, 1, {}, clip, [](int, Image&&) { return true; });
} catch (const OutOfMemoryError&) {
videoRefused = true;
}
check(videoRefused, "and so is a clip");
Rng smallRng(1);
check(renderChain(scanlines, small, 100, 100, smallRng).width() == 100,
"a render that fits still runs");
limitAvailableMemory(0);
failures.clear();
check(loadImages(paths, failures).size() == 3, "with the memory there the batch loads");
std::filesystem::remove_all(dir);
}
std::cout << "video\n";
{
using effector::ChainStage;
using effector::FrameTime;
using effector::VideoSettings;
// Every preset names a canvas format this build offers, and its
// numbers are ones the project reader and the CLI accept.
int sound = 0;
for (const auto& preset : effector::videoPresets()) {
if (effector::canvasFormatByKey(preset.canvasFormat) != nullptr &&
preset.seconds >= effector::kMinDuration && preset.seconds <= effector::kMaxDuration &&
preset.fps >= effector::kMinFps && preset.fps <= effector::kMaxFps &&
effector::videoPresetByKey(preset.key) == &preset) {
++sound;
}
}
check(sound == static_cast<int>(effector::videoPresets().size()),
"every video preset names a known canvas and a valid length and rate");
const auto* story = effector::videoPresetByKey("instagram-story");
check(story != nullptr && story->canvasFormat == "instagram-story" && story->seconds == 15.0,
"the Instagram story preset is a 15 s vertical clip");
VideoSettings clip;
clip.duration = 15.0;
clip.fps = 30;
check(clip.frameCount() == 450, "15 s at 30 fps is 450 frames");
clip.duration = 0.01;
check(clip.frameCount() == 3, "a clip below the shortest length is clamped to it");
check(effector::formatSeconds(7.5) == "7.5 s" && effector::formatSeconds(15) == "15 s",
"lengths read as 7.5 s and 15 s");
check(effector::isVideoPath("a/b.MP4") && effector::isVideoPath("x.webm") &&
effector::isVideoPath("x.gif") && !effector::isVideoPath("x.png") &&
!effector::isVideoPath("mp4"),
"a video is recognised by its extension");
const auto split = effector::effectByName("channelsplit");
const auto tracking = effector::effectByName("tracking");
const std::vector<effector::Image> sources = {gradient(96, 64, 40)};
check(tracking->video() && !split->video(),
"VHS tracking is a video effect, channel split an image one");
bool imagesFirst = true;
bool seenVideo = false;
for (const auto& effect : effects()) {
imagesFirst = imagesFirst && (effect->video() || !seenVideo);
seenVideo = seenVideo || effect->video();
}
check(imagesFirst, "the registry lists the image effects before the video effects");
auto wild = tracking->defaults();
wild["tracking_wobble"] = 40L;
wild["tracking_jitter"] = 30L;
wild["tracking_bar"] = 60L;
auto frozen = tracking->defaults();
frozen["tracking_wobble"] = 0L;
frozen["tracking_jitter"] = 0L;
frozen["tracking_bar"] = 0L;
// A still skips a video effect whole, as it skips a stage switched
// off: the picture is the one the chain made without it.
ChainStage scanlinesStage{effectByName("scanlines"), {}};
ChainStage trackingStage{tracking, wild};
effector::Rng withRng(5);
effector::Rng withoutRng(5);
check(sameBytes(renderChain({scanlinesStage, trackingStage}, sources, 96, 64, withRng),
renderChain({scanlinesStage}, sources, 96, 64, withoutRng)),
"a still skips a video effect as though it were not there");
check(chainTitle({scanlinesStage, trackingStage}, false) == "Scanlines" &&
chainTitle({scanlinesStage, trackingStage}, true) == "Scanlines \u2192 VHS tracking",
"and names it only in a video");
check(chainNeedsSources({trackingStage, scanlinesStage}, false) &&
!chainNeedsSources({trackingStage, scanlinesStage}, true),
"led by a video effect, a still starts from the stage after it");
bool onlyVideo = false;
try {
effector::Rng rng(1);
renderChain({trackingStage}, sources, 96, 64, rng);
} catch (const EffectError&) {
onlyVideo = true;
}
check(onlyVideo, "a still of video effects alone is refused");
// A video effect leaves the shared rng in the same state on every
// frame, so what comes after it holds still.
effector::Rng frameA(9);
effector::Rng frameB(9);
tracking->renderFrame(sources, 96, 64, frameA, wild, FrameTime{3, 30, 30});
tracking->renderFrame(sources, 96, 64, frameB, wild, FrameTime{17, 30, 30});
check(frameA.next() == frameB.next(), "every frame leaves the shared rng in one state");
// Time is rounded to fit the clip, so the frame after the last is the
// first again, and a clip loops without a jump.
const effector::Moment start(FrameTime{0, 30, 30}, 1);
check(start.length() == 1.0 && start.loopRate(0.4) == 1.0 && start.loopRate(2.2) == 2.0,
"a rate is rounded to a whole number of cycles in the clip");
check(effector::Moment(FrameTime{15, 30, 30}, 1).cycle(1.0) == 0.5,
"half-way through the clip is half-way through its one cycle");
auto smooth = wild;
smooth["tracking_jitter"] = 0L;
const std::vector<effector::Image> input = {gradient(96, 64, 70)};
effector::Rng loopA(4);
effector::Rng loopB(4);
check(sameBytes(tracking->renderFrame(input, 96, 64, loopA, smooth, FrameTime{0, 30, 30}),
tracking->renderFrame(input, 96, 64, loopB, smooth, FrameTime{30, 30, 30})),
"without the shake, the clip loops without a jump");
effector::Rng stillFrame(4);
check(sameBytes(tracking->renderFrame(input, 96, 64, stillFrame, frozen, FrameTime{7, 30, 30}),
input[0]),
"with every motion at 0 it leaves its input alone");
auto stage = [](const std::string& name, effector::ParamValues values = {}) {
ChainStage built;
built.effect = effector::effectByName(name);
built.values = values.empty() ? built.effect->defaults() : std::move(values);
return built;
};
std::vector<ChainStage> chain = {stage("tiles"), stage("tracking", wild), stage("grain")};
VideoSettings video;
video.enabled = true;
video.duration = 1.0;
video.fps = 8;
std::vector<effector::Image> first;
const int handed = effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
[&](int, effector::Image&& frame) {
first.push_back(std::move(frame));
return true;
});
check(handed == 8 && first.size() == 8, "a clip hands out every one of its frames");
std::vector<effector::Image> second;
effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
[&](int, effector::Image&& frame) {
second.push_back(std::move(frame));
return true;
});
bool repeatable = second.size() == first.size();
for (std::size_t i = 0; repeatable && i < first.size(); ++i) {
repeatable = sameBytes(first[i], second[i]);
}
check(repeatable, "a clip renders byte for byte again from its seed");
// The still stages before the moving one are rendered once and
// reused; that shortcut must give exactly what a frame on its own is.
bool exact = true;
for (const int index : {0, 3, 7}) {
effector::Rng rng(21);
const auto alone = effector::renderChain(chain, sources, 80, 48, rng, {},
FrameTime{index, 8, 8});
exact = exact && sameBytes(alone, first[static_cast<std::size_t>(index)]);
}
check(exact, "a clip's frame is exactly that frame rendered on its own");
check(!sameBytes(first[0], first[3]) && !sameBytes(first[3], first[7]),
"frames of a moving chain differ");
std::vector<ChainStage> steady = {stage("tiles"), stage("channelsplit"), stage("grain")};
std::vector<effector::Image> held;
effector::renderFrames(steady, sources, 80, 48, 21, {}, video,
[&](int, effector::Image&& frame) {
held.push_back(std::move(frame));
return true;
});
bool same = held.size() == 8;
for (const auto& frame : held) same = same && sameBytes(frame, held[0]);
effector::Rng stillRng(21);
check(same && sameBytes(held[0], effector::renderChain(steady, sources, 80, 48, stillRng)),
"a chain of image effects renders its still on every frame");
int seen = 0;
effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
[&](int, effector::Image&&) { return ++seen < 3; });
check(seen == 3, "returning false stops a clip early");
// A preview renders a draft of every third frame first; the frames
// are the same whichever order they come in.
const auto order = effector::draftFirstOrder(8, 3);
check(order == std::vector<int>({0, 3, 6, 1, 2, 4, 5, 7}),
"a draft order takes every third frame first, then the rest");
std::vector<effector::Image> drafted(8);
const int draftHanded = effector::renderFrames(
chain, sources, 80, 48, 21, {}, video,
[&](int index, effector::Image&& frame) {
drafted[static_cast<std::size_t>(index)] = std::move(frame);
return true;
},
order);
bool sameClip = draftHanded == 8;
for (std::size_t i = 0; sameClip && i < first.size(); ++i) {
sameClip = sameBytes(first[i], drafted[i]);
}
check(sameClip, "a clip rendered draft first holds exactly the same frames");
chain[1].enabled = false;
std::vector<effector::Image> skipped;
effector::renderFrames(chain, sources, 80, 48, 21, {}, video,
[&](int, effector::Image&& frame) {
skipped.push_back(std::move(frame));
return true;
});
check(skipped.size() == 8 && sameBytes(skipped[0], skipped[7]),
"with the moving stage switched off the clip holds still");
const auto big = gradient(300, 120, 10);
const auto small = big.scaledToFit(100);
check(small.width() == 100 && small.height() == 40, "a preview frame keeps its shape");
check(big.scaledToFit(400).width() == 300, "a frame already small enough is left alone");
// Tile swap builds its frames out of the sources rather than
// transforming a crop of one.
{
const auto swap = effectByName("tileswap");
check(swap->video() && swap->needsSources(),
"tile swap is a video effect built from the sources");
ChainStage swapStage = stage("tileswap", {{"tileswap_width", 16L},
{"tileswap_height", 12L},
{"tileswap_rate", 2.0}});
ChainStage scanStage = stage("scanlines");
check(chainNeedsSources({swapStage}, true), "and a clip led by it needs sources");
effector::Rng withSwap(3);
effector::Rng withoutSwap(3);
check(sameBytes(renderChain({swapStage, scanStage}, sources, 80, 48, withSwap),
renderChain({scanStage}, sources, 80, 48, withoutSwap)),
"a still skips tile swap as though it were not there");
bool refused = false;
try {
effector::Rng rng(1);
renderChain({swapStage}, {}, 80, 48, rng, {}, FrameTime{0, 8, 8});
} catch (const EffectError&) {
refused = true;
}
check(refused, "tile swap with no sources is refused");
const std::vector<effector::Image> photos = {solid(60, 40, 255, 0, 0),
solid(50, 30, 0, 0, 255)};
auto frameOf = [&](const ChainStage& at, int index, int count = 8) {
effector::Rng rng(11);
return renderChain({at}, photos, 80, 48, rng, {}, FrameTime{index, count, 8});
};
effector::Rng swapA(9);
effector::Rng swapB(9);
swap->renderFrame(photos, 80, 48, swapA, swapStage.values, FrameTime{3, 8, 8});
swap->renderFrame(photos, 80, 48, swapB, swapStage.values, FrameTime{5, 8, 8});
check(swapA.next() == swapB.next(), "tile swap leaves the shared rng in one state");
auto onlyPhotos = [](const effector::Image& image) {
for (int y = 0; y < image.height(); ++y) {
const std::uint8_t* row = image.row(y);
for (int x = 0; x < image.width(); ++x) {
const std::uint8_t* p = row + static_cast<std::size_t>(x) * 3;
const bool red = p[0] == 255 && p[1] == 0 && p[2] == 0;
const bool blue = p[0] == 0 && p[1] == 0 && p[2] == 255;
if (!red && !blue) return false;
}
}
return true;
};
check(onlyPhotos(frameOf(swapStage, 0)) && onlyPhotos(frameOf(swapStage, 5)),
"its tiles are cut from the photographs");
bool swapped = false;
for (int index = 1; index < 8; ++index) {
swapped = swapped || !sameBytes(frameOf(swapStage, 0), frameOf(swapStage, index));
}
check(swapped, "its tiles change over the clip");
check(sameBytes(frameOf(swapStage, 0), frameOf(swapStage, 8)),
"and the frame after the last is the first again");
ChainStage frozenSwap = swapStage;
frozenSwap.values["tileswap_rate"] = 0.0;
check(sameBytes(frameOf(frozenSwap, 0), frameOf(frozenSwap, 5)),
"at a rate of 0 it holds still");
ChainStage scattered = swapStage;
scattered.values["tileswap_layout"] = std::string("scatter");
ChainStage bricks = swapStage;
bricks.values["tileswap_layout"] = std::string("brick");
bricks.values["tileswap_keep_order"] = true;
check(onlyPhotos(frameOf(scattered, 2)) && onlyPhotos(frameOf(bricks, 6)),
"scattered and brick layouts are cut from the photographs too");
const std::vector<ChainStage> swapChain = {swapStage, stage("grain")};
std::vector<effector::Image> clip;
effector::renderFrames(swapChain, sources, 80, 48, 21, {}, video,
[&](int, effector::Image&& frame) {
clip.push_back(std::move(frame));
return true;
});
bool alone = clip.size() == 8;
for (const int index : {0, 4, 7}) {
effector::Rng rng(21);
alone = alone && sameBytes(effector::renderChain(swapChain, sources, 80, 48, rng,
{}, FrameTime{index, 8, 8}),
clip[static_cast<std::size_t>(index)]);
}
check(alone, "a tile swap clip's frame is exactly that frame rendered on its own");
}
// The video transforms made from image effects. Each gets a setting
// that moves plenty, one without its per-frame noise (which, drawn
// anew on every frame, is the one thing that does not loop), and one
// that switches it off.
struct Moving {
std::string name;
effector::ParamValues lively;
effector::ParamValues smooth;
effector::ParamValues off;
};
const std::vector<Moving> moving = {
{"tunnel", {{"tunnel_drift", 6L}, {"tunnel_fade", 1.0}}, {}, {{"tunnel_fade", 0.0}}},
{"flow", {{"flow_mode", std::string("swirl")}}, {}, {{"flow_strength", 0L}}},
{"sweep", {{"sweep_reverse", true}, {"sweep_max_run", 20L}}, {},
{{"sweep_line_chance", 0.0}}},
{"filmgate", {{"gate_weave", 6L}, {"gate_dust", 20L}, {"gate_scratches", 3L}},
{{"gate_weave", 6L}, {"gate_grain", 0.0}, {"gate_flicker", 0.0}, {"gate_dust", 0L},
{"gate_scratches", 0L}},
{{"gate_weave", 0L}, {"gate_grain", 0.0}, {"gate_flicker", 0.0}, {"gate_dust", 0L},
{"gate_scratches", 0L}}},
{"datamosh", {{"mosh_rate", 0.4}, {"mosh_block", 8L}, {"mosh_bleed", true}}, {},
{{"mosh_rate", 0.0}}},
};
for (const auto& [name, lively, smooth, off] : moving) {
const auto effect = effectByName(name);
auto with = [&](const effector::ParamValues& changes) {
ChainStage built = stage(name);
for (const auto& [key, value] : changes) built.values[key] = value;
return built;
};
auto frameOf = [&](const ChainStage& at, int index) {
effector::Rng rng(6);
return renderChain({at}, input, 96, 64, rng, {}, FrameTime{index, 8, 8});
};
const ChainStage wildStage = with(lively);
check(effect->video() && !effect->needsSources(),
name + " is a video effect that needs no sources");
ChainStage scanStage = stage("scanlines");
effector::Rng withIt(3);
effector::Rng withoutIt(3);
check(sameBytes(renderChain({scanStage, wildStage}, sources, 80, 48, withIt),
renderChain({scanStage}, sources, 80, 48, withoutIt)),
"a still skips " + name + " as though it were not there");
effector::Rng stateA(9);
effector::Rng stateB(9);
effect->renderFrame(sources, 80, 48, stateA, wildStage.values, FrameTime{3, 8, 8});
effect->renderFrame(sources, 80, 48, stateB, wildStage.values, FrameTime{5, 8, 8});
check(stateA.next() == stateB.next(), name + " leaves the shared rng in one state");
bool changes = false;
for (int index = 1; index < 8; ++index) {
changes = changes || !sameBytes(frameOf(wildStage, 0), frameOf(wildStage, index));
}
check(changes, name + " moves over the clip");
const ChainStage smoothStage = with(smooth);
check(sameBytes(frameOf(smoothStage, 0), frameOf(smoothStage, 8)),
name + " loops: the frame after the last is the first again");
check(sameBytes(frameOf(with(off), 5), input[0]),
name + " switched off leaves its input alone");
effector::Rng sliverA(2);
effector::Rng sliverB(2);
check(renderChain({wildStage}, input, 1, 57, sliverA, {}, FrameTime{3, 8, 8}).height() == 57 &&
renderChain({wildStage}, input, 57, 1, sliverB, {}, FrameTime{3, 8, 8}).width() == 57,
name + " copes with a sliver of a canvas");
const std::vector<ChainStage> movingChain = {stage("tiles"), wildStage, stage("grain")};
std::vector<effector::Image> clip;
effector::renderFrames(movingChain, sources, 80, 48, 21, {}, video,
[&](int, effector::Image&& frame) {
clip.push_back(std::move(frame));
return true;
});
bool exactFrames = clip.size() == 8;
for (const int index : {0, 5, 7}) {
effector::Rng rng(21);
exactFrames = exactFrames &&
sameBytes(effector::renderChain(movingChain, sources, 80, 48, rng, {},
FrameTime{index, 8, 8}),
clip[static_cast<std::size_t>(index)]);
}
check(exactFrames, "a " + name + " clip's frame is exactly that frame rendered on its own");
}
// The project file: a video says so, a still says nothing about time.
effector::Project project;
project.stages = {stage("tracking", wild)};
const auto stillJson = effector::projectToJson(project);
check(stillJson.find("\"video\"") == std::string::npos, "a still project writes no video");
check(!effector::projectFromJson(stillJson).video.enabled, "and reads back as a still");
project.video.enabled = true;
project.video.duration = 7.5;
project.video.fps = 24;
project.video.preset = "instagram-story";
const auto back = effector::projectFromJson(effector::projectToJson(project));
check(back.video.enabled && back.video.duration == 7.5 && back.video.fps == 24 &&
back.video.preset == "instagram-story",
"a video project keeps its length, rate and preset");
const auto motion = back.stages.at(0).values;
check(effector::paramInt(motion, "tracking_jitter", 0) == 30 &&
effector::paramInt(motion, "tracking_bar", 0) == 60,
"and its stages keep their motion");
const std::string base = "{\"chain\": [{\"effect\": \"blur\"}], \"video\": {\"duration\": ";
check(!effector::projectFromJson(base + "2, \"preset\": \"cinema\"}}").video.preset.size(),
"an unknown preset reads as custom");
check(effector::projectToJson(project).find("\"quality\"") == std::string::npos,
"the standard quality is not written");
project.video.quality = "small";
check(effector::projectFromJson(effector::projectToJson(project)).video.quality == "small",
"another quality is kept");
check(effector::projectFromJson(base + "2, \"quality\": \"lossless\"}}").video.quality.empty(),
"an unknown quality reads as the standard one");
check(effector::videoQualityOrDefault("").key == effector::kDefaultVideoQuality &&
effector::videoQualityByKey(effector::kDefaultVideoQuality) != nullptr,
"an empty quality is the standard one, which the table holds");
bool rejected = false;
try {
effector::projectFromJson(base + "2, \"fps\": 500}}");
} catch (const effector::EffectError&) {
rejected = true;
}
check(rejected, "a frame rate out of range is refused");
// The command line.
const auto preset = parsedOptions({"--video-preset", "instagram-story", "-o", "x.mp4"});
check(preset && preset->video.enabled && preset->width == 1080 && preset->height == 1920 &&
preset->video.duration == 15.0 && preset->video.fps == 30 &&
preset->video.preset == "instagram-story",
"--video-preset sets the canvas, length and rate");
const auto shorter = parsedOptions({"--duration", "5", "--video-preset", "instagram-story"});
check(shorter && shorter->video.duration == 5.0 && shorter->height == 1920 &&
shorter->video.preset.empty(),
"a length given alongside wins and leaves the preset custom");
const auto sized = parsedOptions({"--video-preset", "hd-clip", "-s", "320x240"});
check(sized && sized->width == 320 && sized->format.empty(),
"a size given alongside wins over the preset's canvas");
check(!parsedOptions({"--fps", "0"}) && !parsedOptions({"--duration", "abc"}) &&
!parsedOptions({"--duration", "400"}) && !parsedOptions({"--video-preset", "vhs"}),
"a bad rate, length or preset is refused");
const auto quality = parsedOptions({"--duration", "2", "--video-quality", "high"});
check(quality && quality->video.quality == "high", "--video-quality picks the encoding");
check(!parsedOptions({"--video-quality", "best"}), "and refuses one it does not know");
check(!parsedOptions({"-o", "x.png"})->video.enabled, "without them the output is a still");
bool wrongName = false;
try {
effector::VideoWriter writer("clip.png", 16, 16, 30);
} catch (const effector::EffectError&) {
wrongName = true;
}
check(wrongName, "a video is not written under an image's name");
if (effector::ffmpegAvailable()) {
const auto dir = std::filesystem::temp_directory_path() / "effector-video-test";
std::filesystem::create_directories(dir);
const auto file = (dir / "clip.mp4").string();
{
// Odd sides: the writer pads them for the encoder.
effector::VideoWriter writer(file, 17, 9, 12);
for (int i = 0; i < 3; ++i) writer.write(gradient(17, 9, 40 * i));
writer.finish();
}
check(std::filesystem::exists(file) && std::filesystem::file_size(file) > 0,
"ffmpeg writes an .mp4 of odd size");
// Every quality, through every codec.
bool everyQuality = true;
for (const auto& quality : effector::videoQualities()) {
for (const char* extension : {".mp4", ".webm", ".gif"}) {
const auto encoded = (dir / (quality.key + extension)).string();
effector::VideoWriter writer(encoded, 16, 16, 12, quality.key);
for (int i = 0; i < 2; ++i) writer.write(gradient(16, 16, 60 * i));
writer.finish();
everyQuality = everyQuality && std::filesystem::file_size(encoded) > 0;
}
}
check(everyQuality, "every quality writes .mp4, .webm and .gif");
const auto dropped = (dir / "dropped.webm").string();
{
effector::VideoWriter writer(dropped, 16, 16, 12);
writer.write(gradient(16, 16, 10));
writer.cancel();
}
check(!std::filesystem::exists(dropped), "a cancelled video leaves no file behind");
bool wrongSize = false;
try {
effector::VideoWriter writer((dir / "sized.mp4").string(), 16, 16, 12);
writer.write(gradient(8, 8, 10));
} catch (const effector::EffectError&) {
wrongSize = true;
}
check(wrongSize && !std::filesystem::exists(dir / "sized.mp4"),
"a frame of the wrong size is refused and the file dropped");
std::filesystem::remove_all(dir);
} else {
std::cout << " skip writing a video (ffmpeg is not installed)\n";
}
}
std::cout << "version\n";
{
// A release is the version in CMakeLists.txt and its entry at the top
// of CHANGELOG.md; bumping one without the other fails here.
const std::string_view log = kChangelog;
const auto newest = log.find("\n## [");
check(newest != std::string_view::npos &&
log.substr(newest + 5).starts_with(std::string(kVersion) + "]"),
"the newest changelog entry is this version");
std::ifstream pkgbuild(std::string(EFFECTOR_SOURCE_DIR) + "/packaging/arch/PKGBUILD");
std::string line;
std::string pkgver;
while (std::getline(pkgbuild, line)) {
if (line.starts_with("pkgver=")) pkgver = line.substr(7);
}
check(pkgver == kVersion, "the Arch PKGBUILD packages this version");
const auto options = parsedOptions({"--version"});
check(options && options->showVersion, "--version is understood");
const auto shortOptions = parsedOptions({"-V"});
check(shortOptions && shortOptions->showVersion, "and so is -V");
}
std::cout << (failures == 0 ? "\nall checks passed\n" : "\n" + std::to_string(failures) + " failed\n");
return failures == 0 ? 0 : 1;
}