Add a text effect

Writes a line or several over its input in a chosen family, weight, size
and colour, optionally on a box filled with a colour or with a picture
from disk. Like the blur and the overlay it needs no sources of its own,
so it can lead a chain; given sources it fills the canvas from a randomly
chosen one first.

Two param types rather than effect-specific branches in the front-ends:
Text, free-form text the user types, and Color, a colour written
"#rrggbb". Both travel as strings, so the interface gets an entry and a
colour button, the command line gets a flag (a colour that does not parse
is refused there rather than drawn as black), and a project keeps them
across a round trip.

The glyphs come from Pango, which draws through Cairo without a display,
so the core stays headless — but they are the one thing here that is not
byte-identical across machines: a machine without the chosen family
renders the substitute its fontconfig picks. Everything the effect is
handed, the rng included, is used exactly as every other effect uses it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Si
2026-09-18 20:31:24 +02:00
co-authored by Claude Opus 5
parent c34bc426fc
commit 1d0343b90a
12 changed files with 725 additions and 6 deletions
+2
View File
@@ -17,6 +17,8 @@ ParamValues Effect::defaults() const {
break;
case ParamSpec::Type::Choice:
case ParamSpec::Type::Path:
case ParamSpec::Type::Text:
case ParamSpec::Type::Color:
values[spec.key] = spec.defaultChoice;
break;
}
+68
View File
@@ -72,6 +72,28 @@ ParamSpec ParamSpec::makePath(std::string key, std::string label, std::string he
return spec;
}
ParamSpec ParamSpec::makeText(std::string key, std::string label, std::string defaultText,
std::string help) {
ParamSpec spec;
spec.type = Type::Text;
spec.key = std::move(key);
spec.label = std::move(label);
spec.help = std::move(help);
spec.defaultChoice = std::move(defaultText);
return spec;
}
ParamSpec ParamSpec::makeColor(std::string key, std::string label, std::string defaultColor,
std::string help) {
ParamSpec spec;
spec.type = Type::Color;
spec.key = std::move(key);
spec.label = std::move(label);
spec.help = std::move(help);
spec.defaultChoice = std::move(defaultColor);
return spec;
}
namespace {
const ParamValue* find(const ParamValues& values, const std::string& key) {
@@ -119,4 +141,50 @@ std::string paramText(const ParamValues& values, const std::string& key,
return fallback;
}
namespace {
int hexDigit(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
} // namespace
bool parseColor(const std::string& text, std::uint8_t rgb[3]) {
std::string digits = text;
if (!digits.empty() && digits.front() == '#') digits.erase(digits.begin());
if (digits.size() != 3 && digits.size() != 6) return false;
int parsed[6];
for (std::size_t i = 0; i < digits.size(); ++i) {
parsed[i] = hexDigit(digits[i]);
if (parsed[i] < 0) return false;
}
for (int c = 0; c < 3; ++c) {
// "#abc" is the usual shorthand for "#aabbcc".
const int value = digits.size() == 3 ? parsed[c] * 17 : parsed[c * 2] * 16 + parsed[c * 2 + 1];
rgb[c] = static_cast<std::uint8_t>(value);
}
return true;
}
std::string formatColor(const std::uint8_t rgb[3]) {
static const char* const kDigits = "0123456789abcdef";
std::string text = "#";
for (int c = 0; c < 3; ++c) {
text += kDigits[rgb[c] >> 4];
text += kDigits[rgb[c] & 0x0F];
}
return text;
}
void paramColor(const ParamValues& values, const std::string& key, const std::string& fallback,
std::uint8_t rgb[3]) {
if (parseColor(paramText(values, key, fallback), rgb)) return;
if (parseColor(fallback, rgb)) return;
rgb[0] = rgb[1] = rgb[2] = 0;
}
} // namespace effector
+20 -1
View File
@@ -1,5 +1,6 @@
#pragma once
#include <cstdint>
#include <map>
#include <string>
#include <variant>
@@ -10,7 +11,7 @@ namespace effector {
// Declarative description of one effect knob. The GUI turns these into widgets
// and the CLI turns them into flags, so neither knows about any effect.
struct ParamSpec {
enum class Type { Int, Double, Bool, Choice, Path };
enum class Type { Int, Double, Bool, Choice, Path, Text, Color };
Type type = Type::Int;
std::string key;
@@ -39,6 +40,14 @@ struct ParamSpec {
// flag taking any string on the command line, written relative to the
// project folder so that a folder still opens after it is moved.
static ParamSpec makePath(std::string key, std::string label, std::string help = {});
// Free-form text the user types: an entry in the interface, a flag taking
// any string on the command line.
static ParamSpec makeText(std::string key, std::string label, std::string defaultText,
std::string help = {});
// A colour written "#rrggbb": a colour button in the interface, the same
// spelling as a flag value.
static ParamSpec makeColor(std::string key, std::string label, std::string defaultColor,
std::string help = {});
};
using ParamValue = std::variant<long, double, bool, std::string>;
@@ -52,4 +61,14 @@ std::string paramChoice(const ParamValues& values, const std::string& key,
std::string paramText(const ParamValues& values, const std::string& key,
const std::string& fallback);
// Reads "#rgb" or "#rrggbb" (the leading hash optional) into three bytes.
// Returns false and leaves `rgb` untouched for anything else, so a malformed
// value falls back to the default rather than painting an arbitrary colour.
bool parseColor(const std::string& text, std::uint8_t rgb[3]);
std::string formatColor(const std::uint8_t rgb[3]);
// The colour of a param, or `fallback` when the value does not parse.
void paramColor(const ParamValues& values, const std::string& key, const std::string& fallback,
std::uint8_t rgb[3]);
} // namespace effector
+21 -1
View File
@@ -2,6 +2,7 @@
#include <charconv>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
@@ -358,8 +359,15 @@ std::string projectToJson(const Project& project, const std::string& directory)
spec.digits);
break;
case ParamSpec::Type::Choice:
out << quote(paramChoice(stage.values, spec.key, spec.defaultChoice));
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.
@@ -452,6 +460,18 @@ Project projectFromJson(const std::string& text, const std::string& directory) {
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) {
+2
View File
@@ -5,6 +5,7 @@
#include "effects/monochrome.hpp"
#include "effects/overlay.hpp"
#include "effects/scanlines.hpp"
#include "effects/text.hpp"
namespace effector {
@@ -16,6 +17,7 @@ const std::vector<EffectPtr>& effects() {
std::make_shared<GrainEffect>(),
std::make_shared<MonochromeEffect>(),
std::make_shared<OverlayEffect>(),
std::make_shared<TextEffect>(),
};
return registry;
}