4 Commits
Author SHA1 Message Date
Si 8113a06da7 Merge branch 'empty-chain-start' 2026-09-19 22:03:31 +02:00
SiandClaude Opus 5 3aade6f361 Let several text stages share one named look
A picture with a title, a caption and a credit meant typing the same
font, size and colour into every text stage, and changing the look meant
typing them all again. A project now holds named styles: a stage follows
one, and editing it moves every stage that follows it at once.

The front-ends learn nothing effect-specific. A ParamSpec can be marked
.styled(), saying a style carries it rather than the stage, and
Effect::styleKind() names the pool it comes from -- so styleSpecs(kind)
is to a style what params() is to a stage, and a second text-ish effect
would slot into the styles that already exist. The look is what a style
carries; the words and the placement stay on the stage, which is what
lets two stages share a typography in two different corners.

stageValues() resolves one in a single place, and a stage keeps a full
set of values of its own beside the style, so switching it to Custom,
dropping the style, or opening the project in a build that does not know
it renders exactly the same picture. A style moves stages together; it
never holds the only copy of anything.

The command line builds no styles and passes none, so it renders what it
always did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:55:55 +02:00
SiandClaude Opus 5 eb417bb41a Let a chain stage be switched off without taking it apart
Trying a stage out meant removing it and building it again from scratch,
settings and all, to see what the picture looked like without it. A stage now
carries an `enabled` flag and can simply be switched off: it keeps its place
in the chain and everything it was set to.

`enabledStages` is the one place that reads the flag, and renderChain,
chainNeedsSources, chainTitle and chainSlug all go through it. A disabled
stage is skipped whole — handed no image and drawing nothing from the shared
rng — so switching one off renders byte for byte what the chain rendered
before that stage was added, which is the only thing that makes the switch
worth anything while comparing. Whichever stage runs first is the one handed
the sources, so disabling a leading scanlines lets blur lead and the chain
needs no images at all. The status line and the generated file name follow
too: they name what was rendered, not what sits in the sidebar. A chain with
every stage disabled throws rather than quietly rendering nothing.

In the interface it is a check on the chain row: the row dims, the params
heading says "(off)" and the settings stay editable — a stage being worked on
is not the same as one being deleted. On the command line it is
`--disable NAME`, which picks its stage by effect name exactly as the params
do. A project writes `"enabled": false` only for a stage that is off, so
existing project files are unchanged and an ordinary one stays uncluttered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 21:11:17 +02:00
SiandClaude Opus 5 f61161aef0 Open on an empty chain, and show the sources only when they are used
The window used to hand the user a Scanlines stage before they had asked for
anything, which also meant the first thing on screen was "Add source images
to start." — an instruction about an effect nobody chose. It now opens on "No
effects yet" and waits: the chain is the subject of the interface, so the
first stage is the user's to pick.

The default effect was injected by the option parser, which would have put
Scanlines straight back on every launch. It moves to renderHeadless, where a
chain really is mandatory — a headless run must render something, while the
interface has no such need. `-o out.png` with no -e is unchanged.

The sources section follows the same question the render gate already asks,
chainNeedsSources(): only the first stage is ever handed the sources, so a
chain led by blur or text makes the whole section beside the point and it is
hidden rather than left there to be filled in vain. Today that shows it for
scanlines and tiles, but no effect is named in the GUI — a new one declaring
needsSources() gets this for free. rebuildChainRows() is the one funnel every
chain change goes through, so that is where the visibility is set.

New in the project menu now starts empty too, matching what the window opens
with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:59:56 +02:00
20 changed files with 1098 additions and 164 deletions
+42 -5
View File
@@ -53,6 +53,17 @@ travel as `ParamValues` (a
`map<string, variant<long,double,bool,string>>`), read back with
`paramInt`/`paramDouble`/`paramBool`/`paramChoice`.
A param can also be marked `.styled()`, which says a *named style* carries it
rather than the stage it sits on: the font and the colour of a text stage, but
never its words or its placement. The pool a style comes from is named by
`Effect::styleKind()` (empty by default — the effect takes no style; `"text"`
for the text effect), and effects answering the same kind share one pool, so a
second text-ish effect would slot into the styles that already exist. The
front-ends know nothing more than that: the interface builds the style row and
the project file writes a style's params from `styleSpecs(kind)`
(`src/core/style.cpp`), which is to a style exactly what `params()` is to a
stage.
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`)
@@ -73,10 +84,32 @@ 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.
A stage carries a `ChainStage::enabled` flag, and `enabledStages`
(`src/core/chain.cpp`) is the one place that reads it: a disabled stage is
skipped whole — handed no image and drawing nothing from the shared rng — so
switching one off renders exactly what the chain rendered before it was added.
`renderChain`, `chainNeedsSources`, `chainTitle` and `chainSlug` all go through
it, which is why the status line and the file name never mention a stage that
did not run. A chain with every stage disabled throws rather than rendering
nothing.
### A style is a look several stages share
`ChainStage::style` names a style, and `stageValues` (`src/core/chain.cpp`) is
the one place that resolves one: an effect's defaults, then the stage's own
values, then the style's values for the params it carries. A stage always keeps
a full set of values of its own beside the style, so switching it to "Custom",
dropping the style, or opening the project in a build that does not know the
style renders exactly the same picture — a style moves stages together, it
never holds the only copy of anything. The interface writes an edited param to
both the style and the stage for that reason. `renderChain` takes the pool as a
trailing argument; the command line builds no styles and passes none.
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`
menu, reorder, remove, switch off with the check on the row, 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`, with `--disable NAME`
leaving a stage in place but skipping it. 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.
@@ -94,11 +127,15 @@ pixels, so a project saved as A4 reopens as A4.
### 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
named styles, the chain with its params (and its `"enabled": false` and
`"style"`, each written only when there is something to say), 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.
project keeps the defaults for params that did not exist when it was saved. The
same leniency covers styles: one of a kind this build does not know, one with
no name or a repeated one, and a stage naming a style the file does not hold
are each dropped on their own rather than refusing the project.
Source paths under the project's own folder are written relative, so a folder
can be moved or shared whole.
+1
View File
@@ -31,6 +31,7 @@ add_library(effector_core STATIC
src/core/project.cpp
src/core/registry.cpp
src/core/rng.cpp
src/core/style.cpp
src/effects/blur.cpp
src/effects/grain.cpp
src/effects/monochrome.cpp
+10 -2
View File
@@ -1,5 +1,6 @@
#include "cli/options.hpp"
#include <algorithm>
#include <charconv>
#include <cstdint>
#include <cstring>
@@ -89,6 +90,10 @@ Options parseOptions(int argc, char** argv) {
std::to_string(kMinDpi) + "-" + std::to_string(kMaxDpi));
}
options.dpi = static_cast<int>(dpi);
} else if (arg == "--disable") {
const std::string name = requireValue(argc, argv, i, arg);
effectByName(name); // validate now, exactly as -e does
options.disabled.push_back(name);
} else if (arg == "--seed") {
const std::string value = requireValue(argc, argv, i, arg);
long seed = 0;
@@ -160,7 +165,6 @@ Options parseOptions(int argc, char** argv) {
if (const auto* format = canvasFormatByKey(options.format)) {
canvasFormatSize(*format, options.dpi, options.width, options.height);
}
if (options.effects.empty()) options.effects.push_back(defaultEffectName());
return options;
}
@@ -169,13 +173,15 @@ std::string usage(const char* programName) {
out << "Usage: " << programName << " [options] [image ...]\n\n"
<< "Image effects app. Runs the GTK interface unless --output is given.\n\n"
<< "Options:\n"
<< " -e, --effect NAME effect to apply, repeat to cascade (default: " << defaultEffectName() << ")\n"
<< " -e, --effect NAME effect to apply, repeat to cascade (--output"
" defaults to " << defaultEffectName() << ")\n"
<< " -o, --output FILE render headlessly to this file and exit\n"
<< " -s, --size WxH canvas size, each side " << kMinCanvasSide << "-"
<< kMaxCanvasSide << " (default: 1920x1080)\n"
<< " --format NAME canvas format, overrides --size (see below)\n"
<< " --dpi N resolution of a print format, " << kMinDpi << "-" << kMaxDpi
<< " (default: " << kDefaultDpi << ")\n"
<< " --disable NAME keep this effect in the chain but skip it\n"
<< " --seed N random seed (default: random)\n"
<< " --list-effects list available effects and exit\n"
<< " -h, --help show this help and exit\n";
@@ -207,6 +213,8 @@ std::vector<ChainStage> buildChain(const Options& options) {
ChainStage stage;
stage.effect = effectByName(name);
stage.values = stage.effect->defaults();
stage.enabled = std::find(options.disabled.begin(), options.disabled.end(), name) ==
options.disabled.end();
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;
+6 -1
View File
@@ -14,6 +14,10 @@ namespace effector {
struct Options {
std::vector<std::string> images;
std::vector<std::string> effects; // one per cascade stage, in order
// The effects named by --disable: their stages stay in the chain, in
// place, but are skipped. Named rather than numbered, exactly as the
// params are, so two stages of one effect are disabled together.
std::vector<std::string> disabled;
std::string output; // empty -> launch the interface
int width = 1920;
int height = 1080;
@@ -33,7 +37,8 @@ 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`.
// together for `-e scanlines -e blur`. A stage named by --disable is built
// with all its settings and marked to be skipped.
std::vector<ChainStage> buildChain(const Options& options);
} // namespace effector
+47 -14
View File
@@ -2,17 +2,51 @@
namespace effector {
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages) {
std::vector<const ChainStage*> running;
running.reserve(stages.size());
for (const auto& stage : stages) {
if (stage.enabled && stage.effect) running.push_back(&stage);
}
return running;
}
ParamValues stageValues(const ChainStage& stage, const StyleSet& styles) {
ParamValues values = stage.effect->defaults();
for (const auto& [key, value] : stage.values) values[key] = value;
const Style* style = findStyle(styles, stage.effect->styleKind(), stage.style);
if (style != nullptr) {
// Only the params the effect marked styled(): the words and the
// placement stay the stage's own, which is what lets two stages share
// one look without sharing a position.
for (const auto& spec : stage.effect->params()) {
if (!spec.inStyle) continue;
const auto found = style->values.find(spec.key);
if (found != style->values.end()) values[spec.key] = found->second;
}
}
return values;
}
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
int width, int height, Rng& rng) {
int width, int height, Rng& rng, const StyleSet& styles) {
if (stages.empty()) throw EffectError("The effect chain is empty.");
for (const auto& stage : stages) {
if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
}
// A disabled stage never reaches the render at all: it is not handed an
// image and draws nothing from the shared rng, so switching one off gives
// back exactly the picture the chain made before it was added.
const auto running = enabledStages(stages);
if (running.empty()) throw EffectError("Every effect in the chain is disabled.");
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.");
for (std::size_t i = 0; i < running.size(); ++i) {
const auto& stage = *running[i];
auto values = stage.effect->defaults();
for (const auto& [key, value] : stage.values) values[key] = value;
const auto values = stageValues(stage, styles);
if (i == 0) {
image = stage.effect->render(sources, width, height, rng, values);
@@ -27,26 +61,25 @@ Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image
}
bool chainNeedsSources(const std::vector<ChainStage>& stages) {
if (stages.empty() || !stages.front().effect) return false;
return stages.front().effect->needsSources();
const auto running = enabledStages(stages);
if (running.empty()) return false;
return running.front()->effect->needsSources();
}
std::string chainTitle(const std::vector<ChainStage>& stages) {
std::string text;
for (const auto& stage : stages) {
if (!stage.effect) continue;
for (const auto* stage : enabledStages(stages)) {
if (!text.empty()) text += " → ";
text += stage.effect->title();
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;
for (const auto* stage : enabledStages(stages)) {
if (!text.empty()) text += "-";
text += stage.effect->name();
text += stage->effect->name();
}
return text;
}
+33 -9
View File
@@ -7,6 +7,7 @@
#include "core/image.hpp"
#include "core/params.hpp"
#include "core/rng.hpp"
#include "core/style.hpp"
namespace effector {
@@ -21,23 +22,46 @@ inline constexpr int kMaxCanvasSide = 20000;
struct ChainStage {
EffectPtr effect;
ParamValues values; // missing keys fall back to the effect's defaults
// A disabled stage is skipped whole, keeping its settings and its place in
// the chain, so a stage can be switched off and back on while trying
// things out without being taken apart and rebuilt.
bool enabled = true;
// The style this stage follows, by name, in the pool its effect names with
// styleKind(); empty when the stage stands on its own. A stage keeps a
// full set of values either way, so a style that is dropped or renamed
// away leaves the stage rendering exactly what it rendered before.
std::string style = {};
};
// 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.
// The values a stage actually runs with: its effect's defaults, then the
// stage's own values, then the values of the style it follows for the params
// that style carries. The one place a style is resolved.
ParamValues stageValues(const ChainStage& stage, const StyleSet& styles);
// The stages that actually run, in order, as pointers into `stages`. A
// disabled stage draws nothing from the rng and is handed no image, so a chain
// renders exactly as it would if that stage had never been added -- this is
// the one place the rest of the code asks what "disabled" means.
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages);
// Renders the enabled 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.
// reproduces the cascade byte for byte. Throws when no stage is left to run.
// `styles` is the project's pool; a chain built without one -- the command
// line's -- passes none and renders from the stages alone.
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
int width, int height, Rng& rng);
int width, int height, Rng& rng, const StyleSet& styles = {});
// 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.
// Whether the chain has to be given source images. Only the first stage that
// runs ever sees them — every later one works on the stage before it — so this
// is simply whether that effect declares that it needs them.
bool chainNeedsSources(const std::vector<ChainStage>& stages);
// "Scanlines → Blur", for window titles and status lines.
// "Scanlines → Blur", for window titles and status lines. Both of these name
// what was rendered, so a disabled stage is left out of them.
std::string chainTitle(const std::vector<ChainStage>& stages);
// "scanlines-blur", for file names.
+1 -19
View File
@@ -4,25 +4,7 @@ namespace effector {
ParamValues Effect::defaults() const {
ParamValues values;
for (const auto& spec : params()) {
switch (spec.type) {
case ParamSpec::Type::Int:
values[spec.key] = static_cast<long>(spec.defaultValue);
break;
case ParamSpec::Type::Double:
values[spec.key] = spec.defaultValue;
break;
case ParamSpec::Type::Bool:
values[spec.key] = spec.defaultValue != 0.0;
break;
case ParamSpec::Type::Choice:
case ParamSpec::Type::Path:
case ParamSpec::Type::Text:
case ParamSpec::Type::Color:
values[spec.key] = spec.defaultChoice;
break;
}
}
for (const auto& spec : params()) values[spec.key] = paramDefault(spec);
return values;
}
+6
View File
@@ -32,6 +32,12 @@ public:
// chain that starts with one of them needs no sources at all.
virtual bool needsSources() const { return true; }
// The pool of named styles this effect's styled params are drawn from, or
// empty when it takes no style. Effects that answer the same kind share
// one pool, so a stage can be pointed at a look its neighbours already
// use. An effect with no kind must declare no styled params.
virtual std::string styleKind() const { return {}; }
virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const = 0;
+23
View File
@@ -10,6 +10,12 @@ std::string ParamSpec::flag() const {
return flag;
}
ParamSpec ParamSpec::styled() const {
ParamSpec copy = *this;
copy.inStyle = true;
return copy;
}
ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum,
long maximum, std::string help, long step) {
ParamSpec spec;
@@ -103,6 +109,23 @@ const ParamValue* find(const ParamValues& values, const std::string& key) {
} // namespace
ParamValue paramDefault(const ParamSpec& spec) {
switch (spec.type) {
case ParamSpec::Type::Int:
return static_cast<long>(spec.defaultValue);
case ParamSpec::Type::Double:
return spec.defaultValue;
case ParamSpec::Type::Bool:
return spec.defaultValue != 0.0;
case ParamSpec::Type::Choice:
case ParamSpec::Type::Path:
case ParamSpec::Type::Text:
case ParamSpec::Type::Color:
break;
}
return spec.defaultChoice;
}
long paramInt(const ParamValues& values, const std::string& key, long fallback) {
if (const auto* value = find(values, key)) {
if (const auto* v = std::get_if<long>(value)) return *v;
+12
View File
@@ -24,9 +24,17 @@ struct ParamSpec {
int digits = 2;
std::string defaultChoice;
std::vector<std::string> choices;
// Whether a named style carries this param rather than the stage it sits
// on: see core/style.hpp.
bool inStyle = false;
std::string flag() const; // "strip_height" -> "--strip-height"
// A copy of this spec marked as carried by a style, so that a declaration
// reads makeDouble("font_size", ...).styled() and the factories keep the
// signatures they have.
ParamSpec styled() const;
static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum,
long maximum, std::string help = {}, long step = 1);
static ParamSpec makeDouble(std::string key, std::string label, double defaultValue,
@@ -53,6 +61,10 @@ struct ParamSpec {
using ParamValue = std::variant<long, double, bool, std::string>;
using ParamValues = std::map<std::string, ParamValue>;
// The value a spec stands for when nothing has been set: one place, so that
// an effect's defaults and a style's are filled the same way.
ParamValue paramDefault(const ParamSpec& spec);
long paramInt(const ParamValues& values, const std::string& key, long fallback);
double paramDouble(const ParamValues& values, const std::string& key, double fallback);
bool paramBool(const ParamValues& values, const std::string& key, bool fallback);
+156 -80
View File
@@ -325,6 +325,96 @@ std::string resolve(const std::string& path, const std::string& directory) {
return (std::filesystem::path(directory) / file).lexically_normal().string();
}
// The params of a stage or of a style, written from the specs rather than from
// whatever the values happen to hold: a key the effect does not declare is
// never written, so it can never be read back either.
void writeParams(std::ostream& out, const std::vector<ParamSpec>& specs, const ParamValues& values,
const std::string& directory) {
out << "{";
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(values, spec.key, spec.defaultValue != 0.0) ? "true" : "false");
break;
case ParamSpec::Type::Int:
out << paramInt(values, spec.key, static_cast<long>(spec.defaultValue));
break;
case ParamSpec::Type::Double:
out << number(paramDouble(values, spec.key, spec.defaultValue), spec.digits);
break;
case ParamSpec::Type::Choice:
case ParamSpec::Type::Text:
out << quote(paramText(values, spec.key, spec.defaultChoice));
break;
case ParamSpec::Type::Color: {
std::uint8_t rgb[3];
paramColor(values, spec.key, spec.defaultChoice, rgb);
out << quote(formatColor(rgb));
break;
}
case ParamSpec::Type::Path: {
// Written relative to the project folder, exactly as the
// sources are, so a folder still opens once it is moved.
const auto path = paramText(values, spec.key, "");
out << quote(path.empty() ? path : relativeTo(path, directory));
break;
}
}
}
out << (specs.empty() ? "}" : "\n }");
}
// The mirror image: only declared keys are read, each behind the gate its type
// asks for, so anything else in the file leaves the default standing.
void readParams(const std::vector<ParamSpec>& specs, const Json& params,
const std::string& directory, ParamValues& values) {
for (const auto& spec : specs) {
const auto* value = params.member(spec.key);
if (value == nullptr) continue;
switch (spec.type) {
case ParamSpec::Type::Bool:
if (value->kind == Json::Kind::Bool) values[spec.key] = value->boolean;
break;
case ParamSpec::Type::Int:
if (value->kind == Json::Kind::Number) {
values[spec.key] = static_cast<long>(std::llround(value->number));
}
break;
case ParamSpec::Type::Double:
if (value->kind == Json::Kind::Number) values[spec.key] = value->number;
break;
case ParamSpec::Type::Path:
if (value->kind == Json::Kind::String) {
values[spec.key] =
value->text.empty() ? value->text : resolve(value->text, directory);
}
break;
case ParamSpec::Type::Text:
if (value->kind == Json::Kind::String) values[spec.key] = value->text;
break;
case ParamSpec::Type::Color: {
// A colour that does not parse keeps the default, exactly as
// an unknown choice does.
std::uint8_t rgb[3];
if (value->kind == Json::Kind::String && parseColor(value->text, rgb)) {
values[spec.key] = formatColor(rgb);
}
break;
}
case ParamSpec::Type::Choice:
for (const auto& choice : spec.choices) {
if (value->kind == Json::Kind::String && value->text == choice) {
values[spec.key] = choice;
}
}
break;
}
}
}
} // namespace
std::string projectToJson(const Project& project, const std::string& directory) {
@@ -353,51 +443,37 @@ std::string projectToJson(const Project& project, const std::string& directory)
}
out << (project.sources.empty() ? "" : "\n ") << "],\n";
// Before the chain, because a stage's "style" is a name from this list.
if (!project.styles.empty()) {
out << " \"styles\": [";
for (std::size_t i = 0; i < project.styles.size(); ++i) {
const auto& style = project.styles[i];
out << (i == 0 ? "\n" : ",\n");
out << " {\n \"kind\": " << quote(style.kind) << ",\n";
out << " \"name\": " << quote(style.name) << ",\n";
out << " \"params\": ";
writeParams(out, styleSpecs(style.kind), style.values, directory);
out << "\n }";
}
out << "\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:
case ParamSpec::Type::Text:
out << quote(paramText(stage.values, spec.key, spec.defaultChoice));
break;
case ParamSpec::Type::Color: {
std::uint8_t rgb[3];
paramColor(stage.values, spec.key, spec.defaultChoice, rgb);
out << quote(formatColor(rgb));
break;
}
case ParamSpec::Type::Path: {
// Written relative to the project folder, exactly as the
// sources are, so a folder still opens once it is moved.
const auto path = paramText(stage.values, spec.key, "");
out << quote(path.empty() ? path : relativeTo(path, directory));
break;
}
}
}
out << (specs.empty() ? "}" : "\n }") << "\n }";
// Only written when it is false: a stage is enabled unless the file
// says otherwise, so an ordinary project stays as uncluttered as it
// was before a stage could be switched off at all.
if (!stage.enabled) out << " \"enabled\": false,\n";
// Likewise only when there is one: a stage that stands on its own
// values says nothing about styles at all.
if (!stage.style.empty()) out << " \"style\": " << quote(stage.style) << ",\n";
out << " \"params\": ";
writeParams(out, stage.effect->params(), stage.values, directory);
out << "\n }";
}
out << (project.stages.empty() ? "" : "\n ") << "]\n";
out << "}\n";
@@ -453,6 +529,33 @@ Project projectFromJson(const std::string& text, const std::string& directory) {
}
}
// Read before the chain, so that a stage's "style" can be checked against
// the pool as it is read.
if (const auto* styles = root.member("styles")) {
for (const auto& item : styles->items) {
const auto* kind = item.member("kind");
const auto* name = item.member("name");
if (kind == nullptr || kind->kind != Json::Kind::String) continue;
if (name == nullptr || name->kind != Json::Kind::String || name->text.empty()) continue;
// A kind no effect in this build claims carries no params, so
// there would be nothing to keep; a repeated name would make
// "style": "Title" ambiguous, so the first one wins.
const auto specs = styleSpecs(kind->text);
if (specs.empty()) continue;
if (findStyle(project.styles, kind->text, name->text) != nullptr) continue;
Style style;
style.kind = kind->text;
style.name = name->text;
style.values = styleDefaults(style.kind);
if (const auto* params = item.member("params")) {
readParams(specs, *params, directory, style.values);
}
project.styles.push_back(std::move(style));
}
}
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");
@@ -466,53 +569,26 @@ Project projectFromJson(const std::string& text, const std::string& directory) {
ChainStage stage;
stage.effect = effectByName(name->text); // throws on an unknown effect
stage.values = stage.effect->defaults();
if (const auto* enabled = item.member("enabled");
enabled != nullptr && enabled->kind == Json::Kind::Bool) {
stage.enabled = enabled->boolean;
}
// 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::Path:
if (value->kind == Json::Kind::String) {
stage.values[spec.key] =
value->text.empty() ? value->text : resolve(value->text, directory);
}
break;
case ParamSpec::Type::Text:
if (value->kind == Json::Kind::String) stage.values[spec.key] = value->text;
break;
case ParamSpec::Type::Color: {
// A colour that does not parse keeps the default,
// exactly as an unknown choice does.
std::uint8_t rgb[3];
if (value->kind == Json::Kind::String && parseColor(value->text, rgb)) {
stage.values[spec.key] = formatColor(rgb);
}
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;
}
readParams(stage.effect->params(), *params, directory, stage.values);
}
// A style is kept only when the file actually holds one of that name
// for this effect's pool: a stage pointing at a style that is not
// there falls back to the values it wrote beside it, which are the
// ones it last rendered with.
if (const auto* style = item.member("style");
style != nullptr && style->kind == Json::Kind::String &&
findStyle(project.styles, stage.effect->styleKind(), style->text) != nullptr) {
stage.style = style->text;
}
project.stages.push_back(std::move(stage));
}
+8 -1
View File
@@ -8,6 +8,7 @@
#include "core/canvas.hpp"
#include "core/chain.hpp"
#include "core/params.hpp"
#include "core/style.hpp"
namespace effector {
@@ -17,6 +18,10 @@ namespace effector {
struct Project {
std::vector<std::string> sources; // absolute paths
std::vector<ChainStage> stages;
// The named looks the stages draw their formatting from. Kept beside the
// chain rather than inside it because that is the whole point of a style:
// several stages share one, and editing it moves all of them at once.
StyleSet styles;
int width = 1920;
int height = 1080;
// The named format the canvas came from, empty for a custom pixel size.
@@ -36,7 +41,9 @@ 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.
// does not declare are dropped, and missing ones keep the effect's default. A
// style of an unknown kind, or a stage naming a style the file does not hold,
// is dropped the same way: the stage keeps its own values and still renders.
Project projectFromJson(const std::string& text, const std::string& directory = {});
// Both throw EffectError: the file is the user's, so failures are reported
+39
View File
@@ -0,0 +1,39 @@
#include "core/style.hpp"
#include <algorithm>
#include "core/registry.hpp"
namespace effector {
std::vector<ParamSpec> styleSpecs(const std::string& kind) {
std::vector<ParamSpec> specs;
if (kind.empty()) return specs;
for (const auto& effect : effects()) {
if (effect->styleKind() != kind) continue;
for (const auto& spec : effect->params()) {
if (!spec.inStyle) continue;
const bool known = std::any_of(specs.begin(), specs.end(), [&](const ParamSpec& seen) {
return seen.key == spec.key;
});
if (!known) specs.push_back(spec);
}
}
return specs;
}
ParamValues styleDefaults(const std::string& kind) {
ParamValues values;
for (const auto& spec : styleSpecs(kind)) values[spec.key] = paramDefault(spec);
return values;
}
const Style* findStyle(const StyleSet& styles, const std::string& kind, const std::string& name) {
if (kind.empty() || name.empty()) return nullptr;
for (const auto& style : styles) {
if (style.kind == kind && style.name == name) return &style;
}
return nullptr;
}
} // namespace effector
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <string>
#include <vector>
#include "core/params.hpp"
namespace effector {
// A named set of formatting values, shared by every stage that follows it.
//
// A style carries only the params an effect marked styled() -- the look, never
// the placement -- so several text stages can share one typography while each
// keeps its own words and its own corner of the canvas. Changing the style is
// the point: it changes every stage pointing at it at once.
struct Style {
std::string kind; // the pool, as Effect::styleKind() names it
std::string name; // unique within a kind, never empty
ParamValues values; // the styled params only
};
using StyleSet = std::vector<Style>;
// The styled specs of a kind: every registered effect answering that kind
// contributes its styled params, the first declaration of a key winning. This
// is to a style what Effect::params() is to a stage -- the only thing the
// interface and the project file know about what a style holds -- so an empty
// or unknown kind gives an empty list.
std::vector<ParamSpec> styleSpecs(const std::string& kind);
// A fresh set of values for a style of that kind: every styled param at the
// default its effect declares, the way Effect::defaults() fills a stage.
ParamValues styleDefaults(const std::string& kind);
// The style of that name in that pool, or nullptr. A stage naming a style that
// is not there simply stands on its own values.
const Style* findStyle(const StyleSet& styles, const std::string& kind, const std::string& name);
} // namespace effector
+20 -11
View File
@@ -17,31 +17,39 @@ const std::vector<ParamSpec>& TextEffect::params() const {
"What to write; a line break starts a new line."),
ParamSpec::makeText("font_family", "Font", "Sans",
"Font family, as it is named on this machine: Sans, Serif, Monospace, "
"\"DejaVu Serif\", ..."),
"\"DejaVu Serif\", ...")
.styled(),
ParamSpec::makeChoice("font_weight", "Weight", "normal",
{"thin", "light", "normal", "medium", "semibold", "bold", "heavy"},
"How bold the glyphs are; a family without the chosen weight is "
"rendered in the nearest one it has."),
ParamSpec::makeBool("font_italic", "Italic", false, "Slants the glyphs."),
"rendered in the nearest one it has.")
.styled(),
ParamSpec::makeBool("font_italic", "Italic", false, "Slants the glyphs.").styled(),
ParamSpec::makeDouble("font_size", "Size", 96.0, 4.0, 2000.0,
"Height of the glyphs in pixels.", 1.0, 1),
ParamSpec::makeColor("text_color", "Colour", "#ffffff", "The colour of the glyphs."),
"Height of the glyphs in pixels.", 1.0, 1)
.styled(),
ParamSpec::makeColor("text_color", "Colour", "#ffffff", "The colour of the glyphs.").styled(),
ParamSpec::makeChoice("text_align", "Alignment", "center", {"left", "center", "right"},
"How several lines line up against each other."),
"How several lines line up against each other.")
.styled(),
ParamSpec::makeInt("text_wrap", "Wrap width", 0, 0, 20000,
"Breaks the text into lines no wider than this many pixels; 0 writes "
"it as given."),
ParamSpec::makeChoice("text_box", "Box", "none", {"none", "colour", "image"},
"What the text sits on: nothing, a filled rectangle, or a picture "
"from disk cropped to the rectangle."),
"from disk cropped to the rectangle.")
.styled(),
ParamSpec::makeColor("box_color", "Box colour", "#000000",
"The colour the box is filled with."),
"The colour the box is filled with.")
.styled(),
ParamSpec::makePath("box_image", "Box picture",
"The picture the box is filled with, scaled to cover it."),
ParamSpec::makeInt("box_padding", "Box padding", 24, 0, 2000,
"How far the box reaches past the text on every side."),
"How far the box reaches past the text on every side.")
.styled(),
ParamSpec::makeDouble("box_opacity", "Box opacity", 1.0, 0.0, 1.0,
"How strongly the box is mixed over the input."),
"How strongly the box is mixed over the input.")
.styled(),
ParamSpec::makeBool("text_center", "Centre", true,
"Places the text in the middle of the canvas rather than at its top "
"left corner; the offset is counted from there either way."),
@@ -50,7 +58,8 @@ const std::vector<ParamSpec>& TextEffect::params() const {
ParamSpec::makeInt("text_offset_y", "Offset Y", 0, -20000, 20000,
"Moves the text down by this many pixels (up when negative)."),
ParamSpec::makeDouble("text_opacity", "Text opacity", 1.0, 0.0, 1.0,
"How strongly the glyphs are mixed over what is behind them."),
"How strongly the glyphs are mixed over what is behind them.")
.styled(),
};
return specs;
}
+1
View File
@@ -28,6 +28,7 @@ public:
"box filled with a colour or with a picture.";
}
const std::vector<ParamSpec>& params() const override;
std::string styleKind() const override { return "text"; }
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
+379 -19
View File
@@ -6,6 +6,7 @@
#include <cstdint>
#include <filesystem>
#include <sstream>
#include <utility>
#include "core/canvas.hpp"
#include "core/chain.hpp"
@@ -49,7 +50,8 @@ EffectorWindow::EffectorWindow(const Options& initial) {
set_default_size(1180, 760);
effects_ = effects();
stages_.push_back({effects_.front(), effects_.front()->defaults()});
// The chain starts empty: the interface opens on "No effects yet" and the
// user picks the first stage, rather than being handed one at random.
seed_ = Rng::randomSeed();
renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished));
@@ -191,6 +193,15 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
return *sidebar;
}
// Only the first stage is ever handed the sources, so whether the chain wants
// them at all is `chainNeedsSources`. An effect that starts from nothing (blur,
// text) makes the whole sources section beside the point, so it is hidden
// rather than left there to be filled in vain. This is the same declarative
// answer the render gate uses -- no effect is named here.
void EffectorWindow::updateSourcesVisible() {
sourcesExpander_.set_visible(chainNeedsSources(stages_));
}
// The expander is collapsed by default, so its label carries the count that
// used to be plain to see in the list.
void EffectorWindow::updateSourcesLabel() {
@@ -350,27 +361,62 @@ void EffectorWindow::buildParamWidgets() {
while (auto* child = paramGrid_.get_first_child()) paramGrid_.remove(*child);
paramWidgets_.clear();
ownedWidgets_.clear();
styleDrop_ = nullptr;
if (stages_.empty()) {
paramHeading_.set_text("No effects");
return;
}
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
// The settings of a stage that is switched off stay editable: it is still
// being worked on, it just is not in the picture for the moment.
paramHeading_.set_text(std::to_string(selectedStage_ + 1) + ". " + stage.effect->title() +
" settings");
(stage.enabled ? " settings" : " settings (off)"));
int row = 0;
// The style the stage follows, when its effect takes one at all: the
// params it carries are then shown and edited on the style rather than on
// the stage, which is what makes one change move every stage using it.
const Style* style = selectedStyle();
if (!stage.effect->styleKind().empty()) {
auto label = std::make_unique<Gtk::Label>("Style");
label->set_xalign(0.0F);
label->set_tooltip_text(
"A named set of formatting shared by several stages: the font, the colour and the "
"box, but never the words or the placement.");
auto widget = buildStyleRow(stage);
widget->set_hexpand(true);
paramGrid_.attach(*label, 0, row);
paramGrid_.attach(*widget, 1, row);
ownedWidgets_.push_back(std::move(label));
ownedWidgets_.push_back(std::move(widget));
++row;
}
for (const auto& spec : stage.effect->params()) {
// A styled param is read from the style while one is followed, so the
// widgets show what the stage will actually render with.
const bool shared = style != nullptr && spec.inStyle;
const ParamValues& values = shared ? style->values : stage.values;
auto label = std::make_unique<Gtk::Label>(spec.label);
label->set_xalign(0.0F);
if (!spec.help.empty()) label->set_tooltip_text(spec.help);
std::string help = spec.help;
if (shared) {
label->set_markup(Glib::Markup::escape_text(spec.label) +
" <span alpha='55%'>(style)</span>");
if (!help.empty()) help += "\n\n";
help += "Part of the style \"" + style->name +
"\": changing it changes every stage that follows it.";
}
if (!help.empty()) label->set_tooltip_text(help);
std::unique_ptr<Gtk::Widget> widget;
switch (spec.type) {
case ParamSpec::Type::Bool: {
auto toggle = std::make_unique<Gtk::Switch>();
toggle->set_halign(Gtk::Align::START);
toggle->set_active(paramBool(stage.values, spec.key, spec.defaultValue != 0.0));
toggle->set_active(paramBool(values, spec.key, spec.defaultValue != 0.0));
toggle->property_active().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(toggle);
@@ -383,9 +429,9 @@ void EffectorWindow::buildParamWidgets() {
spin->set_increments(spec.step, spec.step * 10);
spin->set_digits(spec.type == ParamSpec::Type::Int ? 0 : spec.digits);
spin->set_value(spec.type == ParamSpec::Type::Int
? static_cast<double>(paramInt(stage.values, spec.key,
? static_cast<double>(paramInt(values, spec.key,
static_cast<long>(spec.defaultValue)))
: paramDouble(stage.values, spec.key, spec.defaultValue));
: paramDouble(values, spec.key, spec.defaultValue));
spin->signal_value_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(spin);
@@ -397,7 +443,7 @@ void EffectorWindow::buildParamWidgets() {
auto box = std::make_unique<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
auto* entry = Gtk::make_managed<Gtk::Entry>();
entry->set_hexpand(true);
entry->set_text(paramText(stage.values, spec.key, ""));
entry->set_text(paramText(values, spec.key, ""));
entry->set_placeholder_text("No image chosen");
entry->signal_activate().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
@@ -415,7 +461,7 @@ void EffectorWindow::buildParamWidgets() {
}
case ParamSpec::Type::Text: {
auto entry = std::make_unique<Gtk::Entry>();
entry->set_text(paramText(stage.values, spec.key, spec.defaultChoice));
entry->set_text(paramText(values, spec.key, spec.defaultChoice));
// Re-rendering on every keystroke would start a render per
// letter typed, so the text is taken when the entry is left or
// Enter is pressed.
@@ -432,7 +478,7 @@ void EffectorWindow::buildParamWidgets() {
auto button = std::make_unique<Gtk::ColorDialogButton>(Gtk::ColorDialog::create());
button->set_halign(Gtk::Align::START);
std::uint8_t rgb[3];
paramColor(stage.values, spec.key, spec.defaultChoice, rgb);
paramColor(values, spec.key, spec.defaultChoice, rgb);
Gdk::RGBA colour;
colour.set_rgba(rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0, 1.0);
button->set_rgba(colour);
@@ -445,7 +491,7 @@ void EffectorWindow::buildParamWidgets() {
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
auto dropdown = std::make_unique<Gtk::DropDown>();
dropdown->set_model(Gtk::StringList::create(choices));
const auto current = paramChoice(stage.values, spec.key, spec.defaultChoice);
const auto current = paramChoice(values, spec.key, spec.defaultChoice);
for (std::size_t i = 0; i < spec.choices.size(); ++i) {
if (spec.choices[i] == current) dropdown->set_selected(i);
}
@@ -511,7 +557,282 @@ ParamValues EffectorWindow::paramValues() const {
// 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();
auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
const ParamValues values = paramValues();
stage.values = values;
// A param the style carries is written to the style too -- that is how the
// look of every stage following it is changed from here. The stage keeps
// its own copy of the same value, so leaving the style, or losing it, does
// not move the picture.
if (Style* style = selectedStyle()) {
for (const auto& spec : stage.effect->params()) {
if (!spec.inStyle) continue;
const auto found = values.find(spec.key);
if (found != values.end()) style->values[spec.key] = found->second;
}
}
}
// ---------------------------------------------------------- text styles ---
std::vector<std::string> EffectorWindow::styleNames(const std::string& kind) const {
std::vector<std::string> names;
for (const auto& style : styles_) {
if (style.kind == kind) names.push_back(style.name);
}
return names;
}
Style* EffectorWindow::selectedStyle() {
return const_cast<Style*>(std::as_const(*this).selectedStyle());
}
const Style* EffectorWindow::selectedStyle() const {
if (stages_.empty()) return nullptr;
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
return findStyle(styles_, stage.effect->styleKind(), stage.style);
}
bool EffectorWindow::styleNameTaken(const std::string& kind, const std::string& name) const {
return findStyle(styles_, kind, name) != nullptr;
}
std::unique_ptr<Gtk::Widget> EffectorWindow::buildStyleRow(const ChainStage& stage) {
auto box = std::make_unique<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
const auto names = styleNames(stage.effect->styleKind());
// "Custom" first so that the stage standing on its own is the plain case,
// and "New style…" last so that the names in between keep their indices.
std::vector<Glib::ustring> entries{"Custom"};
for (const auto& name : names) entries.emplace_back(name);
entries.emplace_back("New style…");
auto* drop = Gtk::make_managed<Gtk::DropDown>();
drop->set_model(Gtk::StringList::create(entries));
drop->set_hexpand(true);
suspendStyleSelect_ = true;
drop->set_selected(0);
for (std::size_t i = 0; i < names.size(); ++i) {
if (names[i] == stage.style) drop->set_selected(i + 1);
}
suspendStyleSelect_ = false;
drop->property_selected().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onStyleSelected));
box->append(*drop);
styleDrop_ = drop;
// Renaming and removing only make sense for a style that is being followed.
auto* rename = Gtk::make_managed<Gtk::Button>("Rename…");
rename->set_sensitive(!stage.style.empty());
rename->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onRenameStyle));
box->append(*rename);
auto* remove = Gtk::make_managed<Gtk::Button>("Remove");
remove->set_sensitive(!stage.style.empty());
remove->set_tooltip_text(
"Drops the style. Every stage following it keeps the look it has and goes back to its own "
"settings.");
remove->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onRemoveStyle));
box->append(*remove);
return box;
}
void EffectorWindow::onStyleSelected() {
if (suspendStyleSelect_ || styleDrop_ == nullptr || stages_.empty()) return;
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
const auto names = styleNames(stage.effect->styleKind());
const auto index = styleDrop_->get_selected();
if (index == 0) {
adoptStyle("");
} else if (index <= names.size()) {
adoptStyle(names[index - 1]);
} else {
onNewStyle(); // the last entry
}
}
// Pointing the stage at a style, or at nothing. The style's values are copied
// onto the stage as well, so the project file always says what the stage would
// render if the style went away -- which is the same picture.
void EffectorWindow::adoptStyle(const std::string& name) {
if (stages_.empty()) return;
auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
stage.style = name;
if (const Style* style = selectedStyle()) {
for (const auto& spec : stage.effect->params()) {
if (!spec.inStyle) continue;
const auto found = style->values.find(spec.key);
if (found != style->values.end()) stage.values[spec.key] = found->second;
}
}
buildParamWidgets();
requestRender();
}
void EffectorWindow::onNewStyle() {
if (stages_.empty()) return;
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
const std::string kind = stage.effect->styleKind();
if (kind.empty()) return;
askStyleName("New style", "Style " + std::to_string(styleNames(kind).size() + 1),
[this, kind](const std::string& name) {
// Seeded from the stage it was made on, so the style
// starts as the look that is already on screen.
Style style;
style.kind = kind;
style.name = name;
style.values = styleDefaults(kind);
const auto& current = stages_[static_cast<std::size_t>(selectedStage_)];
for (const auto& spec : current.effect->params()) {
if (!spec.inStyle) continue;
const auto found = current.values.find(spec.key);
if (found != current.values.end()) style.values[spec.key] = found->second;
}
styles_.push_back(std::move(style));
adoptStyle(name);
});
}
void EffectorWindow::onRenameStyle() {
const Style* style = selectedStyle();
if (style == nullptr) return;
const std::string kind = style->kind;
const std::string was = style->name;
askStyleName("Rename style", was, [this, kind, was](const std::string& name) {
if (name == was) return;
for (auto& candidate : styles_) {
if (candidate.kind == kind && candidate.name == was) candidate.name = name;
}
// Every stage that followed the old name follows the new one: a rename
// must not quietly cut a stage loose from its look.
for (auto& stage : stages_) {
if (stage.effect->styleKind() == kind && stage.style == was) stage.style = name;
}
buildParamWidgets();
});
}
void EffectorWindow::onRemoveStyle() {
const Style* style = selectedStyle();
if (style == nullptr) return;
const std::string kind = style->kind;
const std::string name = style->name;
// The stages keep the values they were rendering with, so dropping a style
// changes nothing in the picture -- it only stops the stages moving
// together.
for (auto& stage : stages_) {
if (stage.effect->styleKind() == kind && stage.style == name) stage.style.clear();
}
styles_.erase(std::remove_if(styles_.begin(), styles_.end(),
[&](const Style& candidate) {
return candidate.kind == kind && candidate.name == name;
}),
styles_.end());
buildParamWidgets();
requestRender();
}
// Built from scratch each time it is needed, like the progress window, so the
// entry it holds is a raw pointer that closeStyleWindow() nulls.
void EffectorWindow::askStyleName(const std::string& title, const std::string& initial,
const std::function<void(const std::string&)>& accept) {
closeStyleWindow();
if (stages_.empty()) return;
const std::string kind = stages_[static_cast<std::size_t>(selectedStage_)].effect->styleKind();
styleWindow_ = std::make_shared<Gtk::Window>();
styleWindow_->set_title(title);
styleWindow_->set_transient_for(*this);
styleWindow_->set_modal(true);
styleWindow_->set_resizable(false);
styleWindow_->set_default_size(360, -1);
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
box->set_margin(18);
auto* entry = Gtk::make_managed<Gtk::Entry>();
entry->set_text(initial);
entry->set_activates_default(false);
box->append(*entry);
styleNameEntry_ = entry;
auto* error = Gtk::make_managed<Gtk::Label>("");
error->set_xalign(0.0F);
error->add_css_class("dim-label");
error->set_visible(false);
box->append(*error);
styleNameError_ = error;
auto* buttons = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
buttons->set_halign(Gtk::Align::END);
auto* cancel = Gtk::make_managed<Gtk::Button>("Cancel");
cancel->signal_clicked().connect([this] {
closeStyleWindow();
// The dropdown may be sitting on "New style…"; put it back on what the
// stage actually follows.
buildParamWidgets();
});
buttons->append(*cancel);
auto* confirm = Gtk::make_managed<Gtk::Button>("OK");
confirm->add_css_class("suggested-action");
// A name that is empty or already in the pool is refused in place rather
// than silently changed, so two styles can never share a name.
const auto submit = [this, kind, accept] {
if (styleNameEntry_ == nullptr) return;
const std::string name = styleNameEntry_->get_text();
const char* complaint = nullptr;
if (name.empty()) {
complaint = "A style needs a name.";
} else if (styleNameTaken(kind, name)) {
complaint = "There is already a style with that name.";
}
if (complaint != nullptr) {
if (styleNameError_ != nullptr) {
styleNameError_->set_text(complaint);
styleNameError_->set_visible(true);
}
return;
}
closeStyleWindow();
accept(name);
};
confirm->signal_clicked().connect(submit);
entry->signal_activate().connect(submit);
buttons->append(*confirm);
box->append(*buttons);
styleWindow_->signal_close_request().connect(
[this] {
closeStyleWindow();
buildParamWidgets();
return true;
},
false);
styleWindow_->set_child(*box);
styleWindow_->present();
entry->grab_focus();
}
void EffectorWindow::closeStyleWindow() {
styleNameEntry_ = nullptr;
styleNameError_ = nullptr;
if (!styleWindow_) return;
styleWindow_->set_hide_on_close(false);
styleWindow_->hide();
// Closing is asked for by the window's own buttons and by its close
// request, so it is torn down once that signal has finished running rather
// than underneath it.
Glib::signal_idle().connect_once([window = styleWindow_] {});
styleWindow_.reset();
}
void EffectorWindow::applyInitial(const Options& initial) {
@@ -903,6 +1224,12 @@ void EffectorWindow::rebuildChainRows() {
// removing it would leave the empty list blank.
while (auto* row = chainList_.get_row_at_index(0)) chainList_.remove(*row);
// The first stage that actually runs is the one handed the sources, which
// is not stage 1 once stage 1 is switched off.
const auto running = enabledStages(stages_);
const int firstRunning =
running.empty() ? -1 : static_cast<int>(running.front() - stages_.data());
for (std::size_t i = 0; i < stages_.size(); ++i) {
const int index = static_cast<int>(i);
auto* row = Gtk::make_managed<Gtk::ListBoxRow>();
@@ -912,13 +1239,28 @@ void EffectorWindow::rebuildChainRows() {
box->set_margin_start(8);
box->set_margin_end(4);
// The check is what switches a stage off: it keeps its settings and
// its place in the chain, and the render simply skips it.
auto* check = Gtk::make_managed<Gtk::CheckButton>();
check->set_active(stages_[i].enabled);
check->set_tooltip_text("Apply this effect");
check->set_valign(Gtk::Align::CENTER);
// Connected after set_active, so filling in the rebuilt rows does not
// look like the user reaching for the check.
check->signal_toggled().connect([this, index, check] {
setStageEnabled(index, check->get_active());
});
box->append(*check);
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");
if (!stages_[i].enabled) label->add_css_class("dim-label");
label->set_tooltip_text(!stages_[i].enabled ? "Skipped while it is switched off"
: index == firstRunning ? "Applied to the source images"
: "Applied to the output so far");
box->append(*label);
struct Action {
@@ -952,6 +1294,20 @@ void EffectorWindow::rebuildChainRows() {
selectStage(selectedStage_);
suspendStageSelect_ = false;
updateSourcesVisible();
}
// Switching a stage off leaves everything about it alone -- its settings, its
// place in the chain, the stage the params below are showing -- so the only
// visible change is the dimmed row and the picture the render comes back with.
void EffectorWindow::setStageEnabled(int index, bool enabled) {
if (index < 0 || static_cast<std::size_t>(index) >= stages_.size()) return;
auto& stage = stages_[static_cast<std::size_t>(index)];
if (stage.enabled == enabled) return;
stage.enabled = enabled;
rebuildChainRows(); // the row dims, and another stage may now lead the chain
if (index == selectedStage_) buildParamWidgets(); // the heading says "off"
requestRender();
}
void EffectorWindow::selectStage(int index) {
@@ -1028,12 +1384,13 @@ void EffectorWindow::onRecreate() {
// 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;
if (enabledStages(stages_).empty()) return false;
return !sources_->empty() || !chainNeedsSources(stages_);
}
std::string EffectorWindow::missingText() const {
if (stages_.empty()) return "Add an effect to start.";
if (enabledStages(stages_).empty()) return "Every effect is switched off.";
return "Add source images to start.";
}
@@ -1072,6 +1429,7 @@ void EffectorWindow::startRender() {
const auto sources = sources_;
const auto stages = stages_; // a snapshot, like the sources
const auto styles = styles_; // the same, so the worker reads nothing live
const int width = static_cast<int>(widthSpin_.get_value());
const int height = static_cast<int>(heightSpin_.get_value());
const std::uint64_t seed = seed_;
@@ -1079,13 +1437,13 @@ void EffectorWindow::startRender() {
spinner_.start();
recreateButton_.set_sensitive(false);
worker_ = std::thread([this, sources, stages, width, height, seed] {
worker_ = std::thread([this, sources, stages, styles, width, height, seed] {
Image image;
std::string error;
const auto start = std::chrono::steady_clock::now();
try {
Rng rng(seed);
image = renderChain(stages, *sources, width, height, rng);
image = renderChain(stages, *sources, width, height, rng, styles);
} catch (const std::exception& exception) {
error = exception.what();
}
@@ -1193,6 +1551,7 @@ Project EffectorWindow::currentProject() const {
Project project;
for (const auto& image : *sources_) project.sources.push_back(image.path());
project.stages = stages_;
project.styles = styles_;
project.width = static_cast<int>(widthSpin_.get_value());
project.height = static_cast<int>(heightSpin_.get_value());
project.format = canvasFormatKey();
@@ -1204,9 +1563,12 @@ Project EffectorWindow::currentProject() const {
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;
closeStyleWindow(); // it names a style of the project being replaced
clearSources();
stages_ = project.stages;
// Before the rows and the params: both ask which style a stage follows.
styles_ = project.styles;
selectedStage_ = 0;
rebuildChainRows();
buildParamWidgets();
@@ -1242,9 +1604,7 @@ void EffectorWindow::clearSources() {
}
void EffectorWindow::onNewProject() {
Project fresh;
fresh.stages.push_back({effects_.front(), effects_.front()->defaults()});
applyProject(fresh, "");
applyProject(Project(), ""); // same empty chain the window opens with
}
void EffectorWindow::onOpenProject() {
+37 -1
View File
@@ -7,6 +7,7 @@
#include <memory>
#include <mutex>
#include <optional>
#include <functional>
#include <string>
#include <thread>
#include <vector>
@@ -16,6 +17,7 @@
#include "core/effect.hpp"
#include "core/image.hpp"
#include "core/project.hpp"
#include "core/style.hpp"
namespace effector {
@@ -30,7 +32,29 @@ private:
Gtk::Widget& buildPreview();
void buildParamWidgets();
ParamValues paramValues() const;
void storeParams(); // widgets -> the selected stage's values
void storeParams(); // widgets -> the selected stage, and its style
// The style row above the params of a stage whose effect takes one.
std::unique_ptr<Gtk::Widget> buildStyleRow(const ChainStage& stage);
// The names of the pool the selected stage draws from, in the order the
// dropdown shows them.
std::vector<std::string> styleNames(const std::string& kind) const;
// The style the selected stage follows, or nullptr when it stands alone.
Style* selectedStyle();
const Style* selectedStyle() const;
void onStyleSelected(); // the dropdown: adopt, drop, or make a new one
void adoptStyle(const std::string& name);
void onNewStyle();
void onRenameStyle();
void onRemoveStyle();
// The little modal that asks for a style name, used by both New and
// Rename. Built from scratch each time, like the progress window.
void askStyleName(const std::string& title, const std::string& initial,
const std::function<void(const std::string&)>& accept);
void closeStyleWindow();
// A name that is empty, or already used in the pool, is refused in place.
bool styleNameTaken(const std::string& kind, const std::string& name) const;
void applyInitial(const Options& initial);
bool canRender() const; // a chain, and sources if the chain needs them
@@ -49,6 +73,8 @@ private:
void cancelLoad();
void appendSourceRow(const std::string& path);
void updateSourcesLabel(); // the collapsed expander shows the count
// The whole sources section only shows while the chain asks for sources.
void updateSourcesVisible();
Gtk::Widget& buildCanvasSection();
// "" while "Custom" is picked, otherwise the format's key.
@@ -64,6 +90,8 @@ private:
void appendStage(const EffectPtr& effect);
void removeStage(int index);
void moveStage(int index, int delta);
// The check on a chain row: the stage stays where it is, and is skipped.
void setStageEnabled(int index, bool enabled);
void rebuildChainRows();
void selectStage(int index);
void onStageSelected();
@@ -110,6 +138,9 @@ private:
// The cascade: stage 0 works on the sources, every later stage on the
// output of the one before it.
std::vector<ChainStage> stages_;
// The named looks the stages share. Edited through the params of whichever
// stage follows one, and saved with the project.
StyleSet styles_;
int selectedStage_ = 0;
bool suspendStageSelect_ = false; // guards the rebuild of the chain rows
Image result_;
@@ -162,6 +193,11 @@ private:
// Set while a format fills the spins, so their own handler does not queue
// a second render on top of the one the format change asks for.
bool suspendCanvasSync_ = false;
Gtk::DropDown* styleDrop_ = nullptr; // null while the stage takes no style
bool suspendStyleSelect_ = false; // guards filling the dropdown
std::shared_ptr<Gtk::Window> styleWindow_;
Gtk::Entry* styleNameEntry_ = nullptr; // nulled with the window
Gtk::Label* styleNameError_ = nullptr;
Gtk::Grid paramGrid_;
Gtk::Label paramHeading_;
Gtk::Entry seedEntry_;
+8 -1
View File
@@ -18,7 +18,14 @@
namespace {
int renderHeadless(const effector::Options& options) {
const auto stages = effector::buildChain(options);
auto stages = effector::buildChain(options);
// A headless run always renders something, so with no -e the default
// effect stands in. The interface has no such need: it opens on an empty
// chain and waits for the user to add the first stage.
if (stages.empty()) {
const auto effect = effector::effectByName(effector::defaultEffectName());
stages.push_back({effect, effect->defaults()});
}
std::vector<effector::LoadFailure> failures;
+229
View File
@@ -1,5 +1,6 @@
// Small self-contained checks for the pieces that have no GUI dependency.
#include <algorithm>
#include <array>
#include <atomic>
#include <clocale>
@@ -21,6 +22,7 @@
#include "core/project.hpp"
#include "core/registry.hpp"
#include "core/rng.hpp"
#include "core/style.hpp"
#include "effects/blur.hpp"
#include "effects/grain.hpp"
#include "effects/monochrome.hpp"
@@ -828,6 +830,109 @@ int main() {
std::filesystem::remove_all(dir);
}
std::cout << "text styles\n";
{
const std::vector<Image> blue = {solid(200, 120, 0, 0, 255)};
const auto countRed = [](const Image& image) {
int found = 0;
for (int y = 0; y < image.height(); ++y) {
for (int x = 0; x < image.width(); ++x) {
if (isRed(pixel(image, x, y))) ++found;
}
}
return found;
};
const auto specs = styleSpecs("text");
const auto carries = [&specs](const std::string& key) {
return std::any_of(specs.begin(), specs.end(),
[&key](const ParamSpec& spec) { return spec.key == key; });
};
check(specs.size() == 11, "a text style carries eleven params");
check(carries("font_family") && carries("font_weight") && carries("font_italic") &&
carries("font_size") && carries("text_color") && carries("text_align") &&
carries("text_opacity") && carries("text_box") && carries("box_color") &&
carries("box_padding") && carries("box_opacity"),
"the look is what it carries");
check(!carries("text") && !carries("text_wrap") && !carries("box_image") &&
!carries("text_center") && !carries("text_offset_x") && !carries("text_offset_y"),
"the words and the placement stay on the stage");
check(styleSpecs("").empty() && styleSpecs("nosuchkind").empty(),
"a kind no effect claims carries nothing");
check(paramDouble(styleDefaults("text"), "font_size", 0.0) == 96.0 &&
styleDefaults("text").size() == specs.size(),
"a fresh style starts at the defaults the effect declares");
StyleSet styles = {{"text", "Title", styleDefaults("text")}};
styles[0].values["font_size"] = 40.0;
styles[0].values["text_color"] = std::string("#ff0000");
check(findStyle(styles, "text", "Title") != nullptr &&
findStyle(styles, "text", "Caption") == nullptr &&
findStyle(styles, "other", "Title") == nullptr,
"a style is found by its kind and its name");
const ChainStage own{effectByName("text"),
{{"text", std::string("Hi")},
{"font_size", 12.0},
{"text_color", std::string("#ff0000")}},
true,
""};
ChainStage follows = own;
follows.style = "Title";
check(paramDouble(stageValues(own, styles), "font_size", 0.0) == 12.0,
"a stage on its own keeps the settings it was given");
check(paramDouble(stageValues(follows, styles), "font_size", 0.0) == 40.0,
"a stage following a style takes the style's");
check(paramText(stageValues(follows, styles), "text", "") == "Hi",
"and keeps its own words either way");
Rng ownRng(3);
Rng followRng(3);
const auto small = renderChain({own}, blue, 200, 120, ownRng, styles);
const auto large = renderChain({follows}, blue, 200, 120, followRng, styles);
check(countRed(small) > 0 && countRed(large) > countRed(small),
"the style is what actually reaches the render");
// The whole point: two stages, one style, one look in two places.
ChainStage left = follows;
left.values["text_offset_x"] = -40L;
ChainStage right = follows;
right.values["text_offset_x"] = 40L;
Rng leftRng(3);
Rng rightRng(3);
const auto leftImage = renderChain({left}, blue, 200, 120, leftRng, styles);
const auto rightImage = renderChain({right}, blue, 200, 120, rightRng, styles);
check(countRed(leftImage) == countRed(large) && countRed(rightImage) == countRed(large),
"two stages following one style ink the same glyphs");
check(!sameBytes(leftImage, rightImage), "each in its own place");
StyleSet edited = styles;
edited[0].values["font_size"] = 20.0;
Rng editedLeft(3);
Rng editedRight(3);
const auto afterLeft = renderChain({left}, blue, 200, 120, editedLeft, edited);
const auto afterRight = renderChain({right}, blue, 200, 120, editedRight, edited);
check(countRed(afterLeft) < countRed(leftImage) &&
countRed(afterRight) < countRed(rightImage) &&
countRed(afterLeft) == countRed(afterRight),
"editing the style moves every stage that follows it");
ChainStage missing = follows;
missing.style = "Nowhere";
Rng missingRng(3);
Rng bareRng(3);
check(sameBytes(renderChain({missing}, blue, 200, 120, missingRng, styles),
renderChain({own}, blue, 200, 120, bareRng)),
"naming a style that is not there renders as standing alone");
Rng noStyleRng(3);
Rng noPoolRng(3);
check(sameBytes(renderChain({follows}, blue, 200, 120, noStyleRng, {}),
renderChain({own}, blue, 200, 120, noPoolRng)),
"and so does a chain handed no styles at all");
}
std::cout << "chain\n";
{
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
@@ -889,6 +994,50 @@ int main() {
scanlinesThrew = true;
}
check(scanlinesThrew, "scanlines still refuses to run without sources");
// A stage switched off is skipped whole: it is handed no image and
// draws nothing from the shared rng, so the cascade comes out exactly
// as it would have without that stage in it at all.
ChainStage blurOff = blur;
blurOff.enabled = false;
ChainStage scanlinesOff = scanlines;
scanlinesOff.enabled = false;
Rng skipping(5);
const auto skipped = renderChain({scanlines, blurOff}, sources, 400, 300, skipping);
check(sameBytes(skipped, direct), "a disabled stage is skipped, its rng draws with it");
check(chainTitle({scanlines, blurOff}) == "Scanlines", "the title names only what ran");
check(chainSlug({scanlines, blurOff}) == "scanlines", "and so does the slug");
// Whoever leads the chain is handed the sources, and that is the first
// stage still switched on.
check(!chainNeedsSources({scanlinesOff, blur}),
"a disabled leading stage leaves the question to the stage behind it");
Rng leading(5);
const auto ledByBlur = renderChain({scanlinesOff, blur}, {}, 200, 150, leading);
check(sameBytes(ledByBlur, sourceless), "the stage behind it leads instead");
bool allOffThrew = false;
try {
Rng rng(1);
renderChain({scanlinesOff, blurOff}, sources, 100, 100, rng);
} catch (const EffectError&) {
allOffThrew = true;
}
check(allOffThrew, "a chain with every stage disabled throws");
// The command line switches a stage off by effect name, the same way
// the params pick out the stage they belong to.
const auto cli =
parsedOptions({"-e", "scanlines", "-e", "blur", "--disable", "blur", "--radius", "4"});
check(cli.has_value(), "--disable is accepted");
const auto cliStages = buildChain(*cli);
check(cliStages.size() == 2 && cliStages[0].enabled && !cliStages[1].enabled,
"--disable keeps the stage in the chain and switches it off");
check(paramInt(cliStages[1].values, "radius", 0) == 4,
"a switched-off stage still takes its flags");
check(!parsedOptions({"-e", "blur", "--disable", "nope"}).has_value(),
"--disable refuses an unknown effect");
}
std::cout << "project\n";
@@ -949,6 +1098,86 @@ int main() {
check(stripped.stages[0].values.find("radius") == stripped.stages[0].values.end(),
"a stage only keeps the params its effect declares");
// A stage switched off keeps its place and its settings in the file and
// comes back switched off; nothing is written for the ordinary case.
Project toggled = project;
toggled.stages[1].enabled = false;
const auto offFile = dir / "disabled.json";
saveProject(offFile.string(), toggled);
const auto offBack = loadProject(offFile.string());
check(offBack.stages.size() == 2 && offBack.stages[0].enabled && !offBack.stages[1].enabled,
"a switched-off stage comes back switched off");
check(paramInt(offBack.stages[1].values, "radius", 0) == 3,
"and with the settings it was switched off with");
check(projectToJson(toggled).find("\"enabled\"") != std::string::npos &&
projectToJson(project).find("\"enabled\"") == std::string::npos,
"only a switched-off stage is written out as one");
// Styles ride along with the chain, and the stages come back pointing
// at the ones they followed.
Project stylish;
stylish.styles = {{"text", "Title", styleDefaults("text")},
{"text", "Caption", styleDefaults("text")}};
stylish.styles[0].values["font_size"] = 120.0;
stylish.styles[0].values["text_color"] = std::string("#ff8800");
stylish.styles[1].values["font_family"] = std::string("Serif");
stylish.stages.push_back(
{effectByName("text"), {{"text", std::string("One")}}, true, "Title"});
stylish.stages.push_back(
{effectByName("text"), {{"text", std::string("Two")}}, true, "Title"});
stylish.stages.push_back(
{effectByName("text"), {{"text", std::string("Three")}}, true, ""});
const auto styleFile = dir / "styles.json";
saveProject(styleFile.string(), stylish);
const auto styleBack = loadProject(styleFile.string());
check(styleBack.styles.size() == 2 && styleBack.styles[0].name == "Title" &&
styleBack.styles[1].name == "Caption" && styleBack.styles[0].kind == "text",
"the styles survive a round trip");
check(paramDouble(styleBack.styles[0].values, "font_size", 0.0) == 120.0 &&
paramText(styleBack.styles[0].values, "text_color", "") == "#ff8800" &&
paramText(styleBack.styles[1].values, "font_family", "") == "Serif",
"and so do the values they carry");
check(styleBack.stages[0].style == "Title" && styleBack.stages[1].style == "Title" &&
styleBack.stages[2].style.empty(),
"and which stage follows which");
check(paramDouble(stageValues(styleBack.stages[0], styleBack.styles), "font_size", 0.0) ==
120.0 &&
paramDouble(stageValues(styleBack.stages[2], styleBack.styles), "font_size",
0.0) == 96.0,
"a reloaded project resolves its styles as it did");
check(projectToJson(stylish).find("\"styles\"") != std::string::npos &&
projectToJson(project).find("\"styles\"") == std::string::npos &&
projectToJson(project).find("\"style\"") == std::string::npos,
"a project with no styles writes nothing about them");
// Everything that can be wrong with a style is dropped on its own,
// leaving a project that still opens and still renders.
const auto lenient = projectFromJson(R"({
"styles": [
{"kind": "nosuchkind", "name": "Ghost", "params": {}},
{"kind": "text", "name": "", "params": {}},
{"kind": "text", "name": "Title", "params": {"font_size": 60, "nonsense": 3}},
{"kind": "text", "name": "Title", "params": {"font_size": 12}}
],
"chain": [
{"effect": "text", "style": "Title", "params": {"text": "Kept"}},
{"effect": "text", "style": "Gone", "params": {"font_size": 30}},
{"effect": "blur", "style": "Title", "params": {}}
]
})");
check(lenient.styles.size() == 1 && lenient.styles[0].name == "Title",
"an unknown kind, a nameless style and a repeated name are dropped");
check(paramDouble(lenient.styles[0].values, "font_size", 0.0) == 60.0 &&
lenient.styles[0].values.find("nonsense") == lenient.styles[0].values.end(),
"a style only keeps the params its kind declares, and the first of a name wins");
check(lenient.stages[0].style == "Title" && lenient.stages[1].style.empty(),
"a stage naming a style that is not there is cut loose");
check(lenient.stages[2].style.empty(),
"and so is one whose effect takes no style at all");
check(paramDouble(stageValues(lenient.stages[1], lenient.styles), "font_size", 0.0) == 30.0,
"which leaves it rendering the values written beside it");
int refusals = 0;
for (const char* bad : {"{\"chain\": [{\"effect\": \"nope\"}]}", "{\"chain\": []}",
"{\"chain\": [{\"effect\": \"blur\"},]}", "not json at all"}) {