diff --git a/CLAUDE.md b/CLAUDE.md index 68143f4..2e812cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,8 +14,8 @@ Everything is warning-clean under `-Wall -Wextra -Wpedantic`; keep it that way. The test suite is a single hand-rolled binary (no framework), so there is no per-test filter: run `./build/tests/effector_tests` directly to see the section -headings (`rng`, `registry`, `scanlines`, `blur`, `chain`, `project`, `image -io`, `batch loading`) and the `ok`/`FAIL` lines. To iterate on one area, comment +headings (`rng`, `registry`, `scanlines`, `tiles`, `blur`, `chain`, `project`, +`image io`, `batch loading`) and the `ok`/`FAIL` lines. To iterate on one area, comment out the other blocks in `tests/core_tests.cpp` or add a new `std::cout << "section\n"` block — checks are plain `check(condition, "description")` calls and `main()` returns the failure count. @@ -41,10 +41,15 @@ env -u WAYLAND_DISPLAY GDK_BACKEND=x11 DISPLAY=:79 build/effector ... `Effect` (`src/core/effect.hpp`) exposes `params()` returning `std::vector` (`src/core/params.hpp`). Those specs are the *only* thing the GUI and CLI know -about an effect: `src/gui/window.cpp` builds SpinButtons/Switches/DropDowns from -them and `src/cli/options.cpp` generates flags from them (`strip_height` → -`--strip-height`). Never add effect-specific branches to the GUI or the parser — -add a `ParamSpec` instead. Values travel as `ParamValues` (a +about an effect: `src/gui/window.cpp` builds SpinButtons/Switches/DropDowns/file +choosers from them and `src/cli/options.cpp` generates flags from them +(`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 +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 `map>`), read back with `paramInt`/`paramDouble`/`paramBool`/`paramChoice`. @@ -94,7 +99,8 @@ is discarded rather than mixed into the new project (`dropLoadedImages_`). ### Two link targets, deliberately `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 `src/core/` and `src/effects/`; the tests link the core alone and rely on this. @@ -103,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 ``, because libstdc++ distributions are not portable across 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: the scanlines effect first draws the whole strip plan serially from the rng, then copies pixels in parallel. diff --git a/CMakeLists.txt b/CMakeLists.txt index f47c058..72fc0bd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,6 +15,9 @@ set(CMAKE_CXX_FLAGS_RELEASE "-O3 -DNDEBUG") find_package(PkgConfig REQUIRED) pkg_check_modules(GTKMM REQUIRED IMPORTED_TARGET gtkmm-4.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) @@ -30,12 +33,15 @@ add_library(effector_core STATIC src/effects/blur.cpp src/effects/grain.cpp src/effects/monochrome.cpp + src/effects/overlay.cpp src/effects/scanlines.cpp + src/effects/text.cpp + src/effects/tiles.cpp ) target_include_directories(effector_core PUBLIC src) # The core is GUI-free: it only needs gdk-pixbuf for decoding and encoding, # 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) # --- application -------------------------------------------------------------- diff --git a/src/cli/options.cpp b/src/cli/options.cpp index 6b24f41..307ac90 100644 --- a/src/cli/options.cpp +++ b/src/cli/options.cpp @@ -1,6 +1,7 @@ #include "cli/options.hpp" #include +#include #include #include @@ -110,6 +111,20 @@ Options parseOptions(int argc, char** argv) { } break; } + case ParamSpec::Type::Path: + case ParamSpec::Type::Text: + options.params[spec->key] = requireValue(argc, argv, i, arg); + 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: { const std::string text = requireValue(argc, argv, i, arg); bool valid = false; diff --git a/src/core/effect.cpp b/src/core/effect.cpp index 0d5fa62..d6590a0 100644 --- a/src/core/effect.cpp +++ b/src/core/effect.cpp @@ -16,6 +16,9 @@ ParamValues Effect::defaults() const { 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; } diff --git a/src/core/image.cpp b/src/core/image.cpp index 44ee3a0..73321b8 100644 --- a/src/core/image.cpp +++ b/src/core/image.cpp @@ -43,7 +43,7 @@ Image::Image(int width, int height) pixels_(static_cast(std::max(width, 0)) * 3 * static_cast(std::max(height, 0))) {} -Image Image::load(const std::string& path) { +Image Image::load(const std::string& path, std::vector* alphaOut) { GError* error = nullptr; PixbufPtr pixbuf = wrap(gdk_pixbuf_new_from_file(path.c_str(), &error)); if (!pixbuf) throw std::runtime_error(takeError(error)); @@ -59,11 +59,23 @@ Image Image::load(const std::string& path) { const guint8* src = gdk_pixbuf_get_pixels(pixbuf.get()); Image image(width, height); + if (alphaOut != nullptr) { + alphaOut->assign(static_cast(std::max(width, 0)) * + static_cast(std::max(height, 0)), + 255); + } for (int y = 0; y < height; ++y) { const guint8* in = src + static_cast(y) * srcStride; std::uint8_t* out = image.row(y); if (channels == 3) { std::memcpy(out, in, static_cast(width) * 3); + } else if (alphaOut != nullptr) { + // Kept apart rather than flattened: the caller does the compositing. + std::uint8_t* alpha = alphaOut->data() + static_cast(y) * width; + for (int x = 0; x < width; ++x) { + for (int c = 0; c < 3; ++c) out[x * 3 + c] = in[x * channels + c]; + alpha[x] = in[x * channels + 3]; + } } else { // Flatten alpha onto white, matching what a viewer would show. for (int x = 0; x < width; ++x) { diff --git a/src/core/image.hpp b/src/core/image.hpp index 8ed43ea..6885135 100644 --- a/src/core/image.hpp +++ b/src/core/image.hpp @@ -34,7 +34,11 @@ public: // Decodes via GdkPixbuf (PNG, JPEG, WebP, TIFF, BMP, ...), applying the // embedded EXIF orientation. Throws std::runtime_error on failure. - static Image load(const std::string& path); + // Transparency is flattened onto white, as a viewer would show it, unless + // `alphaOut` is given: it then receives one byte per pixel (255 throughout + // for a file without an alpha channel) and the colours are left untouched, + // which is what an effect compositing the image needs. + static Image load(const std::string& path, std::vector* alphaOut = nullptr); // Encodes by file extension; png is used when the extension is unknown. void save(const std::string& path) const; diff --git a/src/core/params.cpp b/src/core/params.cpp index 5e7e5c2..76e2f19 100644 --- a/src/core/params.cpp +++ b/src/core/params.cpp @@ -63,6 +63,37 @@ ParamSpec ParamSpec::makeChoice(std::string key, std::string label, std::string return spec; } +ParamSpec ParamSpec::makePath(std::string key, std::string label, std::string help) { + ParamSpec spec; + spec.type = Type::Path; + spec.key = std::move(key); + spec.label = std::move(label); + spec.help = std::move(help); + 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) { @@ -99,10 +130,61 @@ bool paramBool(const ParamValues& values, const std::string& key, bool fallback) std::string paramChoice(const ParamValues& values, const std::string& key, const std::string& fallback) { + return paramText(values, key, fallback); +} + +std::string paramText(const ParamValues& values, const std::string& key, + const std::string& fallback) { if (const auto* value = find(values, key)) { if (const auto* v = std::get_if(value)) return *v; } 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(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 diff --git a/src/core/params.hpp b/src/core/params.hpp index fa7f526..eba8421 100644 --- a/src/core/params.hpp +++ b/src/core/params.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -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 }; + enum class Type { Int, Double, Bool, Choice, Path, Text, Color }; Type type = Type::Int; std::string key; @@ -35,6 +36,18 @@ struct ParamSpec { std::string help = {}); static ParamSpec makeChoice(std::string key, std::string label, std::string defaultChoice, std::vector choices, std::string help = {}); + // A path to a file on disk, empty by default: a chooser in the interface, a + // 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; @@ -45,5 +58,17 @@ double paramDouble(const ParamValues& values, const std::string& key, double fal bool paramBool(const ParamValues& values, const std::string& key, bool fallback); std::string paramChoice(const ParamValues& values, const std::string& key, const std::string& fallback); +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 diff --git a/src/core/project.cpp b/src/core/project.cpp index 2712ab5..bfc06d7 100644 --- a/src/core/project.cpp +++ b/src/core/project.cpp @@ -1,10 +1,13 @@ #include "core/project.hpp" +#include #include +#include #include #include #include #include +#include #include #include "core/effect.hpp" @@ -24,7 +27,7 @@ struct Json { bool boolean = false; double number = 0.0; bool integral = true; // written without a fraction or exponent - std::string text; + std::string text; // the string, or the digits a number was written as std::vector items; std::vector> members; @@ -226,11 +229,15 @@ private: Json value; value.kind = Json::Kind::Number; value.integral = integral; - try { - value.number = std::stod(text_.substr(start, pos_ - start)); - } catch (const std::exception&) { + // from_chars rather than stod: a number in JSON always has a point for + // a decimal mark, whatever LC_NUMERIC says the user's decimal mark is. + const char* const first = text_.data() + start + (text_[start] == '+' ? 1 : 0); + const char* const last = text_.data() + pos_; + const auto parsed = std::from_chars(first, last, value.number); + if (parsed.ec != std::errc() || parsed.ptr != last) { fail("'" + text_.substr(start, pos_ - start) + "' is not a number"); } + value.text.assign(first, last); // kept whole for the seed, see below return value; } @@ -263,6 +270,7 @@ std::string quote(const std::string& text) { std::string number(double value, int digits) { std::ostringstream out; + out.imbue(std::locale::classic()); out.setf(std::ios::fixed); out.precision(digits); out << value; @@ -307,7 +315,12 @@ std::string resolve(const std::string& path, const std::string& directory) { } // namespace std::string projectToJson(const Project& project, const std::string& directory) { + // JSON is written in the C locale whatever the user's is: a decimal comma + // or a digit grouping would make the file something this reader — or any + // other — cannot read back. gtkmm sets the global locale from the + // environment, so a stream left to its default is the user's locale. std::ostringstream out; + out.imbue(std::locale::classic()); out << "{\n"; out << " \"effector_project\": 1,\n"; out << " \"canvas\": {\"width\": " << project.width << ", \"height\": " << project.height @@ -346,8 +359,22 @@ 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. + const auto path = paramText(stage.values, spec.key, ""); + out << quote(path.empty() ? path : relativeTo(path, directory)); + break; + } } } out << (specs.empty() ? "}" : "\n }") << "\n }"; @@ -372,8 +399,17 @@ Project projectFromJson(const std::string& text, const std::string& directory) { } if (const auto* seed = root.member("seed")) { - if (seed->kind == Json::Kind::Number) { - project.seed = static_cast(seed->number); + // Taken from the digits rather than from the double: a 64-bit seed has + // more bits than a double has mantissa, and a seed that comes back off + // by a few is a project that no longer renders its own picture. + std::uint64_t value = 0; + const char* const first = seed->text.data(); + const char* const last = first + seed->text.size(); + if (seed->kind == Json::Kind::Number && seed->integral && + std::from_chars(first, last, value).ptr == last) { + project.seed = value; + } else if (seed->kind == Json::Kind::Number) { + throw EffectError("the seed must be a whole number that fits in 64 bits"); } } @@ -418,6 +454,24 @@ Project projectFromJson(const std::string& text, const std::string& directory) { 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) { diff --git a/src/core/registry.cpp b/src/core/registry.cpp index efd0358..e107fcf 100644 --- a/src/core/registry.cpp +++ b/src/core/registry.cpp @@ -3,7 +3,10 @@ #include "effects/blur.hpp" #include "effects/grain.hpp" #include "effects/monochrome.hpp" +#include "effects/overlay.hpp" #include "effects/scanlines.hpp" +#include "effects/text.hpp" +#include "effects/tiles.hpp" namespace effector { @@ -14,6 +17,9 @@ const std::vector& effects() { std::make_shared(), std::make_shared(), std::make_shared(), + std::make_shared(), + std::make_shared(), + std::make_shared(), }; return registry; } diff --git a/src/effects/overlay.cpp b/src/effects/overlay.cpp new file mode 100644 index 0000000..4900fb4 --- /dev/null +++ b/src/effects/overlay.cpp @@ -0,0 +1,220 @@ +#include "effects/overlay.hpp" + +#include +#include +#include + +#include "core/parallel.hpp" +#include "effects/source_fill.hpp" + +namespace effector { + +const std::vector& OverlayEffect::params() const { + static const std::vector specs = { + ParamSpec::makePath("overlay_image", "Image", + "The picture laid over the input: a logo, a texture, a frame."), + ParamSpec::makeChoice("overlay_fit", "Fitting", "fit", + {"fit", "fill", "stretch", "original", "tile"}, + "fit keeps the proportions and shows the whole image; fill keeps " + "them and covers the canvas, cropping what does not fit; stretch " + "distorts it to the canvas exactly; original leaves it at its own " + "size; tile repeats it at its own size over the whole canvas."), + ParamSpec::makeDouble("overlay_scale", "Scale", 1.0, 0.05, 8.0, + "Resizes the fitted image (the tile, for tiling) by this factor.", + 0.05), + ParamSpec::makeBool("overlay_center", "Centre", true, + "Places the image in the middle of the canvas rather than at its top " + "left corner; the offset is counted from there either way."), + ParamSpec::makeInt("overlay_offset_x", "Offset X", 0, -20000, 20000, + "Moves the image right by this many pixels (left when negative)."), + ParamSpec::makeInt("overlay_offset_y", "Offset Y", 0, -20000, 20000, + "Moves the image down by this many pixels (up when negative)."), + ParamSpec::makeDouble("overlay_opacity", "Opacity", 1.0, 0.0, 1.0, + "How strongly the image is mixed over the input; its own " + "transparency is taken into account as well."), + }; + return specs; +} + +namespace { + +// Where the overlay lands on the canvas, in canvas pixels. +struct Placement { + int x = 0; + int y = 0; + int width = 1; + int height = 1; +}; + +Placement placeOverlay(const std::string& fit, double scale, bool centre, int offsetX, int offsetY, + int srcWidth, int srcHeight, int width, int height) { + double drawWidth = srcWidth; + double drawHeight = srcHeight; + if (fit == "stretch") { + drawWidth = width; + drawHeight = height; + } else if (fit == "fit" || fit == "fill") { + const double byWidth = static_cast(width) / srcWidth; + const double byHeight = static_cast(height) / srcHeight; + const double factor = fit == "fit" ? std::min(byWidth, byHeight) : std::max(byWidth, byHeight); + drawWidth = srcWidth * factor; + drawHeight = srcHeight * factor; + } + // "original" and "tile" keep the file's own size; the scale below applies + // to every mode, so a fitted image can still be nudged a little larger. + + Placement placement; + placement.width = std::max(1, static_cast(std::lround(drawWidth * scale))); + placement.height = std::max(1, static_cast(std::lround(drawHeight * scale))); + placement.x = centre ? (width - placement.width) / 2 : 0; + placement.y = centre ? (height - placement.height) / 2 : 0; + placement.x += offsetX; + placement.y += offsetY; + return placement; +} + +int wrapIndex(int value, int limit) { + const int wrapped = value % limit; + return wrapped < 0 ? wrapped + limit : wrapped; +} + +// Bilinear sample of the overlay at a point in its own pixel coordinates, +// clamped at the edges or wrapped around when the image is being tiled. +void sampleOverlay(const Image& src, const std::uint8_t* alpha, double u, double v, bool wrap, + int rgb[3], int& outAlpha) { + const int w = src.width(); + const int h = src.height(); + const double fu = std::floor(u); + const double fv = std::floor(v); + const int u0 = static_cast(fu); + const int v0 = static_cast(fv); + const double du = u - fu; + const double dv = v - fv; + + int xs[2]; + int ys[2]; + for (int i = 0; i < 2; ++i) { + xs[i] = wrap ? wrapIndex(u0 + i, w) : std::clamp(u0 + i, 0, w - 1); + ys[i] = wrap ? wrapIndex(v0 + i, h) : std::clamp(v0 + i, 0, h - 1); + } + + double accum[3] = {0.0, 0.0, 0.0}; + double accumAlpha = 0.0; + for (int j = 0; j < 2; ++j) { + const double wy = j == 0 ? 1.0 - dv : dv; + const std::uint8_t* row = src.row(ys[j]); + const std::uint8_t* alphaRow = alpha + static_cast(ys[j]) * w; + for (int i = 0; i < 2; ++i) { + const double weight = wy * (i == 0 ? 1.0 - du : du); + const std::uint8_t* px = row + static_cast(xs[i]) * 3; + const double alphaWeight = weight * alphaRow[xs[i]]; + // Colour weighted by alpha: a transparent pixel in a PNG is usually + // black, and averaging it in unweighted draws a dark fringe around + // everything the image cuts out. + for (int c = 0; c < 3; ++c) accum[c] += px[c] * alphaWeight; + accumAlpha += alphaWeight; + } + } + outAlpha = std::clamp(static_cast(accumAlpha + 0.5), 0, 255); + for (int c = 0; c < 3; ++c) { + rgb[c] = accumAlpha <= 0.0 ? 0 + : std::clamp(static_cast(accum[c] / accumAlpha + 0.5), 0, + 255); + } +} + +} // namespace + +void OverlayEffect::loadOverlay(const std::string& path, Image& image, + std::vector& alpha) const { + std::lock_guard lock(cacheMutex_); + if (cachedPath_ != path || cached_.empty()) { + std::vector loadedAlpha; + Image loaded; + try { + loaded = Image::load(path, &loadedAlpha); + } catch (const std::exception& error) { + throw EffectError("Cannot read the overlay image '" + path + "': " + error.what()); + } + if (loaded.empty()) throw EffectError("The overlay image '" + path + "' is empty."); + cached_ = std::move(loaded); + cachedAlpha_ = std::move(loadedAlpha); + cachedPath_ = path; + } + image = cached_; + alpha = cachedAlpha_; +} + +Image OverlayEffect::render(const std::vector& 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(rng.inRange(0, std::max(0, src.width() - width))); + const int sy = static_cast(rng.inRange(0, std::max(0, src.height() - height))); + fillFromSource(canvas, src, sx, sy); + } + + const std::string path = paramText(values, "overlay_image", ""); + const double opacity = std::clamp(paramDouble(values, "overlay_opacity", 1.0), 0.0, 1.0); + // No image chosen yet is the state the interface starts in, not an error: + // the stage simply passes its input through. + if (path.empty() || opacity <= 0.0) return canvas; + + Image overlay; + std::vector alpha; + loadOverlay(path, overlay, alpha); + + const std::string fit = paramChoice(values, "overlay_fit", "fit"); + const Placement place = placeOverlay( + fit, std::clamp(paramDouble(values, "overlay_scale", 1.0), 0.05, 8.0), + paramBool(values, "overlay_center", true), + static_cast(paramInt(values, "overlay_offset_x", 0)), + static_cast(paramInt(values, "overlay_offset_y", 0)), overlay.width(), + overlay.height(), width, height); + + const bool tiling = fit == "tile"; + // Source pixels per canvas pixel, the same both ways for every mode but + // stretch, where the two axes are scaled apart on purpose. + const double stepX = static_cast(overlay.width()) / place.width; + const double stepY = static_cast(overlay.height()) / place.height; + + const int firstRow = tiling ? 0 : std::max(0, place.y); + const int lastRow = tiling ? height : std::min(height, place.y + place.height); + const int firstCol = tiling ? 0 : std::max(0, place.x); + const int lastCol = tiling ? width : std::min(width, place.x + place.width); + if (firstRow >= lastRow || firstCol >= lastCol) return canvas; // entirely off the canvas + + const int mix = static_cast(opacity * 255.0 + 0.5); + const std::uint8_t* alphaData = alpha.data(); + + parallelFor(static_cast(lastRow - firstRow), [&](std::size_t begin, + std::size_t end) { + int rgb[3]; + int pixelAlpha = 255; + for (std::size_t i = begin; i < end; ++i) { + const int y = firstRow + static_cast(i); + const double v = (y - place.y + 0.5) * stepY - 0.5; + std::uint8_t* out = canvas.row(y); + for (int x = firstCol; x < lastCol; ++x) { + const double u = (x - place.x + 0.5) * stepX - 0.5; + sampleOverlay(overlay, alphaData, u, v, tiling, rgb, pixelAlpha); + + const int weight = pixelAlpha * mix / 255; + if (weight == 0) continue; + std::uint8_t* px = out + static_cast(x) * 3; + for (int c = 0; c < 3; ++c) { + px[c] = static_cast(px[c] + (rgb[c] - px[c]) * weight / 255); + } + } + } + }); + + return canvas; +} + +} // namespace effector diff --git a/src/effects/overlay.hpp b/src/effects/overlay.hpp new file mode 100644 index 0000000..42caad6 --- /dev/null +++ b/src/effects/overlay.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include + +#include "core/effect.hpp" + +namespace effector { + +// Lays one picture from disk over its input: a logo, a texture, a frame. The +// overlay is fitted to the canvas in the chosen way, placed from the top left +// or from the centre, nudged by an offset and mixed in by its opacity. Like the +// blur 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. +class OverlayEffect : public Effect { +public: + std::string name() const override { return "overlay"; } + std::string title() const override { return "Image overlay"; } + std::string description() const override { + return "Lays a single image from disk over its input, fitted to the canvas, centred or " + "not, nudged by an offset and mixed in by its opacity."; + } + const std::vector& params() const override; + bool needsSources() const override { return false; } + + Image render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const override; + +private: + // The same file 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. + // Returns the decoded file and its alpha, reading it only when the path + // differs from the one already held. + void loadOverlay(const std::string& path, Image& image, + std::vector& alpha) const; + + mutable std::mutex cacheMutex_; + mutable std::string cachedPath_; + mutable Image cached_; + mutable std::vector cachedAlpha_; +}; + +} // namespace effector diff --git a/src/effects/text.cpp b/src/effects/text.cpp new file mode 100644 index 0000000..fe772b7 --- /dev/null +++ b/src/effects/text.cpp @@ -0,0 +1,306 @@ +#include "effects/text.hpp" + +#include + +#include +#include +#include + +#include "core/parallel.hpp" +#include "effects/source_fill.hpp" + +namespace effector { + +const std::vector& TextEffect::params() const { + static const std::vector 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& 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(image.width()) * + static_cast(image.height()); + + for (int y = 0; y < image.height(); ++y) { + const std::uint8_t* in = image.row(y); + auto* out = reinterpret_cast(data + static_cast(y) * stride); + for (int x = 0; x < image.width(); ++x) { + const std::uint32_t a = + hasAlpha ? alpha[static_cast(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(surface)); + const unsigned char* data = + cairo_image_surface_get_data(const_cast(surface)); + const int width = canvas.width(); + + parallelFor(static_cast(canvas.height()), [&](std::size_t begin, std::size_t end) { + for (std::size_t y = begin; y < end; ++y) { + const auto* in = + reinterpret_cast(data + static_cast(y) * stride); + std::uint8_t* out = canvas.row(static_cast(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(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::min(value, 255)); + } + } + } + }); +} + +} // namespace + +void TextEffect::loadBoxImage(const std::string& path, Image& image, + std::vector& alpha) const { + std::lock_guard lock(cacheMutex_); + if (cachedPath_ != path || cached_.empty()) { + std::vector 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& 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(rng.inRange(0, std::max(0, src.width() - width))); + const int sy = static_cast(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(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(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(paramInt(values, "text_offset_x", 0)); + const double boxY = (centre ? (height - boxHeight) / 2.0 : 0.0) + + static_cast(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 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(boxWidth) / picture.width(), + static_cast(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 diff --git a/src/effects/text.hpp b/src/effects/text.hpp new file mode 100644 index 0000000..1d5cc26 --- /dev/null +++ b/src/effects/text.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include +#include + +#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& params() const override; + bool needsSources() const override { return false; } + + Image render(const std::vector& 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& alpha) const; + + mutable std::mutex cacheMutex_; + mutable std::string cachedPath_; + mutable Image cached_; + mutable std::vector cachedAlpha_; +}; + +} // namespace effector diff --git a/src/effects/tiles.cpp b/src/effects/tiles.cpp new file mode 100644 index 0000000..d315bb1 --- /dev/null +++ b/src/effects/tiles.cpp @@ -0,0 +1,180 @@ +#include "effects/tiles.hpp" + +#include +#include +#include + +#include "core/parallel.hpp" +#include "effects/source_fill.hpp" + +namespace effector { + +const std::vector& TilesEffect::params() const { + static const std::vector specs = { + ParamSpec::makeChoice("tile_layout", "Layout", "grid", {"grid", "brick", "scatter"}, + "grid lines the tiles up in rows and columns; brick packs every row " + "on its own so the seams do not meet; scatter drops loose tiles over " + "a filled background, overlapping."), + ParamSpec::makeInt("tile_width", "Tile width", 96, 1, 2048, + "Width in pixels of each cut-out tile."), + ParamSpec::makeInt("tile_height", "Tile height", 96, 1, 2048, + "Height in pixels of each cut-out tile."), + ParamSpec::makeInt("tile_jitter", "Size jitter", 0, 0, 512, + "Random +/- variation added to both tile sizes (0 = uniform)."), + ParamSpec::makeInt("tile_count", "Tile count", 64, 1, 4096, + "How many tiles are dropped on the canvas; scattered layout only."), + ParamSpec::makeBool("tile_keep_order", "Cycle images in order", false, + "Take tiles from the images in order instead of picking randomly."), + }; + return specs; +} + +namespace { + +// One cut-out: `w` by `h` pixels taken from source `sourceIndex` at (sx, sy) +// and laid down at (dstX, dstY). +struct Tile { + int dstX; + int dstY; + int w; + int h; + int sourceIndex; + int sx; + int sy; +}; + +// Splits `span` into consecutive pieces of `base` pixels give or take `jitter`, +// the last one clipped so that the pieces cover the span exactly. +std::vector planSpans(Rng& rng, int base, int jitter, int span) { + std::vector spans; + spans.reserve(static_cast(span / base) + 8); + int used = 0; + while (used < span) { + int size = base; + if (jitter > 0) size += static_cast(rng.inRange(-jitter, jitter)); + size = std::max(1, std::min(size, span - used)); + spans.push_back(size); + used += size; + } + return spans; +} + +void paintTile(Image& canvas, const Image& src, const Tile& tile) { + const int copyWidth = std::min(tile.w, src.width() - tile.sx); + const int copyHeight = std::min(tile.h, src.height() - tile.sy); + const std::size_t copyBytes = static_cast(copyWidth) * 3; + const std::size_t total = static_cast(tile.w) * 3; + const std::size_t offset = static_cast(tile.dstX) * 3; + + for (int line = 0; line < copyHeight; ++line) { + const std::uint8_t* in = src.row(tile.sy + line) + static_cast(tile.sx) * 3; + std::uint8_t* out = canvas.row(tile.dstY + line) + offset; + + std::memcpy(out, in, copyBytes); + // Repeat the cut sideways when the source is narrower than the tile, + // doubling the filled span on each pass. + std::size_t filled = copyBytes; + while (filled < total) { + const std::size_t chunk = std::min(filled, total - filled); + std::memcpy(out + filled, out, chunk); + filled += chunk; + } + } + // Same again downwards, out of the lines already written. + for (int line = copyHeight; line < tile.h; ++line) { + std::memcpy(canvas.row(tile.dstY + line) + offset, + canvas.row(tile.dstY + (line % copyHeight)) + offset, total); + } +} + +} // namespace + +Image TilesEffect::render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const { + if (sources.empty()) throw EffectError("No source images."); + if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels."); + + const std::string layout = paramChoice(values, "tile_layout", "grid"); + const int tileWidth = static_cast(std::max(1L, paramInt(values, "tile_width", 96))); + const int tileHeight = static_cast(std::max(1L, paramInt(values, "tile_height", 96))); + const int jitter = static_cast(std::max(0L, paramInt(values, "tile_jitter", 0))); + const long count = std::max(1L, paramInt(values, "tile_count", 64)); + const bool keepOrder = paramBool(values, "tile_keep_order", false); + + Image canvas(width, height); + + // The whole plan is drawn sequentially so that a seed always gives the same + // layout; only the pixel copying afterwards is parallelised. + std::size_t index = 0; + auto cut = [&](int dstX, int dstY, int w, int h) { + const int sourceIndex = keepOrder ? static_cast(index++ % sources.size()) + : static_cast(rng.below(sources.size() - 1)); + const Image& src = sources[static_cast(sourceIndex)]; + const int sx = static_cast(rng.inRange(0, std::max(0, src.width() - w))); + const int sy = static_cast(rng.inRange(0, std::max(0, src.height() - h))); + return Tile{dstX, dstY, w, h, sourceIndex, sx, sy}; + }; + + std::vector plan; + if (layout == "scatter") { + // Loose tiles leave gaps, so the canvas starts as one of the images + // rather than as black. + const std::size_t backdrop = rng.below(sources.size() - 1); + const Image& src = sources[backdrop]; + const int sx = static_cast(rng.inRange(0, std::max(0, src.width() - width))); + const int sy = static_cast(rng.inRange(0, std::max(0, src.height() - height))); + fillFromSource(canvas, src, sx, sy); + + plan.reserve(static_cast(count)); + for (long i = 0; i < count; ++i) { + int w = tileWidth; + int h = tileHeight; + if (jitter > 0) { + w += static_cast(rng.inRange(-jitter, jitter)); + h += static_cast(rng.inRange(-jitter, jitter)); + } + const int dstX = static_cast(rng.inRange(0, width - 1)); + const int dstY = static_cast(rng.inRange(0, height - 1)); + w = std::max(1, std::min(w, width - dstX)); + h = std::max(1, std::min(h, height - dstY)); + plan.push_back(cut(dstX, dstY, w, h)); + } + } else { + const std::vector rows = planSpans(rng, tileHeight, jitter, height); + // In a grid every row is cut at the same columns; in the brick layout + // each row is packed on its own, so the vertical seams do not line up. + std::vector columns; + if (layout != "brick") columns = planSpans(rng, tileWidth, jitter, width); + + plan.reserve(rows.size() * (columns.empty() ? 8 : columns.size())); + int y = 0; + for (const int rowHeight : rows) { + if (layout == "brick") columns = planSpans(rng, tileWidth, jitter, width); + int x = 0; + for (const int cellWidth : columns) { + plan.push_back(cut(x, y, cellWidth, rowHeight)); + x += cellWidth; + } + y += rowHeight; + } + } + + if (layout == "scatter") { + // Scattered tiles overlap, so they are painted in plan order on one + // thread: letting threads race for the same pixels would make the + // output depend on their scheduling. + for (const Tile& tile : plan) { + paintTile(canvas, sources[static_cast(tile.sourceIndex)], tile); + } + } else { + parallelFor(plan.size(), [&](std::size_t begin, std::size_t end) { + for (std::size_t i = begin; i < end; ++i) { + paintTile(canvas, sources[static_cast(plan[i].sourceIndex)], plan[i]); + } + }); + } + + return canvas; +} + +} // namespace effector diff --git a/src/effects/tiles.hpp b/src/effects/tiles.hpp new file mode 100644 index 0000000..e0579ae --- /dev/null +++ b/src/effects/tiles.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include "core/effect.hpp" + +namespace effector { + +// Covers the canvas with rectangular tiles cut at random positions from the +// source images: the two-dimensional counterpart of the scanlines effect. +class TilesEffect : public Effect { +public: + std::string name() const override { return "tiles"; } + std::string title() const override { return "Tiles"; } + std::string description() const override { + return "Cuts rectangular tiles at random positions from the source images and lays " + "them over the canvas, in a grid, in offset rows or scattered about."; + } + const std::vector& params() const override; + + Image render(const std::vector& sources, int width, int height, Rng& rng, + const ParamValues& values) const override; +}; + +} // namespace effector diff --git a/src/gui/window.cpp b/src/gui/window.cpp index c46a393..377487f 100644 --- a/src/gui/window.cpp +++ b/src/gui/window.cpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include @@ -293,6 +295,56 @@ void EffectorWindow::buildParamWidgets() { widget = std::move(spin); break; } + case ParamSpec::Type::Path: { + // An entry holding the path, with a chooser beside it; the + // entry is what paramValues() reads back. + auto box = std::make_unique(Gtk::Orientation::HORIZONTAL, 6); + auto* entry = Gtk::make_managed(); + entry->set_hexpand(true); + entry->set_text(paramText(stage.values, spec.key, "")); + entry->set_placeholder_text("No image chosen"); + entry->signal_activate().connect( + sigc::mem_fun(*this, &EffectorWindow::onParamChanged)); + auto* choose = Gtk::make_managed("Choose…"); + const int index = selectedStage_; + const std::string key = spec.key; + const std::string title = spec.label; + choose->signal_clicked().connect( + [this, index, key, title] { onChoosePath(index, key, title); }); + box->append(*entry); + box->append(*choose); + paramWidgets_.push_back({spec, entry}); + widget = std::move(box); + break; + } + case ParamSpec::Type::Text: { + auto entry = std::make_unique(); + 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::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: { std::vector choices(spec.choices.begin(), spec.choices.end()); auto dropdown = std::make_unique(); @@ -311,7 +363,9 @@ void EffectorWindow::buildParamWidgets() { widget->set_hexpand(true); paramGrid_.attach(*label, 0, row); paramGrid_.attach(*widget, 1, row); - paramWidgets_.push_back({spec, widget.get()}); + // A path attaches a box but reads back from the entry inside it, which + // it has registered itself. + if (spec.type != ParamSpec::Type::Path) paramWidgets_.push_back({spec, widget.get()}); ownedWidgets_.push_back(std::move(label)); ownedWidgets_.push_back(std::move(widget)); ++row; @@ -333,6 +387,20 @@ ParamValues EffectorWindow::paramValues() const { case ParamSpec::Type::Double: values[entry.spec.key] = static_cast(entry.widget)->get_value(); break; + case ParamSpec::Type::Path: + case ParamSpec::Type::Text: + values[entry.spec.key] = + std::string(static_cast(entry.widget)->get_text()); + break; + case ParamSpec::Type::Color: { + const auto colour = static_cast(entry.widget)->get_rgba(); + const std::uint8_t rgb[3] = { + static_cast(std::lround(colour.get_red() * 255.0)), + static_cast(std::lround(colour.get_green() * 255.0)), + static_cast(std::lround(colour.get_blue() * 255.0))}; + values[entry.spec.key] = formatColor(rgb); + break; + } case ParamSpec::Type::Choice: { const auto index = static_cast(entry.widget)->get_selected(); if (index < entry.spec.choices.size()) values[entry.spec.key] = entry.spec.choices[index]; @@ -809,6 +877,36 @@ void EffectorWindow::onParamChanged() { requestRender(); } +void EffectorWindow::onChoosePath(int stage, const std::string& key, const std::string& label) { + auto dialog = Gtk::FileDialog::create(); + dialog->set_title("Select the " + label); + + auto filter = Gtk::FileFilter::create(); + filter->set_name("Images"); + for (const char* pattern : kImagePatterns) filter->add_pattern(pattern); + auto filters = Gio::ListStore::create(); + filters->append(filter); + dialog->set_filters(filters); + dialog->set_default_filter(filter); + + dialog->open(*this, [this, dialog, stage, key](const Glib::RefPtr& result) { + std::string path; + try { + const auto file = dialog->open_finish(result); + if (file) path = file->get_path(); + } catch (const Glib::Error&) { + return; // cancelled + } + if (path.empty() || stage < 0 || static_cast(stage) >= stages_.size()) return; + // The widgets may be showing another stage by now: store what they hold + // first, then write the path into the stage that asked for it. + storeParams(); + stages_[static_cast(stage)].values[key] = path; + if (stage == selectedStage_) buildParamWidgets(); + requestRender(); + }); +} + void EffectorWindow::onSeedEntered() { try { seed_ = std::stoull(seedEntry_.get_text()); diff --git a/src/gui/window.hpp b/src/gui/window.hpp index 99731e5..d57d6c4 100644 --- a/src/gui/window.hpp +++ b/src/gui/window.hpp @@ -75,6 +75,10 @@ private: void updateTitle(); void onParamChanged(); + // The chooser behind a Path param. The chosen file goes straight into the + // stage's values rather than into the widget, which may well have been + // rebuilt by the time the dialog comes back. + void onChoosePath(int stage, const std::string& key, const std::string& label); void onSeedEntered(); void onRecreate(); void onSave(); diff --git a/tests/core_tests.cpp b/tests/core_tests.cpp index ea4ed87..da83af1 100644 --- a/tests/core_tests.cpp +++ b/tests/core_tests.cpp @@ -1,11 +1,14 @@ // Small self-contained checks for the pieces that have no GUI dependency. +#include #include +#include #include #include #include #include #include +#include #include #include #include @@ -20,7 +23,10 @@ #include "effects/blur.hpp" #include "effects/grain.hpp" #include "effects/monochrome.hpp" +#include "effects/overlay.hpp" #include "effects/scanlines.hpp" +#include "effects/text.hpp" +#include "effects/tiles.hpp" namespace { @@ -116,6 +122,42 @@ effector::Image renderMono(const std::vector& sources, int widt return effect.render(sources, width, height, rng, merged); } +effector::Image renderOverlay(const std::vector& sources, int width, int height, + const effector::ParamValues& values) { + effector::OverlayEffect 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); +} + +effector::Image renderText(const std::vector& 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); +} + +effector::Image renderTiles(const std::vector& sources, int width, int height, + std::uint64_t seed, const effector::ParamValues& values) { + effector::TilesEffect effect; + effector::Rng rng(seed); + 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. +std::array pixel(const effector::Image& image, int x, int y) { + const auto* px = image.row(y) + static_cast(x) * 3; + return {px[0], px[1], px[2]}; +} + +bool isRed(const std::array& px) { return px[0] > 200 && px[1] < 60 && px[2] < 60; } +bool isBlue(const std::array& px) { return px[2] > 200 && px[0] < 60 && px[1] < 60; } + // A flat field of one colour, to read the channel weighting off directly. effector::Image solid(int width, int height, std::uint8_t r, std::uint8_t g, std::uint8_t b) { effector::Image image(width, height); @@ -221,6 +263,81 @@ int main() { check(threw, "no sources throws"); } + std::cout << "tiles\n"; + { + std::vector sources = {gradient(1200, 800, 10), gradient(640, 480, 90), + gradient(300, 200, 200)}; + + for (const std::string layout : {"grid", "brick", "scatter"}) { + const ParamValues values = {{"tile_layout", layout}, + {"tile_width", 70L}, + {"tile_height", 50L}, + {"tile_jitter", 12L}}; + const auto first = renderTiles(sources, 400, 300, 1, values); + const auto again = renderTiles(sources, 400, 300, 1, values); + const auto other = renderTiles(sources, 400, 300, 2, values); + + check(first.width() == 400 && first.height() == 300, layout + " has the requested size"); + check(sameBytes(first, again), layout + ": same seed reproduces the image"); + check(!sameBytes(first, other), layout + ": a different seed changes the image"); + } + + // With no jitter the grid cells line up: the gradient's red channel + // counts up with x inside a tile and only breaks at a cell boundary. + const auto aligned = renderTiles(sources, 400, 300, 5, + {{"tile_layout", std::string("grid")}, {"tile_width", 100L}, + {"tile_height", 100L}}); + bool onlyAtBoundaries = true; + for (int y = 0; y < aligned.height(); ++y) { + const auto* row = aligned.row(y); + for (int x = 1; x < aligned.width(); ++x) { + const bool breaks = row[x * 3] != ((row[(x - 1) * 3] + 1) & 0xFF); + if (breaks && x % 100 != 0) onlyAtBoundaries = false; + } + } + check(onlyAtBoundaries, "grid cells break only on the cell boundaries"); + + // One source smaller than a single tile: the cut is repeated to fill + // the cell, so the canvas is covered rather than left black. + std::vector small = {gradient(60, 40, 200)}; + const auto covered = renderTiles(small, 500, 400, 9, + {{"tile_layout", std::string("brick")}, + {"tile_width", 200L}, + {"tile_height", 150L}}); + bool filled = true; + for (int y = 0; y < covered.height(); ++y) { + const auto* row = covered.row(y); + for (int x = 0; x < covered.width(); ++x) filled = filled && row[x * 3 + 2] == 200; + } + check(filled, "a source smaller than a tile still covers the canvas"); + + const auto tiny = renderTiles(sources, 120, 90, 3, + {{"tile_width", 1L}, {"tile_height", 1L}}); + check(tiny.width() == 120 && tiny.height() == 90, "tiles one pixel across"); + + const auto ordered = renderTiles(sources, 300, 200, 4, + {{"tile_width", 40L}, {"tile_keep_order", true}}); + check(ordered.height() == 200, "keep_order fills the canvas"); + + // A single scattered tile leaves the rest of the canvas to the backdrop. + const auto sparse = renderTiles(small, 400, 300, 6, + {{"tile_layout", std::string("scatter")}, {"tile_count", 1L}}); + bool backdrop = true; + for (int y = 0; y < sparse.height(); ++y) { + const auto* row = sparse.row(y); + for (int x = 0; x < sparse.width(); ++x) backdrop = backdrop && row[x * 3 + 2] == 200; + } + check(backdrop, "scattered tiles land on a filled backdrop"); + + bool threw = false; + try { + renderTiles({}, 100, 100, 1, {}); + } catch (const EffectError&) { + threw = true; + } + check(threw, "no sources throws"); + } + std::cout << "blur\n"; { std::vector sources = {gradient(1200, 800, 10), gradient(640, 480, 90)}; @@ -404,6 +521,293 @@ int main() { check(allBlack, "without sources monochrome renders an empty canvas"); } + std::cout << "overlay\n"; + { + const auto dir = std::filesystem::temp_directory_path() / "effector_test_overlay"; + std::filesystem::create_directories(dir); + const auto logo = (dir / "logo.png").string(); + solid(40, 20, 255, 0, 0).save(logo); // wider than it is tall, to tell the fittings apart + + const std::vector blue = {solid(100, 100, 0, 0, 255)}; + const ParamValues at = {{"overlay_image", logo}, {"overlay_fit", std::string("original")}}; + + const auto centred = renderOverlay(blue, 100, 100, at); + check(isRed(pixel(centred, 50, 50)), "the overlay lands in the middle of the canvas"); + check(isBlue(pixel(centred, 2, 2)), "and leaves the rest of the input alone"); + check(isRed(pixel(centred, 30, 40)) && isBlue(pixel(centred, 29, 40)), + "centred, a 40x20 image starts 30 pixels in"); + + ParamValues corner = at; + corner["overlay_center"] = false; + const auto topLeft = renderOverlay(blue, 100, 100, corner); + check(isRed(pixel(topLeft, 0, 0)) && isBlue(pixel(topLeft, 41, 0)), + "without centring it sits in the top left corner"); + + ParamValues nudged = corner; + nudged["overlay_offset_x"] = 10L; + nudged["overlay_offset_y"] = 5L; + const auto moved = renderOverlay(blue, 100, 100, nudged); + check(isRed(pixel(moved, 10, 5)) && isBlue(pixel(moved, 9, 5)) && isBlue(pixel(moved, 10, 4)), + "the offset moves it right and down"); + + ParamValues back = at; + back["overlay_offset_x"] = -25L; + const auto half = renderOverlay(blue, 100, 100, back); + check(isRed(pixel(half, 5, 50)) && isBlue(pixel(half, 96, 50)), + "a negative offset moves it off the left edge, and it is clipped"); + + // Fitting: fit shows the whole image and keeps its proportions, so a + // 40x20 logo becomes 100x50 with a band of input above and below; fill + // and stretch and tile all cover the canvas. + ParamValues fitted = at; + fitted["overlay_fit"] = std::string("fit"); + const auto fit = renderOverlay(blue, 100, 100, fitted); + check(isBlue(pixel(fit, 50, 10)) && isRed(pixel(fit, 50, 50)) && isRed(pixel(fit, 1, 50)), + "fit shows the whole image, in its own proportions"); + + for (const char* mode : {"fill", "stretch", "tile"}) { + ParamValues covering = at; + covering["overlay_fit"] = std::string(mode); + const auto image = renderOverlay(blue, 100, 100, covering); + bool everywhere = true; + for (int y = 0; y < 100; y += 7) { + for (int x = 0; x < 100; x += 7) everywhere = everywhere && isRed(pixel(image, x, y)); + } + check(everywhere, std::string(mode) + " covers the whole canvas"); + } + + // Scale resizes what the fitting worked out: half of 100x50 is 50x25. + ParamValues scaled = fitted; + scaled["overlay_scale"] = 0.5; + const auto smaller = renderOverlay(blue, 100, 100, scaled); + check(isRed(pixel(smaller, 50, 50)) && isBlue(pixel(smaller, 20, 50)), + "the scale shrinks the fitted image about its centre"); + + // Opacity mixes over the input rather than replacing it. + ParamValues faint = at; + faint["overlay_opacity"] = 0.5; + const auto mixed = pixel(renderOverlay(blue, 100, 100, faint), 50, 50); + check(mixed[0] > 100 && mixed[0] < 155 && mixed[2] > 100 && mixed[2] < 155, + "half opacity sits between the overlay and the input"); + check(sameBytes(renderOverlay(blue, 100, 100, {{"overlay_image", logo}, + {"overlay_opacity", 0.0}}), + solid(100, 100, 0, 0, 255)), + "zero opacity leaves the input untouched"); + + // No image chosen is the state the interface starts in: a pass through. + check(sameBytes(renderOverlay(blue, 100, 100, {}), solid(100, 100, 0, 0, 255)), + "with no image chosen the stage passes its input through"); + + // Like blur and monochrome it needs no sources of its own. + check(!OverlayEffect().needsSources(), "the overlay can lead a chain"); + const auto alone = renderOverlay({}, 60, 40, {{"overlay_image", logo}, + {"overlay_fit", std::string("stretch")}}); + check(alone.width() == 60 && isRed(pixel(alone, 30, 20)), + "without sources it draws on an empty canvas"); + + bool threw = false; + try { + renderOverlay(blue, 100, 100, {{"overlay_image", std::string("/nope/missing.png")}}); + } catch (const EffectError&) { + threw = true; + } + check(threw, "a missing overlay image is reported"); + + // The path travels with the project, written relative to its folder. + Project project; + project.width = 100; + project.height = 100; + project.stages.push_back({effectByName("overlay"), {{"overlay_image", logo}}}); + const auto json = projectToJson(project, dir.string()); + check(json.find("\"logo.png\"") != std::string::npos, + "an overlay image inside the project folder is written relative"); + const auto reread = projectFromJson(json, dir.string()); + check(paramText(reread.stages[0].values, "overlay_image", "") == logo, + "and comes back as the same file"); + + 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(argv)); + } catch (const EffectError&) { + threw = true; + } + check(threw, "an unparseable colour on the command line is reported"); + } + + std::cout << "text\n"; + { + const std::vector 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::vector sources = {gradient(1200, 800, 10), gradient(640, 480, 90)}; @@ -544,6 +948,51 @@ int main() { } check(threw, "a missing project file throws"); + // A project is JSON whatever the user's locale is. gtkmm sets both the + // C and the C++ global locale from the environment, so on a German + // desktop an unguarded stream writes "0,35" and a grouped "1.920" — + // which is not JSON, and the project could not be opened again. + const char* const commaLocales[] = {"de_DE.UTF-8", "de_DE.utf8", "fr_FR.UTF-8", + "fr_FR.utf8"}; + bool decimalCommaTried = false; + for (const char* name : commaLocales) { + if (std::setlocale(LC_ALL, name) == nullptr) continue; + std::locale previous; + try { + previous = std::locale::global(std::locale(name)); + } catch (const std::exception&) { + continue; + } + decimalCommaTried = true; + + Project wide; + wide.width = 1920; + wide.height = 1080; + wide.seed = 17353667313003709502ULL; // more bits than a double holds + wide.stages.push_back({effectByName("grain"), {{"grain_amount", 0.35}}}); + + const auto text = projectToJson(wide); + bool reread = false; + try { + const auto back = projectFromJson(text); + reread = back.width == 1920 && back.height == 1080 && + back.seed == wide.seed && + paramDouble(back.stages[0].values, "grain_amount", -1.0) == 0.35; + } catch (const EffectError&) { + } + check(text.find("0.35") != std::string::npos && + text.find("\"width\": 1920") != std::string::npos, + std::string("a project is written as JSON under ") + name); + check(reread, std::string("a project written under ") + name + " opens again"); + + std::locale::global(previous); + std::setlocale(LC_ALL, "C"); + break; + } + if (!decimalCommaTried) { + std::cout << " skip a decimal-comma locale (none installed)\n"; + } + std::filesystem::remove_all(dir); }