Merge branch 'blur-effect'
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ find_package(Threads REQUIRED)
|
|||||||
|
|
||||||
# --- core: effects and image handling, no GUI ---------------------------------
|
# --- core: effects and image handling, no GUI ---------------------------------
|
||||||
add_library(effector_core STATIC
|
add_library(effector_core STATIC
|
||||||
|
src/core/chain.cpp
|
||||||
src/core/effect.cpp
|
src/core/effect.cpp
|
||||||
src/core/image.cpp
|
src/core/image.cpp
|
||||||
src/core/params.cpp
|
src/core/params.cpp
|
||||||
|
src/core/project.cpp
|
||||||
src/core/registry.cpp
|
src/core/registry.cpp
|
||||||
src/core/rng.cpp
|
src/core/rng.cpp
|
||||||
|
src/effects/blur.cpp
|
||||||
src/effects/scanlines.cpp
|
src/effects/scanlines.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(effector_core PUBLIC src)
|
target_include_directories(effector_core PUBLIC src)
|
||||||
|
|||||||
+21
-4
@@ -4,6 +4,7 @@
|
|||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
|
||||||
|
#include "core/chain.hpp"
|
||||||
#include "core/effect.hpp"
|
#include "core/effect.hpp"
|
||||||
#include "core/registry.hpp"
|
#include "core/registry.hpp"
|
||||||
|
|
||||||
@@ -48,7 +49,6 @@ std::string requireValue(int argc, char** argv, int& i, const std::string& flag)
|
|||||||
|
|
||||||
Options parseOptions(int argc, char** argv) {
|
Options parseOptions(int argc, char** argv) {
|
||||||
Options options;
|
Options options;
|
||||||
options.effect = defaultEffectName();
|
|
||||||
|
|
||||||
for (int i = 1; i < argc; ++i) {
|
for (int i = 1; i < argc; ++i) {
|
||||||
const std::string arg = argv[i];
|
const std::string arg = argv[i];
|
||||||
@@ -58,8 +58,9 @@ Options parseOptions(int argc, char** argv) {
|
|||||||
} else if (arg == "--list-effects") {
|
} else if (arg == "--list-effects") {
|
||||||
options.listEffects = true;
|
options.listEffects = true;
|
||||||
} else if (arg == "-e" || arg == "--effect") {
|
} else if (arg == "-e" || arg == "--effect") {
|
||||||
options.effect = requireValue(argc, argv, i, arg);
|
const std::string name = requireValue(argc, argv, i, arg);
|
||||||
effectByName(options.effect); // validate now, fail early
|
effectByName(name); // validate now, fail early
|
||||||
|
options.effects.push_back(name);
|
||||||
} else if (arg == "-o" || arg == "--output") {
|
} else if (arg == "-o" || arg == "--output") {
|
||||||
options.output = requireValue(argc, argv, i, arg);
|
options.output = requireValue(argc, argv, i, arg);
|
||||||
} else if (arg == "-s" || arg == "--size") {
|
} else if (arg == "-s" || arg == "--size") {
|
||||||
@@ -119,6 +120,7 @@ Options parseOptions(int argc, char** argv) {
|
|||||||
options.images.push_back(arg);
|
options.images.push_back(arg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (options.effects.empty()) options.effects.push_back(defaultEffectName());
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +129,7 @@ std::string usage(const char* programName) {
|
|||||||
out << "Usage: " << programName << " [options] [image ...]\n\n"
|
out << "Usage: " << programName << " [options] [image ...]\n\n"
|
||||||
<< "Image effects app. Runs the GTK interface unless --output is given.\n\n"
|
<< "Image effects app. Runs the GTK interface unless --output is given.\n\n"
|
||||||
<< "Options:\n"
|
<< "Options:\n"
|
||||||
<< " -e, --effect NAME effect to apply (default: " << defaultEffectName() << ")\n"
|
<< " -e, --effect NAME effect to apply, repeat to cascade (default: " << defaultEffectName() << ")\n"
|
||||||
<< " -o, --output FILE render headlessly to this file and exit\n"
|
<< " -o, --output FILE render headlessly to this file and exit\n"
|
||||||
<< " -s, --size WxH canvas size (default: 1920x1080)\n"
|
<< " -s, --size WxH canvas size (default: 1920x1080)\n"
|
||||||
<< " --seed N random seed (default: random)\n"
|
<< " --seed N random seed (default: random)\n"
|
||||||
@@ -147,4 +149,19 @@ std::string usage(const char* programName) {
|
|||||||
return out.str();
|
return out.str();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::vector<ChainStage> buildChain(const Options& options) {
|
||||||
|
std::vector<ChainStage> stages;
|
||||||
|
for (const auto& name : options.effects) {
|
||||||
|
ChainStage stage;
|
||||||
|
stage.effect = effectByName(name);
|
||||||
|
stage.values = stage.effect->defaults();
|
||||||
|
for (const auto& spec : stage.effect->params()) {
|
||||||
|
const auto it = options.params.find(spec.key);
|
||||||
|
if (it != options.params.end()) stage.values[spec.key] = it->second;
|
||||||
|
}
|
||||||
|
stages.push_back(std::move(stage));
|
||||||
|
}
|
||||||
|
return stages;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace effector
|
} // namespace effector
|
||||||
|
|||||||
+7
-1
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#include "core/chain.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
||||||
|
int width, int height, Rng& rng) {
|
||||||
|
if (stages.empty()) throw EffectError("The effect chain is empty.");
|
||||||
|
|
||||||
|
Image image;
|
||||||
|
for (std::size_t i = 0; i < stages.size(); ++i) {
|
||||||
|
const auto& stage = stages[i];
|
||||||
|
if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
|
||||||
|
|
||||||
|
auto values = stage.effect->defaults();
|
||||||
|
for (const auto& [key, value] : stage.values) values[key] = value;
|
||||||
|
|
||||||
|
if (i == 0) {
|
||||||
|
image = stage.effect->render(sources, width, height, rng, values);
|
||||||
|
} else {
|
||||||
|
// Feeding the previous result back in as the only source is what
|
||||||
|
// cascading means here; effects need to know nothing about it.
|
||||||
|
const std::vector<Image> previous = {std::move(image)};
|
||||||
|
image = stage.effect->render(previous, width, height, rng, values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool chainNeedsSources(const std::vector<ChainStage>& stages) {
|
||||||
|
if (stages.empty() || !stages.front().effect) return false;
|
||||||
|
return stages.front().effect->needsSources();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string chainTitle(const std::vector<ChainStage>& stages) {
|
||||||
|
std::string text;
|
||||||
|
for (const auto& stage : stages) {
|
||||||
|
if (!stage.effect) continue;
|
||||||
|
if (!text.empty()) text += " → ";
|
||||||
|
text += stage.effect->title();
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string chainSlug(const std::vector<ChainStage>& stages) {
|
||||||
|
std::string text;
|
||||||
|
for (const auto& stage : stages) {
|
||||||
|
if (!stage.effect) continue;
|
||||||
|
if (!text.empty()) text += "-";
|
||||||
|
text += stage.effect->name();
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/effect.hpp"
|
||||||
|
#include "core/image.hpp"
|
||||||
|
#include "core/params.hpp"
|
||||||
|
#include "core/rng.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// One link of a cascade: an effect together with the values it runs with.
|
||||||
|
struct ChainStage {
|
||||||
|
EffectPtr effect;
|
||||||
|
ParamValues values; // missing keys fall back to the effect's defaults
|
||||||
|
};
|
||||||
|
|
||||||
|
// Renders the stages in order. The first one sees the loaded sources; every
|
||||||
|
// later one sees the previous stage's output as its only source, so blur after
|
||||||
|
// scanlines softens the scanlines rather than the photographs.
|
||||||
|
//
|
||||||
|
// All stages share one Rng and run strictly in sequence, so a seed still
|
||||||
|
// reproduces the cascade byte for byte.
|
||||||
|
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
||||||
|
int width, int height, Rng& rng);
|
||||||
|
|
||||||
|
// Whether the chain has to be given source images. Only the first stage ever
|
||||||
|
// sees them — every later one works on the stage before it — so this is simply
|
||||||
|
// whether the first effect declares that it needs them.
|
||||||
|
bool chainNeedsSources(const std::vector<ChainStage>& stages);
|
||||||
|
|
||||||
|
// "Scanlines → Blur", for window titles and status lines.
|
||||||
|
std::string chainTitle(const std::vector<ChainStage>& stages);
|
||||||
|
|
||||||
|
// "scanlines-blur", for file names.
|
||||||
|
std::string chainSlug(const std::vector<ChainStage>& stages);
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,443 @@
|
|||||||
|
#include "core/project.hpp"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
#include "core/effect.hpp"
|
||||||
|
#include "core/registry.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// A project file is small and its shape is fixed, so it carries its own
|
||||||
|
// reader rather than a JSON dependency: enough of the grammar to read back
|
||||||
|
// what projectToJson writes, and to give a line number when a hand-edited
|
||||||
|
// file is wrong.
|
||||||
|
struct Json {
|
||||||
|
enum class Kind { Null, Bool, Number, String, Array, Object };
|
||||||
|
|
||||||
|
Kind kind = Kind::Null;
|
||||||
|
bool boolean = false;
|
||||||
|
double number = 0.0;
|
||||||
|
bool integral = true; // written without a fraction or exponent
|
||||||
|
std::string text;
|
||||||
|
std::vector<Json> items;
|
||||||
|
std::vector<std::pair<std::string, Json>> members;
|
||||||
|
|
||||||
|
const Json* member(const std::string& key) const {
|
||||||
|
for (const auto& entry : members) {
|
||||||
|
if (entry.first == key) return &entry.second;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class JsonParser {
|
||||||
|
public:
|
||||||
|
explicit JsonParser(const std::string& text) : text_(text) {}
|
||||||
|
|
||||||
|
Json parse() {
|
||||||
|
skipSpace();
|
||||||
|
Json value = parseValue();
|
||||||
|
skipSpace();
|
||||||
|
if (pos_ != text_.size()) fail("trailing text after the end of the document");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
[[noreturn]] void fail(const std::string& what) const {
|
||||||
|
int line = 1;
|
||||||
|
for (std::size_t i = 0; i < pos_ && i < text_.size(); ++i) {
|
||||||
|
if (text_[i] == '\n') ++line;
|
||||||
|
}
|
||||||
|
throw EffectError("line " + std::to_string(line) + ": " + what);
|
||||||
|
}
|
||||||
|
|
||||||
|
char peek() const { return pos_ < text_.size() ? text_[pos_] : '\0'; }
|
||||||
|
|
||||||
|
void skipSpace() {
|
||||||
|
while (pos_ < text_.size()) {
|
||||||
|
const char c = text_[pos_];
|
||||||
|
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
|
||||||
|
++pos_;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void expect(char c) {
|
||||||
|
if (peek() != c) fail(std::string("expected '") + c + "'");
|
||||||
|
++pos_;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool literal(const char* word) {
|
||||||
|
const std::size_t length = std::char_traits<char>::length(word);
|
||||||
|
if (text_.compare(pos_, length, word) != 0) return false;
|
||||||
|
pos_ += length;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Json parseValue() {
|
||||||
|
switch (peek()) {
|
||||||
|
case '{': return parseObject();
|
||||||
|
case '[': return parseArray();
|
||||||
|
case '"': {
|
||||||
|
Json value;
|
||||||
|
value.kind = Json::Kind::String;
|
||||||
|
value.text = parseString();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
if (literal("true")) return boolean(true);
|
||||||
|
if (literal("false")) return boolean(false);
|
||||||
|
if (literal("null")) return Json();
|
||||||
|
return parseNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Json boolean(bool state) {
|
||||||
|
Json value;
|
||||||
|
value.kind = Json::Kind::Bool;
|
||||||
|
value.boolean = state;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
Json parseObject() {
|
||||||
|
Json value;
|
||||||
|
value.kind = Json::Kind::Object;
|
||||||
|
expect('{');
|
||||||
|
skipSpace();
|
||||||
|
if (peek() == '}') {
|
||||||
|
++pos_;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
while (true) {
|
||||||
|
skipSpace();
|
||||||
|
std::string key = parseString();
|
||||||
|
skipSpace();
|
||||||
|
expect(':');
|
||||||
|
skipSpace();
|
||||||
|
value.members.emplace_back(std::move(key), parseValue());
|
||||||
|
skipSpace();
|
||||||
|
if (peek() == ',') {
|
||||||
|
++pos_;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
expect('}');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Json parseArray() {
|
||||||
|
Json value;
|
||||||
|
value.kind = Json::Kind::Array;
|
||||||
|
expect('[');
|
||||||
|
skipSpace();
|
||||||
|
if (peek() == ']') {
|
||||||
|
++pos_;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
while (true) {
|
||||||
|
skipSpace();
|
||||||
|
value.items.push_back(parseValue());
|
||||||
|
skipSpace();
|
||||||
|
if (peek() == ',') {
|
||||||
|
++pos_;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
expect(']');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string parseString() {
|
||||||
|
expect('"');
|
||||||
|
std::string out;
|
||||||
|
while (true) {
|
||||||
|
if (pos_ >= text_.size()) fail("unterminated string");
|
||||||
|
const char c = text_[pos_++];
|
||||||
|
if (c == '"') return out;
|
||||||
|
if (c != '\\') {
|
||||||
|
out.push_back(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (pos_ >= text_.size()) fail("unterminated escape");
|
||||||
|
switch (text_[pos_++]) {
|
||||||
|
case '"': out.push_back('"'); break;
|
||||||
|
case '\\': out.push_back('\\'); break;
|
||||||
|
case '/': out.push_back('/'); break;
|
||||||
|
case 'b': out.push_back('\b'); break;
|
||||||
|
case 'f': out.push_back('\f'); break;
|
||||||
|
case 'n': out.push_back('\n'); break;
|
||||||
|
case 'r': out.push_back('\r'); break;
|
||||||
|
case 't': out.push_back('\t'); break;
|
||||||
|
case 'u': {
|
||||||
|
// Only the basic plane, which is all a path or a choice
|
||||||
|
// name can need; surrogate pairs are passed through as is.
|
||||||
|
if (pos_ + 4 > text_.size()) fail("short \\u escape");
|
||||||
|
const unsigned code = static_cast<unsigned>(
|
||||||
|
std::strtoul(text_.substr(pos_, 4).c_str(), nullptr, 16));
|
||||||
|
pos_ += 4;
|
||||||
|
appendUtf8(out, code);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: fail("unknown escape");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void appendUtf8(std::string& out, unsigned code) {
|
||||||
|
if (code < 0x80) {
|
||||||
|
out.push_back(static_cast<char>(code));
|
||||||
|
} else if (code < 0x800) {
|
||||||
|
out.push_back(static_cast<char>(0xC0 | (code >> 6)));
|
||||||
|
out.push_back(static_cast<char>(0x80 | (code & 0x3F)));
|
||||||
|
} else {
|
||||||
|
out.push_back(static_cast<char>(0xE0 | (code >> 12)));
|
||||||
|
out.push_back(static_cast<char>(0x80 | ((code >> 6) & 0x3F)));
|
||||||
|
out.push_back(static_cast<char>(0x80 | (code & 0x3F)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Json parseNumber() {
|
||||||
|
const std::size_t start = pos_;
|
||||||
|
if (peek() == '-' || peek() == '+') ++pos_;
|
||||||
|
bool integral = true;
|
||||||
|
while (pos_ < text_.size()) {
|
||||||
|
const char c = text_[pos_];
|
||||||
|
if (c >= '0' && c <= '9') {
|
||||||
|
++pos_;
|
||||||
|
} else if (c == '.' || c == 'e' || c == 'E') {
|
||||||
|
integral = false;
|
||||||
|
++pos_;
|
||||||
|
} else if (c == '-' || c == '+') { // an exponent's sign
|
||||||
|
++pos_;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pos_ == start) fail("expected a value");
|
||||||
|
|
||||||
|
Json value;
|
||||||
|
value.kind = Json::Kind::Number;
|
||||||
|
value.integral = integral;
|
||||||
|
try {
|
||||||
|
value.number = std::stod(text_.substr(start, pos_ - start));
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
fail("'" + text_.substr(start, pos_ - start) + "' is not a number");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string& text_;
|
||||||
|
std::size_t pos_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::string quote(const std::string& text) {
|
||||||
|
std::string out = "\"";
|
||||||
|
for (const char c : text) {
|
||||||
|
switch (c) {
|
||||||
|
case '"': out += "\\\""; break;
|
||||||
|
case '\\': out += "\\\\"; break;
|
||||||
|
case '\n': out += "\\n"; break;
|
||||||
|
case '\r': out += "\\r"; break;
|
||||||
|
case '\t': out += "\\t"; break;
|
||||||
|
default:
|
||||||
|
if (static_cast<unsigned char>(c) < 0x20) {
|
||||||
|
char buffer[7];
|
||||||
|
std::snprintf(buffer, sizeof(buffer), "\\u%04x",
|
||||||
|
static_cast<unsigned>(static_cast<unsigned char>(c)));
|
||||||
|
out += buffer;
|
||||||
|
} else {
|
||||||
|
out.push_back(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string number(double value, int digits) {
|
||||||
|
std::ostringstream out;
|
||||||
|
out.setf(std::ios::fixed);
|
||||||
|
out.precision(digits);
|
||||||
|
out << value;
|
||||||
|
std::string text = out.str();
|
||||||
|
// 5.00 reads better as 5.0 and 0.250 as 0.25.
|
||||||
|
while (text.size() > 1 && text.back() == '0' && text[text.size() - 2] != '.') text.pop_back();
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative when the image lives under the project folder, absolute otherwise:
|
||||||
|
// a folder holding the project and its images stays movable.
|
||||||
|
std::string relativeTo(const std::string& path, const std::string& directory) {
|
||||||
|
if (directory.empty()) return path;
|
||||||
|
std::error_code error;
|
||||||
|
const auto relative = std::filesystem::relative(path, directory, error);
|
||||||
|
if (error || relative.empty()) return path;
|
||||||
|
const auto text = relative.generic_string();
|
||||||
|
return text.rfind("..", 0) == 0 ? path : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string resolve(const std::string& path, const std::string& directory) {
|
||||||
|
std::filesystem::path file(path);
|
||||||
|
if (file.is_absolute() || directory.empty()) return path;
|
||||||
|
return (std::filesystem::path(directory) / file).lexically_normal().string();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string projectToJson(const Project& project, const std::string& directory) {
|
||||||
|
std::ostringstream out;
|
||||||
|
out << "{\n";
|
||||||
|
out << " \"effector_project\": 1,\n";
|
||||||
|
out << " \"canvas\": {\"width\": " << project.width << ", \"height\": " << project.height
|
||||||
|
<< "},\n";
|
||||||
|
if (project.seed) out << " \"seed\": " << *project.seed << ",\n";
|
||||||
|
|
||||||
|
out << " \"sources\": [";
|
||||||
|
for (std::size_t i = 0; i < project.sources.size(); ++i) {
|
||||||
|
out << (i == 0 ? "\n" : ",\n") << " " << quote(relativeTo(project.sources[i], directory));
|
||||||
|
}
|
||||||
|
out << (project.sources.empty() ? "" : "\n ") << "],\n";
|
||||||
|
|
||||||
|
out << " \"chain\": [";
|
||||||
|
for (std::size_t i = 0; i < project.stages.size(); ++i) {
|
||||||
|
const auto& stage = project.stages[i];
|
||||||
|
if (!stage.effect) continue;
|
||||||
|
out << (i == 0 ? "\n" : ",\n");
|
||||||
|
out << " {\n \"effect\": " << quote(stage.effect->name()) << ",\n";
|
||||||
|
out << " \"params\": {";
|
||||||
|
|
||||||
|
const auto& specs = stage.effect->params();
|
||||||
|
bool first = true;
|
||||||
|
for (const auto& spec : specs) {
|
||||||
|
out << (first ? "\n" : ",\n") << " " << quote(spec.key) << ": ";
|
||||||
|
first = false;
|
||||||
|
switch (spec.type) {
|
||||||
|
case ParamSpec::Type::Bool:
|
||||||
|
out << (paramBool(stage.values, spec.key, spec.defaultValue != 0.0) ? "true"
|
||||||
|
: "false");
|
||||||
|
break;
|
||||||
|
case ParamSpec::Type::Int:
|
||||||
|
out << paramInt(stage.values, spec.key, static_cast<long>(spec.defaultValue));
|
||||||
|
break;
|
||||||
|
case ParamSpec::Type::Double:
|
||||||
|
out << number(paramDouble(stage.values, spec.key, spec.defaultValue),
|
||||||
|
spec.digits);
|
||||||
|
break;
|
||||||
|
case ParamSpec::Type::Choice:
|
||||||
|
out << quote(paramChoice(stage.values, spec.key, spec.defaultChoice));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out << (specs.empty() ? "}" : "\n }") << "\n }";
|
||||||
|
}
|
||||||
|
out << (project.stages.empty() ? "" : "\n ") << "]\n";
|
||||||
|
out << "}\n";
|
||||||
|
return out.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
Project projectFromJson(const std::string& text, const std::string& directory) {
|
||||||
|
JsonParser parser(text);
|
||||||
|
const Json root = parser.parse();
|
||||||
|
if (root.kind != Json::Kind::Object) throw EffectError("a project must be a JSON object");
|
||||||
|
|
||||||
|
Project project;
|
||||||
|
|
||||||
|
if (const auto* canvas = root.member("canvas")) {
|
||||||
|
if (const auto* width = canvas->member("width")) project.width = static_cast<int>(width->number);
|
||||||
|
if (const auto* height = canvas->member("height")) {
|
||||||
|
project.height = static_cast<int>(height->number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (project.width <= 0 || project.height <= 0) throw EffectError("the canvas size is not positive");
|
||||||
|
|
||||||
|
if (const auto* seed = root.member("seed")) {
|
||||||
|
if (seed->kind == Json::Kind::Number) {
|
||||||
|
project.seed = static_cast<std::uint64_t>(seed->number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (const auto* sources = root.member("sources")) {
|
||||||
|
for (const auto& item : sources->items) {
|
||||||
|
if (item.kind == Json::Kind::String) {
|
||||||
|
project.sources.push_back(resolve(item.text, directory));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto* chain = root.member("chain");
|
||||||
|
if (chain == nullptr || chain->kind != Json::Kind::Array || chain->items.empty()) {
|
||||||
|
throw EffectError("the project has no effect chain");
|
||||||
|
}
|
||||||
|
for (const auto& item : chain->items) {
|
||||||
|
const auto* name = item.member("effect");
|
||||||
|
if (name == nullptr || name->kind != Json::Kind::String) {
|
||||||
|
throw EffectError("a chain stage has no effect name");
|
||||||
|
}
|
||||||
|
|
||||||
|
ChainStage stage;
|
||||||
|
stage.effect = effectByName(name->text); // throws on an unknown effect
|
||||||
|
stage.values = stage.effect->defaults();
|
||||||
|
|
||||||
|
// Driven by the specs, not by the file: a key the effect does not
|
||||||
|
// declare is dropped, and an old project simply keeps the defaults for
|
||||||
|
// params that did not exist when it was written.
|
||||||
|
if (const auto* params = item.member("params")) {
|
||||||
|
for (const auto& spec : stage.effect->params()) {
|
||||||
|
const auto* value = params->member(spec.key);
|
||||||
|
if (value == nullptr) continue;
|
||||||
|
switch (spec.type) {
|
||||||
|
case ParamSpec::Type::Bool:
|
||||||
|
if (value->kind == Json::Kind::Bool) stage.values[spec.key] = value->boolean;
|
||||||
|
break;
|
||||||
|
case ParamSpec::Type::Int:
|
||||||
|
if (value->kind == Json::Kind::Number) {
|
||||||
|
stage.values[spec.key] = static_cast<long>(std::llround(value->number));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ParamSpec::Type::Double:
|
||||||
|
if (value->kind == Json::Kind::Number) stage.values[spec.key] = value->number;
|
||||||
|
break;
|
||||||
|
case ParamSpec::Type::Choice:
|
||||||
|
for (const auto& choice : spec.choices) {
|
||||||
|
if (value->kind == Json::Kind::String && value->text == choice) {
|
||||||
|
stage.values[spec.key] = choice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
project.stages.push_back(std::move(stage));
|
||||||
|
}
|
||||||
|
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
void saveProject(const std::string& path, const Project& project) {
|
||||||
|
const auto directory = std::filesystem::path(path).parent_path().string();
|
||||||
|
std::ofstream file(path, std::ios::binary | std::ios::trunc);
|
||||||
|
if (!file) throw EffectError("cannot write " + path);
|
||||||
|
file << projectToJson(project, directory);
|
||||||
|
if (!file) throw EffectError("could not write " + path);
|
||||||
|
}
|
||||||
|
|
||||||
|
Project loadProject(const std::string& path) {
|
||||||
|
std::ifstream file(path, std::ios::binary);
|
||||||
|
if (!file) throw EffectError("cannot read " + path);
|
||||||
|
std::ostringstream buffer;
|
||||||
|
buffer << file.rdbuf();
|
||||||
|
try {
|
||||||
|
return projectFromJson(buffer.str(), std::filesystem::path(path).parent_path().string());
|
||||||
|
} catch (const EffectError& error) {
|
||||||
|
throw EffectError(std::filesystem::path(path).filename().string() + ": " + error.what());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/chain.hpp"
|
||||||
|
#include "core/params.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// Everything needed to recreate a render: which images went in, the cascade
|
||||||
|
// with its settings, the canvas and the seed. Deliberately no pixels — a
|
||||||
|
// project is a small text file that points at the sources on disk.
|
||||||
|
struct Project {
|
||||||
|
std::vector<std::string> sources; // absolute paths
|
||||||
|
std::vector<ChainStage> stages;
|
||||||
|
int width = 1920;
|
||||||
|
int height = 1080;
|
||||||
|
std::optional<std::uint64_t> seed;
|
||||||
|
};
|
||||||
|
|
||||||
|
// JSON, written for humans: two-space indent, one source path per line, and
|
||||||
|
// the params of a stage spelled with the same keys the effect declares.
|
||||||
|
// Source paths are written relative to `directory` when they live under it, so
|
||||||
|
// a project folder can be moved or shared whole.
|
||||||
|
std::string projectToJson(const Project& project, const std::string& directory = {});
|
||||||
|
|
||||||
|
// Throws EffectError on malformed JSON, an unknown effect or a missing chain.
|
||||||
|
// Relative source paths are resolved against `directory`; params the effect
|
||||||
|
// does not declare are dropped, and missing ones keep the effect's default.
|
||||||
|
Project projectFromJson(const std::string& text, const std::string& directory = {});
|
||||||
|
|
||||||
|
// Both throw EffectError: the file is the user's, so failures are reported
|
||||||
|
// rather than silently swallowed.
|
||||||
|
void saveProject(const std::string& path, const Project& project);
|
||||||
|
Project loadProject(const std::string& path);
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "core/registry.hpp"
|
#include "core/registry.hpp"
|
||||||
|
|
||||||
|
#include "effects/blur.hpp"
|
||||||
#include "effects/scanlines.hpp"
|
#include "effects/scanlines.hpp"
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
@@ -8,6 +9,7 @@ namespace effector {
|
|||||||
const std::vector<EffectPtr>& effects() {
|
const std::vector<EffectPtr>& effects() {
|
||||||
static const std::vector<EffectPtr> registry = {
|
static const std::vector<EffectPtr> registry = {
|
||||||
std::make_shared<ScanlinesEffect>(),
|
std::make_shared<ScanlinesEffect>(),
|
||||||
|
std::make_shared<BlurEffect>(),
|
||||||
};
|
};
|
||||||
return registry;
|
return registry;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
#include "effects/blur.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/parallel.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
const std::vector<ParamSpec>& BlurEffect::params() const {
|
||||||
|
static const std::vector<ParamSpec> specs = {
|
||||||
|
ParamSpec::makeInt("radius", "Radius", 8, 1, 256,
|
||||||
|
"Half width in pixels of the blur window."),
|
||||||
|
ParamSpec::makeInt("passes", "Passes", 2, 1, 8,
|
||||||
|
"How often the box blur is repeated; more passes look more gaussian."),
|
||||||
|
ParamSpec::makeChoice("direction", "Direction", "both", {"both", "horizontal", "vertical"},
|
||||||
|
"Axis the blur is applied along."),
|
||||||
|
ParamSpec::makeBool("first_source", "Use the first image", false,
|
||||||
|
"Always blur the first source instead of picking one at random."),
|
||||||
|
};
|
||||||
|
return specs;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Copies `src` into the canvas starting at (sx, sy), repeating it as needed so
|
||||||
|
// that every pixel is covered even when the source is smaller than the canvas.
|
||||||
|
void fillFromSource(Image& canvas, const Image& src, int sx, int sy) {
|
||||||
|
const int width = canvas.width();
|
||||||
|
const std::size_t total = static_cast<std::size_t>(width) * 3;
|
||||||
|
const int copyWidth = std::min(width, src.width() - sx);
|
||||||
|
const std::size_t copyBytes = static_cast<std::size_t>(copyWidth) * 3;
|
||||||
|
|
||||||
|
parallelFor(static_cast<std::size_t>(canvas.height()), [&](std::size_t begin, std::size_t end) {
|
||||||
|
for (std::size_t y = begin; y < end; ++y) {
|
||||||
|
const int srcY = sy + (static_cast<int>(y) % std::max(1, src.height() - sy));
|
||||||
|
const std::uint8_t* in = src.row(srcY) + static_cast<std::size_t>(sx) * 3;
|
||||||
|
std::uint8_t* out = canvas.row(static_cast<int>(y));
|
||||||
|
|
||||||
|
std::memcpy(out, in, copyBytes);
|
||||||
|
std::size_t filled = copyBytes;
|
||||||
|
while (filled < total) {
|
||||||
|
const std::size_t chunk = std::min(filled, total - filled);
|
||||||
|
std::memcpy(out + filled, out, chunk);
|
||||||
|
filled += chunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// One separable box blur pass. `stride` is the distance in bytes between two
|
||||||
|
// neighbouring samples along the blurred axis, `count` how many of them there
|
||||||
|
// are; the window is clamped at both ends.
|
||||||
|
void boxPass(const std::uint8_t* in, std::uint8_t* out, int count, std::size_t stride, int radius) {
|
||||||
|
const int window = 2 * radius + 1;
|
||||||
|
int sum[3] = {0, 0, 0};
|
||||||
|
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
sum[c] = in[c] * (radius + 1);
|
||||||
|
for (int i = 1; i <= radius; ++i) {
|
||||||
|
sum[c] += in[static_cast<std::size_t>(std::min(i, count - 1)) * stride + c];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
out[static_cast<std::size_t>(i) * stride + c] =
|
||||||
|
static_cast<std::uint8_t>(sum[c] / window);
|
||||||
|
}
|
||||||
|
const int add = std::min(i + radius + 1, count - 1);
|
||||||
|
const int drop = std::max(i - radius, 0);
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
sum[c] += in[static_cast<std::size_t>(add) * stride + c] -
|
||||||
|
in[static_cast<std::size_t>(drop) * stride + c];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void blurHorizontal(const Image& src, Image& dst, int radius) {
|
||||||
|
parallelFor(static_cast<std::size_t>(src.height()), [&](std::size_t begin, std::size_t end) {
|
||||||
|
for (std::size_t y = begin; y < end; ++y) {
|
||||||
|
boxPass(src.row(static_cast<int>(y)), dst.row(static_cast<int>(y)), src.width(), 3,
|
||||||
|
radius);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void blurVertical(const Image& src, Image& dst, int radius) {
|
||||||
|
const std::size_t stride = src.stride();
|
||||||
|
parallelFor(static_cast<std::size_t>(src.width()), [&](std::size_t begin, std::size_t end) {
|
||||||
|
for (std::size_t x = begin; x < end; ++x) {
|
||||||
|
const std::size_t offset = x * 3;
|
||||||
|
boxPass(src.data() + offset, dst.data() + offset, src.height(), stride, radius);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Image BlurEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||||
|
const ParamValues& values) const {
|
||||||
|
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
|
||||||
|
|
||||||
|
const int radius = static_cast<int>(std::max(1L, paramInt(values, "radius", 8)));
|
||||||
|
const int passes = static_cast<int>(std::max(1L, paramInt(values, "passes", 2)));
|
||||||
|
const std::string direction = paramChoice(values, "direction", "both");
|
||||||
|
const bool firstSource = paramBool(values, "first_source", false);
|
||||||
|
|
||||||
|
Image canvas(width, height);
|
||||||
|
if (!sources.empty()) {
|
||||||
|
// Every random value is drawn here, before any threading, so that a
|
||||||
|
// seed always gives the same image.
|
||||||
|
const std::size_t index = firstSource ? 0 : rng.below(sources.size() - 1);
|
||||||
|
const Image& src = sources[index];
|
||||||
|
const int sx = static_cast<int>(rng.inRange(0, std::max(0, src.width() - width)));
|
||||||
|
const int sy = static_cast<int>(rng.inRange(0, std::max(0, src.height() - height)));
|
||||||
|
fillFromSource(canvas, src, sx, sy);
|
||||||
|
}
|
||||||
|
|
||||||
|
Image scratch(width, height);
|
||||||
|
for (int pass = 0; pass < passes; ++pass) {
|
||||||
|
if (direction != "vertical") {
|
||||||
|
blurHorizontal(canvas, scratch, std::min(radius, std::max(1, width - 1)));
|
||||||
|
std::swap(canvas, scratch);
|
||||||
|
}
|
||||||
|
if (direction != "horizontal") {
|
||||||
|
blurVertical(canvas, scratch, std::min(radius, std::max(1, height - 1)));
|
||||||
|
std::swap(canvas, scratch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -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
|
||||||
+445
-73
@@ -1,9 +1,12 @@
|
|||||||
#include "gui/window.hpp"
|
#include "gui/window.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
|
||||||
|
#include "core/chain.hpp"
|
||||||
|
#include "core/project.hpp"
|
||||||
#include "core/registry.hpp"
|
#include "core/registry.hpp"
|
||||||
#include "core/rng.hpp"
|
#include "core/rng.hpp"
|
||||||
|
|
||||||
@@ -14,6 +17,15 @@ const char* const kImagePatterns[] = {"*.png", "*.jpg", "*.jpeg", "*.webp", "*
|
|||||||
"*.tif", "*.tiff", "*.gif", "*.ppm", "*.PNG",
|
"*.tif", "*.tiff", "*.gif", "*.ppm", "*.PNG",
|
||||||
"*.JPG", "*.JPEG", "*.WEBP"};
|
"*.JPG", "*.JPEG", "*.WEBP"};
|
||||||
|
|
||||||
|
Glib::RefPtr<Gio::ListStore<Gtk::FileFilter>> projectFilters() {
|
||||||
|
auto filter = Gtk::FileFilter::create();
|
||||||
|
filter->set_name("Effector projects");
|
||||||
|
filter->add_pattern("*.json");
|
||||||
|
auto filters = Gio::ListStore<Gtk::FileFilter>::create();
|
||||||
|
filters->append(filter);
|
||||||
|
return filters;
|
||||||
|
}
|
||||||
|
|
||||||
Glib::RefPtr<Gdk::Texture> toTexture(const Image& image) {
|
Glib::RefPtr<Gdk::Texture> toTexture(const Image& image) {
|
||||||
auto bytes = Glib::Bytes::create(image.data(), image.byteSize());
|
auto bytes = Glib::Bytes::create(image.data(), image.byteSize());
|
||||||
return Gdk::MemoryTexture::create(image.width(), image.height(), Gdk::MemoryTexture::Format::R8G8B8,
|
return Gdk::MemoryTexture::create(image.width(), image.height(), Gdk::MemoryTexture::Format::R8G8B8,
|
||||||
@@ -34,7 +46,7 @@ EffectorWindow::EffectorWindow(const Options& initial) {
|
|||||||
set_default_size(1180, 760);
|
set_default_size(1180, 760);
|
||||||
|
|
||||||
effects_ = effects();
|
effects_ = effects();
|
||||||
effect_ = effects_.front();
|
stages_.push_back({effects_.front(), effects_.front()->defaults()});
|
||||||
seed_ = Rng::randomSeed();
|
seed_ = Rng::randomSeed();
|
||||||
|
|
||||||
renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished));
|
renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished));
|
||||||
@@ -45,7 +57,13 @@ EffectorWindow::EffectorWindow(const Options& initial) {
|
|||||||
applyInitial(initial);
|
applyInitial(initial);
|
||||||
|
|
||||||
suspendRender_ = false;
|
suspendRender_ = false;
|
||||||
if (!sources_->empty()) startRender();
|
if (loading_) {
|
||||||
|
placeholder_.set_text(missingText());
|
||||||
|
} else if (canRender()) {
|
||||||
|
startRender();
|
||||||
|
} else {
|
||||||
|
showPlaceholder();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
EffectorWindow::~EffectorWindow() {
|
EffectorWindow::~EffectorWindow() {
|
||||||
@@ -62,6 +80,11 @@ void EffectorWindow::buildUi() {
|
|||||||
auto* header = Gtk::make_managed<Gtk::HeaderBar>();
|
auto* header = Gtk::make_managed<Gtk::HeaderBar>();
|
||||||
set_titlebar(*header);
|
set_titlebar(*header);
|
||||||
|
|
||||||
|
menuButton_.set_icon_name("open-menu-symbolic");
|
||||||
|
menuButton_.set_tooltip_text("Project menu");
|
||||||
|
menuButton_.set_popover(static_cast<Gtk::PopoverMenu&>(buildMainMenu()));
|
||||||
|
header->pack_end(menuButton_);
|
||||||
|
|
||||||
auto* addButton = Gtk::make_managed<Gtk::Button>();
|
auto* addButton = Gtk::make_managed<Gtk::Button>();
|
||||||
addButton->set_icon_name("list-add-symbolic");
|
addButton->set_icon_name("list-add-symbolic");
|
||||||
addButton->set_tooltip_text("Add images");
|
addButton->set_tooltip_text("Add images");
|
||||||
@@ -69,14 +92,6 @@ void EffectorWindow::buildUi() {
|
|||||||
header->pack_start(*addButton);
|
header->pack_start(*addButton);
|
||||||
addButton_ = addButton;
|
addButton_ = addButton;
|
||||||
|
|
||||||
std::vector<Glib::ustring> titles;
|
|
||||||
for (const auto& effect : effects_) titles.push_back(effect->title());
|
|
||||||
effectDropdown_.set_model(Gtk::StringList::create(titles));
|
|
||||||
effectDropdown_.set_tooltip_text("Effect");
|
|
||||||
effectDropdown_.property_selected().signal_changed().connect(
|
|
||||||
sigc::mem_fun(*this, &EffectorWindow::onEffectChanged));
|
|
||||||
header->pack_start(effectDropdown_);
|
|
||||||
|
|
||||||
saveButton_.set_sensitive(false);
|
saveButton_.set_sensitive(false);
|
||||||
saveButton_.signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onSave));
|
saveButton_.signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onSave));
|
||||||
header->pack_end(saveButton_);
|
header->pack_end(saveButton_);
|
||||||
@@ -148,10 +163,15 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
|
|||||||
grid->attach(heightSpin_, 1, 1);
|
grid->attach(heightSpin_, 1, 1);
|
||||||
sidebar->append(*grid);
|
sidebar->append(*grid);
|
||||||
|
|
||||||
sidebar->append(*sectionLabel("Effect settings"));
|
sidebar->append(buildChainSection());
|
||||||
|
|
||||||
|
paramHeading_.set_xalign(0.0F);
|
||||||
|
paramHeading_.add_css_class("heading");
|
||||||
|
sidebar->append(paramHeading_);
|
||||||
paramGrid_.set_column_spacing(8);
|
paramGrid_.set_column_spacing(8);
|
||||||
paramGrid_.set_row_spacing(6);
|
paramGrid_.set_row_spacing(6);
|
||||||
sidebar->append(paramGrid_);
|
sidebar->append(paramGrid_);
|
||||||
|
rebuildChainRows();
|
||||||
buildParamWidgets();
|
buildParamWidgets();
|
||||||
|
|
||||||
auto* seedBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
|
auto* seedBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
|
||||||
@@ -202,8 +222,16 @@ void EffectorWindow::buildParamWidgets() {
|
|||||||
paramWidgets_.clear();
|
paramWidgets_.clear();
|
||||||
ownedWidgets_.clear();
|
ownedWidgets_.clear();
|
||||||
|
|
||||||
|
if (stages_.empty()) {
|
||||||
|
paramHeading_.set_text("No effects");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
|
||||||
|
paramHeading_.set_text(std::to_string(selectedStage_ + 1) + ". " + stage.effect->title() +
|
||||||
|
" settings");
|
||||||
|
|
||||||
int row = 0;
|
int row = 0;
|
||||||
for (const auto& spec : effect_->params()) {
|
for (const auto& spec : stage.effect->params()) {
|
||||||
auto label = std::make_unique<Gtk::Label>(spec.label);
|
auto label = std::make_unique<Gtk::Label>(spec.label);
|
||||||
label->set_xalign(0.0F);
|
label->set_xalign(0.0F);
|
||||||
if (!spec.help.empty()) label->set_tooltip_text(spec.help);
|
if (!spec.help.empty()) label->set_tooltip_text(spec.help);
|
||||||
@@ -213,7 +241,7 @@ void EffectorWindow::buildParamWidgets() {
|
|||||||
case ParamSpec::Type::Bool: {
|
case ParamSpec::Type::Bool: {
|
||||||
auto toggle = std::make_unique<Gtk::Switch>();
|
auto toggle = std::make_unique<Gtk::Switch>();
|
||||||
toggle->set_halign(Gtk::Align::START);
|
toggle->set_halign(Gtk::Align::START);
|
||||||
toggle->set_active(spec.defaultValue != 0.0);
|
toggle->set_active(paramBool(stage.values, spec.key, spec.defaultValue != 0.0));
|
||||||
toggle->property_active().signal_changed().connect(
|
toggle->property_active().signal_changed().connect(
|
||||||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||||||
widget = std::move(toggle);
|
widget = std::move(toggle);
|
||||||
@@ -225,7 +253,10 @@ void EffectorWindow::buildParamWidgets() {
|
|||||||
spin->set_range(spec.minimum, spec.maximum);
|
spin->set_range(spec.minimum, spec.maximum);
|
||||||
spin->set_increments(spec.step, spec.step * 10);
|
spin->set_increments(spec.step, spec.step * 10);
|
||||||
spin->set_digits(spec.type == ParamSpec::Type::Int ? 0 : spec.digits);
|
spin->set_digits(spec.type == ParamSpec::Type::Int ? 0 : spec.digits);
|
||||||
spin->set_value(spec.defaultValue);
|
spin->set_value(spec.type == ParamSpec::Type::Int
|
||||||
|
? static_cast<double>(paramInt(stage.values, spec.key,
|
||||||
|
static_cast<long>(spec.defaultValue)))
|
||||||
|
: paramDouble(stage.values, spec.key, spec.defaultValue));
|
||||||
spin->signal_value_changed().connect(
|
spin->signal_value_changed().connect(
|
||||||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||||||
widget = std::move(spin);
|
widget = std::move(spin);
|
||||||
@@ -235,8 +266,9 @@ void EffectorWindow::buildParamWidgets() {
|
|||||||
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
|
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
|
||||||
auto dropdown = std::make_unique<Gtk::DropDown>();
|
auto dropdown = std::make_unique<Gtk::DropDown>();
|
||||||
dropdown->set_model(Gtk::StringList::create(choices));
|
dropdown->set_model(Gtk::StringList::create(choices));
|
||||||
|
const auto current = paramChoice(stage.values, spec.key, spec.defaultChoice);
|
||||||
for (std::size_t i = 0; i < spec.choices.size(); ++i) {
|
for (std::size_t i = 0; i < spec.choices.size(); ++i) {
|
||||||
if (spec.choices[i] == spec.defaultChoice) dropdown->set_selected(i);
|
if (spec.choices[i] == current) dropdown->set_selected(i);
|
||||||
}
|
}
|
||||||
dropdown->property_selected().signal_changed().connect(
|
dropdown->property_selected().signal_changed().connect(
|
||||||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||||||
@@ -256,7 +288,8 @@ void EffectorWindow::buildParamWidgets() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ParamValues EffectorWindow::paramValues() const {
|
ParamValues EffectorWindow::paramValues() const {
|
||||||
ParamValues values = effect_->defaults();
|
if (stages_.empty()) return {};
|
||||||
|
ParamValues values = stages_[static_cast<std::size_t>(selectedStage_)].values;
|
||||||
for (const auto& entry : paramWidgets_) {
|
for (const auto& entry : paramWidgets_) {
|
||||||
switch (entry.spec.type) {
|
switch (entry.spec.type) {
|
||||||
case ParamSpec::Type::Bool:
|
case ParamSpec::Type::Bool:
|
||||||
@@ -279,43 +312,28 @@ ParamValues EffectorWindow::paramValues() const {
|
|||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The widgets only ever show one stage, so their values are written back
|
||||||
|
// whenever they change; the other stages keep the settings they were given.
|
||||||
|
void EffectorWindow::storeParams() {
|
||||||
|
if (stages_.empty()) return;
|
||||||
|
stages_[static_cast<std::size_t>(selectedStage_)].values = paramValues();
|
||||||
|
}
|
||||||
|
|
||||||
void EffectorWindow::applyInitial(const Options& initial) {
|
void EffectorWindow::applyInitial(const Options& initial) {
|
||||||
for (std::size_t i = 0; i < effects_.size(); ++i) {
|
// `-e scanlines -e blur` starts the interface with that cascade already set
|
||||||
if (effects_[i]->name() == initial.effect) effectDropdown_.set_selected(i);
|
// up, flags included.
|
||||||
}
|
auto stages = buildChain(initial);
|
||||||
|
if (!stages.empty()) stages_ = std::move(stages);
|
||||||
|
selectedStage_ = 0;
|
||||||
|
rebuildChainRows();
|
||||||
|
buildParamWidgets();
|
||||||
|
|
||||||
widthSpin_.set_value(initial.width);
|
widthSpin_.set_value(initial.width);
|
||||||
heightSpin_.set_value(initial.height);
|
heightSpin_.set_value(initial.height);
|
||||||
if (initial.seed) {
|
if (initial.seed) {
|
||||||
seed_ = *initial.seed;
|
seed_ = *initial.seed;
|
||||||
seedEntry_.set_text(std::to_string(seed_));
|
seedEntry_.set_text(std::to_string(seed_));
|
||||||
}
|
}
|
||||||
for (const auto& entry : paramWidgets_) {
|
|
||||||
const auto it = initial.params.find(entry.spec.key);
|
|
||||||
if (it == initial.params.end()) continue;
|
|
||||||
switch (entry.spec.type) {
|
|
||||||
case ParamSpec::Type::Bool:
|
|
||||||
static_cast<Gtk::Switch*>(entry.widget)
|
|
||||||
->set_active(paramBool(initial.params, entry.spec.key, false));
|
|
||||||
break;
|
|
||||||
case ParamSpec::Type::Int:
|
|
||||||
static_cast<Gtk::SpinButton*>(entry.widget)
|
|
||||||
->set_value(static_cast<double>(paramInt(initial.params, entry.spec.key, 0)));
|
|
||||||
break;
|
|
||||||
case ParamSpec::Type::Double:
|
|
||||||
static_cast<Gtk::SpinButton*>(entry.widget)
|
|
||||||
->set_value(paramDouble(initial.params, entry.spec.key, 0.0));
|
|
||||||
break;
|
|
||||||
case ParamSpec::Type::Choice: {
|
|
||||||
const auto value = paramChoice(initial.params, entry.spec.key, entry.spec.defaultChoice);
|
|
||||||
for (std::size_t i = 0; i < entry.spec.choices.size(); ++i) {
|
|
||||||
if (entry.spec.choices[i] == value) {
|
|
||||||
static_cast<Gtk::DropDown*>(entry.widget)->set_selected(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!initial.images.empty()) addPaths(initial.images);
|
if (!initial.images.empty()) addPaths(initial.images);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,10 +439,19 @@ void EffectorWindow::onLoadFinished() {
|
|||||||
if (loadCancel_.load(std::memory_order_relaxed)) {
|
if (loadCancel_.load(std::memory_order_relaxed)) {
|
||||||
// A cancelled batch is incomplete, so none of it is taken.
|
// A cancelled batch is incomplete, so none of it is taken.
|
||||||
loadQueue_.clear();
|
loadQueue_.clear();
|
||||||
|
dropLoadedImages_ = false;
|
||||||
showError("Loading cancelled.");
|
showError("Loading cancelled.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dropLoadedImages_) {
|
||||||
|
// A project was opened while this batch was decoding: its images belong
|
||||||
|
// to the previous source list, and the project's own are queued behind.
|
||||||
|
dropLoadedImages_ = false;
|
||||||
|
images.clear();
|
||||||
|
failures.clear();
|
||||||
|
}
|
||||||
|
|
||||||
for (const auto& image : images) appendSourceRow(image.path());
|
for (const auto& image : images) appendSourceRow(image.path());
|
||||||
mutateSources([&images](SourceList& list) {
|
mutateSources([&images](SourceList& list) {
|
||||||
for (auto& image : images) list.push_back(std::move(image));
|
for (auto& image : images) list.push_back(std::move(image));
|
||||||
@@ -548,15 +575,7 @@ void EffectorWindow::removeSource(Gtk::ListBoxRow* row) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
sourceList_.remove(*row);
|
sourceList_.remove(*row);
|
||||||
|
requestRender();
|
||||||
if (!sources_->empty()) {
|
|
||||||
requestRender();
|
|
||||||
} else {
|
|
||||||
result_ = Image();
|
|
||||||
saveButton_.set_sensitive(false);
|
|
||||||
previewStack_.set_visible_child("placeholder");
|
|
||||||
status_.set_text("");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void EffectorWindow::onAddImages() {
|
void EffectorWindow::onAddImages() {
|
||||||
@@ -589,17 +608,168 @@ void EffectorWindow::onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result
|
|||||||
if (!paths.empty()) addPaths(paths);
|
if (!paths.empty()) addPaths(paths);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------ rendering ---
|
// ---------------------------------------------------------------- chain ---
|
||||||
|
|
||||||
void EffectorWindow::onEffectChanged() {
|
Gtk::Widget& EffectorWindow::buildChainSection() {
|
||||||
const auto index = effectDropdown_.get_selected();
|
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
|
||||||
if (index >= effects_.size()) return;
|
box->append(*sectionLabel("Effect chain"));
|
||||||
effect_ = effects_[index];
|
|
||||||
|
chainList_.add_css_class("boxed-list");
|
||||||
|
chainList_.set_selection_mode(Gtk::SelectionMode::SINGLE);
|
||||||
|
auto* empty = Gtk::make_managed<Gtk::Label>("No effects yet");
|
||||||
|
empty->set_margin_top(12);
|
||||||
|
empty->set_margin_bottom(12);
|
||||||
|
chainList_.set_placeholder(*empty);
|
||||||
|
chainList_.signal_row_selected().connect(
|
||||||
|
[this](Gtk::ListBoxRow*) { onStageSelected(); });
|
||||||
|
box->append(chainList_);
|
||||||
|
|
||||||
|
// set_label() and set_icon_name() replace one another, so the icon and the
|
||||||
|
// text are put together by hand.
|
||||||
|
auto* addBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
|
||||||
|
addBox->append(*Gtk::make_managed<Gtk::Image>(
|
||||||
|
Gio::ThemedIcon::create("list-add-symbolic")));
|
||||||
|
addBox->append(*Gtk::make_managed<Gtk::Label>("Add effect"));
|
||||||
|
addEffectButton_.set_child(*addBox);
|
||||||
|
addEffectButton_.set_always_show_arrow(true);
|
||||||
|
addEffectButton_.set_tooltip_text("Append an effect, applied to the output so far");
|
||||||
|
addEffectButton_.set_popover(static_cast<Gtk::Popover&>(buildAddMenu()));
|
||||||
|
addEffectButton_.set_halign(Gtk::Align::START);
|
||||||
|
box->append(addEffectButton_);
|
||||||
|
|
||||||
|
return *box;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One entry per registered effect, so a new effect shows up here without this
|
||||||
|
// file knowing anything about it.
|
||||||
|
Gtk::Widget& EffectorWindow::buildAddMenu() {
|
||||||
|
auto* popover = Gtk::make_managed<Gtk::Popover>();
|
||||||
|
auto* list = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 2);
|
||||||
|
|
||||||
|
for (const auto& effect : effects_) {
|
||||||
|
auto* item = Gtk::make_managed<Gtk::Button>(effect->title());
|
||||||
|
item->add_css_class("flat");
|
||||||
|
item->set_tooltip_text(effect->description());
|
||||||
|
if (auto* child = dynamic_cast<Gtk::Label*>(item->get_child())) child->set_xalign(0.0F);
|
||||||
|
item->signal_clicked().connect([this, effect] {
|
||||||
|
addEffectButton_.popdown();
|
||||||
|
appendStage(effect);
|
||||||
|
});
|
||||||
|
list->append(*item);
|
||||||
|
}
|
||||||
|
|
||||||
|
popover->set_child(*list);
|
||||||
|
return *popover;
|
||||||
|
}
|
||||||
|
|
||||||
|
void EffectorWindow::appendStage(const EffectPtr& effect) {
|
||||||
|
stages_.push_back({effect, effect->defaults()});
|
||||||
|
selectedStage_ = static_cast<int>(stages_.size()) - 1;
|
||||||
|
rebuildChainRows();
|
||||||
buildParamWidgets();
|
buildParamWidgets();
|
||||||
requestRender();
|
requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
void EffectorWindow::onParamChanged() { requestRender(); }
|
void EffectorWindow::removeStage(int index) {
|
||||||
|
if (index < 0 || static_cast<std::size_t>(index) >= stages_.size()) return;
|
||||||
|
stages_.erase(stages_.begin() + index);
|
||||||
|
selectedStage_ = std::min(selectedStage_, static_cast<int>(stages_.size()) - 1);
|
||||||
|
if (selectedStage_ < 0) selectedStage_ = 0;
|
||||||
|
rebuildChainRows();
|
||||||
|
buildParamWidgets();
|
||||||
|
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
void EffectorWindow::moveStage(int index, int delta) {
|
||||||
|
const int target = index + delta;
|
||||||
|
if (index < 0 || static_cast<std::size_t>(index) >= stages_.size()) return;
|
||||||
|
if (target < 0 || static_cast<std::size_t>(target) >= stages_.size()) return;
|
||||||
|
std::swap(stages_[static_cast<std::size_t>(index)], stages_[static_cast<std::size_t>(target)]);
|
||||||
|
selectedStage_ = target;
|
||||||
|
rebuildChainRows();
|
||||||
|
buildParamWidgets();
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
void EffectorWindow::rebuildChainRows() {
|
||||||
|
// Rebuilding drops and re-adds the selection, which would otherwise look
|
||||||
|
// like the user picking a different stage.
|
||||||
|
suspendStageSelect_ = true;
|
||||||
|
// Only the rows: the placeholder is a child of the list box as well, and
|
||||||
|
// removing it would leave the empty list blank.
|
||||||
|
while (auto* row = chainList_.get_row_at_index(0)) chainList_.remove(*row);
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < stages_.size(); ++i) {
|
||||||
|
const int index = static_cast<int>(i);
|
||||||
|
auto* row = Gtk::make_managed<Gtk::ListBoxRow>();
|
||||||
|
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 4);
|
||||||
|
box->set_margin_top(4);
|
||||||
|
box->set_margin_bottom(4);
|
||||||
|
box->set_margin_start(8);
|
||||||
|
box->set_margin_end(4);
|
||||||
|
|
||||||
|
auto* label = Gtk::make_managed<Gtk::Label>(std::to_string(index + 1) + ". " +
|
||||||
|
stages_[i].effect->title());
|
||||||
|
label->set_xalign(0.0F);
|
||||||
|
label->set_hexpand(true);
|
||||||
|
label->set_ellipsize(Pango::EllipsizeMode::END);
|
||||||
|
label->set_tooltip_text(index == 0 ? "Applied to the source images"
|
||||||
|
: "Applied to the output of the stage above");
|
||||||
|
box->append(*label);
|
||||||
|
|
||||||
|
struct Action {
|
||||||
|
const char* icon;
|
||||||
|
const char* tip;
|
||||||
|
int delta; // 0 means remove
|
||||||
|
};
|
||||||
|
for (const Action& action : {Action{"go-up-symbolic", "Move up", -1},
|
||||||
|
Action{"go-down-symbolic", "Move down", 1},
|
||||||
|
Action{"window-close-symbolic", "Remove", 0}}) {
|
||||||
|
auto* button = Gtk::make_managed<Gtk::Button>();
|
||||||
|
button->set_icon_name(action.icon);
|
||||||
|
button->set_tooltip_text(action.tip);
|
||||||
|
button->add_css_class("flat");
|
||||||
|
const int delta = action.delta;
|
||||||
|
button->signal_clicked().connect([this, index, delta] {
|
||||||
|
if (delta == 0) {
|
||||||
|
removeStage(index);
|
||||||
|
} else {
|
||||||
|
moveStage(index, delta);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (delta == -1) button->set_sensitive(index > 0);
|
||||||
|
if (delta == 1) button->set_sensitive(static_cast<std::size_t>(index) + 1 < stages_.size());
|
||||||
|
box->append(*button);
|
||||||
|
}
|
||||||
|
|
||||||
|
row->set_child(*box);
|
||||||
|
chainList_.append(*row);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectStage(selectedStage_);
|
||||||
|
suspendStageSelect_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void EffectorWindow::selectStage(int index) {
|
||||||
|
if (auto* row = chainList_.get_row_at_index(index)) chainList_.select_row(*row);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EffectorWindow::onStageSelected() {
|
||||||
|
if (suspendStageSelect_) return;
|
||||||
|
auto* row = chainList_.get_selected_row();
|
||||||
|
if (row == nullptr) return;
|
||||||
|
if (row->get_index() == selectedStage_) return;
|
||||||
|
selectedStage_ = row->get_index();
|
||||||
|
buildParamWidgets();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ rendering ---
|
||||||
|
|
||||||
|
void EffectorWindow::onParamChanged() {
|
||||||
|
storeParams();
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
void EffectorWindow::onSeedEntered() {
|
void EffectorWindow::onSeedEntered() {
|
||||||
try {
|
try {
|
||||||
@@ -614,15 +784,40 @@ void EffectorWindow::onSeedEntered() {
|
|||||||
void EffectorWindow::onRecreate() {
|
void EffectorWindow::onRecreate() {
|
||||||
seed_ = Rng::randomSeed();
|
seed_ = Rng::randomSeed();
|
||||||
seedEntry_.set_text(std::to_string(seed_));
|
seedEntry_.set_text(std::to_string(seed_));
|
||||||
if (sources_->empty()) {
|
if (!canRender()) {
|
||||||
showError("Add at least one source image first.");
|
showError(missingText());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
requestRender();
|
requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether the effect chain has everything it needs. Only the first stage is
|
||||||
|
// ever handed the sources, so an effect that does not want them (blur) renders
|
||||||
|
// happily with none loaded.
|
||||||
|
bool EffectorWindow::canRender() const {
|
||||||
|
if (stages_.empty()) return false;
|
||||||
|
return !sources_->empty() || !chainNeedsSources(stages_);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string EffectorWindow::missingText() const {
|
||||||
|
if (stages_.empty()) return "Add an effect to start.";
|
||||||
|
return "Add source images to start.";
|
||||||
|
}
|
||||||
|
|
||||||
|
void EffectorWindow::showPlaceholder() {
|
||||||
|
result_ = Image();
|
||||||
|
saveButton_.set_sensitive(false);
|
||||||
|
placeholder_.set_text(missingText());
|
||||||
|
previewStack_.set_visible_child("placeholder");
|
||||||
|
status_.set_text("");
|
||||||
|
}
|
||||||
|
|
||||||
void EffectorWindow::requestRender() {
|
void EffectorWindow::requestRender() {
|
||||||
if (suspendRender_ || sources_->empty()) return;
|
if (suspendRender_) return;
|
||||||
|
if (!canRender()) {
|
||||||
|
showPlaceholder();
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Spin buttons fire on every step while they are held down; coalesce those
|
// Spin buttons fire on every step while they are held down; coalesce those
|
||||||
// into one render instead of queueing a job per tick.
|
// into one render instead of queueing a job per tick.
|
||||||
debounce_.disconnect();
|
debounce_.disconnect();
|
||||||
@@ -635,7 +830,7 @@ void EffectorWindow::requestRender() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void EffectorWindow::startRender() {
|
void EffectorWindow::startRender() {
|
||||||
if (sources_->empty()) return;
|
if (!canRender()) return;
|
||||||
if (rendering_) {
|
if (rendering_) {
|
||||||
staleResult_ = true; // a fresh render follows this one
|
staleResult_ = true; // a fresh render follows this one
|
||||||
return;
|
return;
|
||||||
@@ -643,22 +838,21 @@ void EffectorWindow::startRender() {
|
|||||||
rendering_ = true;
|
rendering_ = true;
|
||||||
|
|
||||||
const auto sources = sources_;
|
const auto sources = sources_;
|
||||||
const auto effect = effect_;
|
const auto stages = stages_; // a snapshot, like the sources
|
||||||
const int width = static_cast<int>(widthSpin_.get_value());
|
const int width = static_cast<int>(widthSpin_.get_value());
|
||||||
const int height = static_cast<int>(heightSpin_.get_value());
|
const int height = static_cast<int>(heightSpin_.get_value());
|
||||||
const std::uint64_t seed = seed_;
|
const std::uint64_t seed = seed_;
|
||||||
const ParamValues values = paramValues();
|
|
||||||
|
|
||||||
spinner_.start();
|
spinner_.start();
|
||||||
recreateButton_.set_sensitive(false);
|
recreateButton_.set_sensitive(false);
|
||||||
|
|
||||||
worker_ = std::thread([this, sources, effect, width, height, seed, values] {
|
worker_ = std::thread([this, sources, stages, width, height, seed] {
|
||||||
Image image;
|
Image image;
|
||||||
std::string error;
|
std::string error;
|
||||||
const auto start = std::chrono::steady_clock::now();
|
const auto start = std::chrono::steady_clock::now();
|
||||||
try {
|
try {
|
||||||
Rng rng(seed);
|
Rng rng(seed);
|
||||||
image = effect->render(*sources, width, height, rng, values);
|
image = renderChain(stages, *sources, width, height, rng);
|
||||||
} catch (const std::exception& exception) {
|
} catch (const std::exception& exception) {
|
||||||
error = exception.what();
|
error = exception.what();
|
||||||
}
|
}
|
||||||
@@ -714,12 +908,190 @@ void EffectorWindow::onRenderFinished() {
|
|||||||
std::ostringstream status;
|
std::ostringstream status;
|
||||||
status.setf(std::ios::fixed);
|
status.setf(std::ios::fixed);
|
||||||
status.precision(1);
|
status.precision(1);
|
||||||
status << effect_->title() << " · " << result_.width() << "×" << result_.height() << " · "
|
status << chainTitle(stages_) << " · " << result_.width() << "×" << result_.height() << " · ";
|
||||||
<< sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · seed "
|
if (!sources_->empty()) {
|
||||||
<< seed_ << " · " << millis << " ms";
|
status << sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · ";
|
||||||
|
}
|
||||||
|
status << "seed " << seed_ << " · " << millis << " ms";
|
||||||
status_.set_text(status.str());
|
status_.set_text(status.str());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- project ---
|
||||||
|
|
||||||
|
// The project menu lives in the header bar, next to the buttons that act on
|
||||||
|
// the current render.
|
||||||
|
Gtk::Widget& EffectorWindow::buildMainMenu() {
|
||||||
|
add_action("new-project", sigc::mem_fun(*this, &EffectorWindow::onNewProject));
|
||||||
|
add_action("open-project", sigc::mem_fun(*this, &EffectorWindow::onOpenProject));
|
||||||
|
add_action("save-project", sigc::mem_fun(*this, &EffectorWindow::onSaveProject));
|
||||||
|
add_action("save-project-as", sigc::mem_fun(*this, &EffectorWindow::onSaveProjectAs));
|
||||||
|
|
||||||
|
auto menu = Gio::Menu::create();
|
||||||
|
auto open = Gio::Menu::create();
|
||||||
|
open->append("New Project", "win.new-project");
|
||||||
|
open->append("Open Project…", "win.open-project");
|
||||||
|
menu->append_section(open);
|
||||||
|
auto save = Gio::Menu::create();
|
||||||
|
save->append("Save Project", "win.save-project");
|
||||||
|
save->append("Save Project As…", "win.save-project-as");
|
||||||
|
menu->append_section(save);
|
||||||
|
|
||||||
|
// The shortcuts hang off the window rather than the application, which the
|
||||||
|
// window does not belong to yet while it is being built.
|
||||||
|
auto shortcuts = Gtk::ShortcutController::create();
|
||||||
|
shortcuts->set_scope(Gtk::ShortcutScope::GLOBAL);
|
||||||
|
const struct Accel {
|
||||||
|
const char* keys;
|
||||||
|
const char* action;
|
||||||
|
} accels[] = {{"<Control>n", "win.new-project"},
|
||||||
|
{"<Control>o", "win.open-project"},
|
||||||
|
{"<Control>s", "win.save-project"},
|
||||||
|
{"<Control><Shift>s", "win.save-project-as"}};
|
||||||
|
for (const Accel& accel : accels) {
|
||||||
|
shortcuts->add_shortcut(Gtk::Shortcut::create(
|
||||||
|
Gtk::ShortcutTrigger::parse_string(accel.keys), Gtk::NamedAction::create(accel.action)));
|
||||||
|
}
|
||||||
|
add_controller(shortcuts);
|
||||||
|
|
||||||
|
return *Gtk::make_managed<Gtk::PopoverMenu>(menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
Project EffectorWindow::currentProject() const {
|
||||||
|
Project project;
|
||||||
|
for (const auto& image : *sources_) project.sources.push_back(image.path());
|
||||||
|
project.stages = stages_;
|
||||||
|
project.width = static_cast<int>(widthSpin_.get_value());
|
||||||
|
project.height = static_cast<int>(heightSpin_.get_value());
|
||||||
|
project.seed = seed_;
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
void EffectorWindow::applyProject(const Project& project, const std::string& path) {
|
||||||
|
// One render at the end rather than one per piece of the project.
|
||||||
|
suspendRender_ = true;
|
||||||
|
clearSources();
|
||||||
|
|
||||||
|
stages_ = project.stages;
|
||||||
|
selectedStage_ = 0;
|
||||||
|
rebuildChainRows();
|
||||||
|
buildParamWidgets();
|
||||||
|
|
||||||
|
widthSpin_.set_value(project.width);
|
||||||
|
heightSpin_.set_value(project.height);
|
||||||
|
seed_ = project.seed.value_or(Rng::randomSeed());
|
||||||
|
seedEntry_.set_text(std::to_string(seed_));
|
||||||
|
|
||||||
|
projectPath_ = path;
|
||||||
|
updateTitle();
|
||||||
|
clearError();
|
||||||
|
suspendRender_ = false;
|
||||||
|
|
||||||
|
if (project.sources.empty()) {
|
||||||
|
requestRender(); // a chain that needs no sources still has a picture
|
||||||
|
} else {
|
||||||
|
addPaths(project.sources); // renders once the images are decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EffectorWindow::clearSources() {
|
||||||
|
while (auto* row = sourceList_.get_row_at_index(0)) sourceList_.remove(*row);
|
||||||
|
mutateSources([](SourceList& list) { list.clear(); });
|
||||||
|
loadQueue_.clear();
|
||||||
|
if (loading_) dropLoadedImages_ = true;
|
||||||
|
|
||||||
|
result_ = Image();
|
||||||
|
saveButton_.set_sensitive(false);
|
||||||
|
previewStack_.set_visible_child("placeholder");
|
||||||
|
}
|
||||||
|
|
||||||
|
void EffectorWindow::onNewProject() {
|
||||||
|
Project fresh;
|
||||||
|
fresh.stages.push_back({effects_.front(), effects_.front()->defaults()});
|
||||||
|
applyProject(fresh, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
void EffectorWindow::onOpenProject() {
|
||||||
|
auto dialog = Gtk::FileDialog::create();
|
||||||
|
dialog->set_title("Open project");
|
||||||
|
dialog->set_filters(projectFilters());
|
||||||
|
dialog->open(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
|
||||||
|
onProjectChosen(result, dialog);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void EffectorWindow::onProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
|
||||||
|
const Glib::RefPtr<Gtk::FileDialog>& dialog) {
|
||||||
|
std::string path;
|
||||||
|
try {
|
||||||
|
const auto file = dialog->open_finish(result);
|
||||||
|
if (!file) return;
|
||||||
|
path = file->get_path();
|
||||||
|
} catch (const Glib::Error&) {
|
||||||
|
return; // cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
applyProject(loadProject(path), path);
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
showError(std::string("Could not open the project: ") + error.what());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EffectorWindow::onSaveProject() {
|
||||||
|
if (projectPath_.empty()) {
|
||||||
|
onSaveProjectAs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeProject(projectPath_);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EffectorWindow::onSaveProjectAs() {
|
||||||
|
auto dialog = Gtk::FileDialog::create();
|
||||||
|
dialog->set_title("Save project");
|
||||||
|
dialog->set_filters(projectFilters());
|
||||||
|
dialog->set_initial_name(projectPath_.empty()
|
||||||
|
? "effector-" + chainSlug(stages_) + ".json"
|
||||||
|
: std::filesystem::path(projectPath_).filename().string());
|
||||||
|
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
|
||||||
|
onSaveProjectChosen(result, dialog);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void EffectorWindow::onSaveProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
|
||||||
|
const Glib::RefPtr<Gtk::FileDialog>& dialog) {
|
||||||
|
std::filesystem::path path;
|
||||||
|
try {
|
||||||
|
const auto file = dialog->save_finish(result);
|
||||||
|
if (!file) return;
|
||||||
|
path = file->get_path();
|
||||||
|
} catch (const Glib::Error&) {
|
||||||
|
return; // cancelled
|
||||||
|
}
|
||||||
|
if (path.extension().empty()) path.replace_extension(".json");
|
||||||
|
writeProject(path.string());
|
||||||
|
}
|
||||||
|
|
||||||
|
void EffectorWindow::writeProject(const std::string& path) {
|
||||||
|
try {
|
||||||
|
saveProject(path, currentProject());
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
showError(std::string("Could not save the project: ") + error.what());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearError();
|
||||||
|
projectPath_ = path;
|
||||||
|
updateTitle();
|
||||||
|
status_.set_text("Saved " + path);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EffectorWindow::updateTitle() {
|
||||||
|
if (projectPath_.empty()) {
|
||||||
|
set_title("Effector");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set_title(std::filesystem::path(projectPath_).stem().string() + " — Effector");
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------- saving ---
|
// --------------------------------------------------------------- saving ---
|
||||||
|
|
||||||
void EffectorWindow::onSave() {
|
void EffectorWindow::onSave() {
|
||||||
@@ -727,7 +1099,7 @@ void EffectorWindow::onSave() {
|
|||||||
|
|
||||||
auto dialog = Gtk::FileDialog::create();
|
auto dialog = Gtk::FileDialog::create();
|
||||||
dialog->set_title("Save image");
|
dialog->set_title("Save image");
|
||||||
dialog->set_initial_name("effector-" + effect_->name() + "-" + std::to_string(seed_) + ".png");
|
dialog->set_initial_name("effector-" + chainSlug(stages_) + "-" + std::to_string(seed_) + ".png");
|
||||||
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
|
dialog->save(*this, [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& result) {
|
||||||
onSaveChosen(result, dialog);
|
onSaveChosen(result, dialog);
|
||||||
});
|
});
|
||||||
|
|||||||
+40
-3
@@ -12,8 +12,10 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "cli/options.hpp"
|
#include "cli/options.hpp"
|
||||||
|
#include "core/chain.hpp"
|
||||||
#include "core/effect.hpp"
|
#include "core/effect.hpp"
|
||||||
#include "core/image.hpp"
|
#include "core/image.hpp"
|
||||||
|
#include "core/project.hpp"
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
|
|
||||||
@@ -28,8 +30,12 @@ private:
|
|||||||
Gtk::Widget& buildPreview();
|
Gtk::Widget& buildPreview();
|
||||||
void buildParamWidgets();
|
void buildParamWidgets();
|
||||||
ParamValues paramValues() const;
|
ParamValues paramValues() const;
|
||||||
|
void storeParams(); // widgets -> the selected stage's values
|
||||||
void applyInitial(const Options& initial);
|
void applyInitial(const Options& initial);
|
||||||
|
|
||||||
|
bool canRender() const; // a chain, and sources if the chain needs them
|
||||||
|
std::string missingText() const; // what is still missing before a render
|
||||||
|
void showPlaceholder(); // drops the preview and says what is missing
|
||||||
void requestRender(); // coalesced: restarts the short debounce timer
|
void requestRender(); // coalesced: restarts the short debounce timer
|
||||||
void startRender(); // starts a worker, or marks the result stale
|
void startRender(); // starts a worker, or marks the result stale
|
||||||
void onAddImages();
|
void onAddImages();
|
||||||
@@ -44,7 +50,29 @@ private:
|
|||||||
void appendSourceRow(const std::string& path);
|
void appendSourceRow(const std::string& path);
|
||||||
void removeSource(Gtk::ListBoxRow* row);
|
void removeSource(Gtk::ListBoxRow* row);
|
||||||
|
|
||||||
void onEffectChanged();
|
Gtk::Widget& buildChainSection();
|
||||||
|
Gtk::Widget& buildAddMenu(); // the menu of effects that can be appended
|
||||||
|
void appendStage(const EffectPtr& effect);
|
||||||
|
void removeStage(int index);
|
||||||
|
void moveStage(int index, int delta);
|
||||||
|
void rebuildChainRows();
|
||||||
|
void selectStage(int index);
|
||||||
|
void onStageSelected();
|
||||||
|
Gtk::Widget& buildMainMenu(); // the header menu: new, open, save project
|
||||||
|
Project currentProject() const;
|
||||||
|
void applyProject(const Project& project, const std::string& path);
|
||||||
|
void clearSources();
|
||||||
|
void onNewProject();
|
||||||
|
void onOpenProject();
|
||||||
|
void onProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
|
||||||
|
const Glib::RefPtr<Gtk::FileDialog>& dialog);
|
||||||
|
void onSaveProject(); // falls back to Save As while the project is untitled
|
||||||
|
void onSaveProjectAs();
|
||||||
|
void onSaveProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
|
||||||
|
const Glib::RefPtr<Gtk::FileDialog>& dialog);
|
||||||
|
void writeProject(const std::string& path);
|
||||||
|
void updateTitle();
|
||||||
|
|
||||||
void onParamChanged();
|
void onParamChanged();
|
||||||
void onSeedEntered();
|
void onSeedEntered();
|
||||||
void onRecreate();
|
void onRecreate();
|
||||||
@@ -66,9 +94,14 @@ private:
|
|||||||
// Model
|
// Model
|
||||||
std::shared_ptr<const SourceList> sources_ = std::make_shared<SourceList>();
|
std::shared_ptr<const SourceList> sources_ = std::make_shared<SourceList>();
|
||||||
std::vector<EffectPtr> effects_;
|
std::vector<EffectPtr> effects_;
|
||||||
EffectPtr effect_;
|
// The cascade: stage 0 works on the sources, every later stage on the
|
||||||
|
// output of the one before it.
|
||||||
|
std::vector<ChainStage> stages_;
|
||||||
|
int selectedStage_ = 0;
|
||||||
|
bool suspendStageSelect_ = false; // guards the rebuild of the chain rows
|
||||||
Image result_;
|
Image result_;
|
||||||
std::uint64_t seed_ = 0;
|
std::uint64_t seed_ = 0;
|
||||||
|
std::string projectPath_; // empty until the project has been saved once
|
||||||
bool suspendRender_ = true;
|
bool suspendRender_ = true;
|
||||||
|
|
||||||
// Source loading. Decoding a batch of large photographs takes seconds, so
|
// Source loading. Decoding a batch of large photographs takes seconds, so
|
||||||
@@ -85,6 +118,7 @@ private:
|
|||||||
std::vector<LoadFailure> loadFailures_;
|
std::vector<LoadFailure> loadFailures_;
|
||||||
std::vector<std::string> loadQueue_; // chosen while another batch was loading
|
std::vector<std::string> loadQueue_; // chosen while another batch was loading
|
||||||
bool loading_ = false;
|
bool loading_ = false;
|
||||||
|
bool dropLoadedImages_ = false; // the batch in flight belongs to a replaced project
|
||||||
sigc::connection loadWindowDelay_;
|
sigc::connection loadWindowDelay_;
|
||||||
|
|
||||||
// Render job
|
// Render job
|
||||||
@@ -109,8 +143,11 @@ private:
|
|||||||
Gtk::SpinButton widthSpin_;
|
Gtk::SpinButton widthSpin_;
|
||||||
Gtk::SpinButton heightSpin_;
|
Gtk::SpinButton heightSpin_;
|
||||||
Gtk::Grid paramGrid_;
|
Gtk::Grid paramGrid_;
|
||||||
|
Gtk::Label paramHeading_;
|
||||||
Gtk::Entry seedEntry_;
|
Gtk::Entry seedEntry_;
|
||||||
Gtk::DropDown effectDropdown_;
|
Gtk::ListBox chainList_;
|
||||||
|
Gtk::MenuButton addEffectButton_;
|
||||||
|
Gtk::MenuButton menuButton_;
|
||||||
Gtk::Button recreateButton_{"Recreate"};
|
Gtk::Button recreateButton_{"Recreate"};
|
||||||
Gtk::Button saveButton_{"Save As…"};
|
Gtk::Button saveButton_{"Save As…"};
|
||||||
Gtk::Picture picture_;
|
Gtk::Picture picture_;
|
||||||
|
|||||||
+6
-7
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,17 @@
|
|||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/chain.hpp"
|
||||||
|
#include "core/project.hpp"
|
||||||
#include "core/registry.hpp"
|
#include "core/registry.hpp"
|
||||||
#include "core/rng.hpp"
|
#include "core/rng.hpp"
|
||||||
|
#include "effects/blur.hpp"
|
||||||
#include "effects/scanlines.hpp"
|
#include "effects/scanlines.hpp"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -44,6 +48,29 @@ bool sameBytes(const effector::Image& a, const effector::Image& b) {
|
|||||||
return std::equal(a.data(), a.data() + a.byteSize(), b.data());
|
return std::equal(a.data(), a.data() + a.byteSize(), b.data());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alternating black and white columns: any blur along x has to pull these
|
||||||
|
// towards mid grey.
|
||||||
|
effector::Image stripes(int width, int height) {
|
||||||
|
effector::Image image(width, height);
|
||||||
|
for (int y = 0; y < height; ++y) {
|
||||||
|
auto* row = image.row(y);
|
||||||
|
for (int x = 0; x < width; ++x) {
|
||||||
|
const std::uint8_t value = (x % 2 == 0) ? 0 : 255;
|
||||||
|
row[x * 3 + 0] = row[x * 3 + 1] = row[x * 3 + 2] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
effector::Image renderBlur(const std::vector<effector::Image>& sources, int width, int height,
|
||||||
|
std::uint64_t seed, const effector::ParamValues& values) {
|
||||||
|
effector::BlurEffect effect;
|
||||||
|
effector::Rng rng(seed);
|
||||||
|
auto merged = effect.defaults();
|
||||||
|
for (const auto& [key, value] : values) merged[key] = value;
|
||||||
|
return effect.render(sources, width, height, rng, merged);
|
||||||
|
}
|
||||||
|
|
||||||
effector::Image render(const std::vector<effector::Image>& sources, int width, int height,
|
effector::Image render(const std::vector<effector::Image>& sources, int width, int height,
|
||||||
std::uint64_t seed, const effector::ParamValues& values) {
|
std::uint64_t seed, const effector::ParamValues& values) {
|
||||||
effector::ScanlinesEffect effect;
|
effector::ScanlinesEffect effect;
|
||||||
@@ -128,6 +155,196 @@ int main() {
|
|||||||
check(threw, "no sources throws");
|
check(threw, "no sources throws");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::cout << "blur\n";
|
||||||
|
{
|
||||||
|
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
|
||||||
|
|
||||||
|
const auto first = renderBlur(sources, 400, 300, 4, {{"radius", 10L}});
|
||||||
|
const auto again = renderBlur(sources, 400, 300, 4, {{"radius", 10L}});
|
||||||
|
const auto other = renderBlur(sources, 400, 300, 11, {{"radius", 10L}});
|
||||||
|
check(first.width() == 400 && first.height() == 300, "canvas has the requested size");
|
||||||
|
check(sameBytes(first, again), "same seed reproduces the image");
|
||||||
|
check(!sameBytes(first, other), "a different seed changes the image");
|
||||||
|
|
||||||
|
// A wide blur over one-pixel stripes has to land near mid grey, and only
|
||||||
|
// along the axis it is applied to.
|
||||||
|
std::vector<Image> striped = {stripes(200, 100)};
|
||||||
|
const auto flattened = renderBlur(striped, 200, 100, 1,
|
||||||
|
{{"radius", 8L}, {"direction", std::string("horizontal")}});
|
||||||
|
// The clamped edges ramp in over radius * passes pixels on each side.
|
||||||
|
bool grey = true;
|
||||||
|
for (int x = 32; x < 168; ++x) grey = grey && std::abs(flattened.row(50)[x * 3] - 127) <= 2;
|
||||||
|
check(grey, "a horizontal blur evens out vertical stripes");
|
||||||
|
|
||||||
|
const auto untouched = renderBlur(striped, 200, 100, 1,
|
||||||
|
{{"radius", 8L}, {"direction", std::string("vertical")}});
|
||||||
|
check(untouched.row(50)[0] == 0 && untouched.row(50)[3] == 255,
|
||||||
|
"a vertical blur leaves columns alone");
|
||||||
|
|
||||||
|
// Every column is uniform, so a vertical blur must not change anything.
|
||||||
|
const auto vertical = renderBlur(striped, 200, 100, 1,
|
||||||
|
{{"radius", 4L}, {"passes", 3L}, {"direction", std::string("vertical")}});
|
||||||
|
check(sameBytes(untouched, vertical), "repeated vertical passes stay stable");
|
||||||
|
|
||||||
|
// Radius larger than the canvas must clamp rather than read out of bounds.
|
||||||
|
const auto tiny = renderBlur(striped, 3, 3, 2, {{"radius", 200L}});
|
||||||
|
check(tiny.width() == 3 && tiny.height() == 3, "radius wider than the canvas is clamped");
|
||||||
|
|
||||||
|
const auto pinned = renderBlur(sources, 300, 200, 6, {{"first_source", true}});
|
||||||
|
const auto pinnedAgain = renderBlur(sources, 300, 200, 6, {{"first_source", true}});
|
||||||
|
check(sameBytes(pinned, pinnedAgain), "first_source is reproducible");
|
||||||
|
|
||||||
|
// Blur declares that it needs no sources, so with none it softens the
|
||||||
|
// empty canvas it starts from instead of refusing to run.
|
||||||
|
const auto blank = renderBlur({}, 100, 100, 1, {});
|
||||||
|
bool allBlack = blank.width() == 100 && blank.height() == 100;
|
||||||
|
for (std::size_t i = 0; i < blank.byteSize(); ++i) allBlack = allBlack && blank.data()[i] == 0;
|
||||||
|
check(allBlack, "without sources blur renders an empty canvas");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::cout << "chain\n";
|
||||||
|
{
|
||||||
|
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
|
||||||
|
|
||||||
|
ChainStage scanlines{effectByName("scanlines"), {{"strip_height", 6L}}};
|
||||||
|
ChainStage blur{effectByName("blur"), {{"radius", 6L}}};
|
||||||
|
|
||||||
|
Rng single(5);
|
||||||
|
const auto direct = renderChain({scanlines}, sources, 400, 300, single);
|
||||||
|
const auto alone = render(sources, 400, 300, 5, {{"strip_height", 6L}});
|
||||||
|
check(sameBytes(direct, alone), "a one stage chain matches the effect on its own");
|
||||||
|
|
||||||
|
Rng a(5);
|
||||||
|
Rng b(5);
|
||||||
|
const auto cascaded = renderChain({scanlines, blur}, sources, 400, 300, a);
|
||||||
|
const auto again = renderChain({scanlines, blur}, sources, 400, 300, b);
|
||||||
|
check(cascaded.width() == 400 && cascaded.height() == 300, "the chain keeps the canvas size");
|
||||||
|
check(sameBytes(cascaded, again), "the same seed reproduces the cascade");
|
||||||
|
check(!sameBytes(cascaded, direct), "a second stage changes the image");
|
||||||
|
|
||||||
|
// Order matters: blurring scanlines is not the same as scanning a blur.
|
||||||
|
Rng forward(5);
|
||||||
|
Rng backward(5);
|
||||||
|
const auto blurLast = renderChain({scanlines, blur}, sources, 400, 300, forward);
|
||||||
|
const auto blurFirst = renderChain({blur, scanlines}, sources, 400, 300, backward);
|
||||||
|
check(!sameBytes(blurLast, blurFirst), "the stage order changes the image");
|
||||||
|
|
||||||
|
check(chainTitle({scanlines, blur}) == "Scanlines \u2192 Blur", "the chain has a readable title");
|
||||||
|
check(chainSlug({scanlines, blur}) == "scanlines-blur", "the chain has a file name slug");
|
||||||
|
|
||||||
|
bool threw = false;
|
||||||
|
try {
|
||||||
|
Rng rng(1);
|
||||||
|
renderChain({}, sources, 100, 100, rng);
|
||||||
|
} catch (const EffectError&) {
|
||||||
|
threw = true;
|
||||||
|
}
|
||||||
|
check(threw, "an empty chain throws");
|
||||||
|
|
||||||
|
// Sources are an effect's own business: scanlines cuts strips out of
|
||||||
|
// them, blur only softens what it is handed and runs without any.
|
||||||
|
check(effectByName("scanlines")->needsSources(), "scanlines needs source images");
|
||||||
|
check(!effectByName("blur")->needsSources(), "blur needs no source images");
|
||||||
|
check(chainNeedsSources({scanlines, blur}), "a chain led by scanlines needs sources");
|
||||||
|
check(!chainNeedsSources({blur, scanlines}),
|
||||||
|
"a chain led by blur needs none, later stages see the stage before them");
|
||||||
|
check(!chainNeedsSources({}), "an empty chain needs no sources");
|
||||||
|
|
||||||
|
Rng bare(5);
|
||||||
|
const auto sourceless = renderChain({blur}, {}, 200, 150, bare);
|
||||||
|
check(sourceless.width() == 200 && sourceless.height() == 150,
|
||||||
|
"blur renders a canvas without sources");
|
||||||
|
|
||||||
|
bool scanlinesThrew = false;
|
||||||
|
try {
|
||||||
|
Rng rng(1);
|
||||||
|
renderChain({scanlines}, {}, 100, 100, rng);
|
||||||
|
} catch (const EffectError&) {
|
||||||
|
scanlinesThrew = true;
|
||||||
|
}
|
||||||
|
check(scanlinesThrew, "scanlines still refuses to run without sources");
|
||||||
|
}
|
||||||
|
|
||||||
|
std::cout << "project\n";
|
||||||
|
{
|
||||||
|
const auto dir = std::filesystem::temp_directory_path() / "effector_test_project";
|
||||||
|
std::filesystem::create_directories(dir);
|
||||||
|
const auto source = dir / "source.png";
|
||||||
|
gradient(64, 48, 7).save(source.string());
|
||||||
|
|
||||||
|
Project project;
|
||||||
|
project.sources = {source.string(), "/elsewhere/other.png"};
|
||||||
|
project.stages.push_back({effectByName("scanlines"), {{"strip_height", 9L}}});
|
||||||
|
project.stages.push_back(
|
||||||
|
{effectByName("blur"), {{"radius", 3L}, {"direction", std::string("vertical")}}});
|
||||||
|
project.width = 640;
|
||||||
|
project.height = 400;
|
||||||
|
project.seed = 1234567890123ULL;
|
||||||
|
|
||||||
|
const auto file = dir / "test.json";
|
||||||
|
saveProject(file.string(), project);
|
||||||
|
const auto reloaded = loadProject(file.string());
|
||||||
|
|
||||||
|
check(reloaded.width == 640 && reloaded.height == 400, "the canvas survives a round trip");
|
||||||
|
check(reloaded.seed && *reloaded.seed == 1234567890123ULL, "the seed survives a round trip");
|
||||||
|
check(reloaded.stages.size() == 2 && reloaded.stages[0].effect->name() == "scanlines" &&
|
||||||
|
reloaded.stages[1].effect->name() == "blur",
|
||||||
|
"the chain keeps its effects and their order");
|
||||||
|
check(paramInt(reloaded.stages[0].values, "strip_height", 0) == 9 &&
|
||||||
|
paramInt(reloaded.stages[1].values, "radius", 0) == 3 &&
|
||||||
|
paramChoice(reloaded.stages[1].values, "direction", "") == "vertical",
|
||||||
|
"the stage params survive a round trip");
|
||||||
|
check(paramInt(reloaded.stages[0].values, "jitter", -1) ==
|
||||||
|
paramInt(effectByName("scanlines")->defaults(), "jitter", -2),
|
||||||
|
"params the file leaves out keep their default");
|
||||||
|
|
||||||
|
// A source next to the project is written relative, so the folder can
|
||||||
|
// be moved; anything outside it stays absolute.
|
||||||
|
std::ifstream stream(file.string(), std::ios::binary);
|
||||||
|
const std::string text((std::istreambuf_iterator<char>(stream)),
|
||||||
|
std::istreambuf_iterator<char>());
|
||||||
|
check(text.find("\"source.png\"") != std::string::npos, "a nearby source is written relative");
|
||||||
|
check(text.find("\"/elsewhere/other.png\"") != std::string::npos,
|
||||||
|
"a source elsewhere stays absolute");
|
||||||
|
check(reloaded.sources.size() == 2 && reloaded.sources[0] == source.string(),
|
||||||
|
"a relative source is resolved against the project");
|
||||||
|
|
||||||
|
// The point of a project: the same picture comes back out of the file.
|
||||||
|
std::vector<Image> sources = {gradient(300, 200, 4)};
|
||||||
|
Rng before(*project.seed);
|
||||||
|
Rng after(*reloaded.seed);
|
||||||
|
const auto original = renderChain(project.stages, sources, project.width, project.height, before);
|
||||||
|
const auto restored =
|
||||||
|
renderChain(reloaded.stages, sources, reloaded.width, reloaded.height, after);
|
||||||
|
check(sameBytes(original, restored), "a reloaded project renders the same image");
|
||||||
|
|
||||||
|
// Params the effects do not declare are dropped rather than carried.
|
||||||
|
const auto stripped = projectFromJson(projectToJson(reloaded));
|
||||||
|
check(stripped.stages[0].values.find("radius") == stripped.stages[0].values.end(),
|
||||||
|
"a stage only keeps the params its effect declares");
|
||||||
|
|
||||||
|
int refusals = 0;
|
||||||
|
for (const char* bad : {"{\"chain\": [{\"effect\": \"nope\"}]}", "{\"chain\": []}",
|
||||||
|
"{\"chain\": [{\"effect\": \"blur\"},]}", "not json at all"}) {
|
||||||
|
try {
|
||||||
|
projectFromJson(bad);
|
||||||
|
} catch (const EffectError&) {
|
||||||
|
++refusals;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(refusals == 4, "a broken project is refused");
|
||||||
|
|
||||||
|
bool threw = false;
|
||||||
|
try {
|
||||||
|
loadProject((dir / "missing.json").string());
|
||||||
|
} catch (const EffectError&) {
|
||||||
|
threw = true;
|
||||||
|
}
|
||||||
|
check(threw, "a missing project file throws");
|
||||||
|
|
||||||
|
std::filesystem::remove_all(dir);
|
||||||
|
}
|
||||||
|
|
||||||
std::cout << "image io\n";
|
std::cout << "image io\n";
|
||||||
{
|
{
|
||||||
const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png";
|
const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png";
|
||||||
|
|||||||
Reference in New Issue
Block a user