From b6b5f94f08dcaa9b75e276f2e528ac9a0bd4faf3 Mon Sep 17 00:00:00 2001 From: Si Date: Fri, 18 Sep 2026 16:36:43 +0200 Subject: [PATCH 1/4] Put the chain first in the sidebar The source images took the top of the sidebar and most of its height, which made the list of files look like the subject of the window rather than its input. The chain is what the interface is about, so it now comes first with the selected stage's params right below it, taking the free space; the sources sit at the bottom in a collapsed expander whose label carries the count, with their own "Add images" button instead of the one that used to be in the header bar. Co-Authored-By: Claude Opus 5 --- src/gui/window.cpp | 89 +++++++++++++++++++++++++++++++++------------- src/gui/window.hpp | 2 ++ 2 files changed, 66 insertions(+), 25 deletions(-) diff --git a/src/gui/window.cpp b/src/gui/window.cpp index 0c01216..5c20daf 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,24 +113,64 @@ 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(); @@ -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_; From 82e730221e48ca2fc52b184c0215f8320a710c7a Mon Sep 17 00:00:00 2001 From: Si Date: Fri, 18 Sep 2026 16:42:04 +0200 Subject: [PATCH 2/4] Add a film grain effect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grain is the third effect and the second that declares needsSources() false, so it works as a filter stage in a cascade and can also lead one, filling the canvas from a source the way blur does. Its knobs are the amount a grain may push a pixel, the grain size in pixels, whether the channels are drawn apart into coloured grain, and whether the response is weighted towards the midtones — which it is by default, since film grains least where it is least exposed. That last one means grain over an empty canvas stays black unless the weighting is turned off. Determinism comes from the same shape the scanlines effect uses: the whole grain field is drawn serially from the Rng into one cell per grain before any threading, and only the integer pixel arithmetic afterwards runs under parallelFor. The canvas fill blur used to keep to itself moves to effects/source_fill.hpp unchanged, now that two effects want it. Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 1 + src/core/registry.cpp | 2 + src/effects/blur.cpp | 26 +---------- src/effects/grain.cpp | 90 +++++++++++++++++++++++++++++++++++++ src/effects/grain.hpp | 27 +++++++++++ src/effects/source_fill.hpp | 38 ++++++++++++++++ tests/core_tests.cpp | 85 +++++++++++++++++++++++++++++++++++ 7 files changed, 244 insertions(+), 25 deletions(-) create mode 100644 src/effects/grain.cpp create mode 100644 src/effects/grain.hpp create mode 100644 src/effects/source_fill.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 57540ef..75540c1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,6 +28,7 @@ add_library(effector_core STATIC src/core/registry.cpp src/core/rng.cpp src/effects/blur.cpp + src/effects/grain.cpp src/effects/scanlines.cpp ) target_include_directories(effector_core PUBLIC src) diff --git a/src/core/registry.cpp b/src/core/registry.cpp index b540896..03633d7 100644 --- a/src/core/registry.cpp +++ b/src/core/registry.cpp @@ -1,6 +1,7 @@ #include "core/registry.hpp" #include "effects/blur.hpp" +#include "effects/grain.hpp" #include "effects/scanlines.hpp" namespace effector { @@ -10,6 +11,7 @@ const std::vector& effects() { static const std::vector registry = { 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/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/tests/core_tests.cpp b/tests/core_tests.cpp index dd1ce27..24f1b03 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -15,6 +15,7 @@ #include "core/registry.hpp" #include "core/rng.hpp" #include "effects/blur.hpp" +#include "effects/grain.hpp" #include "effects/scanlines.hpp" namespace { @@ -71,6 +72,22 @@ 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); +} + +// 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 +219,74 @@ 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 << "chain\n"; { std::vector sources = {gradient(1200, 800, 10), gradient(640, 480, 90)}; From 67417727e75549d11d713df67a4e4f63e849adae Mon Sep 17 00:00:00 2001 From: Si Date: Fri, 18 Sep 2026 16:49:44 +0200 Subject: [PATCH 3/4] Check the canvas size before narrowing it to an int MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A canvas side was cast to int with no range check, so a number that did not fit came back as one that did: `-s 4294968296x600` rendered a perfectly plausible 1000x600, and a hand-edited project file could ask for a width the reader turned into something else (or, for a double out of range, into undefined behaviour). Both front-ends now check the side before the cast, against one shared bound in chain.hpp — the 1..20000 the GUI spin buttons already offered, so the help text, the CLI error, the project reader and the interface all move together. The tests gain a `canvas size` section covering the round trip at both ends of the range and the refusals on either side of it, through the project reader and through `-s`; the test binary picks up the CLI parser to do it, which stays GUI-free. Co-Authored-By: Claude Opus 5 --- src/cli/options.cpp | 12 ++++-- src/core/chain.hpp | 7 ++++ src/core/project.cpp | 20 ++++++++-- src/gui/window.cpp | 4 +- tests/CMakeLists.txt | 4 +- tests/core_tests.cpp | 91 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 129 insertions(+), 9 deletions(-) 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/gui/window.cpp b/src/gui/window.cpp index 5c20daf..c46a393 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -176,10 +176,10 @@ Gtk::Widget& EffectorWindow::buildSidebar() { 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_}) { 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 24f1b03..eb4541b 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -7,9 +7,12 @@ #include #include #include +#include #include +#include #include +#include "cli/options.hpp" #include "core/chain.hpp" #include "core/project.hpp" #include "core/registry.hpp" @@ -44,6 +47,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()); @@ -430,6 +455,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"; From efee1cf22413cdefa2cd0122b7d3be741bba8b8d Mon Sep 17 00:00:00 2001 From: Si Date: Fri, 18 Sep 2026 16:55:00 +0200 Subject: [PATCH 4/4] Add a black and white effect Monochrome is the fourth effect and the third that declares needsSources() false, so it works as a filter stage at the end of a cascade and can also lead one, filling the canvas from a source the way blur and grain do. Its knobs are the channel weighting, the contrast, a toner and the strength: the weighting offers the mixes a photographer would make behind the lens, so the colour names weigh that channel most and "red" darkens a sky the way a red filter does; contrast pulls the greys away from mid grey; the toners tint the finished greys warm (sepia), barely (selenium) or cold (cyanotype); and strength mixes the result back over the colour input, which at 0 is a pass through. Contrast and toner only ever see one grey, so the whole tail of the conversion is folded into a 256-entry table per channel, built once before the parallel pass; the per-pixel work is then integer only, with the weights as 1024ths and the tints as 256ths. The Rng is touched only for the source pick and the crop offset, serially, before any threading. The tests gain a `monochrome` section: neutrality of the output, each weighting measured on a pure red field, the contrast pivot at mid grey, both toner directions, strength as a pass through and as a midpoint, and the sourceless render that stays black. Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 1 + src/core/registry.cpp | 2 + src/effects/monochrome.cpp | 105 +++++++++++++++++++++++++++++++++++++ src/effects/monochrome.hpp | 28 ++++++++++ tests/core_tests.cpp | 92 ++++++++++++++++++++++++++++++++ 5 files changed, 228 insertions(+) create mode 100644 src/effects/monochrome.cpp create mode 100644 src/effects/monochrome.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 75540c1..f47c058 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -29,6 +29,7 @@ add_library(effector_core STATIC 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/core/registry.cpp b/src/core/registry.cpp index 03633d7..efd0358 100644 --- a/src/core/registry.cpp +++ b/src/core/registry.cpp @@ -2,6 +2,7 @@ #include "effects/blur.hpp" #include "effects/grain.hpp" +#include "effects/monochrome.hpp" #include "effects/scanlines.hpp" namespace effector { @@ -12,6 +13,7 @@ const std::vector& effects() { std::make_shared(), std::make_shared(), std::make_shared(), + std::make_shared(), }; return registry; } 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/tests/core_tests.cpp b/tests/core_tests.cpp index eb4541b..ea4ed87 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -19,6 +19,7 @@ #include "core/rng.hpp" #include "effects/blur.hpp" #include "effects/grain.hpp" +#include "effects/monochrome.hpp" #include "effects/scanlines.hpp" namespace { @@ -106,6 +107,29 @@ effector::Image renderGrain(const std::vector& sources, int wid 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); @@ -312,6 +336,74 @@ int main() { 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)};