From 89985cb98959570a764426c1df546cd1db68045b Mon Sep 17 00:00:00 2001 From: Si Date: Fri, 18 Sep 2026 15:21:29 +0200 Subject: [PATCH] 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";