Author SHA1 Message Date
SiandClaude Opus 5 67417727e7 Check the canvas size before narrowing it to an int
A canvas side was cast to int with no range check, so a number that did
not fit came back as one that did: `-s 4294968296x600` rendered a
perfectly plausible 1000x600, and a hand-edited project file could ask
for a width the reader turned into something else (or, for a double out
of range, into undefined behaviour).

Both front-ends now check the side before the cast, against one shared
bound in chain.hpp — the 1..20000 the GUI spin buttons already offered,
so the help text, the CLI error, the project reader and the interface
all move together.

The tests gain a `canvas size` section covering the round trip at both
ends of the range and the refusals on either side of it, through the
project reader and through `-s`; the test binary picks up the CLI parser
to do it, which stays GUI-free.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:49:44 +02:00
SiandClaude Opus 5 82e730221e Add a film grain effect
Grain is the third effect and the second that declares needsSources()
false, so it works as a filter stage in a cascade and can also lead one,
filling the canvas from a source the way blur does. Its knobs are the
amount a grain may push a pixel, the grain size in pixels, whether the
channels are drawn apart into coloured grain, and whether the response is
weighted towards the midtones — which it is by default, since film grains
least where it is least exposed. That last one means grain over an empty
canvas stays black unless the weighting is turned off.

Determinism comes from the same shape the scanlines effect uses: the
whole grain field is drawn serially from the Rng into one cell per grain
before any threading, and only the integer pixel arithmetic afterwards
runs under parallelFor.

The canvas fill blur used to keep to itself moves to
effects/source_fill.hpp unchanged, now that two effects want it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:42:04 +02:00
SiandClaude Opus 5 b6b5f94f08 Put the chain first in the sidebar
The source images took the top of the sidebar and most of its height,
which made the list of files look like the subject of the window rather
than its input. The chain is what the interface is about, so it now comes
first with the selected stage's params right below it, taking the free
space; the sources sit at the bottom in a collapsed expander whose label
carries the count, with their own "Add images" button instead of the one
that used to be in the header bar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:36:43 +02:00
Si 6cb3da8dc6 Merge branch 'blur-effect' 2026-09-18 16:01:44 +02:00
SiandClaude Opus 5 4739d39a51 Let effects declare whether they need source images
Whether an effect works from the loaded photographs is now part of the
declarative plug-in contract, next to params(): Effect::needsSources()
defaults to true, and blur — which only softens what it is handed — says
no and blurs an empty canvas when given nothing.

Only the first stage of a cascade ever sees the sources, so
chainNeedsSources() just asks the first stage. Both front-ends gate on
that instead of on "are there any images": the CLI only refuses an empty
batch when the chain wants one, and the interface renders a blur-led
chain with no images loaded, saying whichever thing is actually missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:49:16 +02:00
SiandClaude Opus 5 a82fba7886 Save and open projects as JSON
A project is the whole setup as a small text file: the source paths, the
effect chain with its params, the canvas and the seed. Reading and writing
are driven by the effects' ParamSpecs rather than by whatever sits in a
stage's values, so a new effect is saveable without touching this code, a
key an effect does not declare is never written or read back, and an old
project keeps the defaults for params that did not exist when it was
saved. Paths under the project's own folder are written relative, so a
folder of images and their project can be moved whole.

src/core/project.cpp carries its own small JSON reader instead of a
dependency, and names the line when a hand-edited file is wrong.

The interface gets a header-bar menu: New, Open, Save, Save As, with
Ctrl+N/O/S. The title shows the project name. Opening a project replaces
the sources, discarding a decode still in flight rather than mixing it
into the new project.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:37:37 +02:00
SiandClaude Opus 5 89985cb989 Cascade effects through a chain
renderChain runs a list of stages in order: stage 0 works on the loaded
sources, every later one on the output of the stage before it, so blur
after scanlines softens the scanlines rather than the photographs. The
effects themselves are untouched — none of them knows whether its input
came from disk. All stages share one Rng and run in sequence, so a seed
still reproduces the cascade byte for byte.

The sidebar grows an "Effect chain" list: add a stage from a menu built
from the registry, reorder it, remove it, and edit the selected stage's
params underneath. Each stage keeps its own values. The CLI takes the
same chain from repeated -e, with every flag going to the stage whose
effect declares it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:21:29 +02:00
SiandClaude Opus 5 ee4b1038ac Add a blur effect plugin
Fills the canvas from one source, tiling it when it is smaller, then
applies a separable box blur repeated `passes` times to approximate a
gaussian. Params: radius, passes, direction and first_source, so the CLI
flags and the GUI widgets come out of the ParamSpecs as usual.

All random values (source index and crop offset) are drawn serially
before the parallel pixel work, so a seed reproduces the image exactly;
the box window clamps at both ends and the radius is clamped to the
canvas so an oversized radius cannot read out of bounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:05:15 +02:00
20 changed files with 1931 additions and 117 deletions
+45 -6
View File
@@ -14,11 +14,11 @@ Everything is warning-clean under `-Wall -Wextra -Wpedantic`; keep it that way.
The test suite is a single hand-rolled binary (no framework), so there is no The test suite is a single hand-rolled binary (no framework), so there is no
per-test filter: run `./build/tests/effector_tests` directly to see the section per-test filter: run `./build/tests/effector_tests` directly to see the section
headings (`rng`, `registry`, `scanlines`, `batch loading`) and the `ok`/`FAIL` headings (`rng`, `registry`, `scanlines`, `blur`, `chain`, `project`, `image
lines. To iterate on one area, comment out the other blocks in io`, `batch loading`) and the `ok`/`FAIL` lines. To iterate on one area, comment
`tests/core_tests.cpp` or add a new `std::cout << "section\n"` block — checks are out the other blocks in `tests/core_tests.cpp` or add a new
plain `check(condition, "description")` calls and `main()` returns the failure `std::cout << "section\n"` block — checks are plain
count. `check(condition, "description")` calls and `main()` returns the failure count.
Headless render (no display needed, the fastest end-to-end smoke test): Headless render (no display needed, the fastest end-to-end smoke test):
@@ -48,9 +48,48 @@ add a `ParamSpec` instead. Values travel as `ParamValues` (a
`map<string, variant<long,double,bool,string>>`), read back with `map<string, variant<long,double,bool,string>>`), read back with
`paramInt`/`paramDouble`/`paramBool`/`paramChoice`. `paramInt`/`paramDouble`/`paramBool`/`paramChoice`.
Whether an effect works from the loaded photographs is part of the same
contract: `Effect::needsSources()` (default true, false for blur). Only the
first stage ever sees the sources, so `chainNeedsSources` (`src/core/chain.cpp`)
just asks the first stage — a chain led by blur renders with no images loaded at
all, and both front-ends gate on that rather than on "are there any images".
Adding an effect: new `src/effects/<name>.{hpp,cpp}` subclassing `Effect`, one Adding an effect: new `src/effects/<name>.{hpp,cpp}` subclassing `Effect`, one
line in the registry list in `src/core/registry.cpp`, one line in the line in the registry list in `src/core/registry.cpp`, one line in the
`effector_core` sources in `CMakeLists.txt`. Nothing else. `effector_core` sources in `CMakeLists.txt`. Nothing else — it shows up in the
CLI, in the chain menu and as a cascade stage on its own.
### Effects cascade
Effects are never applied on their own: `renderChain` (`src/core/chain.cpp`)
runs a `std::vector<ChainStage>` in order, handing stage 0 the loaded sources
and every later stage the previous stage's output as its *only* source. That is
the whole of the cascade — an effect neither knows nor cares whether its input
came from disk. All stages share one `Rng` and run strictly in sequence, so a
seed still reproduces the cascade byte for byte.
The interface builds the chain in the sidebar ("Effect chain": add from the
menu, reorder, remove, one stage selected at a time with its params below), the
CLI builds it from repeated `-e`: `-e scanlines --strip-height 6 -e blur
--radius 5`. Flags share one namespace across all effects, so `buildChain`
(`src/cli/options.cpp`) hands each flag to the stage whose effect declares it —
two effects must not declare the same param key.
### Projects are plain JSON
`src/core/project.cpp` reads and writes the whole setup — source paths, the
chain with its params, canvas and seed — as a small JSON file, with its own
minimal reader rather than a JSON dependency. Writing is driven by the effects'
`ParamSpec`s, not by whatever happens to sit in a stage's `ParamValues`, so a
key an effect does not declare is never written and never read back: an old
project keeps the defaults for params that did not exist when it was saved.
Source paths under the project's own folder are written relative, so a folder
can be moved or shared whole.
The interface opens and saves projects from the header-bar menu (New, Open,
Save, Save As, with the usual Ctrl+N/O/S accelerators); the window title shows
the project name. Opening one replaces the sources, so a decode still in flight
is discarded rather than mixed into the new project (`dropLoadedImages_`).
### Two link targets, deliberately ### Two link targets, deliberately
+4
View File
@@ -20,11 +20,15 @@ find_package(Threads REQUIRED)
# --- core: effects and image handling, no GUI --------------------------------- # --- core: effects and image handling, no GUI ---------------------------------
add_library(effector_core STATIC add_library(effector_core STATIC
src/core/chain.cpp
src/core/effect.cpp src/core/effect.cpp
src/core/image.cpp src/core/image.cpp
src/core/params.cpp src/core/params.cpp
src/core/project.cpp
src/core/registry.cpp src/core/registry.cpp
src/core/rng.cpp src/core/rng.cpp
src/effects/blur.cpp
src/effects/grain.cpp
src/effects/scanlines.cpp src/effects/scanlines.cpp
) )
target_include_directories(effector_core PUBLIC src) target_include_directories(effector_core PUBLIC src)
+30 -7
View File
@@ -4,6 +4,7 @@
#include <cstring> #include <cstring>
#include <sstream> #include <sstream>
#include "core/chain.hpp"
#include "core/effect.hpp" #include "core/effect.hpp"
#include "core/registry.hpp" #include "core/registry.hpp"
@@ -23,7 +24,10 @@ bool parseSize(const std::string& text, int& width, int& height) {
long w = 0; long w = 0;
long h = 0; long h = 0;
if (!parseLong(text.substr(0, sep), w) || !parseLong(text.substr(sep + 1), h)) return false; 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); width = static_cast<int>(w);
height = static_cast<int>(h); height = static_cast<int>(h);
return true; 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 parseOptions(int argc, char** argv) {
Options options; Options options;
options.effect = defaultEffectName();
for (int i = 1; i < argc; ++i) { for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i]; const std::string arg = argv[i];
@@ -58,14 +61,17 @@ Options parseOptions(int argc, char** argv) {
} else if (arg == "--list-effects") { } else if (arg == "--list-effects") {
options.listEffects = true; options.listEffects = true;
} else if (arg == "-e" || arg == "--effect") { } else if (arg == "-e" || arg == "--effect") {
options.effect = requireValue(argc, argv, i, arg); const std::string name = requireValue(argc, argv, i, arg);
effectByName(options.effect); // validate now, fail early effectByName(name); // validate now, fail early
options.effects.push_back(name);
} else if (arg == "-o" || arg == "--output") { } else if (arg == "-o" || arg == "--output") {
options.output = requireValue(argc, argv, i, arg); options.output = requireValue(argc, argv, i, arg);
} else if (arg == "-s" || arg == "--size") { } else if (arg == "-s" || arg == "--size") {
const std::string value = requireValue(argc, argv, i, arg); const std::string value = requireValue(argc, argv, i, arg);
if (!parseSize(value, options.width, options.height)) { 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") { } else if (arg == "--seed") {
const std::string value = requireValue(argc, argv, i, arg); const std::string value = requireValue(argc, argv, i, arg);
@@ -119,6 +125,7 @@ Options parseOptions(int argc, char** argv) {
options.images.push_back(arg); options.images.push_back(arg);
} }
} }
if (options.effects.empty()) options.effects.push_back(defaultEffectName());
return options; return options;
} }
@@ -127,9 +134,10 @@ std::string usage(const char* programName) {
out << "Usage: " << programName << " [options] [image ...]\n\n" out << "Usage: " << programName << " [options] [image ...]\n\n"
<< "Image effects app. Runs the GTK interface unless --output is given.\n\n" << "Image effects app. Runs the GTK interface unless --output is given.\n\n"
<< "Options:\n" << "Options:\n"
<< " -e, --effect NAME effect to apply (default: " << defaultEffectName() << ")\n" << " -e, --effect NAME effect to apply, repeat to cascade (default: " << defaultEffectName() << ")\n"
<< " -o, --output FILE render headlessly to this file and exit\n" << " -o, --output FILE render headlessly to this file and exit\n"
<< " -s, --size WxH canvas size (default: 1920x1080)\n" << " -s, --size WxH canvas size, each side " << kMinCanvasSide << "-"
<< kMaxCanvasSide << " (default: 1920x1080)\n"
<< " --seed N random seed (default: random)\n" << " --seed N random seed (default: random)\n"
<< " --list-effects list available effects and exit\n" << " --list-effects list available effects and exit\n"
<< " -h, --help show this help and exit\n"; << " -h, --help show this help and exit\n";
@@ -147,4 +155,19 @@ std::string usage(const char* programName) {
return out.str(); return out.str();
} }
std::vector<ChainStage> buildChain(const Options& options) {
std::vector<ChainStage> stages;
for (const auto& name : options.effects) {
ChainStage stage;
stage.effect = effectByName(name);
stage.values = stage.effect->defaults();
for (const auto& spec : stage.effect->params()) {
const auto it = options.params.find(spec.key);
if (it != options.params.end()) stage.values[spec.key] = it->second;
}
stages.push_back(std::move(stage));
}
return stages;
}
} // namespace effector } // namespace effector
+7 -1
View File
@@ -5,13 +5,14 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include "core/chain.hpp"
#include "core/params.hpp" #include "core/params.hpp"
namespace effector { namespace effector {
struct Options { struct Options {
std::vector<std::string> images; std::vector<std::string> images;
std::string effect; std::vector<std::string> effects; // one per cascade stage, in order
std::string output; // empty -> launch the interface std::string output; // empty -> launch the interface
int width = 1920; int width = 1920;
int height = 1080; int height = 1080;
@@ -27,4 +28,9 @@ Options parseOptions(int argc, char** argv);
std::string usage(const char* programName); std::string usage(const char* programName);
// Turns the parsed names and flags into cascade stages: every flag goes to the
// stage whose effect declares it, so --strip-height and --radius can be given
// together for `-e scanlines -e blur`.
std::vector<ChainStage> buildChain(const Options& options);
} // namespace effector } // namespace effector
+54
View File
@@ -0,0 +1,54 @@
#include "core/chain.hpp"
namespace effector {
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
int width, int height, Rng& rng) {
if (stages.empty()) throw EffectError("The effect chain is empty.");
Image image;
for (std::size_t i = 0; i < stages.size(); ++i) {
const auto& stage = stages[i];
if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
auto values = stage.effect->defaults();
for (const auto& [key, value] : stage.values) values[key] = value;
if (i == 0) {
image = stage.effect->render(sources, width, height, rng, values);
} else {
// Feeding the previous result back in as the only source is what
// cascading means here; effects need to know nothing about it.
const std::vector<Image> previous = {std::move(image)};
image = stage.effect->render(previous, width, height, rng, values);
}
}
return image;
}
bool chainNeedsSources(const std::vector<ChainStage>& stages) {
if (stages.empty() || !stages.front().effect) return false;
return stages.front().effect->needsSources();
}
std::string chainTitle(const std::vector<ChainStage>& stages) {
std::string text;
for (const auto& stage : stages) {
if (!stage.effect) continue;
if (!text.empty()) text += " → ";
text += stage.effect->title();
}
return text;
}
std::string chainSlug(const std::vector<ChainStage>& stages) {
std::string text;
for (const auto& stage : stages) {
if (!stage.effect) continue;
if (!text.empty()) text += "-";
text += stage.effect->name();
}
return text;
}
} // namespace effector
+46
View File
@@ -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
+5
View File
@@ -27,6 +27,11 @@ public:
virtual std::string description() const = 0; virtual std::string description() const = 0;
virtual const std::vector<ParamSpec>& params() const = 0; virtual const std::vector<ParamSpec>& params() const = 0;
// Whether the effect builds its image out of the loaded source images.
// Effects that only transform what they are handed (blur) say no, and a
// chain that starts with one of them needs no sources at all.
virtual bool needsSources() const { return true; }
virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng, virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const = 0; const ParamValues& values) const = 0;
+457
View File
@@ -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
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include "core/chain.hpp"
#include "core/params.hpp"
namespace effector {
// Everything needed to recreate a render: which images went in, the cascade
// with its settings, the canvas and the seed. Deliberately no pixels — a
// project is a small text file that points at the sources on disk.
struct Project {
std::vector<std::string> sources; // absolute paths
std::vector<ChainStage> stages;
int width = 1920;
int height = 1080;
std::optional<std::uint64_t> seed;
};
// JSON, written for humans: two-space indent, one source path per line, and
// the params of a stage spelled with the same keys the effect declares.
// Source paths are written relative to `directory` when they live under it, so
// a project folder can be moved or shared whole.
std::string projectToJson(const Project& project, const std::string& directory = {});
// Throws EffectError on malformed JSON, an unknown effect or a missing chain.
// Relative source paths are resolved against `directory`; params the effect
// does not declare are dropped, and missing ones keep the effect's default.
Project projectFromJson(const std::string& text, const std::string& directory = {});
// Both throw EffectError: the file is the user's, so failures are reported
// rather than silently swallowed.
void saveProject(const std::string& path, const Project& project);
Project loadProject(const std::string& path);
} // namespace effector
+4
View File
@@ -1,5 +1,7 @@
#include "core/registry.hpp" #include "core/registry.hpp"
#include "effects/blur.hpp"
#include "effects/grain.hpp"
#include "effects/scanlines.hpp" #include "effects/scanlines.hpp"
namespace effector { namespace effector {
@@ -8,6 +10,8 @@ namespace effector {
const std::vector<EffectPtr>& effects() { const std::vector<EffectPtr>& effects() {
static const std::vector<EffectPtr> registry = { static const std::vector<EffectPtr> registry = {
std::make_shared<ScanlinesEffect>(), std::make_shared<ScanlinesEffect>(),
std::make_shared<BlurEffect>(),
std::make_shared<GrainEffect>(),
}; };
return registry; return registry;
} }
+112
View File
@@ -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
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include "core/effect.hpp"
namespace effector {
// Softens its input with a separable box blur, repeated a few times to
// approximate a gaussian. It needs no source images of its own: given none it
// blurs an empty canvas, which is what makes it usable as a pure filter stage.
class BlurEffect : public Effect {
public:
std::string name() const override { return "blur"; }
std::string title() const override { return "Blur"; }
std::string description() const override {
return "Softens its input with a repeated box blur, horizontally, vertically or both; "
"with sources loaded it fills the canvas from a randomly chosen one first.";
}
const std::vector<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+90
View File
@@ -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
+27
View File
@@ -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
+38
View File
@@ -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
View File
@@ -1,9 +1,12 @@
#include "gui/window.hpp" #include "gui/window.hpp"
#include <algorithm>
#include <chrono> #include <chrono>
#include <filesystem> #include <filesystem>
#include <sstream> #include <sstream>
#include "core/chain.hpp"
#include "core/project.hpp"
#include "core/registry.hpp" #include "core/registry.hpp"
#include "core/rng.hpp" #include "core/rng.hpp"
@@ -14,6 +17,15 @@ const char* const kImagePatterns[] = {"*.png", "*.jpg", "*.jpeg", "*.webp", "*
"*.tif", "*.tiff", "*.gif", "*.ppm", "*.PNG", "*.tif", "*.tiff", "*.gif", "*.ppm", "*.PNG",
"*.JPG", "*.JPEG", "*.WEBP"}; "*.JPG", "*.JPEG", "*.WEBP"};
Glib::RefPtr<Gio::ListStore<Gtk::FileFilter>> projectFilters() {
auto filter = Gtk::FileFilter::create();
filter->set_name("Effector projects");
filter->add_pattern("*.json");
auto filters = Gio::ListStore<Gtk::FileFilter>::create();
filters->append(filter);
return filters;
}
Glib::RefPtr<Gdk::Texture> toTexture(const Image& image) { Glib::RefPtr<Gdk::Texture> toTexture(const Image& image) {
auto bytes = Glib::Bytes::create(image.data(), image.byteSize()); auto bytes = Glib::Bytes::create(image.data(), image.byteSize());
return Gdk::MemoryTexture::create(image.width(), image.height(), Gdk::MemoryTexture::Format::R8G8B8, return Gdk::MemoryTexture::create(image.width(), image.height(), Gdk::MemoryTexture::Format::R8G8B8,
@@ -34,7 +46,7 @@ EffectorWindow::EffectorWindow(const Options& initial) {
set_default_size(1180, 760); set_default_size(1180, 760);
effects_ = effects(); effects_ = effects();
effect_ = effects_.front(); stages_.push_back({effects_.front(), effects_.front()->defaults()});
seed_ = Rng::randomSeed(); seed_ = Rng::randomSeed();
renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished)); renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished));
@@ -45,7 +57,13 @@ EffectorWindow::EffectorWindow(const Options& initial) {
applyInitial(initial); applyInitial(initial);
suspendRender_ = false; suspendRender_ = false;
if (!sources_->empty()) startRender(); if (loading_) {
placeholder_.set_text(missingText());
} else if (canRender()) {
startRender();
} else {
showPlaceholder();
}
} }
EffectorWindow::~EffectorWindow() { EffectorWindow::~EffectorWindow() {
@@ -62,20 +80,10 @@ void EffectorWindow::buildUi() {
auto* header = Gtk::make_managed<Gtk::HeaderBar>(); auto* header = Gtk::make_managed<Gtk::HeaderBar>();
set_titlebar(*header); set_titlebar(*header);
auto* addButton = Gtk::make_managed<Gtk::Button>(); menuButton_.set_icon_name("open-menu-symbolic");
addButton->set_icon_name("list-add-symbolic"); menuButton_.set_tooltip_text("Project menu");
addButton->set_tooltip_text("Add images"); menuButton_.set_popover(static_cast<Gtk::PopoverMenu&>(buildMainMenu()));
addButton->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onAddImages)); header->pack_end(menuButton_);
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_);
saveButton_.set_sensitive(false); saveButton_.set_sensitive(false);
saveButton_.signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onSave)); saveButton_.signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onSave));
@@ -105,33 +113,73 @@ void EffectorWindow::buildUi() {
root->append(*paned); 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() { Gtk::Widget& EffectorWindow::buildSidebar() {
auto* sidebar = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12); auto* sidebar = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
sidebar->set_margin(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_.set_selection_mode(Gtk::SelectionMode::NONE);
sourceList_.add_css_class("boxed-list"); sourceList_.add_css_class("boxed-list");
auto* empty = Gtk::make_managed<Gtk::Label>("No images yet"); auto* empty = Gtk::make_managed<Gtk::Label>("No images yet");
empty->set_margin_top(18); empty->set_margin_top(12);
empty->set_margin_bottom(18); empty->set_margin_bottom(12);
sourceList_.set_placeholder(*empty); sourceList_.set_placeholder(*empty);
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>(); auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC); scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scroller->set_child(sourceList_); scroller->set_child(sourceList_);
scroller->set_size_request(-1, 170); scroller->set_size_request(-1, 140);
scroller->set_vexpand(true); scroller->set_margin_top(6);
sidebar->append(*scroller);
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")); sidebar->append(*sectionLabel("Canvas"));
auto* grid = Gtk::make_managed<Gtk::Grid>(); auto* grid = Gtk::make_managed<Gtk::Grid>();
grid->set_column_spacing(8); grid->set_column_spacing(8);
grid->set_row_spacing(6); grid->set_row_spacing(6);
widthSpin_.set_range(1, 20000); widthSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
widthSpin_.set_increments(1, 100); widthSpin_.set_increments(1, 100);
widthSpin_.set_value(1920); widthSpin_.set_value(1920);
heightSpin_.set_range(1, 20000); heightSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
heightSpin_.set_increments(1, 100); heightSpin_.set_increments(1, 100);
heightSpin_.set_value(1080); heightSpin_.set_value(1080);
for (auto* spin : {&widthSpin_, &heightSpin_}) { for (auto* spin : {&widthSpin_, &heightSpin_}) {
@@ -148,12 +196,6 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
grid->attach(heightSpin_, 1, 1); grid->attach(heightSpin_, 1, 1);
sidebar->append(*grid); sidebar->append(*grid);
sidebar->append(*sectionLabel("Effect settings"));
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* seedBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
auto* seedLabel = Gtk::make_managed<Gtk::Label>("Seed"); auto* seedLabel = Gtk::make_managed<Gtk::Label>("Seed");
seedLabel->set_xalign(0.0F); seedLabel->set_xalign(0.0F);
@@ -167,6 +209,15 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
return *sidebar; 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() { Gtk::Widget& EffectorWindow::buildPreview() {
picture_.set_can_shrink(true); picture_.set_can_shrink(true);
picture_.set_content_fit(Gtk::ContentFit::CONTAIN); picture_.set_content_fit(Gtk::ContentFit::CONTAIN);
@@ -202,8 +253,16 @@ void EffectorWindow::buildParamWidgets() {
paramWidgets_.clear(); paramWidgets_.clear();
ownedWidgets_.clear(); ownedWidgets_.clear();
if (stages_.empty()) {
paramHeading_.set_text("No effects");
return;
}
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
paramHeading_.set_text(std::to_string(selectedStage_ + 1) + ". " + stage.effect->title() +
" settings");
int row = 0; int row = 0;
for (const auto& spec : effect_->params()) { for (const auto& spec : stage.effect->params()) {
auto label = std::make_unique<Gtk::Label>(spec.label); auto label = std::make_unique<Gtk::Label>(spec.label);
label->set_xalign(0.0F); label->set_xalign(0.0F);
if (!spec.help.empty()) label->set_tooltip_text(spec.help); if (!spec.help.empty()) label->set_tooltip_text(spec.help);
@@ -213,7 +272,7 @@ void EffectorWindow::buildParamWidgets() {
case ParamSpec::Type::Bool: { case ParamSpec::Type::Bool: {
auto toggle = std::make_unique<Gtk::Switch>(); auto toggle = std::make_unique<Gtk::Switch>();
toggle->set_halign(Gtk::Align::START); toggle->set_halign(Gtk::Align::START);
toggle->set_active(spec.defaultValue != 0.0); toggle->set_active(paramBool(stage.values, spec.key, spec.defaultValue != 0.0));
toggle->property_active().signal_changed().connect( toggle->property_active().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(toggle); widget = std::move(toggle);
@@ -225,7 +284,10 @@ void EffectorWindow::buildParamWidgets() {
spin->set_range(spec.minimum, spec.maximum); spin->set_range(spec.minimum, spec.maximum);
spin->set_increments(spec.step, spec.step * 10); spin->set_increments(spec.step, spec.step * 10);
spin->set_digits(spec.type == ParamSpec::Type::Int ? 0 : spec.digits); spin->set_digits(spec.type == ParamSpec::Type::Int ? 0 : spec.digits);
spin->set_value(spec.defaultValue); spin->set_value(spec.type == ParamSpec::Type::Int
? static_cast<double>(paramInt(stage.values, spec.key,
static_cast<long>(spec.defaultValue)))
: paramDouble(stage.values, spec.key, spec.defaultValue));
spin->signal_value_changed().connect( spin->signal_value_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(spin); widget = std::move(spin);
@@ -235,8 +297,9 @@ void EffectorWindow::buildParamWidgets() {
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end()); std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
auto dropdown = std::make_unique<Gtk::DropDown>(); auto dropdown = std::make_unique<Gtk::DropDown>();
dropdown->set_model(Gtk::StringList::create(choices)); dropdown->set_model(Gtk::StringList::create(choices));
const auto current = paramChoice(stage.values, spec.key, spec.defaultChoice);
for (std::size_t i = 0; i < spec.choices.size(); ++i) { for (std::size_t i = 0; i < spec.choices.size(); ++i) {
if (spec.choices[i] == spec.defaultChoice) dropdown->set_selected(i); if (spec.choices[i] == current) dropdown->set_selected(i);
} }
dropdown->property_selected().signal_changed().connect( dropdown->property_selected().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
@@ -256,7 +319,8 @@ void EffectorWindow::buildParamWidgets() {
} }
ParamValues EffectorWindow::paramValues() const { ParamValues EffectorWindow::paramValues() const {
ParamValues values = effect_->defaults(); if (stages_.empty()) return {};
ParamValues values = stages_[static_cast<std::size_t>(selectedStage_)].values;
for (const auto& entry : paramWidgets_) { for (const auto& entry : paramWidgets_) {
switch (entry.spec.type) { switch (entry.spec.type) {
case ParamSpec::Type::Bool: case ParamSpec::Type::Bool:
@@ -279,43 +343,28 @@ ParamValues EffectorWindow::paramValues() const {
return values; return values;
} }
void EffectorWindow::applyInitial(const Options& initial) { // The widgets only ever show one stage, so their values are written back
for (std::size_t i = 0; i < effects_.size(); ++i) { // whenever they change; the other stages keep the settings they were given.
if (effects_[i]->name() == initial.effect) effectDropdown_.set_selected(i); void EffectorWindow::storeParams() {
if (stages_.empty()) return;
stages_[static_cast<std::size_t>(selectedStage_)].values = paramValues();
} }
void EffectorWindow::applyInitial(const Options& initial) {
// `-e scanlines -e blur` starts the interface with that cascade already set
// up, flags included.
auto stages = buildChain(initial);
if (!stages.empty()) stages_ = std::move(stages);
selectedStage_ = 0;
rebuildChainRows();
buildParamWidgets();
widthSpin_.set_value(initial.width); widthSpin_.set_value(initial.width);
heightSpin_.set_value(initial.height); heightSpin_.set_value(initial.height);
if (initial.seed) { if (initial.seed) {
seed_ = *initial.seed; seed_ = *initial.seed;
seedEntry_.set_text(std::to_string(seed_)); seedEntry_.set_text(std::to_string(seed_));
} }
for (const auto& entry : paramWidgets_) {
const auto it = initial.params.find(entry.spec.key);
if (it == initial.params.end()) continue;
switch (entry.spec.type) {
case ParamSpec::Type::Bool:
static_cast<Gtk::Switch*>(entry.widget)
->set_active(paramBool(initial.params, entry.spec.key, false));
break;
case ParamSpec::Type::Int:
static_cast<Gtk::SpinButton*>(entry.widget)
->set_value(static_cast<double>(paramInt(initial.params, entry.spec.key, 0)));
break;
case ParamSpec::Type::Double:
static_cast<Gtk::SpinButton*>(entry.widget)
->set_value(paramDouble(initial.params, entry.spec.key, 0.0));
break;
case ParamSpec::Type::Choice: {
const auto value = paramChoice(initial.params, entry.spec.key, entry.spec.defaultChoice);
for (std::size_t i = 0; i < entry.spec.choices.size(); ++i) {
if (entry.spec.choices[i] == value) {
static_cast<Gtk::DropDown*>(entry.widget)->set_selected(i);
}
}
break;
}
}
}
if (!initial.images.empty()) addPaths(initial.images); if (!initial.images.empty()) addPaths(initial.images);
} }
@@ -421,10 +470,19 @@ void EffectorWindow::onLoadFinished() {
if (loadCancel_.load(std::memory_order_relaxed)) { if (loadCancel_.load(std::memory_order_relaxed)) {
// A cancelled batch is incomplete, so none of it is taken. // A cancelled batch is incomplete, so none of it is taken.
loadQueue_.clear(); loadQueue_.clear();
dropLoadedImages_ = false;
showError("Loading cancelled."); showError("Loading cancelled.");
return; return;
} }
if (dropLoadedImages_) {
// A project was opened while this batch was decoding: its images belong
// to the previous source list, and the project's own are queued behind.
dropLoadedImages_ = false;
images.clear();
failures.clear();
}
for (const auto& image : images) appendSourceRow(image.path()); for (const auto& image : images) appendSourceRow(image.path());
mutateSources([&images](SourceList& list) { mutateSources([&images](SourceList& list) {
for (auto& image : images) list.push_back(std::move(image)); for (auto& image : images) list.push_back(std::move(image));
@@ -538,6 +596,7 @@ void EffectorWindow::appendSourceRow(const std::string& path) {
row->set_child(*box); row->set_child(*box);
sourceList_.append(*row); sourceList_.append(*row);
updateSourcesLabel();
} }
void EffectorWindow::removeSource(Gtk::ListBoxRow* row) { void EffectorWindow::removeSource(Gtk::ListBoxRow* row) {
@@ -548,15 +607,8 @@ void EffectorWindow::removeSource(Gtk::ListBoxRow* row) {
} }
}); });
sourceList_.remove(*row); sourceList_.remove(*row);
updateSourcesLabel();
if (!sources_->empty()) {
requestRender(); requestRender();
} else {
result_ = Image();
saveButton_.set_sensitive(false);
previewStack_.set_visible_child("placeholder");
status_.set_text("");
}
} }
void EffectorWindow::onAddImages() { void EffectorWindow::onAddImages() {
@@ -589,17 +641,173 @@ void EffectorWindow::onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result
if (!paths.empty()) addPaths(paths); if (!paths.empty()) addPaths(paths);
} }
// ------------------------------------------------------------ rendering --- // ---------------------------------------------------------------- chain ---
void EffectorWindow::onEffectChanged() { Gtk::Widget& EffectorWindow::buildChainSection() {
const auto index = effectDropdown_.get_selected(); auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
if (index >= effects_.size()) return; box->append(*sectionLabel("Effect chain"));
effect_ = effects_[index];
chainList_.add_css_class("boxed-list");
chainList_.set_selection_mode(Gtk::SelectionMode::SINGLE);
auto* empty = Gtk::make_managed<Gtk::Label>("No effects yet");
empty->set_margin_top(12);
empty->set_margin_bottom(12);
chainList_.set_placeholder(*empty);
chainList_.signal_row_selected().connect(
[this](Gtk::ListBoxRow*) { onStageSelected(); });
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(); buildParamWidgets();
requestRender(); requestRender();
} }
void EffectorWindow::onParamChanged() { requestRender(); } void EffectorWindow::removeStage(int index) {
if (index < 0 || static_cast<std::size_t>(index) >= stages_.size()) return;
stages_.erase(stages_.begin() + index);
selectedStage_ = std::min(selectedStage_, static_cast<int>(stages_.size()) - 1);
if (selectedStage_ < 0) selectedStage_ = 0;
rebuildChainRows();
buildParamWidgets();
requestRender();
}
void EffectorWindow::moveStage(int index, int delta) {
const int target = index + delta;
if (index < 0 || static_cast<std::size_t>(index) >= stages_.size()) return;
if (target < 0 || static_cast<std::size_t>(target) >= stages_.size()) return;
std::swap(stages_[static_cast<std::size_t>(index)], stages_[static_cast<std::size_t>(target)]);
selectedStage_ = target;
rebuildChainRows();
buildParamWidgets();
requestRender();
}
void EffectorWindow::rebuildChainRows() {
// Rebuilding drops and re-adds the selection, which would otherwise look
// like the user picking a different stage.
suspendStageSelect_ = true;
// Only the rows: the placeholder is a child of the list box as well, and
// removing it would leave the empty list blank.
while (auto* row = chainList_.get_row_at_index(0)) chainList_.remove(*row);
for (std::size_t i = 0; i < stages_.size(); ++i) {
const int index = static_cast<int>(i);
auto* row = Gtk::make_managed<Gtk::ListBoxRow>();
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 4);
box->set_margin_top(4);
box->set_margin_bottom(4);
box->set_margin_start(8);
box->set_margin_end(4);
auto* label = Gtk::make_managed<Gtk::Label>(std::to_string(index + 1) + ". " +
stages_[i].effect->title());
label->set_xalign(0.0F);
label->set_hexpand(true);
label->set_ellipsize(Pango::EllipsizeMode::END);
label->set_tooltip_text(index == 0 ? "Applied to the source images"
: "Applied to the output of the stage above");
box->append(*label);
struct Action {
const char* icon;
const char* tip;
int delta; // 0 means remove
};
for (const Action& action : {Action{"go-up-symbolic", "Move up", -1},
Action{"go-down-symbolic", "Move down", 1},
Action{"window-close-symbolic", "Remove", 0}}) {
auto* button = Gtk::make_managed<Gtk::Button>();
button->set_icon_name(action.icon);
button->set_tooltip_text(action.tip);
button->add_css_class("flat");
const int delta = action.delta;
button->signal_clicked().connect([this, index, delta] {
if (delta == 0) {
removeStage(index);
} else {
moveStage(index, delta);
}
});
if (delta == -1) button->set_sensitive(index > 0);
if (delta == 1) button->set_sensitive(static_cast<std::size_t>(index) + 1 < stages_.size());
box->append(*button);
}
row->set_child(*box);
chainList_.append(*row);
}
selectStage(selectedStage_);
suspendStageSelect_ = false;
}
void EffectorWindow::selectStage(int index) {
if (auto* row = chainList_.get_row_at_index(index)) chainList_.select_row(*row);
}
void EffectorWindow::onStageSelected() {
if (suspendStageSelect_) return;
auto* row = chainList_.get_selected_row();
if (row == nullptr) return;
if (row->get_index() == selectedStage_) return;
selectedStage_ = row->get_index();
buildParamWidgets();
}
// ------------------------------------------------------------ rendering ---
void EffectorWindow::onParamChanged() {
storeParams();
requestRender();
}
void EffectorWindow::onSeedEntered() { void EffectorWindow::onSeedEntered() {
try { try {
@@ -614,15 +822,40 @@ void EffectorWindow::onSeedEntered() {
void EffectorWindow::onRecreate() { void EffectorWindow::onRecreate() {
seed_ = Rng::randomSeed(); seed_ = Rng::randomSeed();
seedEntry_.set_text(std::to_string(seed_)); seedEntry_.set_text(std::to_string(seed_));
if (sources_->empty()) { if (!canRender()) {
showError("Add at least one source image first."); showError(missingText());
return; return;
} }
requestRender(); requestRender();
} }
// Whether the effect chain has everything it needs. Only the first stage is
// ever handed the sources, so an effect that does not want them (blur) renders
// happily with none loaded.
bool EffectorWindow::canRender() const {
if (stages_.empty()) return false;
return !sources_->empty() || !chainNeedsSources(stages_);
}
std::string EffectorWindow::missingText() const {
if (stages_.empty()) return "Add an effect to start.";
return "Add source images to start.";
}
void EffectorWindow::showPlaceholder() {
result_ = Image();
saveButton_.set_sensitive(false);
placeholder_.set_text(missingText());
previewStack_.set_visible_child("placeholder");
status_.set_text("");
}
void EffectorWindow::requestRender() { void EffectorWindow::requestRender() {
if (suspendRender_ || sources_->empty()) return; if (suspendRender_) return;
if (!canRender()) {
showPlaceholder();
return;
}
// Spin buttons fire on every step while they are held down; coalesce those // Spin buttons fire on every step while they are held down; coalesce those
// into one render instead of queueing a job per tick. // into one render instead of queueing a job per tick.
debounce_.disconnect(); debounce_.disconnect();
@@ -635,7 +868,7 @@ void EffectorWindow::requestRender() {
} }
void EffectorWindow::startRender() { void EffectorWindow::startRender() {
if (sources_->empty()) return; if (!canRender()) return;
if (rendering_) { if (rendering_) {
staleResult_ = true; // a fresh render follows this one staleResult_ = true; // a fresh render follows this one
return; return;
@@ -643,22 +876,21 @@ void EffectorWindow::startRender() {
rendering_ = true; rendering_ = true;
const auto sources = sources_; const auto sources = sources_;
const auto effect = effect_; const auto stages = stages_; // a snapshot, like the sources
const int width = static_cast<int>(widthSpin_.get_value()); const int width = static_cast<int>(widthSpin_.get_value());
const int height = static_cast<int>(heightSpin_.get_value()); const int height = static_cast<int>(heightSpin_.get_value());
const std::uint64_t seed = seed_; const std::uint64_t seed = seed_;
const ParamValues values = paramValues();
spinner_.start(); spinner_.start();
recreateButton_.set_sensitive(false); recreateButton_.set_sensitive(false);
worker_ = std::thread([this, sources, effect, width, height, seed, values] { worker_ = std::thread([this, sources, stages, width, height, seed] {
Image image; Image image;
std::string error; std::string error;
const auto start = std::chrono::steady_clock::now(); const auto start = std::chrono::steady_clock::now();
try { try {
Rng rng(seed); Rng rng(seed);
image = effect->render(*sources, width, height, rng, values); image = renderChain(stages, *sources, width, height, rng);
} catch (const std::exception& exception) { } catch (const std::exception& exception) {
error = exception.what(); error = exception.what();
} }
@@ -714,12 +946,191 @@ void EffectorWindow::onRenderFinished() {
std::ostringstream status; std::ostringstream status;
status.setf(std::ios::fixed); status.setf(std::ios::fixed);
status.precision(1); status.precision(1);
status << effect_->title() << " · " << result_.width() << "×" << result_.height() << " · " status << chainTitle(stages_) << " · " << result_.width() << "×" << result_.height() << " · ";
<< sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · seed " if (!sources_->empty()) {
<< seed_ << " · " << millis << " ms"; status << sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · ";
}
status << "seed " << seed_ << " · " << millis << " ms";
status_.set_text(status.str()); status_.set_text(status.str());
} }
// -------------------------------------------------------------- project ---
// The project menu lives in the header bar, next to the buttons that act on
// the current render.
Gtk::Widget& EffectorWindow::buildMainMenu() {
add_action("new-project", sigc::mem_fun(*this, &EffectorWindow::onNewProject));
add_action("open-project", sigc::mem_fun(*this, &EffectorWindow::onOpenProject));
add_action("save-project", sigc::mem_fun(*this, &EffectorWindow::onSaveProject));
add_action("save-project-as", sigc::mem_fun(*this, &EffectorWindow::onSaveProjectAs));
auto menu = Gio::Menu::create();
auto open = Gio::Menu::create();
open->append("New Project", "win.new-project");
open->append("Open Project…", "win.open-project");
menu->append_section(open);
auto save = Gio::Menu::create();
save->append("Save Project", "win.save-project");
save->append("Save Project As…", "win.save-project-as");
menu->append_section(save);
// The shortcuts hang off the window rather than the application, which the
// window does not belong to yet while it is being built.
auto shortcuts = Gtk::ShortcutController::create();
shortcuts->set_scope(Gtk::ShortcutScope::GLOBAL);
const struct Accel {
const char* keys;
const char* action;
} accels[] = {{"<Control>n", "win.new-project"},
{"<Control>o", "win.open-project"},
{"<Control>s", "win.save-project"},
{"<Control><Shift>s", "win.save-project-as"}};
for (const Accel& accel : accels) {
shortcuts->add_shortcut(Gtk::Shortcut::create(
Gtk::ShortcutTrigger::parse_string(accel.keys), Gtk::NamedAction::create(accel.action)));
}
add_controller(shortcuts);
return *Gtk::make_managed<Gtk::PopoverMenu>(menu);
}
Project EffectorWindow::currentProject() const {
Project project;
for (const auto& image : *sources_) project.sources.push_back(image.path());
project.stages = stages_;
project.width = static_cast<int>(widthSpin_.get_value());
project.height = static_cast<int>(heightSpin_.get_value());
project.seed = seed_;
return project;
}
void EffectorWindow::applyProject(const Project& project, const std::string& path) {
// One render at the end rather than one per piece of the project.
suspendRender_ = true;
clearSources();
stages_ = project.stages;
selectedStage_ = 0;
rebuildChainRows();
buildParamWidgets();
widthSpin_.set_value(project.width);
heightSpin_.set_value(project.height);
seed_ = project.seed.value_or(Rng::randomSeed());
seedEntry_.set_text(std::to_string(seed_));
projectPath_ = path;
updateTitle();
clearError();
suspendRender_ = false;
if (project.sources.empty()) {
requestRender(); // a chain that needs no sources still has a picture
} else {
addPaths(project.sources); // renders once the images are decoded
}
}
void EffectorWindow::clearSources() {
while (auto* row = sourceList_.get_row_at_index(0)) sourceList_.remove(*row);
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 --- // --------------------------------------------------------------- saving ---
void EffectorWindow::onSave() { void EffectorWindow::onSave() {
@@ -727,7 +1138,7 @@ void EffectorWindow::onSave() {
auto dialog = Gtk::FileDialog::create(); auto dialog = Gtk::FileDialog::create();
dialog->set_title("Save image"); dialog->set_title("Save image");
dialog->set_initial_name("effector-" + effect_->name() + "-" + std::to_string(seed_) + ".png"); dialog->set_initial_name("effector-" + chainSlug(stages_) + "-" + std::to_string(seed_) + ".png");
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) { dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
onSaveChosen(result, dialog); onSaveChosen(result, dialog);
}); });
+42 -3
View File
@@ -12,8 +12,10 @@
#include <vector> #include <vector>
#include "cli/options.hpp" #include "cli/options.hpp"
#include "core/chain.hpp"
#include "core/effect.hpp" #include "core/effect.hpp"
#include "core/image.hpp" #include "core/image.hpp"
#include "core/project.hpp"
namespace effector { namespace effector {
@@ -28,8 +30,12 @@ private:
Gtk::Widget& buildPreview(); Gtk::Widget& buildPreview();
void buildParamWidgets(); void buildParamWidgets();
ParamValues paramValues() const; ParamValues paramValues() const;
void storeParams(); // widgets -> the selected stage's values
void applyInitial(const Options& initial); void applyInitial(const Options& initial);
bool canRender() const; // a chain, and sources if the chain needs them
std::string missingText() const; // what is still missing before a render
void showPlaceholder(); // drops the preview and says what is missing
void requestRender(); // coalesced: restarts the short debounce timer void requestRender(); // coalesced: restarts the short debounce timer
void startRender(); // starts a worker, or marks the result stale void startRender(); // starts a worker, or marks the result stale
void onAddImages(); void onAddImages();
@@ -42,9 +48,32 @@ private:
void closeLoadWindow(); void closeLoadWindow();
void cancelLoad(); void cancelLoad();
void appendSourceRow(const std::string& path); void appendSourceRow(const std::string& path);
void updateSourcesLabel(); // the collapsed expander shows the count
void removeSource(Gtk::ListBoxRow* row); void removeSource(Gtk::ListBoxRow* row);
void onEffectChanged(); Gtk::Widget& buildChainSection();
Gtk::Widget& buildAddMenu(); // the menu of effects that can be appended
void appendStage(const EffectPtr& effect);
void removeStage(int index);
void moveStage(int index, int delta);
void rebuildChainRows();
void selectStage(int index);
void onStageSelected();
Gtk::Widget& buildMainMenu(); // the header menu: new, open, save project
Project currentProject() const;
void applyProject(const Project& project, const std::string& path);
void clearSources();
void onNewProject();
void onOpenProject();
void onProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& dialog);
void onSaveProject(); // falls back to Save As while the project is untitled
void onSaveProjectAs();
void onSaveProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& dialog);
void writeProject(const std::string& path);
void updateTitle();
void onParamChanged(); void onParamChanged();
void onSeedEntered(); void onSeedEntered();
void onRecreate(); void onRecreate();
@@ -66,9 +95,14 @@ private:
// Model // Model
std::shared_ptr<const SourceList> sources_ = std::make_shared<SourceList>(); std::shared_ptr<const SourceList> sources_ = std::make_shared<SourceList>();
std::vector<EffectPtr> effects_; std::vector<EffectPtr> effects_;
EffectPtr effect_; // The cascade: stage 0 works on the sources, every later stage on the
// output of the one before it.
std::vector<ChainStage> stages_;
int selectedStage_ = 0;
bool suspendStageSelect_ = false; // guards the rebuild of the chain rows
Image result_; Image result_;
std::uint64_t seed_ = 0; std::uint64_t seed_ = 0;
std::string projectPath_; // empty until the project has been saved once
bool suspendRender_ = true; bool suspendRender_ = true;
// Source loading. Decoding a batch of large photographs takes seconds, so // Source loading. Decoding a batch of large photographs takes seconds, so
@@ -85,6 +119,7 @@ private:
std::vector<LoadFailure> loadFailures_; std::vector<LoadFailure> loadFailures_;
std::vector<std::string> loadQueue_; // chosen while another batch was loading std::vector<std::string> loadQueue_; // chosen while another batch was loading
bool loading_ = false; bool loading_ = false;
bool dropLoadedImages_ = false; // the batch in flight belongs to a replaced project
sigc::connection loadWindowDelay_; sigc::connection loadWindowDelay_;
// Render job // Render job
@@ -105,12 +140,16 @@ private:
std::unique_ptr<Gtk::Window> loadWindow_; std::unique_ptr<Gtk::Window> loadWindow_;
Gtk::ProgressBar* loadBar_ = nullptr; Gtk::ProgressBar* loadBar_ = nullptr;
Gtk::Label* loadFileLabel_ = nullptr; Gtk::Label* loadFileLabel_ = nullptr;
Gtk::Expander sourcesExpander_;
Gtk::ListBox sourceList_; Gtk::ListBox sourceList_;
Gtk::SpinButton widthSpin_; Gtk::SpinButton widthSpin_;
Gtk::SpinButton heightSpin_; Gtk::SpinButton heightSpin_;
Gtk::Grid paramGrid_; Gtk::Grid paramGrid_;
Gtk::Label paramHeading_;
Gtk::Entry seedEntry_; Gtk::Entry seedEntry_;
Gtk::DropDown effectDropdown_; Gtk::ListBox chainList_;
Gtk::MenuButton addEffectButton_;
Gtk::MenuButton menuButton_;
Gtk::Button recreateButton_{"Recreate"}; Gtk::Button recreateButton_{"Recreate"};
Gtk::Button saveButton_{"Save As…"}; Gtk::Button saveButton_{"Save As…"};
Gtk::Picture picture_; Gtk::Picture picture_;
+6 -7
View File
@@ -8,6 +8,7 @@
#include <mutex> #include <mutex>
#include "cli/options.hpp" #include "cli/options.hpp"
#include "core/chain.hpp"
#include "core/effect.hpp" #include "core/effect.hpp"
#include "core/image.hpp" #include "core/image.hpp"
#include "core/registry.hpp" #include "core/registry.hpp"
@@ -17,7 +18,7 @@
namespace { namespace {
int renderHeadless(const effector::Options& options) { int renderHeadless(const effector::Options& options) {
const auto effect = effector::effectByName(options.effect); const auto stages = effector::buildChain(options);
std::vector<effector::LoadFailure> failures; std::vector<effector::LoadFailure> failures;
@@ -39,7 +40,7 @@ int renderHeadless(const effector::Options& options) {
for (const auto& failure : failures) { for (const auto& failure : failures) {
std::cerr << "effector: skipping " << failure.path << ": " << failure.message << "\n"; std::cerr << "effector: skipping " << failure.path << ": " << failure.message << "\n";
} }
if (sources.empty()) { if (sources.empty() && effector::chainNeedsSources(stages)) {
std::cerr << "effector: no usable source images\n"; std::cerr << "effector: no usable source images\n";
return 2; return 2;
} }
@@ -47,17 +48,15 @@ int renderHeadless(const effector::Options& options) {
const std::uint64_t seed = options.seed.value_or(effector::Rng::randomSeed()); const std::uint64_t seed = options.seed.value_or(effector::Rng::randomSeed());
effector::Rng rng(seed); effector::Rng rng(seed);
auto values = effect->defaults();
for (const auto& [key, value] : options.params) values[key] = value;
const auto start = std::chrono::steady_clock::now(); const auto start = std::chrono::steady_clock::now();
const auto image = effect->render(sources, options.width, options.height, rng, values); const auto image =
effector::renderChain(stages, sources, options.width, options.height, rng);
const auto renderMillis = const auto renderMillis =
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count(); std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
image.save(options.output); image.save(options.output);
std::cout << options.output << " " << options.width << "x" << options.height std::cout << options.output << " " << options.width << "x" << options.height
<< " effect=" << effect->name() << " seed=" << seed << " render=" << " effect=" << effector::chainSlug(stages) << " seed=" << seed << " render="
<< std::fixed << std::setprecision(1) << renderMillis << " ms\n"; << std::fixed << std::setprecision(1) << renderMillis << " ms\n";
return 0; return 0;
} }
+3 -1
View File
@@ -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_link_libraries(effector_tests PRIVATE effector_core)
target_compile_options(effector_tests PRIVATE -Wall -Wextra -Wpedantic) target_compile_options(effector_tests PRIVATE -Wall -Wextra -Wpedantic)
+393
View File
@@ -4,13 +4,21 @@
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <filesystem> #include <filesystem>
#include <fstream>
#include <iostream> #include <iostream>
#include <mutex> #include <mutex>
#include <optional>
#include <string> #include <string>
#include <utility>
#include <vector> #include <vector>
#include "cli/options.hpp"
#include "core/chain.hpp"
#include "core/project.hpp"
#include "core/registry.hpp" #include "core/registry.hpp"
#include "core/rng.hpp" #include "core/rng.hpp"
#include "effects/blur.hpp"
#include "effects/grain.hpp"
#include "effects/scanlines.hpp" #include "effects/scanlines.hpp"
namespace { namespace {
@@ -39,11 +47,72 @@ effector::Image gradient(int width, int height, int shade) {
return image; 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) { bool sameBytes(const effector::Image& a, const effector::Image& b) {
if (a.width() != b.width() || a.height() != b.height()) return false; if (a.width() != b.width() || a.height() != b.height()) return false;
return std::equal(a.data(), a.data() + a.byteSize(), b.data()); return std::equal(a.data(), a.data() + a.byteSize(), b.data());
} }
// Alternating black and white columns: any blur along x has to pull these
// towards mid grey.
effector::Image stripes(int width, int height) {
effector::Image image(width, height);
for (int y = 0; y < height; ++y) {
auto* row = image.row(y);
for (int x = 0; x < width; ++x) {
const std::uint8_t value = (x % 2 == 0) ? 0 : 255;
row[x * 3 + 0] = row[x * 3 + 1] = row[x * 3 + 2] = value;
}
}
return image;
}
effector::Image renderBlur(const std::vector<effector::Image>& sources, int width, int height,
std::uint64_t seed, const effector::ParamValues& values) {
effector::BlurEffect effect;
effector::Rng rng(seed);
auto merged = effect.defaults();
for (const auto& [key, value] : values) merged[key] = value;
return effect.render(sources, width, height, rng, merged);
}
effector::Image 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, effector::Image render(const std::vector<effector::Image>& sources, int width, int height,
std::uint64_t seed, const effector::ParamValues& values) { std::uint64_t seed, const effector::ParamValues& values) {
effector::ScanlinesEffect effect; effector::ScanlinesEffect effect;
@@ -128,6 +197,330 @@ int main() {
check(threw, "no sources throws"); check(threw, "no sources throws");
} }
std::cout << "blur\n";
{
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
const auto first = renderBlur(sources, 400, 300, 4, {{"radius", 10L}});
const auto again = renderBlur(sources, 400, 300, 4, {{"radius", 10L}});
const auto other = renderBlur(sources, 400, 300, 11, {{"radius", 10L}});
check(first.width() == 400 && first.height() == 300, "canvas has the requested size");
check(sameBytes(first, again), "same seed reproduces the image");
check(!sameBytes(first, other), "a different seed changes the image");
// A wide blur over one-pixel stripes has to land near mid grey, and only
// along the axis it is applied to.
std::vector<Image> striped = {stripes(200, 100)};
const auto flattened = renderBlur(striped, 200, 100, 1,
{{"radius", 8L}, {"direction", std::string("horizontal")}});
// The clamped edges ramp in over radius * passes pixels on each side.
bool grey = true;
for (int x = 32; x < 168; ++x) grey = grey && std::abs(flattened.row(50)[x * 3] - 127) <= 2;
check(grey, "a horizontal blur evens out vertical stripes");
const auto untouched = renderBlur(striped, 200, 100, 1,
{{"radius", 8L}, {"direction", std::string("vertical")}});
check(untouched.row(50)[0] == 0 && untouched.row(50)[3] == 255,
"a vertical blur leaves columns alone");
// Every column is uniform, so a vertical blur must not change anything.
const auto vertical = renderBlur(striped, 200, 100, 1,
{{"radius", 4L}, {"passes", 3L}, {"direction", std::string("vertical")}});
check(sameBytes(untouched, vertical), "repeated vertical passes stay stable");
// Radius larger than the canvas must clamp rather than read out of bounds.
const auto tiny = renderBlur(striped, 3, 3, 2, {{"radius", 200L}});
check(tiny.width() == 3 && tiny.height() == 3, "radius wider than the canvas is clamped");
const auto pinned = renderBlur(sources, 300, 200, 6, {{"first_source", true}});
const auto pinnedAgain = renderBlur(sources, 300, 200, 6, {{"first_source", true}});
check(sameBytes(pinned, pinnedAgain), "first_source is reproducible");
// Blur declares that it needs no sources, so with none it softens the
// empty canvas it starts from instead of refusing to run.
const auto blank = renderBlur({}, 100, 100, 1, {});
bool allBlack = blank.width() == 100 && blank.height() == 100;
for (std::size_t i = 0; i < blank.byteSize(); ++i) allBlack = allBlack && blank.data()[i] == 0;
check(allBlack, "without sources blur renders an empty canvas");
}
std::cout << "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"; std::cout << "image io\n";
{ {
const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png"; const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png";