Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67417727e7 | ||
|
|
82e730221e | ||
|
|
b6b5f94f08 | ||
|
|
6cb3da8dc6 | ||
|
|
4739d39a51 | ||
|
|
a82fba7886 | ||
|
|
89985cb989 | ||
|
|
ee4b1038ac |
@@ -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<string, variant<long,double,bool,string>>`), 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/<name>.{hpp,cpp}` subclassing `Effect`, one
|
||||
line in the registry list in `src/core/registry.cpp`, one line in the
|
||||
`effector_core` sources in `CMakeLists.txt`. Nothing else.
|
||||
`effector_core` sources in `CMakeLists.txt`. Nothing else — it shows up in the
|
||||
CLI, in the chain menu and as a cascade stage on its own.
|
||||
|
||||
### Effects cascade
|
||||
|
||||
Effects are never applied on their own: `renderChain` (`src/core/chain.cpp`)
|
||||
runs a `std::vector<ChainStage>` in order, handing stage 0 the loaded sources
|
||||
and every later stage the previous stage's output as its *only* source. That is
|
||||
the whole of the cascade — an effect neither knows nor cares whether its input
|
||||
came from disk. All stages share one `Rng` and run strictly in sequence, so a
|
||||
seed still reproduces the cascade byte for byte.
|
||||
|
||||
The interface builds the chain in the sidebar ("Effect chain": add from the
|
||||
menu, reorder, remove, one stage selected at a time with its params below), the
|
||||
CLI builds it from repeated `-e`: `-e scanlines --strip-height 6 -e blur
|
||||
--radius 5`. Flags share one namespace across all effects, so `buildChain`
|
||||
(`src/cli/options.cpp`) hands each flag to the stage whose effect declares it —
|
||||
two effects must not declare the same param key.
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -20,11 +20,15 @@ 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/grain.cpp
|
||||
src/effects/scanlines.cpp
|
||||
)
|
||||
target_include_directories(effector_core PUBLIC src)
|
||||
|
||||
+30
-7
@@ -4,6 +4,7 @@
|
||||
#include <cstring>
|
||||
#include <sstream>
|
||||
|
||||
#include "core/chain.hpp"
|
||||
#include "core/effect.hpp"
|
||||
#include "core/registry.hpp"
|
||||
|
||||
@@ -23,7 +24,10 @@ bool parseSize(const std::string& text, int& width, int& height) {
|
||||
long w = 0;
|
||||
long h = 0;
|
||||
if (!parseLong(text.substr(0, sep), w) || !parseLong(text.substr(sep + 1), h)) return false;
|
||||
if (w <= 0 || h <= 0) return false;
|
||||
// Checked as longs, before the narrowing: 4294968296 cast straight to int
|
||||
// is 1000, which would render a plausible canvas nobody asked for.
|
||||
if (w < kMinCanvasSide || w > kMaxCanvasSide) return false;
|
||||
if (h < kMinCanvasSide || h > kMaxCanvasSide) return false;
|
||||
width = static_cast<int>(w);
|
||||
height = static_cast<int>(h);
|
||||
return true;
|
||||
@@ -48,7 +52,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,14 +61,17 @@ 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") {
|
||||
const std::string value = requireValue(argc, argv, i, arg);
|
||||
if (!parseSize(value, options.width, options.height)) {
|
||||
throw EffectError("invalid size '" + value + "', expected e.g. 1920x1080");
|
||||
throw EffectError("invalid size '" + value + "', expected e.g. 1920x1080 with "
|
||||
"each side between " + std::to_string(kMinCanvasSide) + " and " +
|
||||
std::to_string(kMaxCanvasSide));
|
||||
}
|
||||
} else if (arg == "--seed") {
|
||||
const std::string value = requireValue(argc, argv, i, arg);
|
||||
@@ -119,6 +125,7 @@ Options parseOptions(int argc, char** argv) {
|
||||
options.images.push_back(arg);
|
||||
}
|
||||
}
|
||||
if (options.effects.empty()) options.effects.push_back(defaultEffectName());
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -127,9 +134,10 @@ 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"
|
||||
<< " -s, --size WxH canvas size, each side " << kMinCanvasSide << "-"
|
||||
<< kMaxCanvasSide << " (default: 1920x1080)\n"
|
||||
<< " --seed N random seed (default: random)\n"
|
||||
<< " --list-effects list available effects and exit\n"
|
||||
<< " -h, --help show this help and exit\n";
|
||||
@@ -147,4 +155,19 @@ std::string usage(const char* programName) {
|
||||
return out.str();
|
||||
}
|
||||
|
||||
std::vector<ChainStage> buildChain(const Options& options) {
|
||||
std::vector<ChainStage> stages;
|
||||
for (const auto& name : options.effects) {
|
||||
ChainStage stage;
|
||||
stage.effect = effectByName(name);
|
||||
stage.values = stage.effect->defaults();
|
||||
for (const auto& spec : stage.effect->params()) {
|
||||
const auto it = options.params.find(spec.key);
|
||||
if (it != options.params.end()) stage.values[spec.key] = it->second;
|
||||
}
|
||||
stages.push_back(std::move(stage));
|
||||
}
|
||||
return stages;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
|
||||
+7
-1
@@ -5,13 +5,14 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/chain.hpp"
|
||||
#include "core/params.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
struct Options {
|
||||
std::vector<std::string> images;
|
||||
std::string effect;
|
||||
std::vector<std::string> effects; // one per cascade stage, in order
|
||||
std::string output; // empty -> launch the interface
|
||||
int width = 1920;
|
||||
int height = 1080;
|
||||
@@ -27,4 +28,9 @@ Options parseOptions(int argc, char** argv);
|
||||
|
||||
std::string usage(const char* programName);
|
||||
|
||||
// Turns the parsed names and flags into cascade stages: every flag goes to the
|
||||
// stage whose effect declares it, so --strip-height and --radius can be given
|
||||
// together for `-e scanlines -e blur`.
|
||||
std::vector<ChainStage> buildChain(const Options& options);
|
||||
|
||||
} // namespace effector
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/effect.hpp"
|
||||
#include "core/image.hpp"
|
||||
#include "core/params.hpp"
|
||||
#include "core/rng.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// The canvas sides both front-ends accept. The upper bound is not a hardware
|
||||
// limit but the point past which a side stops fitting comfortably in an int
|
||||
// once multiplied out, so both the CLI and the project reader can reject a
|
||||
// number rather than silently wrap it into a plausible-looking one.
|
||||
inline constexpr int kMinCanvasSide = 1;
|
||||
inline constexpr int kMaxCanvasSide = 20000;
|
||||
|
||||
// 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
|
||||
@@ -27,6 +27,11 @@ public:
|
||||
virtual std::string description() 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,
|
||||
const ParamValues& values) const = 0;
|
||||
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
#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;
|
||||
}
|
||||
|
||||
// A canvas side is checked as a double, before it is narrowed: casting a
|
||||
// number outside the range of an int is undefined, and even where it wraps
|
||||
// quietly it turns a size the file never named into one that looks ordinary.
|
||||
int canvasSide(const Json& value, const char* what) {
|
||||
if (value.kind != Json::Kind::Number || value.number != std::trunc(value.number)) {
|
||||
throw EffectError(std::string("the canvas ") + what + " must be a whole number");
|
||||
}
|
||||
if (!(value.number >= kMinCanvasSide) || !(value.number <= kMaxCanvasSide)) {
|
||||
throw EffectError(std::string("the canvas ") + what + " must be between " +
|
||||
std::to_string(kMinCanvasSide) + " and " +
|
||||
std::to_string(kMaxCanvasSide) + " pixels");
|
||||
}
|
||||
return static_cast<int>(value.number);
|
||||
}
|
||||
|
||||
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 = canvasSide(*width, "width");
|
||||
if (const auto* height = canvas->member("height")) {
|
||||
project.height = canvasSide(*height, "height");
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "core/registry.hpp"
|
||||
|
||||
#include "effects/blur.hpp"
|
||||
#include "effects/grain.hpp"
|
||||
#include "effects/scanlines.hpp"
|
||||
|
||||
namespace effector {
|
||||
@@ -8,6 +10,8 @@ namespace effector {
|
||||
const std::vector<EffectPtr>& effects() {
|
||||
static const std::vector<EffectPtr> registry = {
|
||||
std::make_shared<ScanlinesEffect>(),
|
||||
std::make_shared<BlurEffect>(),
|
||||
std::make_shared<GrainEffect>(),
|
||||
};
|
||||
return registry;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
#include "effects/blur.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/source_fill.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 {
|
||||
|
||||
// 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
|
||||
@@ -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
|
||||
@@ -0,0 +1,90 @@
|
||||
#include "effects/grain.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& GrainEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeDouble("grain_amount", "Amount", 0.35, 0.0, 1.0,
|
||||
"How far a grain may push a pixel, as a fraction of full scale."),
|
||||
ParamSpec::makeInt("grain_size", "Grain size", 1, 1, 32,
|
||||
"Width in pixels of one grain; larger sizes look coarser."),
|
||||
ParamSpec::makeBool("grain_colour", "Coloured grain", false,
|
||||
"Draw the channels separately instead of one grey grain per pixel."),
|
||||
ParamSpec::makeBool("grain_midtones", "Weight towards midtones", true,
|
||||
"Fade the grain out in the shadows and highlights, as film does."),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Response curve: 1 at mid grey, falling to 0 at black and at white, which is
|
||||
// roughly how grain behaves on film.
|
||||
inline int midtoneWeight(int value) { return 255 - std::abs(2 * value - 255); }
|
||||
|
||||
} // namespace
|
||||
|
||||
Image GrainEffect::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 double amount = std::clamp(paramDouble(values, "grain_amount", 0.35), 0.0, 1.0);
|
||||
const int size = static_cast<int>(std::max(1L, paramInt(values, "grain_size", 1)));
|
||||
const bool colour = paramBool(values, "grain_colour", false);
|
||||
const bool midtones = paramBool(values, "grain_midtones", true);
|
||||
|
||||
Image canvas(width, height);
|
||||
if (!sources.empty()) {
|
||||
// Drawn before any threading, so that a seed always gives the same image.
|
||||
const std::size_t index = 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);
|
||||
}
|
||||
|
||||
if (amount <= 0.0) return canvas;
|
||||
|
||||
// The whole grain field is drawn serially from the rng, exactly like the
|
||||
// scanlines plan, and only the pixel arithmetic afterwards is parallel.
|
||||
const int cellsX = (width + size - 1) / size;
|
||||
const int cellsY = (height + size - 1) / size;
|
||||
const int channels = colour ? 3 : 1;
|
||||
std::vector<std::int8_t> field(static_cast<std::size_t>(cellsX) * cellsY * channels);
|
||||
for (auto& cell : field) cell = static_cast<std::int8_t>(rng.inRange(-127, 127));
|
||||
|
||||
// 127 * 255 is the largest product of a grain and a midtone weight; scaling
|
||||
// by `amount` there keeps the whole thing in integers.
|
||||
const int scale = static_cast<int>(amount * 255.0 * 65536.0 / 127.0);
|
||||
|
||||
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t y = begin; y < end; ++y) {
|
||||
std::uint8_t* out = canvas.row(static_cast<int>(y));
|
||||
const std::size_t rowCell =
|
||||
static_cast<std::size_t>(static_cast<int>(y) / size) * cellsX;
|
||||
|
||||
for (int x = 0; x < width; ++x) {
|
||||
const std::size_t cell = (rowCell + static_cast<std::size_t>(x / size)) * channels;
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
const int value = out[static_cast<std::size_t>(x) * 3 + c];
|
||||
const int grain = field[cell + (colour ? static_cast<std::size_t>(c) : 0)];
|
||||
const int weight = midtones ? midtoneWeight(value) : 255;
|
||||
const int delta = grain * weight / 255 * scale / 65536;
|
||||
out[static_cast<std::size_t>(x) * 3 + c] =
|
||||
static_cast<std::uint8_t>(std::clamp(value + delta, 0, 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// Scatters film-like grain over its input. Like the blur it needs no source
|
||||
// images of its own, so it can lead a chain: given sources it fills the canvas
|
||||
// from one of them first, given none it grains an empty canvas — which with the
|
||||
// midtone weighting on stays black, exactly as an unexposed film would.
|
||||
class GrainEffect : public Effect {
|
||||
public:
|
||||
std::string name() const override { return "grain"; }
|
||||
std::string title() const override { return "Film grain"; }
|
||||
std::string description() const override {
|
||||
return "Scatters film-like grain over its input, optionally coloured and weighted towards "
|
||||
"the midtones; 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
|
||||
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#include "core/image.hpp"
|
||||
#include "core/parallel.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// 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.
|
||||
// Shared by the effects that do not build an image of their own but still want
|
||||
// something to work on when they lead a chain.
|
||||
inline 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
+502
-91
@@ -1,9 +1,12 @@
|
||||
#include "gui/window.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
#include <sstream>
|
||||
|
||||
#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<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) {
|
||||
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,20 +80,10 @@ void EffectorWindow::buildUi() {
|
||||
auto* header = Gtk::make_managed<Gtk::HeaderBar>();
|
||||
set_titlebar(*header);
|
||||
|
||||
auto* addButton = Gtk::make_managed<Gtk::Button>();
|
||||
addButton->set_icon_name("list-add-symbolic");
|
||||
addButton->set_tooltip_text("Add images");
|
||||
addButton->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onAddImages));
|
||||
header->pack_start(*addButton);
|
||||
addButton_ = addButton;
|
||||
|
||||
std::vector<Glib::ustring> titles;
|
||||
for (const auto& effect : effects_) titles.push_back(effect->title());
|
||||
effectDropdown_.set_model(Gtk::StringList::create(titles));
|
||||
effectDropdown_.set_tooltip_text("Effect");
|
||||
effectDropdown_.property_selected().signal_changed().connect(
|
||||
sigc::mem_fun(*this, &EffectorWindow::onEffectChanged));
|
||||
header->pack_start(effectDropdown_);
|
||||
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_);
|
||||
|
||||
saveButton_.set_sensitive(false);
|
||||
saveButton_.signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onSave));
|
||||
@@ -105,33 +113,73 @@ void EffectorWindow::buildUi() {
|
||||
root->append(*paned);
|
||||
}
|
||||
|
||||
// The chain is what the interface is about, so it comes first and the params
|
||||
// of the selected stage sit right below it. The sources are a prerequisite,
|
||||
// not the subject: they live at the bottom in a collapsed expander.
|
||||
Gtk::Widget& EffectorWindow::buildSidebar() {
|
||||
auto* sidebar = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
|
||||
sidebar->set_margin(12);
|
||||
|
||||
sidebar->append(*sectionLabel("Source images"));
|
||||
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);
|
||||
auto* paramScroller = Gtk::make_managed<Gtk::ScrolledWindow>();
|
||||
paramScroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
|
||||
paramScroller->set_child(paramGrid_);
|
||||
paramScroller->set_vexpand(true);
|
||||
sidebar->append(*paramScroller);
|
||||
rebuildChainRows();
|
||||
buildParamWidgets();
|
||||
|
||||
sidebar->append(*Gtk::make_managed<Gtk::Separator>(Gtk::Orientation::HORIZONTAL));
|
||||
|
||||
sourceList_.set_selection_mode(Gtk::SelectionMode::NONE);
|
||||
sourceList_.add_css_class("boxed-list");
|
||||
auto* empty = Gtk::make_managed<Gtk::Label>("No images yet");
|
||||
empty->set_margin_top(18);
|
||||
empty->set_margin_bottom(18);
|
||||
empty->set_margin_top(12);
|
||||
empty->set_margin_bottom(12);
|
||||
sourceList_.set_placeholder(*empty);
|
||||
|
||||
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
|
||||
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
|
||||
scroller->set_child(sourceList_);
|
||||
scroller->set_size_request(-1, 170);
|
||||
scroller->set_vexpand(true);
|
||||
sidebar->append(*scroller);
|
||||
scroller->set_size_request(-1, 140);
|
||||
scroller->set_margin_top(6);
|
||||
|
||||
auto* sourcesBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
|
||||
sourcesBox->append(*scroller);
|
||||
|
||||
// Same shape as "Add effect": the icon and the text are put together by
|
||||
// hand because set_label() and set_icon_name() replace one another.
|
||||
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 images"));
|
||||
auto* addButton = Gtk::make_managed<Gtk::Button>();
|
||||
addButton->set_child(*addBox);
|
||||
addButton->set_halign(Gtk::Align::START);
|
||||
addButton->set_tooltip_text("Add source images");
|
||||
addButton->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onAddImages));
|
||||
sourcesBox->append(*addButton);
|
||||
addButton_ = addButton;
|
||||
|
||||
sourcesExpander_.set_child(*sourcesBox);
|
||||
sourcesExpander_.set_expanded(false);
|
||||
updateSourcesLabel();
|
||||
sidebar->append(sourcesExpander_);
|
||||
|
||||
sidebar->append(*sectionLabel("Canvas"));
|
||||
auto* grid = Gtk::make_managed<Gtk::Grid>();
|
||||
grid->set_column_spacing(8);
|
||||
grid->set_row_spacing(6);
|
||||
widthSpin_.set_range(1, 20000);
|
||||
widthSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
|
||||
widthSpin_.set_increments(1, 100);
|
||||
widthSpin_.set_value(1920);
|
||||
heightSpin_.set_range(1, 20000);
|
||||
heightSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
|
||||
heightSpin_.set_increments(1, 100);
|
||||
heightSpin_.set_value(1080);
|
||||
for (auto* spin : {&widthSpin_, &heightSpin_}) {
|
||||
@@ -148,12 +196,6 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
|
||||
grid->attach(heightSpin_, 1, 1);
|
||||
sidebar->append(*grid);
|
||||
|
||||
sidebar->append(*sectionLabel("Effect settings"));
|
||||
paramGrid_.set_column_spacing(8);
|
||||
paramGrid_.set_row_spacing(6);
|
||||
sidebar->append(paramGrid_);
|
||||
buildParamWidgets();
|
||||
|
||||
auto* seedBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
|
||||
auto* seedLabel = Gtk::make_managed<Gtk::Label>("Seed");
|
||||
seedLabel->set_xalign(0.0F);
|
||||
@@ -167,6 +209,15 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
|
||||
return *sidebar;
|
||||
}
|
||||
|
||||
// The expander is collapsed by default, so its label carries the count that
|
||||
// used to be plain to see in the list.
|
||||
void EffectorWindow::updateSourcesLabel() {
|
||||
int count = 0;
|
||||
while (sourceList_.get_row_at_index(count) != nullptr) ++count;
|
||||
sourcesExpander_.set_label(count == 0 ? "Source images"
|
||||
: "Source images (" + std::to_string(count) + ")");
|
||||
}
|
||||
|
||||
Gtk::Widget& EffectorWindow::buildPreview() {
|
||||
picture_.set_can_shrink(true);
|
||||
picture_.set_content_fit(Gtk::ContentFit::CONTAIN);
|
||||
@@ -202,8 +253,16 @@ void EffectorWindow::buildParamWidgets() {
|
||||
paramWidgets_.clear();
|
||||
ownedWidgets_.clear();
|
||||
|
||||
if (stages_.empty()) {
|
||||
paramHeading_.set_text("No effects");
|
||||
return;
|
||||
}
|
||||
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
|
||||
paramHeading_.set_text(std::to_string(selectedStage_ + 1) + ". " + stage.effect->title() +
|
||||
" settings");
|
||||
|
||||
int row = 0;
|
||||
for (const auto& spec : effect_->params()) {
|
||||
for (const auto& spec : stage.effect->params()) {
|
||||
auto label = std::make_unique<Gtk::Label>(spec.label);
|
||||
label->set_xalign(0.0F);
|
||||
if (!spec.help.empty()) label->set_tooltip_text(spec.help);
|
||||
@@ -213,7 +272,7 @@ void EffectorWindow::buildParamWidgets() {
|
||||
case ParamSpec::Type::Bool: {
|
||||
auto toggle = std::make_unique<Gtk::Switch>();
|
||||
toggle->set_halign(Gtk::Align::START);
|
||||
toggle->set_active(spec.defaultValue != 0.0);
|
||||
toggle->set_active(paramBool(stage.values, spec.key, spec.defaultValue != 0.0));
|
||||
toggle->property_active().signal_changed().connect(
|
||||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||||
widget = std::move(toggle);
|
||||
@@ -225,7 +284,10 @@ void EffectorWindow::buildParamWidgets() {
|
||||
spin->set_range(spec.minimum, spec.maximum);
|
||||
spin->set_increments(spec.step, spec.step * 10);
|
||||
spin->set_digits(spec.type == ParamSpec::Type::Int ? 0 : spec.digits);
|
||||
spin->set_value(spec.defaultValue);
|
||||
spin->set_value(spec.type == ParamSpec::Type::Int
|
||||
? static_cast<double>(paramInt(stage.values, spec.key,
|
||||
static_cast<long>(spec.defaultValue)))
|
||||
: paramDouble(stage.values, spec.key, spec.defaultValue));
|
||||
spin->signal_value_changed().connect(
|
||||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||||
widget = std::move(spin);
|
||||
@@ -235,8 +297,9 @@ void EffectorWindow::buildParamWidgets() {
|
||||
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
|
||||
auto dropdown = std::make_unique<Gtk::DropDown>();
|
||||
dropdown->set_model(Gtk::StringList::create(choices));
|
||||
const auto current = paramChoice(stage.values, spec.key, spec.defaultChoice);
|
||||
for (std::size_t i = 0; i < spec.choices.size(); ++i) {
|
||||
if (spec.choices[i] == spec.defaultChoice) dropdown->set_selected(i);
|
||||
if (spec.choices[i] == current) dropdown->set_selected(i);
|
||||
}
|
||||
dropdown->property_selected().signal_changed().connect(
|
||||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||||
@@ -256,7 +319,8 @@ void EffectorWindow::buildParamWidgets() {
|
||||
}
|
||||
|
||||
ParamValues EffectorWindow::paramValues() const {
|
||||
ParamValues values = effect_->defaults();
|
||||
if (stages_.empty()) return {};
|
||||
ParamValues values = stages_[static_cast<std::size_t>(selectedStage_)].values;
|
||||
for (const auto& entry : paramWidgets_) {
|
||||
switch (entry.spec.type) {
|
||||
case ParamSpec::Type::Bool:
|
||||
@@ -279,43 +343,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<std::size_t>(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<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);
|
||||
}
|
||||
|
||||
@@ -421,10 +470,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));
|
||||
@@ -538,6 +596,7 @@ void EffectorWindow::appendSourceRow(const std::string& path) {
|
||||
|
||||
row->set_child(*box);
|
||||
sourceList_.append(*row);
|
||||
updateSourcesLabel();
|
||||
}
|
||||
|
||||
void EffectorWindow::removeSource(Gtk::ListBoxRow* row) {
|
||||
@@ -548,15 +607,8 @@ void EffectorWindow::removeSource(Gtk::ListBoxRow* row) {
|
||||
}
|
||||
});
|
||||
sourceList_.remove(*row);
|
||||
|
||||
if (!sources_->empty()) {
|
||||
updateSourcesLabel();
|
||||
requestRender();
|
||||
} else {
|
||||
result_ = Image();
|
||||
saveButton_.set_sensitive(false);
|
||||
previewStack_.set_visible_child("placeholder");
|
||||
status_.set_text("");
|
||||
}
|
||||
}
|
||||
|
||||
void EffectorWindow::onAddImages() {
|
||||
@@ -589,17 +641,173 @@ void EffectorWindow::onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result
|
||||
if (!paths.empty()) addPaths(paths);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ rendering ---
|
||||
// ---------------------------------------------------------------- chain ---
|
||||
|
||||
void EffectorWindow::onEffectChanged() {
|
||||
const auto index = effectDropdown_.get_selected();
|
||||
if (index >= effects_.size()) return;
|
||||
effect_ = effects_[index];
|
||||
Gtk::Widget& EffectorWindow::buildChainSection() {
|
||||
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
|
||||
box->append(*sectionLabel("Effect chain"));
|
||||
|
||||
chainList_.add_css_class("boxed-list");
|
||||
chainList_.set_selection_mode(Gtk::SelectionMode::SINGLE);
|
||||
auto* empty = Gtk::make_managed<Gtk::Label>("No effects yet");
|
||||
empty->set_margin_top(12);
|
||||
empty->set_margin_bottom(12);
|
||||
chainList_.set_placeholder(*empty);
|
||||
chainList_.signal_row_selected().connect(
|
||||
[this](Gtk::ListBoxRow*) { onStageSelected(); });
|
||||
|
||||
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
|
||||
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
|
||||
scroller->set_child(chainList_);
|
||||
scroller->set_size_request(-1, 150);
|
||||
box->append(*scroller);
|
||||
|
||||
// set_label() and set_icon_name() replace one another, so the icon and the
|
||||
// text are put together by hand.
|
||||
auto* addBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
|
||||
addBox->append(*Gtk::make_managed<Gtk::Image>(
|
||||
Gio::ThemedIcon::create("list-add-symbolic")));
|
||||
addBox->append(*Gtk::make_managed<Gtk::Label>("Add effect"));
|
||||
addEffectButton_.set_child(*addBox);
|
||||
addEffectButton_.set_always_show_arrow(true);
|
||||
addEffectButton_.set_tooltip_text("Append an effect, applied to the output so far");
|
||||
addEffectButton_.set_popover(static_cast<Gtk::Popover&>(buildAddMenu()));
|
||||
addEffectButton_.set_halign(Gtk::Align::START);
|
||||
box->append(addEffectButton_);
|
||||
|
||||
return *box;
|
||||
}
|
||||
|
||||
// One entry per registered effect, so a new effect shows up here without this
|
||||
// file knowing anything about it.
|
||||
Gtk::Widget& EffectorWindow::buildAddMenu() {
|
||||
auto* popover = Gtk::make_managed<Gtk::Popover>();
|
||||
auto* list = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 2);
|
||||
|
||||
for (const auto& effect : effects_) {
|
||||
auto* item = Gtk::make_managed<Gtk::Button>(effect->title());
|
||||
item->add_css_class("flat");
|
||||
item->set_tooltip_text(effect->description());
|
||||
if (auto* child = dynamic_cast<Gtk::Label*>(item->get_child())) child->set_xalign(0.0F);
|
||||
item->signal_clicked().connect([this, effect] {
|
||||
addEffectButton_.popdown();
|
||||
appendStage(effect);
|
||||
});
|
||||
list->append(*item);
|
||||
}
|
||||
|
||||
popover->set_child(*list);
|
||||
return *popover;
|
||||
}
|
||||
|
||||
void EffectorWindow::appendStage(const EffectPtr& effect) {
|
||||
stages_.push_back({effect, effect->defaults()});
|
||||
selectedStage_ = static_cast<int>(stages_.size()) - 1;
|
||||
rebuildChainRows();
|
||||
buildParamWidgets();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
void EffectorWindow::onParamChanged() { requestRender(); }
|
||||
void EffectorWindow::removeStage(int index) {
|
||||
if (index < 0 || static_cast<std::size_t>(index) >= stages_.size()) return;
|
||||
stages_.erase(stages_.begin() + index);
|
||||
selectedStage_ = std::min(selectedStage_, static_cast<int>(stages_.size()) - 1);
|
||||
if (selectedStage_ < 0) selectedStage_ = 0;
|
||||
rebuildChainRows();
|
||||
buildParamWidgets();
|
||||
|
||||
requestRender();
|
||||
}
|
||||
|
||||
void EffectorWindow::moveStage(int index, int delta) {
|
||||
const int target = index + delta;
|
||||
if (index < 0 || static_cast<std::size_t>(index) >= stages_.size()) return;
|
||||
if (target < 0 || static_cast<std::size_t>(target) >= stages_.size()) return;
|
||||
std::swap(stages_[static_cast<std::size_t>(index)], stages_[static_cast<std::size_t>(target)]);
|
||||
selectedStage_ = target;
|
||||
rebuildChainRows();
|
||||
buildParamWidgets();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
void EffectorWindow::rebuildChainRows() {
|
||||
// Rebuilding drops and re-adds the selection, which would otherwise look
|
||||
// like the user picking a different stage.
|
||||
suspendStageSelect_ = true;
|
||||
// Only the rows: the placeholder is a child of the list box as well, and
|
||||
// removing it would leave the empty list blank.
|
||||
while (auto* row = chainList_.get_row_at_index(0)) chainList_.remove(*row);
|
||||
|
||||
for (std::size_t i = 0; i < stages_.size(); ++i) {
|
||||
const int index = static_cast<int>(i);
|
||||
auto* row = Gtk::make_managed<Gtk::ListBoxRow>();
|
||||
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 4);
|
||||
box->set_margin_top(4);
|
||||
box->set_margin_bottom(4);
|
||||
box->set_margin_start(8);
|
||||
box->set_margin_end(4);
|
||||
|
||||
auto* label = Gtk::make_managed<Gtk::Label>(std::to_string(index + 1) + ". " +
|
||||
stages_[i].effect->title());
|
||||
label->set_xalign(0.0F);
|
||||
label->set_hexpand(true);
|
||||
label->set_ellipsize(Pango::EllipsizeMode::END);
|
||||
label->set_tooltip_text(index == 0 ? "Applied to the source images"
|
||||
: "Applied to the output of the stage above");
|
||||
box->append(*label);
|
||||
|
||||
struct Action {
|
||||
const char* icon;
|
||||
const char* tip;
|
||||
int delta; // 0 means remove
|
||||
};
|
||||
for (const Action& action : {Action{"go-up-symbolic", "Move up", -1},
|
||||
Action{"go-down-symbolic", "Move down", 1},
|
||||
Action{"window-close-symbolic", "Remove", 0}}) {
|
||||
auto* button = Gtk::make_managed<Gtk::Button>();
|
||||
button->set_icon_name(action.icon);
|
||||
button->set_tooltip_text(action.tip);
|
||||
button->add_css_class("flat");
|
||||
const int delta = action.delta;
|
||||
button->signal_clicked().connect([this, index, delta] {
|
||||
if (delta == 0) {
|
||||
removeStage(index);
|
||||
} else {
|
||||
moveStage(index, delta);
|
||||
}
|
||||
});
|
||||
if (delta == -1) button->set_sensitive(index > 0);
|
||||
if (delta == 1) button->set_sensitive(static_cast<std::size_t>(index) + 1 < stages_.size());
|
||||
box->append(*button);
|
||||
}
|
||||
|
||||
row->set_child(*box);
|
||||
chainList_.append(*row);
|
||||
}
|
||||
|
||||
selectStage(selectedStage_);
|
||||
suspendStageSelect_ = false;
|
||||
}
|
||||
|
||||
void EffectorWindow::selectStage(int index) {
|
||||
if (auto* row = chainList_.get_row_at_index(index)) chainList_.select_row(*row);
|
||||
}
|
||||
|
||||
void EffectorWindow::onStageSelected() {
|
||||
if (suspendStageSelect_) return;
|
||||
auto* row = chainList_.get_selected_row();
|
||||
if (row == nullptr) return;
|
||||
if (row->get_index() == selectedStage_) return;
|
||||
selectedStage_ = row->get_index();
|
||||
buildParamWidgets();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ rendering ---
|
||||
|
||||
void EffectorWindow::onParamChanged() {
|
||||
storeParams();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
void EffectorWindow::onSeedEntered() {
|
||||
try {
|
||||
@@ -614,15 +822,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 +868,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 +876,21 @@ void EffectorWindow::startRender() {
|
||||
rendering_ = true;
|
||||
|
||||
const auto sources = sources_;
|
||||
const auto effect = effect_;
|
||||
const auto stages = stages_; // a snapshot, like the sources
|
||||
const int width = static_cast<int>(widthSpin_.get_value());
|
||||
const int height = static_cast<int>(heightSpin_.get_value());
|
||||
const std::uint64_t seed = seed_;
|
||||
const ParamValues values = paramValues();
|
||||
|
||||
spinner_.start();
|
||||
recreateButton_.set_sensitive(false);
|
||||
|
||||
worker_ = std::thread([this, sources, effect, width, height, seed, values] {
|
||||
worker_ = std::thread([this, sources, stages, width, height, seed] {
|
||||
Image image;
|
||||
std::string error;
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
try {
|
||||
Rng rng(seed);
|
||||
image = effect->render(*sources, width, height, rng, values);
|
||||
image = renderChain(stages, *sources, width, height, rng);
|
||||
} catch (const std::exception& exception) {
|
||||
error = exception.what();
|
||||
}
|
||||
@@ -714,12 +946,191 @@ 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[] = {{"<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);
|
||||
updateSourcesLabel();
|
||||
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 ---
|
||||
|
||||
void EffectorWindow::onSave() {
|
||||
@@ -727,7 +1138,7 @@ void EffectorWindow::onSave() {
|
||||
|
||||
auto dialog = Gtk::FileDialog::create();
|
||||
dialog->set_title("Save image");
|
||||
dialog->set_initial_name("effector-" + effect_->name() + "-" + std::to_string(seed_) + ".png");
|
||||
dialog->set_initial_name("effector-" + chainSlug(stages_) + "-" + std::to_string(seed_) + ".png");
|
||||
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
|
||||
onSaveChosen(result, dialog);
|
||||
});
|
||||
|
||||
+42
-3
@@ -12,8 +12,10 @@
|
||||
#include <vector>
|
||||
|
||||
#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();
|
||||
@@ -42,9 +48,32 @@ private:
|
||||
void closeLoadWindow();
|
||||
void cancelLoad();
|
||||
void appendSourceRow(const std::string& path);
|
||||
void updateSourcesLabel(); // the collapsed expander shows the count
|
||||
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 onSeedEntered();
|
||||
void onRecreate();
|
||||
@@ -66,9 +95,14 @@ private:
|
||||
// Model
|
||||
std::shared_ptr<const SourceList> sources_ = std::make_shared<SourceList>();
|
||||
std::vector<EffectPtr> effects_;
|
||||
EffectPtr effect_;
|
||||
// The cascade: stage 0 works on the sources, every later stage on the
|
||||
// output of the one before it.
|
||||
std::vector<ChainStage> stages_;
|
||||
int selectedStage_ = 0;
|
||||
bool suspendStageSelect_ = false; // guards the rebuild of the chain rows
|
||||
Image result_;
|
||||
std::uint64_t seed_ = 0;
|
||||
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 +119,7 @@ private:
|
||||
std::vector<LoadFailure> loadFailures_;
|
||||
std::vector<std::string> 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
|
||||
@@ -105,12 +140,16 @@ private:
|
||||
std::unique_ptr<Gtk::Window> loadWindow_;
|
||||
Gtk::ProgressBar* loadBar_ = nullptr;
|
||||
Gtk::Label* loadFileLabel_ = nullptr;
|
||||
Gtk::Expander sourcesExpander_;
|
||||
Gtk::ListBox sourceList_;
|
||||
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_;
|
||||
|
||||
+6
-7
@@ -8,6 +8,7 @@
|
||||
#include <mutex>
|
||||
|
||||
#include "cli/options.hpp"
|
||||
#include "core/chain.hpp"
|
||||
#include "core/effect.hpp"
|
||||
#include "core/image.hpp"
|
||||
#include "core/registry.hpp"
|
||||
@@ -17,7 +18,7 @@
|
||||
namespace {
|
||||
|
||||
int renderHeadless(const effector::Options& options) {
|
||||
const auto effect = effector::effectByName(options.effect);
|
||||
const auto stages = effector::buildChain(options);
|
||||
|
||||
std::vector<effector::LoadFailure> failures;
|
||||
|
||||
@@ -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<double, std::milli>(std::chrono::steady_clock::now() - start).count();
|
||||
image.save(options.output);
|
||||
|
||||
std::cout << options.output << " " << options.width << "x" << options.height
|
||||
<< " effect=" << effect->name() << " seed=" << seed << " render="
|
||||
<< " effect=" << effector::chainSlug(stages) << " seed=" << seed << " render="
|
||||
<< std::fixed << std::setprecision(1) << renderMillis << " ms\n";
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
add_executable(effector_tests core_tests.cpp)
|
||||
# The CLI parser joins the core here: it is GUI-free too, and the canvas size
|
||||
# it produces has to obey the same bounds a project file does.
|
||||
add_executable(effector_tests core_tests.cpp ../src/cli/options.cpp)
|
||||
target_link_libraries(effector_tests PRIVATE effector_core)
|
||||
target_compile_options(effector_tests PRIVATE -Wall -Wextra -Wpedantic)
|
||||
|
||||
|
||||
@@ -4,13 +4,21 @@
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "cli/options.hpp"
|
||||
#include "core/chain.hpp"
|
||||
#include "core/project.hpp"
|
||||
#include "core/registry.hpp"
|
||||
#include "core/rng.hpp"
|
||||
#include "effects/blur.hpp"
|
||||
#include "effects/grain.hpp"
|
||||
#include "effects/scanlines.hpp"
|
||||
|
||||
namespace {
|
||||
@@ -39,11 +47,72 @@ effector::Image gradient(int width, int height, int shade) {
|
||||
return image;
|
||||
}
|
||||
|
||||
// A canvas taken from a project file, written by hand rather than by
|
||||
// projectToJson, so a size no writer would produce can be fed to the reader.
|
||||
std::string projectWithCanvas(const std::string& width, const std::string& height) {
|
||||
return "{\"canvas\": {\"width\": " + width + ", \"height\": " + height +
|
||||
"},\n \"chain\": [{\"effect\": \"blur\"}]}";
|
||||
}
|
||||
|
||||
// The canvas a `-s` value ends up as, or nothing when the parser refuses it.
|
||||
std::optional<std::pair<int, int>> canvasFromSize(const std::string& size) {
|
||||
char program[] = "effector";
|
||||
char flag[] = "-s";
|
||||
std::vector<char> value(size.begin(), size.end());
|
||||
value.push_back('\0');
|
||||
char* argv[] = {program, flag, value.data()};
|
||||
try {
|
||||
const auto options = effector::parseOptions(3, argv);
|
||||
return std::pair<int, int>{options.width, options.height};
|
||||
} catch (const effector::EffectError&) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
bool sameBytes(const effector::Image& a, const effector::Image& b) {
|
||||
if (a.width() != b.width() || a.height() != b.height()) return false;
|
||||
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 renderGrain(const std::vector<effector::Image>& sources, int width, int height,
|
||||
std::uint64_t seed, const effector::ParamValues& values) {
|
||||
effector::GrainEffect 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);
|
||||
}
|
||||
|
||||
// A flat field of one shade, to measure what the grain does to it.
|
||||
effector::Image flat(int width, int height, std::uint8_t shade) {
|
||||
effector::Image image(width, height);
|
||||
std::fill(image.data(), image.data() + image.byteSize(), shade);
|
||||
return image;
|
||||
}
|
||||
|
||||
effector::Image render(const std::vector<effector::Image>& sources, int width, int height,
|
||||
std::uint64_t seed, const effector::ParamValues& values) {
|
||||
effector::ScanlinesEffect effect;
|
||||
@@ -128,6 +197,330 @@ int main() {
|
||||
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 << "grain\n";
|
||||
{
|
||||
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
|
||||
|
||||
const auto first = renderGrain(sources, 400, 300, 4, {});
|
||||
const auto again = renderGrain(sources, 400, 300, 4, {});
|
||||
const auto other = renderGrain(sources, 400, 300, 11, {});
|
||||
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");
|
||||
|
||||
// Mid grey is where the grain is strongest, so it must move, and stay
|
||||
// grey while the channels are drawn together.
|
||||
std::vector<Image> grey = {flat(200, 100, 128)};
|
||||
const auto grained = renderGrain(grey, 200, 100, 3, {{"grain_amount", 0.5}});
|
||||
bool moved = false;
|
||||
bool neutral = true;
|
||||
for (int x = 0; x < 200; ++x) {
|
||||
const auto* px = grained.row(50) + x * 3;
|
||||
moved = moved || px[0] != 128;
|
||||
neutral = neutral && px[0] == px[1] && px[1] == px[2];
|
||||
}
|
||||
check(moved, "grain moves a flat mid grey");
|
||||
check(neutral, "monochrome grain keeps the channels equal");
|
||||
|
||||
const auto coloured = renderGrain(grey, 200, 100, 3, {{"grain_colour", true}});
|
||||
bool split = false;
|
||||
for (int x = 0; x < 200; ++x) {
|
||||
const auto* px = coloured.row(50) + x * 3;
|
||||
split = split || px[0] != px[1] || px[1] != px[2];
|
||||
}
|
||||
check(split, "coloured grain pulls the channels apart");
|
||||
|
||||
// With the midtone weighting on, black and white are left alone; with
|
||||
// it off the grain lands everywhere.
|
||||
const auto blackWeighted = renderGrain({flat(64, 64, 0)}, 64, 64, 2, {});
|
||||
bool untouched = true;
|
||||
for (std::size_t i = 0; i < blackWeighted.byteSize(); ++i) {
|
||||
untouched = untouched && blackWeighted.data()[i] == 0;
|
||||
}
|
||||
check(untouched, "the midtone weighting leaves black alone");
|
||||
|
||||
const auto blackFlat =
|
||||
renderGrain({flat(64, 64, 0)}, 64, 64, 2, {{"grain_midtones", false}});
|
||||
bool lifted = false;
|
||||
for (std::size_t i = 0; i < blackFlat.byteSize(); ++i) {
|
||||
lifted = lifted || blackFlat.data()[i] != 0;
|
||||
}
|
||||
check(lifted, "without the weighting grain reaches the shadows");
|
||||
|
||||
// Zero amount is a pass through, and a grain wider than the canvas must
|
||||
// still only read the one cell it has.
|
||||
check(sameBytes(renderGrain({flat(32, 32, 128)}, 32, 32, 1, {{"grain_amount", 0.0}}),
|
||||
flat(32, 32, 128)),
|
||||
"zero amount leaves the image untouched");
|
||||
const auto coarse = renderGrain(grey, 40, 30, 7, {{"grain_size", 64L}});
|
||||
check(coarse.width() == 40 && coarse.height() == 30,
|
||||
"a grain wider than the canvas is clamped");
|
||||
|
||||
// Like blur, grain needs no sources: with none it grains the empty
|
||||
// canvas it starts from instead of refusing to run.
|
||||
const auto blank = renderGrain({}, 100, 100, 1, {{"grain_midtones", false}});
|
||||
bool speckled = blank.width() == 100 && blank.height() == 100;
|
||||
bool any = false;
|
||||
for (std::size_t i = 0; i < blank.byteSize(); ++i) any = any || blank.data()[i] != 0;
|
||||
check(speckled && any, "without sources grain works on 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 << "canvas size\n";
|
||||
{
|
||||
// The sizes the front-ends actually offer make the round trip whole.
|
||||
int kept = 0;
|
||||
for (const auto [w, h] : {std::pair<int, int>{1, 1},
|
||||
{640, 400},
|
||||
{1920, 1080},
|
||||
{kMaxCanvasSide, kMaxCanvasSide}}) {
|
||||
const auto back = projectFromJson(
|
||||
projectWithCanvas(std::to_string(w), std::to_string(h)));
|
||||
if (back.width == w && back.height == h) ++kept;
|
||||
}
|
||||
check(kept == 4, "every canvas in range is read back exactly");
|
||||
|
||||
Project project;
|
||||
project.stages.push_back({effectByName("blur"), {}});
|
||||
project.width = kMaxCanvasSide;
|
||||
project.height = 3;
|
||||
const auto written = projectFromJson(projectToJson(project));
|
||||
check(written.width == kMaxCanvasSide && written.height == 3,
|
||||
"the largest canvas survives a write and a read");
|
||||
|
||||
// A side that does not fit in an int must not be narrowed into one
|
||||
// that does: 4294968296 truncates to a perfectly plausible 1000.
|
||||
int refusals = 0;
|
||||
for (const char* bad : {"4294968296", "2147483648", "99999999999999999999", "-5", "0",
|
||||
"20001", "800.5", "\"800\"", "true"}) {
|
||||
try {
|
||||
projectFromJson(projectWithCanvas(bad, "600"));
|
||||
} catch (const EffectError&) {
|
||||
++refusals;
|
||||
}
|
||||
}
|
||||
check(refusals == 9, "a canvas width an int cannot hold is refused, not wrapped");
|
||||
|
||||
refusals = 0;
|
||||
for (const char* bad : {"4294968296", "-5", "0", "20001", "800.5"}) {
|
||||
try {
|
||||
projectFromJson(projectWithCanvas("800", bad));
|
||||
} catch (const EffectError&) {
|
||||
++refusals;
|
||||
}
|
||||
}
|
||||
check(refusals == 5, "the same bounds apply to the canvas height");
|
||||
|
||||
// A canvas the file does not mention keeps the defaults rather than
|
||||
// failing: an old project is still readable.
|
||||
const auto bare = projectFromJson("{\"chain\": [{\"effect\": \"blur\"}]}");
|
||||
check(bare.width == 1920 && bare.height == 1080, "a project with no canvas keeps 1920x1080");
|
||||
|
||||
// The CLI narrows the same way, from a long rather than a double.
|
||||
const auto plain = canvasFromSize("800x600");
|
||||
check(plain && plain->first == 800 && plain->second == 600, "-s 800x600 is stored as given");
|
||||
const auto big = canvasFromSize(std::to_string(kMaxCanvasSide) + "x" +
|
||||
std::to_string(kMaxCanvasSide));
|
||||
check(big && big->first == kMaxCanvasSide, "-s takes the largest canvas");
|
||||
check(!canvasFromSize("4294968296x600"), "-s refuses a width that does not fit in an int");
|
||||
check(!canvasFromSize("2147483648x600"), "-s refuses a width just past INT_MAX");
|
||||
check(!canvasFromSize("99999999999999999999x600"), "-s refuses a width past a long");
|
||||
check(!canvasFromSize("800x4294968296"), "-s refuses a height that does not fit in an int");
|
||||
check(!canvasFromSize("20001x600") && !canvasFromSize("800x20001"),
|
||||
"-s refuses a side past the canvas limit");
|
||||
check(!canvasFromSize("0x600") && !canvasFromSize("-5x600"),
|
||||
"-s refuses a side that is not positive");
|
||||
}
|
||||
|
||||
std::cout << "image io\n";
|
||||
{
|
||||
const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png";
|
||||
|
||||
Reference in New Issue
Block a user