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
+19 -2
View File
@@ -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/<name>.{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<ChainStage>` 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
+1
View File
@@ -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
+21 -4
View File
@@ -4,6 +4,7 @@
#include <cstring>
#include <sstream>
#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<ChainStage> buildChain(const Options& options) {
std::vector<ChainStage> 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
+7 -1
View File
@@ -5,13 +5,14 @@
#include <string>
#include <vector>
#include "core/chain.hpp"
#include "core/params.hpp"
namespace effector {
struct Options {
std::vector<std::string> images;
std::string effect;
std::vector<std::string> 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<ChainStage> buildChain(const Options& options);
} // namespace effector
+49
View File
@@ -0,0 +1,49 @@
#include "core/chain.hpp"
namespace effector {
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& 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<Image> previous = {std::move(image)};
image = stage.effect->render(previous, width, height, rng, values);
}
}
return image;
}
std::string chainTitle(const std::vector<ChainStage>& 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<ChainStage>& 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
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <string>
#include <vector>
#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<ChainStage>& stages, const std::vector<Image>& sources,
int width, int height, Rng& rng);
// "Scanlines → Blur", for window titles and status lines.
std::string chainTitle(const std::vector<ChainStage>& stages);
// "scanlines-blur", for file names.
std::string chainSlug(const std::vector<ChainStage>& stages);
} // namespace effector
+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;
}
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);
// 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) {
// `-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);
});
+18 -3
View File
@@ -12,6 +12,7 @@
#include <vector>
#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<const SourceList> sources_ = std::make_shared<SourceList>();
std::vector<EffectPtr> effects_;
EffectPtr effect_;
// The cascade: stage 0 works on the sources, every later stage on the
// output of the one before it.
std::vector<ChainStage> 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_;
+5 -6
View File
@@ -8,6 +8,7 @@
#include <mutex>
#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<effector::LoadFailure> 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<double, std::milli>(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;
}
+41
View File
@@ -9,6 +9,7 @@
#include <string>
#include <vector>
#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<Image> 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";