diff --git a/CMakeLists.txt b/CMakeLists.txt index 57540ef..f47c058 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,6 +28,8 @@ add_library(effector_core STATIC src/core/registry.cpp src/core/rng.cpp src/effects/blur.cpp + src/effects/grain.cpp + src/effects/monochrome.cpp src/effects/scanlines.cpp ) target_include_directories(effector_core PUBLIC src) diff --git a/src/cli/options.cpp b/src/cli/options.cpp index 504a159..6b24f41 100644 --- a/src/cli/options.cpp +++ b/src/cli/options.cpp @@ -24,7 +24,10 @@ bool parseSize(const std::string& text, int& width, int& height) { long w = 0; long h = 0; if (!parseLong(text.substr(0, sep), w) || !parseLong(text.substr(sep + 1), h)) return false; - if (w <= 0 || h <= 0) return false; + // Checked as longs, before the narrowing: 4294968296 cast straight to int + // is 1000, which would render a plausible canvas nobody asked for. + if (w < kMinCanvasSide || w > kMaxCanvasSide) return false; + if (h < kMinCanvasSide || h > kMaxCanvasSide) return false; width = static_cast(w); height = static_cast(h); return true; @@ -66,7 +69,9 @@ Options parseOptions(int argc, char** argv) { } else if (arg == "-s" || arg == "--size") { const std::string value = requireValue(argc, argv, i, arg); if (!parseSize(value, options.width, options.height)) { - throw EffectError("invalid size '" + value + "', expected e.g. 1920x1080"); + throw EffectError("invalid size '" + value + "', expected e.g. 1920x1080 with " + "each side between " + std::to_string(kMinCanvasSide) + " and " + + std::to_string(kMaxCanvasSide)); } } else if (arg == "--seed") { const std::string value = requireValue(argc, argv, i, arg); @@ -131,7 +136,8 @@ std::string usage(const char* programName) { << "Options:\n" << " -e, --effect NAME effect to apply, repeat to cascade (default: " << defaultEffectName() << ")\n" << " -o, --output FILE render headlessly to this file and exit\n" - << " -s, --size WxH canvas size (default: 1920x1080)\n" + << " -s, --size WxH canvas size, each side " << kMinCanvasSide << "-" + << kMaxCanvasSide << " (default: 1920x1080)\n" << " --seed N random seed (default: random)\n" << " --list-effects list available effects and exit\n" << " -h, --help show this help and exit\n"; diff --git a/src/core/chain.hpp b/src/core/chain.hpp index 72d95ef..420470b 100644 --- a/src/core/chain.hpp +++ b/src/core/chain.hpp @@ -10,6 +10,13 @@ namespace effector { +// The canvas sides both front-ends accept. The upper bound is not a hardware +// limit but the point past which a side stops fitting comfortably in an int +// once multiplied out, so both the CLI and the project reader can reject a +// number rather than silently wrap it into a plausible-looking one. +inline constexpr int kMinCanvasSide = 1; +inline constexpr int kMaxCanvasSide = 20000; + // One link of a cascade: an effect together with the values it runs with. struct ChainStage { EffectPtr effect; diff --git a/src/core/project.cpp b/src/core/project.cpp index d0de8c7..2712ab5 100644 --- a/src/core/project.cpp +++ b/src/core/project.cpp @@ -283,6 +283,21 @@ std::string relativeTo(const std::string& path, const std::string& directory) { return text.rfind("..", 0) == 0 ? path : text; } +// A canvas side is checked as a double, before it is narrowed: casting a +// number outside the range of an int is undefined, and even where it wraps +// quietly it turns a size the file never named into one that looks ordinary. +int canvasSide(const Json& value, const char* what) { + if (value.kind != Json::Kind::Number || value.number != std::trunc(value.number)) { + throw EffectError(std::string("the canvas ") + what + " must be a whole number"); + } + if (!(value.number >= kMinCanvasSide) || !(value.number <= kMaxCanvasSide)) { + throw EffectError(std::string("the canvas ") + what + " must be between " + + std::to_string(kMinCanvasSide) + " and " + + std::to_string(kMaxCanvasSide) + " pixels"); + } + return static_cast(value.number); +} + std::string resolve(const std::string& path, const std::string& directory) { std::filesystem::path file(path); if (file.is_absolute() || directory.empty()) return path; @@ -350,12 +365,11 @@ Project projectFromJson(const std::string& text, const std::string& directory) { Project project; if (const auto* canvas = root.member("canvas")) { - if (const auto* width = canvas->member("width")) project.width = static_cast(width->number); + if (const auto* width = canvas->member("width")) project.width = canvasSide(*width, "width"); if (const auto* height = canvas->member("height")) { - project.height = static_cast(height->number); + project.height = canvasSide(*height, "height"); } } - if (project.width <= 0 || project.height <= 0) throw EffectError("the canvas size is not positive"); if (const auto* seed = root.member("seed")) { if (seed->kind == Json::Kind::Number) { diff --git a/src/core/registry.cpp b/src/core/registry.cpp index b540896..efd0358 100644 --- a/src/core/registry.cpp +++ b/src/core/registry.cpp @@ -1,6 +1,8 @@ #include "core/registry.hpp" #include "effects/blur.hpp" +#include "effects/grain.hpp" +#include "effects/monochrome.hpp" #include "effects/scanlines.hpp" namespace effector { @@ -10,6 +12,8 @@ const std::vector& effects() { static const std::vector registry = { std::make_shared(), std::make_shared(), + std::make_shared(), + std::make_shared(), }; return registry; } diff --git a/src/effects/blur.cpp b/src/effects/blur.cpp index e69f0ef..99f2f28 100644 --- a/src/effects/blur.cpp +++ b/src/effects/blur.cpp @@ -5,6 +5,7 @@ #include #include "core/parallel.hpp" +#include "effects/source_fill.hpp" namespace effector { @@ -24,31 +25,6 @@ const std::vector& BlurEffect::params() const { namespace { -// Copies `src` into the canvas starting at (sx, sy), repeating it as needed so -// that every pixel is covered even when the source is smaller than the canvas. -void fillFromSource(Image& canvas, const Image& src, int sx, int sy) { - const int width = canvas.width(); - const std::size_t total = static_cast(width) * 3; - const int copyWidth = std::min(width, src.width() - sx); - const std::size_t copyBytes = static_cast(copyWidth) * 3; - - parallelFor(static_cast(canvas.height()), [&](std::size_t begin, std::size_t end) { - for (std::size_t y = begin; y < end; ++y) { - const int srcY = sy + (static_cast(y) % std::max(1, src.height() - sy)); - const std::uint8_t* in = src.row(srcY) + static_cast(sx) * 3; - std::uint8_t* out = canvas.row(static_cast(y)); - - std::memcpy(out, in, copyBytes); - std::size_t filled = copyBytes; - while (filled < total) { - const std::size_t chunk = std::min(filled, total - filled); - std::memcpy(out + filled, out, chunk); - filled += chunk; - } - } - }); -} - // One separable box blur pass. `stride` is the distance in bytes between two // neighbouring samples along the blurred axis, `count` how many of them there // are; the window is clamped at both ends. diff --git a/src/effects/grain.cpp b/src/effects/grain.cpp new file mode 100644 index 0000000..0166557 --- /dev/null +++ b/src/effects/grain.cpp @@ -0,0 +1,90 @@ +#include "effects/grain.hpp" + +#include +#include +#include + +#include "core/parallel.hpp" +#include "effects/source_fill.hpp" + +namespace effector { + +const std::vector& GrainEffect::params() const { + static const std::vector specs = { + ParamSpec::makeDouble("grain_amount", "Amount", 0.35, 0.0, 1.0, + "How far a grain may push a pixel, as a fraction of full scale."), + ParamSpec::makeInt("grain_size", "Grain size", 1, 1, 32, + "Width in pixels of one grain; larger sizes look coarser."), + ParamSpec::makeBool("grain_colour", "Coloured grain", false, + "Draw the channels separately instead of one grey grain per pixel."), + ParamSpec::makeBool("grain_midtones", "Weight towards midtones", true, + "Fade the grain out in the shadows and highlights, as film does."), + }; + return specs; +} + +namespace { + +// Response curve: 1 at mid grey, falling to 0 at black and at white, which is +// roughly how grain behaves on film. +inline int midtoneWeight(int value) { return 255 - std::abs(2 * value - 255); } + +} // namespace + +Image GrainEffect::render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const { + if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels."); + + const double amount = std::clamp(paramDouble(values, "grain_amount", 0.35), 0.0, 1.0); + const int size = static_cast(std::max(1L, paramInt(values, "grain_size", 1))); + const bool colour = paramBool(values, "grain_colour", false); + const bool midtones = paramBool(values, "grain_midtones", true); + + Image canvas(width, height); + if (!sources.empty()) { + // Drawn before any threading, so that a seed always gives the same image. + const std::size_t index = rng.below(sources.size() - 1); + const Image& src = sources[index]; + const int sx = static_cast(rng.inRange(0, std::max(0, src.width() - width))); + const int sy = static_cast(rng.inRange(0, std::max(0, src.height() - height))); + fillFromSource(canvas, src, sx, sy); + } + + if (amount <= 0.0) return canvas; + + // The whole grain field is drawn serially from the rng, exactly like the + // scanlines plan, and only the pixel arithmetic afterwards is parallel. + const int cellsX = (width + size - 1) / size; + const int cellsY = (height + size - 1) / size; + const int channels = colour ? 3 : 1; + std::vector field(static_cast(cellsX) * cellsY * channels); + for (auto& cell : field) cell = static_cast(rng.inRange(-127, 127)); + + // 127 * 255 is the largest product of a grain and a midtone weight; scaling + // by `amount` there keeps the whole thing in integers. + const int scale = static_cast(amount * 255.0 * 65536.0 / 127.0); + + parallelFor(static_cast(height), [&](std::size_t begin, std::size_t end) { + for (std::size_t y = begin; y < end; ++y) { + std::uint8_t* out = canvas.row(static_cast(y)); + const std::size_t rowCell = + static_cast(static_cast(y) / size) * cellsX; + + for (int x = 0; x < width; ++x) { + const std::size_t cell = (rowCell + static_cast(x / size)) * channels; + for (int c = 0; c < 3; ++c) { + const int value = out[static_cast(x) * 3 + c]; + const int grain = field[cell + (colour ? static_cast(c) : 0)]; + const int weight = midtones ? midtoneWeight(value) : 255; + const int delta = grain * weight / 255 * scale / 65536; + out[static_cast(x) * 3 + c] = + static_cast(std::clamp(value + delta, 0, 255)); + } + } + } + }); + + return canvas; +} + +} // namespace effector diff --git a/src/effects/grain.hpp b/src/effects/grain.hpp new file mode 100644 index 0000000..3cf1826 --- /dev/null +++ b/src/effects/grain.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include "core/effect.hpp" + +namespace effector { + +// Scatters film-like grain over its input. Like the blur it needs no source +// images of its own, so it can lead a chain: given sources it fills the canvas +// from one of them first, given none it grains an empty canvas — which with the +// midtone weighting on stays black, exactly as an unexposed film would. +class GrainEffect : public Effect { +public: + std::string name() const override { return "grain"; } + std::string title() const override { return "Film grain"; } + std::string description() const override { + return "Scatters film-like grain over its input, optionally coloured and weighted towards " + "the midtones; with sources loaded it fills the canvas from a randomly chosen one " + "first."; + } + const std::vector& params() const override; + bool needsSources() const override { return false; } + + Image render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const override; +}; + +} // namespace effector diff --git a/src/effects/monochrome.cpp b/src/effects/monochrome.cpp new file mode 100644 index 0000000..dd291a4 --- /dev/null +++ b/src/effects/monochrome.cpp @@ -0,0 +1,105 @@ +#include "effects/monochrome.hpp" + +#include +#include +#include + +#include "core/parallel.hpp" +#include "effects/source_fill.hpp" + +namespace effector { + +const std::vector& MonochromeEffect::params() const { + static const std::vector specs = { + ParamSpec::makeChoice("mono_weighting", "Weighting", "luminance", + {"luminance", "average", "red", "green", "blue"}, + "How the three channels are mixed into one grey; the colour names " + "weigh that channel most, like a filter over the lens."), + ParamSpec::makeDouble("mono_contrast", "Contrast", 1.0, 0.1, 4.0, + "Pull of the greys away from mid grey; 1 leaves them as they are."), + ParamSpec::makeChoice("mono_tone", "Toner", "none", + {"none", "sepia", "selenium", "cyanotype"}, + "Tints the finished greys, as a darkroom toner would."), + ParamSpec::makeDouble("mono_strength", "Strength", 1.0, 0.0, 1.0, + "How far the result is mixed back over the colour input."), + }; + return specs; +} + +namespace { + +// Channel weights summing to 1024, so a grey is one integer multiply-add away. +std::array weightsFor(const std::string& weighting) { + if (weighting == "average") return {341, 342, 341}; + if (weighting == "red") return {819, 154, 51}; + if (weighting == "green") return {154, 819, 51}; + if (weighting == "blue") return {154, 51, 819}; + return {218, 732, 74}; // Rec. 709 luminance. +} + +// Tint multipliers per channel, as 256ths. +std::array tintFor(const std::string& tone) { + if (tone == "sepia") return {282, 251, 205}; + if (tone == "selenium") return {261, 251, 271}; + if (tone == "cyanotype") return {205, 243, 307}; + return {256, 256, 256}; +} + +} // namespace + +Image MonochromeEffect::render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const { + if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels."); + + const auto weights = weightsFor(paramChoice(values, "mono_weighting", "luminance")); + const auto tint = tintFor(paramChoice(values, "mono_tone", "none")); + const double contrast = std::clamp(paramDouble(values, "mono_contrast", 1.0), 0.1, 4.0); + const double strength = std::clamp(paramDouble(values, "mono_strength", 1.0), 0.0, 1.0); + + Image canvas(width, height); + if (!sources.empty()) { + // Drawn before any threading, so that a seed always gives the same image. + const std::size_t index = rng.below(sources.size() - 1); + const Image& src = sources[index]; + const int sx = static_cast(rng.inRange(0, std::max(0, src.width() - width))); + const int sy = static_cast(rng.inRange(0, std::max(0, src.height() - height))); + fillFromSource(canvas, src, sx, sy); + } + + if (strength <= 0.0) return canvas; + + // Contrast and toner only ever see one grey, so the whole tail of the + // conversion is a table: grey in, the three toned channels out. + std::array, 3> table{}; + for (int grey = 0; grey < 256; ++grey) { + const int pulled = + std::clamp(static_cast(128.0 + (grey - 128) * contrast + 0.5), 0, 255); + for (int c = 0; c < 3; ++c) { + table[static_cast(c)][static_cast(grey)] = + static_cast( + std::clamp(pulled * tint[static_cast(c)] / 256, 0, 255)); + } + } + + const int mix = static_cast(strength * 256.0 + 0.5); + + parallelFor(static_cast(height), [&](std::size_t begin, std::size_t end) { + for (std::size_t y = begin; y < end; ++y) { + std::uint8_t* out = canvas.row(static_cast(y)); + for (int x = 0; x < width; ++x) { + std::uint8_t* px = out + static_cast(x) * 3; + const int grey = + (px[0] * weights[0] + px[1] * weights[1] + px[2] * weights[2]) / 1024; + for (int c = 0; c < 3; ++c) { + const int toned = table[static_cast(c)] + [static_cast(std::clamp(grey, 0, 255))]; + px[c] = static_cast(px[c] + (toned - px[c]) * mix / 256); + } + } + } + }); + + return canvas; +} + +} // namespace effector diff --git a/src/effects/monochrome.hpp b/src/effects/monochrome.hpp new file mode 100644 index 0000000..d116b02 --- /dev/null +++ b/src/effects/monochrome.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "core/effect.hpp" + +namespace effector { + +// Converts its input to black and white, with the choice of channel weighting +// a photographer would make behind the lens (a red filter darkening a sky, for +// instance), an optional contrast pull and an optional toner. Like the blur it +// needs no source images of its own, so it can lead a chain; given sources it +// fills the canvas from one of them first. +class MonochromeEffect : public Effect { + public: + std::string name() const override { return "monochrome"; } + std::string title() const override { return "Black and white"; } + std::string description() const override { + return "Converts its input to black and white with a choice of channel weighting, an " + "optional contrast pull and an optional toner; with sources loaded it fills the " + "canvas from a randomly chosen one first."; + } + const std::vector& params() const override; + bool needsSources() const override { return false; } + + Image render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const override; +}; + +} // namespace effector diff --git a/src/effects/source_fill.hpp b/src/effects/source_fill.hpp new file mode 100644 index 0000000..9a96059 --- /dev/null +++ b/src/effects/source_fill.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +#include "core/image.hpp" +#include "core/parallel.hpp" + +namespace effector { + +// Copies `src` into the canvas starting at (sx, sy), repeating it as needed so +// that every pixel is covered even when the source is smaller than the canvas. +// Shared by the effects that do not build an image of their own but still want +// something to work on when they lead a chain. +inline void fillFromSource(Image& canvas, const Image& src, int sx, int sy) { + const int width = canvas.width(); + const std::size_t total = static_cast(width) * 3; + const int copyWidth = std::min(width, src.width() - sx); + const std::size_t copyBytes = static_cast(copyWidth) * 3; + + parallelFor(static_cast(canvas.height()), [&](std::size_t begin, std::size_t end) { + for (std::size_t y = begin; y < end; ++y) { + const int srcY = sy + (static_cast(y) % std::max(1, src.height() - sy)); + const std::uint8_t* in = src.row(srcY) + static_cast(sx) * 3; + std::uint8_t* out = canvas.row(static_cast(y)); + + std::memcpy(out, in, copyBytes); + std::size_t filled = copyBytes; + while (filled < total) { + const std::size_t chunk = std::min(filled, total - filled); + std::memcpy(out + filled, out, chunk); + filled += chunk; + } + } + }); +} + +} // namespace effector diff --git a/src/gui/window.cpp b/src/gui/window.cpp index 0c01216..c46a393 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -85,13 +85,6 @@ void EffectorWindow::buildUi() { menuButton_.set_popover(static_cast(buildMainMenu())); header->pack_end(menuButton_); - auto* addButton = Gtk::make_managed(); - addButton->set_icon_name("list-add-symbolic"); - addButton->set_tooltip_text("Add images"); - addButton->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onAddImages)); - header->pack_start(*addButton); - addButton_ = addButton; - saveButton_.set_sensitive(false); saveButton_.signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onSave)); header->pack_end(saveButton_); @@ -120,33 +113,73 @@ void EffectorWindow::buildUi() { root->append(*paned); } +// The chain is what the interface is about, so it comes first and the params +// of the selected stage sit right below it. The sources are a prerequisite, +// not the subject: they live at the bottom in a collapsed expander. Gtk::Widget& EffectorWindow::buildSidebar() { auto* sidebar = Gtk::make_managed(Gtk::Orientation::VERTICAL, 12); sidebar->set_margin(12); - sidebar->append(*sectionLabel("Source images")); + sidebar->append(buildChainSection()); + + paramHeading_.set_xalign(0.0F); + paramHeading_.add_css_class("heading"); + sidebar->append(paramHeading_); + paramGrid_.set_column_spacing(8); + paramGrid_.set_row_spacing(6); + auto* paramScroller = Gtk::make_managed(); + paramScroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC); + paramScroller->set_child(paramGrid_); + paramScroller->set_vexpand(true); + sidebar->append(*paramScroller); + rebuildChainRows(); + buildParamWidgets(); + + sidebar->append(*Gtk::make_managed(Gtk::Orientation::HORIZONTAL)); + sourceList_.set_selection_mode(Gtk::SelectionMode::NONE); sourceList_.add_css_class("boxed-list"); auto* empty = Gtk::make_managed("No images yet"); - empty->set_margin_top(18); - empty->set_margin_bottom(18); + empty->set_margin_top(12); + empty->set_margin_bottom(12); sourceList_.set_placeholder(*empty); auto* scroller = Gtk::make_managed(); scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC); scroller->set_child(sourceList_); - scroller->set_size_request(-1, 170); - scroller->set_vexpand(true); - sidebar->append(*scroller); + scroller->set_size_request(-1, 140); + scroller->set_margin_top(6); + + auto* sourcesBox = Gtk::make_managed(Gtk::Orientation::VERTICAL, 6); + sourcesBox->append(*scroller); + + // Same shape as "Add effect": the icon and the text are put together by + // hand because set_label() and set_icon_name() replace one another. + auto* addBox = Gtk::make_managed(Gtk::Orientation::HORIZONTAL, 6); + addBox->append(*Gtk::make_managed( + Gio::ThemedIcon::create("list-add-symbolic"))); + addBox->append(*Gtk::make_managed("Add images")); + auto* addButton = Gtk::make_managed(); + addButton->set_child(*addBox); + addButton->set_halign(Gtk::Align::START); + addButton->set_tooltip_text("Add source images"); + addButton->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onAddImages)); + sourcesBox->append(*addButton); + addButton_ = addButton; + + sourcesExpander_.set_child(*sourcesBox); + sourcesExpander_.set_expanded(false); + updateSourcesLabel(); + sidebar->append(sourcesExpander_); sidebar->append(*sectionLabel("Canvas")); auto* grid = Gtk::make_managed(); grid->set_column_spacing(8); grid->set_row_spacing(6); - widthSpin_.set_range(1, 20000); + widthSpin_.set_range(kMinCanvasSide, kMaxCanvasSide); widthSpin_.set_increments(1, 100); widthSpin_.set_value(1920); - heightSpin_.set_range(1, 20000); + heightSpin_.set_range(kMinCanvasSide, kMaxCanvasSide); heightSpin_.set_increments(1, 100); heightSpin_.set_value(1080); for (auto* spin : {&widthSpin_, &heightSpin_}) { @@ -163,17 +196,6 @@ Gtk::Widget& EffectorWindow::buildSidebar() { grid->attach(heightSpin_, 1, 1); sidebar->append(*grid); - sidebar->append(buildChainSection()); - - paramHeading_.set_xalign(0.0F); - paramHeading_.add_css_class("heading"); - sidebar->append(paramHeading_); - paramGrid_.set_column_spacing(8); - paramGrid_.set_row_spacing(6); - sidebar->append(paramGrid_); - rebuildChainRows(); - buildParamWidgets(); - auto* seedBox = Gtk::make_managed(Gtk::Orientation::HORIZONTAL, 8); auto* seedLabel = Gtk::make_managed("Seed"); seedLabel->set_xalign(0.0F); @@ -187,6 +209,15 @@ Gtk::Widget& EffectorWindow::buildSidebar() { return *sidebar; } +// The expander is collapsed by default, so its label carries the count that +// used to be plain to see in the list. +void EffectorWindow::updateSourcesLabel() { + int count = 0; + while (sourceList_.get_row_at_index(count) != nullptr) ++count; + sourcesExpander_.set_label(count == 0 ? "Source images" + : "Source images (" + std::to_string(count) + ")"); +} + Gtk::Widget& EffectorWindow::buildPreview() { picture_.set_can_shrink(true); picture_.set_content_fit(Gtk::ContentFit::CONTAIN); @@ -565,6 +596,7 @@ void EffectorWindow::appendSourceRow(const std::string& path) { row->set_child(*box); sourceList_.append(*row); + updateSourcesLabel(); } void EffectorWindow::removeSource(Gtk::ListBoxRow* row) { @@ -575,6 +607,7 @@ void EffectorWindow::removeSource(Gtk::ListBoxRow* row) { } }); sourceList_.remove(*row); + updateSourcesLabel(); requestRender(); } @@ -622,7 +655,12 @@ Gtk::Widget& EffectorWindow::buildChainSection() { chainList_.set_placeholder(*empty); chainList_.signal_row_selected().connect( [this](Gtk::ListBoxRow*) { onStageSelected(); }); - box->append(chainList_); + + auto* scroller = Gtk::make_managed(); + scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC); + scroller->set_child(chainList_); + scroller->set_size_request(-1, 150); + box->append(*scroller); // set_label() and set_icon_name() replace one another, so the icon and the // text are put together by hand. @@ -995,6 +1033,7 @@ void EffectorWindow::applyProject(const Project& project, const std::string& pat void EffectorWindow::clearSources() { while (auto* row = sourceList_.get_row_at_index(0)) sourceList_.remove(*row); + updateSourcesLabel(); mutateSources([](SourceList& list) { list.clear(); }); loadQueue_.clear(); if (loading_) dropLoadedImages_ = true; diff --git a/src/gui/window.hpp b/src/gui/window.hpp index 5c70fa5..99731e5 100644 --- a/src/gui/window.hpp +++ b/src/gui/window.hpp @@ -48,6 +48,7 @@ private: void closeLoadWindow(); void cancelLoad(); void appendSourceRow(const std::string& path); + void updateSourcesLabel(); // the collapsed expander shows the count void removeSource(Gtk::ListBoxRow* row); Gtk::Widget& buildChainSection(); @@ -139,6 +140,7 @@ private: std::unique_ptr loadWindow_; Gtk::ProgressBar* loadBar_ = nullptr; Gtk::Label* loadFileLabel_ = nullptr; + Gtk::Expander sourcesExpander_; Gtk::ListBox sourceList_; Gtk::SpinButton widthSpin_; Gtk::SpinButton heightSpin_; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5fcf2aa..e98d7bd 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,4 +1,6 @@ -add_executable(effector_tests core_tests.cpp) +# The CLI parser joins the core here: it is GUI-free too, and the canvas size +# it produces has to obey the same bounds a project file does. +add_executable(effector_tests core_tests.cpp ../src/cli/options.cpp) target_link_libraries(effector_tests PRIVATE effector_core) target_compile_options(effector_tests PRIVATE -Wall -Wextra -Wpedantic) diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index dd1ce27..ea4ed87 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -7,14 +7,19 @@ #include #include #include +#include #include +#include #include +#include "cli/options.hpp" #include "core/chain.hpp" #include "core/project.hpp" #include "core/registry.hpp" #include "core/rng.hpp" #include "effects/blur.hpp" +#include "effects/grain.hpp" +#include "effects/monochrome.hpp" #include "effects/scanlines.hpp" namespace { @@ -43,6 +48,28 @@ effector::Image gradient(int width, int height, int shade) { return image; } +// A canvas taken from a project file, written by hand rather than by +// projectToJson, so a size no writer would produce can be fed to the reader. +std::string projectWithCanvas(const std::string& width, const std::string& height) { + return "{\"canvas\": {\"width\": " + width + ", \"height\": " + height + + "},\n \"chain\": [{\"effect\": \"blur\"}]}"; +} + +// The canvas a `-s` value ends up as, or nothing when the parser refuses it. +std::optional> canvasFromSize(const std::string& size) { + char program[] = "effector"; + char flag[] = "-s"; + std::vector value(size.begin(), size.end()); + value.push_back('\0'); + char* argv[] = {program, flag, value.data()}; + try { + const auto options = effector::parseOptions(3, argv); + return std::pair{options.width, options.height}; + } catch (const effector::EffectError&) { + return std::nullopt; + } +} + 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()); @@ -71,6 +98,45 @@ effector::Image renderBlur(const std::vector& sources, int widt return effect.render(sources, width, height, rng, merged); } +effector::Image renderGrain(const std::vector& sources, int width, int height, + std::uint64_t seed, const effector::ParamValues& values) { + effector::GrainEffect effect; + effector::Rng rng(seed); + auto merged = effect.defaults(); + for (const auto& [key, value] : values) merged[key] = value; + return effect.render(sources, width, height, rng, merged); +} + +effector::Image renderMono(const std::vector& sources, int width, int height, + std::uint64_t seed, const effector::ParamValues& values) { + effector::MonochromeEffect effect; + effector::Rng rng(seed); + auto merged = effect.defaults(); + for (const auto& [key, value] : values) merged[key] = value; + return effect.render(sources, width, height, rng, merged); +} + +// A flat field of one colour, to read the channel weighting off directly. +effector::Image solid(int width, int height, std::uint8_t r, std::uint8_t g, std::uint8_t b) { + effector::Image image(width, height); + for (int y = 0; y < height; ++y) { + auto* row = image.row(y); + for (int x = 0; x < width; ++x) { + row[x * 3 + 0] = r; + row[x * 3 + 1] = g; + row[x * 3 + 2] = b; + } + } + return image; +} + +// A flat field of one shade, to measure what the grain does to it. +effector::Image flat(int width, int height, std::uint8_t shade) { + effector::Image image(width, height); + std::fill(image.data(), image.data() + image.byteSize(), shade); + return image; +} + effector::Image render(const std::vector& sources, int width, int height, std::uint64_t seed, const effector::ParamValues& values) { effector::ScanlinesEffect effect; @@ -202,6 +268,142 @@ int main() { check(allBlack, "without sources blur renders an empty canvas"); } + std::cout << "grain\n"; + { + std::vector sources = {gradient(1200, 800, 10), gradient(640, 480, 90)}; + + const auto first = renderGrain(sources, 400, 300, 4, {}); + const auto again = renderGrain(sources, 400, 300, 4, {}); + const auto other = renderGrain(sources, 400, 300, 11, {}); + check(first.width() == 400 && first.height() == 300, "canvas has the requested size"); + check(sameBytes(first, again), "same seed reproduces the image"); + check(!sameBytes(first, other), "a different seed changes the image"); + + // Mid grey is where the grain is strongest, so it must move, and stay + // grey while the channels are drawn together. + std::vector grey = {flat(200, 100, 128)}; + const auto grained = renderGrain(grey, 200, 100, 3, {{"grain_amount", 0.5}}); + bool moved = false; + bool neutral = true; + for (int x = 0; x < 200; ++x) { + const auto* px = grained.row(50) + x * 3; + moved = moved || px[0] != 128; + neutral = neutral && px[0] == px[1] && px[1] == px[2]; + } + check(moved, "grain moves a flat mid grey"); + check(neutral, "monochrome grain keeps the channels equal"); + + const auto coloured = renderGrain(grey, 200, 100, 3, {{"grain_colour", true}}); + bool split = false; + for (int x = 0; x < 200; ++x) { + const auto* px = coloured.row(50) + x * 3; + split = split || px[0] != px[1] || px[1] != px[2]; + } + check(split, "coloured grain pulls the channels apart"); + + // With the midtone weighting on, black and white are left alone; with + // it off the grain lands everywhere. + const auto blackWeighted = renderGrain({flat(64, 64, 0)}, 64, 64, 2, {}); + bool untouched = true; + for (std::size_t i = 0; i < blackWeighted.byteSize(); ++i) { + untouched = untouched && blackWeighted.data()[i] == 0; + } + check(untouched, "the midtone weighting leaves black alone"); + + const auto blackFlat = + renderGrain({flat(64, 64, 0)}, 64, 64, 2, {{"grain_midtones", false}}); + bool lifted = false; + for (std::size_t i = 0; i < blackFlat.byteSize(); ++i) { + lifted = lifted || blackFlat.data()[i] != 0; + } + check(lifted, "without the weighting grain reaches the shadows"); + + // Zero amount is a pass through, and a grain wider than the canvas must + // still only read the one cell it has. + check(sameBytes(renderGrain({flat(32, 32, 128)}, 32, 32, 1, {{"grain_amount", 0.0}}), + flat(32, 32, 128)), + "zero amount leaves the image untouched"); + const auto coarse = renderGrain(grey, 40, 30, 7, {{"grain_size", 64L}}); + check(coarse.width() == 40 && coarse.height() == 30, + "a grain wider than the canvas is clamped"); + + // Like blur, grain needs no sources: with none it grains the empty + // canvas it starts from instead of refusing to run. + const auto blank = renderGrain({}, 100, 100, 1, {{"grain_midtones", false}}); + bool speckled = blank.width() == 100 && blank.height() == 100; + bool any = false; + for (std::size_t i = 0; i < blank.byteSize(); ++i) any = any || blank.data()[i] != 0; + check(speckled && any, "without sources grain works on an empty canvas"); + } + + std::cout << "monochrome\n"; + { + std::vector sources = {gradient(1200, 800, 10), gradient(640, 480, 90)}; + + const auto first = renderMono(sources, 400, 300, 4, {}); + const auto again = renderMono(sources, 400, 300, 4, {}); + check(first.width() == 400 && first.height() == 300, "canvas has the requested size"); + check(sameBytes(first, again), "same seed reproduces the image"); + + bool grey = true; + for (std::size_t i = 0; i + 2 < first.byteSize(); i += 3) { + grey = grey && first.data()[i] == first.data()[i + 1] && + first.data()[i + 1] == first.data()[i + 2]; + } + check(grey, "every pixel comes out neutral grey"); + + // A pure red field: the luminance weighting keeps little of it, the red + // weighting nearly all, and the blue weighting almost none. + std::vector red = {solid(64, 64, 255, 0, 0)}; + const auto viaLuma = renderMono(red, 64, 64, 1, {}); + const auto viaRed = renderMono(red, 64, 64, 1, {{"mono_weighting", std::string("red")}}); + const auto viaBlue = renderMono(red, 64, 64, 1, {{"mono_weighting", std::string("blue")}}); + check(viaLuma.data()[0] > 40 && viaLuma.data()[0] < 70, "luminance darkens a pure red"); + check(viaRed.data()[0] > 190, "the red weighting keeps a pure red light"); + check(viaBlue.data()[0] < 50, "the blue weighting renders a pure red dark"); + + // Contrast works about mid grey: it leaves that alone and pushes the + // shades either side of it apart. + const auto midHard = + renderMono({solid(32, 32, 128, 128, 128)}, 32, 32, 1, {{"mono_contrast", 2.0}}); + check(midHard.data()[0] >= 127 && midHard.data()[0] <= 129, "contrast pivots on mid grey"); + const auto darkFlat = renderMono({solid(32, 32, 80, 80, 80)}, 32, 32, 1, {}); + const auto darkHard = + renderMono({solid(32, 32, 80, 80, 80)}, 32, 32, 1, {{"mono_contrast", 2.0}}); + check(darkHard.data()[0] < darkFlat.data()[0], "contrast deepens the shadows"); + + // The toners tint the greys, each in its own direction. + const auto sepia = renderMono({solid(32, 32, 128, 128, 128)}, 32, 32, 1, + {{"mono_tone", std::string("sepia")}}); + check(sepia.data()[0] > sepia.data()[1] && sepia.data()[1] > sepia.data()[2], + "sepia warms the greys"); + const auto cyan = renderMono({solid(32, 32, 128, 128, 128)}, 32, 32, 1, + {{"mono_tone", std::string("cyanotype")}}); + check(cyan.data()[2] > cyan.data()[1] && cyan.data()[1] > cyan.data()[0], + "the cyanotype toner cools them"); + + // Strength mixes back towards the colour input: zero is a pass through, + // half sits between the two. + check(sameBytes( + renderMono({solid(32, 32, 200, 40, 10)}, 32, 32, 1, {{"mono_strength", 0.0}}), + solid(32, 32, 200, 40, 10)), + "zero strength leaves the colours untouched"); + const auto half = + renderMono({solid(32, 32, 200, 40, 10)}, 32, 32, 1, {{"mono_strength", 0.5}}); + const auto full = renderMono({solid(32, 32, 200, 40, 10)}, 32, 32, 1, {}); + check(half.data()[0] > full.data()[0] && half.data()[0] < 200, + "half strength sits between the grey and the colour"); + + // Like blur and grain it needs no sources: with none it converts the + // empty canvas, which stays black. + const auto blank = renderMono({}, 50, 50, 1, {}); + bool allBlack = blank.width() == 50 && blank.height() == 50; + for (std::size_t i = 0; i < blank.byteSize(); ++i) { + allBlack = allBlack && blank.data()[i] == 0; + } + check(allBlack, "without sources monochrome renders an empty canvas"); + } + std::cout << "chain\n"; { std::vector sources = {gradient(1200, 800, 10), gradient(640, 480, 90)}; @@ -345,6 +547,72 @@ int main() { std::filesystem::remove_all(dir); } + std::cout << "canvas size\n"; + { + // The sizes the front-ends actually offer make the round trip whole. + int kept = 0; + for (const auto [w, h] : {std::pair{1, 1}, + {640, 400}, + {1920, 1080}, + {kMaxCanvasSide, kMaxCanvasSide}}) { + const auto back = projectFromJson( + projectWithCanvas(std::to_string(w), std::to_string(h))); + if (back.width == w && back.height == h) ++kept; + } + check(kept == 4, "every canvas in range is read back exactly"); + + Project project; + project.stages.push_back({effectByName("blur"), {}}); + project.width = kMaxCanvasSide; + project.height = 3; + const auto written = projectFromJson(projectToJson(project)); + check(written.width == kMaxCanvasSide && written.height == 3, + "the largest canvas survives a write and a read"); + + // A side that does not fit in an int must not be narrowed into one + // that does: 4294968296 truncates to a perfectly plausible 1000. + int refusals = 0; + for (const char* bad : {"4294968296", "2147483648", "99999999999999999999", "-5", "0", + "20001", "800.5", "\"800\"", "true"}) { + try { + projectFromJson(projectWithCanvas(bad, "600")); + } catch (const EffectError&) { + ++refusals; + } + } + check(refusals == 9, "a canvas width an int cannot hold is refused, not wrapped"); + + refusals = 0; + for (const char* bad : {"4294968296", "-5", "0", "20001", "800.5"}) { + try { + projectFromJson(projectWithCanvas("800", bad)); + } catch (const EffectError&) { + ++refusals; + } + } + check(refusals == 5, "the same bounds apply to the canvas height"); + + // A canvas the file does not mention keeps the defaults rather than + // failing: an old project is still readable. + const auto bare = projectFromJson("{\"chain\": [{\"effect\": \"blur\"}]}"); + check(bare.width == 1920 && bare.height == 1080, "a project with no canvas keeps 1920x1080"); + + // The CLI narrows the same way, from a long rather than a double. + const auto plain = canvasFromSize("800x600"); + check(plain && plain->first == 800 && plain->second == 600, "-s 800x600 is stored as given"); + const auto big = canvasFromSize(std::to_string(kMaxCanvasSide) + "x" + + std::to_string(kMaxCanvasSide)); + check(big && big->first == kMaxCanvasSide, "-s takes the largest canvas"); + check(!canvasFromSize("4294968296x600"), "-s refuses a width that does not fit in an int"); + check(!canvasFromSize("2147483648x600"), "-s refuses a width just past INT_MAX"); + check(!canvasFromSize("99999999999999999999x600"), "-s refuses a width past a long"); + check(!canvasFromSize("800x4294968296"), "-s refuses a height that does not fit in an int"); + check(!canvasFromSize("20001x600") && !canvasFromSize("800x20001"), + "-s refuses a side past the canvas limit"); + check(!canvasFromSize("0x600") && !canvasFromSize("-5x600"), + "-s refuses a side that is not positive"); + } + std::cout << "image io\n"; { const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png";