From ee4b1038ac02f69e7565d9a7594bacdc29c89ba3 Mon Sep 17 00:00:00 2001 From: Si Date: Fri, 18 Sep 2026 15:05:15 +0200 Subject: [PATCH 1/4] Add a blur effect plugin Fills the canvas from one source, tiling it when it is smaller, then applies a separable box blur repeated `passes` times to approximate a gaussian. Params: radius, passes, direction and first_source, so the CLI flags and the GUI widgets come out of the ParamSpecs as usual. All random values (source index and crop offset) are drawn serially before the parallel pixel work, so a seed reproduces the image exactly; the box window clamps at both ends and the radius is clamped to the canvas so an oversized radius cannot read out of bounds. Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 1 + src/core/registry.cpp | 2 + src/effects/blur.cpp | 136 ++++++++++++++++++++++++++++++++++++++++++ src/effects/blur.hpp | 23 +++++++ tests/core_tests.cpp | 72 ++++++++++++++++++++++ 5 files changed, 234 insertions(+) create mode 100644 src/effects/blur.cpp create mode 100644 src/effects/blur.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 0139194..c14b919 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,6 +25,7 @@ add_library(effector_core STATIC src/core/params.cpp src/core/registry.cpp src/core/rng.cpp + src/effects/blur.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 de5c25d..b540896 100644 --- a/src/core/registry.cpp +++ b/src/core/registry.cpp @@ -1,5 +1,6 @@ #include "core/registry.hpp" +#include "effects/blur.hpp" #include "effects/scanlines.hpp" namespace effector { @@ -8,6 +9,7 @@ namespace effector { const std::vector& effects() { static const std::vector registry = { std::make_shared(), + std::make_shared(), }; return registry; } diff --git a/src/effects/blur.cpp b/src/effects/blur.cpp new file mode 100644 index 0000000..81b2e0b --- /dev/null +++ b/src/effects/blur.cpp @@ -0,0 +1,136 @@ +#include "effects/blur.hpp" + +#include +#include +#include + +#include "core/parallel.hpp" + +namespace effector { + +const std::vector& BlurEffect::params() const { + static const std::vector specs = { + ParamSpec::makeInt("radius", "Radius", 8, 1, 256, + "Half width in pixels of the blur window."), + ParamSpec::makeInt("passes", "Passes", 2, 1, 8, + "How often the box blur is repeated; more passes look more gaussian."), + ParamSpec::makeChoice("direction", "Direction", "both", {"both", "horizontal", "vertical"}, + "Axis the blur is applied along."), + ParamSpec::makeBool("first_source", "Use the first image", false, + "Always blur the first source instead of picking one at random."), + }; + return specs; +} + +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. +void boxPass(const std::uint8_t* in, std::uint8_t* out, int count, std::size_t stride, int radius) { + const int window = 2 * radius + 1; + int sum[3] = {0, 0, 0}; + + for (int c = 0; c < 3; ++c) { + sum[c] = in[c] * (radius + 1); + for (int i = 1; i <= radius; ++i) { + sum[c] += in[static_cast(std::min(i, count - 1)) * stride + c]; + } + } + + for (int i = 0; i < count; ++i) { + for (int c = 0; c < 3; ++c) { + out[static_cast(i) * stride + c] = + static_cast(sum[c] / window); + } + const int add = std::min(i + radius + 1, count - 1); + const int drop = std::max(i - radius, 0); + for (int c = 0; c < 3; ++c) { + sum[c] += in[static_cast(add) * stride + c] - + in[static_cast(drop) * stride + c]; + } + } +} + +void blurHorizontal(const Image& src, Image& dst, int radius) { + parallelFor(static_cast(src.height()), [&](std::size_t begin, std::size_t end) { + for (std::size_t y = begin; y < end; ++y) { + boxPass(src.row(static_cast(y)), dst.row(static_cast(y)), src.width(), 3, + radius); + } + }); +} + +void blurVertical(const Image& src, Image& dst, int radius) { + const std::size_t stride = src.stride(); + parallelFor(static_cast(src.width()), [&](std::size_t begin, std::size_t end) { + for (std::size_t x = begin; x < end; ++x) { + const std::size_t offset = x * 3; + boxPass(src.data() + offset, dst.data() + offset, src.height(), stride, radius); + } + }); +} + +} // namespace + +Image BlurEffect::render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const { + if (sources.empty()) throw EffectError("No source images."); + if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels."); + + const int radius = static_cast(std::max(1L, paramInt(values, "radius", 8))); + const int passes = static_cast(std::max(1L, paramInt(values, "passes", 2))); + const std::string direction = paramChoice(values, "direction", "both"); + const bool firstSource = paramBool(values, "first_source", false); + + // Every random value is drawn here, before any threading, so that a seed + // always gives the same image. + const std::size_t index = firstSource ? 0 : 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))); + + Image canvas(width, height); + fillFromSource(canvas, src, sx, sy); + + Image scratch(width, height); + for (int pass = 0; pass < passes; ++pass) { + if (direction != "vertical") { + blurHorizontal(canvas, scratch, std::min(radius, std::max(1, width - 1))); + std::swap(canvas, scratch); + } + if (direction != "horizontal") { + blurVertical(canvas, scratch, std::min(radius, std::max(1, height - 1))); + std::swap(canvas, scratch); + } + } + + return canvas; +} + +} // namespace effector diff --git a/src/effects/blur.hpp b/src/effects/blur.hpp new file mode 100644 index 0000000..57cf3d2 --- /dev/null +++ b/src/effects/blur.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include "core/effect.hpp" + +namespace effector { + +// Fills the canvas with one of the sources and softens it with a separable box +// blur, repeated a few times to approximate a gaussian. +class BlurEffect : public Effect { +public: + std::string name() const override { return "blur"; } + std::string title() const override { return "Blur"; } + std::string description() const override { + return "Fills the canvas from a randomly chosen source and softens it with a repeated " + "box blur, horizontally, vertically or both."; + } + const std::vector& params() const override; + + 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 d913488..3b0e38e 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -11,6 +11,7 @@ #include "core/registry.hpp" #include "core/rng.hpp" +#include "effects/blur.hpp" #include "effects/scanlines.hpp" namespace { @@ -44,6 +45,29 @@ bool sameBytes(const effector::Image& a, const effector::Image& b) { return std::equal(a.data(), a.data() + a.byteSize(), b.data()); } +// Alternating black and white columns: any blur along x has to pull these +// towards mid grey. +effector::Image stripes(int width, int height) { + effector::Image image(width, height); + for (int y = 0; y < height; ++y) { + auto* row = image.row(y); + for (int x = 0; x < width; ++x) { + const std::uint8_t value = (x % 2 == 0) ? 0 : 255; + row[x * 3 + 0] = row[x * 3 + 1] = row[x * 3 + 2] = value; + } + } + return image; +} + +effector::Image renderBlur(const std::vector& sources, int width, int height, + std::uint64_t seed, const effector::ParamValues& values) { + effector::BlurEffect effect; + effector::Rng rng(seed); + auto merged = effect.defaults(); + for (const auto& [key, value] : values) merged[key] = value; + return effect.render(sources, width, height, rng, merged); +} + effector::Image render(const std::vector& sources, int width, int height, std::uint64_t seed, const effector::ParamValues& values) { effector::ScanlinesEffect effect; @@ -128,6 +152,54 @@ int main() { check(threw, "no sources throws"); } + std::cout << "blur\n"; + { + std::vector sources = {gradient(1200, 800, 10), gradient(640, 480, 90)}; + + const auto first = renderBlur(sources, 400, 300, 4, {{"radius", 10L}}); + const auto again = renderBlur(sources, 400, 300, 4, {{"radius", 10L}}); + const auto other = renderBlur(sources, 400, 300, 11, {{"radius", 10L}}); + check(first.width() == 400 && first.height() == 300, "canvas has the requested size"); + check(sameBytes(first, again), "same seed reproduces the image"); + check(!sameBytes(first, other), "a different seed changes the image"); + + // A wide blur over one-pixel stripes has to land near mid grey, and only + // along the axis it is applied to. + std::vector striped = {stripes(200, 100)}; + const auto flattened = renderBlur(striped, 200, 100, 1, + {{"radius", 8L}, {"direction", std::string("horizontal")}}); + // The clamped edges ramp in over radius * passes pixels on each side. + bool grey = true; + for (int x = 32; x < 168; ++x) grey = grey && std::abs(flattened.row(50)[x * 3] - 127) <= 2; + check(grey, "a horizontal blur evens out vertical stripes"); + + const auto untouched = renderBlur(striped, 200, 100, 1, + {{"radius", 8L}, {"direction", std::string("vertical")}}); + check(untouched.row(50)[0] == 0 && untouched.row(50)[3] == 255, + "a vertical blur leaves columns alone"); + + // Every column is uniform, so a vertical blur must not change anything. + const auto vertical = renderBlur(striped, 200, 100, 1, + {{"radius", 4L}, {"passes", 3L}, {"direction", std::string("vertical")}}); + check(sameBytes(untouched, vertical), "repeated vertical passes stay stable"); + + // Radius larger than the canvas must clamp rather than read out of bounds. + const auto tiny = renderBlur(striped, 3, 3, 2, {{"radius", 200L}}); + check(tiny.width() == 3 && tiny.height() == 3, "radius wider than the canvas is clamped"); + + const auto pinned = renderBlur(sources, 300, 200, 6, {{"first_source", true}}); + const auto pinnedAgain = renderBlur(sources, 300, 200, 6, {{"first_source", true}}); + check(sameBytes(pinned, pinnedAgain), "first_source is reproducible"); + + bool threw = false; + try { + renderBlur({}, 100, 100, 1, {}); + } catch (const EffectError&) { + threw = true; + } + check(threw, "no sources throws"); + } + std::cout << "image io\n"; { const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png"; From 89985cb98959570a764426c1df546cd1db68045b Mon Sep 17 00:00:00 2001 From: Si Date: Fri, 18 Sep 2026 15:21:29 +0200 Subject: [PATCH 2/4] Cascade effects through a chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderChain runs a list of stages in order: stage 0 works on the loaded sources, every later one on the output of the stage before it, so blur after scanlines softens the scanlines rather than the photographs. The effects themselves are untouched — none of them knows whether its input came from disk. All stages share one Rng and run in sequence, so a seed still reproduces the cascade byte for byte. The sidebar grows an "Effect chain" list: add a stage from a menu built from the registry, reorder it, remove it, and edit the selected stage's params underneath. Each stage keeps its own values. The CLI takes the same chain from repeated -e, with every flag going to the stage whose effect declares it. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 21 +++- CMakeLists.txt | 1 + src/cli/options.cpp | 25 +++- src/cli/options.hpp | 8 +- src/core/chain.cpp | 49 ++++++++ src/core/chain.hpp | 34 ++++++ src/gui/window.cpp | 273 +++++++++++++++++++++++++++++++++---------- src/gui/window.hpp | 21 +++- src/main.cpp | 11 +- tests/core_tests.cpp | 41 +++++++ 10 files changed, 409 insertions(+), 75 deletions(-) create mode 100644 src/core/chain.cpp create mode 100644 src/core/chain.hpp diff --git a/CLAUDE.md b/CLAUDE.md index 0a40bb8..2f2f7d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ Everything is warning-clean under `-Wall -Wextra -Wpedantic`; keep it that way. The test suite is a single hand-rolled binary (no framework), so there is no per-test filter: run `./build/tests/effector_tests` directly to see the section -headings (`rng`, `registry`, `scanlines`, `batch loading`) and the `ok`/`FAIL` +headings (`rng`, `registry`, `scanlines`, `blur`, `chain`, `batch loading`) and the `ok`/`FAIL` lines. To iterate on one area, comment out the other blocks in `tests/core_tests.cpp` or add a new `std::cout << "section\n"` block — checks are plain `check(condition, "description")` calls and `main()` returns the failure @@ -50,7 +50,24 @@ add a `ParamSpec` instead. Values travel as `ParamValues` (a Adding an effect: new `src/effects/.{hpp,cpp}` subclassing `Effect`, one line in the registry list in `src/core/registry.cpp`, one line in the -`effector_core` sources in `CMakeLists.txt`. Nothing else. +`effector_core` sources in `CMakeLists.txt`. Nothing else — it shows up in the +CLI, in the chain menu and as a cascade stage on its own. + +### Effects cascade + +Effects are never applied on their own: `renderChain` (`src/core/chain.cpp`) +runs a `std::vector` in order, handing stage 0 the loaded sources +and every later stage the previous stage's output as its *only* source. That is +the whole of the cascade — an effect neither knows nor cares whether its input +came from disk. All stages share one `Rng` and run strictly in sequence, so a +seed still reproduces the cascade byte for byte. + +The interface builds the chain in the sidebar ("Effect chain": add from the +menu, reorder, remove, one stage selected at a time with its params below), the +CLI builds it from repeated `-e`: `-e scanlines --strip-height 6 -e blur +--radius 5`. Flags share one namespace across all effects, so `buildChain` +(`src/cli/options.cpp`) hands each flag to the stage whose effect declares it — +two effects must not declare the same param key. ### Two link targets, deliberately diff --git a/CMakeLists.txt b/CMakeLists.txt index c14b919..75fd45e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,6 +20,7 @@ find_package(Threads REQUIRED) # --- core: effects and image handling, no GUI --------------------------------- add_library(effector_core STATIC + src/core/chain.cpp src/core/effect.cpp src/core/image.cpp src/core/params.cpp diff --git a/src/cli/options.cpp b/src/cli/options.cpp index 5a76186..504a159 100644 --- a/src/cli/options.cpp +++ b/src/cli/options.cpp @@ -4,6 +4,7 @@ #include #include +#include "core/chain.hpp" #include "core/effect.hpp" #include "core/registry.hpp" @@ -48,7 +49,6 @@ std::string requireValue(int argc, char** argv, int& i, const std::string& flag) Options parseOptions(int argc, char** argv) { Options options; - options.effect = defaultEffectName(); for (int i = 1; i < argc; ++i) { const std::string arg = argv[i]; @@ -58,8 +58,9 @@ Options parseOptions(int argc, char** argv) { } else if (arg == "--list-effects") { options.listEffects = true; } else if (arg == "-e" || arg == "--effect") { - options.effect = requireValue(argc, argv, i, arg); - effectByName(options.effect); // validate now, fail early + const std::string name = requireValue(argc, argv, i, arg); + effectByName(name); // validate now, fail early + options.effects.push_back(name); } else if (arg == "-o" || arg == "--output") { options.output = requireValue(argc, argv, i, arg); } else if (arg == "-s" || arg == "--size") { @@ -119,6 +120,7 @@ Options parseOptions(int argc, char** argv) { options.images.push_back(arg); } } + if (options.effects.empty()) options.effects.push_back(defaultEffectName()); return options; } @@ -127,7 +129,7 @@ std::string usage(const char* programName) { out << "Usage: " << programName << " [options] [image ...]\n\n" << "Image effects app. Runs the GTK interface unless --output is given.\n\n" << "Options:\n" - << " -e, --effect NAME effect to apply (default: " << defaultEffectName() << ")\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" << " --seed N random seed (default: random)\n" @@ -147,4 +149,19 @@ std::string usage(const char* programName) { return out.str(); } +std::vector buildChain(const Options& options) { + std::vector stages; + for (const auto& name : options.effects) { + ChainStage stage; + stage.effect = effectByName(name); + stage.values = stage.effect->defaults(); + for (const auto& spec : stage.effect->params()) { + const auto it = options.params.find(spec.key); + if (it != options.params.end()) stage.values[spec.key] = it->second; + } + stages.push_back(std::move(stage)); + } + return stages; +} + } // namespace effector diff --git a/src/cli/options.hpp b/src/cli/options.hpp index 7e509a8..68897c8 100644 --- a/src/cli/options.hpp +++ b/src/cli/options.hpp @@ -5,13 +5,14 @@ #include #include +#include "core/chain.hpp" #include "core/params.hpp" namespace effector { struct Options { std::vector images; - std::string effect; + std::vector effects; // one per cascade stage, in order std::string output; // empty -> launch the interface int width = 1920; int height = 1080; @@ -27,4 +28,9 @@ Options parseOptions(int argc, char** argv); std::string usage(const char* programName); +// Turns the parsed names and flags into cascade stages: every flag goes to the +// stage whose effect declares it, so --strip-height and --radius can be given +// together for `-e scanlines -e blur`. +std::vector buildChain(const Options& options); + } // namespace effector diff --git a/src/core/chain.cpp b/src/core/chain.cpp new file mode 100644 index 0000000..0225a2f --- /dev/null +++ b/src/core/chain.cpp @@ -0,0 +1,49 @@ +#include "core/chain.hpp" + +namespace effector { + +Image renderChain(const std::vector& stages, const std::vector& sources, + int width, int height, Rng& rng) { + if (stages.empty()) throw EffectError("The effect chain is empty."); + + Image image; + for (std::size_t i = 0; i < stages.size(); ++i) { + const auto& stage = stages[i]; + if (!stage.effect) throw EffectError("The effect chain has an empty stage."); + + auto values = stage.effect->defaults(); + for (const auto& [key, value] : stage.values) values[key] = value; + + if (i == 0) { + image = stage.effect->render(sources, width, height, rng, values); + } else { + // Feeding the previous result back in as the only source is what + // cascading means here; effects need to know nothing about it. + const std::vector previous = {std::move(image)}; + image = stage.effect->render(previous, width, height, rng, values); + } + } + return image; +} + +std::string chainTitle(const std::vector& stages) { + std::string text; + for (const auto& stage : stages) { + if (!stage.effect) continue; + if (!text.empty()) text += " → "; + text += stage.effect->title(); + } + return text; +} + +std::string chainSlug(const std::vector& stages) { + std::string text; + for (const auto& stage : stages) { + if (!stage.effect) continue; + if (!text.empty()) text += "-"; + text += stage.effect->name(); + } + return text; +} + +} // namespace effector diff --git a/src/core/chain.hpp b/src/core/chain.hpp new file mode 100644 index 0000000..028fedc --- /dev/null +++ b/src/core/chain.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include +#include + +#include "core/effect.hpp" +#include "core/image.hpp" +#include "core/params.hpp" +#include "core/rng.hpp" + +namespace effector { + +// One link of a cascade: an effect together with the values it runs with. +struct ChainStage { + EffectPtr effect; + ParamValues values; // missing keys fall back to the effect's defaults +}; + +// Renders the stages in order. The first one sees the loaded sources; every +// later one sees the previous stage's output as its only source, so blur after +// scanlines softens the scanlines rather than the photographs. +// +// All stages share one Rng and run strictly in sequence, so a seed still +// reproduces the cascade byte for byte. +Image renderChain(const std::vector& stages, const std::vector& sources, + int width, int height, Rng& rng); + +// "Scanlines → Blur", for window titles and status lines. +std::string chainTitle(const std::vector& stages); + +// "scanlines-blur", for file names. +std::string chainSlug(const std::vector& stages); + +} // namespace effector diff --git a/src/gui/window.cpp b/src/gui/window.cpp index c6acd1e..56d3e7d 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -1,9 +1,11 @@ #include "gui/window.hpp" +#include #include #include #include +#include "core/chain.hpp" #include "core/registry.hpp" #include "core/rng.hpp" @@ -34,7 +36,7 @@ EffectorWindow::EffectorWindow(const Options& initial) { set_default_size(1180, 760); effects_ = effects(); - effect_ = effects_.front(); + stages_.push_back({effects_.front(), effects_.front()->defaults()}); seed_ = Rng::randomSeed(); renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished)); @@ -69,14 +71,6 @@ void EffectorWindow::buildUi() { header->pack_start(*addButton); addButton_ = addButton; - std::vector titles; - for (const auto& effect : effects_) titles.push_back(effect->title()); - effectDropdown_.set_model(Gtk::StringList::create(titles)); - effectDropdown_.set_tooltip_text("Effect"); - effectDropdown_.property_selected().signal_changed().connect( - sigc::mem_fun(*this, &EffectorWindow::onEffectChanged)); - header->pack_start(effectDropdown_); - saveButton_.set_sensitive(false); saveButton_.signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onSave)); header->pack_end(saveButton_); @@ -148,10 +142,15 @@ Gtk::Widget& EffectorWindow::buildSidebar() { grid->attach(heightSpin_, 1, 1); sidebar->append(*grid); - sidebar->append(*sectionLabel("Effect settings")); + 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); @@ -202,8 +201,16 @@ void EffectorWindow::buildParamWidgets() { paramWidgets_.clear(); ownedWidgets_.clear(); + if (stages_.empty()) { + paramHeading_.set_text("No effects"); + return; + } + const auto& stage = stages_[static_cast(selectedStage_)]; + paramHeading_.set_text(std::to_string(selectedStage_ + 1) + ". " + stage.effect->title() + + " settings"); + int row = 0; - for (const auto& spec : effect_->params()) { + for (const auto& spec : stage.effect->params()) { auto label = std::make_unique(spec.label); label->set_xalign(0.0F); if (!spec.help.empty()) label->set_tooltip_text(spec.help); @@ -213,7 +220,7 @@ void EffectorWindow::buildParamWidgets() { case ParamSpec::Type::Bool: { auto toggle = std::make_unique(); toggle->set_halign(Gtk::Align::START); - toggle->set_active(spec.defaultValue != 0.0); + toggle->set_active(paramBool(stage.values, spec.key, spec.defaultValue != 0.0)); toggle->property_active().signal_changed().connect( sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); widget = std::move(toggle); @@ -225,7 +232,10 @@ void EffectorWindow::buildParamWidgets() { spin->set_range(spec.minimum, spec.maximum); spin->set_increments(spec.step, spec.step * 10); spin->set_digits(spec.type == ParamSpec::Type::Int ? 0 : spec.digits); - spin->set_value(spec.defaultValue); + spin->set_value(spec.type == ParamSpec::Type::Int + ? static_cast(paramInt(stage.values, spec.key, + static_cast(spec.defaultValue))) + : paramDouble(stage.values, spec.key, spec.defaultValue)); spin->signal_value_changed().connect( sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); widget = std::move(spin); @@ -235,8 +245,9 @@ void EffectorWindow::buildParamWidgets() { std::vector choices(spec.choices.begin(), spec.choices.end()); auto dropdown = std::make_unique(); dropdown->set_model(Gtk::StringList::create(choices)); + const auto current = paramChoice(stage.values, spec.key, spec.defaultChoice); for (std::size_t i = 0; i < spec.choices.size(); ++i) { - if (spec.choices[i] == spec.defaultChoice) dropdown->set_selected(i); + if (spec.choices[i] == current) dropdown->set_selected(i); } dropdown->property_selected().signal_changed().connect( sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); @@ -256,7 +267,8 @@ void EffectorWindow::buildParamWidgets() { } ParamValues EffectorWindow::paramValues() const { - ParamValues values = effect_->defaults(); + if (stages_.empty()) return {}; + ParamValues values = stages_[static_cast(selectedStage_)].values; for (const auto& entry : paramWidgets_) { switch (entry.spec.type) { case ParamSpec::Type::Bool: @@ -279,43 +291,28 @@ ParamValues EffectorWindow::paramValues() const { return values; } +// The widgets only ever show one stage, so their values are written back +// whenever they change; the other stages keep the settings they were given. +void EffectorWindow::storeParams() { + if (stages_.empty()) return; + stages_[static_cast(selectedStage_)].values = paramValues(); +} + void EffectorWindow::applyInitial(const Options& initial) { - for (std::size_t i = 0; i < effects_.size(); ++i) { - if (effects_[i]->name() == initial.effect) effectDropdown_.set_selected(i); - } + // `-e scanlines -e blur` starts the interface with that cascade already set + // up, flags included. + auto stages = buildChain(initial); + if (!stages.empty()) stages_ = std::move(stages); + selectedStage_ = 0; + rebuildChainRows(); + buildParamWidgets(); + widthSpin_.set_value(initial.width); heightSpin_.set_value(initial.height); if (initial.seed) { seed_ = *initial.seed; seedEntry_.set_text(std::to_string(seed_)); } - for (const auto& entry : paramWidgets_) { - const auto it = initial.params.find(entry.spec.key); - if (it == initial.params.end()) continue; - switch (entry.spec.type) { - case ParamSpec::Type::Bool: - static_cast(entry.widget) - ->set_active(paramBool(initial.params, entry.spec.key, false)); - break; - case ParamSpec::Type::Int: - static_cast(entry.widget) - ->set_value(static_cast(paramInt(initial.params, entry.spec.key, 0))); - break; - case ParamSpec::Type::Double: - static_cast(entry.widget) - ->set_value(paramDouble(initial.params, entry.spec.key, 0.0)); - break; - case ParamSpec::Type::Choice: { - const auto value = paramChoice(initial.params, entry.spec.key, entry.spec.defaultChoice); - for (std::size_t i = 0; i < entry.spec.choices.size(); ++i) { - if (entry.spec.choices[i] == value) { - static_cast(entry.widget)->set_selected(i); - } - } - break; - } - } - } if (!initial.images.empty()) addPaths(initial.images); } @@ -589,17 +586,176 @@ void EffectorWindow::onImagesChosen(const Glib::RefPtr& result if (!paths.empty()) addPaths(paths); } -// ------------------------------------------------------------ rendering --- +// ---------------------------------------------------------------- chain --- -void EffectorWindow::onEffectChanged() { - const auto index = effectDropdown_.get_selected(); - if (index >= effects_.size()) return; - effect_ = effects_[index]; +Gtk::Widget& EffectorWindow::buildChainSection() { + auto* box = Gtk::make_managed(Gtk::Orientation::VERTICAL, 6); + box->append(*sectionLabel("Effect chain")); + + chainList_.add_css_class("boxed-list"); + chainList_.set_selection_mode(Gtk::SelectionMode::SINGLE); + auto* empty = Gtk::make_managed("No effects yet"); + empty->set_margin_top(12); + empty->set_margin_bottom(12); + chainList_.set_placeholder(*empty); + chainList_.signal_row_selected().connect( + [this](Gtk::ListBoxRow*) { onStageSelected(); }); + box->append(chainList_); + + // set_label() and set_icon_name() replace one another, so the icon and the + // text are put together by hand. + 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 effect")); + addEffectButton_.set_child(*addBox); + addEffectButton_.set_always_show_arrow(true); + addEffectButton_.set_tooltip_text("Append an effect, applied to the output so far"); + addEffectButton_.set_popover(static_cast(buildAddMenu())); + addEffectButton_.set_halign(Gtk::Align::START); + box->append(addEffectButton_); + + return *box; +} + +// One entry per registered effect, so a new effect shows up here without this +// file knowing anything about it. +Gtk::Widget& EffectorWindow::buildAddMenu() { + auto* popover = Gtk::make_managed(); + auto* list = Gtk::make_managed(Gtk::Orientation::VERTICAL, 2); + + for (const auto& effect : effects_) { + auto* item = Gtk::make_managed(effect->title()); + item->add_css_class("flat"); + item->set_tooltip_text(effect->description()); + if (auto* child = dynamic_cast(item->get_child())) child->set_xalign(0.0F); + item->signal_clicked().connect([this, effect] { + addEffectButton_.popdown(); + appendStage(effect); + }); + list->append(*item); + } + + popover->set_child(*list); + return *popover; +} + +void EffectorWindow::appendStage(const EffectPtr& effect) { + stages_.push_back({effect, effect->defaults()}); + selectedStage_ = static_cast(stages_.size()) - 1; + rebuildChainRows(); buildParamWidgets(); requestRender(); } -void EffectorWindow::onParamChanged() { requestRender(); } +void EffectorWindow::removeStage(int index) { + if (index < 0 || static_cast(index) >= stages_.size()) return; + stages_.erase(stages_.begin() + index); + selectedStage_ = std::min(selectedStage_, static_cast(stages_.size()) - 1); + if (selectedStage_ < 0) selectedStage_ = 0; + rebuildChainRows(); + buildParamWidgets(); + + if (stages_.empty()) { + result_ = Image(); + saveButton_.set_sensitive(false); + previewStack_.set_visible_child("placeholder"); + placeholder_.set_text("Add an effect to start."); + status_.set_text(""); + return; + } + requestRender(); +} + +void EffectorWindow::moveStage(int index, int delta) { + const int target = index + delta; + if (index < 0 || static_cast(index) >= stages_.size()) return; + if (target < 0 || static_cast(target) >= stages_.size()) return; + std::swap(stages_[static_cast(index)], stages_[static_cast(target)]); + selectedStage_ = target; + rebuildChainRows(); + buildParamWidgets(); + requestRender(); +} + +void EffectorWindow::rebuildChainRows() { + // Rebuilding drops and re-adds the selection, which would otherwise look + // like the user picking a different stage. + suspendStageSelect_ = true; + // Only the rows: the placeholder is a child of the list box as well, and + // removing it would leave the empty list blank. + while (auto* row = chainList_.get_row_at_index(0)) chainList_.remove(*row); + + for (std::size_t i = 0; i < stages_.size(); ++i) { + const int index = static_cast(i); + auto* row = Gtk::make_managed(); + auto* box = Gtk::make_managed(Gtk::Orientation::HORIZONTAL, 4); + box->set_margin_top(4); + box->set_margin_bottom(4); + box->set_margin_start(8); + box->set_margin_end(4); + + auto* label = Gtk::make_managed(std::to_string(index + 1) + ". " + + stages_[i].effect->title()); + label->set_xalign(0.0F); + label->set_hexpand(true); + label->set_ellipsize(Pango::EllipsizeMode::END); + label->set_tooltip_text(index == 0 ? "Applied to the source images" + : "Applied to the output of the stage above"); + box->append(*label); + + struct Action { + const char* icon; + const char* tip; + int delta; // 0 means remove + }; + for (const Action& action : {Action{"go-up-symbolic", "Move up", -1}, + Action{"go-down-symbolic", "Move down", 1}, + Action{"window-close-symbolic", "Remove", 0}}) { + auto* button = Gtk::make_managed(); + button->set_icon_name(action.icon); + button->set_tooltip_text(action.tip); + button->add_css_class("flat"); + const int delta = action.delta; + button->signal_clicked().connect([this, index, delta] { + if (delta == 0) { + removeStage(index); + } else { + moveStage(index, delta); + } + }); + if (delta == -1) button->set_sensitive(index > 0); + if (delta == 1) button->set_sensitive(static_cast(index) + 1 < stages_.size()); + box->append(*button); + } + + row->set_child(*box); + chainList_.append(*row); + } + + selectStage(selectedStage_); + suspendStageSelect_ = false; +} + +void EffectorWindow::selectStage(int index) { + if (auto* row = chainList_.get_row_at_index(index)) chainList_.select_row(*row); +} + +void EffectorWindow::onStageSelected() { + if (suspendStageSelect_) return; + auto* row = chainList_.get_selected_row(); + if (row == nullptr) return; + if (row->get_index() == selectedStage_) return; + selectedStage_ = row->get_index(); + buildParamWidgets(); +} + +// ------------------------------------------------------------ rendering --- + +void EffectorWindow::onParamChanged() { + storeParams(); + requestRender(); +} void EffectorWindow::onSeedEntered() { try { @@ -622,7 +778,7 @@ void EffectorWindow::onRecreate() { } void EffectorWindow::requestRender() { - if (suspendRender_ || sources_->empty()) return; + if (suspendRender_ || sources_->empty() || stages_.empty()) return; // Spin buttons fire on every step while they are held down; coalesce those // into one render instead of queueing a job per tick. debounce_.disconnect(); @@ -635,7 +791,7 @@ void EffectorWindow::requestRender() { } void EffectorWindow::startRender() { - if (sources_->empty()) return; + if (sources_->empty() || stages_.empty()) return; if (rendering_) { staleResult_ = true; // a fresh render follows this one return; @@ -643,22 +799,21 @@ void EffectorWindow::startRender() { rendering_ = true; const auto sources = sources_; - const auto effect = effect_; + const auto stages = stages_; // a snapshot, like the sources const int width = static_cast(widthSpin_.get_value()); const int height = static_cast(heightSpin_.get_value()); const std::uint64_t seed = seed_; - const ParamValues values = paramValues(); spinner_.start(); recreateButton_.set_sensitive(false); - worker_ = std::thread([this, sources, effect, width, height, seed, values] { + worker_ = std::thread([this, sources, stages, width, height, seed] { Image image; std::string error; const auto start = std::chrono::steady_clock::now(); try { Rng rng(seed); - image = effect->render(*sources, width, height, rng, values); + image = renderChain(stages, *sources, width, height, rng); } catch (const std::exception& exception) { error = exception.what(); } @@ -714,7 +869,7 @@ void EffectorWindow::onRenderFinished() { std::ostringstream status; status.setf(std::ios::fixed); status.precision(1); - status << effect_->title() << " · " << result_.width() << "×" << result_.height() << " · " + status << chainTitle(stages_) << " · " << result_.width() << "×" << result_.height() << " · " << sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · seed " << seed_ << " · " << millis << " ms"; status_.set_text(status.str()); @@ -727,7 +882,7 @@ void EffectorWindow::onSave() { auto dialog = Gtk::FileDialog::create(); dialog->set_title("Save image"); - dialog->set_initial_name("effector-" + effect_->name() + "-" + std::to_string(seed_) + ".png"); + dialog->set_initial_name("effector-" + chainSlug(stages_) + "-" + std::to_string(seed_) + ".png"); dialog->save(*this, [this, dialog](const Glib::RefPtr& result) { onSaveChosen(result, dialog); }); diff --git a/src/gui/window.hpp b/src/gui/window.hpp index 72d267d..c34c979 100644 --- a/src/gui/window.hpp +++ b/src/gui/window.hpp @@ -12,6 +12,7 @@ #include #include "cli/options.hpp" +#include "core/chain.hpp" #include "core/effect.hpp" #include "core/image.hpp" @@ -28,6 +29,7 @@ private: Gtk::Widget& buildPreview(); void buildParamWidgets(); ParamValues paramValues() const; + void storeParams(); // widgets -> the selected stage's values void applyInitial(const Options& initial); void requestRender(); // coalesced: restarts the short debounce timer @@ -44,7 +46,14 @@ private: void appendSourceRow(const std::string& path); void removeSource(Gtk::ListBoxRow* row); - void onEffectChanged(); + Gtk::Widget& buildChainSection(); + Gtk::Widget& buildAddMenu(); // the menu of effects that can be appended + void appendStage(const EffectPtr& effect); + void removeStage(int index); + void moveStage(int index, int delta); + void rebuildChainRows(); + void selectStage(int index); + void onStageSelected(); void onParamChanged(); void onSeedEntered(); void onRecreate(); @@ -66,7 +75,11 @@ private: // Model std::shared_ptr sources_ = std::make_shared(); std::vector effects_; - EffectPtr effect_; + // The cascade: stage 0 works on the sources, every later stage on the + // output of the one before it. + std::vector stages_; + int selectedStage_ = 0; + bool suspendStageSelect_ = false; // guards the rebuild of the chain rows Image result_; std::uint64_t seed_ = 0; bool suspendRender_ = true; @@ -109,8 +122,10 @@ private: Gtk::SpinButton widthSpin_; Gtk::SpinButton heightSpin_; Gtk::Grid paramGrid_; + Gtk::Label paramHeading_; Gtk::Entry seedEntry_; - Gtk::DropDown effectDropdown_; + Gtk::ListBox chainList_; + Gtk::MenuButton addEffectButton_; Gtk::Button recreateButton_{"Recreate"}; Gtk::Button saveButton_{"Save As…"}; Gtk::Picture picture_; diff --git a/src/main.cpp b/src/main.cpp index 8f7af92..67defbc 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -8,6 +8,7 @@ #include #include "cli/options.hpp" +#include "core/chain.hpp" #include "core/effect.hpp" #include "core/image.hpp" #include "core/registry.hpp" @@ -17,7 +18,7 @@ namespace { int renderHeadless(const effector::Options& options) { - const auto effect = effector::effectByName(options.effect); + const auto stages = effector::buildChain(options); std::vector failures; @@ -47,17 +48,15 @@ int renderHeadless(const effector::Options& options) { const std::uint64_t seed = options.seed.value_or(effector::Rng::randomSeed()); effector::Rng rng(seed); - auto values = effect->defaults(); - for (const auto& [key, value] : options.params) values[key] = value; - const auto start = std::chrono::steady_clock::now(); - const auto image = effect->render(sources, options.width, options.height, rng, values); + const auto image = + effector::renderChain(stages, sources, options.width, options.height, rng); const auto renderMillis = std::chrono::duration(std::chrono::steady_clock::now() - start).count(); image.save(options.output); std::cout << options.output << " " << options.width << "x" << options.height - << " effect=" << effect->name() << " seed=" << seed << " render=" + << " effect=" << effector::chainSlug(stages) << " seed=" << seed << " render=" << std::fixed << std::setprecision(1) << renderMillis << " ms\n"; return 0; } diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index 3b0e38e..0c07cd1 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -9,6 +9,7 @@ #include #include +#include "core/chain.hpp" #include "core/registry.hpp" #include "core/rng.hpp" #include "effects/blur.hpp" @@ -200,6 +201,46 @@ int main() { check(threw, "no sources throws"); } + std::cout << "chain\n"; + { + std::vector sources = {gradient(1200, 800, 10), gradient(640, 480, 90)}; + + ChainStage scanlines{effectByName("scanlines"), {{"strip_height", 6L}}}; + ChainStage blur{effectByName("blur"), {{"radius", 6L}}}; + + Rng single(5); + const auto direct = renderChain({scanlines}, sources, 400, 300, single); + const auto alone = render(sources, 400, 300, 5, {{"strip_height", 6L}}); + check(sameBytes(direct, alone), "a one stage chain matches the effect on its own"); + + Rng a(5); + Rng b(5); + const auto cascaded = renderChain({scanlines, blur}, sources, 400, 300, a); + const auto again = renderChain({scanlines, blur}, sources, 400, 300, b); + check(cascaded.width() == 400 && cascaded.height() == 300, "the chain keeps the canvas size"); + check(sameBytes(cascaded, again), "the same seed reproduces the cascade"); + check(!sameBytes(cascaded, direct), "a second stage changes the image"); + + // Order matters: blurring scanlines is not the same as scanning a blur. + Rng forward(5); + Rng backward(5); + const auto blurLast = renderChain({scanlines, blur}, sources, 400, 300, forward); + const auto blurFirst = renderChain({blur, scanlines}, sources, 400, 300, backward); + check(!sameBytes(blurLast, blurFirst), "the stage order changes the image"); + + check(chainTitle({scanlines, blur}) == "Scanlines \u2192 Blur", "the chain has a readable title"); + check(chainSlug({scanlines, blur}) == "scanlines-blur", "the chain has a file name slug"); + + bool threw = false; + try { + Rng rng(1); + renderChain({}, sources, 100, 100, rng); + } catch (const EffectError&) { + threw = true; + } + check(threw, "an empty chain throws"); + } + std::cout << "image io\n"; { const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png"; From a82fba7886c3a0be3f2a9f999823bdcdab638271 Mon Sep 17 00:00:00 2001 From: Si Date: Fri, 18 Sep 2026 15:37:37 +0200 Subject: [PATCH 3/4] Save and open projects as JSON A project is the whole setup as a small text file: the source paths, the effect chain with its params, the canvas and the seed. Reading and writing are driven by the effects' ParamSpecs rather than by whatever sits in a stage's values, so a new effect is saveable without touching this code, a key an effect does not declare is never written or read back, and an old project keeps the defaults for params that did not exist when it was saved. Paths under the project's own folder are written relative, so a folder of images and their project can be moved whole. src/core/project.cpp carries its own small JSON reader instead of a dependency, and names the line when a hand-edited file is wrong. The interface gets a header-bar menu: New, Open, Save, Save As, with Ctrl+N/O/S. The title shows the project name. Opening a project replaces the sources, discarding a decode still in flight rather than mixing it into the new project. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 26 ++- CMakeLists.txt | 1 + src/core/project.cpp | 443 +++++++++++++++++++++++++++++++++++++++++++ src/core/project.hpp | 40 ++++ src/gui/window.cpp | 201 ++++++++++++++++++++ src/gui/window.hpp | 19 ++ tests/core_tests.cpp | 82 ++++++++ 7 files changed, 807 insertions(+), 5 deletions(-) create mode 100644 src/core/project.cpp create mode 100644 src/core/project.hpp diff --git a/CLAUDE.md b/CLAUDE.md index 2f2f7d9..7222430 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,11 +14,11 @@ Everything is warning-clean under `-Wall -Wextra -Wpedantic`; keep it that way. The test suite is a single hand-rolled binary (no framework), so there is no per-test filter: run `./build/tests/effector_tests` directly to see the section -headings (`rng`, `registry`, `scanlines`, `blur`, `chain`, `batch loading`) and the `ok`/`FAIL` -lines. To iterate on one area, comment out the other blocks in -`tests/core_tests.cpp` or add a new `std::cout << "section\n"` block — checks are -plain `check(condition, "description")` calls and `main()` returns the failure -count. +headings (`rng`, `registry`, `scanlines`, `blur`, `chain`, `project`, `image +io`, `batch loading`) and the `ok`/`FAIL` lines. To iterate on one area, comment +out the other blocks in `tests/core_tests.cpp` or add a new +`std::cout << "section\n"` block — checks are plain +`check(condition, "description")` calls and `main()` returns the failure count. Headless render (no display needed, the fastest end-to-end smoke test): @@ -69,6 +69,22 @@ CLI builds it from repeated `-e`: `-e scanlines --strip-height 6 -e blur (`src/cli/options.cpp`) hands each flag to the stage whose effect declares it — two effects must not declare the same param key. +### Projects are plain JSON + +`src/core/project.cpp` reads and writes the whole setup — source paths, the +chain with its params, canvas and seed — as a small JSON file, with its own +minimal reader rather than a JSON dependency. Writing is driven by the effects' +`ParamSpec`s, not by whatever happens to sit in a stage's `ParamValues`, so a +key an effect does not declare is never written and never read back: an old +project keeps the defaults for params that did not exist when it was saved. +Source paths under the project's own folder are written relative, so a folder +can be moved or shared whole. + +The interface opens and saves projects from the header-bar menu (New, Open, +Save, Save As, with the usual Ctrl+N/O/S accelerators); the window title shows +the project name. Opening one replaces the sources, so a decode still in flight +is discarded rather than mixed into the new project (`dropLoadedImages_`). + ### Two link targets, deliberately `effector_core` (static) holds image IO, params, effects, rng and links only diff --git a/CMakeLists.txt b/CMakeLists.txt index 75fd45e..57540ef 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -24,6 +24,7 @@ add_library(effector_core STATIC src/core/effect.cpp src/core/image.cpp src/core/params.cpp + src/core/project.cpp src/core/registry.cpp src/core/rng.cpp src/effects/blur.cpp diff --git a/src/core/project.cpp b/src/core/project.cpp new file mode 100644 index 0000000..d0de8c7 --- /dev/null +++ b/src/core/project.cpp @@ -0,0 +1,443 @@ +#include "core/project.hpp" + +#include +#include +#include +#include +#include +#include + +#include "core/effect.hpp" +#include "core/registry.hpp" + +namespace effector { +namespace { + +// A project file is small and its shape is fixed, so it carries its own +// reader rather than a JSON dependency: enough of the grammar to read back +// what projectToJson writes, and to give a line number when a hand-edited +// file is wrong. +struct Json { + enum class Kind { Null, Bool, Number, String, Array, Object }; + + Kind kind = Kind::Null; + bool boolean = false; + double number = 0.0; + bool integral = true; // written without a fraction or exponent + std::string text; + std::vector items; + std::vector> members; + + const Json* member(const std::string& key) const { + for (const auto& entry : members) { + if (entry.first == key) return &entry.second; + } + return nullptr; + } +}; + +class JsonParser { +public: + explicit JsonParser(const std::string& text) : text_(text) {} + + Json parse() { + skipSpace(); + Json value = parseValue(); + skipSpace(); + if (pos_ != text_.size()) fail("trailing text after the end of the document"); + return value; + } + +private: + [[noreturn]] void fail(const std::string& what) const { + int line = 1; + for (std::size_t i = 0; i < pos_ && i < text_.size(); ++i) { + if (text_[i] == '\n') ++line; + } + throw EffectError("line " + std::to_string(line) + ": " + what); + } + + char peek() const { return pos_ < text_.size() ? text_[pos_] : '\0'; } + + void skipSpace() { + while (pos_ < text_.size()) { + const char c = text_[pos_]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + ++pos_; + } else { + break; + } + } + } + + void expect(char c) { + if (peek() != c) fail(std::string("expected '") + c + "'"); + ++pos_; + } + + bool literal(const char* word) { + const std::size_t length = std::char_traits::length(word); + if (text_.compare(pos_, length, word) != 0) return false; + pos_ += length; + return true; + } + + Json parseValue() { + switch (peek()) { + case '{': return parseObject(); + case '[': return parseArray(); + case '"': { + Json value; + value.kind = Json::Kind::String; + value.text = parseString(); + return value; + } + default: break; + } + if (literal("true")) return boolean(true); + if (literal("false")) return boolean(false); + if (literal("null")) return Json(); + return parseNumber(); + } + + static Json boolean(bool state) { + Json value; + value.kind = Json::Kind::Bool; + value.boolean = state; + return value; + } + + Json parseObject() { + Json value; + value.kind = Json::Kind::Object; + expect('{'); + skipSpace(); + if (peek() == '}') { + ++pos_; + return value; + } + while (true) { + skipSpace(); + std::string key = parseString(); + skipSpace(); + expect(':'); + skipSpace(); + value.members.emplace_back(std::move(key), parseValue()); + skipSpace(); + if (peek() == ',') { + ++pos_; + continue; + } + expect('}'); + return value; + } + } + + Json parseArray() { + Json value; + value.kind = Json::Kind::Array; + expect('['); + skipSpace(); + if (peek() == ']') { + ++pos_; + return value; + } + while (true) { + skipSpace(); + value.items.push_back(parseValue()); + skipSpace(); + if (peek() == ',') { + ++pos_; + continue; + } + expect(']'); + return value; + } + } + + std::string parseString() { + expect('"'); + std::string out; + while (true) { + if (pos_ >= text_.size()) fail("unterminated string"); + const char c = text_[pos_++]; + if (c == '"') return out; + if (c != '\\') { + out.push_back(c); + continue; + } + if (pos_ >= text_.size()) fail("unterminated escape"); + switch (text_[pos_++]) { + case '"': out.push_back('"'); break; + case '\\': out.push_back('\\'); break; + case '/': out.push_back('/'); break; + case 'b': out.push_back('\b'); break; + case 'f': out.push_back('\f'); break; + case 'n': out.push_back('\n'); break; + case 'r': out.push_back('\r'); break; + case 't': out.push_back('\t'); break; + case 'u': { + // Only the basic plane, which is all a path or a choice + // name can need; surrogate pairs are passed through as is. + if (pos_ + 4 > text_.size()) fail("short \\u escape"); + const unsigned code = static_cast( + std::strtoul(text_.substr(pos_, 4).c_str(), nullptr, 16)); + pos_ += 4; + appendUtf8(out, code); + break; + } + default: fail("unknown escape"); + } + } + } + + static void appendUtf8(std::string& out, unsigned code) { + if (code < 0x80) { + out.push_back(static_cast(code)); + } else if (code < 0x800) { + out.push_back(static_cast(0xC0 | (code >> 6))); + out.push_back(static_cast(0x80 | (code & 0x3F))); + } else { + out.push_back(static_cast(0xE0 | (code >> 12))); + out.push_back(static_cast(0x80 | ((code >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (code & 0x3F))); + } + } + + Json parseNumber() { + const std::size_t start = pos_; + if (peek() == '-' || peek() == '+') ++pos_; + bool integral = true; + while (pos_ < text_.size()) { + const char c = text_[pos_]; + if (c >= '0' && c <= '9') { + ++pos_; + } else if (c == '.' || c == 'e' || c == 'E') { + integral = false; + ++pos_; + } else if (c == '-' || c == '+') { // an exponent's sign + ++pos_; + } else { + break; + } + } + if (pos_ == start) fail("expected a value"); + + Json value; + value.kind = Json::Kind::Number; + value.integral = integral; + try { + value.number = std::stod(text_.substr(start, pos_ - start)); + } catch (const std::exception&) { + fail("'" + text_.substr(start, pos_ - start) + "' is not a number"); + } + return value; + } + + const std::string& text_; + std::size_t pos_ = 0; +}; + +std::string quote(const std::string& text) { + std::string out = "\""; + for (const char c : text) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (static_cast(c) < 0x20) { + char buffer[7]; + std::snprintf(buffer, sizeof(buffer), "\\u%04x", + static_cast(static_cast(c))); + out += buffer; + } else { + out.push_back(c); + } + } + } + return out + "\""; +} + +std::string number(double value, int digits) { + std::ostringstream out; + out.setf(std::ios::fixed); + out.precision(digits); + out << value; + std::string text = out.str(); + // 5.00 reads better as 5.0 and 0.250 as 0.25. + while (text.size() > 1 && text.back() == '0' && text[text.size() - 2] != '.') text.pop_back(); + return text; +} + +// Relative when the image lives under the project folder, absolute otherwise: +// a folder holding the project and its images stays movable. +std::string relativeTo(const std::string& path, const std::string& directory) { + if (directory.empty()) return path; + std::error_code error; + const auto relative = std::filesystem::relative(path, directory, error); + if (error || relative.empty()) return path; + const auto text = relative.generic_string(); + return text.rfind("..", 0) == 0 ? path : text; +} + +std::string resolve(const std::string& path, const std::string& directory) { + std::filesystem::path file(path); + if (file.is_absolute() || directory.empty()) return path; + return (std::filesystem::path(directory) / file).lexically_normal().string(); +} + +} // namespace + +std::string projectToJson(const Project& project, const std::string& directory) { + std::ostringstream out; + out << "{\n"; + out << " \"effector_project\": 1,\n"; + out << " \"canvas\": {\"width\": " << project.width << ", \"height\": " << project.height + << "},\n"; + if (project.seed) out << " \"seed\": " << *project.seed << ",\n"; + + out << " \"sources\": ["; + for (std::size_t i = 0; i < project.sources.size(); ++i) { + out << (i == 0 ? "\n" : ",\n") << " " << quote(relativeTo(project.sources[i], directory)); + } + out << (project.sources.empty() ? "" : "\n ") << "],\n"; + + out << " \"chain\": ["; + for (std::size_t i = 0; i < project.stages.size(); ++i) { + const auto& stage = project.stages[i]; + if (!stage.effect) continue; + out << (i == 0 ? "\n" : ",\n"); + out << " {\n \"effect\": " << quote(stage.effect->name()) << ",\n"; + out << " \"params\": {"; + + const auto& specs = stage.effect->params(); + bool first = true; + for (const auto& spec : specs) { + out << (first ? "\n" : ",\n") << " " << quote(spec.key) << ": "; + first = false; + switch (spec.type) { + case ParamSpec::Type::Bool: + out << (paramBool(stage.values, spec.key, spec.defaultValue != 0.0) ? "true" + : "false"); + break; + case ParamSpec::Type::Int: + out << paramInt(stage.values, spec.key, static_cast(spec.defaultValue)); + break; + case ParamSpec::Type::Double: + out << number(paramDouble(stage.values, spec.key, spec.defaultValue), + spec.digits); + break; + case ParamSpec::Type::Choice: + out << quote(paramChoice(stage.values, spec.key, spec.defaultChoice)); + break; + } + } + out << (specs.empty() ? "}" : "\n }") << "\n }"; + } + out << (project.stages.empty() ? "" : "\n ") << "]\n"; + out << "}\n"; + return out.str(); +} + +Project projectFromJson(const std::string& text, const std::string& directory) { + JsonParser parser(text); + const Json root = parser.parse(); + if (root.kind != Json::Kind::Object) throw EffectError("a project must be a JSON object"); + + 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* height = canvas->member("height")) { + project.height = static_cast(height->number); + } + } + 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) { + project.seed = static_cast(seed->number); + } + } + + if (const auto* sources = root.member("sources")) { + for (const auto& item : sources->items) { + if (item.kind == Json::Kind::String) { + project.sources.push_back(resolve(item.text, directory)); + } + } + } + + const auto* chain = root.member("chain"); + if (chain == nullptr || chain->kind != Json::Kind::Array || chain->items.empty()) { + throw EffectError("the project has no effect chain"); + } + for (const auto& item : chain->items) { + const auto* name = item.member("effect"); + if (name == nullptr || name->kind != Json::Kind::String) { + throw EffectError("a chain stage has no effect name"); + } + + ChainStage stage; + stage.effect = effectByName(name->text); // throws on an unknown effect + stage.values = stage.effect->defaults(); + + // Driven by the specs, not by the file: a key the effect does not + // declare is dropped, and an old project simply keeps the defaults for + // params that did not exist when it was written. + if (const auto* params = item.member("params")) { + for (const auto& spec : stage.effect->params()) { + const auto* value = params->member(spec.key); + if (value == nullptr) continue; + switch (spec.type) { + case ParamSpec::Type::Bool: + if (value->kind == Json::Kind::Bool) stage.values[spec.key] = value->boolean; + break; + case ParamSpec::Type::Int: + if (value->kind == Json::Kind::Number) { + stage.values[spec.key] = static_cast(std::llround(value->number)); + } + break; + case ParamSpec::Type::Double: + if (value->kind == Json::Kind::Number) stage.values[spec.key] = value->number; + break; + case ParamSpec::Type::Choice: + for (const auto& choice : spec.choices) { + if (value->kind == Json::Kind::String && value->text == choice) { + stage.values[spec.key] = choice; + } + } + break; + } + } + } + project.stages.push_back(std::move(stage)); + } + + return project; +} + +void saveProject(const std::string& path, const Project& project) { + const auto directory = std::filesystem::path(path).parent_path().string(); + std::ofstream file(path, std::ios::binary | std::ios::trunc); + if (!file) throw EffectError("cannot write " + path); + file << projectToJson(project, directory); + if (!file) throw EffectError("could not write " + path); +} + +Project loadProject(const std::string& path) { + std::ifstream file(path, std::ios::binary); + if (!file) throw EffectError("cannot read " + path); + std::ostringstream buffer; + buffer << file.rdbuf(); + try { + return projectFromJson(buffer.str(), std::filesystem::path(path).parent_path().string()); + } catch (const EffectError& error) { + throw EffectError(std::filesystem::path(path).filename().string() + ": " + error.what()); + } +} + +} // namespace effector diff --git a/src/core/project.hpp b/src/core/project.hpp new file mode 100644 index 0000000..4f6bcdf --- /dev/null +++ b/src/core/project.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include + +#include "core/chain.hpp" +#include "core/params.hpp" + +namespace effector { + +// Everything needed to recreate a render: which images went in, the cascade +// with its settings, the canvas and the seed. Deliberately no pixels — a +// project is a small text file that points at the sources on disk. +struct Project { + std::vector sources; // absolute paths + std::vector stages; + int width = 1920; + int height = 1080; + std::optional seed; +}; + +// JSON, written for humans: two-space indent, one source path per line, and +// the params of a stage spelled with the same keys the effect declares. +// Source paths are written relative to `directory` when they live under it, so +// a project folder can be moved or shared whole. +std::string projectToJson(const Project& project, const std::string& directory = {}); + +// Throws EffectError on malformed JSON, an unknown effect or a missing chain. +// Relative source paths are resolved against `directory`; params the effect +// does not declare are dropped, and missing ones keep the effect's default. +Project projectFromJson(const std::string& text, const std::string& directory = {}); + +// Both throw EffectError: the file is the user's, so failures are reported +// rather than silently swallowed. +void saveProject(const std::string& path, const Project& project); +Project loadProject(const std::string& path); + +} // namespace effector diff --git a/src/gui/window.cpp b/src/gui/window.cpp index 56d3e7d..5e56157 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -6,6 +6,7 @@ #include #include "core/chain.hpp" +#include "core/project.hpp" #include "core/registry.hpp" #include "core/rng.hpp" @@ -16,6 +17,15 @@ const char* const kImagePatterns[] = {"*.png", "*.jpg", "*.jpeg", "*.webp", "* "*.tif", "*.tiff", "*.gif", "*.ppm", "*.PNG", "*.JPG", "*.JPEG", "*.WEBP"}; +Glib::RefPtr> projectFilters() { + auto filter = Gtk::FileFilter::create(); + filter->set_name("Effector projects"); + filter->add_pattern("*.json"); + auto filters = Gio::ListStore::create(); + filters->append(filter); + return filters; +} + Glib::RefPtr toTexture(const Image& image) { auto bytes = Glib::Bytes::create(image.data(), image.byteSize()); return Gdk::MemoryTexture::create(image.width(), image.height(), Gdk::MemoryTexture::Format::R8G8B8, @@ -64,6 +74,11 @@ void EffectorWindow::buildUi() { auto* header = Gtk::make_managed(); set_titlebar(*header); + menuButton_.set_icon_name("open-menu-symbolic"); + menuButton_.set_tooltip_text("Project menu"); + 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"); @@ -418,10 +433,19 @@ void EffectorWindow::onLoadFinished() { if (loadCancel_.load(std::memory_order_relaxed)) { // A cancelled batch is incomplete, so none of it is taken. loadQueue_.clear(); + dropLoadedImages_ = false; showError("Loading cancelled."); return; } + if (dropLoadedImages_) { + // A project was opened while this batch was decoding: its images belong + // to the previous source list, and the project's own are queued behind. + dropLoadedImages_ = false; + images.clear(); + failures.clear(); + } + for (const auto& image : images) appendSourceRow(image.path()); mutateSources([&images](SourceList& list) { for (auto& image : images) list.push_back(std::move(image)); @@ -875,6 +899,183 @@ void EffectorWindow::onRenderFinished() { status_.set_text(status.str()); } +// -------------------------------------------------------------- project --- + +// The project menu lives in the header bar, next to the buttons that act on +// the current render. +Gtk::Widget& EffectorWindow::buildMainMenu() { + add_action("new-project", sigc::mem_fun(*this, &EffectorWindow::onNewProject)); + add_action("open-project", sigc::mem_fun(*this, &EffectorWindow::onOpenProject)); + add_action("save-project", sigc::mem_fun(*this, &EffectorWindow::onSaveProject)); + add_action("save-project-as", sigc::mem_fun(*this, &EffectorWindow::onSaveProjectAs)); + + auto menu = Gio::Menu::create(); + auto open = Gio::Menu::create(); + open->append("New Project", "win.new-project"); + open->append("Open Project…", "win.open-project"); + menu->append_section(open); + auto save = Gio::Menu::create(); + save->append("Save Project", "win.save-project"); + save->append("Save Project As…", "win.save-project-as"); + menu->append_section(save); + + // The shortcuts hang off the window rather than the application, which the + // window does not belong to yet while it is being built. + auto shortcuts = Gtk::ShortcutController::create(); + shortcuts->set_scope(Gtk::ShortcutScope::GLOBAL); + const struct Accel { + const char* keys; + const char* action; + } accels[] = {{"n", "win.new-project"}, + {"o", "win.open-project"}, + {"s", "win.save-project"}, + {"s", "win.save-project-as"}}; + for (const Accel& accel : accels) { + shortcuts->add_shortcut(Gtk::Shortcut::create( + Gtk::ShortcutTrigger::parse_string(accel.keys), Gtk::NamedAction::create(accel.action))); + } + add_controller(shortcuts); + + return *Gtk::make_managed(menu); +} + +Project EffectorWindow::currentProject() const { + Project project; + for (const auto& image : *sources_) project.sources.push_back(image.path()); + project.stages = stages_; + project.width = static_cast(widthSpin_.get_value()); + project.height = static_cast(heightSpin_.get_value()); + project.seed = seed_; + return project; +} + +void EffectorWindow::applyProject(const Project& project, const std::string& path) { + // One render at the end rather than one per piece of the project. + suspendRender_ = true; + clearSources(); + + stages_ = project.stages; + selectedStage_ = 0; + rebuildChainRows(); + buildParamWidgets(); + + widthSpin_.set_value(project.width); + heightSpin_.set_value(project.height); + seed_ = project.seed.value_or(Rng::randomSeed()); + seedEntry_.set_text(std::to_string(seed_)); + + projectPath_ = path; + updateTitle(); + clearError(); + suspendRender_ = false; + + if (project.sources.empty()) { + placeholder_.set_text("Add source images to start."); + status_.set_text(""); + } else { + addPaths(project.sources); // renders once the images are decoded + } +} + +void EffectorWindow::clearSources() { + while (auto* row = sourceList_.get_row_at_index(0)) sourceList_.remove(*row); + mutateSources([](SourceList& list) { list.clear(); }); + loadQueue_.clear(); + if (loading_) dropLoadedImages_ = true; + + result_ = Image(); + saveButton_.set_sensitive(false); + previewStack_.set_visible_child("placeholder"); +} + +void EffectorWindow::onNewProject() { + Project fresh; + fresh.stages.push_back({effects_.front(), effects_.front()->defaults()}); + applyProject(fresh, ""); +} + +void EffectorWindow::onOpenProject() { + auto dialog = Gtk::FileDialog::create(); + dialog->set_title("Open project"); + dialog->set_filters(projectFilters()); + dialog->open(*this, [this, dialog](const Glib::RefPtr& result) { + onProjectChosen(result, dialog); + }); +} + +void EffectorWindow::onProjectChosen(const Glib::RefPtr& result, + const Glib::RefPtr& dialog) { + std::string path; + try { + const auto file = dialog->open_finish(result); + if (!file) return; + path = file->get_path(); + } catch (const Glib::Error&) { + return; // cancelled + } + + try { + applyProject(loadProject(path), path); + } catch (const std::exception& error) { + showError(std::string("Could not open the project: ") + error.what()); + } +} + +void EffectorWindow::onSaveProject() { + if (projectPath_.empty()) { + onSaveProjectAs(); + return; + } + writeProject(projectPath_); +} + +void EffectorWindow::onSaveProjectAs() { + auto dialog = Gtk::FileDialog::create(); + dialog->set_title("Save project"); + dialog->set_filters(projectFilters()); + dialog->set_initial_name(projectPath_.empty() + ? "effector-" + chainSlug(stages_) + ".json" + : std::filesystem::path(projectPath_).filename().string()); + dialog->save(*this, [this, dialog](const Glib::RefPtr& result) { + onSaveProjectChosen(result, dialog); + }); +} + +void EffectorWindow::onSaveProjectChosen(const Glib::RefPtr& result, + const Glib::RefPtr& dialog) { + std::filesystem::path path; + try { + const auto file = dialog->save_finish(result); + if (!file) return; + path = file->get_path(); + } catch (const Glib::Error&) { + return; // cancelled + } + if (path.extension().empty()) path.replace_extension(".json"); + writeProject(path.string()); +} + +void EffectorWindow::writeProject(const std::string& path) { + try { + saveProject(path, currentProject()); + } catch (const std::exception& error) { + showError(std::string("Could not save the project: ") + error.what()); + return; + } + clearError(); + projectPath_ = path; + updateTitle(); + status_.set_text("Saved " + path); +} + +void EffectorWindow::updateTitle() { + if (projectPath_.empty()) { + set_title("Effector"); + return; + } + set_title(std::filesystem::path(projectPath_).stem().string() + " — Effector"); +} + // --------------------------------------------------------------- saving --- void EffectorWindow::onSave() { diff --git a/src/gui/window.hpp b/src/gui/window.hpp index c34c979..ec0f653 100644 --- a/src/gui/window.hpp +++ b/src/gui/window.hpp @@ -15,6 +15,7 @@ #include "core/chain.hpp" #include "core/effect.hpp" #include "core/image.hpp" +#include "core/project.hpp" namespace effector { @@ -54,6 +55,21 @@ private: void rebuildChainRows(); void selectStage(int index); void onStageSelected(); + Gtk::Widget& buildMainMenu(); // the header menu: new, open, save project + Project currentProject() const; + void applyProject(const Project& project, const std::string& path); + void clearSources(); + void onNewProject(); + void onOpenProject(); + void onProjectChosen(const Glib::RefPtr& result, + const Glib::RefPtr& dialog); + void onSaveProject(); // falls back to Save As while the project is untitled + void onSaveProjectAs(); + void onSaveProjectChosen(const Glib::RefPtr& result, + const Glib::RefPtr& dialog); + void writeProject(const std::string& path); + void updateTitle(); + void onParamChanged(); void onSeedEntered(); void onRecreate(); @@ -82,6 +98,7 @@ private: bool suspendStageSelect_ = false; // guards the rebuild of the chain rows Image result_; std::uint64_t seed_ = 0; + std::string projectPath_; // empty until the project has been saved once bool suspendRender_ = true; // Source loading. Decoding a batch of large photographs takes seconds, so @@ -98,6 +115,7 @@ private: std::vector loadFailures_; std::vector loadQueue_; // chosen while another batch was loading bool loading_ = false; + bool dropLoadedImages_ = false; // the batch in flight belongs to a replaced project sigc::connection loadWindowDelay_; // Render job @@ -126,6 +144,7 @@ private: Gtk::Entry seedEntry_; Gtk::ListBox chainList_; Gtk::MenuButton addEffectButton_; + Gtk::MenuButton menuButton_; Gtk::Button recreateButton_{"Recreate"}; Gtk::Button saveButton_{"Save As…"}; Gtk::Picture picture_; diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index 0c07cd1..d9b47f3 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -4,12 +4,14 @@ #include #include #include +#include #include #include #include #include #include "core/chain.hpp" +#include "core/project.hpp" #include "core/registry.hpp" #include "core/rng.hpp" #include "effects/blur.hpp" @@ -241,6 +243,86 @@ int main() { check(threw, "an empty chain throws"); } + std::cout << "project\n"; + { + const auto dir = std::filesystem::temp_directory_path() / "effector_test_project"; + std::filesystem::create_directories(dir); + const auto source = dir / "source.png"; + gradient(64, 48, 7).save(source.string()); + + Project project; + project.sources = {source.string(), "/elsewhere/other.png"}; + project.stages.push_back({effectByName("scanlines"), {{"strip_height", 9L}}}); + project.stages.push_back( + {effectByName("blur"), {{"radius", 3L}, {"direction", std::string("vertical")}}}); + project.width = 640; + project.height = 400; + project.seed = 1234567890123ULL; + + const auto file = dir / "test.json"; + saveProject(file.string(), project); + const auto reloaded = loadProject(file.string()); + + check(reloaded.width == 640 && reloaded.height == 400, "the canvas survives a round trip"); + check(reloaded.seed && *reloaded.seed == 1234567890123ULL, "the seed survives a round trip"); + check(reloaded.stages.size() == 2 && reloaded.stages[0].effect->name() == "scanlines" && + reloaded.stages[1].effect->name() == "blur", + "the chain keeps its effects and their order"); + check(paramInt(reloaded.stages[0].values, "strip_height", 0) == 9 && + paramInt(reloaded.stages[1].values, "radius", 0) == 3 && + paramChoice(reloaded.stages[1].values, "direction", "") == "vertical", + "the stage params survive a round trip"); + check(paramInt(reloaded.stages[0].values, "jitter", -1) == + paramInt(effectByName("scanlines")->defaults(), "jitter", -2), + "params the file leaves out keep their default"); + + // A source next to the project is written relative, so the folder can + // be moved; anything outside it stays absolute. + std::ifstream stream(file.string(), std::ios::binary); + const std::string text((std::istreambuf_iterator(stream)), + std::istreambuf_iterator()); + check(text.find("\"source.png\"") != std::string::npos, "a nearby source is written relative"); + check(text.find("\"/elsewhere/other.png\"") != std::string::npos, + "a source elsewhere stays absolute"); + check(reloaded.sources.size() == 2 && reloaded.sources[0] == source.string(), + "a relative source is resolved against the project"); + + // The point of a project: the same picture comes back out of the file. + std::vector sources = {gradient(300, 200, 4)}; + Rng before(*project.seed); + Rng after(*reloaded.seed); + const auto original = renderChain(project.stages, sources, project.width, project.height, before); + const auto restored = + renderChain(reloaded.stages, sources, reloaded.width, reloaded.height, after); + check(sameBytes(original, restored), "a reloaded project renders the same image"); + + // Params the effects do not declare are dropped rather than carried. + const auto stripped = projectFromJson(projectToJson(reloaded)); + check(stripped.stages[0].values.find("radius") == stripped.stages[0].values.end(), + "a stage only keeps the params its effect declares"); + + 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"); + + std::filesystem::remove_all(dir); + } + std::cout << "image io\n"; { const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png"; From 4739d39a51c367e22f3587644c86f5c65801385d Mon Sep 17 00:00:00 2001 From: Si Date: Fri, 18 Sep 2026 15:49:16 +0200 Subject: [PATCH 4/4] Let effects declare whether they need source images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whether an effect works from the loaded photographs is now part of the declarative plug-in contract, next to params(): Effect::needsSources() defaults to true, and blur — which only softens what it is handed — says no and blurs an empty canvas when given nothing. Only the first stage of a cascade ever sees the sources, so chainNeedsSources() just asks the first stage. Both front-ends gate on that instead of on "are there any images": the CLI only refuses an empty batch when the chain wants one, and the interface renders a blur-led chain with no images loaded, saying whichever thing is actually missing. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 6 ++++ src/core/chain.cpp | 5 ++++ src/core/chain.hpp | 5 ++++ src/core/effect.hpp | 5 ++++ src/effects/blur.cpp | 18 ++++++------ src/effects/blur.hpp | 10 ++++--- src/gui/window.cpp | 70 +++++++++++++++++++++++++++----------------- src/gui/window.hpp | 3 ++ src/main.cpp | 2 +- tests/core_tests.cpp | 36 ++++++++++++++++++----- 10 files changed, 112 insertions(+), 48 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7222430..68143f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,12 @@ add a `ParamSpec` instead. Values travel as `ParamValues` (a `map>`), read back with `paramInt`/`paramDouble`/`paramBool`/`paramChoice`. +Whether an effect works from the loaded photographs is part of the same +contract: `Effect::needsSources()` (default true, false for blur). Only the +first stage ever sees the sources, so `chainNeedsSources` (`src/core/chain.cpp`) +just asks the first stage — a chain led by blur renders with no images loaded at +all, and both front-ends gate on that rather than on "are there any images". + Adding an effect: new `src/effects/.{hpp,cpp}` subclassing `Effect`, one line in the registry list in `src/core/registry.cpp`, one line in the `effector_core` sources in `CMakeLists.txt`. Nothing else — it shows up in the diff --git a/src/core/chain.cpp b/src/core/chain.cpp index 0225a2f..bb52ec8 100644 --- a/src/core/chain.cpp +++ b/src/core/chain.cpp @@ -26,6 +26,11 @@ Image renderChain(const std::vector& stages, const std::vector& stages) { + if (stages.empty() || !stages.front().effect) return false; + return stages.front().effect->needsSources(); +} + std::string chainTitle(const std::vector& stages) { std::string text; for (const auto& stage : stages) { diff --git a/src/core/chain.hpp b/src/core/chain.hpp index 028fedc..72d95ef 100644 --- a/src/core/chain.hpp +++ b/src/core/chain.hpp @@ -25,6 +25,11 @@ struct ChainStage { Image renderChain(const std::vector& stages, const std::vector& sources, int width, int height, Rng& rng); +// Whether the chain has to be given source images. Only the first stage ever +// sees them — every later one works on the stage before it — so this is simply +// whether the first effect declares that it needs them. +bool chainNeedsSources(const std::vector& stages); + // "Scanlines → Blur", for window titles and status lines. std::string chainTitle(const std::vector& stages); diff --git a/src/core/effect.hpp b/src/core/effect.hpp index b461b5f..af60c9a 100644 --- a/src/core/effect.hpp +++ b/src/core/effect.hpp @@ -27,6 +27,11 @@ public: virtual std::string description() const = 0; virtual const std::vector& params() const = 0; + // Whether the effect builds its image out of the loaded source images. + // Effects that only transform what they are handed (blur) say no, and a + // chain that starts with one of them needs no sources at all. + virtual bool needsSources() const { return true; } + virtual Image render(const std::vector& sources, int width, int height, Rng& rng, const ParamValues& values) const = 0; diff --git a/src/effects/blur.cpp b/src/effects/blur.cpp index 81b2e0b..e69f0ef 100644 --- a/src/effects/blur.cpp +++ b/src/effects/blur.cpp @@ -100,7 +100,6 @@ void blurVertical(const Image& src, Image& dst, int radius) { Image BlurEffect::render(const std::vector& sources, int width, int height, Rng& rng, const ParamValues& values) const { - if (sources.empty()) throw EffectError("No source images."); if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels."); const int radius = static_cast(std::max(1L, paramInt(values, "radius", 8))); @@ -108,15 +107,16 @@ Image BlurEffect::render(const std::vector& sources, int width, int heigh const std::string direction = paramChoice(values, "direction", "both"); const bool firstSource = paramBool(values, "first_source", false); - // Every random value is drawn here, before any threading, so that a seed - // always gives the same image. - const std::size_t index = firstSource ? 0 : 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))); - Image canvas(width, height); - fillFromSource(canvas, src, sx, sy); + if (!sources.empty()) { + // Every random value is drawn here, before any threading, so that a + // seed always gives the same image. + const std::size_t index = firstSource ? 0 : 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); + } Image scratch(width, height); for (int pass = 0; pass < passes; ++pass) { diff --git a/src/effects/blur.hpp b/src/effects/blur.hpp index 57cf3d2..1fdcc8f 100644 --- a/src/effects/blur.hpp +++ b/src/effects/blur.hpp @@ -4,17 +4,19 @@ namespace effector { -// Fills the canvas with one of the sources and softens it with a separable box -// blur, repeated a few times to approximate a gaussian. +// Softens its input with a separable box blur, repeated a few times to +// approximate a gaussian. It needs no source images of its own: given none it +// blurs an empty canvas, which is what makes it usable as a pure filter stage. class BlurEffect : public Effect { public: std::string name() const override { return "blur"; } std::string title() const override { return "Blur"; } std::string description() const override { - return "Fills the canvas from a randomly chosen source and softens it with a repeated " - "box blur, horizontally, vertically or both."; + return "Softens its input with a repeated box blur, horizontally, vertically or both; " + "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; diff --git a/src/gui/window.cpp b/src/gui/window.cpp index 5e56157..0c01216 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -57,7 +57,13 @@ EffectorWindow::EffectorWindow(const Options& initial) { applyInitial(initial); suspendRender_ = false; - if (!sources_->empty()) startRender(); + if (loading_) { + placeholder_.set_text(missingText()); + } else if (canRender()) { + startRender(); + } else { + showPlaceholder(); + } } EffectorWindow::~EffectorWindow() { @@ -569,15 +575,7 @@ void EffectorWindow::removeSource(Gtk::ListBoxRow* row) { } }); sourceList_.remove(*row); - - if (!sources_->empty()) { - requestRender(); - } else { - result_ = Image(); - saveButton_.set_sensitive(false); - previewStack_.set_visible_child("placeholder"); - status_.set_text(""); - } + requestRender(); } void EffectorWindow::onAddImages() { @@ -680,14 +678,6 @@ void EffectorWindow::removeStage(int index) { rebuildChainRows(); buildParamWidgets(); - if (stages_.empty()) { - result_ = Image(); - saveButton_.set_sensitive(false); - previewStack_.set_visible_child("placeholder"); - placeholder_.set_text("Add an effect to start."); - status_.set_text(""); - return; - } requestRender(); } @@ -794,15 +784,40 @@ void EffectorWindow::onSeedEntered() { void EffectorWindow::onRecreate() { seed_ = Rng::randomSeed(); seedEntry_.set_text(std::to_string(seed_)); - if (sources_->empty()) { - showError("Add at least one source image first."); + if (!canRender()) { + showError(missingText()); return; } requestRender(); } +// Whether the effect chain has everything it needs. Only the first stage is +// ever handed the sources, so an effect that does not want them (blur) renders +// happily with none loaded. +bool EffectorWindow::canRender() const { + if (stages_.empty()) return false; + return !sources_->empty() || !chainNeedsSources(stages_); +} + +std::string EffectorWindow::missingText() const { + if (stages_.empty()) return "Add an effect to start."; + return "Add source images to start."; +} + +void EffectorWindow::showPlaceholder() { + result_ = Image(); + saveButton_.set_sensitive(false); + placeholder_.set_text(missingText()); + previewStack_.set_visible_child("placeholder"); + status_.set_text(""); +} + void EffectorWindow::requestRender() { - if (suspendRender_ || sources_->empty() || stages_.empty()) return; + if (suspendRender_) return; + if (!canRender()) { + showPlaceholder(); + return; + } // Spin buttons fire on every step while they are held down; coalesce those // into one render instead of queueing a job per tick. debounce_.disconnect(); @@ -815,7 +830,7 @@ void EffectorWindow::requestRender() { } void EffectorWindow::startRender() { - if (sources_->empty() || stages_.empty()) return; + if (!canRender()) return; if (rendering_) { staleResult_ = true; // a fresh render follows this one return; @@ -893,9 +908,11 @@ void EffectorWindow::onRenderFinished() { std::ostringstream status; status.setf(std::ios::fixed); status.precision(1); - status << chainTitle(stages_) << " · " << result_.width() << "×" << result_.height() << " · " - << sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · seed " - << seed_ << " · " << millis << " ms"; + status << chainTitle(stages_) << " · " << result_.width() << "×" << result_.height() << " · "; + if (!sources_->empty()) { + status << sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · "; + } + status << "seed " << seed_ << " · " << millis << " ms"; status_.set_text(status.str()); } @@ -970,8 +987,7 @@ void EffectorWindow::applyProject(const Project& project, const std::string& pat suspendRender_ = false; if (project.sources.empty()) { - placeholder_.set_text("Add source images to start."); - status_.set_text(""); + requestRender(); // a chain that needs no sources still has a picture } else { addPaths(project.sources); // renders once the images are decoded } diff --git a/src/gui/window.hpp b/src/gui/window.hpp index ec0f653..5c70fa5 100644 --- a/src/gui/window.hpp +++ b/src/gui/window.hpp @@ -33,6 +33,9 @@ private: void storeParams(); // widgets -> the selected stage's values void applyInitial(const Options& initial); + bool canRender() const; // a chain, and sources if the chain needs them + std::string missingText() const; // what is still missing before a render + void showPlaceholder(); // drops the preview and says what is missing void requestRender(); // coalesced: restarts the short debounce timer void startRender(); // starts a worker, or marks the result stale void onAddImages(); diff --git a/src/main.cpp b/src/main.cpp index 67defbc..79fe47c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -40,7 +40,7 @@ int renderHeadless(const effector::Options& options) { for (const auto& failure : failures) { std::cerr << "effector: skipping " << failure.path << ": " << failure.message << "\n"; } - if (sources.empty()) { + if (sources.empty() && effector::chainNeedsSources(stages)) { std::cerr << "effector: no usable source images\n"; return 2; } diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index d9b47f3..dd1ce27 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -194,13 +194,12 @@ int main() { const auto pinnedAgain = renderBlur(sources, 300, 200, 6, {{"first_source", true}}); check(sameBytes(pinned, pinnedAgain), "first_source is reproducible"); - bool threw = false; - try { - renderBlur({}, 100, 100, 1, {}); - } catch (const EffectError&) { - threw = true; - } - check(threw, "no sources throws"); + // 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 << "chain\n"; @@ -241,6 +240,29 @@ int main() { threw = true; } check(threw, "an empty chain throws"); + + // Sources are an effect's own business: scanlines cuts strips out of + // them, blur only softens what it is handed and runs without any. + check(effectByName("scanlines")->needsSources(), "scanlines needs source images"); + check(!effectByName("blur")->needsSources(), "blur needs no source images"); + check(chainNeedsSources({scanlines, blur}), "a chain led by scanlines needs sources"); + check(!chainNeedsSources({blur, scanlines}), + "a chain led by blur needs none, later stages see the stage before them"); + check(!chainNeedsSources({}), "an empty chain needs no sources"); + + Rng bare(5); + const auto sourceless = renderChain({blur}, {}, 200, 150, bare); + check(sourceless.width() == 200 && sourceless.height() == 150, + "blur renders a canvas without sources"); + + bool scanlinesThrew = false; + try { + Rng rng(1); + renderChain({scanlines}, {}, 100, 100, rng); + } catch (const EffectError&) { + scanlinesThrew = true; + } + check(scanlinesThrew, "scanlines still refuses to run without sources"); } std::cout << "project\n";