Merge branch 'blur-effect'

This commit is contained in:
Si
2026-09-18 16:01:44 +02:00
16 changed files with 1528 additions and 94 deletions
+45 -6
View File
@@ -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 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 per-test filter: run `./build/tests/effector_tests` directly to see the section
headings (`rng`, `registry`, `scanlines`, `batch loading`) and the `ok`/`FAIL` headings (`rng`, `registry`, `scanlines`, `blur`, `chain`, `project`, `image
lines. To iterate on one area, comment out the other blocks in io`, `batch loading`) and the `ok`/`FAIL` lines. To iterate on one area, comment
`tests/core_tests.cpp` or add a new `std::cout << "section\n"` block — checks are out the other blocks in `tests/core_tests.cpp` or add a new
plain `check(condition, "description")` calls and `main()` returns the failure `std::cout << "section\n"` block — checks are plain
count. `check(condition, "description")` calls and `main()` returns the failure count.
Headless render (no display needed, the fastest end-to-end smoke test): 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<string, variant<long,double,bool,string>>`), read back with `map<string, variant<long,double,bool,string>>`), read back with
`paramInt`/`paramDouble`/`paramBool`/`paramChoice`. `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/<name>.{hpp,cpp}` subclassing `Effect`, one Adding an effect: new `src/effects/<name>.{hpp,cpp}` subclassing `Effect`, one
line in the registry list in `src/core/registry.cpp`, one line in the line in the registry list in `src/core/registry.cpp`, one line in the
`effector_core` sources in `CMakeLists.txt`. Nothing else. `effector_core` sources in `CMakeLists.txt`. Nothing else — it shows up in the
CLI, in the chain menu and as a cascade stage on its own.
### Effects cascade
Effects are never applied on their own: `renderChain` (`src/core/chain.cpp`)
runs a `std::vector<ChainStage>` in order, handing stage 0 the loaded sources
and every later stage the previous stage's output as its *only* source. That is
the whole of the cascade — an effect neither knows nor cares whether its input
came from disk. All stages share one `Rng` and run strictly in sequence, so a
seed still reproduces the cascade byte for byte.
The interface builds the chain in the sidebar ("Effect chain": add from the
menu, reorder, remove, one stage selected at a time with its params below), the
CLI builds it from repeated `-e`: `-e scanlines --strip-height 6 -e blur
--radius 5`. Flags share one namespace across all effects, so `buildChain`
(`src/cli/options.cpp`) hands each flag to the stage whose effect declares it —
two effects must not declare the same param key.
### 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 ### Two link targets, deliberately
+3
View File
@@ -20,11 +20,14 @@ find_package(Threads REQUIRED)
# --- core: effects and image handling, no GUI --------------------------------- # --- core: effects and image handling, no GUI ---------------------------------
add_library(effector_core STATIC add_library(effector_core STATIC
src/core/chain.cpp
src/core/effect.cpp src/core/effect.cpp
src/core/image.cpp src/core/image.cpp
src/core/params.cpp src/core/params.cpp
src/core/project.cpp
src/core/registry.cpp src/core/registry.cpp
src/core/rng.cpp src/core/rng.cpp
src/effects/blur.cpp
src/effects/scanlines.cpp src/effects/scanlines.cpp
) )
target_include_directories(effector_core PUBLIC src) target_include_directories(effector_core PUBLIC src)
+21 -4
View File
@@ -4,6 +4,7 @@
#include <cstring> #include <cstring>
#include <sstream> #include <sstream>
#include "core/chain.hpp"
#include "core/effect.hpp" #include "core/effect.hpp"
#include "core/registry.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 parseOptions(int argc, char** argv) {
Options options; Options options;
options.effect = defaultEffectName();
for (int i = 1; i < argc; ++i) { for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i]; const std::string arg = argv[i];
@@ -58,8 +58,9 @@ Options parseOptions(int argc, char** argv) {
} else if (arg == "--list-effects") { } else if (arg == "--list-effects") {
options.listEffects = true; options.listEffects = true;
} else if (arg == "-e" || arg == "--effect") { } else if (arg == "-e" || arg == "--effect") {
options.effect = requireValue(argc, argv, i, arg); const std::string name = requireValue(argc, argv, i, arg);
effectByName(options.effect); // validate now, fail early effectByName(name); // validate now, fail early
options.effects.push_back(name);
} else if (arg == "-o" || arg == "--output") { } else if (arg == "-o" || arg == "--output") {
options.output = requireValue(argc, argv, i, arg); options.output = requireValue(argc, argv, i, arg);
} else if (arg == "-s" || arg == "--size") { } else if (arg == "-s" || arg == "--size") {
@@ -119,6 +120,7 @@ Options parseOptions(int argc, char** argv) {
options.images.push_back(arg); options.images.push_back(arg);
} }
} }
if (options.effects.empty()) options.effects.push_back(defaultEffectName());
return options; return options;
} }
@@ -127,7 +129,7 @@ std::string usage(const char* programName) {
out << "Usage: " << programName << " [options] [image ...]\n\n" out << "Usage: " << programName << " [options] [image ...]\n\n"
<< "Image effects app. Runs the GTK interface unless --output is given.\n\n" << "Image effects app. Runs the GTK interface unless --output is given.\n\n"
<< "Options:\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" << " -o, --output FILE render headlessly to this file and exit\n"
<< " -s, --size WxH canvas size (default: 1920x1080)\n" << " -s, --size WxH canvas size (default: 1920x1080)\n"
<< " --seed N random seed (default: random)\n" << " --seed N random seed (default: random)\n"
@@ -147,4 +149,19 @@ std::string usage(const char* programName) {
return out.str(); return out.str();
} }
std::vector<ChainStage> buildChain(const Options& options) {
std::vector<ChainStage> stages;
for (const auto& name : options.effects) {
ChainStage stage;
stage.effect = effectByName(name);
stage.values = stage.effect->defaults();
for (const auto& spec : stage.effect->params()) {
const auto it = options.params.find(spec.key);
if (it != options.params.end()) stage.values[spec.key] = it->second;
}
stages.push_back(std::move(stage));
}
return stages;
}
} // namespace effector } // namespace effector
+7 -1
View File
@@ -5,13 +5,14 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "core/chain.hpp"
#include "core/params.hpp" #include "core/params.hpp"
namespace effector { namespace effector {
struct Options { struct Options {
std::vector<std::string> images; std::vector<std::string> images;
std::string effect; std::vector<std::string> effects; // one per cascade stage, in order
std::string output; // empty -> launch the interface std::string output; // empty -> launch the interface
int width = 1920; int width = 1920;
int height = 1080; int height = 1080;
@@ -27,4 +28,9 @@ Options parseOptions(int argc, char** argv);
std::string usage(const char* programName); std::string usage(const char* programName);
// Turns the parsed names and flags into cascade stages: every flag goes to the
// stage whose effect declares it, so --strip-height and --radius can be given
// together for `-e scanlines -e blur`.
std::vector<ChainStage> buildChain(const Options& options);
} // namespace effector } // namespace effector
+54
View File
@@ -0,0 +1,54 @@
#include "core/chain.hpp"
namespace effector {
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
int width, int height, Rng& rng) {
if (stages.empty()) throw EffectError("The effect chain is empty.");
Image image;
for (std::size_t i = 0; i < stages.size(); ++i) {
const auto& stage = stages[i];
if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
auto values = stage.effect->defaults();
for (const auto& [key, value] : stage.values) values[key] = value;
if (i == 0) {
image = stage.effect->render(sources, width, height, rng, values);
} else {
// Feeding the previous result back in as the only source is what
// cascading means here; effects need to know nothing about it.
const std::vector<Image> previous = {std::move(image)};
image = stage.effect->render(previous, width, height, rng, values);
}
}
return image;
}
bool chainNeedsSources(const std::vector<ChainStage>& stages) {
if (stages.empty() || !stages.front().effect) return false;
return stages.front().effect->needsSources();
}
std::string chainTitle(const std::vector<ChainStage>& stages) {
std::string text;
for (const auto& stage : stages) {
if (!stage.effect) continue;
if (!text.empty()) text += " → ";
text += stage.effect->title();
}
return text;
}
std::string chainSlug(const std::vector<ChainStage>& stages) {
std::string text;
for (const auto& stage : stages) {
if (!stage.effect) continue;
if (!text.empty()) text += "-";
text += stage.effect->name();
}
return text;
}
} // namespace effector
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <string>
#include <vector>
#include "core/effect.hpp"
#include "core/image.hpp"
#include "core/params.hpp"
#include "core/rng.hpp"
namespace effector {
// One link of a cascade: an effect together with the values it runs with.
struct ChainStage {
EffectPtr effect;
ParamValues values; // missing keys fall back to the effect's defaults
};
// Renders the stages in order. The first one sees the loaded sources; every
// later one sees the previous stage's output as its only source, so blur after
// scanlines softens the scanlines rather than the photographs.
//
// All stages share one Rng and run strictly in sequence, so a seed still
// reproduces the cascade byte for byte.
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
int width, int height, Rng& rng);
// 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<ChainStage>& stages);
// "Scanlines → Blur", for window titles and status lines.
std::string chainTitle(const std::vector<ChainStage>& stages);
// "scanlines-blur", for file names.
std::string chainSlug(const std::vector<ChainStage>& stages);
} // namespace effector
+5
View File
@@ -27,6 +27,11 @@ public:
virtual std::string description() const = 0; virtual std::string description() const = 0;
virtual const std::vector<ParamSpec>& params() const = 0; virtual const std::vector<ParamSpec>& 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<Image>& sources, int width, int height, Rng& rng, virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const = 0; const ParamValues& values) const = 0;
+443
View File
@@ -0,0 +1,443 @@
#include "core/project.hpp"
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <sstream>
#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<Json> items;
std::vector<std::pair<std::string, Json>> 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<char>::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<unsigned>(
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<char>(code));
} else if (code < 0x800) {
out.push_back(static_cast<char>(0xC0 | (code >> 6)));
out.push_back(static_cast<char>(0x80 | (code & 0x3F)));
} else {
out.push_back(static_cast<char>(0xE0 | (code >> 12)));
out.push_back(static_cast<char>(0x80 | ((code >> 6) & 0x3F)));
out.push_back(static_cast<char>(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<unsigned char>(c) < 0x20) {
char buffer[7];
std::snprintf(buffer, sizeof(buffer), "\\u%04x",
static_cast<unsigned>(static_cast<unsigned char>(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<long>(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<int>(width->number);
if (const auto* height = canvas->member("height")) {
project.height = static_cast<int>(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<std::uint64_t>(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<long>(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
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#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<std::string> sources; // absolute paths
std::vector<ChainStage> stages;
int width = 1920;
int height = 1080;
std::optional<std::uint64_t> 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
+2
View File
@@ -1,5 +1,6 @@
#include "core/registry.hpp" #include "core/registry.hpp"
#include "effects/blur.hpp"
#include "effects/scanlines.hpp" #include "effects/scanlines.hpp"
namespace effector { namespace effector {
@@ -8,6 +9,7 @@ namespace effector {
const std::vector<EffectPtr>& effects() { const std::vector<EffectPtr>& effects() {
static const std::vector<EffectPtr> registry = { static const std::vector<EffectPtr> registry = {
std::make_shared<ScanlinesEffect>(), std::make_shared<ScanlinesEffect>(),
std::make_shared<BlurEffect>(),
}; };
return registry; return registry;
} }
+136
View File
@@ -0,0 +1,136 @@
#include "effects/blur.hpp"
#include <algorithm>
#include <cstring>
#include <vector>
#include "core/parallel.hpp"
namespace effector {
const std::vector<ParamSpec>& BlurEffect::params() const {
static const std::vector<ParamSpec> 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<std::size_t>(width) * 3;
const int copyWidth = std::min(width, src.width() - sx);
const std::size_t copyBytes = static_cast<std::size_t>(copyWidth) * 3;
parallelFor(static_cast<std::size_t>(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<int>(y) % std::max(1, src.height() - sy));
const std::uint8_t* in = src.row(srcY) + static_cast<std::size_t>(sx) * 3;
std::uint8_t* out = canvas.row(static_cast<int>(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::size_t>(std::min(i, count - 1)) * stride + c];
}
}
for (int i = 0; i < count; ++i) {
for (int c = 0; c < 3; ++c) {
out[static_cast<std::size_t>(i) * stride + c] =
static_cast<std::uint8_t>(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<std::size_t>(add) * stride + c] -
in[static_cast<std::size_t>(drop) * stride + c];
}
}
}
void blurHorizontal(const Image& src, Image& dst, int radius) {
parallelFor(static_cast<std::size_t>(src.height()), [&](std::size_t begin, std::size_t end) {
for (std::size_t y = begin; y < end; ++y) {
boxPass(src.row(static_cast<int>(y)), dst.row(static_cast<int>(y)), src.width(), 3,
radius);
}
});
}
void blurVertical(const Image& src, Image& dst, int radius) {
const std::size_t stride = src.stride();
parallelFor(static_cast<std::size_t>(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<Image>& 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<int>(std::max(1L, paramInt(values, "radius", 8)));
const int passes = static_cast<int>(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<int>(rng.inRange(0, std::max(0, src.width() - width)));
const int sy = static_cast<int>(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
+25
View File
@@ -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<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+444 -72
View File
@@ -1,9 +1,12 @@
#include "gui/window.hpp" #include "gui/window.hpp"
#include <algorithm>
#include <chrono> #include <chrono>
#include <filesystem> #include <filesystem>
#include <sstream> #include <sstream>
#include "core/chain.hpp"
#include "core/project.hpp"
#include "core/registry.hpp" #include "core/registry.hpp"
#include "core/rng.hpp" #include "core/rng.hpp"
@@ -14,6 +17,15 @@ const char* const kImagePatterns[] = {"*.png", "*.jpg", "*.jpeg", "*.webp", "*
"*.tif", "*.tiff", "*.gif", "*.ppm", "*.PNG", "*.tif", "*.tiff", "*.gif", "*.ppm", "*.PNG",
"*.JPG", "*.JPEG", "*.WEBP"}; "*.JPG", "*.JPEG", "*.WEBP"};
Glib::RefPtr<Gio::ListStore<Gtk::FileFilter>> projectFilters() {
auto filter = Gtk::FileFilter::create();
filter->set_name("Effector projects");
filter->add_pattern("*.json");
auto filters = Gio::ListStore<Gtk::FileFilter>::create();
filters->append(filter);
return filters;
}
Glib::RefPtr<Gdk::Texture> toTexture(const Image& image) { Glib::RefPtr<Gdk::Texture> toTexture(const Image& image) {
auto bytes = Glib::Bytes::create(image.data(), image.byteSize()); auto bytes = Glib::Bytes::create(image.data(), image.byteSize());
return Gdk::MemoryTexture::create(image.width(), image.height(), Gdk::MemoryTexture::Format::R8G8B8, 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); set_default_size(1180, 760);
effects_ = effects(); effects_ = effects();
effect_ = effects_.front(); stages_.push_back({effects_.front(), effects_.front()->defaults()});
seed_ = Rng::randomSeed(); seed_ = Rng::randomSeed();
renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished)); renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished));
@@ -45,7 +57,13 @@ EffectorWindow::EffectorWindow(const Options& initial) {
applyInitial(initial); applyInitial(initial);
suspendRender_ = false; suspendRender_ = false;
if (!sources_->empty()) startRender(); if (loading_) {
placeholder_.set_text(missingText());
} else if (canRender()) {
startRender();
} else {
showPlaceholder();
}
} }
EffectorWindow::~EffectorWindow() { EffectorWindow::~EffectorWindow() {
@@ -62,6 +80,11 @@ void EffectorWindow::buildUi() {
auto* header = Gtk::make_managed<Gtk::HeaderBar>(); auto* header = Gtk::make_managed<Gtk::HeaderBar>();
set_titlebar(*header); set_titlebar(*header);
menuButton_.set_icon_name("open-menu-symbolic");
menuButton_.set_tooltip_text("Project menu");
menuButton_.set_popover(static_cast<Gtk::PopoverMenu&>(buildMainMenu()));
header->pack_end(menuButton_);
auto* addButton = Gtk::make_managed<Gtk::Button>(); auto* addButton = Gtk::make_managed<Gtk::Button>();
addButton->set_icon_name("list-add-symbolic"); addButton->set_icon_name("list-add-symbolic");
addButton->set_tooltip_text("Add images"); addButton->set_tooltip_text("Add images");
@@ -69,14 +92,6 @@ void EffectorWindow::buildUi() {
header->pack_start(*addButton); header->pack_start(*addButton);
addButton_ = addButton; addButton_ = addButton;
std::vector<Glib::ustring> titles;
for (const auto& effect : effects_) titles.push_back(effect->title());
effectDropdown_.set_model(Gtk::StringList::create(titles));
effectDropdown_.set_tooltip_text("Effect");
effectDropdown_.property_selected().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onEffectChanged));
header->pack_start(effectDropdown_);
saveButton_.set_sensitive(false); saveButton_.set_sensitive(false);
saveButton_.signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onSave)); saveButton_.signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onSave));
header->pack_end(saveButton_); header->pack_end(saveButton_);
@@ -148,10 +163,15 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
grid->attach(heightSpin_, 1, 1); grid->attach(heightSpin_, 1, 1);
sidebar->append(*grid); 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_column_spacing(8);
paramGrid_.set_row_spacing(6); paramGrid_.set_row_spacing(6);
sidebar->append(paramGrid_); sidebar->append(paramGrid_);
rebuildChainRows();
buildParamWidgets(); buildParamWidgets();
auto* seedBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8); auto* seedBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
@@ -202,8 +222,16 @@ void EffectorWindow::buildParamWidgets() {
paramWidgets_.clear(); paramWidgets_.clear();
ownedWidgets_.clear(); ownedWidgets_.clear();
if (stages_.empty()) {
paramHeading_.set_text("No effects");
return;
}
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
paramHeading_.set_text(std::to_string(selectedStage_ + 1) + ". " + stage.effect->title() +
" settings");
int row = 0; int row = 0;
for (const auto& spec : effect_->params()) { for (const auto& spec : stage.effect->params()) {
auto label = std::make_unique<Gtk::Label>(spec.label); auto label = std::make_unique<Gtk::Label>(spec.label);
label->set_xalign(0.0F); label->set_xalign(0.0F);
if (!spec.help.empty()) label->set_tooltip_text(spec.help); if (!spec.help.empty()) label->set_tooltip_text(spec.help);
@@ -213,7 +241,7 @@ void EffectorWindow::buildParamWidgets() {
case ParamSpec::Type::Bool: { case ParamSpec::Type::Bool: {
auto toggle = std::make_unique<Gtk::Switch>(); auto toggle = std::make_unique<Gtk::Switch>();
toggle->set_halign(Gtk::Align::START); 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( toggle->property_active().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(toggle); widget = std::move(toggle);
@@ -225,7 +253,10 @@ void EffectorWindow::buildParamWidgets() {
spin->set_range(spec.minimum, spec.maximum); spin->set_range(spec.minimum, spec.maximum);
spin->set_increments(spec.step, spec.step * 10); spin->set_increments(spec.step, spec.step * 10);
spin->set_digits(spec.type == ParamSpec::Type::Int ? 0 : spec.digits); spin->set_digits(spec.type == ParamSpec::Type::Int ? 0 : spec.digits);
spin->set_value(spec.defaultValue); spin->set_value(spec.type == ParamSpec::Type::Int
? static_cast<double>(paramInt(stage.values, spec.key,
static_cast<long>(spec.defaultValue)))
: paramDouble(stage.values, spec.key, spec.defaultValue));
spin->signal_value_changed().connect( spin->signal_value_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(spin); widget = std::move(spin);
@@ -235,8 +266,9 @@ void EffectorWindow::buildParamWidgets() {
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end()); std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
auto dropdown = std::make_unique<Gtk::DropDown>(); auto dropdown = std::make_unique<Gtk::DropDown>();
dropdown->set_model(Gtk::StringList::create(choices)); 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) { 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( dropdown->property_selected().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
@@ -256,7 +288,8 @@ void EffectorWindow::buildParamWidgets() {
} }
ParamValues EffectorWindow::paramValues() const { ParamValues EffectorWindow::paramValues() const {
ParamValues values = effect_->defaults(); if (stages_.empty()) return {};
ParamValues values = stages_[static_cast<std::size_t>(selectedStage_)].values;
for (const auto& entry : paramWidgets_) { for (const auto& entry : paramWidgets_) {
switch (entry.spec.type) { switch (entry.spec.type) {
case ParamSpec::Type::Bool: case ParamSpec::Type::Bool:
@@ -279,43 +312,28 @@ ParamValues EffectorWindow::paramValues() const {
return values; return values;
} }
void EffectorWindow::applyInitial(const Options& initial) { // The widgets only ever show one stage, so their values are written back
for (std::size_t i = 0; i < effects_.size(); ++i) { // whenever they change; the other stages keep the settings they were given.
if (effects_[i]->name() == initial.effect) effectDropdown_.set_selected(i); void EffectorWindow::storeParams() {
if (stages_.empty()) return;
stages_[static_cast<std::size_t>(selectedStage_)].values = paramValues();
} }
void EffectorWindow::applyInitial(const Options& initial) {
// `-e scanlines -e blur` starts the interface with that cascade already set
// up, flags included.
auto stages = buildChain(initial);
if (!stages.empty()) stages_ = std::move(stages);
selectedStage_ = 0;
rebuildChainRows();
buildParamWidgets();
widthSpin_.set_value(initial.width); widthSpin_.set_value(initial.width);
heightSpin_.set_value(initial.height); heightSpin_.set_value(initial.height);
if (initial.seed) { if (initial.seed) {
seed_ = *initial.seed; seed_ = *initial.seed;
seedEntry_.set_text(std::to_string(seed_)); seedEntry_.set_text(std::to_string(seed_));
} }
for (const auto& entry : paramWidgets_) {
const auto it = initial.params.find(entry.spec.key);
if (it == initial.params.end()) continue;
switch (entry.spec.type) {
case ParamSpec::Type::Bool:
static_cast<Gtk::Switch*>(entry.widget)
->set_active(paramBool(initial.params, entry.spec.key, false));
break;
case ParamSpec::Type::Int:
static_cast<Gtk::SpinButton*>(entry.widget)
->set_value(static_cast<double>(paramInt(initial.params, entry.spec.key, 0)));
break;
case ParamSpec::Type::Double:
static_cast<Gtk::SpinButton*>(entry.widget)
->set_value(paramDouble(initial.params, entry.spec.key, 0.0));
break;
case ParamSpec::Type::Choice: {
const auto value = paramChoice(initial.params, entry.spec.key, entry.spec.defaultChoice);
for (std::size_t i = 0; i < entry.spec.choices.size(); ++i) {
if (entry.spec.choices[i] == value) {
static_cast<Gtk::DropDown*>(entry.widget)->set_selected(i);
}
}
break;
}
}
}
if (!initial.images.empty()) addPaths(initial.images); if (!initial.images.empty()) addPaths(initial.images);
} }
@@ -421,10 +439,19 @@ void EffectorWindow::onLoadFinished() {
if (loadCancel_.load(std::memory_order_relaxed)) { if (loadCancel_.load(std::memory_order_relaxed)) {
// A cancelled batch is incomplete, so none of it is taken. // A cancelled batch is incomplete, so none of it is taken.
loadQueue_.clear(); loadQueue_.clear();
dropLoadedImages_ = false;
showError("Loading cancelled."); showError("Loading cancelled.");
return; 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()); for (const auto& image : images) appendSourceRow(image.path());
mutateSources([&images](SourceList& list) { mutateSources([&images](SourceList& list) {
for (auto& image : images) list.push_back(std::move(image)); for (auto& image : images) list.push_back(std::move(image));
@@ -548,15 +575,7 @@ void EffectorWindow::removeSource(Gtk::ListBoxRow* row) {
} }
}); });
sourceList_.remove(*row); sourceList_.remove(*row);
if (!sources_->empty()) {
requestRender(); requestRender();
} else {
result_ = Image();
saveButton_.set_sensitive(false);
previewStack_.set_visible_child("placeholder");
status_.set_text("");
}
} }
void EffectorWindow::onAddImages() { void EffectorWindow::onAddImages() {
@@ -589,17 +608,168 @@ void EffectorWindow::onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result
if (!paths.empty()) addPaths(paths); if (!paths.empty()) addPaths(paths);
} }
// ------------------------------------------------------------ rendering --- // ---------------------------------------------------------------- chain ---
void EffectorWindow::onEffectChanged() { Gtk::Widget& EffectorWindow::buildChainSection() {
const auto index = effectDropdown_.get_selected(); auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
if (index >= effects_.size()) return; box->append(*sectionLabel("Effect chain"));
effect_ = effects_[index];
chainList_.add_css_class("boxed-list");
chainList_.set_selection_mode(Gtk::SelectionMode::SINGLE);
auto* empty = Gtk::make_managed<Gtk::Label>("No effects yet");
empty->set_margin_top(12);
empty->set_margin_bottom(12);
chainList_.set_placeholder(*empty);
chainList_.signal_row_selected().connect(
[this](Gtk::ListBoxRow*) { onStageSelected(); });
box->append(chainList_);
// set_label() and set_icon_name() replace one another, so the icon and the
// text are put together by hand.
auto* addBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
addBox->append(*Gtk::make_managed<Gtk::Image>(
Gio::ThemedIcon::create("list-add-symbolic")));
addBox->append(*Gtk::make_managed<Gtk::Label>("Add effect"));
addEffectButton_.set_child(*addBox);
addEffectButton_.set_always_show_arrow(true);
addEffectButton_.set_tooltip_text("Append an effect, applied to the output so far");
addEffectButton_.set_popover(static_cast<Gtk::Popover&>(buildAddMenu()));
addEffectButton_.set_halign(Gtk::Align::START);
box->append(addEffectButton_);
return *box;
}
// One entry per registered effect, so a new effect shows up here without this
// file knowing anything about it.
Gtk::Widget& EffectorWindow::buildAddMenu() {
auto* popover = Gtk::make_managed<Gtk::Popover>();
auto* list = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 2);
for (const auto& effect : effects_) {
auto* item = Gtk::make_managed<Gtk::Button>(effect->title());
item->add_css_class("flat");
item->set_tooltip_text(effect->description());
if (auto* child = dynamic_cast<Gtk::Label*>(item->get_child())) child->set_xalign(0.0F);
item->signal_clicked().connect([this, effect] {
addEffectButton_.popdown();
appendStage(effect);
});
list->append(*item);
}
popover->set_child(*list);
return *popover;
}
void EffectorWindow::appendStage(const EffectPtr& effect) {
stages_.push_back({effect, effect->defaults()});
selectedStage_ = static_cast<int>(stages_.size()) - 1;
rebuildChainRows();
buildParamWidgets(); buildParamWidgets();
requestRender(); requestRender();
} }
void EffectorWindow::onParamChanged() { requestRender(); } void EffectorWindow::removeStage(int index) {
if (index < 0 || static_cast<std::size_t>(index) >= stages_.size()) return;
stages_.erase(stages_.begin() + index);
selectedStage_ = std::min(selectedStage_, static_cast<int>(stages_.size()) - 1);
if (selectedStage_ < 0) selectedStage_ = 0;
rebuildChainRows();
buildParamWidgets();
requestRender();
}
void EffectorWindow::moveStage(int index, int delta) {
const int target = index + delta;
if (index < 0 || static_cast<std::size_t>(index) >= stages_.size()) return;
if (target < 0 || static_cast<std::size_t>(target) >= stages_.size()) return;
std::swap(stages_[static_cast<std::size_t>(index)], stages_[static_cast<std::size_t>(target)]);
selectedStage_ = target;
rebuildChainRows();
buildParamWidgets();
requestRender();
}
void EffectorWindow::rebuildChainRows() {
// Rebuilding drops and re-adds the selection, which would otherwise look
// like the user picking a different stage.
suspendStageSelect_ = true;
// Only the rows: the placeholder is a child of the list box as well, and
// removing it would leave the empty list blank.
while (auto* row = chainList_.get_row_at_index(0)) chainList_.remove(*row);
for (std::size_t i = 0; i < stages_.size(); ++i) {
const int index = static_cast<int>(i);
auto* row = Gtk::make_managed<Gtk::ListBoxRow>();
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 4);
box->set_margin_top(4);
box->set_margin_bottom(4);
box->set_margin_start(8);
box->set_margin_end(4);
auto* label = Gtk::make_managed<Gtk::Label>(std::to_string(index + 1) + ". " +
stages_[i].effect->title());
label->set_xalign(0.0F);
label->set_hexpand(true);
label->set_ellipsize(Pango::EllipsizeMode::END);
label->set_tooltip_text(index == 0 ? "Applied to the source images"
: "Applied to the output of the stage above");
box->append(*label);
struct Action {
const char* icon;
const char* tip;
int delta; // 0 means remove
};
for (const Action& action : {Action{"go-up-symbolic", "Move up", -1},
Action{"go-down-symbolic", "Move down", 1},
Action{"window-close-symbolic", "Remove", 0}}) {
auto* button = Gtk::make_managed<Gtk::Button>();
button->set_icon_name(action.icon);
button->set_tooltip_text(action.tip);
button->add_css_class("flat");
const int delta = action.delta;
button->signal_clicked().connect([this, index, delta] {
if (delta == 0) {
removeStage(index);
} else {
moveStage(index, delta);
}
});
if (delta == -1) button->set_sensitive(index > 0);
if (delta == 1) button->set_sensitive(static_cast<std::size_t>(index) + 1 < stages_.size());
box->append(*button);
}
row->set_child(*box);
chainList_.append(*row);
}
selectStage(selectedStage_);
suspendStageSelect_ = false;
}
void EffectorWindow::selectStage(int index) {
if (auto* row = chainList_.get_row_at_index(index)) chainList_.select_row(*row);
}
void EffectorWindow::onStageSelected() {
if (suspendStageSelect_) return;
auto* row = chainList_.get_selected_row();
if (row == nullptr) return;
if (row->get_index() == selectedStage_) return;
selectedStage_ = row->get_index();
buildParamWidgets();
}
// ------------------------------------------------------------ rendering ---
void EffectorWindow::onParamChanged() {
storeParams();
requestRender();
}
void EffectorWindow::onSeedEntered() { void EffectorWindow::onSeedEntered() {
try { try {
@@ -614,15 +784,40 @@ void EffectorWindow::onSeedEntered() {
void EffectorWindow::onRecreate() { void EffectorWindow::onRecreate() {
seed_ = Rng::randomSeed(); seed_ = Rng::randomSeed();
seedEntry_.set_text(std::to_string(seed_)); seedEntry_.set_text(std::to_string(seed_));
if (sources_->empty()) { if (!canRender()) {
showError("Add at least one source image first."); showError(missingText());
return; return;
} }
requestRender(); 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() { 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 // Spin buttons fire on every step while they are held down; coalesce those
// into one render instead of queueing a job per tick. // into one render instead of queueing a job per tick.
debounce_.disconnect(); debounce_.disconnect();
@@ -635,7 +830,7 @@ void EffectorWindow::requestRender() {
} }
void EffectorWindow::startRender() { void EffectorWindow::startRender() {
if (sources_->empty()) return; if (!canRender()) return;
if (rendering_) { if (rendering_) {
staleResult_ = true; // a fresh render follows this one staleResult_ = true; // a fresh render follows this one
return; return;
@@ -643,22 +838,21 @@ void EffectorWindow::startRender() {
rendering_ = true; rendering_ = true;
const auto sources = sources_; const auto sources = sources_;
const auto effect = effect_; const auto stages = stages_; // a snapshot, like the sources
const int width = static_cast<int>(widthSpin_.get_value()); const int width = static_cast<int>(widthSpin_.get_value());
const int height = static_cast<int>(heightSpin_.get_value()); const int height = static_cast<int>(heightSpin_.get_value());
const std::uint64_t seed = seed_; const std::uint64_t seed = seed_;
const ParamValues values = paramValues();
spinner_.start(); spinner_.start();
recreateButton_.set_sensitive(false); 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; Image image;
std::string error; std::string error;
const auto start = std::chrono::steady_clock::now(); const auto start = std::chrono::steady_clock::now();
try { try {
Rng rng(seed); Rng rng(seed);
image = effect->render(*sources, width, height, rng, values); image = renderChain(stages, *sources, width, height, rng);
} catch (const std::exception& exception) { } catch (const std::exception& exception) {
error = exception.what(); error = exception.what();
} }
@@ -714,12 +908,190 @@ void EffectorWindow::onRenderFinished() {
std::ostringstream status; std::ostringstream status;
status.setf(std::ios::fixed); status.setf(std::ios::fixed);
status.precision(1); status.precision(1);
status << effect_->title() << " · " << result_.width() << "×" << result_.height() << " · " status << chainTitle(stages_) << " · " << result_.width() << "×" << result_.height() << " · ";
<< sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · seed " if (!sources_->empty()) {
<< seed_ << " · " << millis << " ms"; status << sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · ";
}
status << "seed " << seed_ << " · " << millis << " ms";
status_.set_text(status.str()); 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[] = {{"<Control>n", "win.new-project"},
{"<Control>o", "win.open-project"},
{"<Control>s", "win.save-project"},
{"<Control><Shift>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<Gtk::PopoverMenu>(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<int>(widthSpin_.get_value());
project.height = static_cast<int>(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<Gio::AsyncResult>& result) {
onProjectChosen(result, dialog);
});
}
void EffectorWindow::onProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& 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<Gio::AsyncResult>& result) {
onSaveProjectChosen(result, dialog);
});
}
void EffectorWindow::onSaveProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& 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 --- // --------------------------------------------------------------- saving ---
void EffectorWindow::onSave() { void EffectorWindow::onSave() {
@@ -727,7 +1099,7 @@ void EffectorWindow::onSave() {
auto dialog = Gtk::FileDialog::create(); auto dialog = Gtk::FileDialog::create();
dialog->set_title("Save image"); dialog->set_title("Save image");
dialog->set_initial_name("effector-" + effect_->name() + "-" + std::to_string(seed_) + ".png"); dialog->set_initial_name("effector-" + chainSlug(stages_) + "-" + std::to_string(seed_) + ".png");
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) { dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
onSaveChosen(result, dialog); onSaveChosen(result, dialog);
}); });
+40 -3
View File
@@ -12,8 +12,10 @@
#include <vector> #include <vector>
#include "cli/options.hpp" #include "cli/options.hpp"
#include "core/chain.hpp"
#include "core/effect.hpp" #include "core/effect.hpp"
#include "core/image.hpp" #include "core/image.hpp"
#include "core/project.hpp"
namespace effector { namespace effector {
@@ -28,8 +30,12 @@ private:
Gtk::Widget& buildPreview(); Gtk::Widget& buildPreview();
void buildParamWidgets(); void buildParamWidgets();
ParamValues paramValues() const; ParamValues paramValues() const;
void storeParams(); // widgets -> the selected stage's values
void applyInitial(const Options& initial); 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 requestRender(); // coalesced: restarts the short debounce timer
void startRender(); // starts a worker, or marks the result stale void startRender(); // starts a worker, or marks the result stale
void onAddImages(); void onAddImages();
@@ -44,7 +50,29 @@ private:
void appendSourceRow(const std::string& path); void appendSourceRow(const std::string& path);
void removeSource(Gtk::ListBoxRow* row); 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<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& dialog);
void onSaveProject(); // falls back to Save As while the project is untitled
void onSaveProjectAs();
void onSaveProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& dialog);
void writeProject(const std::string& path);
void updateTitle();
void onParamChanged(); void onParamChanged();
void onSeedEntered(); void onSeedEntered();
void onRecreate(); void onRecreate();
@@ -66,9 +94,14 @@ private:
// Model // Model
std::shared_ptr<const SourceList> sources_ = std::make_shared<SourceList>(); std::shared_ptr<const SourceList> sources_ = std::make_shared<SourceList>();
std::vector<EffectPtr> effects_; std::vector<EffectPtr> effects_;
EffectPtr effect_; // The cascade: stage 0 works on the sources, every later stage on the
// output of the one before it.
std::vector<ChainStage> stages_;
int selectedStage_ = 0;
bool suspendStageSelect_ = false; // guards the rebuild of the chain rows
Image result_; Image result_;
std::uint64_t seed_ = 0; std::uint64_t seed_ = 0;
std::string projectPath_; // empty until the project has been saved once
bool suspendRender_ = true; bool suspendRender_ = true;
// Source loading. Decoding a batch of large photographs takes seconds, so // Source loading. Decoding a batch of large photographs takes seconds, so
@@ -85,6 +118,7 @@ private:
std::vector<LoadFailure> loadFailures_; std::vector<LoadFailure> loadFailures_;
std::vector<std::string> loadQueue_; // chosen while another batch was loading std::vector<std::string> loadQueue_; // chosen while another batch was loading
bool loading_ = false; bool loading_ = false;
bool dropLoadedImages_ = false; // the batch in flight belongs to a replaced project
sigc::connection loadWindowDelay_; sigc::connection loadWindowDelay_;
// Render job // Render job
@@ -109,8 +143,11 @@ private:
Gtk::SpinButton widthSpin_; Gtk::SpinButton widthSpin_;
Gtk::SpinButton heightSpin_; Gtk::SpinButton heightSpin_;
Gtk::Grid paramGrid_; Gtk::Grid paramGrid_;
Gtk::Label paramHeading_;
Gtk::Entry seedEntry_; Gtk::Entry seedEntry_;
Gtk::DropDown effectDropdown_; Gtk::ListBox chainList_;
Gtk::MenuButton addEffectButton_;
Gtk::MenuButton menuButton_;
Gtk::Button recreateButton_{"Recreate"}; Gtk::Button recreateButton_{"Recreate"};
Gtk::Button saveButton_{"Save As…"}; Gtk::Button saveButton_{"Save As…"};
Gtk::Picture picture_; Gtk::Picture picture_;
+6 -7
View File
@@ -8,6 +8,7 @@
#include <mutex> #include <mutex>
#include "cli/options.hpp" #include "cli/options.hpp"
#include "core/chain.hpp"
#include "core/effect.hpp" #include "core/effect.hpp"
#include "core/image.hpp" #include "core/image.hpp"
#include "core/registry.hpp" #include "core/registry.hpp"
@@ -17,7 +18,7 @@
namespace { namespace {
int renderHeadless(const effector::Options& options) { int renderHeadless(const effector::Options& options) {
const auto effect = effector::effectByName(options.effect); const auto stages = effector::buildChain(options);
std::vector<effector::LoadFailure> failures; std::vector<effector::LoadFailure> failures;
@@ -39,7 +40,7 @@ int renderHeadless(const effector::Options& options) {
for (const auto& failure : failures) { for (const auto& failure : failures) {
std::cerr << "effector: skipping " << failure.path << ": " << failure.message << "\n"; 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"; std::cerr << "effector: no usable source images\n";
return 2; return 2;
} }
@@ -47,17 +48,15 @@ int renderHeadless(const effector::Options& options) {
const std::uint64_t seed = options.seed.value_or(effector::Rng::randomSeed()); const std::uint64_t seed = options.seed.value_or(effector::Rng::randomSeed());
effector::Rng rng(seed); 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 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 = const auto renderMillis =
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count(); std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
image.save(options.output); image.save(options.output);
std::cout << options.output << " " << options.width << "x" << options.height 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"; << std::fixed << std::setprecision(1) << renderMillis << " ms\n";
return 0; return 0;
} }
+217
View File
@@ -4,13 +4,17 @@
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <filesystem> #include <filesystem>
#include <fstream>
#include <iostream> #include <iostream>
#include <mutex> #include <mutex>
#include <string> #include <string>
#include <vector> #include <vector>
#include "core/chain.hpp"
#include "core/project.hpp"
#include "core/registry.hpp" #include "core/registry.hpp"
#include "core/rng.hpp" #include "core/rng.hpp"
#include "effects/blur.hpp"
#include "effects/scanlines.hpp" #include "effects/scanlines.hpp"
namespace { 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()); 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<effector::Image>& 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<effector::Image>& sources, int width, int height, effector::Image render(const std::vector<effector::Image>& sources, int width, int height,
std::uint64_t seed, const effector::ParamValues& values) { std::uint64_t seed, const effector::ParamValues& values) {
effector::ScanlinesEffect effect; effector::ScanlinesEffect effect;
@@ -128,6 +155,196 @@ int main() {
check(threw, "no sources throws"); check(threw, "no sources throws");
} }
std::cout << "blur\n";
{
std::vector<Image> 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<Image> 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<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
ChainStage scanlines{effectByName("scanlines"), {{"strip_height", 6L}}};
ChainStage blur{effectByName("blur"), {{"radius", 6L}}};
Rng single(5);
const auto direct = renderChain({scanlines}, sources, 400, 300, single);
const auto alone = render(sources, 400, 300, 5, {{"strip_height", 6L}});
check(sameBytes(direct, alone), "a one stage chain matches the effect on its own");
Rng a(5);
Rng b(5);
const auto cascaded = renderChain({scanlines, blur}, sources, 400, 300, a);
const auto again = renderChain({scanlines, blur}, sources, 400, 300, b);
check(cascaded.width() == 400 && cascaded.height() == 300, "the chain keeps the canvas size");
check(sameBytes(cascaded, again), "the same seed reproduces the cascade");
check(!sameBytes(cascaded, direct), "a second stage changes the image");
// Order matters: blurring scanlines is not the same as scanning a blur.
Rng forward(5);
Rng backward(5);
const auto blurLast = renderChain({scanlines, blur}, sources, 400, 300, forward);
const auto blurFirst = renderChain({blur, scanlines}, sources, 400, 300, backward);
check(!sameBytes(blurLast, blurFirst), "the stage order changes the image");
check(chainTitle({scanlines, blur}) == "Scanlines \u2192 Blur", "the chain has a readable title");
check(chainSlug({scanlines, blur}) == "scanlines-blur", "the chain has a file name slug");
bool threw = false;
try {
Rng rng(1);
renderChain({}, sources, 100, 100, rng);
} catch (const EffectError&) {
threw = true;
}
check(threw, "an empty chain throws");
// 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<char>(stream)),
std::istreambuf_iterator<char>());
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<Image> 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"; std::cout << "image io\n";
{ {
const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png"; const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png";