Cascade effects through a chain

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 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-18 15:21:29 +02:00
co-authored by Claude Opus 5
parent ee4b1038ac
commit 89985cb989
10 changed files with 409 additions and 75 deletions
+214 -59
View File
@@ -1,9 +1,11 @@
#include "gui/window.hpp"
#include <algorithm>
#include <chrono>
#include <filesystem>
#include <sstream>
#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<Glib::ustring> 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::Box>(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<std::size_t>(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<Gtk::Label>(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<Gtk::Switch>();
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<double>(paramInt(stage.values, spec.key,
static_cast<long>(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<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
auto dropdown = std::make_unique<Gtk::DropDown>();
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<std::size_t>(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<std::size_t>(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<Gtk::Switch*>(entry.widget)
->set_active(paramBool(initial.params, entry.spec.key, false));
break;
case ParamSpec::Type::Int:
static_cast<Gtk::SpinButton*>(entry.widget)
->set_value(static_cast<double>(paramInt(initial.params, entry.spec.key, 0)));
break;
case ParamSpec::Type::Double:
static_cast<Gtk::SpinButton*>(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<Gtk::DropDown*>(entry.widget)->set_selected(i);
}
}
break;
}
}
}
if (!initial.images.empty()) addPaths(initial.images);
}
@@ -589,17 +586,176 @@ void EffectorWindow::onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& 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::Box>(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<Gtk::Label>("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::Box>(Gtk::Orientation::HORIZONTAL, 6);
addBox->append(*Gtk::make_managed<Gtk::Image>(
Gio::ThemedIcon::create("list-add-symbolic")));
addBox->append(*Gtk::make_managed<Gtk::Label>("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<Gtk::Popover&>(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<Gtk::Popover>();
auto* list = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 2);
for (const auto& effect : effects_) {
auto* item = Gtk::make_managed<Gtk::Button>(effect->title());
item->add_css_class("flat");
item->set_tooltip_text(effect->description());
if (auto* child = dynamic_cast<Gtk::Label*>(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<int>(stages_.size()) - 1;
rebuildChainRows();
buildParamWidgets();
requestRender();
}
void EffectorWindow::onParamChanged() { requestRender(); }
void EffectorWindow::removeStage(int index) {
if (index < 0 || static_cast<std::size_t>(index) >= stages_.size()) return;
stages_.erase(stages_.begin() + index);
selectedStage_ = std::min(selectedStage_, static_cast<int>(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<std::size_t>(index) >= stages_.size()) return;
if (target < 0 || static_cast<std::size_t>(target) >= stages_.size()) return;
std::swap(stages_[static_cast<std::size_t>(index)], stages_[static_cast<std::size_t>(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<int>(i);
auto* row = Gtk::make_managed<Gtk::ListBoxRow>();
auto* box = Gtk::make_managed<Gtk::Box>(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<Gtk::Label>(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<Gtk::Button>();
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<std::size_t>(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<int>(widthSpin_.get_value());
const int height = static_cast<int>(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<Gio::AsyncResult>& result) {
onSaveChosen(result, dialog);
});