diff --git a/CLAUDE.md b/CLAUDE.md index ab47bf4..b35d896 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/src/cli/options.cpp b/src/cli/options.cpp index 582e2ec..24e952d 100644 --- a/src/cli/options.cpp +++ b/src/cli/options.cpp @@ -1,5 +1,6 @@ #include "cli/options.hpp" +#include #include #include #include @@ -89,6 +90,10 @@ Options parseOptions(int argc, char** argv) { std::to_string(kMinDpi) + "-" + std::to_string(kMaxDpi)); } options.dpi = static_cast(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 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; diff --git a/src/cli/options.hpp b/src/cli/options.hpp index e46e2f9..6cb4d2b 100644 --- a/src/cli/options.hpp +++ b/src/cli/options.hpp @@ -14,6 +14,10 @@ namespace effector { struct Options { std::vector images; std::vector 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 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 buildChain(const Options& options); } // namespace effector diff --git a/src/core/chain.cpp b/src/core/chain.cpp index bb52ec8..d7a5051 100644 --- a/src/core/chain.cpp +++ b/src/core/chain.cpp @@ -2,14 +2,31 @@ namespace effector { +std::vector enabledStages(const std::vector& stages) { + std::vector 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& stages, const std::vector& 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& stages, const std::vector& 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& 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& 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; } diff --git a/src/core/chain.hpp b/src/core/chain.hpp index 420470b..2b712d6 100644 --- a/src/core/chain.hpp +++ b/src/core/chain.hpp @@ -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 enabledStages(const std::vector& 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& stages, const std::vector& 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& 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& stages); // "scanlines-blur", for file names. diff --git a/src/core/project.cpp b/src/core/project.cpp index 1d86f1e..1a2eb3a 100644 --- a/src/core/project.cpp +++ b/src/core/project.cpp @@ -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 diff --git a/src/gui/window.cpp b/src/gui/window.cpp index 355413d..d90e4cf 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -366,8 +366,10 @@ void EffectorWindow::buildParamWidgets() { return; } const auto& stage = stages_[static_cast(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(running.front() - stages_.data()); + for (std::size_t i = 0; i < stages_.size(); ++i) { const int index = static_cast(i); auto* row = Gtk::make_managed(); @@ -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(); + 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(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(index) >= stages_.size()) return; + auto& stage = stages_[static_cast(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."; } diff --git a/src/gui/window.hpp b/src/gui/window.hpp index 1a5cee9..1ee4a0a 100644 --- a/src/gui/window.hpp +++ b/src/gui/window.hpp @@ -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(); diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index 0872dac..adee19c 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -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"}) {