Merge branch 'image-overlay-effect'
This commit is contained in:
@@ -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
|
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
|
per-test filter: run `./build/tests/effector_tests` directly to see the section
|
||||||
headings (`rng`, `registry`, `scanlines`, `blur`, `chain`, `project`, `image
|
headings (`rng`, `registry`, `scanlines`, `tiles`, `blur`, `chain`, `project`,
|
||||||
io`, `batch loading`) and the `ok`/`FAIL` lines. To iterate on one area, comment
|
`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
|
out the other blocks in `tests/core_tests.cpp` or add a new
|
||||||
`std::cout << "section\n"` block — checks are plain
|
`std::cout << "section\n"` block — checks are plain
|
||||||
`check(condition, "description")` calls and `main()` returns the failure count.
|
`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<ParamSpec>`
|
`Effect` (`src/core/effect.hpp`) exposes `params()` returning `std::vector<ParamSpec>`
|
||||||
(`src/core/params.hpp`). Those specs are the *only* thing the GUI and CLI know
|
(`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
|
about an effect: `src/gui/window.cpp` builds SpinButtons/Switches/DropDowns/file
|
||||||
them and `src/cli/options.cpp` generates flags from them (`strip_height` →
|
choosers from them and `src/cli/options.cpp` generates flags from them
|
||||||
`--strip-height`). Never add effect-specific branches to the GUI or the parser —
|
(`strip_height` → `--strip-height`). Never add effect-specific branches to the
|
||||||
add a `ParamSpec` instead. Values travel as `ParamValues` (a
|
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<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`.
|
||||||
|
|
||||||
@@ -94,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.
|
||||||
|
|
||||||
@@ -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
|
`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.
|
||||||
|
|||||||
+7
-1
@@ -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)
|
||||||
|
|
||||||
@@ -30,12 +33,15 @@ add_library(effector_core STATIC
|
|||||||
src/effects/blur.cpp
|
src/effects/blur.cpp
|
||||||
src/effects/grain.cpp
|
src/effects/grain.cpp
|
||||||
src/effects/monochrome.cpp
|
src/effects/monochrome.cpp
|
||||||
|
src/effects/overlay.cpp
|
||||||
src/effects/scanlines.cpp
|
src/effects/scanlines.cpp
|
||||||
|
src/effects/text.cpp
|
||||||
|
src/effects/tiles.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 --------------------------------------------------------------
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -110,6 +111,20 @@ Options parseOptions(int argc, char** argv) {
|
|||||||
}
|
}
|
||||||
break;
|
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: {
|
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;
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ ParamValues Effect::defaults() const {
|
|||||||
values[spec.key] = spec.defaultValue != 0.0;
|
values[spec.key] = spec.defaultValue != 0.0;
|
||||||
break;
|
break;
|
||||||
case ParamSpec::Type::Choice:
|
case ParamSpec::Type::Choice:
|
||||||
|
case ParamSpec::Type::Path:
|
||||||
|
case ParamSpec::Type::Text:
|
||||||
|
case ParamSpec::Type::Color:
|
||||||
values[spec.key] = spec.defaultChoice;
|
values[spec.key] = spec.defaultChoice;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -43,7 +43,7 @@ Image::Image(int width, int height)
|
|||||||
pixels_(static_cast<std::size_t>(std::max(width, 0)) * 3 *
|
pixels_(static_cast<std::size_t>(std::max(width, 0)) * 3 *
|
||||||
static_cast<std::size_t>(std::max(height, 0))) {}
|
static_cast<std::size_t>(std::max(height, 0))) {}
|
||||||
|
|
||||||
Image Image::load(const std::string& path) {
|
Image Image::load(const std::string& path, std::vector<std::uint8_t>* alphaOut) {
|
||||||
GError* error = nullptr;
|
GError* error = nullptr;
|
||||||
PixbufPtr pixbuf = wrap(gdk_pixbuf_new_from_file(path.c_str(), &error));
|
PixbufPtr pixbuf = wrap(gdk_pixbuf_new_from_file(path.c_str(), &error));
|
||||||
if (!pixbuf) throw std::runtime_error(takeError(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());
|
const guint8* src = gdk_pixbuf_get_pixels(pixbuf.get());
|
||||||
|
|
||||||
Image image(width, height);
|
Image image(width, height);
|
||||||
|
if (alphaOut != nullptr) {
|
||||||
|
alphaOut->assign(static_cast<std::size_t>(std::max(width, 0)) *
|
||||||
|
static_cast<std::size_t>(std::max(height, 0)),
|
||||||
|
255);
|
||||||
|
}
|
||||||
for (int y = 0; y < height; ++y) {
|
for (int y = 0; y < height; ++y) {
|
||||||
const guint8* in = src + static_cast<std::size_t>(y) * srcStride;
|
const guint8* in = src + static_cast<std::size_t>(y) * srcStride;
|
||||||
std::uint8_t* out = image.row(y);
|
std::uint8_t* out = image.row(y);
|
||||||
if (channels == 3) {
|
if (channels == 3) {
|
||||||
std::memcpy(out, in, static_cast<std::size_t>(width) * 3);
|
std::memcpy(out, in, static_cast<std::size_t>(width) * 3);
|
||||||
|
} else if (alphaOut != nullptr) {
|
||||||
|
// Kept apart rather than flattened: the caller does the compositing.
|
||||||
|
std::uint8_t* alpha = alphaOut->data() + static_cast<std::size_t>(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 {
|
} else {
|
||||||
// Flatten alpha onto white, matching what a viewer would show.
|
// Flatten alpha onto white, matching what a viewer would show.
|
||||||
for (int x = 0; x < width; ++x) {
|
for (int x = 0; x < width; ++x) {
|
||||||
|
|||||||
+5
-1
@@ -34,7 +34,11 @@ public:
|
|||||||
|
|
||||||
// Decodes via GdkPixbuf (PNG, JPEG, WebP, TIFF, BMP, ...), applying the
|
// Decodes via GdkPixbuf (PNG, JPEG, WebP, TIFF, BMP, ...), applying the
|
||||||
// embedded EXIF orientation. Throws std::runtime_error on failure.
|
// 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<std::uint8_t>* alphaOut = nullptr);
|
||||||
|
|
||||||
// Encodes by file extension; png is used when the extension is unknown.
|
// Encodes by file extension; png is used when the extension is unknown.
|
||||||
void save(const std::string& path) const;
|
void save(const std::string& path) const;
|
||||||
|
|||||||
@@ -63,6 +63,37 @@ ParamSpec ParamSpec::makeChoice(std::string key, std::string label, std::string
|
|||||||
return spec;
|
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 {
|
namespace {
|
||||||
|
|
||||||
const ParamValue* find(const ParamValues& values, const std::string& key) {
|
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,
|
std::string paramChoice(const ParamValues& values, const std::string& key,
|
||||||
const std::string& fallback) {
|
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* value = find(values, key)) {
|
||||||
if (const auto* v = std::get_if<std::string>(value)) return *v;
|
if (const auto* v = std::get_if<std::string>(value)) return *v;
|
||||||
}
|
}
|
||||||
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
|
||||||
|
|||||||
+26
-1
@@ -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 };
|
enum class Type { Int, Double, Bool, Choice, Path, Text, Color };
|
||||||
|
|
||||||
Type type = Type::Int;
|
Type type = Type::Int;
|
||||||
std::string key;
|
std::string key;
|
||||||
@@ -35,6 +36,18 @@ struct ParamSpec {
|
|||||||
std::string help = {});
|
std::string help = {});
|
||||||
static ParamSpec makeChoice(std::string key, std::string label, std::string defaultChoice,
|
static ParamSpec makeChoice(std::string key, std::string label, std::string defaultChoice,
|
||||||
std::vector<std::string> choices, std::string help = {});
|
std::vector<std::string> 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<long, double, bool, std::string>;
|
using ParamValue = std::variant<long, double, bool, std::string>;
|
||||||
@@ -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);
|
bool paramBool(const ParamValues& values, const std::string& key, bool fallback);
|
||||||
std::string paramChoice(const ParamValues& values, const std::string& key,
|
std::string paramChoice(const ParamValues& values, const std::string& key,
|
||||||
const std::string& fallback);
|
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
|
} // namespace effector
|
||||||
|
|||||||
+61
-7
@@ -1,10 +1,13 @@
|
|||||||
#include "core/project.hpp"
|
#include "core/project.hpp"
|
||||||
|
|
||||||
|
#include <charconv>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
|
#include <locale>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
|
||||||
#include "core/effect.hpp"
|
#include "core/effect.hpp"
|
||||||
@@ -24,7 +27,7 @@ struct Json {
|
|||||||
bool boolean = false;
|
bool boolean = false;
|
||||||
double number = 0.0;
|
double number = 0.0;
|
||||||
bool integral = true; // written without a fraction or exponent
|
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<Json> items;
|
std::vector<Json> items;
|
||||||
std::vector<std::pair<std::string, Json>> members;
|
std::vector<std::pair<std::string, Json>> members;
|
||||||
|
|
||||||
@@ -226,11 +229,15 @@ private:
|
|||||||
Json value;
|
Json value;
|
||||||
value.kind = Json::Kind::Number;
|
value.kind = Json::Kind::Number;
|
||||||
value.integral = integral;
|
value.integral = integral;
|
||||||
try {
|
// from_chars rather than stod: a number in JSON always has a point for
|
||||||
value.number = std::stod(text_.substr(start, pos_ - start));
|
// a decimal mark, whatever LC_NUMERIC says the user's decimal mark is.
|
||||||
} catch (const std::exception&) {
|
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");
|
fail("'" + text_.substr(start, pos_ - start) + "' is not a number");
|
||||||
}
|
}
|
||||||
|
value.text.assign(first, last); // kept whole for the seed, see below
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +270,7 @@ std::string quote(const std::string& text) {
|
|||||||
|
|
||||||
std::string number(double value, int digits) {
|
std::string number(double value, int digits) {
|
||||||
std::ostringstream out;
|
std::ostringstream out;
|
||||||
|
out.imbue(std::locale::classic());
|
||||||
out.setf(std::ios::fixed);
|
out.setf(std::ios::fixed);
|
||||||
out.precision(digits);
|
out.precision(digits);
|
||||||
out << value;
|
out << value;
|
||||||
@@ -307,7 +315,12 @@ std::string resolve(const std::string& path, const std::string& directory) {
|
|||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
std::string projectToJson(const Project& project, const std::string& directory) {
|
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;
|
std::ostringstream out;
|
||||||
|
out.imbue(std::locale::classic());
|
||||||
out << "{\n";
|
out << "{\n";
|
||||||
out << " \"effector_project\": 1,\n";
|
out << " \"effector_project\": 1,\n";
|
||||||
out << " \"canvas\": {\"width\": " << project.width << ", \"height\": " << project.height
|
out << " \"canvas\": {\"width\": " << project.width << ", \"height\": " << project.height
|
||||||
@@ -346,8 +359,22 @@ 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: {
|
||||||
|
// 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 }";
|
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 (const auto* seed = root.member("seed")) {
|
||||||
if (seed->kind == Json::Kind::Number) {
|
// Taken from the digits rather than from the double: a 64-bit seed has
|
||||||
project.seed = static_cast<std::uint64_t>(seed->number);
|
// 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:
|
case ParamSpec::Type::Double:
|
||||||
if (value->kind == Json::Kind::Number) stage.values[spec.key] = value->number;
|
if (value->kind == Json::Kind::Number) stage.values[spec.key] = value->number;
|
||||||
break;
|
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:
|
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) {
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
#include "effects/blur.hpp"
|
#include "effects/blur.hpp"
|
||||||
#include "effects/grain.hpp"
|
#include "effects/grain.hpp"
|
||||||
#include "effects/monochrome.hpp"
|
#include "effects/monochrome.hpp"
|
||||||
|
#include "effects/overlay.hpp"
|
||||||
#include "effects/scanlines.hpp"
|
#include "effects/scanlines.hpp"
|
||||||
|
#include "effects/text.hpp"
|
||||||
|
#include "effects/tiles.hpp"
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
|
|
||||||
@@ -14,6 +17,9 @@ const std::vector<EffectPtr>& effects() {
|
|||||||
std::make_shared<BlurEffect>(),
|
std::make_shared<BlurEffect>(),
|
||||||
std::make_shared<GrainEffect>(),
|
std::make_shared<GrainEffect>(),
|
||||||
std::make_shared<MonochromeEffect>(),
|
std::make_shared<MonochromeEffect>(),
|
||||||
|
std::make_shared<OverlayEffect>(),
|
||||||
|
std::make_shared<TextEffect>(),
|
||||||
|
std::make_shared<TilesEffect>(),
|
||||||
};
|
};
|
||||||
return registry;
|
return registry;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
#include "effects/overlay.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "core/parallel.hpp"
|
||||||
|
#include "effects/source_fill.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
const std::vector<ParamSpec>& OverlayEffect::params() const {
|
||||||
|
static const std::vector<ParamSpec> 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<double>(width) / srcWidth;
|
||||||
|
const double byHeight = static_cast<double>(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<int>(std::lround(drawWidth * scale)));
|
||||||
|
placement.height = std::max(1, static_cast<int>(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<int>(fu);
|
||||||
|
const int v0 = static_cast<int>(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<std::size_t>(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<std::size_t>(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<int>(accumAlpha + 0.5), 0, 255);
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
rgb[c] = accumAlpha <= 0.0 ? 0
|
||||||
|
: std::clamp(static_cast<int>(accum[c] / accumAlpha + 0.5), 0,
|
||||||
|
255);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void OverlayEffect::loadOverlay(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 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<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 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<std::uint8_t> 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<int>(paramInt(values, "overlay_offset_x", 0)),
|
||||||
|
static_cast<int>(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<double>(overlay.width()) / place.width;
|
||||||
|
const double stepY = static_cast<double>(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<int>(opacity * 255.0 + 0.5);
|
||||||
|
const std::uint8_t* alphaData = alpha.data();
|
||||||
|
|
||||||
|
parallelFor(static_cast<std::size_t>(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<int>(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<std::size_t>(x) * 3;
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
px[c] = static_cast<std::uint8_t>(px[c] + (rgb[c] - px[c]) * weight / 255);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#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<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 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<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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#include "effects/tiles.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/parallel.hpp"
|
||||||
|
#include "effects/source_fill.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
const std::vector<ParamSpec>& TilesEffect::params() const {
|
||||||
|
static const std::vector<ParamSpec> 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<int> planSpans(Rng& rng, int base, int jitter, int span) {
|
||||||
|
std::vector<int> spans;
|
||||||
|
spans.reserve(static_cast<std::size_t>(span / base) + 8);
|
||||||
|
int used = 0;
|
||||||
|
while (used < span) {
|
||||||
|
int size = base;
|
||||||
|
if (jitter > 0) size += static_cast<int>(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<std::size_t>(copyWidth) * 3;
|
||||||
|
const std::size_t total = static_cast<std::size_t>(tile.w) * 3;
|
||||||
|
const std::size_t offset = static_cast<std::size_t>(tile.dstX) * 3;
|
||||||
|
|
||||||
|
for (int line = 0; line < copyHeight; ++line) {
|
||||||
|
const std::uint8_t* in = src.row(tile.sy + line) + static_cast<std::size_t>(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<Image>& 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<int>(std::max(1L, paramInt(values, "tile_width", 96)));
|
||||||
|
const int tileHeight = static_cast<int>(std::max(1L, paramInt(values, "tile_height", 96)));
|
||||||
|
const int jitter = static_cast<int>(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<int>(index++ % sources.size())
|
||||||
|
: static_cast<int>(rng.below(sources.size() - 1));
|
||||||
|
const Image& src = sources[static_cast<std::size_t>(sourceIndex)];
|
||||||
|
const int sx = static_cast<int>(rng.inRange(0, std::max(0, src.width() - w)));
|
||||||
|
const int sy = static_cast<int>(rng.inRange(0, std::max(0, src.height() - h)));
|
||||||
|
return Tile{dstX, dstY, w, h, sourceIndex, sx, sy};
|
||||||
|
};
|
||||||
|
|
||||||
|
std::vector<Tile> 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<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);
|
||||||
|
|
||||||
|
plan.reserve(static_cast<std::size_t>(count));
|
||||||
|
for (long i = 0; i < count; ++i) {
|
||||||
|
int w = tileWidth;
|
||||||
|
int h = tileHeight;
|
||||||
|
if (jitter > 0) {
|
||||||
|
w += static_cast<int>(rng.inRange(-jitter, jitter));
|
||||||
|
h += static_cast<int>(rng.inRange(-jitter, jitter));
|
||||||
|
}
|
||||||
|
const int dstX = static_cast<int>(rng.inRange(0, width - 1));
|
||||||
|
const int dstY = static_cast<int>(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<int> 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<int> 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<std::size_t>(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<std::size_t>(plan[i].sourceIndex)], plan[i]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -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<ParamSpec>& params() const override;
|
||||||
|
|
||||||
|
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||||
|
const ParamValues& values) const override;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
+99
-1
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
|
||||||
@@ -293,6 +295,56 @@ void EffectorWindow::buildParamWidgets() {
|
|||||||
widget = std::move(spin);
|
widget = std::move(spin);
|
||||||
break;
|
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::Box>(Gtk::Orientation::HORIZONTAL, 6);
|
||||||
|
auto* entry = Gtk::make_managed<Gtk::Entry>();
|
||||||
|
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<Gtk::Button>("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<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>();
|
||||||
@@ -311,7 +363,9 @@ void EffectorWindow::buildParamWidgets() {
|
|||||||
widget->set_hexpand(true);
|
widget->set_hexpand(true);
|
||||||
paramGrid_.attach(*label, 0, row);
|
paramGrid_.attach(*label, 0, row);
|
||||||
paramGrid_.attach(*widget, 1, 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(label));
|
||||||
ownedWidgets_.push_back(std::move(widget));
|
ownedWidgets_.push_back(std::move(widget));
|
||||||
++row;
|
++row;
|
||||||
@@ -333,6 +387,20 @@ ParamValues EffectorWindow::paramValues() const {
|
|||||||
case ParamSpec::Type::Double:
|
case ParamSpec::Type::Double:
|
||||||
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::Text:
|
||||||
|
values[entry.spec.key] =
|
||||||
|
std::string(static_cast<Gtk::Entry*>(entry.widget)->get_text());
|
||||||
|
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];
|
||||||
@@ -809,6 +877,36 @@ void EffectorWindow::onParamChanged() {
|
|||||||
requestRender();
|
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<Gtk::FileFilter>::create();
|
||||||
|
filters->append(filter);
|
||||||
|
dialog->set_filters(filters);
|
||||||
|
dialog->set_default_filter(filter);
|
||||||
|
|
||||||
|
dialog->open(*this, [this, dialog, stage, key](const Glib::RefPtr<Gio::AsyncResult>& 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<std::size_t>(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<std::size_t>(stage)].values[key] = path;
|
||||||
|
if (stage == selectedStage_) buildParamWidgets();
|
||||||
|
requestRender();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void EffectorWindow::onSeedEntered() {
|
void EffectorWindow::onSeedEntered() {
|
||||||
try {
|
try {
|
||||||
seed_ = std::stoull(seedEntry_.get_text());
|
seed_ = std::stoull(seedEntry_.get_text());
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ private:
|
|||||||
void updateTitle();
|
void updateTitle();
|
||||||
|
|
||||||
void onParamChanged();
|
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 onSeedEntered();
|
||||||
void onRecreate();
|
void onRecreate();
|
||||||
void onSave();
|
void onSave();
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
// Small self-contained checks for the pieces that have no GUI dependency.
|
// Small self-contained checks for the pieces that have no GUI dependency.
|
||||||
|
|
||||||
|
#include <array>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
|
#include <clocale>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
|
#include <locale>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -20,7 +23,10 @@
|
|||||||
#include "effects/blur.hpp"
|
#include "effects/blur.hpp"
|
||||||
#include "effects/grain.hpp"
|
#include "effects/grain.hpp"
|
||||||
#include "effects/monochrome.hpp"
|
#include "effects/monochrome.hpp"
|
||||||
|
#include "effects/overlay.hpp"
|
||||||
#include "effects/scanlines.hpp"
|
#include "effects/scanlines.hpp"
|
||||||
|
#include "effects/text.hpp"
|
||||||
|
#include "effects/tiles.hpp"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -116,6 +122,42 @@ effector::Image renderMono(const std::vector<effector::Image>& sources, int widt
|
|||||||
return effect.render(sources, width, height, rng, merged);
|
return effect.render(sources, width, height, rng, merged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
effector::Image renderOverlay(const std::vector<effector::Image>& 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<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);
|
||||||
|
}
|
||||||
|
|
||||||
|
effector::Image renderTiles(const std::vector<effector::Image>& 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<int, 3> pixel(const effector::Image& image, int x, int y) {
|
||||||
|
const auto* px = image.row(y) + static_cast<std::size_t>(x) * 3;
|
||||||
|
return {px[0], px[1], px[2]};
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isRed(const std::array<int, 3>& px) { return px[0] > 200 && px[1] < 60 && px[2] < 60; }
|
||||||
|
bool isBlue(const std::array<int, 3>& px) { return px[2] > 200 && px[0] < 60 && px[1] < 60; }
|
||||||
|
|
||||||
// A flat field of one colour, to read the channel weighting off directly.
|
// 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 solid(int width, int height, std::uint8_t r, std::uint8_t g, std::uint8_t b) {
|
||||||
effector::Image image(width, height);
|
effector::Image image(width, height);
|
||||||
@@ -221,6 +263,81 @@ int main() {
|
|||||||
check(threw, "no sources throws");
|
check(threw, "no sources throws");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::cout << "tiles\n";
|
||||||
|
{
|
||||||
|
std::vector<Image> 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<Image> 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::cout << "blur\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)};
|
||||||
@@ -404,6 +521,293 @@ int main() {
|
|||||||
check(allBlack, "without sources monochrome renders an empty canvas");
|
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<Image> 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<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)};
|
||||||
@@ -544,6 +948,51 @@ int main() {
|
|||||||
}
|
}
|
||||||
check(threw, "a missing project file throws");
|
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);
|
std::filesystem::remove_all(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user