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";