diff --git a/CLAUDE.md b/CLAUDE.md index 0a40bb8..68143f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,11 +14,11 @@ 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` -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 -count. +headings (`rng`, `registry`, `scanlines`, `blur`, `chain`, `project`, `image +io`, `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 count. Headless render (no display needed, the fastest end-to-end smoke test): @@ -48,9 +48,48 @@ add a `ParamSpec` instead. Values travel as `ParamValues` (a `map>`), read back with `paramInt`/`paramDouble`/`paramBool`/`paramChoice`. +Whether an effect works from the loaded photographs is part of the same +contract: `Effect::needsSources()` (default true, false for blur). Only the +first stage ever sees the sources, so `chainNeedsSources` (`src/core/chain.cpp`) +just asks the first stage — a chain led by blur renders with no images loaded at +all, and both front-ends gate on that rather than on "are there any images". + 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. + +### 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 +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 +project keeps the defaults for params that did not exist when it was saved. +Source paths under the project's own folder are written relative, so a folder +can be moved or shared whole. + +The interface opens and saves projects from the header-bar menu (New, Open, +Save, Save As, with the usual Ctrl+N/O/S accelerators); the window title shows +the project name. Opening one replaces the sources, so a decode still in flight +is discarded rather than mixed into the new project (`dropLoadedImages_`). ### Two link targets, deliberately diff --git a/CMakeLists.txt b/CMakeLists.txt index 0139194..57540ef 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,11 +20,14 @@ 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 + src/core/project.cpp src/core/registry.cpp src/core/rng.cpp + src/effects/blur.cpp src/effects/scanlines.cpp ) target_include_directories(effector_core PUBLIC src) 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..bb52ec8 --- /dev/null +++ b/src/core/chain.cpp @@ -0,0 +1,54 @@ +#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; +} + +bool chainNeedsSources(const std::vector& stages) { + if (stages.empty() || !stages.front().effect) return false; + return stages.front().effect->needsSources(); +} + +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..72d95ef --- /dev/null +++ b/src/core/chain.hpp @@ -0,0 +1,39 @@ +#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); + +// 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. +bool chainNeedsSources(const std::vector& stages); + +// "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/core/effect.hpp b/src/core/effect.hpp index b461b5f..af60c9a 100644 --- a/src/core/effect.hpp +++ b/src/core/effect.hpp @@ -27,6 +27,11 @@ public: virtual std::string description() const = 0; virtual const std::vector& params() const = 0; + // Whether the effect builds its image out of the loaded source images. + // Effects that only transform what they are handed (blur) say no, and a + // chain that starts with one of them needs no sources at all. + virtual bool needsSources() const { return true; } + virtual Image render(const std::vector& sources, int width, int height, Rng& rng, const ParamValues& values) const = 0; diff --git a/src/core/project.cpp b/src/core/project.cpp new file mode 100644 index 0000000..d0de8c7 --- /dev/null +++ b/src/core/project.cpp @@ -0,0 +1,443 @@ +#include "core/project.hpp" + +#include +#include +#include +#include +#include +#include + +#include "core/effect.hpp" +#include "core/registry.hpp" + +namespace effector { +namespace { + +// A project file is small and its shape is fixed, so it carries its own +// reader rather than a JSON dependency: enough of the grammar to read back +// what projectToJson writes, and to give a line number when a hand-edited +// file is wrong. +struct Json { + enum class Kind { Null, Bool, Number, String, Array, Object }; + + Kind kind = Kind::Null; + bool boolean = false; + double number = 0.0; + bool integral = true; // written without a fraction or exponent + std::string text; + std::vector items; + std::vector> members; + + const Json* member(const std::string& key) const { + for (const auto& entry : members) { + if (entry.first == key) return &entry.second; + } + return nullptr; + } +}; + +class JsonParser { +public: + explicit JsonParser(const std::string& text) : text_(text) {} + + Json parse() { + skipSpace(); + Json value = parseValue(); + skipSpace(); + if (pos_ != text_.size()) fail("trailing text after the end of the document"); + return value; + } + +private: + [[noreturn]] void fail(const std::string& what) const { + int line = 1; + for (std::size_t i = 0; i < pos_ && i < text_.size(); ++i) { + if (text_[i] == '\n') ++line; + } + throw EffectError("line " + std::to_string(line) + ": " + what); + } + + char peek() const { return pos_ < text_.size() ? text_[pos_] : '\0'; } + + void skipSpace() { + while (pos_ < text_.size()) { + const char c = text_[pos_]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + ++pos_; + } else { + break; + } + } + } + + void expect(char c) { + if (peek() != c) fail(std::string("expected '") + c + "'"); + ++pos_; + } + + bool literal(const char* word) { + const std::size_t length = std::char_traits::length(word); + if (text_.compare(pos_, length, word) != 0) return false; + pos_ += length; + return true; + } + + Json parseValue() { + switch (peek()) { + case '{': return parseObject(); + case '[': return parseArray(); + case '"': { + Json value; + value.kind = Json::Kind::String; + value.text = parseString(); + return value; + } + default: break; + } + if (literal("true")) return boolean(true); + if (literal("false")) return boolean(false); + if (literal("null")) return Json(); + return parseNumber(); + } + + static Json boolean(bool state) { + Json value; + value.kind = Json::Kind::Bool; + value.boolean = state; + return value; + } + + Json parseObject() { + Json value; + value.kind = Json::Kind::Object; + expect('{'); + skipSpace(); + if (peek() == '}') { + ++pos_; + return value; + } + while (true) { + skipSpace(); + std::string key = parseString(); + skipSpace(); + expect(':'); + skipSpace(); + value.members.emplace_back(std::move(key), parseValue()); + skipSpace(); + if (peek() == ',') { + ++pos_; + continue; + } + expect('}'); + return value; + } + } + + Json parseArray() { + Json value; + value.kind = Json::Kind::Array; + expect('['); + skipSpace(); + if (peek() == ']') { + ++pos_; + return value; + } + while (true) { + skipSpace(); + value.items.push_back(parseValue()); + skipSpace(); + if (peek() == ',') { + ++pos_; + continue; + } + expect(']'); + return value; + } + } + + std::string parseString() { + expect('"'); + std::string out; + while (true) { + if (pos_ >= text_.size()) fail("unterminated string"); + const char c = text_[pos_++]; + if (c == '"') return out; + if (c != '\\') { + out.push_back(c); + continue; + } + if (pos_ >= text_.size()) fail("unterminated escape"); + switch (text_[pos_++]) { + case '"': out.push_back('"'); break; + case '\\': out.push_back('\\'); break; + case '/': out.push_back('/'); break; + case 'b': out.push_back('\b'); break; + case 'f': out.push_back('\f'); break; + case 'n': out.push_back('\n'); break; + case 'r': out.push_back('\r'); break; + case 't': out.push_back('\t'); break; + case 'u': { + // Only the basic plane, which is all a path or a choice + // name can need; surrogate pairs are passed through as is. + if (pos_ + 4 > text_.size()) fail("short \\u escape"); + const unsigned code = static_cast( + std::strtoul(text_.substr(pos_, 4).c_str(), nullptr, 16)); + pos_ += 4; + appendUtf8(out, code); + break; + } + default: fail("unknown escape"); + } + } + } + + static void appendUtf8(std::string& out, unsigned code) { + if (code < 0x80) { + out.push_back(static_cast(code)); + } else if (code < 0x800) { + out.push_back(static_cast(0xC0 | (code >> 6))); + out.push_back(static_cast(0x80 | (code & 0x3F))); + } else { + out.push_back(static_cast(0xE0 | (code >> 12))); + out.push_back(static_cast(0x80 | ((code >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (code & 0x3F))); + } + } + + Json parseNumber() { + const std::size_t start = pos_; + if (peek() == '-' || peek() == '+') ++pos_; + bool integral = true; + while (pos_ < text_.size()) { + const char c = text_[pos_]; + if (c >= '0' && c <= '9') { + ++pos_; + } else if (c == '.' || c == 'e' || c == 'E') { + integral = false; + ++pos_; + } else if (c == '-' || c == '+') { // an exponent's sign + ++pos_; + } else { + break; + } + } + if (pos_ == start) fail("expected a value"); + + Json value; + value.kind = Json::Kind::Number; + value.integral = integral; + try { + value.number = std::stod(text_.substr(start, pos_ - start)); + } catch (const std::exception&) { + fail("'" + text_.substr(start, pos_ - start) + "' is not a number"); + } + return value; + } + + const std::string& text_; + std::size_t pos_ = 0; +}; + +std::string quote(const std::string& text) { + std::string out = "\""; + for (const char c : text) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (static_cast(c) < 0x20) { + char buffer[7]; + std::snprintf(buffer, sizeof(buffer), "\\u%04x", + static_cast(static_cast(c))); + out += buffer; + } else { + out.push_back(c); + } + } + } + return out + "\""; +} + +std::string number(double value, int digits) { + std::ostringstream out; + out.setf(std::ios::fixed); + out.precision(digits); + out << value; + std::string text = out.str(); + // 5.00 reads better as 5.0 and 0.250 as 0.25. + while (text.size() > 1 && text.back() == '0' && text[text.size() - 2] != '.') text.pop_back(); + return text; +} + +// Relative when the image lives under the project folder, absolute otherwise: +// a folder holding the project and its images stays movable. +std::string relativeTo(const std::string& path, const std::string& directory) { + if (directory.empty()) return path; + std::error_code error; + const auto relative = std::filesystem::relative(path, directory, error); + if (error || relative.empty()) return path; + const auto text = relative.generic_string(); + return text.rfind("..", 0) == 0 ? path : text; +} + +std::string resolve(const std::string& path, const std::string& directory) { + std::filesystem::path file(path); + if (file.is_absolute() || directory.empty()) return path; + return (std::filesystem::path(directory) / file).lexically_normal().string(); +} + +} // namespace + +std::string projectToJson(const Project& project, const std::string& directory) { + std::ostringstream out; + out << "{\n"; + out << " \"effector_project\": 1,\n"; + out << " \"canvas\": {\"width\": " << project.width << ", \"height\": " << project.height + << "},\n"; + if (project.seed) out << " \"seed\": " << *project.seed << ",\n"; + + out << " \"sources\": ["; + for (std::size_t i = 0; i < project.sources.size(); ++i) { + out << (i == 0 ? "\n" : ",\n") << " " << quote(relativeTo(project.sources[i], directory)); + } + out << (project.sources.empty() ? "" : "\n ") << "],\n"; + + out << " \"chain\": ["; + for (std::size_t i = 0; i < project.stages.size(); ++i) { + const auto& stage = project.stages[i]; + if (!stage.effect) continue; + out << (i == 0 ? "\n" : ",\n"); + out << " {\n \"effect\": " << quote(stage.effect->name()) << ",\n"; + out << " \"params\": {"; + + const auto& specs = stage.effect->params(); + bool first = true; + for (const auto& spec : specs) { + out << (first ? "\n" : ",\n") << " " << quote(spec.key) << ": "; + first = false; + switch (spec.type) { + case ParamSpec::Type::Bool: + out << (paramBool(stage.values, spec.key, spec.defaultValue != 0.0) ? "true" + : "false"); + break; + case ParamSpec::Type::Int: + out << paramInt(stage.values, spec.key, static_cast(spec.defaultValue)); + break; + case ParamSpec::Type::Double: + out << number(paramDouble(stage.values, spec.key, spec.defaultValue), + spec.digits); + break; + case ParamSpec::Type::Choice: + out << quote(paramChoice(stage.values, spec.key, spec.defaultChoice)); + break; + } + } + out << (specs.empty() ? "}" : "\n }") << "\n }"; + } + out << (project.stages.empty() ? "" : "\n ") << "]\n"; + out << "}\n"; + return out.str(); +} + +Project projectFromJson(const std::string& text, const std::string& directory) { + JsonParser parser(text); + const Json root = parser.parse(); + if (root.kind != Json::Kind::Object) throw EffectError("a project must be a JSON object"); + + Project project; + + if (const auto* canvas = root.member("canvas")) { + if (const auto* width = canvas->member("width")) project.width = static_cast(width->number); + if (const auto* height = canvas->member("height")) { + project.height = static_cast(height->number); + } + } + if (project.width <= 0 || project.height <= 0) throw EffectError("the canvas size is not positive"); + + if (const auto* seed = root.member("seed")) { + if (seed->kind == Json::Kind::Number) { + project.seed = static_cast(seed->number); + } + } + + if (const auto* sources = root.member("sources")) { + for (const auto& item : sources->items) { + if (item.kind == Json::Kind::String) { + project.sources.push_back(resolve(item.text, directory)); + } + } + } + + const auto* chain = root.member("chain"); + if (chain == nullptr || chain->kind != Json::Kind::Array || chain->items.empty()) { + throw EffectError("the project has no effect chain"); + } + for (const auto& item : chain->items) { + const auto* name = item.member("effect"); + if (name == nullptr || name->kind != Json::Kind::String) { + throw EffectError("a chain stage has no effect name"); + } + + ChainStage stage; + stage.effect = effectByName(name->text); // throws on an unknown effect + stage.values = stage.effect->defaults(); + + // 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 + // params that did not exist when it was written. + if (const auto* params = item.member("params")) { + for (const auto& spec : stage.effect->params()) { + const auto* value = params->member(spec.key); + if (value == nullptr) continue; + switch (spec.type) { + case ParamSpec::Type::Bool: + if (value->kind == Json::Kind::Bool) stage.values[spec.key] = value->boolean; + break; + case ParamSpec::Type::Int: + if (value->kind == Json::Kind::Number) { + stage.values[spec.key] = static_cast(std::llround(value->number)); + } + break; + case ParamSpec::Type::Double: + if (value->kind == Json::Kind::Number) stage.values[spec.key] = value->number; + break; + case ParamSpec::Type::Choice: + for (const auto& choice : spec.choices) { + if (value->kind == Json::Kind::String && value->text == choice) { + stage.values[spec.key] = choice; + } + } + break; + } + } + } + project.stages.push_back(std::move(stage)); + } + + return project; +} + +void saveProject(const std::string& path, const Project& project) { + const auto directory = std::filesystem::path(path).parent_path().string(); + std::ofstream file(path, std::ios::binary | std::ios::trunc); + if (!file) throw EffectError("cannot write " + path); + file << projectToJson(project, directory); + if (!file) throw EffectError("could not write " + path); +} + +Project loadProject(const std::string& path) { + std::ifstream file(path, std::ios::binary); + if (!file) throw EffectError("cannot read " + path); + std::ostringstream buffer; + buffer << file.rdbuf(); + try { + return projectFromJson(buffer.str(), std::filesystem::path(path).parent_path().string()); + } catch (const EffectError& error) { + throw EffectError(std::filesystem::path(path).filename().string() + ": " + error.what()); + } +} + +} // namespace effector diff --git a/src/core/project.hpp b/src/core/project.hpp new file mode 100644 index 0000000..4f6bcdf --- /dev/null +++ b/src/core/project.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include + +#include "core/chain.hpp" +#include "core/params.hpp" + +namespace effector { + +// Everything needed to recreate a render: which images went in, the cascade +// with its settings, the canvas and the seed. Deliberately no pixels — a +// project is a small text file that points at the sources on disk. +struct Project { + std::vector sources; // absolute paths + std::vector stages; + int width = 1920; + int height = 1080; + std::optional seed; +}; + +// JSON, written for humans: two-space indent, one source path per line, and +// the params of a stage spelled with the same keys the effect declares. +// Source paths are written relative to `directory` when they live under it, so +// a project folder can be moved or shared whole. +std::string projectToJson(const Project& project, const std::string& directory = {}); + +// Throws EffectError on malformed JSON, an unknown effect or a missing chain. +// Relative source paths are resolved against `directory`; params the effect +// does not declare are dropped, and missing ones keep the effect's default. +Project projectFromJson(const std::string& text, const std::string& directory = {}); + +// Both throw EffectError: the file is the user's, so failures are reported +// rather than silently swallowed. +void saveProject(const std::string& path, const Project& project); +Project loadProject(const std::string& path); + +} // namespace effector diff --git a/src/core/registry.cpp b/src/core/registry.cpp index de5c25d..b540896 100644 --- a/src/core/registry.cpp +++ b/src/core/registry.cpp @@ -1,5 +1,6 @@ #include "core/registry.hpp" +#include "effects/blur.hpp" #include "effects/scanlines.hpp" namespace effector { @@ -8,6 +9,7 @@ namespace effector { const std::vector& effects() { static const std::vector registry = { std::make_shared(), + std::make_shared(), }; return registry; } diff --git a/src/effects/blur.cpp b/src/effects/blur.cpp new file mode 100644 index 0000000..e69f0ef --- /dev/null +++ b/src/effects/blur.cpp @@ -0,0 +1,136 @@ +#include "effects/blur.hpp" + +#include +#include +#include + +#include "core/parallel.hpp" + +namespace effector { + +const std::vector& BlurEffect::params() const { + static const std::vector specs = { + ParamSpec::makeInt("radius", "Radius", 8, 1, 256, + "Half width in pixels of the blur window."), + ParamSpec::makeInt("passes", "Passes", 2, 1, 8, + "How often the box blur is repeated; more passes look more gaussian."), + ParamSpec::makeChoice("direction", "Direction", "both", {"both", "horizontal", "vertical"}, + "Axis the blur is applied along."), + ParamSpec::makeBool("first_source", "Use the first image", false, + "Always blur the first source instead of picking one at random."), + }; + return specs; +} + +namespace { + +// Copies `src` into the canvas starting at (sx, sy), repeating it as needed so +// that every pixel is covered even when the source is smaller than the canvas. +void fillFromSource(Image& canvas, const Image& src, int sx, int sy) { + const int width = canvas.width(); + const std::size_t total = static_cast(width) * 3; + const int copyWidth = std::min(width, src.width() - sx); + const std::size_t copyBytes = static_cast(copyWidth) * 3; + + parallelFor(static_cast(canvas.height()), [&](std::size_t begin, std::size_t end) { + for (std::size_t y = begin; y < end; ++y) { + const int srcY = sy + (static_cast(y) % std::max(1, src.height() - sy)); + const std::uint8_t* in = src.row(srcY) + static_cast(sx) * 3; + std::uint8_t* out = canvas.row(static_cast(y)); + + std::memcpy(out, in, copyBytes); + std::size_t filled = copyBytes; + while (filled < total) { + const std::size_t chunk = std::min(filled, total - filled); + std::memcpy(out + filled, out, chunk); + filled += chunk; + } + } + }); +} + +// One separable box blur pass. `stride` is the distance in bytes between two +// neighbouring samples along the blurred axis, `count` how many of them there +// are; the window is clamped at both ends. +void boxPass(const std::uint8_t* in, std::uint8_t* out, int count, std::size_t stride, int radius) { + const int window = 2 * radius + 1; + int sum[3] = {0, 0, 0}; + + for (int c = 0; c < 3; ++c) { + sum[c] = in[c] * (radius + 1); + for (int i = 1; i <= radius; ++i) { + sum[c] += in[static_cast(std::min(i, count - 1)) * stride + c]; + } + } + + for (int i = 0; i < count; ++i) { + for (int c = 0; c < 3; ++c) { + out[static_cast(i) * stride + c] = + static_cast(sum[c] / window); + } + const int add = std::min(i + radius + 1, count - 1); + const int drop = std::max(i - radius, 0); + for (int c = 0; c < 3; ++c) { + sum[c] += in[static_cast(add) * stride + c] - + in[static_cast(drop) * stride + c]; + } + } +} + +void blurHorizontal(const Image& src, Image& dst, int radius) { + parallelFor(static_cast(src.height()), [&](std::size_t begin, std::size_t end) { + for (std::size_t y = begin; y < end; ++y) { + boxPass(src.row(static_cast(y)), dst.row(static_cast(y)), src.width(), 3, + radius); + } + }); +} + +void blurVertical(const Image& src, Image& dst, int radius) { + const std::size_t stride = src.stride(); + parallelFor(static_cast(src.width()), [&](std::size_t begin, std::size_t end) { + for (std::size_t x = begin; x < end; ++x) { + const std::size_t offset = x * 3; + boxPass(src.data() + offset, dst.data() + offset, src.height(), stride, radius); + } + }); +} + +} // namespace + +Image BlurEffect::render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const { + if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels."); + + const int radius = static_cast(std::max(1L, paramInt(values, "radius", 8))); + const int passes = static_cast(std::max(1L, paramInt(values, "passes", 2))); + const std::string direction = paramChoice(values, "direction", "both"); + const bool firstSource = paramBool(values, "first_source", false); + + Image canvas(width, height); + if (!sources.empty()) { + // Every random value is drawn here, before any threading, so that a + // seed always gives the same image. + const std::size_t index = firstSource ? 0 : rng.below(sources.size() - 1); + const Image& src = sources[index]; + const int sx = static_cast(rng.inRange(0, std::max(0, src.width() - width))); + const int sy = static_cast(rng.inRange(0, std::max(0, src.height() - height))); + fillFromSource(canvas, src, sx, sy); + } + + Image scratch(width, height); + for (int pass = 0; pass < passes; ++pass) { + if (direction != "vertical") { + blurHorizontal(canvas, scratch, std::min(radius, std::max(1, width - 1))); + std::swap(canvas, scratch); + } + if (direction != "horizontal") { + blurVertical(canvas, scratch, std::min(radius, std::max(1, height - 1))); + std::swap(canvas, scratch); + } + } + + return canvas; +} + +} // namespace effector diff --git a/src/effects/blur.hpp b/src/effects/blur.hpp new file mode 100644 index 0000000..1fdcc8f --- /dev/null +++ b/src/effects/blur.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include "core/effect.hpp" + +namespace effector { + +// Softens its input with a separable box blur, repeated a few times to +// approximate a gaussian. It needs no source images of its own: given none it +// blurs an empty canvas, which is what makes it usable as a pure filter stage. +class BlurEffect : public Effect { +public: + std::string name() const override { return "blur"; } + std::string title() const override { return "Blur"; } + std::string description() const override { + return "Softens its input with a repeated box blur, horizontally, vertically or both; " + "with sources loaded it fills the canvas from a randomly chosen one first."; + } + const std::vector& params() const override; + bool needsSources() const override { return false; } + + Image render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const override; +}; + +} // namespace effector diff --git a/src/gui/window.cpp b/src/gui/window.cpp index c6acd1e..0c01216 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -1,9 +1,12 @@ #include "gui/window.hpp" +#include #include #include #include +#include "core/chain.hpp" +#include "core/project.hpp" #include "core/registry.hpp" #include "core/rng.hpp" @@ -14,6 +17,15 @@ const char* const kImagePatterns[] = {"*.png", "*.jpg", "*.jpeg", "*.webp", "* "*.tif", "*.tiff", "*.gif", "*.ppm", "*.PNG", "*.JPG", "*.JPEG", "*.WEBP"}; +Glib::RefPtr> projectFilters() { + auto filter = Gtk::FileFilter::create(); + filter->set_name("Effector projects"); + filter->add_pattern("*.json"); + auto filters = Gio::ListStore::create(); + filters->append(filter); + return filters; +} + Glib::RefPtr toTexture(const Image& image) { auto bytes = Glib::Bytes::create(image.data(), image.byteSize()); return Gdk::MemoryTexture::create(image.width(), image.height(), Gdk::MemoryTexture::Format::R8G8B8, @@ -34,7 +46,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)); @@ -45,7 +57,13 @@ EffectorWindow::EffectorWindow(const Options& initial) { applyInitial(initial); suspendRender_ = false; - if (!sources_->empty()) startRender(); + if (loading_) { + placeholder_.set_text(missingText()); + } else if (canRender()) { + startRender(); + } else { + showPlaceholder(); + } } EffectorWindow::~EffectorWindow() { @@ -62,6 +80,11 @@ void EffectorWindow::buildUi() { auto* header = Gtk::make_managed(); set_titlebar(*header); + menuButton_.set_icon_name("open-menu-symbolic"); + menuButton_.set_tooltip_text("Project menu"); + menuButton_.set_popover(static_cast(buildMainMenu())); + header->pack_end(menuButton_); + auto* addButton = Gtk::make_managed(); addButton->set_icon_name("list-add-symbolic"); addButton->set_tooltip_text("Add images"); @@ -69,14 +92,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 +163,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 +222,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 +241,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 +253,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 +266,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 +288,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 +312,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); } @@ -421,10 +439,19 @@ void EffectorWindow::onLoadFinished() { if (loadCancel_.load(std::memory_order_relaxed)) { // A cancelled batch is incomplete, so none of it is taken. loadQueue_.clear(); + dropLoadedImages_ = false; showError("Loading cancelled."); return; } + if (dropLoadedImages_) { + // A project was opened while this batch was decoding: its images belong + // to the previous source list, and the project's own are queued behind. + dropLoadedImages_ = false; + images.clear(); + failures.clear(); + } + for (const auto& image : images) appendSourceRow(image.path()); mutateSources([&images](SourceList& list) { for (auto& image : images) list.push_back(std::move(image)); @@ -548,15 +575,7 @@ void EffectorWindow::removeSource(Gtk::ListBoxRow* row) { } }); sourceList_.remove(*row); - - if (!sources_->empty()) { - requestRender(); - } else { - result_ = Image(); - saveButton_.set_sensitive(false); - previewStack_.set_visible_child("placeholder"); - status_.set_text(""); - } + requestRender(); } void EffectorWindow::onAddImages() { @@ -589,17 +608,168 @@ 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(); + + 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 { @@ -614,15 +784,40 @@ void EffectorWindow::onSeedEntered() { void EffectorWindow::onRecreate() { seed_ = Rng::randomSeed(); seedEntry_.set_text(std::to_string(seed_)); - if (sources_->empty()) { - showError("Add at least one source image first."); + if (!canRender()) { + showError(missingText()); return; } requestRender(); } +// Whether the effect chain has everything it needs. Only the first stage is +// 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; + return !sources_->empty() || !chainNeedsSources(stages_); +} + +std::string EffectorWindow::missingText() const { + if (stages_.empty()) return "Add an effect to start."; + return "Add source images to start."; +} + +void EffectorWindow::showPlaceholder() { + result_ = Image(); + saveButton_.set_sensitive(false); + placeholder_.set_text(missingText()); + previewStack_.set_visible_child("placeholder"); + status_.set_text(""); +} + void EffectorWindow::requestRender() { - if (suspendRender_ || sources_->empty()) return; + if (suspendRender_) return; + if (!canRender()) { + showPlaceholder(); + 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 +830,7 @@ void EffectorWindow::requestRender() { } void EffectorWindow::startRender() { - if (sources_->empty()) return; + if (!canRender()) return; if (rendering_) { staleResult_ = true; // a fresh render follows this one return; @@ -643,22 +838,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,12 +908,190 @@ void EffectorWindow::onRenderFinished() { std::ostringstream status; status.setf(std::ios::fixed); status.precision(1); - status << effect_->title() << " · " << result_.width() << "×" << result_.height() << " · " - << sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · seed " - << seed_ << " · " << millis << " ms"; + status << chainTitle(stages_) << " · " << result_.width() << "×" << result_.height() << " · "; + if (!sources_->empty()) { + status << sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · "; + } + status << "seed " << seed_ << " · " << millis << " ms"; status_.set_text(status.str()); } +// -------------------------------------------------------------- project --- + +// The project menu lives in the header bar, next to the buttons that act on +// the current render. +Gtk::Widget& EffectorWindow::buildMainMenu() { + add_action("new-project", sigc::mem_fun(*this, &EffectorWindow::onNewProject)); + add_action("open-project", sigc::mem_fun(*this, &EffectorWindow::onOpenProject)); + add_action("save-project", sigc::mem_fun(*this, &EffectorWindow::onSaveProject)); + add_action("save-project-as", sigc::mem_fun(*this, &EffectorWindow::onSaveProjectAs)); + + auto menu = Gio::Menu::create(); + auto open = Gio::Menu::create(); + open->append("New Project", "win.new-project"); + open->append("Open Project…", "win.open-project"); + menu->append_section(open); + auto save = Gio::Menu::create(); + save->append("Save Project", "win.save-project"); + save->append("Save Project As…", "win.save-project-as"); + menu->append_section(save); + + // The shortcuts hang off the window rather than the application, which the + // window does not belong to yet while it is being built. + auto shortcuts = Gtk::ShortcutController::create(); + shortcuts->set_scope(Gtk::ShortcutScope::GLOBAL); + const struct Accel { + const char* keys; + const char* action; + } accels[] = {{"n", "win.new-project"}, + {"o", "win.open-project"}, + {"s", "win.save-project"}, + {"s", "win.save-project-as"}}; + for (const Accel& accel : accels) { + shortcuts->add_shortcut(Gtk::Shortcut::create( + Gtk::ShortcutTrigger::parse_string(accel.keys), Gtk::NamedAction::create(accel.action))); + } + add_controller(shortcuts); + + return *Gtk::make_managed(menu); +} + +Project EffectorWindow::currentProject() const { + Project project; + for (const auto& image : *sources_) project.sources.push_back(image.path()); + project.stages = stages_; + project.width = static_cast(widthSpin_.get_value()); + project.height = static_cast(heightSpin_.get_value()); + project.seed = seed_; + return project; +} + +void EffectorWindow::applyProject(const Project& project, const std::string& path) { + // One render at the end rather than one per piece of the project. + suspendRender_ = true; + clearSources(); + + stages_ = project.stages; + selectedStage_ = 0; + rebuildChainRows(); + buildParamWidgets(); + + widthSpin_.set_value(project.width); + heightSpin_.set_value(project.height); + seed_ = project.seed.value_or(Rng::randomSeed()); + seedEntry_.set_text(std::to_string(seed_)); + + projectPath_ = path; + updateTitle(); + clearError(); + suspendRender_ = false; + + if (project.sources.empty()) { + requestRender(); // a chain that needs no sources still has a picture + } else { + addPaths(project.sources); // renders once the images are decoded + } +} + +void EffectorWindow::clearSources() { + while (auto* row = sourceList_.get_row_at_index(0)) sourceList_.remove(*row); + mutateSources([](SourceList& list) { list.clear(); }); + loadQueue_.clear(); + if (loading_) dropLoadedImages_ = true; + + result_ = Image(); + saveButton_.set_sensitive(false); + previewStack_.set_visible_child("placeholder"); +} + +void EffectorWindow::onNewProject() { + Project fresh; + fresh.stages.push_back({effects_.front(), effects_.front()->defaults()}); + applyProject(fresh, ""); +} + +void EffectorWindow::onOpenProject() { + auto dialog = Gtk::FileDialog::create(); + dialog->set_title("Open project"); + dialog->set_filters(projectFilters()); + dialog->open(*this, [this, dialog](const Glib::RefPtr& result) { + onProjectChosen(result, dialog); + }); +} + +void EffectorWindow::onProjectChosen(const Glib::RefPtr& result, + const Glib::RefPtr& dialog) { + std::string path; + try { + const auto file = dialog->open_finish(result); + if (!file) return; + path = file->get_path(); + } catch (const Glib::Error&) { + return; // cancelled + } + + try { + applyProject(loadProject(path), path); + } catch (const std::exception& error) { + showError(std::string("Could not open the project: ") + error.what()); + } +} + +void EffectorWindow::onSaveProject() { + if (projectPath_.empty()) { + onSaveProjectAs(); + return; + } + writeProject(projectPath_); +} + +void EffectorWindow::onSaveProjectAs() { + auto dialog = Gtk::FileDialog::create(); + dialog->set_title("Save project"); + dialog->set_filters(projectFilters()); + dialog->set_initial_name(projectPath_.empty() + ? "effector-" + chainSlug(stages_) + ".json" + : std::filesystem::path(projectPath_).filename().string()); + dialog->save(*this, [this, dialog](const Glib::RefPtr& result) { + onSaveProjectChosen(result, dialog); + }); +} + +void EffectorWindow::onSaveProjectChosen(const Glib::RefPtr& result, + const Glib::RefPtr& dialog) { + std::filesystem::path path; + try { + const auto file = dialog->save_finish(result); + if (!file) return; + path = file->get_path(); + } catch (const Glib::Error&) { + return; // cancelled + } + if (path.extension().empty()) path.replace_extension(".json"); + writeProject(path.string()); +} + +void EffectorWindow::writeProject(const std::string& path) { + try { + saveProject(path, currentProject()); + } catch (const std::exception& error) { + showError(std::string("Could not save the project: ") + error.what()); + return; + } + clearError(); + projectPath_ = path; + updateTitle(); + status_.set_text("Saved " + path); +} + +void EffectorWindow::updateTitle() { + if (projectPath_.empty()) { + set_title("Effector"); + return; + } + set_title(std::filesystem::path(projectPath_).stem().string() + " — Effector"); +} + // --------------------------------------------------------------- saving --- void EffectorWindow::onSave() { @@ -727,7 +1099,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..5c70fa5 100644 --- a/src/gui/window.hpp +++ b/src/gui/window.hpp @@ -12,8 +12,10 @@ #include #include "cli/options.hpp" +#include "core/chain.hpp" #include "core/effect.hpp" #include "core/image.hpp" +#include "core/project.hpp" namespace effector { @@ -28,8 +30,12 @@ private: Gtk::Widget& buildPreview(); void buildParamWidgets(); ParamValues paramValues() const; + void storeParams(); // widgets -> the selected stage's values void applyInitial(const Options& initial); + bool canRender() const; // a chain, and sources if the chain needs them + std::string missingText() const; // what is still missing before a render + void showPlaceholder(); // drops the preview and says what is missing void requestRender(); // coalesced: restarts the short debounce timer void startRender(); // starts a worker, or marks the result stale void onAddImages(); @@ -44,7 +50,29 @@ 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(); + Gtk::Widget& buildMainMenu(); // the header menu: new, open, save project + Project currentProject() const; + void applyProject(const Project& project, const std::string& path); + void clearSources(); + void onNewProject(); + void onOpenProject(); + void onProjectChosen(const Glib::RefPtr& result, + const Glib::RefPtr& dialog); + void onSaveProject(); // falls back to Save As while the project is untitled + void onSaveProjectAs(); + void onSaveProjectChosen(const Glib::RefPtr& result, + const Glib::RefPtr& dialog); + void writeProject(const std::string& path); + void updateTitle(); + void onParamChanged(); void onSeedEntered(); void onRecreate(); @@ -66,9 +94,14 @@ 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; + std::string projectPath_; // empty until the project has been saved once bool suspendRender_ = true; // Source loading. Decoding a batch of large photographs takes seconds, so @@ -85,6 +118,7 @@ private: std::vector loadFailures_; std::vector loadQueue_; // chosen while another batch was loading bool loading_ = false; + bool dropLoadedImages_ = false; // the batch in flight belongs to a replaced project sigc::connection loadWindowDelay_; // Render job @@ -109,8 +143,11 @@ 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::MenuButton menuButton_; Gtk::Button recreateButton_{"Recreate"}; Gtk::Button saveButton_{"Save As…"}; Gtk::Picture picture_; diff --git a/src/main.cpp b/src/main.cpp index 8f7af92..79fe47c 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; @@ -39,7 +40,7 @@ int renderHeadless(const effector::Options& options) { for (const auto& failure : failures) { std::cerr << "effector: skipping " << failure.path << ": " << failure.message << "\n"; } - if (sources.empty()) { + if (sources.empty() && effector::chainNeedsSources(stages)) { std::cerr << "effector: no usable source images\n"; return 2; } @@ -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 d913488..dd1ce27 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -4,13 +4,17 @@ #include #include #include +#include #include #include #include #include +#include "core/chain.hpp" +#include "core/project.hpp" #include "core/registry.hpp" #include "core/rng.hpp" +#include "effects/blur.hpp" #include "effects/scanlines.hpp" namespace { @@ -44,6 +48,29 @@ bool sameBytes(const effector::Image& a, const effector::Image& b) { return std::equal(a.data(), a.data() + a.byteSize(), b.data()); } +// Alternating black and white columns: any blur along x has to pull these +// towards mid grey. +effector::Image stripes(int width, int height) { + effector::Image image(width, height); + for (int y = 0; y < height; ++y) { + auto* row = image.row(y); + for (int x = 0; x < width; ++x) { + const std::uint8_t value = (x % 2 == 0) ? 0 : 255; + row[x * 3 + 0] = row[x * 3 + 1] = row[x * 3 + 2] = value; + } + } + return image; +} + +effector::Image renderBlur(const std::vector& sources, int width, int height, + std::uint64_t seed, const effector::ParamValues& values) { + effector::BlurEffect effect; + effector::Rng rng(seed); + auto merged = effect.defaults(); + for (const auto& [key, value] : values) merged[key] = value; + return effect.render(sources, width, height, rng, merged); +} + effector::Image render(const std::vector& sources, int width, int height, std::uint64_t seed, const effector::ParamValues& values) { effector::ScanlinesEffect effect; @@ -128,6 +155,196 @@ int main() { check(threw, "no sources throws"); } + std::cout << "blur\n"; + { + std::vector sources = {gradient(1200, 800, 10), gradient(640, 480, 90)}; + + const auto first = renderBlur(sources, 400, 300, 4, {{"radius", 10L}}); + const auto again = renderBlur(sources, 400, 300, 4, {{"radius", 10L}}); + const auto other = renderBlur(sources, 400, 300, 11, {{"radius", 10L}}); + check(first.width() == 400 && first.height() == 300, "canvas has the requested size"); + check(sameBytes(first, again), "same seed reproduces the image"); + check(!sameBytes(first, other), "a different seed changes the image"); + + // A wide blur over one-pixel stripes has to land near mid grey, and only + // along the axis it is applied to. + std::vector striped = {stripes(200, 100)}; + const auto flattened = renderBlur(striped, 200, 100, 1, + {{"radius", 8L}, {"direction", std::string("horizontal")}}); + // The clamped edges ramp in over radius * passes pixels on each side. + bool grey = true; + for (int x = 32; x < 168; ++x) grey = grey && std::abs(flattened.row(50)[x * 3] - 127) <= 2; + check(grey, "a horizontal blur evens out vertical stripes"); + + const auto untouched = renderBlur(striped, 200, 100, 1, + {{"radius", 8L}, {"direction", std::string("vertical")}}); + check(untouched.row(50)[0] == 0 && untouched.row(50)[3] == 255, + "a vertical blur leaves columns alone"); + + // Every column is uniform, so a vertical blur must not change anything. + const auto vertical = renderBlur(striped, 200, 100, 1, + {{"radius", 4L}, {"passes", 3L}, {"direction", std::string("vertical")}}); + check(sameBytes(untouched, vertical), "repeated vertical passes stay stable"); + + // Radius larger than the canvas must clamp rather than read out of bounds. + const auto tiny = renderBlur(striped, 3, 3, 2, {{"radius", 200L}}); + check(tiny.width() == 3 && tiny.height() == 3, "radius wider than the canvas is clamped"); + + const auto pinned = renderBlur(sources, 300, 200, 6, {{"first_source", true}}); + const auto pinnedAgain = renderBlur(sources, 300, 200, 6, {{"first_source", true}}); + check(sameBytes(pinned, pinnedAgain), "first_source is reproducible"); + + // Blur declares that it needs no sources, so with none it softens the + // empty canvas it starts from instead of refusing to run. + const auto blank = renderBlur({}, 100, 100, 1, {}); + bool allBlack = blank.width() == 100 && blank.height() == 100; + for (std::size_t i = 0; i < blank.byteSize(); ++i) allBlack = allBlack && blank.data()[i] == 0; + check(allBlack, "without sources blur renders an empty canvas"); + } + + 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"); + + // Sources are an effect's own business: scanlines cuts strips out of + // them, blur only softens what it is handed and runs without any. + check(effectByName("scanlines")->needsSources(), "scanlines needs source images"); + check(!effectByName("blur")->needsSources(), "blur needs no source images"); + check(chainNeedsSources({scanlines, blur}), "a chain led by scanlines needs sources"); + check(!chainNeedsSources({blur, scanlines}), + "a chain led by blur needs none, later stages see the stage before them"); + check(!chainNeedsSources({}), "an empty chain needs no sources"); + + Rng bare(5); + const auto sourceless = renderChain({blur}, {}, 200, 150, bare); + check(sourceless.width() == 200 && sourceless.height() == 150, + "blur renders a canvas without sources"); + + bool scanlinesThrew = false; + try { + Rng rng(1); + renderChain({scanlines}, {}, 100, 100, rng); + } catch (const EffectError&) { + scanlinesThrew = true; + } + check(scanlinesThrew, "scanlines still refuses to run without sources"); + } + + std::cout << "project\n"; + { + const auto dir = std::filesystem::temp_directory_path() / "effector_test_project"; + std::filesystem::create_directories(dir); + const auto source = dir / "source.png"; + gradient(64, 48, 7).save(source.string()); + + Project project; + project.sources = {source.string(), "/elsewhere/other.png"}; + project.stages.push_back({effectByName("scanlines"), {{"strip_height", 9L}}}); + project.stages.push_back( + {effectByName("blur"), {{"radius", 3L}, {"direction", std::string("vertical")}}}); + project.width = 640; + project.height = 400; + project.seed = 1234567890123ULL; + + const auto file = dir / "test.json"; + saveProject(file.string(), project); + const auto reloaded = loadProject(file.string()); + + check(reloaded.width == 640 && reloaded.height == 400, "the canvas survives a round trip"); + check(reloaded.seed && *reloaded.seed == 1234567890123ULL, "the seed survives a round trip"); + check(reloaded.stages.size() == 2 && reloaded.stages[0].effect->name() == "scanlines" && + reloaded.stages[1].effect->name() == "blur", + "the chain keeps its effects and their order"); + check(paramInt(reloaded.stages[0].values, "strip_height", 0) == 9 && + paramInt(reloaded.stages[1].values, "radius", 0) == 3 && + paramChoice(reloaded.stages[1].values, "direction", "") == "vertical", + "the stage params survive a round trip"); + check(paramInt(reloaded.stages[0].values, "jitter", -1) == + paramInt(effectByName("scanlines")->defaults(), "jitter", -2), + "params the file leaves out keep their default"); + + // A source next to the project is written relative, so the folder can + // be moved; anything outside it stays absolute. + std::ifstream stream(file.string(), std::ios::binary); + const std::string text((std::istreambuf_iterator(stream)), + std::istreambuf_iterator()); + check(text.find("\"source.png\"") != std::string::npos, "a nearby source is written relative"); + check(text.find("\"/elsewhere/other.png\"") != std::string::npos, + "a source elsewhere stays absolute"); + check(reloaded.sources.size() == 2 && reloaded.sources[0] == source.string(), + "a relative source is resolved against the project"); + + // The point of a project: the same picture comes back out of the file. + std::vector sources = {gradient(300, 200, 4)}; + Rng before(*project.seed); + Rng after(*reloaded.seed); + const auto original = renderChain(project.stages, sources, project.width, project.height, before); + const auto restored = + renderChain(reloaded.stages, sources, reloaded.width, reloaded.height, after); + check(sameBytes(original, restored), "a reloaded project renders the same image"); + + // Params the effects do not declare are dropped rather than carried. + const auto stripped = projectFromJson(projectToJson(reloaded)); + check(stripped.stages[0].values.find("radius") == stripped.stages[0].values.end(), + "a stage only keeps the params its effect declares"); + + int refusals = 0; + for (const char* bad : {"{\"chain\": [{\"effect\": \"nope\"}]}", "{\"chain\": []}", + "{\"chain\": [{\"effect\": \"blur\"},]}", "not json at all"}) { + try { + projectFromJson(bad); + } catch (const EffectError&) { + ++refusals; + } + } + check(refusals == 4, "a broken project is refused"); + + bool threw = false; + try { + loadProject((dir / "missing.json").string()); + } catch (const EffectError&) { + threw = true; + } + check(threw, "a missing project file throws"); + + std::filesystem::remove_all(dir); + } + std::cout << "image io\n"; { const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png";