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
+10 -3
View File
@@ -45,7 +45,10 @@ about an effect: `src/gui/window.cpp` builds SpinButtons/Switches/DropDowns/file
choosers from them and `src/cli/options.cpp` generates flags from them choosers from them and `src/cli/options.cpp` generates flags from them
(`strip_height` → `--strip-height`). Never add effect-specific branches to the (`strip_height` → `--strip-height`). Never add effect-specific branches to the
GUI or the parser — add a `ParamSpec` instead. A `Path` param names a file on GUI or the parser — add a `ParamSpec` instead. A `Path` param names a file on
disk; like a source path it is written relative to the project folder. Values disk; like a source path it is written relative to the project folder. A `Text`
param is free-form text the user types, and a `Color` param is a colour written
`#rrggbb` (`parseColor`/`formatColor`/`paramColor` in `src/core/params.hpp`),
shown as a colour button in the interface. Values
travel as `ParamValues` (a travel as `ParamValues` (a
`map<string, variant<long,double,bool,string>>`), read back with `map<string, variant<long,double,bool,string>>`), read back with
`paramInt`/`paramDouble`/`paramBool`/`paramChoice`. `paramInt`/`paramDouble`/`paramBool`/`paramChoice`.
@@ -96,7 +99,8 @@ is discarded rather than mixed into the new project (`dropLoadedImages_`).
### Two link targets, deliberately ### Two link targets, deliberately
`effector_core` (static) holds image IO, params, effects, rng and links only `effector_core` (static) holds image IO, params, effects, rng and links only
`gdk-pixbuf-2.0` + Threads — **no gtkmm, no display**. The `effector` executable `gdk-pixbuf-2.0`, `pangocairo` (the text effect's glyphs; Pango and Cairo need
no display) + Threads — **no gtkmm, no display**. The `effector` executable
adds `src/main.cpp`, `src/cli/`, `src/gui/` and links gtkmm. Keep GTK out of adds `src/main.cpp`, `src/cli/`, `src/gui/` and links gtkmm. Keep GTK out of
`src/core/` and `src/effects/`; the tests link the core alone and rely on this. `src/core/` and `src/effects/`; the tests link the core alone and rely on this.
@@ -105,7 +109,10 @@ adds `src/main.cpp`, `src/cli/`, `src/gui/` and links gtkmm. Keep GTK out of
`Rng` (`src/core/rng.hpp`) is a hand-written xoshiro256\*\* rather than `Rng` (`src/core/rng.hpp`) is a hand-written xoshiro256\*\* rather than
`<random>`, because libstdc++ distributions are not portable across `<random>`, because libstdc++ distributions are not portable across
implementations. Same sources + settings + seed must produce byte-identical implementations. Same sources + settings + seed must produce byte-identical
output on any machine — the tests assert this. Effects must draw *every* random output on any machine — the tests assert this. The one thing outside this is the
text effect's glyphs, which are whatever shapes the machine's fonts and
fontconfig give: everything it is handed, the rng included, is still used the
same way. Effects must draw *every* random
value from the `Rng&` they are handed, and must not depend on thread scheduling: value from the `Rng&` they are handed, and must not depend on thread scheduling:
the scanlines effect first draws the whole strip plan serially from the rng, the scanlines effect first draws the whole strip plan serially from the rng,
then copies pixels in parallel. then copies pixels in parallel.
+5 -1
View File
@@ -15,6 +15,9 @@ set(CMAKE_CXX_FLAGS_RELEASE "-O3 -DNDEBUG")
find_package(PkgConfig REQUIRED) find_package(PkgConfig REQUIRED)
pkg_check_modules(GTKMM REQUIRED IMPORTED_TARGET gtkmm-4.0) pkg_check_modules(GTKMM REQUIRED IMPORTED_TARGET gtkmm-4.0)
pkg_check_modules(GDKPIXBUF REQUIRED IMPORTED_TARGET gdk-pixbuf-2.0) pkg_check_modules(GDKPIXBUF REQUIRED IMPORTED_TARGET gdk-pixbuf-2.0)
# Pango and Cairo draw the text effect's glyphs. Neither needs a display, so
# the core stays headless.
pkg_check_modules(PANGOCAIRO REQUIRED IMPORTED_TARGET pangocairo)
find_package(Threads REQUIRED) find_package(Threads REQUIRED)
@@ -32,11 +35,12 @@ add_library(effector_core STATIC
src/effects/monochrome.cpp src/effects/monochrome.cpp
src/effects/overlay.cpp src/effects/overlay.cpp
src/effects/scanlines.cpp src/effects/scanlines.cpp
src/effects/text.cpp
) )
target_include_directories(effector_core PUBLIC src) target_include_directories(effector_core PUBLIC src)
# The core is GUI-free: it only needs gdk-pixbuf for decoding and encoding, # The core is GUI-free: it only needs gdk-pixbuf for decoding and encoding,
# so headless rendering works without a display. # so headless rendering works without a display.
target_link_libraries(effector_core PUBLIC PkgConfig::GDKPIXBUF Threads::Threads) target_link_libraries(effector_core PUBLIC PkgConfig::GDKPIXBUF PkgConfig::PANGOCAIRO Threads::Threads)
target_compile_options(effector_core PRIVATE -Wall -Wextra -Wpedantic) target_compile_options(effector_core PRIVATE -Wall -Wextra -Wpedantic)
# --- application -------------------------------------------------------------- # --- application --------------------------------------------------------------
+12
View File
@@ -1,6 +1,7 @@
#include "cli/options.hpp" #include "cli/options.hpp"
#include <charconv> #include <charconv>
#include <cstdint>
#include <cstring> #include <cstring>
#include <sstream> #include <sstream>
@@ -111,8 +112,19 @@ Options parseOptions(int argc, char** argv) {
break; break;
} }
case ParamSpec::Type::Path: case ParamSpec::Type::Path:
case ParamSpec::Type::Text:
options.params[spec->key] = requireValue(argc, argv, i, arg); options.params[spec->key] = requireValue(argc, argv, i, arg);
break; break;
case ParamSpec::Type::Color: {
const std::string text = requireValue(argc, argv, i, arg);
std::uint8_t rgb[3];
if (!parseColor(text, rgb)) {
throw EffectError(arg + ": '" + text +
"' is not a colour, expected e.g. #ff8800");
}
options.params[spec->key] = formatColor(rgb);
break;
}
case ParamSpec::Type::Choice: { case ParamSpec::Type::Choice: {
const std::string text = requireValue(argc, argv, i, arg); const std::string text = requireValue(argc, argv, i, arg);
bool valid = false; bool valid = false;
+2
View File
@@ -17,6 +17,8 @@ ParamValues Effect::defaults() const {
break; break;
case ParamSpec::Type::Choice: case ParamSpec::Type::Choice:
case ParamSpec::Type::Path: case ParamSpec::Type::Path:
case ParamSpec::Type::Text:
case ParamSpec::Type::Color:
values[spec.key] = spec.defaultChoice; values[spec.key] = spec.defaultChoice;
break; break;
} }
+68
View File
@@ -72,6 +72,28 @@ ParamSpec ParamSpec::makePath(std::string key, std::string label, std::string he
return spec; 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 { namespace {
const ParamValue* find(const ParamValues& values, const std::string& key) { 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; 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 } // namespace effector
+20 -1
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <cstdint>
#include <map> #include <map>
#include <string> #include <string>
#include <variant> #include <variant>
@@ -10,7 +11,7 @@ namespace effector {
// Declarative description of one effect knob. The GUI turns these into widgets // 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. // and the CLI turns them into flags, so neither knows about any effect.
struct ParamSpec { struct ParamSpec {
enum class Type { Int, Double, Bool, Choice, Path }; enum class Type { Int, Double, Bool, Choice, Path, Text, Color };
Type type = Type::Int; Type type = Type::Int;
std::string key; std::string key;
@@ -39,6 +40,14 @@ struct ParamSpec {
// flag taking any string on the command line, written relative to the // flag taking any string on the command line, written relative to the
// project folder so that a folder still opens after it is moved. // project folder so that a folder still opens after it is moved.
static ParamSpec makePath(std::string key, std::string label, std::string help = {}); 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>; 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, std::string paramText(const ParamValues& values, const std::string& key,
const std::string& fallback); 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 } // namespace effector
+21 -1
View File
@@ -2,6 +2,7 @@
#include <charconv> #include <charconv>
#include <cmath> #include <cmath>
#include <cstdint>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <filesystem> #include <filesystem>
@@ -358,8 +359,15 @@ std::string projectToJson(const Project& project, const std::string& directory)
spec.digits); spec.digits);
break; break;
case ParamSpec::Type::Choice: 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; 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: { case ParamSpec::Type::Path: {
// Written relative to the project folder, exactly as the // Written relative to the project folder, exactly as the
// sources are, so a folder still opens once it is moved. // 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); value->text.empty() ? value->text : resolve(value->text, directory);
} }
break; 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: case ParamSpec::Type::Choice:
for (const auto& choice : spec.choices) { for (const auto& choice : spec.choices) {
if (value->kind == Json::Kind::String && value->text == choice) { if (value->kind == Json::Kind::String && value->text == choice) {
+2
View File
@@ -5,6 +5,7 @@
#include "effects/monochrome.hpp" #include "effects/monochrome.hpp"
#include "effects/overlay.hpp" #include "effects/overlay.hpp"
#include "effects/scanlines.hpp" #include "effects/scanlines.hpp"
#include "effects/text.hpp"
namespace effector { namespace effector {
@@ -16,6 +17,7 @@ const std::vector<EffectPtr>& effects() {
std::make_shared<GrainEffect>(), std::make_shared<GrainEffect>(),
std::make_shared<MonochromeEffect>(), std::make_shared<MonochromeEffect>(),
std::make_shared<OverlayEffect>(), std::make_shared<OverlayEffect>(),
std::make_shared<TextEffect>(),
}; };
return registry; return registry;
} }
+306
View File
@@ -0,0 +1,306 @@
#include "effects/text.hpp"
#include <pango/pangocairo.h>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include "core/parallel.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& TextEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeText("text", "Text", "Effector",
"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\", ..."),
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."),
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."),
ParamSpec::makeChoice("text_align", "Alignment", "center", {"left", "center", "right"},
"How several lines line up against each other."),
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."),
ParamSpec::makeColor("box_color", "Box colour", "#000000",
"The colour the box is filled with."),
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."),
ParamSpec::makeDouble("box_opacity", "Box opacity", 1.0, 0.0, 1.0,
"How strongly the box is mixed over the input."),
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."),
ParamSpec::makeInt("text_offset_x", "Offset X", 0, -20000, 20000,
"Moves the text right by this many pixels (left when negative)."),
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."),
};
return specs;
}
namespace {
// Small holders so that an EffectError thrown half way through still frees the
// Cairo and Pango objects.
struct SurfaceHandle {
cairo_surface_t* get = nullptr;
~SurfaceHandle() {
if (get != nullptr) cairo_surface_destroy(get);
}
};
struct ContextHandle {
cairo_t* get = nullptr;
~ContextHandle() {
if (get != nullptr) cairo_destroy(get);
}
};
struct LayoutHandle {
PangoLayout* get = nullptr;
~LayoutHandle() {
if (get != nullptr) g_object_unref(get);
}
};
struct FontHandle {
PangoFontDescription* get = nullptr;
~FontHandle() {
if (get != nullptr) pango_font_description_free(get);
}
};
PangoWeight weightFor(const std::string& name) {
if (name == "thin") return PANGO_WEIGHT_THIN;
if (name == "light") return PANGO_WEIGHT_LIGHT;
if (name == "medium") return PANGO_WEIGHT_MEDIUM;
if (name == "semibold") return PANGO_WEIGHT_SEMIBOLD;
if (name == "bold") return PANGO_WEIGHT_BOLD;
if (name == "heavy") return PANGO_WEIGHT_HEAVY;
return PANGO_WEIGHT_NORMAL;
}
PangoAlignment alignmentFor(const std::string& name) {
if (name == "left") return PANGO_ALIGN_LEFT;
if (name == "right") return PANGO_ALIGN_RIGHT;
return PANGO_ALIGN_CENTER;
}
// An RGB image plus its alpha as a Cairo ARGB32 surface, premultiplied, which
// is the only layout Cairo paints from.
cairo_surface_t* toSurface(const Image& image, const std::vector<std::uint8_t>& alpha) {
cairo_surface_t* surface =
cairo_image_surface_create(CAIRO_FORMAT_ARGB32, image.width(), image.height());
if (cairo_surface_status(surface) != CAIRO_STATUS_SUCCESS) {
cairo_surface_destroy(surface);
throw EffectError("Cannot hold the box picture in memory.");
}
const int stride = cairo_image_surface_get_stride(surface);
unsigned char* data = cairo_image_surface_get_data(surface);
const bool hasAlpha = alpha.size() == static_cast<std::size_t>(image.width()) *
static_cast<std::size_t>(image.height());
for (int y = 0; y < image.height(); ++y) {
const std::uint8_t* in = image.row(y);
auto* out = reinterpret_cast<std::uint32_t*>(data + static_cast<std::size_t>(y) * stride);
for (int x = 0; x < image.width(); ++x) {
const std::uint32_t a =
hasAlpha ? alpha[static_cast<std::size_t>(y) * image.width() + x] : 255;
const std::uint32_t r = in[x * 3 + 0] * a / 255;
const std::uint32_t g = in[x * 3 + 1] * a / 255;
const std::uint32_t b = in[x * 3 + 2] * a / 255;
out[x] = (a << 24) | (r << 16) | (g << 8) | b;
}
}
cairo_surface_mark_dirty(surface);
return surface;
}
void setColor(cairo_t* cr, const std::uint8_t rgb[3], double opacity) {
cairo_set_source_rgba(cr, rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0, opacity);
}
// Mixes the drawn layer, ARGB32 and premultiplied, over the canvas.
void composite(Image& canvas, const cairo_surface_t* surface) {
const int stride = cairo_image_surface_get_stride(const_cast<cairo_surface_t*>(surface));
const unsigned char* data =
cairo_image_surface_get_data(const_cast<cairo_surface_t*>(surface));
const int width = canvas.width();
parallelFor(static_cast<std::size_t>(canvas.height()), [&](std::size_t begin, std::size_t end) {
for (std::size_t y = begin; y < end; ++y) {
const auto* in =
reinterpret_cast<const std::uint32_t*>(data + static_cast<std::size_t>(y) * stride);
std::uint8_t* out = canvas.row(static_cast<int>(y));
for (int x = 0; x < width; ++x) {
const std::uint32_t pixel = in[x];
const std::uint32_t a = pixel >> 24;
if (a == 0) continue;
// Premultiplied: the colour is already scaled by alpha, so the
// mix is an addition once the background is scaled down.
const std::uint32_t channel[3] = {(pixel >> 16) & 0xFF, (pixel >> 8) & 0xFF,
pixel & 0xFF};
std::uint8_t* px = out + static_cast<std::size_t>(x) * 3;
for (int c = 0; c < 3; ++c) {
const std::uint32_t value = channel[c] + px[c] * (255 - a) / 255;
px[c] = static_cast<std::uint8_t>(std::min<std::uint32_t>(value, 255));
}
}
}
});
}
} // namespace
void TextEffect::loadBoxImage(const std::string& path, Image& image,
std::vector<std::uint8_t>& alpha) const {
std::lock_guard<std::mutex> lock(cacheMutex_);
if (cachedPath_ != path || cached_.empty()) {
std::vector<std::uint8_t> loadedAlpha;
Image loaded;
try {
loaded = Image::load(path, &loadedAlpha);
} catch (const std::exception& error) {
throw EffectError("Cannot read the box picture '" + path + "': " + error.what());
}
if (loaded.empty()) throw EffectError("The box picture '" + path + "' is empty.");
cached_ = std::move(loaded);
cachedAlpha_ = std::move(loadedAlpha);
cachedPath_ = path;
}
image = cached_;
alpha = cachedAlpha_;
}
Image TextEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
Image canvas(width, height);
if (!sources.empty()) {
// Drawn before any threading, so that a seed always gives the same image.
const std::size_t index = rng.below(sources.size() - 1);
const Image& src = sources[index];
const int sx = static_cast<int>(rng.inRange(0, std::max(0, src.width() - width)));
const int sy = static_cast<int>(rng.inRange(0, std::max(0, src.height() - height)));
fillFromSource(canvas, src, sx, sy);
}
const std::string text = paramText(values, "text", "");
const double textOpacity = std::clamp(paramDouble(values, "text_opacity", 1.0), 0.0, 1.0);
const std::string boxKind = paramChoice(values, "text_box", "none");
const double boxOpacity = std::clamp(paramDouble(values, "box_opacity", 1.0), 0.0, 1.0);
const bool drawsText = !text.empty() && textOpacity > 0.0;
const bool drawsBox = boxKind != "none" && boxOpacity > 0.0;
// No text typed yet is the state the interface starts in, not an error: the
// stage simply passes its input through.
if (!drawsText && !drawsBox) return canvas;
SurfaceHandle surface;
surface.get = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, width, height);
if (cairo_surface_status(surface.get) != CAIRO_STATUS_SUCCESS) {
throw EffectError("Cannot hold the text layer in memory.");
}
ContextHandle cr;
cr.get = cairo_create(surface.get);
LayoutHandle layout;
layout.get = pango_cairo_create_layout(cr.get);
pango_layout_set_text(layout.get, text.c_str(), -1);
pango_layout_set_alignment(layout.get, alignmentFor(paramChoice(values, "text_align", "center")));
const int wrap = static_cast<int>(paramInt(values, "text_wrap", 0));
if (wrap > 0) {
pango_layout_set_width(layout.get, wrap * PANGO_SCALE);
pango_layout_set_wrap(layout.get, PANGO_WRAP_WORD_CHAR);
}
FontHandle font;
font.get = pango_font_description_new();
const std::string family = paramText(values, "font_family", "Sans");
pango_font_description_set_family(font.get, family.empty() ? "Sans" : family.c_str());
pango_font_description_set_weight(font.get, weightFor(paramChoice(values, "font_weight", "normal")));
pango_font_description_set_style(
font.get, paramBool(values, "font_italic", false) ? PANGO_STYLE_ITALIC : PANGO_STYLE_NORMAL);
// Absolute size: the param is in pixels, not in points at some unstated dpi.
pango_font_description_set_absolute_size(
font.get, std::clamp(paramDouble(values, "font_size", 96.0), 4.0, 2000.0) * PANGO_SCALE);
pango_layout_set_font_description(layout.get, font.get);
PangoRectangle logical;
pango_layout_get_pixel_extents(layout.get, nullptr, &logical);
const int padding = static_cast<int>(paramInt(values, "box_padding", 24));
const int boxWidth = std::max(1, logical.width + 2 * padding);
const int boxHeight = std::max(1, logical.height + 2 * padding);
const bool centre = paramBool(values, "text_center", true);
const double boxX = (centre ? (width - boxWidth) / 2.0 : 0.0) +
static_cast<double>(paramInt(values, "text_offset_x", 0));
const double boxY = (centre ? (height - boxHeight) / 2.0 : 0.0) +
static_cast<double>(paramInt(values, "text_offset_y", 0));
if (drawsBox) {
cairo_save(cr.get);
cairo_rectangle(cr.get, boxX, boxY, boxWidth, boxHeight);
cairo_clip(cr.get);
if (boxKind == "image") {
const std::string path = paramText(values, "box_image", "");
if (!path.empty()) {
Image picture;
std::vector<std::uint8_t> alpha;
loadBoxImage(path, picture, alpha);
SurfaceHandle fill;
fill.get = toSurface(picture, alpha);
// Scaled to cover the box, keeping its proportions, exactly as
// the overlay's "fill" does; what does not fit is clipped away.
const double factor = std::max(static_cast<double>(boxWidth) / picture.width(),
static_cast<double>(boxHeight) / picture.height());
cairo_translate(cr.get, boxX + (boxWidth - picture.width() * factor) / 2.0,
boxY + (boxHeight - picture.height() * factor) / 2.0);
cairo_scale(cr.get, factor, factor);
cairo_set_source_surface(cr.get, fill.get, 0.0, 0.0);
cairo_pattern_set_filter(cairo_get_source(cr.get), CAIRO_FILTER_BILINEAR);
cairo_paint_with_alpha(cr.get, boxOpacity);
}
} else {
std::uint8_t rgb[3];
paramColor(values, "box_color", "#000000", rgb);
setColor(cr.get, rgb, boxOpacity);
cairo_paint(cr.get);
}
cairo_restore(cr.get);
}
if (drawsText) {
std::uint8_t rgb[3];
paramColor(values, "text_color", "#ffffff", rgb);
setColor(cr.get, rgb, textOpacity);
// logical.x/y are where the layout actually starts, which a wrapped or
// right-aligned layout shifts; subtracting them puts the glyphs inside
// the padding on every side.
cairo_move_to(cr.get, boxX + padding - logical.x, boxY + padding - logical.y);
pango_cairo_show_layout(cr.get, layout.get);
}
cairo_surface_flush(surface.get);
composite(canvas, surface.get);
return canvas;
}
} // namespace effector
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <cstdint>
#include <mutex>
#include <string>
#include <vector>
#include "core/effect.hpp"
namespace effector {
// Writes a line (or several) over its input: a title, a caption, a watermark.
// The family and the weight are chosen freely, and the text can sit on a box
// filled with a colour or with a picture from disk. Like the blur and the
// overlay it needs no source images of its own, so it can lead a chain; given
// sources it fills the canvas from a randomly chosen one first.
//
// The glyphs come from Pango, so the exact pixels depend on the fonts the
// machine has installed: a seed reproduces everything this effect draws, but a
// machine without the chosen family renders the substitute its fontconfig
// picks.
class TextEffect : public Effect {
public:
std::string name() const override { return "text"; }
std::string title() const override { return "Text"; }
std::string description() const override {
return "Writes text over its input in a chosen family, weight and size, optionally on a "
"box filled with a colour or with a picture.";
}
const std::vector<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
private:
// The box picture is re-read on every param change otherwise, and a render
// is a keystroke away in the interface. Renders can overlap, so it is
// guarded.
void loadBoxImage(const std::string& path, Image& image,
std::vector<std::uint8_t>& alpha) const;
mutable std::mutex cacheMutex_;
mutable std::string cachedPath_;
mutable Image cached_;
mutable std::vector<std::uint8_t> cachedAlpha_;
};
} // namespace effector
+40
View File
@@ -2,6 +2,8 @@
#include <algorithm> #include <algorithm>
#include <chrono> #include <chrono>
#include <cmath>
#include <cstdint>
#include <filesystem> #include <filesystem>
#include <sstream> #include <sstream>
@@ -315,6 +317,34 @@ void EffectorWindow::buildParamWidgets() {
widget = std::move(box); widget = std::move(box);
break; break;
} }
case ParamSpec::Type::Text: {
auto entry = std::make_unique<Gtk::Entry>();
entry->set_text(paramText(stage.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.
entry->signal_activate().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
auto focus = Gtk::EventControllerFocus::create();
focus->signal_leave().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
entry->add_controller(focus);
widget = std::move(entry);
break;
}
case ParamSpec::Type::Color: {
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);
Gdk::RGBA colour;
colour.set_rgba(rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0, 1.0);
button->set_rgba(colour);
button->property_rgba().signal_changed().connect(
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
widget = std::move(button);
break;
}
case ParamSpec::Type::Choice: { case ParamSpec::Type::Choice: {
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end()); std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
auto dropdown = std::make_unique<Gtk::DropDown>(); auto dropdown = std::make_unique<Gtk::DropDown>();
@@ -358,9 +388,19 @@ ParamValues EffectorWindow::paramValues() const {
values[entry.spec.key] = static_cast<Gtk::SpinButton*>(entry.widget)->get_value(); values[entry.spec.key] = static_cast<Gtk::SpinButton*>(entry.widget)->get_value();
break; break;
case ParamSpec::Type::Path: case ParamSpec::Type::Path:
case ParamSpec::Type::Text:
values[entry.spec.key] = values[entry.spec.key] =
std::string(static_cast<Gtk::Entry*>(entry.widget)->get_text()); std::string(static_cast<Gtk::Entry*>(entry.widget)->get_text());
break; break;
case ParamSpec::Type::Color: {
const auto colour = static_cast<Gtk::ColorDialogButton*>(entry.widget)->get_rgba();
const std::uint8_t rgb[3] = {
static_cast<std::uint8_t>(std::lround(colour.get_red() * 255.0)),
static_cast<std::uint8_t>(std::lround(colour.get_green() * 255.0)),
static_cast<std::uint8_t>(std::lround(colour.get_blue() * 255.0))};
values[entry.spec.key] = formatColor(rgb);
break;
}
case ParamSpec::Type::Choice: { case ParamSpec::Type::Choice: {
const auto index = static_cast<Gtk::DropDown*>(entry.widget)->get_selected(); const auto index = static_cast<Gtk::DropDown*>(entry.widget)->get_selected();
if (index < entry.spec.choices.size()) values[entry.spec.key] = entry.spec.choices[index]; if (index < entry.spec.choices.size()) values[entry.spec.key] = entry.spec.choices[index];
+190
View File
@@ -25,6 +25,7 @@
#include "effects/monochrome.hpp" #include "effects/monochrome.hpp"
#include "effects/overlay.hpp" #include "effects/overlay.hpp"
#include "effects/scanlines.hpp" #include "effects/scanlines.hpp"
#include "effects/text.hpp"
namespace { namespace {
@@ -129,6 +130,15 @@ effector::Image renderOverlay(const std::vector<effector::Image>& sources, int w
return effect.render(sources, width, height, rng, merged); return effect.render(sources, width, height, rng, merged);
} }
effector::Image renderText(const std::vector<effector::Image>& sources, int width, int height,
const effector::ParamValues& values) {
effector::TextEffect effect;
effector::Rng rng(7);
auto merged = effect.defaults();
for (const auto& [key, value] : values) merged[key] = value;
return effect.render(sources, width, height, rng, merged);
}
// The three channels of one pixel, to read a placement off directly. // The three channels of one pixel, to read a placement off directly.
std::array<int, 3> pixel(const effector::Image& image, int x, int y) { std::array<int, 3> pixel(const effector::Image& image, int x, int y) {
const auto* px = image.row(y) + static_cast<std::size_t>(x) * 3; const auto* px = image.row(y) + static_cast<std::size_t>(x) * 3;
@@ -533,6 +543,186 @@ int main() {
std::filesystem::remove_all(dir); std::filesystem::remove_all(dir);
} }
std::cout << "colour params\n";
{
std::uint8_t rgb[3] = {0, 0, 0};
check(parseColor("#ff8800", rgb) && rgb[0] == 255 && rgb[1] == 136 && rgb[2] == 0,
"a six digit colour is read");
check(parseColor("00FF80", rgb) && rgb[1] == 255 && rgb[2] == 128,
"the hash is optional and the case does not matter");
check(parseColor("#f80", rgb) && rgb[0] == 255 && rgb[1] == 136 && rgb[2] == 0,
"the three digit shorthand doubles each digit");
check(!parseColor("#12345", rgb) && !parseColor("#gg0000", rgb) && !parseColor("", rgb),
"anything else is refused");
const std::uint8_t written[3] = {1, 171, 255};
check(formatColor(written) == "#01abff", "a colour is written back as six digits");
// A value that does not parse falls back rather than painting
// something arbitrary.
std::uint8_t fallback[3] = {0, 0, 0};
paramColor({{"c", std::string("nonsense")}}, "c", "#102030", fallback);
check(fallback[0] == 0x10 && fallback[1] == 0x20 && fallback[2] == 0x30,
"a malformed colour keeps the default");
// Both new types survive a project round trip, and the reader drops a
// colour it cannot read exactly as it drops an unknown choice.
Project project;
project.width = 64;
project.height = 64;
project.stages.push_back({effectByName("text"),
{{"text", std::string("a \"quoted\" line")},
{"text_color", std::string("#ff0080")}}});
const auto reread = projectFromJson(projectToJson(project, ""), "");
check(paramText(reread.stages[0].values, "text", "") == "a \"quoted\" line",
"text with quotes in it survives a project round trip");
check(paramText(reread.stages[0].values, "text_color", "") == "#ff0080",
"and so does a colour");
const auto broken = projectFromJson(
"{\"chain\": [{\"effect\": \"text\", \"params\": {\"text_color\": \"#zz\"}}]}", "");
check(paramText(broken.stages[0].values, "text_color", "") == "#ffffff",
"a colour a project cannot be read keeps the effect's default");
// The command line refuses one too, rather than drawing black.
bool threw = false;
try {
const char* argv[] = {"effector", "-e", "text", "--text-color", "purple"};
parseOptions(5, const_cast<char**>(argv));
} catch (const EffectError&) {
threw = true;
}
check(threw, "an unparseable colour on the command line is reported");
}
std::cout << "text\n";
{
const std::vector<Image> blue = {solid(200, 120, 0, 0, 255)};
const ParamValues plain = {{"text", std::string("Hi")},
{"font_size", 60.0},
{"text_color", std::string("#ff0000")}};
// Counting the red pixels is how every placement below is read: the
// exact glyphs belong to whatever font this machine has.
const auto countRed = [](const Image& image, int x0, int y0, int x1, int y1) {
int found = 0;
for (int y = y0; y < y1; ++y) {
for (int x = x0; x < x1; ++x) {
if (isRed(pixel(image, x, y))) ++found;
}
}
return found;
};
const auto centred = renderText(blue, 200, 120, plain);
check(countRed(centred, 60, 30, 140, 90) > 0, "the text lands in the middle of the canvas");
check(countRed(centred, 0, 0, 200, 120) == countRed(centred, 50, 20, 150, 100),
"and nothing is written outside it");
ParamValues corner = plain;
corner["text_center"] = false;
const auto topLeft = renderText(blue, 200, 120, corner);
check(countRed(topLeft, 0, 0, 100, 60) > 0 && countRed(topLeft, 100, 60, 200, 120) == 0,
"uncentred it starts at the top left corner");
ParamValues nudged = corner;
nudged["text_offset_x"] = 100L;
nudged["text_offset_y"] = 60L;
const auto moved = renderText(blue, 200, 120, nudged);
check(countRed(moved, 100, 60, 200, 120) > 0 && countRed(moved, 0, 0, 100, 60) == 0,
"the offset moves it down and to the right");
// Weight and size are the two knobs the glyphs themselves answer to: a
// bolder or a larger setting covers strictly more of the canvas.
ParamValues bold = plain;
bold["font_weight"] = std::string("heavy");
check(countRed(renderText(blue, 200, 120, bold), 0, 0, 200, 120) >
countRed(centred, 0, 0, 200, 120),
"a heavier weight inks more pixels than a normal one");
ParamValues large = plain;
large["font_size"] = 90.0;
check(countRed(renderText(blue, 200, 120, large), 0, 0, 200, 120) >
countRed(centred, 0, 0, 200, 120),
"and so does a larger size");
// The box is drawn under the text, in a colour or from a picture.
ParamValues boxed = plain;
boxed["text_box"] = std::string("colour");
boxed["box_color"] = std::string("#00ff00");
boxed["box_padding"] = 10L;
const auto onBox = renderText(blue, 200, 120, boxed);
const auto corner_px = pixel(onBox, 2, 2);
check(corner_px[2] > 200, "the box leaves the canvas outside it alone");
bool greenSomewhere = false;
for (int y = 0; y < 120 && !greenSomewhere; ++y) {
for (int x = 0; x < 200; ++x) {
const auto px = pixel(onBox, x, y);
if (px[1] > 200 && px[0] < 60 && px[2] < 60) greenSomewhere = true;
}
}
check(greenSomewhere, "and is filled with the colour it was given");
check(countRed(onBox, 0, 0, 200, 120) > 0, "the text is drawn over the box");
ParamValues faint = boxed;
faint["box_opacity"] = 0.5;
const auto half = renderText(blue, 200, 120, faint);
bool mixed = false;
for (int y = 0; y < 120 && !mixed; ++y) {
for (int x = 0; x < 200; ++x) {
const auto px = pixel(half, x, y);
// Half the green box over the blue input, and neither of them.
if (px[1] > 100 && px[1] < 200 && px[2] > 100 && px[2] < 200) mixed = true;
}
}
check(mixed, "half a box opacity sits between the box and the input");
const auto dir = std::filesystem::temp_directory_path() / "effector_test_text";
std::filesystem::create_directories(dir);
const auto picture = (dir / "fill.png").string();
solid(40, 40, 255, 255, 0).save(picture);
ParamValues fromPicture = boxed;
fromPicture["text_box"] = std::string("image");
fromPicture["box_image"] = picture;
const auto onPicture = renderText(blue, 200, 120, fromPicture);
bool yellowSomewhere = false;
for (int y = 0; y < 120 && !yellowSomewhere; ++y) {
for (int x = 0; x < 200; ++x) {
const auto px = pixel(onPicture, x, y);
if (px[0] > 200 && px[1] > 200 && px[2] < 60) yellowSomewhere = true;
}
}
check(yellowSomewhere, "a box picture fills the box");
check(pixel(onPicture, 2, 2)[2] > 200, "and stops at its edge");
bool threw = false;
try {
ParamValues missing = fromPicture;
missing["box_image"] = std::string("/nope/missing.png");
renderText(blue, 200, 120, missing);
} catch (const EffectError&) {
threw = true;
}
check(threw, "a missing box picture is reported");
// Nothing to draw is the state the interface starts in: a pass through.
check(sameBytes(renderText(blue, 200, 120, {{"text", std::string("")}}),
solid(200, 120, 0, 0, 255)),
"with no text typed the stage passes its input through");
check(sameBytes(renderText(blue, 200, 120, {{"text", std::string("Hi")},
{"text_opacity", 0.0}}),
solid(200, 120, 0, 0, 255)),
"zero opacity leaves the input untouched");
// Like blur and overlay it needs no sources of its own, and the same
// settings render the same bytes twice over.
check(!TextEffect().needsSources(), "text can lead a chain");
const auto alone = renderText({}, 200, 120, plain);
check(alone.width() == 200 && countRed(alone, 0, 0, 200, 120) > 0,
"without sources it draws on an empty canvas");
check(sameBytes(renderText(blue, 200, 120, boxed), onBox),
"the same settings render the same bytes");
std::filesystem::remove_all(dir);
}
std::cout << "chain\n"; std::cout << "chain\n";
{ {
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)}; std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};