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>
This commit is contained in:
Si
2026-09-19 21:55:55 +02:00
co-authored by Claude Opus 5
parent eb417bb41a
commit 3aade6f361
17 changed files with 890 additions and 131 deletions
+20 -3
View File
@@ -11,8 +11,26 @@ std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stag
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.");
@@ -28,8 +46,7 @@ Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image
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);
+14 -1
View File
@@ -7,6 +7,7 @@
#include "core/image.hpp"
#include "core/params.hpp"
#include "core/rng.hpp"
#include "core/style.hpp"
namespace effector {
@@ -25,8 +26,18 @@ struct ChainStage {
// 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 = {};
};
// 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
@@ -39,8 +50,10 @@ std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stag
//
// All stages share one Rng and run strictly in sequence, so a seed still
// 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 that
// runs ever sees them — every later one works on the stage before it — so this
+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);
+149 -81
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,6 +443,21 @@ 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];
@@ -363,45 +468,12 @@ std::string projectToJson(const Project& project, const std::string& directory)
// 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";
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 }";
// 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";
@@ -457,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");
@@ -479,48 +578,17 @@ Project projectFromJson(const std::string& text, const std::string& directory) {
// 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