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
+170
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)};
@@ -1008,6 +1113,71 @@ int main() {
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"}) {