Let a chain stage be switched off without taking it apart

Trying a stage out meant removing it and building it again from scratch,
settings and all, to see what the picture looked like without it. A stage now
carries an `enabled` flag and can simply be switched off: it keeps its place
in the chain and everything it was set to.

`enabledStages` is the one place that reads the flag, and renderChain,
chainNeedsSources, chainTitle and chainSlug all go through it. A disabled
stage is skipped whole — handed no image and drawing nothing from the shared
rng — so switching one off renders byte for byte what the chain rendered
before that stage was added, which is the only thing that makes the switch
worth anything while comparing. Whichever stage runs first is the one handed
the sources, so disabling a leading scanlines lets blur lead and the chain
needs no images at all. The status line and the generated file name follow
too: they name what was rendered, not what sits in the sidebar. A chain with
every stage disabled throws rather than quietly rendering nothing.

In the interface it is a check on the chain row: the row dims, the params
heading says "(off)" and the settings stay editable — a stage being worked on
is not the same as one being deleted. On the command line it is
`--disable NAME`, which picks its stage by effect name exactly as the params
do. A project writes `"enabled": false` only for a stage that is off, so
existing project files are unchanged and an ordinary one stays uncluttered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-19 21:11:17 +02:00
co-authored by Claude Opus 5
parent f61161aef0
commit eb417bb41a
9 changed files with 185 additions and 28 deletions
+15 -4
View File
@@ -73,10 +73,20 @@ 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.
A stage carries a `ChainStage::enabled` flag, and `enabledStages`
(`src/core/chain.cpp`) is the one place that reads it: a disabled stage is
skipped whole — handed no image and drawing nothing from the shared rng — so
switching one off renders exactly what the chain rendered before it was added.
`renderChain`, `chainNeedsSources`, `chainTitle` and `chainSlug` all go through
it, which is why the status line and the file name never mention a stage that
did not run. A chain with every stage disabled throws rather than rendering
nothing.
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`
menu, reorder, remove, switch off with the check on the row, 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`, with `--disable NAME`
leaving a stage in place but skipping it. 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.
@@ -94,7 +104,8 @@ pixels, so a project saved as A4 reopens as A4.
### Projects are plain JSON
`src/core/project.cpp` reads and writes the whole setup — source paths, the
chain with its params, canvas and seed — as a small JSON file, with its own
chain with its params (and its `"enabled": false`, written only for a stage
that is switched off), canvas and seed — as a small JSON file, with its own
minimal reader rather than a JSON dependency. Writing is driven by the effects'
`ParamSpec`s, not by whatever happens to sit in a stage's `ParamValues`, so a
key an effect does not declare is never written and never read back: an old
+8
View File
@@ -1,5 +1,6 @@
#include "cli/options.hpp"
#include <algorithm>
#include <charconv>
#include <cstdint>
#include <cstring>
@@ -89,6 +90,10 @@ Options parseOptions(int argc, char** argv) {
std::to_string(kMinDpi) + "-" + std::to_string(kMaxDpi));
}
options.dpi = static_cast<int>(dpi);
} else if (arg == "--disable") {
const std::string name = requireValue(argc, argv, i, arg);
effectByName(name); // validate now, exactly as -e does
options.disabled.push_back(name);
} else if (arg == "--seed") {
const std::string value = requireValue(argc, argv, i, arg);
long seed = 0;
@@ -176,6 +181,7 @@ std::string usage(const char* programName) {
<< " --format NAME canvas format, overrides --size (see below)\n"
<< " --dpi N resolution of a print format, " << kMinDpi << "-" << kMaxDpi
<< " (default: " << kDefaultDpi << ")\n"
<< " --disable NAME keep this effect in the chain but skip it\n"
<< " --seed N random seed (default: random)\n"
<< " --list-effects list available effects and exit\n"
<< " -h, --help show this help and exit\n";
@@ -207,6 +213,8 @@ std::vector<ChainStage> buildChain(const Options& options) {
ChainStage stage;
stage.effect = effectByName(name);
stage.values = stage.effect->defaults();
stage.enabled = std::find(options.disabled.begin(), options.disabled.end(), name) ==
options.disabled.end();
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;
+6 -1
View File
@@ -14,6 +14,10 @@ namespace effector {
struct Options {
std::vector<std::string> images;
std::vector<std::string> effects; // one per cascade stage, in order
// The effects named by --disable: their stages stay in the chain, in
// place, but are skipped. Named rather than numbered, exactly as the
// params are, so two stages of one effect are disabled together.
std::vector<std::string> disabled;
std::string output; // empty -> launch the interface
int width = 1920;
int height = 1080;
@@ -33,7 +37,8 @@ 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`.
// together for `-e scanlines -e blur`. A stage named by --disable is built
// with all its settings and marked to be skipped.
std::vector<ChainStage> buildChain(const Options& options);
} // namespace effector
+27 -11
View File
@@ -2,14 +2,31 @@
namespace effector {
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages) {
std::vector<const ChainStage*> running;
running.reserve(stages.size());
for (const auto& stage : stages) {
if (stage.enabled && stage.effect) running.push_back(&stage);
}
return running;
}
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.");
for (const auto& stage : stages) {
if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
}
// A disabled stage never reaches the render at all: it is not handed an
// image and draws nothing from the shared rng, so switching one off gives
// back exactly the picture the chain made before it was added.
const auto running = enabledStages(stages);
if (running.empty()) throw EffectError("Every effect in the chain is disabled.");
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.");
for (std::size_t i = 0; i < running.size(); ++i) {
const auto& stage = *running[i];
auto values = stage.effect->defaults();
for (const auto& [key, value] : stage.values) values[key] = value;
@@ -27,26 +44,25 @@ Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image
}
bool chainNeedsSources(const std::vector<ChainStage>& stages) {
if (stages.empty() || !stages.front().effect) return false;
return stages.front().effect->needsSources();
const auto running = enabledStages(stages);
if (running.empty()) return false;
return running.front()->effect->needsSources();
}
std::string chainTitle(const std::vector<ChainStage>& stages) {
std::string text;
for (const auto& stage : stages) {
if (!stage.effect) continue;
for (const auto* stage : enabledStages(stages)) {
if (!text.empty()) text += " → ";
text += stage.effect->title();
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;
for (const auto* stage : enabledStages(stages)) {
if (!text.empty()) text += "-";
text += stage.effect->name();
text += stage->effect->name();
}
return text;
}
+19 -8
View File
@@ -21,23 +21,34 @@ inline constexpr int kMaxCanvasSide = 20000;
struct ChainStage {
EffectPtr effect;
ParamValues values; // missing keys fall back to the effect's defaults
// A disabled stage is skipped whole, keeping its settings and its place in
// the chain, so a stage can be switched off and back on while trying
// things out without being taken apart and rebuilt.
bool enabled = true;
};
// 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.
// The stages that actually run, in order, as pointers into `stages`. A
// disabled stage draws nothing from the rng and is handed no image, so a chain
// renders exactly as it would if that stage had never been added -- this is
// the one place the rest of the code asks what "disabled" means.
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages);
// Renders the enabled 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.
// reproduces the cascade byte for byte. Throws when no stage is left to run.
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
int width, int height, Rng& rng);
// Whether the chain has to be given source images. Only the first stage ever
// sees them — every later one works on the stage before it — so this is simply
// whether the first effect declares that it needs them.
// Whether the chain has to be given source images. Only the first stage that
// runs ever sees them — every later one works on the stage before it — so this
// is simply whether that effect declares that it needs them.
bool chainNeedsSources(const std::vector<ChainStage>& stages);
// "Scanlines → Blur", for window titles and status lines.
// "Scanlines → Blur", for window titles and status lines. Both of these name
// what was rendered, so a disabled stage is left out of them.
std::string chainTitle(const std::vector<ChainStage>& stages);
// "scanlines-blur", for file names.
+8
View File
@@ -359,6 +359,10 @@ std::string projectToJson(const Project& project, const std::string& directory)
if (!stage.effect) continue;
out << (i == 0 ? "\n" : ",\n");
out << " {\n \"effect\": " << quote(stage.effect->name()) << ",\n";
// Only written when it is false: a stage is enabled unless the file
// says otherwise, so an ordinary project stays as uncluttered as it
// was before a stage could be switched off at all.
if (!stage.enabled) out << " \"enabled\": false,\n";
out << " \"params\": {";
const auto& specs = stage.effect->params();
@@ -466,6 +470,10 @@ Project projectFromJson(const std::string& text, const std::string& directory) {
ChainStage stage;
stage.effect = effectByName(name->text); // throws on an unknown effect
stage.values = stage.effect->defaults();
if (const auto* enabled = item.member("enabled");
enabled != nullptr && enabled->kind == Json::Kind::Bool) {
stage.enabled = enabled->boolean;
}
// Driven by the specs, not by the file: a key the effect does not
// declare is dropped, and an old project simply keeps the defaults for
+41 -4
View File
@@ -366,8 +366,10 @@ void EffectorWindow::buildParamWidgets() {
return;
}
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
// The settings of a stage that is switched off stay editable: it is still
// being worked on, it just is not in the picture for the moment.
paramHeading_.set_text(std::to_string(selectedStage_ + 1) + ". " + stage.effect->title() +
" settings");
(stage.enabled ? " settings" : " settings (off)"));
int row = 0;
for (const auto& spec : stage.effect->params()) {
@@ -913,6 +915,12 @@ void EffectorWindow::rebuildChainRows() {
// removing it would leave the empty list blank.
while (auto* row = chainList_.get_row_at_index(0)) chainList_.remove(*row);
// The first stage that actually runs is the one handed the sources, which
// is not stage 1 once stage 1 is switched off.
const auto running = enabledStages(stages_);
const int firstRunning =
running.empty() ? -1 : static_cast<int>(running.front() - stages_.data());
for (std::size_t i = 0; i < stages_.size(); ++i) {
const int index = static_cast<int>(i);
auto* row = Gtk::make_managed<Gtk::ListBoxRow>();
@@ -922,13 +930,28 @@ void EffectorWindow::rebuildChainRows() {
box->set_margin_start(8);
box->set_margin_end(4);
// The check is what switches a stage off: it keeps its settings and
// its place in the chain, and the render simply skips it.
auto* check = Gtk::make_managed<Gtk::CheckButton>();
check->set_active(stages_[i].enabled);
check->set_tooltip_text("Apply this effect");
check->set_valign(Gtk::Align::CENTER);
// Connected after set_active, so filling in the rebuilt rows does not
// look like the user reaching for the check.
check->signal_toggled().connect([this, index, check] {
setStageEnabled(index, check->get_active());
});
box->append(*check);
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");
if (!stages_[i].enabled) label->add_css_class("dim-label");
label->set_tooltip_text(!stages_[i].enabled ? "Skipped while it is switched off"
: index == firstRunning ? "Applied to the source images"
: "Applied to the output so far");
box->append(*label);
struct Action {
@@ -965,6 +988,19 @@ void EffectorWindow::rebuildChainRows() {
updateSourcesVisible();
}
// Switching a stage off leaves everything about it alone -- its settings, its
// place in the chain, the stage the params below are showing -- so the only
// visible change is the dimmed row and the picture the render comes back with.
void EffectorWindow::setStageEnabled(int index, bool enabled) {
if (index < 0 || static_cast<std::size_t>(index) >= stages_.size()) return;
auto& stage = stages_[static_cast<std::size_t>(index)];
if (stage.enabled == enabled) return;
stage.enabled = enabled;
rebuildChainRows(); // the row dims, and another stage may now lead the chain
if (index == selectedStage_) buildParamWidgets(); // the heading says "off"
requestRender();
}
void EffectorWindow::selectStage(int index) {
if (auto* row = chainList_.get_row_at_index(index)) chainList_.select_row(*row);
}
@@ -1039,12 +1075,13 @@ void EffectorWindow::onRecreate() {
// ever handed the sources, so an effect that does not want them (blur) renders
// happily with none loaded.
bool EffectorWindow::canRender() const {
if (stages_.empty()) return false;
if (enabledStages(stages_).empty()) return false;
return !sources_->empty() || !chainNeedsSources(stages_);
}
std::string EffectorWindow::missingText() const {
if (stages_.empty()) return "Add an effect to start.";
if (enabledStages(stages_).empty()) return "Every effect is switched off.";
return "Add source images to start.";
}
+2
View File
@@ -66,6 +66,8 @@ private:
void appendStage(const EffectPtr& effect);
void removeStage(int index);
void moveStage(int index, int delta);
// The check on a chain row: the stage stays where it is, and is skipped.
void setStageEnabled(int index, bool enabled);
void rebuildChainRows();
void selectStage(int index);
void onStageSelected();
+59
View File
@@ -889,6 +889,50 @@ int main() {
scanlinesThrew = true;
}
check(scanlinesThrew, "scanlines still refuses to run without sources");
// A stage switched off is skipped whole: it is handed no image and
// draws nothing from the shared rng, so the cascade comes out exactly
// as it would have without that stage in it at all.
ChainStage blurOff = blur;
blurOff.enabled = false;
ChainStage scanlinesOff = scanlines;
scanlinesOff.enabled = false;
Rng skipping(5);
const auto skipped = renderChain({scanlines, blurOff}, sources, 400, 300, skipping);
check(sameBytes(skipped, direct), "a disabled stage is skipped, its rng draws with it");
check(chainTitle({scanlines, blurOff}) == "Scanlines", "the title names only what ran");
check(chainSlug({scanlines, blurOff}) == "scanlines", "and so does the slug");
// Whoever leads the chain is handed the sources, and that is the first
// stage still switched on.
check(!chainNeedsSources({scanlinesOff, blur}),
"a disabled leading stage leaves the question to the stage behind it");
Rng leading(5);
const auto ledByBlur = renderChain({scanlinesOff, blur}, {}, 200, 150, leading);
check(sameBytes(ledByBlur, sourceless), "the stage behind it leads instead");
bool allOffThrew = false;
try {
Rng rng(1);
renderChain({scanlinesOff, blurOff}, sources, 100, 100, rng);
} catch (const EffectError&) {
allOffThrew = true;
}
check(allOffThrew, "a chain with every stage disabled throws");
// The command line switches a stage off by effect name, the same way
// the params pick out the stage they belong to.
const auto cli =
parsedOptions({"-e", "scanlines", "-e", "blur", "--disable", "blur", "--radius", "4"});
check(cli.has_value(), "--disable is accepted");
const auto cliStages = buildChain(*cli);
check(cliStages.size() == 2 && cliStages[0].enabled && !cliStages[1].enabled,
"--disable keeps the stage in the chain and switches it off");
check(paramInt(cliStages[1].values, "radius", 0) == 4,
"a switched-off stage still takes its flags");
check(!parsedOptions({"-e", "blur", "--disable", "nope"}).has_value(),
"--disable refuses an unknown effect");
}
std::cout << "project\n";
@@ -949,6 +993,21 @@ int main() {
check(stripped.stages[0].values.find("radius") == stripped.stages[0].values.end(),
"a stage only keeps the params its effect declares");
// A stage switched off keeps its place and its settings in the file and
// comes back switched off; nothing is written for the ordinary case.
Project toggled = project;
toggled.stages[1].enabled = false;
const auto offFile = dir / "disabled.json";
saveProject(offFile.string(), toggled);
const auto offBack = loadProject(offFile.string());
check(offBack.stages.size() == 2 && offBack.stages[0].enabled && !offBack.stages[1].enabled,
"a switched-off stage comes back switched off");
check(paramInt(offBack.stages[1].values, "radius", 0) == 3,
"and with the settings it was switched off with");
check(projectToJson(toggled).find("\"enabled\"") != std::string::npos &&
projectToJson(project).find("\"enabled\"") == std::string::npos,
"only a switched-off stage is written out as one");
int refusals = 0;
for (const char* bad : {"{\"chain\": [{\"effect\": \"nope\"}]}", "{\"chain\": []}",
"{\"chain\": [{\"effect\": \"blur\"},]}", "not json at all"}) {