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:
@@ -4,12 +4,14 @@
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/chain.hpp"
|
||||
#include "core/project.hpp"
|
||||
#include "core/registry.hpp"
|
||||
#include "core/rng.hpp"
|
||||
#include "effects/blur.hpp"
|
||||
@@ -241,6 +243,86 @@ int main() {
|
||||
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";
|
||||
{
|
||||
const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png";
|
||||
|
||||
Reference in New Issue
Block a user