Before decoding, loadImages reads every header to size the batch against the free memory. That pass was serial, silent and could not be cancelled, so the loading window sat at "0 of N". LoadProgress gains onSized, the headers are read in parallel on the same cursor-driven workers as the decodes, and a cancel stops the pass. The window's bar runs twice, "Checking sizes" then "Decoding", and the CLI prints a sizing line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2427 lines
122 KiB
C++
2427 lines
122 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::atomic<std::size_t> sizedCalls{0};
|
|
std::atomic<bool> sizedAfterDecode{false};
|
|
std::size_t lastTotal = 0;
|
|
std::mutex mutex;
|
|
LoadProgress progress;
|
|
progress.onSized = [&](std::size_t done, std::size_t total, const std::string&) {
|
|
++sizedCalls;
|
|
if (calls.load() > 0) sizedAfterDecode = true;
|
|
check(done >= 1 && done <= total, "sizing step is within the batch");
|
|
};
|
|
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(sizedCalls.load() == paths.size() && !sizedAfterDecode.load(),
|
|
"every header is sized, all before the first decode");
|
|
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");
|
|
}
|
|
|
|
// Strip roll builds its frames out of the sources too, and rolls the
|
|
// picture in each strip through its image.
|
|
{
|
|
const auto roll = effectByName("striproll");
|
|
check(roll->video() && roll->needsSources(),
|
|
"strip roll is a video effect built from the sources");
|
|
ChainStage rollStage = stage("striproll", {{"roll_height", 6L}, {"roll_speed", 40L}});
|
|
ChainStage scanStage = stage("scanlines");
|
|
effector::Rng withRoll(3);
|
|
effector::Rng withoutRoll(3);
|
|
check(sameBytes(renderChain({rollStage, scanStage}, sources, 80, 48, withRoll),
|
|
renderChain({scanStage}, sources, 80, 48, withoutRoll)),
|
|
"a still skips strip roll as though it were not there");
|
|
bool refused = false;
|
|
try {
|
|
effector::Rng rng(1);
|
|
renderChain({rollStage}, {}, 80, 48, rng, {}, FrameTime{0, 8, 8});
|
|
} catch (const EffectError&) {
|
|
refused = true;
|
|
}
|
|
check(refused, "strip roll with no sources is refused");
|
|
|
|
const std::vector<effector::Image> photos = {gradient(60, 40, 20), gradient(50, 30, 150)};
|
|
auto frameOf = [&](const ChainStage& at, int index) {
|
|
effector::Rng rng(11);
|
|
return renderChain({at}, photos, 80, 48, rng, {}, FrameTime{index, 8, 8});
|
|
};
|
|
effector::Rng rollA(9);
|
|
effector::Rng rollB(9);
|
|
roll->renderFrame(photos, 80, 48, rollA, rollStage.values, FrameTime{3, 8, 8});
|
|
roll->renderFrame(photos, 80, 48, rollB, rollStage.values, FrameTime{5, 8, 8});
|
|
check(rollA.next() == rollB.next(), "strip roll leaves the shared rng in one state");
|
|
|
|
// Every row of a strip is a row of one photograph, turned round.
|
|
auto rowsOfPhotos = [&](const effector::Image& image) {
|
|
for (int y = 0; y < image.height(); ++y) {
|
|
bool found = false;
|
|
for (const auto& photo : photos) {
|
|
for (int py = 0; !found && py < photo.height(); ++py) {
|
|
for (int shift = 0; !found && shift < photo.width(); ++shift) {
|
|
bool same = true;
|
|
for (int x = 0; same && x < image.width(); ++x) {
|
|
const int px = (x + shift) % photo.width();
|
|
same = std::equal(image.row(y) + x * 3, image.row(y) + x * 3 + 3,
|
|
photo.row(py) + px * 3);
|
|
}
|
|
found = same;
|
|
}
|
|
}
|
|
}
|
|
if (!found) return false;
|
|
}
|
|
return true;
|
|
};
|
|
check(rowsOfPhotos(frameOf(rollStage, 0)) && rowsOfPhotos(frameOf(rollStage, 5)),
|
|
"its strips are rows of the photographs");
|
|
bool rolled = false;
|
|
for (int index = 1; index < 8; ++index) {
|
|
rolled = rolled || !sameBytes(frameOf(rollStage, 0), frameOf(rollStage, index));
|
|
}
|
|
check(rolled, "its strips roll over the clip");
|
|
check(sameBytes(frameOf(rollStage, 0), frameOf(rollStage, 8)),
|
|
"and the frame after the last is the first again");
|
|
ChainStage sideways = rollStage;
|
|
sideways.values["roll_axis"] = std::string("horizontal");
|
|
sideways.values["roll_direction"] = std::string("random");
|
|
sideways.values["roll_keep_order"] = true;
|
|
check(rowsOfPhotos(frameOf(sideways, 3)) &&
|
|
!sameBytes(frameOf(sideways, 0), frameOf(sideways, 3)) &&
|
|
sameBytes(frameOf(sideways, 0), frameOf(sideways, 8)),
|
|
"sideways, its strips slide along their rows and loop");
|
|
ChainStage frozenRoll = rollStage;
|
|
frozenRoll.values["roll_speed"] = 0L;
|
|
check(sameBytes(frameOf(frozenRoll, 0), frameOf(frozenRoll, 5)),
|
|
"at a speed of 0 it holds still");
|
|
|
|
const std::vector<ChainStage> rollChain = {rollStage, stage("grain")};
|
|
std::vector<effector::Image> clip;
|
|
effector::renderFrames(rollChain, 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(rollChain, sources, 80, 48, rng,
|
|
{}, FrameTime{index, 8, 8}),
|
|
clip[static_cast<std::size_t>(index)]);
|
|
}
|
|
check(alone, "a strip roll 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}}},
|
|
{"focuspull", {{"focus_radius", 5L}}, {}, {{"focus_radius", 0L}}},
|
|
{"focuspull", {{"focus_mode", std::string("band")}, {"focus_speed", 1.0}}, {},
|
|
{{"focus_mode", std::string("band")}, {"focus_radius", 0L}}},
|
|
};
|
|
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;
|
|
}
|