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:
Si
2026-09-18 15:37:37 +02:00
co-authored by Claude Opus 5
parent 89985cb989
commit a82fba7886
7 changed files with 807 additions and 5 deletions
+443
View File
@@ -0,0 +1,443 @@
#include "core/project.hpp"
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <sstream>
#include "core/effect.hpp"
#include "core/registry.hpp"
namespace effector {
namespace {
// A project file is small and its shape is fixed, so it carries its own
// reader rather than a JSON dependency: enough of the grammar to read back
// what projectToJson writes, and to give a line number when a hand-edited
// file is wrong.
struct Json {
enum class Kind { Null, Bool, Number, String, Array, Object };
Kind kind = Kind::Null;
bool boolean = false;
double number = 0.0;
bool integral = true; // written without a fraction or exponent
std::string text;
std::vector<Json> items;
std::vector<std::pair<std::string, Json>> members;
const Json* member(const std::string& key) const {
for (const auto& entry : members) {
if (entry.first == key) return &entry.second;
}
return nullptr;
}
};
class JsonParser {
public:
explicit JsonParser(const std::string& text) : text_(text) {}
Json parse() {
skipSpace();
Json value = parseValue();
skipSpace();
if (pos_ != text_.size()) fail("trailing text after the end of the document");
return value;
}
private:
[[noreturn]] void fail(const std::string& what) const {
int line = 1;
for (std::size_t i = 0; i < pos_ && i < text_.size(); ++i) {
if (text_[i] == '\n') ++line;
}
throw EffectError("line " + std::to_string(line) + ": " + what);
}
char peek() const { return pos_ < text_.size() ? text_[pos_] : '\0'; }
void skipSpace() {
while (pos_ < text_.size()) {
const char c = text_[pos_];
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
++pos_;
} else {
break;
}
}
}
void expect(char c) {
if (peek() != c) fail(std::string("expected '") + c + "'");
++pos_;
}
bool literal(const char* word) {
const std::size_t length = std::char_traits<char>::length(word);
if (text_.compare(pos_, length, word) != 0) return false;
pos_ += length;
return true;
}
Json parseValue() {
switch (peek()) {
case '{': return parseObject();
case '[': return parseArray();
case '"': {
Json value;
value.kind = Json::Kind::String;
value.text = parseString();
return value;
}
default: break;
}
if (literal("true")) return boolean(true);
if (literal("false")) return boolean(false);
if (literal("null")) return Json();
return parseNumber();
}
static Json boolean(bool state) {
Json value;
value.kind = Json::Kind::Bool;
value.boolean = state;
return value;
}
Json parseObject() {
Json value;
value.kind = Json::Kind::Object;
expect('{');
skipSpace();
if (peek() == '}') {
++pos_;
return value;
}
while (true) {
skipSpace();
std::string key = parseString();
skipSpace();
expect(':');
skipSpace();
value.members.emplace_back(std::move(key), parseValue());
skipSpace();
if (peek() == ',') {
++pos_;
continue;
}
expect('}');
return value;
}
}
Json parseArray() {
Json value;
value.kind = Json::Kind::Array;
expect('[');
skipSpace();
if (peek() == ']') {
++pos_;
return value;
}
while (true) {
skipSpace();
value.items.push_back(parseValue());
skipSpace();
if (peek() == ',') {
++pos_;
continue;
}
expect(']');
return value;
}
}
std::string parseString() {
expect('"');
std::string out;
while (true) {
if (pos_ >= text_.size()) fail("unterminated string");
const char c = text_[pos_++];
if (c == '"') return out;
if (c != '\\') {
out.push_back(c);
continue;
}
if (pos_ >= text_.size()) fail("unterminated escape");
switch (text_[pos_++]) {
case '"': out.push_back('"'); break;
case '\\': out.push_back('\\'); break;
case '/': out.push_back('/'); break;
case 'b': out.push_back('\b'); break;
case 'f': out.push_back('\f'); break;
case 'n': out.push_back('\n'); break;
case 'r': out.push_back('\r'); break;
case 't': out.push_back('\t'); break;
case 'u': {
// Only the basic plane, which is all a path or a choice
// name can need; surrogate pairs are passed through as is.
if (pos_ + 4 > text_.size()) fail("short \\u escape");
const unsigned code = static_cast<unsigned>(
std::strtoul(text_.substr(pos_, 4).c_str(), nullptr, 16));
pos_ += 4;
appendUtf8(out, code);
break;
}
default: fail("unknown escape");
}
}
}
static void appendUtf8(std::string& out, unsigned code) {
if (code < 0x80) {
out.push_back(static_cast<char>(code));
} else if (code < 0x800) {
out.push_back(static_cast<char>(0xC0 | (code >> 6)));
out.push_back(static_cast<char>(0x80 | (code & 0x3F)));
} else {
out.push_back(static_cast<char>(0xE0 | (code >> 12)));
out.push_back(static_cast<char>(0x80 | ((code >> 6) & 0x3F)));
out.push_back(static_cast<char>(0x80 | (code & 0x3F)));
}
}
Json parseNumber() {
const std::size_t start = pos_;
if (peek() == '-' || peek() == '+') ++pos_;
bool integral = true;
while (pos_ < text_.size()) {
const char c = text_[pos_];
if (c >= '0' && c <= '9') {
++pos_;
} else if (c == '.' || c == 'e' || c == 'E') {
integral = false;
++pos_;
} else if (c == '-' || c == '+') { // an exponent's sign
++pos_;
} else {
break;
}
}
if (pos_ == start) fail("expected a value");
Json value;
value.kind = Json::Kind::Number;
value.integral = integral;
try {
value.number = std::stod(text_.substr(start, pos_ - start));
} catch (const std::exception&) {
fail("'" + text_.substr(start, pos_ - start) + "' is not a number");
}
return value;
}
const std::string& text_;
std::size_t pos_ = 0;
};
std::string quote(const std::string& text) {
std::string out = "\"";
for (const char c : text) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (static_cast<unsigned char>(c) < 0x20) {
char buffer[7];
std::snprintf(buffer, sizeof(buffer), "\\u%04x",
static_cast<unsigned>(static_cast<unsigned char>(c)));
out += buffer;
} else {
out.push_back(c);
}
}
}
return out + "\"";
}
std::string number(double value, int digits) {
std::ostringstream out;
out.setf(std::ios::fixed);
out.precision(digits);
out << value;
std::string text = out.str();
// 5.00 reads better as 5.0 and 0.250 as 0.25.
while (text.size() > 1 && text.back() == '0' && text[text.size() - 2] != '.') text.pop_back();
return text;
}
// Relative when the image lives under the project folder, absolute otherwise:
// a folder holding the project and its images stays movable.
std::string relativeTo(const std::string& path, const std::string& directory) {
if (directory.empty()) return path;
std::error_code error;
const auto relative = std::filesystem::relative(path, directory, error);
if (error || relative.empty()) return path;
const auto text = relative.generic_string();
return text.rfind("..", 0) == 0 ? path : text;
}
std::string resolve(const std::string& path, const std::string& directory) {
std::filesystem::path file(path);
if (file.is_absolute() || directory.empty()) return path;
return (std::filesystem::path(directory) / file).lexically_normal().string();
}
} // namespace
std::string projectToJson(const Project& project, const std::string& directory) {
std::ostringstream out;
out << "{\n";
out << " \"effector_project\": 1,\n";
out << " \"canvas\": {\"width\": " << project.width << ", \"height\": " << project.height
<< "},\n";
if (project.seed) out << " \"seed\": " << *project.seed << ",\n";
out << " \"sources\": [";
for (std::size_t i = 0; i < project.sources.size(); ++i) {
out << (i == 0 ? "\n" : ",\n") << " " << quote(relativeTo(project.sources[i], directory));
}
out << (project.sources.empty() ? "" : "\n ") << "],\n";
out << " \"chain\": [";
for (std::size_t i = 0; i < project.stages.size(); ++i) {
const auto& stage = project.stages[i];
if (!stage.effect) continue;
out << (i == 0 ? "\n" : ",\n");
out << " {\n \"effect\": " << quote(stage.effect->name()) << ",\n";
out << " \"params\": {";
const auto& specs = stage.effect->params();
bool first = true;
for (const auto& spec : specs) {
out << (first ? "\n" : ",\n") << " " << quote(spec.key) << ": ";
first = false;
switch (spec.type) {
case ParamSpec::Type::Bool:
out << (paramBool(stage.values, spec.key, spec.defaultValue != 0.0) ? "true"
: "false");
break;
case ParamSpec::Type::Int:
out << paramInt(stage.values, spec.key, static_cast<long>(spec.defaultValue));
break;
case ParamSpec::Type::Double:
out << number(paramDouble(stage.values, spec.key, spec.defaultValue),
spec.digits);
break;
case ParamSpec::Type::Choice:
out << quote(paramChoice(stage.values, spec.key, spec.defaultChoice));
break;
}
}
out << (specs.empty() ? "}" : "\n }") << "\n }";
}
out << (project.stages.empty() ? "" : "\n ") << "]\n";
out << "}\n";
return out.str();
}
Project projectFromJson(const std::string& text, const std::string& directory) {
JsonParser parser(text);
const Json root = parser.parse();
if (root.kind != Json::Kind::Object) throw EffectError("a project must be a JSON object");
Project project;
if (const auto* canvas = root.member("canvas")) {
if (const auto* width = canvas->member("width")) project.width = static_cast<int>(width->number);
if (const auto* height = canvas->member("height")) {
project.height = static_cast<int>(height->number);
}
}
if (project.width <= 0 || project.height <= 0) throw EffectError("the canvas size is not positive");
if (const auto* seed = root.member("seed")) {
if (seed->kind == Json::Kind::Number) {
project.seed = static_cast<std::uint64_t>(seed->number);
}
}
if (const auto* sources = root.member("sources")) {
for (const auto& item : sources->items) {
if (item.kind == Json::Kind::String) {
project.sources.push_back(resolve(item.text, directory));
}
}
}
const auto* chain = root.member("chain");
if (chain == nullptr || chain->kind != Json::Kind::Array || chain->items.empty()) {
throw EffectError("the project has no effect chain");
}
for (const auto& item : chain->items) {
const auto* name = item.member("effect");
if (name == nullptr || name->kind != Json::Kind::String) {
throw EffectError("a chain stage has no effect name");
}
ChainStage stage;
stage.effect = effectByName(name->text); // throws on an unknown effect
stage.values = stage.effect->defaults();
// Driven by the specs, not by the file: a key the effect does not
// declare is dropped, and an old project simply keeps the defaults for
// params that did not exist when it was written.
if (const auto* params = item.member("params")) {
for (const auto& spec : stage.effect->params()) {
const auto* value = params->member(spec.key);
if (value == nullptr) continue;
switch (spec.type) {
case ParamSpec::Type::Bool:
if (value->kind == Json::Kind::Bool) stage.values[spec.key] = value->boolean;
break;
case ParamSpec::Type::Int:
if (value->kind == Json::Kind::Number) {
stage.values[spec.key] = static_cast<long>(std::llround(value->number));
}
break;
case ParamSpec::Type::Double:
if (value->kind == Json::Kind::Number) stage.values[spec.key] = value->number;
break;
case ParamSpec::Type::Choice:
for (const auto& choice : spec.choices) {
if (value->kind == Json::Kind::String && value->text == choice) {
stage.values[spec.key] = choice;
}
}
break;
}
}
}
project.stages.push_back(std::move(stage));
}
return project;
}
void saveProject(const std::string& path, const Project& project) {
const auto directory = std::filesystem::path(path).parent_path().string();
std::ofstream file(path, std::ios::binary | std::ios::trunc);
if (!file) throw EffectError("cannot write " + path);
file << projectToJson(project, directory);
if (!file) throw EffectError("could not write " + path);
}
Project loadProject(const std::string& path) {
std::ifstream file(path, std::ios::binary);
if (!file) throw EffectError("cannot read " + path);
std::ostringstream buffer;
buffer << file.rdbuf();
try {
return projectFromJson(buffer.str(), std::filesystem::path(path).parent_path().string());
} catch (const EffectError& error) {
throw EffectError(std::filesystem::path(path).filename().string() + ": " + error.what());
}
}
} // namespace effector
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include "core/chain.hpp"
#include "core/params.hpp"
namespace effector {
// Everything needed to recreate a render: which images went in, the cascade
// with its settings, the canvas and the seed. Deliberately no pixels — a
// project is a small text file that points at the sources on disk.
struct Project {
std::vector<std::string> sources; // absolute paths
std::vector<ChainStage> stages;
int width = 1920;
int height = 1080;
std::optional<std::uint64_t> seed;
};
// JSON, written for humans: two-space indent, one source path per line, and
// the params of a stage spelled with the same keys the effect declares.
// Source paths are written relative to `directory` when they live under it, so
// a project folder can be moved or shared whole.
std::string projectToJson(const Project& project, const std::string& directory = {});
// Throws EffectError on malformed JSON, an unknown effect or a missing chain.
// Relative source paths are resolved against `directory`; params the effect
// does not declare are dropped, and missing ones keep the effect's default.
Project projectFromJson(const std::string& text, const std::string& directory = {});
// Both throw EffectError: the file is the user's, so failures are reported
// rather than silently swallowed.
void saveProject(const std::string& path, const Project& project);
Project loadProject(const std::string& path);
} // namespace effector