Save and open projects as JSON
A project is the whole setup as a small text file: the source paths, the effect chain with its params, the canvas and the seed. Reading and writing are driven by the effects' ParamSpecs rather than by whatever sits in a stage's values, so a new effect is saveable without touching this code, a key an effect does not declare is never written or read back, and an old project keeps the defaults for params that did not exist when it was saved. Paths under the project's own folder are written relative, so a folder of images and their project can be moved whole. src/core/project.cpp carries its own small JSON reader instead of a dependency, and names the line when a hand-edited file is wrong. The interface gets a header-bar menu: New, Open, Save, Save As, with Ctrl+N/O/S. The title shows the project name. Opening a project replaces the sources, discarding a decode still in flight rather than mixing it into the new project. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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`, `blur`, `chain`, `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):
|
||||||
|
|
||||||
@@ -69,6 +69,22 @@ CLI builds it from repeated `-e`: `-e scanlines --strip-height 6 -e blur
|
|||||||
(`src/cli/options.cpp`) hands each flag to the stage whose effect declares it —
|
(`src/cli/options.cpp`) hands each flag to the stage whose effect declares it —
|
||||||
two effects must not declare the same param key.
|
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
|
||||||
|
|
||||||
`effector_core` (static) holds image IO, params, effects, rng and links only
|
`effector_core` (static) holds image IO, params, effects, rng and links only
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ add_library(effector_core STATIC
|
|||||||
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/blur.cpp
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
|
||||||
#include "core/chain.hpp"
|
#include "core/chain.hpp"
|
||||||
|
#include "core/project.hpp"
|
||||||
#include "core/registry.hpp"
|
#include "core/registry.hpp"
|
||||||
#include "core/rng.hpp"
|
#include "core/rng.hpp"
|
||||||
|
|
||||||
@@ -16,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,
|
||||||
@@ -64,6 +74,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");
|
||||||
@@ -418,10 +433,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));
|
||||||
@@ -875,6 +899,183 @@ void EffectorWindow::onRenderFinished() {
|
|||||||
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()) {
|
||||||
|
placeholder_.set_text("Add source images to start.");
|
||||||
|
status_.set_text("");
|
||||||
|
} 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() {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
#include "core/chain.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 {
|
||||||
|
|
||||||
@@ -54,6 +55,21 @@ private:
|
|||||||
void rebuildChainRows();
|
void rebuildChainRows();
|
||||||
void selectStage(int index);
|
void selectStage(int index);
|
||||||
void onStageSelected();
|
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();
|
||||||
@@ -82,6 +98,7 @@ private:
|
|||||||
bool suspendStageSelect_ = false; // guards the rebuild of the chain rows
|
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
|
||||||
@@ -98,6 +115,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
|
||||||
@@ -126,6 +144,7 @@ private:
|
|||||||
Gtk::Entry seedEntry_;
|
Gtk::Entry seedEntry_;
|
||||||
Gtk::ListBox chainList_;
|
Gtk::ListBox chainList_;
|
||||||
Gtk::MenuButton addEffectButton_;
|
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_;
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
#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/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/blur.hpp"
|
||||||
@@ -241,6 +243,86 @@ int main() {
|
|||||||
check(threw, "an empty chain throws");
|
check(threw, "an empty chain throws");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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