Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc54d64bc4 | ||
|
|
d48a81c665 | ||
|
|
f4d8457828 | ||
|
|
749ea239bb | ||
|
|
1d0343b90a | ||
|
|
c34bc426fc | ||
|
|
e2252ecd30 | ||
|
|
2a64ab115e | ||
|
|
efee1cf224 | ||
|
|
67417727e7 | ||
|
|
82e730221e | ||
|
|
b6b5f94f08 | ||
|
|
6cb3da8dc6 | ||
|
|
4739d39a51 | ||
|
|
a82fba7886 | ||
|
|
89985cb989 | ||
|
|
ee4b1038ac |
@@ -14,11 +14,11 @@ 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`, `batch loading`) and the `ok`/`FAIL`
|
headings (`rng`, `registry`, `scanlines`, `tiles`, `blur`, `chain`, `project`,
|
||||||
lines. To iterate on one area, comment out the other blocks in
|
`image io`, `batch loading`) and the `ok`/`FAIL` lines. To iterate on one area, comment
|
||||||
`tests/core_tests.cpp` or add a new `std::cout << "section\n"` block — checks are
|
out the other blocks in `tests/core_tests.cpp` or add a new
|
||||||
plain `check(condition, "description")` calls and `main()` returns the failure
|
`std::cout << "section\n"` block — checks are plain
|
||||||
count.
|
`check(condition, "description")` calls and `main()` returns the failure count.
|
||||||
|
|
||||||
Headless render (no display needed, the fastest end-to-end smoke test):
|
Headless render (no display needed, the fastest end-to-end smoke test):
|
||||||
|
|
||||||
@@ -41,21 +41,77 @@ 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`.
|
||||||
|
|
||||||
|
Whether an effect works from the loaded photographs is part of the same
|
||||||
|
contract: `Effect::needsSources()` (default true, false for blur). Only the
|
||||||
|
first stage ever sees the sources, so `chainNeedsSources` (`src/core/chain.cpp`)
|
||||||
|
just asks the first stage — a chain led by blur renders with no images loaded at
|
||||||
|
all, and both front-ends gate on that rather than on "are there any images".
|
||||||
|
|
||||||
Adding an effect: new `src/effects/<name>.{hpp,cpp}` subclassing `Effect`, one
|
Adding an effect: new `src/effects/<name>.{hpp,cpp}` subclassing `Effect`, one
|
||||||
line in the registry list in `src/core/registry.cpp`, one line in the
|
line in the registry list in `src/core/registry.cpp`, one line in the
|
||||||
`effector_core` sources in `CMakeLists.txt`. Nothing else.
|
`effector_core` sources in `CMakeLists.txt`. Nothing else — it shows up in the
|
||||||
|
CLI, in the chain menu and as a cascade stage on its own.
|
||||||
|
|
||||||
|
### Effects cascade
|
||||||
|
|
||||||
|
Effects are never applied on their own: `renderChain` (`src/core/chain.cpp`)
|
||||||
|
runs a `std::vector<ChainStage>` in order, handing stage 0 the loaded sources
|
||||||
|
and every later stage the previous stage's output as its *only* source. That is
|
||||||
|
the whole of the cascade — an effect neither knows nor cares whether its input
|
||||||
|
came from disk. All stages share one `Rng` and run strictly in sequence, so a
|
||||||
|
seed still reproduces the cascade byte for byte.
|
||||||
|
|
||||||
|
The interface builds the chain in the sidebar ("Effect chain": add from the
|
||||||
|
menu, reorder, remove, one stage selected at a time with its params below), the
|
||||||
|
CLI builds it from repeated `-e`: `-e scanlines --strip-height 6 -e blur
|
||||||
|
--radius 5`. Flags share one namespace across all effects, so `buildChain`
|
||||||
|
(`src/cli/options.cpp`) hands each flag to the stage whose effect declares it —
|
||||||
|
two effects must not declare the same param key.
|
||||||
|
|
||||||
|
### The canvas is a format or plain pixels
|
||||||
|
|
||||||
|
`src/core/canvas.cpp` holds the one table of standard sizes both front-ends
|
||||||
|
offer: web formats are a fixed pixel size, print formats a sheet in millimetres
|
||||||
|
that becomes pixels at a dpi. Adding a format is a line in `canvasFormats()` —
|
||||||
|
the dropdown, `--format` and `--help` all walk that table. The pixels stay the
|
||||||
|
truth everywhere (`renderChain` never hears about formats); a format is the
|
||||||
|
*presentation* of a canvas, carried alongside the pixels in `Options` and
|
||||||
|
`Project` and empty for a custom size. Where a format is named it decides the
|
||||||
|
pixels, so a project saved as A4 reopens as A4.
|
||||||
|
|
||||||
|
### Projects are plain JSON
|
||||||
|
|
||||||
|
`src/core/project.cpp` reads and writes the whole setup — source paths, the
|
||||||
|
chain with its params, canvas and seed — as a small JSON file, with its own
|
||||||
|
minimal reader rather than a JSON dependency. Writing is driven by the effects'
|
||||||
|
`ParamSpec`s, not by whatever happens to sit in a stage's `ParamValues`, so a
|
||||||
|
key an effect does not declare is never written and never read back: an old
|
||||||
|
project keeps the defaults for params that did not exist when it was saved.
|
||||||
|
Source paths under the project's own folder are written relative, so a folder
|
||||||
|
can be moved or shared whole.
|
||||||
|
|
||||||
|
The interface opens and saves projects from the header-bar menu (New, Open,
|
||||||
|
Save, Save As, with the usual Ctrl+N/O/S accelerators); the window title shows
|
||||||
|
the project name. Opening one replaces the sources, so a decode still in flight
|
||||||
|
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.
|
||||||
|
|
||||||
@@ -64,7 +120,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.
|
||||||
|
|||||||
+13
-1
@@ -15,22 +15,34 @@ 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)
|
||||||
|
|
||||||
# --- core: effects and image handling, no GUI ---------------------------------
|
# --- core: effects and image handling, no GUI ---------------------------------
|
||||||
add_library(effector_core STATIC
|
add_library(effector_core STATIC
|
||||||
|
src/core/canvas.cpp
|
||||||
|
src/core/chain.cpp
|
||||||
src/core/effect.cpp
|
src/core/effect.cpp
|
||||||
src/core/image.cpp
|
src/core/image.cpp
|
||||||
src/core/params.cpp
|
src/core/params.cpp
|
||||||
|
src/core/project.cpp
|
||||||
src/core/registry.cpp
|
src/core/registry.cpp
|
||||||
src/core/rng.cpp
|
src/core/rng.cpp
|
||||||
|
src/effects/blur.cpp
|
||||||
|
src/effects/grain.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 --------------------------------------------------------------
|
||||||
|
|||||||
@@ -34,6 +34,22 @@ build/effector -o out.png -s 1920x1080 \
|
|||||||
--strip-height 8 --jitter 4 --seed 42 photo1.jpg photo2.png
|
--strip-height 8 --jitter 4 --seed 42 photo1.jpg photo2.png
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The canvas is either a size in pixels (`-s 1920x1080`, the Custom entry in the
|
||||||
|
interface) or one of the standard formats, which `--help` lists in full:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
build/effector -o post.png --format instagram-post -e tiles
|
||||||
|
build/effector -o sheet.png --format a4 --dpi 300 -e scanlines
|
||||||
|
```
|
||||||
|
|
||||||
|
A web format — the Instagram sizes, Full HD, 4K UHD — is a fixed pixel size. A
|
||||||
|
print format — A5, A4, A3, US Letter — is a sheet of paper, and becomes pixels
|
||||||
|
at `--dpi` (36–1200, 300 by default), so A4 at 300 dpi is 2480×3508. Picking a
|
||||||
|
format in the interface fills the width and height in and greys them out;
|
||||||
|
Custom hands them back, keeping whatever the format last worked out to. A
|
||||||
|
format is saved with the project, so an A4 project reopens as A4 rather than as
|
||||||
|
the pixels it happened to be.
|
||||||
|
|
||||||
Any option can also be passed when launching the interface; it becomes the
|
Any option can also be passed when launching the interface; it becomes the
|
||||||
starting state (images, canvas size, effect settings, seed).
|
starting state (images, canvas size, effect settings, seed).
|
||||||
|
|
||||||
|
|||||||
+76
-7
@@ -1,9 +1,12 @@
|
|||||||
#include "cli/options.hpp"
|
#include "cli/options.hpp"
|
||||||
|
|
||||||
#include <charconv>
|
#include <charconv>
|
||||||
|
#include <cstdint>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
|
||||||
|
#include "core/canvas.hpp"
|
||||||
|
#include "core/chain.hpp"
|
||||||
#include "core/effect.hpp"
|
#include "core/effect.hpp"
|
||||||
#include "core/registry.hpp"
|
#include "core/registry.hpp"
|
||||||
|
|
||||||
@@ -23,7 +26,10 @@ bool parseSize(const std::string& text, int& width, int& height) {
|
|||||||
long w = 0;
|
long w = 0;
|
||||||
long h = 0;
|
long h = 0;
|
||||||
if (!parseLong(text.substr(0, sep), w) || !parseLong(text.substr(sep + 1), h)) return false;
|
if (!parseLong(text.substr(0, sep), w) || !parseLong(text.substr(sep + 1), h)) return false;
|
||||||
if (w <= 0 || h <= 0) return false;
|
// Checked as longs, before the narrowing: 4294968296 cast straight to int
|
||||||
|
// is 1000, which would render a plausible canvas nobody asked for.
|
||||||
|
if (w < kMinCanvasSide || w > kMaxCanvasSide) return false;
|
||||||
|
if (h < kMinCanvasSide || h > kMaxCanvasSide) return false;
|
||||||
width = static_cast<int>(w);
|
width = static_cast<int>(w);
|
||||||
height = static_cast<int>(h);
|
height = static_cast<int>(h);
|
||||||
return true;
|
return true;
|
||||||
@@ -48,7 +54,6 @@ std::string requireValue(int argc, char** argv, int& i, const std::string& flag)
|
|||||||
|
|
||||||
Options parseOptions(int argc, char** argv) {
|
Options parseOptions(int argc, char** argv) {
|
||||||
Options options;
|
Options options;
|
||||||
options.effect = defaultEffectName();
|
|
||||||
|
|
||||||
for (int i = 1; i < argc; ++i) {
|
for (int i = 1; i < argc; ++i) {
|
||||||
const std::string arg = argv[i];
|
const std::string arg = argv[i];
|
||||||
@@ -58,15 +63,32 @@ Options parseOptions(int argc, char** argv) {
|
|||||||
} else if (arg == "--list-effects") {
|
} else if (arg == "--list-effects") {
|
||||||
options.listEffects = true;
|
options.listEffects = true;
|
||||||
} else if (arg == "-e" || arg == "--effect") {
|
} else if (arg == "-e" || arg == "--effect") {
|
||||||
options.effect = requireValue(argc, argv, i, arg);
|
const std::string name = requireValue(argc, argv, i, arg);
|
||||||
effectByName(options.effect); // validate now, fail early
|
effectByName(name); // validate now, fail early
|
||||||
|
options.effects.push_back(name);
|
||||||
} else if (arg == "-o" || arg == "--output") {
|
} else if (arg == "-o" || arg == "--output") {
|
||||||
options.output = requireValue(argc, argv, i, arg);
|
options.output = requireValue(argc, argv, i, arg);
|
||||||
} else if (arg == "-s" || arg == "--size") {
|
} else if (arg == "-s" || arg == "--size") {
|
||||||
const std::string value = requireValue(argc, argv, i, arg);
|
const std::string value = requireValue(argc, argv, i, arg);
|
||||||
if (!parseSize(value, options.width, options.height)) {
|
if (!parseSize(value, options.width, options.height)) {
|
||||||
throw EffectError("invalid size '" + value + "', expected e.g. 1920x1080");
|
throw EffectError("invalid size '" + value + "', expected e.g. 1920x1080 with "
|
||||||
|
"each side between " + std::to_string(kMinCanvasSide) + " and " +
|
||||||
|
std::to_string(kMaxCanvasSide));
|
||||||
}
|
}
|
||||||
|
} else if (arg == "--format") {
|
||||||
|
const std::string value = requireValue(argc, argv, i, arg);
|
||||||
|
if (canvasFormatByKey(value) == nullptr) {
|
||||||
|
throw EffectError("unknown canvas format '" + value + "', see --help");
|
||||||
|
}
|
||||||
|
options.format = value;
|
||||||
|
} else if (arg == "--dpi") {
|
||||||
|
const std::string value = requireValue(argc, argv, i, arg);
|
||||||
|
long dpi = 0;
|
||||||
|
if (!parseLong(value, dpi) || dpi < kMinDpi || dpi > kMaxDpi) {
|
||||||
|
throw EffectError("invalid dpi '" + value + "', expected " +
|
||||||
|
std::to_string(kMinDpi) + "-" + std::to_string(kMaxDpi));
|
||||||
|
}
|
||||||
|
options.dpi = static_cast<int>(dpi);
|
||||||
} else if (arg == "--seed") {
|
} else if (arg == "--seed") {
|
||||||
const std::string value = requireValue(argc, argv, i, arg);
|
const std::string value = requireValue(argc, argv, i, arg);
|
||||||
long seed = 0;
|
long seed = 0;
|
||||||
@@ -104,6 +126,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;
|
||||||
@@ -119,6 +155,12 @@ Options parseOptions(int argc, char** argv) {
|
|||||||
options.images.push_back(arg);
|
options.images.push_back(arg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Applied once everything is parsed, so --dpi may come before --format and
|
||||||
|
// so a format always wins over an -s given alongside it.
|
||||||
|
if (const auto* format = canvasFormatByKey(options.format)) {
|
||||||
|
canvasFormatSize(*format, options.dpi, options.width, options.height);
|
||||||
|
}
|
||||||
|
if (options.effects.empty()) options.effects.push_back(defaultEffectName());
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,13 +169,25 @@ std::string usage(const char* programName) {
|
|||||||
out << "Usage: " << programName << " [options] [image ...]\n\n"
|
out << "Usage: " << programName << " [options] [image ...]\n\n"
|
||||||
<< "Image effects app. Runs the GTK interface unless --output is given.\n\n"
|
<< "Image effects app. Runs the GTK interface unless --output is given.\n\n"
|
||||||
<< "Options:\n"
|
<< "Options:\n"
|
||||||
<< " -e, --effect NAME effect to apply (default: " << defaultEffectName() << ")\n"
|
<< " -e, --effect NAME effect to apply, repeat to cascade (default: " << defaultEffectName() << ")\n"
|
||||||
<< " -o, --output FILE render headlessly to this file and exit\n"
|
<< " -o, --output FILE render headlessly to this file and exit\n"
|
||||||
<< " -s, --size WxH canvas size (default: 1920x1080)\n"
|
<< " -s, --size WxH canvas size, each side " << kMinCanvasSide << "-"
|
||||||
|
<< kMaxCanvasSide << " (default: 1920x1080)\n"
|
||||||
|
<< " --format NAME canvas format, overrides --size (see below)\n"
|
||||||
|
<< " --dpi N resolution of a print format, " << kMinDpi << "-" << kMaxDpi
|
||||||
|
<< " (default: " << kDefaultDpi << ")\n"
|
||||||
<< " --seed N random seed (default: random)\n"
|
<< " --seed N random seed (default: random)\n"
|
||||||
<< " --list-effects list available effects and exit\n"
|
<< " --list-effects list available effects and exit\n"
|
||||||
<< " -h, --help show this help and exit\n";
|
<< " -h, --help show this help and exit\n";
|
||||||
|
|
||||||
|
out << "\nCanvas formats (--format):\n";
|
||||||
|
for (const auto& format : canvasFormats()) {
|
||||||
|
out << " " << format.key;
|
||||||
|
if (format.key.size() < 20) out << std::string(20 - format.key.size(), ' ');
|
||||||
|
out << " " << format.label << ", " << canvasFormatDetail(format)
|
||||||
|
<< (format.print ? " at --dpi" : "") << "\n";
|
||||||
|
}
|
||||||
|
|
||||||
for (const auto& effect : effects()) {
|
for (const auto& effect : effects()) {
|
||||||
out << "\n" << effect->title() << " options:\n";
|
out << "\n" << effect->title() << " options:\n";
|
||||||
for (const auto& spec : effect->params()) {
|
for (const auto& spec : effect->params()) {
|
||||||
@@ -147,4 +201,19 @@ std::string usage(const char* programName) {
|
|||||||
return out.str();
|
return out.str();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::vector<ChainStage> buildChain(const Options& options) {
|
||||||
|
std::vector<ChainStage> stages;
|
||||||
|
for (const auto& name : options.effects) {
|
||||||
|
ChainStage stage;
|
||||||
|
stage.effect = effectByName(name);
|
||||||
|
stage.values = stage.effect->defaults();
|
||||||
|
for (const auto& spec : stage.effect->params()) {
|
||||||
|
const auto it = options.params.find(spec.key);
|
||||||
|
if (it != options.params.end()) stage.values[spec.key] = it->second;
|
||||||
|
}
|
||||||
|
stages.push_back(std::move(stage));
|
||||||
|
}
|
||||||
|
return stages;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace effector
|
} // namespace effector
|
||||||
|
|||||||
+10
-1
@@ -5,16 +5,20 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/canvas.hpp"
|
||||||
|
#include "core/chain.hpp"
|
||||||
#include "core/params.hpp"
|
#include "core/params.hpp"
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
|
|
||||||
struct Options {
|
struct Options {
|
||||||
std::vector<std::string> images;
|
std::vector<std::string> images;
|
||||||
std::string effect;
|
std::vector<std::string> effects; // one per cascade stage, in order
|
||||||
std::string output; // empty -> launch the interface
|
std::string output; // empty -> launch the interface
|
||||||
int width = 1920;
|
int width = 1920;
|
||||||
int height = 1080;
|
int height = 1080;
|
||||||
|
std::string format; // a named canvas format, empty for plain pixels
|
||||||
|
int dpi = kDefaultDpi; // only a print format is rendered at a resolution
|
||||||
std::optional<std::uint64_t> seed;
|
std::optional<std::uint64_t> seed;
|
||||||
ParamValues params; // only the keys the user actually passed
|
ParamValues params; // only the keys the user actually passed
|
||||||
bool listEffects = false;
|
bool listEffects = false;
|
||||||
@@ -27,4 +31,9 @@ Options parseOptions(int argc, char** argv);
|
|||||||
|
|
||||||
std::string usage(const char* programName);
|
std::string usage(const char* programName);
|
||||||
|
|
||||||
|
// Turns the parsed names and flags into cascade stages: every flag goes to the
|
||||||
|
// stage whose effect declares it, so --strip-height and --radius can be given
|
||||||
|
// together for `-e scanlines -e blur`.
|
||||||
|
std::vector<ChainStage> buildChain(const Options& options);
|
||||||
|
|
||||||
} // namespace effector
|
} // namespace effector
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#include "core/canvas.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <locale>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
#include "core/chain.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// 210 mm reads as "210", 215.9 mm as "215.9". In the C locale whatever the
|
||||||
|
// user's is: gtkmm sets the global locale from the environment, so a stream
|
||||||
|
// left to its default writes "210,0" on a German desktop.
|
||||||
|
std::string millimetres(double mm) {
|
||||||
|
std::ostringstream out;
|
||||||
|
out.imbue(std::locale::classic());
|
||||||
|
out.setf(std::ios::fixed);
|
||||||
|
out.precision(1);
|
||||||
|
out << mm;
|
||||||
|
std::string text = out.str();
|
||||||
|
if (text.size() > 2 && text.compare(text.size() - 2, 2, ".0") == 0) text.resize(text.size() - 2);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
const std::vector<CanvasFormat>& canvasFormats() {
|
||||||
|
// Written out once and handed back by reference: the table is fixed, and
|
||||||
|
// both front-ends walk it for their menu and their --help.
|
||||||
|
static const std::vector<CanvasFormat> formats = {
|
||||||
|
{"instagram-post", "Instagram post", false, 1080, 1080, 0.0, 0.0},
|
||||||
|
{"instagram-portrait", "Instagram portrait", false, 1080, 1350, 0.0, 0.0},
|
||||||
|
{"instagram-story", "Instagram story", false, 1080, 1920, 0.0, 0.0},
|
||||||
|
{"hd", "Full HD", false, 1920, 1080, 0.0, 0.0},
|
||||||
|
{"uhd", "4K UHD", false, 3840, 2160, 0.0, 0.0},
|
||||||
|
{"a5", "A5", true, 0, 0, 148.0, 210.0},
|
||||||
|
{"a4", "A4", true, 0, 0, 210.0, 297.0},
|
||||||
|
{"a3", "A3", true, 0, 0, 297.0, 420.0},
|
||||||
|
{"letter", "US Letter", true, 0, 0, 215.9, 279.4},
|
||||||
|
};
|
||||||
|
return formats;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CanvasFormat* canvasFormatByKey(const std::string& key) {
|
||||||
|
if (key.empty()) return nullptr;
|
||||||
|
for (const auto& format : canvasFormats()) {
|
||||||
|
if (format.key == key) return &format;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void canvasFormatSize(const CanvasFormat& format, int dpi, int& width, int& height) {
|
||||||
|
if (!format.print) {
|
||||||
|
width = format.width;
|
||||||
|
height = format.height;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const int clamped = std::clamp(dpi, kMinDpi, kMaxDpi);
|
||||||
|
const auto pixels = [clamped](double mm) {
|
||||||
|
const long value = std::lround(mm / 25.4 * clamped);
|
||||||
|
return static_cast<int>(std::clamp<long>(value, kMinCanvasSide, kMaxCanvasSide));
|
||||||
|
};
|
||||||
|
width = pixels(format.widthMm);
|
||||||
|
height = pixels(format.heightMm);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string canvasFormatDetail(const CanvasFormat& format) {
|
||||||
|
if (format.print) {
|
||||||
|
return millimetres(format.widthMm) + " × " + millimetres(format.heightMm) + " mm";
|
||||||
|
}
|
||||||
|
return std::to_string(format.width) + " × " + std::to_string(format.height) + " px";
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// A named canvas size both front-ends offer, so "A4 at 300 dpi" is one choice
|
||||||
|
// rather than a pixel count the user has to work out. Web formats are a fixed
|
||||||
|
// pixel size; print formats are a physical size in millimetres and only become
|
||||||
|
// pixels once a resolution is given, which is why the dpi belongs to the
|
||||||
|
// canvas rather than to any one format.
|
||||||
|
struct CanvasFormat {
|
||||||
|
std::string key; // written in a project and given to --format, e.g. "a4"
|
||||||
|
std::string label; // shown in the interface, e.g. "A4"
|
||||||
|
bool print = false;
|
||||||
|
int width = 0; // pixels, web formats only
|
||||||
|
int height = 0; //
|
||||||
|
double widthMm = 0.0; // millimetres, print formats only
|
||||||
|
double heightMm = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The dpi a print format is rendered at. The bounds keep A3 — the largest
|
||||||
|
// sheet here — inside kMaxCanvasSide at the top end, and keep a sheet from
|
||||||
|
// coming out too small to see at the bottom.
|
||||||
|
inline constexpr int kMinDpi = 36;
|
||||||
|
inline constexpr int kMaxDpi = 1200;
|
||||||
|
inline constexpr int kDefaultDpi = 300;
|
||||||
|
|
||||||
|
// In the order they are offered: the web formats first, then the paper sizes.
|
||||||
|
const std::vector<CanvasFormat>& canvasFormats();
|
||||||
|
|
||||||
|
// Null when the key names no format, which is how a custom canvas — the user's
|
||||||
|
// own pixel sizes — is spelled everywhere: an empty or unknown key.
|
||||||
|
const CanvasFormat* canvasFormatByKey(const std::string& key);
|
||||||
|
|
||||||
|
// The pixel size of a format. `dpi` is ignored by a web format, which already
|
||||||
|
// is a pixel size; a print format is rounded to the nearest pixel and clamped
|
||||||
|
// to the canvas bounds.
|
||||||
|
void canvasFormatSize(const CanvasFormat& format, int dpi, int& width, int& height);
|
||||||
|
|
||||||
|
// "1080 × 1080 px" or "210 × 297 mm", for the dropdown and for --help.
|
||||||
|
std::string canvasFormatDetail(const CanvasFormat& format);
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#include "core/chain.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
||||||
|
int width, int height, Rng& rng) {
|
||||||
|
if (stages.empty()) throw EffectError("The effect chain is empty.");
|
||||||
|
|
||||||
|
Image image;
|
||||||
|
for (std::size_t i = 0; i < stages.size(); ++i) {
|
||||||
|
const auto& stage = stages[i];
|
||||||
|
if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
|
||||||
|
|
||||||
|
auto values = stage.effect->defaults();
|
||||||
|
for (const auto& [key, value] : stage.values) values[key] = value;
|
||||||
|
|
||||||
|
if (i == 0) {
|
||||||
|
image = stage.effect->render(sources, width, height, rng, values);
|
||||||
|
} else {
|
||||||
|
// Feeding the previous result back in as the only source is what
|
||||||
|
// cascading means here; effects need to know nothing about it.
|
||||||
|
const std::vector<Image> previous = {std::move(image)};
|
||||||
|
image = stage.effect->render(previous, width, height, rng, values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool chainNeedsSources(const std::vector<ChainStage>& stages) {
|
||||||
|
if (stages.empty() || !stages.front().effect) return false;
|
||||||
|
return stages.front().effect->needsSources();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string chainTitle(const std::vector<ChainStage>& stages) {
|
||||||
|
std::string text;
|
||||||
|
for (const auto& stage : stages) {
|
||||||
|
if (!stage.effect) continue;
|
||||||
|
if (!text.empty()) text += " → ";
|
||||||
|
text += stage.effect->title();
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string chainSlug(const std::vector<ChainStage>& stages) {
|
||||||
|
std::string text;
|
||||||
|
for (const auto& stage : stages) {
|
||||||
|
if (!stage.effect) continue;
|
||||||
|
if (!text.empty()) text += "-";
|
||||||
|
text += stage.effect->name();
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/effect.hpp"
|
||||||
|
#include "core/image.hpp"
|
||||||
|
#include "core/params.hpp"
|
||||||
|
#include "core/rng.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// The canvas sides both front-ends accept. The upper bound is not a hardware
|
||||||
|
// limit but the point past which a side stops fitting comfortably in an int
|
||||||
|
// once multiplied out, so both the CLI and the project reader can reject a
|
||||||
|
// number rather than silently wrap it into a plausible-looking one.
|
||||||
|
inline constexpr int kMinCanvasSide = 1;
|
||||||
|
inline constexpr int kMaxCanvasSide = 20000;
|
||||||
|
|
||||||
|
// One link of a cascade: an effect together with the values it runs with.
|
||||||
|
struct ChainStage {
|
||||||
|
EffectPtr effect;
|
||||||
|
ParamValues values; // missing keys fall back to the effect's defaults
|
||||||
|
};
|
||||||
|
|
||||||
|
// Renders the stages in order. The first one sees the loaded sources; every
|
||||||
|
// later one sees the previous stage's output as its only source, so blur after
|
||||||
|
// scanlines softens the scanlines rather than the photographs.
|
||||||
|
//
|
||||||
|
// All stages share one Rng and run strictly in sequence, so a seed still
|
||||||
|
// reproduces the cascade byte for byte.
|
||||||
|
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
||||||
|
int width, int height, Rng& rng);
|
||||||
|
|
||||||
|
// Whether the chain has to be given source images. Only the first stage ever
|
||||||
|
// sees them — every later one works on the stage before it — so this is simply
|
||||||
|
// whether the first effect declares that it needs them.
|
||||||
|
bool chainNeedsSources(const std::vector<ChainStage>& stages);
|
||||||
|
|
||||||
|
// "Scanlines → Blur", for window titles and status lines.
|
||||||
|
std::string chainTitle(const std::vector<ChainStage>& stages);
|
||||||
|
|
||||||
|
// "scanlines-blur", for file names.
|
||||||
|
std::string chainSlug(const std::vector<ChainStage>& stages);
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ public:
|
|||||||
virtual std::string description() const = 0;
|
virtual std::string description() const = 0;
|
||||||
virtual const std::vector<ParamSpec>& params() const = 0;
|
virtual const std::vector<ParamSpec>& params() const = 0;
|
||||||
|
|
||||||
|
// Whether the effect builds its image out of the loaded source images.
|
||||||
|
// Effects that only transform what they are handed (blur) say no, and a
|
||||||
|
// chain that starts with one of them needs no sources at all.
|
||||||
|
virtual bool needsSources() const { return true; }
|
||||||
|
|
||||||
virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||||
const ParamValues& values) const = 0;
|
const ParamValues& values) const = 0;
|
||||||
|
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -0,0 +1,543 @@
|
|||||||
|
#include "core/project.hpp"
|
||||||
|
|
||||||
|
#include <charconv>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <locale>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
#include "core/canvas.hpp"
|
||||||
|
#include "core/effect.hpp"
|
||||||
|
#include "core/registry.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// A project file is small and its shape is fixed, so it carries its own
|
||||||
|
// reader rather than a JSON dependency: enough of the grammar to read back
|
||||||
|
// what projectToJson writes, and to give a line number when a hand-edited
|
||||||
|
// file is wrong.
|
||||||
|
struct Json {
|
||||||
|
enum class Kind { Null, Bool, Number, String, Array, Object };
|
||||||
|
|
||||||
|
Kind kind = Kind::Null;
|
||||||
|
bool boolean = false;
|
||||||
|
double number = 0.0;
|
||||||
|
bool integral = true; // written without a fraction or exponent
|
||||||
|
std::string text; // the string, or the digits a number was written as
|
||||||
|
std::vector<Json> items;
|
||||||
|
std::vector<std::pair<std::string, Json>> members;
|
||||||
|
|
||||||
|
const Json* member(const std::string& key) const {
|
||||||
|
for (const auto& entry : members) {
|
||||||
|
if (entry.first == key) return &entry.second;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class JsonParser {
|
||||||
|
public:
|
||||||
|
explicit JsonParser(const std::string& text) : text_(text) {}
|
||||||
|
|
||||||
|
Json parse() {
|
||||||
|
skipSpace();
|
||||||
|
Json value = parseValue();
|
||||||
|
skipSpace();
|
||||||
|
if (pos_ != text_.size()) fail("trailing text after the end of the document");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
[[noreturn]] void fail(const std::string& what) const {
|
||||||
|
int line = 1;
|
||||||
|
for (std::size_t i = 0; i < pos_ && i < text_.size(); ++i) {
|
||||||
|
if (text_[i] == '\n') ++line;
|
||||||
|
}
|
||||||
|
throw EffectError("line " + std::to_string(line) + ": " + what);
|
||||||
|
}
|
||||||
|
|
||||||
|
char peek() const { return pos_ < text_.size() ? text_[pos_] : '\0'; }
|
||||||
|
|
||||||
|
void skipSpace() {
|
||||||
|
while (pos_ < text_.size()) {
|
||||||
|
const char c = text_[pos_];
|
||||||
|
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
|
||||||
|
++pos_;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void expect(char c) {
|
||||||
|
if (peek() != c) fail(std::string("expected '") + c + "'");
|
||||||
|
++pos_;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool literal(const char* word) {
|
||||||
|
const std::size_t length = std::char_traits<char>::length(word);
|
||||||
|
if (text_.compare(pos_, length, word) != 0) return false;
|
||||||
|
pos_ += length;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Json parseValue() {
|
||||||
|
switch (peek()) {
|
||||||
|
case '{': return parseObject();
|
||||||
|
case '[': return parseArray();
|
||||||
|
case '"': {
|
||||||
|
Json value;
|
||||||
|
value.kind = Json::Kind::String;
|
||||||
|
value.text = parseString();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
if (literal("true")) return boolean(true);
|
||||||
|
if (literal("false")) return boolean(false);
|
||||||
|
if (literal("null")) return Json();
|
||||||
|
return parseNumber();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Json boolean(bool state) {
|
||||||
|
Json value;
|
||||||
|
value.kind = Json::Kind::Bool;
|
||||||
|
value.boolean = state;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
Json parseObject() {
|
||||||
|
Json value;
|
||||||
|
value.kind = Json::Kind::Object;
|
||||||
|
expect('{');
|
||||||
|
skipSpace();
|
||||||
|
if (peek() == '}') {
|
||||||
|
++pos_;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
while (true) {
|
||||||
|
skipSpace();
|
||||||
|
std::string key = parseString();
|
||||||
|
skipSpace();
|
||||||
|
expect(':');
|
||||||
|
skipSpace();
|
||||||
|
value.members.emplace_back(std::move(key), parseValue());
|
||||||
|
skipSpace();
|
||||||
|
if (peek() == ',') {
|
||||||
|
++pos_;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
expect('}');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Json parseArray() {
|
||||||
|
Json value;
|
||||||
|
value.kind = Json::Kind::Array;
|
||||||
|
expect('[');
|
||||||
|
skipSpace();
|
||||||
|
if (peek() == ']') {
|
||||||
|
++pos_;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
while (true) {
|
||||||
|
skipSpace();
|
||||||
|
value.items.push_back(parseValue());
|
||||||
|
skipSpace();
|
||||||
|
if (peek() == ',') {
|
||||||
|
++pos_;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
expect(']');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string parseString() {
|
||||||
|
expect('"');
|
||||||
|
std::string out;
|
||||||
|
while (true) {
|
||||||
|
if (pos_ >= text_.size()) fail("unterminated string");
|
||||||
|
const char c = text_[pos_++];
|
||||||
|
if (c == '"') return out;
|
||||||
|
if (c != '\\') {
|
||||||
|
out.push_back(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (pos_ >= text_.size()) fail("unterminated escape");
|
||||||
|
switch (text_[pos_++]) {
|
||||||
|
case '"': out.push_back('"'); break;
|
||||||
|
case '\\': out.push_back('\\'); break;
|
||||||
|
case '/': out.push_back('/'); break;
|
||||||
|
case 'b': out.push_back('\b'); break;
|
||||||
|
case 'f': out.push_back('\f'); break;
|
||||||
|
case 'n': out.push_back('\n'); break;
|
||||||
|
case 'r': out.push_back('\r'); break;
|
||||||
|
case 't': out.push_back('\t'); break;
|
||||||
|
case 'u': {
|
||||||
|
// Only the basic plane, which is all a path or a choice
|
||||||
|
// name can need; surrogate pairs are passed through as is.
|
||||||
|
if (pos_ + 4 > text_.size()) fail("short \\u escape");
|
||||||
|
const unsigned code = static_cast<unsigned>(
|
||||||
|
std::strtoul(text_.substr(pos_, 4).c_str(), nullptr, 16));
|
||||||
|
pos_ += 4;
|
||||||
|
appendUtf8(out, code);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: fail("unknown escape");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static void appendUtf8(std::string& out, unsigned code) {
|
||||||
|
if (code < 0x80) {
|
||||||
|
out.push_back(static_cast<char>(code));
|
||||||
|
} else if (code < 0x800) {
|
||||||
|
out.push_back(static_cast<char>(0xC0 | (code >> 6)));
|
||||||
|
out.push_back(static_cast<char>(0x80 | (code & 0x3F)));
|
||||||
|
} else {
|
||||||
|
out.push_back(static_cast<char>(0xE0 | (code >> 12)));
|
||||||
|
out.push_back(static_cast<char>(0x80 | ((code >> 6) & 0x3F)));
|
||||||
|
out.push_back(static_cast<char>(0x80 | (code & 0x3F)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Json parseNumber() {
|
||||||
|
const std::size_t start = pos_;
|
||||||
|
if (peek() == '-' || peek() == '+') ++pos_;
|
||||||
|
bool integral = true;
|
||||||
|
while (pos_ < text_.size()) {
|
||||||
|
const char c = text_[pos_];
|
||||||
|
if (c >= '0' && c <= '9') {
|
||||||
|
++pos_;
|
||||||
|
} else if (c == '.' || c == 'e' || c == 'E') {
|
||||||
|
integral = false;
|
||||||
|
++pos_;
|
||||||
|
} else if (c == '-' || c == '+') { // an exponent's sign
|
||||||
|
++pos_;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pos_ == start) fail("expected a value");
|
||||||
|
|
||||||
|
Json value;
|
||||||
|
value.kind = Json::Kind::Number;
|
||||||
|
value.integral = integral;
|
||||||
|
// from_chars rather than stod: a number in JSON always has a point for
|
||||||
|
// a decimal mark, whatever LC_NUMERIC says the user's decimal mark is.
|
||||||
|
const char* const first = text_.data() + start + (text_[start] == '+' ? 1 : 0);
|
||||||
|
const char* const last = text_.data() + pos_;
|
||||||
|
const auto parsed = std::from_chars(first, last, value.number);
|
||||||
|
if (parsed.ec != std::errc() || parsed.ptr != last) {
|
||||||
|
fail("'" + text_.substr(start, pos_ - start) + "' is not a number");
|
||||||
|
}
|
||||||
|
value.text.assign(first, last); // kept whole for the seed, see below
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string& text_;
|
||||||
|
std::size_t pos_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::string quote(const std::string& text) {
|
||||||
|
std::string out = "\"";
|
||||||
|
for (const char c : text) {
|
||||||
|
switch (c) {
|
||||||
|
case '"': out += "\\\""; break;
|
||||||
|
case '\\': out += "\\\\"; break;
|
||||||
|
case '\n': out += "\\n"; break;
|
||||||
|
case '\r': out += "\\r"; break;
|
||||||
|
case '\t': out += "\\t"; break;
|
||||||
|
default:
|
||||||
|
if (static_cast<unsigned char>(c) < 0x20) {
|
||||||
|
char buffer[7];
|
||||||
|
std::snprintf(buffer, sizeof(buffer), "\\u%04x",
|
||||||
|
static_cast<unsigned>(static_cast<unsigned char>(c)));
|
||||||
|
out += buffer;
|
||||||
|
} else {
|
||||||
|
out.push_back(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string number(double value, int digits) {
|
||||||
|
std::ostringstream out;
|
||||||
|
out.imbue(std::locale::classic());
|
||||||
|
out.setf(std::ios::fixed);
|
||||||
|
out.precision(digits);
|
||||||
|
out << value;
|
||||||
|
std::string text = out.str();
|
||||||
|
// 5.00 reads better as 5.0 and 0.250 as 0.25.
|
||||||
|
while (text.size() > 1 && text.back() == '0' && text[text.size() - 2] != '.') text.pop_back();
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative when the image lives under the project folder, absolute otherwise:
|
||||||
|
// a folder holding the project and its images stays movable.
|
||||||
|
std::string relativeTo(const std::string& path, const std::string& directory) {
|
||||||
|
if (directory.empty()) return path;
|
||||||
|
std::error_code error;
|
||||||
|
const auto relative = std::filesystem::relative(path, directory, error);
|
||||||
|
if (error || relative.empty()) return path;
|
||||||
|
const auto text = relative.generic_string();
|
||||||
|
return text.rfind("..", 0) == 0 ? path : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A canvas side is checked as a double, before it is narrowed: casting a
|
||||||
|
// number outside the range of an int is undefined, and even where it wraps
|
||||||
|
// quietly it turns a size the file never named into one that looks ordinary.
|
||||||
|
int canvasSide(const Json& value, const char* what) {
|
||||||
|
if (value.kind != Json::Kind::Number || value.number != std::trunc(value.number)) {
|
||||||
|
throw EffectError(std::string("the canvas ") + what + " must be a whole number");
|
||||||
|
}
|
||||||
|
if (!(value.number >= kMinCanvasSide) || !(value.number <= kMaxCanvasSide)) {
|
||||||
|
throw EffectError(std::string("the canvas ") + what + " must be between " +
|
||||||
|
std::to_string(kMinCanvasSide) + " and " +
|
||||||
|
std::to_string(kMaxCanvasSide) + " pixels");
|
||||||
|
}
|
||||||
|
return static_cast<int>(value.number);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checked as a double before narrowing, exactly as a canvas side is.
|
||||||
|
int canvasDpi(const Json& value) {
|
||||||
|
if (value.kind != Json::Kind::Number || value.number != std::trunc(value.number)) {
|
||||||
|
throw EffectError("the canvas dpi must be a whole number");
|
||||||
|
}
|
||||||
|
if (!(value.number >= kMinDpi) || !(value.number <= kMaxDpi)) {
|
||||||
|
throw EffectError("the canvas dpi must be between " + std::to_string(kMinDpi) + " and " +
|
||||||
|
std::to_string(kMaxDpi));
|
||||||
|
}
|
||||||
|
return static_cast<int>(value.number);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string resolve(const std::string& path, const std::string& directory) {
|
||||||
|
std::filesystem::path file(path);
|
||||||
|
if (file.is_absolute() || directory.empty()) return path;
|
||||||
|
return (std::filesystem::path(directory) / file).lexically_normal().string();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string projectToJson(const Project& project, const std::string& directory) {
|
||||||
|
// JSON is written in the C locale whatever the user's is: a decimal comma
|
||||||
|
// or a digit grouping would make the file something this reader — or any
|
||||||
|
// other — cannot read back. gtkmm sets the global locale from the
|
||||||
|
// environment, so a stream left to its default is the user's locale.
|
||||||
|
std::ostringstream out;
|
||||||
|
out.imbue(std::locale::classic());
|
||||||
|
out << "{\n";
|
||||||
|
out << " \"effector_project\": 1,\n";
|
||||||
|
out << " \"canvas\": {\"width\": " << project.width << ", \"height\": " << project.height;
|
||||||
|
// The pixels are always written, so a project opens the same in a reader
|
||||||
|
// that knows nothing of formats; the format and its dpi come after them
|
||||||
|
// and only when there is one to name.
|
||||||
|
if (const auto* format = canvasFormatByKey(project.format)) {
|
||||||
|
out << ", \"format\": " << quote(format->key);
|
||||||
|
if (format->print) out << ", \"dpi\": " << project.dpi;
|
||||||
|
}
|
||||||
|
out << "},\n";
|
||||||
|
if (project.seed) out << " \"seed\": " << *project.seed << ",\n";
|
||||||
|
|
||||||
|
out << " \"sources\": [";
|
||||||
|
for (std::size_t i = 0; i < project.sources.size(); ++i) {
|
||||||
|
out << (i == 0 ? "\n" : ",\n") << " " << quote(relativeTo(project.sources[i], directory));
|
||||||
|
}
|
||||||
|
out << (project.sources.empty() ? "" : "\n ") << "],\n";
|
||||||
|
|
||||||
|
out << " \"chain\": [";
|
||||||
|
for (std::size_t i = 0; i < project.stages.size(); ++i) {
|
||||||
|
const auto& stage = project.stages[i];
|
||||||
|
if (!stage.effect) continue;
|
||||||
|
out << (i == 0 ? "\n" : ",\n");
|
||||||
|
out << " {\n \"effect\": " << quote(stage.effect->name()) << ",\n";
|
||||||
|
out << " \"params\": {";
|
||||||
|
|
||||||
|
const auto& specs = stage.effect->params();
|
||||||
|
bool first = true;
|
||||||
|
for (const auto& spec : specs) {
|
||||||
|
out << (first ? "\n" : ",\n") << " " << quote(spec.key) << ": ";
|
||||||
|
first = false;
|
||||||
|
switch (spec.type) {
|
||||||
|
case ParamSpec::Type::Bool:
|
||||||
|
out << (paramBool(stage.values, spec.key, spec.defaultValue != 0.0) ? "true"
|
||||||
|
: "false");
|
||||||
|
break;
|
||||||
|
case ParamSpec::Type::Int:
|
||||||
|
out << paramInt(stage.values, spec.key, static_cast<long>(spec.defaultValue));
|
||||||
|
break;
|
||||||
|
case ParamSpec::Type::Double:
|
||||||
|
out << number(paramDouble(stage.values, spec.key, spec.defaultValue),
|
||||||
|
spec.digits);
|
||||||
|
break;
|
||||||
|
case ParamSpec::Type::Choice:
|
||||||
|
case ParamSpec::Type::Text:
|
||||||
|
out << quote(paramText(stage.values, spec.key, spec.defaultChoice));
|
||||||
|
break;
|
||||||
|
case ParamSpec::Type::Color: {
|
||||||
|
std::uint8_t rgb[3];
|
||||||
|
paramColor(stage.values, spec.key, spec.defaultChoice, rgb);
|
||||||
|
out << quote(formatColor(rgb));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ParamSpec::Type::Path: {
|
||||||
|
// Written relative to the project folder, exactly as the
|
||||||
|
// sources are, so a folder still opens once it is moved.
|
||||||
|
const auto path = paramText(stage.values, spec.key, "");
|
||||||
|
out << quote(path.empty() ? path : relativeTo(path, directory));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out << (specs.empty() ? "}" : "\n }") << "\n }";
|
||||||
|
}
|
||||||
|
out << (project.stages.empty() ? "" : "\n ") << "]\n";
|
||||||
|
out << "}\n";
|
||||||
|
return out.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
Project projectFromJson(const std::string& text, const std::string& directory) {
|
||||||
|
JsonParser parser(text);
|
||||||
|
const Json root = parser.parse();
|
||||||
|
if (root.kind != Json::Kind::Object) throw EffectError("a project must be a JSON object");
|
||||||
|
|
||||||
|
Project project;
|
||||||
|
|
||||||
|
if (const auto* canvas = root.member("canvas")) {
|
||||||
|
if (const auto* width = canvas->member("width")) project.width = canvasSide(*width, "width");
|
||||||
|
if (const auto* height = canvas->member("height")) {
|
||||||
|
project.height = canvasSide(*height, "height");
|
||||||
|
}
|
||||||
|
if (const auto* dpi = canvas->member("dpi")) project.dpi = canvasDpi(*dpi);
|
||||||
|
// A format the build does not know is simply a custom canvas: the
|
||||||
|
// pixels were written too, so the project still renders as it was
|
||||||
|
// saved. Where the format is known it decides the pixels, so a
|
||||||
|
// hand-edited dpi takes effect and a preset stays true to its name.
|
||||||
|
if (const auto* name = canvas->member("format"); name != nullptr &&
|
||||||
|
name->kind == Json::Kind::String) {
|
||||||
|
if (const auto* format = canvasFormatByKey(name->text)) {
|
||||||
|
project.format = format->key;
|
||||||
|
canvasFormatSize(*format, project.dpi, project.width, project.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (const auto* seed = root.member("seed")) {
|
||||||
|
// Taken from the digits rather than from the double: a 64-bit seed has
|
||||||
|
// more bits than a double has mantissa, and a seed that comes back off
|
||||||
|
// by a few is a project that no longer renders its own picture.
|
||||||
|
std::uint64_t value = 0;
|
||||||
|
const char* const first = seed->text.data();
|
||||||
|
const char* const last = first + seed->text.size();
|
||||||
|
if (seed->kind == Json::Kind::Number && seed->integral &&
|
||||||
|
std::from_chars(first, last, value).ptr == last) {
|
||||||
|
project.seed = value;
|
||||||
|
} else if (seed->kind == Json::Kind::Number) {
|
||||||
|
throw EffectError("the seed must be a whole number that fits in 64 bits");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (const auto* sources = root.member("sources")) {
|
||||||
|
for (const auto& item : sources->items) {
|
||||||
|
if (item.kind == Json::Kind::String) {
|
||||||
|
project.sources.push_back(resolve(item.text, directory));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto* chain = root.member("chain");
|
||||||
|
if (chain == nullptr || chain->kind != Json::Kind::Array || chain->items.empty()) {
|
||||||
|
throw EffectError("the project has no effect chain");
|
||||||
|
}
|
||||||
|
for (const auto& item : chain->items) {
|
||||||
|
const auto* name = item.member("effect");
|
||||||
|
if (name == nullptr || name->kind != Json::Kind::String) {
|
||||||
|
throw EffectError("a chain stage has no effect name");
|
||||||
|
}
|
||||||
|
|
||||||
|
ChainStage stage;
|
||||||
|
stage.effect = effectByName(name->text); // throws on an unknown effect
|
||||||
|
stage.values = stage.effect->defaults();
|
||||||
|
|
||||||
|
// Driven by the specs, not by the file: a key the effect does not
|
||||||
|
// declare is dropped, and an old project simply keeps the defaults for
|
||||||
|
// params that did not exist when it was written.
|
||||||
|
if (const auto* params = item.member("params")) {
|
||||||
|
for (const auto& spec : stage.effect->params()) {
|
||||||
|
const auto* value = params->member(spec.key);
|
||||||
|
if (value == nullptr) continue;
|
||||||
|
switch (spec.type) {
|
||||||
|
case ParamSpec::Type::Bool:
|
||||||
|
if (value->kind == Json::Kind::Bool) stage.values[spec.key] = value->boolean;
|
||||||
|
break;
|
||||||
|
case ParamSpec::Type::Int:
|
||||||
|
if (value->kind == Json::Kind::Number) {
|
||||||
|
stage.values[spec.key] = static_cast<long>(std::llround(value->number));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ParamSpec::Type::Double:
|
||||||
|
if (value->kind == Json::Kind::Number) stage.values[spec.key] = value->number;
|
||||||
|
break;
|
||||||
|
case ParamSpec::Type::Path:
|
||||||
|
if (value->kind == Json::Kind::String) {
|
||||||
|
stage.values[spec.key] =
|
||||||
|
value->text.empty() ? value->text : resolve(value->text, directory);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ParamSpec::Type::Text:
|
||||||
|
if (value->kind == Json::Kind::String) stage.values[spec.key] = value->text;
|
||||||
|
break;
|
||||||
|
case ParamSpec::Type::Color: {
|
||||||
|
// A colour that does not parse keeps the default,
|
||||||
|
// exactly as an unknown choice does.
|
||||||
|
std::uint8_t rgb[3];
|
||||||
|
if (value->kind == Json::Kind::String && parseColor(value->text, rgb)) {
|
||||||
|
stage.values[spec.key] = formatColor(rgb);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case ParamSpec::Type::Choice:
|
||||||
|
for (const auto& choice : spec.choices) {
|
||||||
|
if (value->kind == Json::Kind::String && value->text == choice) {
|
||||||
|
stage.values[spec.key] = choice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
project.stages.push_back(std::move(stage));
|
||||||
|
}
|
||||||
|
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
void saveProject(const std::string& path, const Project& project) {
|
||||||
|
const auto directory = std::filesystem::path(path).parent_path().string();
|
||||||
|
std::ofstream file(path, std::ios::binary | std::ios::trunc);
|
||||||
|
if (!file) throw EffectError("cannot write " + path);
|
||||||
|
file << projectToJson(project, directory);
|
||||||
|
if (!file) throw EffectError("could not write " + path);
|
||||||
|
}
|
||||||
|
|
||||||
|
Project loadProject(const std::string& path) {
|
||||||
|
std::ifstream file(path, std::ios::binary);
|
||||||
|
if (!file) throw EffectError("cannot read " + path);
|
||||||
|
std::ostringstream buffer;
|
||||||
|
buffer << file.rdbuf();
|
||||||
|
try {
|
||||||
|
return projectFromJson(buffer.str(), std::filesystem::path(path).parent_path().string());
|
||||||
|
} catch (const EffectError& error) {
|
||||||
|
throw EffectError(std::filesystem::path(path).filename().string() + ": " + error.what());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/canvas.hpp"
|
||||||
|
#include "core/chain.hpp"
|
||||||
|
#include "core/params.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// Everything needed to recreate a render: which images went in, the cascade
|
||||||
|
// with its settings, the canvas and the seed. Deliberately no pixels — a
|
||||||
|
// project is a small text file that points at the sources on disk.
|
||||||
|
struct Project {
|
||||||
|
std::vector<std::string> sources; // absolute paths
|
||||||
|
std::vector<ChainStage> stages;
|
||||||
|
int width = 1920;
|
||||||
|
int height = 1080;
|
||||||
|
// The named format the canvas came from, empty for a custom pixel size.
|
||||||
|
// The pixels above stay the truth for the render; this is what the
|
||||||
|
// interface puts back in its dropdown, and what a print format needs its
|
||||||
|
// resolution for.
|
||||||
|
std::string format;
|
||||||
|
int dpi = kDefaultDpi;
|
||||||
|
std::optional<std::uint64_t> seed;
|
||||||
|
};
|
||||||
|
|
||||||
|
// JSON, written for humans: two-space indent, one source path per line, and
|
||||||
|
// the params of a stage spelled with the same keys the effect declares.
|
||||||
|
// Source paths are written relative to `directory` when they live under it, so
|
||||||
|
// a project folder can be moved or shared whole.
|
||||||
|
std::string projectToJson(const Project& project, const std::string& directory = {});
|
||||||
|
|
||||||
|
// Throws EffectError on malformed JSON, an unknown effect or a missing chain.
|
||||||
|
// Relative source paths are resolved against `directory`; params the effect
|
||||||
|
// does not declare are dropped, and missing ones keep the effect's default.
|
||||||
|
Project projectFromJson(const std::string& text, const std::string& directory = {});
|
||||||
|
|
||||||
|
// Both throw EffectError: the file is the user's, so failures are reported
|
||||||
|
// rather than silently swallowed.
|
||||||
|
void saveProject(const std::string& path, const Project& project);
|
||||||
|
Project loadProject(const std::string& path);
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
#include "core/registry.hpp"
|
#include "core/registry.hpp"
|
||||||
|
|
||||||
|
#include "effects/blur.hpp"
|
||||||
|
#include "effects/grain.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 {
|
||||||
|
|
||||||
@@ -8,6 +14,12 @@ namespace effector {
|
|||||||
const std::vector<EffectPtr>& effects() {
|
const std::vector<EffectPtr>& effects() {
|
||||||
static const std::vector<EffectPtr> registry = {
|
static const std::vector<EffectPtr> registry = {
|
||||||
std::make_shared<ScanlinesEffect>(),
|
std::make_shared<ScanlinesEffect>(),
|
||||||
|
std::make_shared<BlurEffect>(),
|
||||||
|
std::make_shared<GrainEffect>(),
|
||||||
|
std::make_shared<MonochromeEffect>(),
|
||||||
|
std::make_shared<OverlayEffect>(),
|
||||||
|
std::make_shared<TextEffect>(),
|
||||||
|
std::make_shared<TilesEffect>(),
|
||||||
};
|
};
|
||||||
return registry;
|
return registry;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
#include "effects/blur.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/parallel.hpp"
|
||||||
|
#include "effects/source_fill.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
const std::vector<ParamSpec>& BlurEffect::params() const {
|
||||||
|
static const std::vector<ParamSpec> specs = {
|
||||||
|
ParamSpec::makeInt("radius", "Radius", 8, 1, 256,
|
||||||
|
"Half width in pixels of the blur window."),
|
||||||
|
ParamSpec::makeInt("passes", "Passes", 2, 1, 8,
|
||||||
|
"How often the box blur is repeated; more passes look more gaussian."),
|
||||||
|
ParamSpec::makeChoice("direction", "Direction", "both", {"both", "horizontal", "vertical"},
|
||||||
|
"Axis the blur is applied along."),
|
||||||
|
ParamSpec::makeBool("first_source", "Use the first image", false,
|
||||||
|
"Always blur the first source instead of picking one at random."),
|
||||||
|
};
|
||||||
|
return specs;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// One separable box blur pass. `stride` is the distance in bytes between two
|
||||||
|
// neighbouring samples along the blurred axis, `count` how many of them there
|
||||||
|
// are; the window is clamped at both ends.
|
||||||
|
void boxPass(const std::uint8_t* in, std::uint8_t* out, int count, std::size_t stride, int radius) {
|
||||||
|
const int window = 2 * radius + 1;
|
||||||
|
int sum[3] = {0, 0, 0};
|
||||||
|
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
sum[c] = in[c] * (radius + 1);
|
||||||
|
for (int i = 1; i <= radius; ++i) {
|
||||||
|
sum[c] += in[static_cast<std::size_t>(std::min(i, count - 1)) * stride + c];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
out[static_cast<std::size_t>(i) * stride + c] =
|
||||||
|
static_cast<std::uint8_t>(sum[c] / window);
|
||||||
|
}
|
||||||
|
const int add = std::min(i + radius + 1, count - 1);
|
||||||
|
const int drop = std::max(i - radius, 0);
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
sum[c] += in[static_cast<std::size_t>(add) * stride + c] -
|
||||||
|
in[static_cast<std::size_t>(drop) * stride + c];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void blurHorizontal(const Image& src, Image& dst, int radius) {
|
||||||
|
parallelFor(static_cast<std::size_t>(src.height()), [&](std::size_t begin, std::size_t end) {
|
||||||
|
for (std::size_t y = begin; y < end; ++y) {
|
||||||
|
boxPass(src.row(static_cast<int>(y)), dst.row(static_cast<int>(y)), src.width(), 3,
|
||||||
|
radius);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void blurVertical(const Image& src, Image& dst, int radius) {
|
||||||
|
const std::size_t stride = src.stride();
|
||||||
|
parallelFor(static_cast<std::size_t>(src.width()), [&](std::size_t begin, std::size_t end) {
|
||||||
|
for (std::size_t x = begin; x < end; ++x) {
|
||||||
|
const std::size_t offset = x * 3;
|
||||||
|
boxPass(src.data() + offset, dst.data() + offset, src.height(), stride, radius);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Image BlurEffect::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.");
|
||||||
|
|
||||||
|
const int radius = static_cast<int>(std::max(1L, paramInt(values, "radius", 8)));
|
||||||
|
const int passes = static_cast<int>(std::max(1L, paramInt(values, "passes", 2)));
|
||||||
|
const std::string direction = paramChoice(values, "direction", "both");
|
||||||
|
const bool firstSource = paramBool(values, "first_source", false);
|
||||||
|
|
||||||
|
Image canvas(width, height);
|
||||||
|
if (!sources.empty()) {
|
||||||
|
// Every random value is drawn here, before any threading, so that a
|
||||||
|
// seed always gives the same image.
|
||||||
|
const std::size_t index = firstSource ? 0 : 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
Image scratch(width, height);
|
||||||
|
for (int pass = 0; pass < passes; ++pass) {
|
||||||
|
if (direction != "vertical") {
|
||||||
|
blurHorizontal(canvas, scratch, std::min(radius, std::max(1, width - 1)));
|
||||||
|
std::swap(canvas, scratch);
|
||||||
|
}
|
||||||
|
if (direction != "horizontal") {
|
||||||
|
blurVertical(canvas, scratch, std::min(radius, std::max(1, height - 1)));
|
||||||
|
std::swap(canvas, scratch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "core/effect.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// Softens its input with a separable box blur, repeated a few times to
|
||||||
|
// approximate a gaussian. It needs no source images of its own: given none it
|
||||||
|
// blurs an empty canvas, which is what makes it usable as a pure filter stage.
|
||||||
|
class BlurEffect : public Effect {
|
||||||
|
public:
|
||||||
|
std::string name() const override { return "blur"; }
|
||||||
|
std::string title() const override { return "Blur"; }
|
||||||
|
std::string description() const override {
|
||||||
|
return "Softens its input with a repeated box blur, horizontally, vertically or both; "
|
||||||
|
"with sources loaded it fills the canvas from a randomly chosen one first.";
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
#include "effects/grain.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "core/parallel.hpp"
|
||||||
|
#include "effects/source_fill.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
const std::vector<ParamSpec>& GrainEffect::params() const {
|
||||||
|
static const std::vector<ParamSpec> specs = {
|
||||||
|
ParamSpec::makeDouble("grain_amount", "Amount", 0.35, 0.0, 1.0,
|
||||||
|
"How far a grain may push a pixel, as a fraction of full scale."),
|
||||||
|
ParamSpec::makeInt("grain_size", "Grain size", 1, 1, 32,
|
||||||
|
"Width in pixels of one grain; larger sizes look coarser."),
|
||||||
|
ParamSpec::makeBool("grain_colour", "Coloured grain", false,
|
||||||
|
"Draw the channels separately instead of one grey grain per pixel."),
|
||||||
|
ParamSpec::makeBool("grain_midtones", "Weight towards midtones", true,
|
||||||
|
"Fade the grain out in the shadows and highlights, as film does."),
|
||||||
|
};
|
||||||
|
return specs;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Response curve: 1 at mid grey, falling to 0 at black and at white, which is
|
||||||
|
// roughly how grain behaves on film.
|
||||||
|
inline int midtoneWeight(int value) { return 255 - std::abs(2 * value - 255); }
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Image GrainEffect::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.");
|
||||||
|
|
||||||
|
const double amount = std::clamp(paramDouble(values, "grain_amount", 0.35), 0.0, 1.0);
|
||||||
|
const int size = static_cast<int>(std::max(1L, paramInt(values, "grain_size", 1)));
|
||||||
|
const bool colour = paramBool(values, "grain_colour", false);
|
||||||
|
const bool midtones = paramBool(values, "grain_midtones", true);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount <= 0.0) return canvas;
|
||||||
|
|
||||||
|
// The whole grain field is drawn serially from the rng, exactly like the
|
||||||
|
// scanlines plan, and only the pixel arithmetic afterwards is parallel.
|
||||||
|
const int cellsX = (width + size - 1) / size;
|
||||||
|
const int cellsY = (height + size - 1) / size;
|
||||||
|
const int channels = colour ? 3 : 1;
|
||||||
|
std::vector<std::int8_t> field(static_cast<std::size_t>(cellsX) * cellsY * channels);
|
||||||
|
for (auto& cell : field) cell = static_cast<std::int8_t>(rng.inRange(-127, 127));
|
||||||
|
|
||||||
|
// 127 * 255 is the largest product of a grain and a midtone weight; scaling
|
||||||
|
// by `amount` there keeps the whole thing in integers.
|
||||||
|
const int scale = static_cast<int>(amount * 255.0 * 65536.0 / 127.0);
|
||||||
|
|
||||||
|
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||||
|
for (std::size_t y = begin; y < end; ++y) {
|
||||||
|
std::uint8_t* out = canvas.row(static_cast<int>(y));
|
||||||
|
const std::size_t rowCell =
|
||||||
|
static_cast<std::size_t>(static_cast<int>(y) / size) * cellsX;
|
||||||
|
|
||||||
|
for (int x = 0; x < width; ++x) {
|
||||||
|
const std::size_t cell = (rowCell + static_cast<std::size_t>(x / size)) * channels;
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
const int value = out[static_cast<std::size_t>(x) * 3 + c];
|
||||||
|
const int grain = field[cell + (colour ? static_cast<std::size_t>(c) : 0)];
|
||||||
|
const int weight = midtones ? midtoneWeight(value) : 255;
|
||||||
|
const int delta = grain * weight / 255 * scale / 65536;
|
||||||
|
out[static_cast<std::size_t>(x) * 3 + c] =
|
||||||
|
static_cast<std::uint8_t>(std::clamp(value + delta, 0, 255));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "core/effect.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// Scatters film-like grain over its input. Like the blur it needs no source
|
||||||
|
// images of its own, so it can lead a chain: given sources it fills the canvas
|
||||||
|
// from one of them first, given none it grains an empty canvas — which with the
|
||||||
|
// midtone weighting on stays black, exactly as an unexposed film would.
|
||||||
|
class GrainEffect : public Effect {
|
||||||
|
public:
|
||||||
|
std::string name() const override { return "grain"; }
|
||||||
|
std::string title() const override { return "Film grain"; }
|
||||||
|
std::string description() const override {
|
||||||
|
return "Scatters film-like grain over its input, optionally coloured and weighted towards "
|
||||||
|
"the midtones; with sources loaded it fills the canvas from a randomly chosen one "
|
||||||
|
"first.";
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#include "effects/monochrome.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "core/parallel.hpp"
|
||||||
|
#include "effects/source_fill.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
const std::vector<ParamSpec>& MonochromeEffect::params() const {
|
||||||
|
static const std::vector<ParamSpec> specs = {
|
||||||
|
ParamSpec::makeChoice("mono_weighting", "Weighting", "luminance",
|
||||||
|
{"luminance", "average", "red", "green", "blue"},
|
||||||
|
"How the three channels are mixed into one grey; the colour names "
|
||||||
|
"weigh that channel most, like a filter over the lens."),
|
||||||
|
ParamSpec::makeDouble("mono_contrast", "Contrast", 1.0, 0.1, 4.0,
|
||||||
|
"Pull of the greys away from mid grey; 1 leaves them as they are."),
|
||||||
|
ParamSpec::makeChoice("mono_tone", "Toner", "none",
|
||||||
|
{"none", "sepia", "selenium", "cyanotype"},
|
||||||
|
"Tints the finished greys, as a darkroom toner would."),
|
||||||
|
ParamSpec::makeDouble("mono_strength", "Strength", 1.0, 0.0, 1.0,
|
||||||
|
"How far the result is mixed back over the colour input."),
|
||||||
|
};
|
||||||
|
return specs;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Channel weights summing to 1024, so a grey is one integer multiply-add away.
|
||||||
|
std::array<int, 3> weightsFor(const std::string& weighting) {
|
||||||
|
if (weighting == "average") return {341, 342, 341};
|
||||||
|
if (weighting == "red") return {819, 154, 51};
|
||||||
|
if (weighting == "green") return {154, 819, 51};
|
||||||
|
if (weighting == "blue") return {154, 51, 819};
|
||||||
|
return {218, 732, 74}; // Rec. 709 luminance.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tint multipliers per channel, as 256ths.
|
||||||
|
std::array<int, 3> tintFor(const std::string& tone) {
|
||||||
|
if (tone == "sepia") return {282, 251, 205};
|
||||||
|
if (tone == "selenium") return {261, 251, 271};
|
||||||
|
if (tone == "cyanotype") return {205, 243, 307};
|
||||||
|
return {256, 256, 256};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Image MonochromeEffect::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.");
|
||||||
|
|
||||||
|
const auto weights = weightsFor(paramChoice(values, "mono_weighting", "luminance"));
|
||||||
|
const auto tint = tintFor(paramChoice(values, "mono_tone", "none"));
|
||||||
|
const double contrast = std::clamp(paramDouble(values, "mono_contrast", 1.0), 0.1, 4.0);
|
||||||
|
const double strength = std::clamp(paramDouble(values, "mono_strength", 1.0), 0.0, 1.0);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strength <= 0.0) return canvas;
|
||||||
|
|
||||||
|
// Contrast and toner only ever see one grey, so the whole tail of the
|
||||||
|
// conversion is a table: grey in, the three toned channels out.
|
||||||
|
std::array<std::array<std::uint8_t, 256>, 3> table{};
|
||||||
|
for (int grey = 0; grey < 256; ++grey) {
|
||||||
|
const int pulled =
|
||||||
|
std::clamp(static_cast<int>(128.0 + (grey - 128) * contrast + 0.5), 0, 255);
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
table[static_cast<std::size_t>(c)][static_cast<std::size_t>(grey)] =
|
||||||
|
static_cast<std::uint8_t>(
|
||||||
|
std::clamp(pulled * tint[static_cast<std::size_t>(c)] / 256, 0, 255));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const int mix = static_cast<int>(strength * 256.0 + 0.5);
|
||||||
|
|
||||||
|
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||||
|
for (std::size_t y = begin; y < end; ++y) {
|
||||||
|
std::uint8_t* out = canvas.row(static_cast<int>(y));
|
||||||
|
for (int x = 0; x < width; ++x) {
|
||||||
|
std::uint8_t* px = out + static_cast<std::size_t>(x) * 3;
|
||||||
|
const int grey =
|
||||||
|
(px[0] * weights[0] + px[1] * weights[1] + px[2] * weights[2]) / 1024;
|
||||||
|
for (int c = 0; c < 3; ++c) {
|
||||||
|
const int toned = table[static_cast<std::size_t>(c)]
|
||||||
|
[static_cast<std::size_t>(std::clamp(grey, 0, 255))];
|
||||||
|
px[c] = static_cast<std::uint8_t>(px[c] + (toned - px[c]) * mix / 256);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "core/effect.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// Converts its input to black and white, with the choice of channel weighting
|
||||||
|
// a photographer would make behind the lens (a red filter darkening a sky, for
|
||||||
|
// instance), an optional contrast pull and an optional toner. Like the blur it
|
||||||
|
// needs no source images of its own, so it can lead a chain; given sources it
|
||||||
|
// fills the canvas from one of them first.
|
||||||
|
class MonochromeEffect : public Effect {
|
||||||
|
public:
|
||||||
|
std::string name() const override { return "monochrome"; }
|
||||||
|
std::string title() const override { return "Black and white"; }
|
||||||
|
std::string description() const override {
|
||||||
|
return "Converts its input to black and white with a choice of channel weighting, an "
|
||||||
|
"optional contrast pull and an optional toner; with sources loaded it fills the "
|
||||||
|
"canvas from a randomly chosen one first.";
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace effector
|
||||||
@@ -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,38 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "core/image.hpp"
|
||||||
|
#include "core/parallel.hpp"
|
||||||
|
|
||||||
|
namespace effector {
|
||||||
|
|
||||||
|
// Copies `src` into the canvas starting at (sx, sy), repeating it as needed so
|
||||||
|
// that every pixel is covered even when the source is smaller than the canvas.
|
||||||
|
// Shared by the effects that do not build an image of their own but still want
|
||||||
|
// something to work on when they lead a chain.
|
||||||
|
inline void fillFromSource(Image& canvas, const Image& src, int sx, int sy) {
|
||||||
|
const int width = canvas.width();
|
||||||
|
const std::size_t total = static_cast<std::size_t>(width) * 3;
|
||||||
|
const int copyWidth = std::min(width, src.width() - sx);
|
||||||
|
const std::size_t copyBytes = static_cast<std::size_t>(copyWidth) * 3;
|
||||||
|
|
||||||
|
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 int srcY = sy + (static_cast<int>(y) % std::max(1, src.height() - sy));
|
||||||
|
const std::uint8_t* in = src.row(srcY) + static_cast<std::size_t>(sx) * 3;
|
||||||
|
std::uint8_t* out = canvas.row(static_cast<int>(y));
|
||||||
|
|
||||||
|
std::memcpy(out, in, copyBytes);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} // 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
|
||||||
+722
-113
File diff suppressed because it is too large
Load Diff
+60
-3
@@ -12,8 +12,10 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "cli/options.hpp"
|
#include "cli/options.hpp"
|
||||||
|
#include "core/chain.hpp"
|
||||||
#include "core/effect.hpp"
|
#include "core/effect.hpp"
|
||||||
#include "core/image.hpp"
|
#include "core/image.hpp"
|
||||||
|
#include "core/project.hpp"
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
|
|
||||||
@@ -28,8 +30,12 @@ private:
|
|||||||
Gtk::Widget& buildPreview();
|
Gtk::Widget& buildPreview();
|
||||||
void buildParamWidgets();
|
void buildParamWidgets();
|
||||||
ParamValues paramValues() const;
|
ParamValues paramValues() const;
|
||||||
|
void storeParams(); // widgets -> the selected stage's values
|
||||||
void applyInitial(const Options& initial);
|
void applyInitial(const Options& initial);
|
||||||
|
|
||||||
|
bool canRender() const; // a chain, and sources if the chain needs them
|
||||||
|
std::string missingText() const; // what is still missing before a render
|
||||||
|
void showPlaceholder(); // drops the preview and says what is missing
|
||||||
void requestRender(); // coalesced: restarts the short debounce timer
|
void requestRender(); // coalesced: restarts the short debounce timer
|
||||||
void startRender(); // starts a worker, or marks the result stale
|
void startRender(); // starts a worker, or marks the result stale
|
||||||
void onAddImages();
|
void onAddImages();
|
||||||
@@ -42,10 +48,45 @@ private:
|
|||||||
void closeLoadWindow();
|
void closeLoadWindow();
|
||||||
void cancelLoad();
|
void cancelLoad();
|
||||||
void appendSourceRow(const std::string& path);
|
void appendSourceRow(const std::string& path);
|
||||||
|
void updateSourcesLabel(); // the collapsed expander shows the count
|
||||||
|
|
||||||
|
Gtk::Widget& buildCanvasSection();
|
||||||
|
// "" while "Custom" is picked, otherwise the format's key.
|
||||||
|
std::string canvasFormatKey() const;
|
||||||
|
void setCanvasFormat(const std::string& key, int dpi); // dropdown and dpi
|
||||||
|
// A format owns the pixel sizes, so the spins follow it and are greyed
|
||||||
|
// out; "Custom" hands them back to the user untouched.
|
||||||
|
void syncCanvasWidgets();
|
||||||
void removeSource(Gtk::ListBoxRow* row);
|
void removeSource(Gtk::ListBoxRow* row);
|
||||||
|
|
||||||
void onEffectChanged();
|
Gtk::Widget& buildChainSection();
|
||||||
|
Gtk::Widget& buildAddMenu(); // the menu of effects that can be appended
|
||||||
|
void appendStage(const EffectPtr& effect);
|
||||||
|
void removeStage(int index);
|
||||||
|
void moveStage(int index, int delta);
|
||||||
|
void rebuildChainRows();
|
||||||
|
void selectStage(int index);
|
||||||
|
void onStageSelected();
|
||||||
|
Gtk::Widget& buildMainMenu(); // the header menu: new, open, save project
|
||||||
|
Project currentProject() const;
|
||||||
|
void applyProject(const Project& project, const std::string& path);
|
||||||
|
void clearSources();
|
||||||
|
void onNewProject();
|
||||||
|
void onOpenProject();
|
||||||
|
void onProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
|
||||||
|
const Glib::RefPtr<Gtk::FileDialog>& dialog);
|
||||||
|
void onSaveProject(); // falls back to Save As while the project is untitled
|
||||||
|
void onSaveProjectAs();
|
||||||
|
void onSaveProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
|
||||||
|
const Glib::RefPtr<Gtk::FileDialog>& dialog);
|
||||||
|
void writeProject(const std::string& path);
|
||||||
|
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();
|
||||||
@@ -66,9 +107,14 @@ private:
|
|||||||
// Model
|
// Model
|
||||||
std::shared_ptr<const SourceList> sources_ = std::make_shared<SourceList>();
|
std::shared_ptr<const SourceList> sources_ = std::make_shared<SourceList>();
|
||||||
std::vector<EffectPtr> effects_;
|
std::vector<EffectPtr> effects_;
|
||||||
EffectPtr effect_;
|
// The cascade: stage 0 works on the sources, every later stage on the
|
||||||
|
// output of the one before it.
|
||||||
|
std::vector<ChainStage> stages_;
|
||||||
|
int selectedStage_ = 0;
|
||||||
|
bool suspendStageSelect_ = false; // guards the rebuild of the chain rows
|
||||||
Image result_;
|
Image result_;
|
||||||
std::uint64_t seed_ = 0;
|
std::uint64_t seed_ = 0;
|
||||||
|
std::string projectPath_; // empty until the project has been saved once
|
||||||
bool suspendRender_ = true;
|
bool suspendRender_ = true;
|
||||||
|
|
||||||
// Source loading. Decoding a batch of large photographs takes seconds, so
|
// Source loading. Decoding a batch of large photographs takes seconds, so
|
||||||
@@ -85,6 +131,7 @@ private:
|
|||||||
std::vector<LoadFailure> loadFailures_;
|
std::vector<LoadFailure> loadFailures_;
|
||||||
std::vector<std::string> loadQueue_; // chosen while another batch was loading
|
std::vector<std::string> loadQueue_; // chosen while another batch was loading
|
||||||
bool loading_ = false;
|
bool loading_ = false;
|
||||||
|
bool dropLoadedImages_ = false; // the batch in flight belongs to a replaced project
|
||||||
sigc::connection loadWindowDelay_;
|
sigc::connection loadWindowDelay_;
|
||||||
|
|
||||||
// Render job
|
// Render job
|
||||||
@@ -105,12 +152,22 @@ private:
|
|||||||
std::unique_ptr<Gtk::Window> loadWindow_;
|
std::unique_ptr<Gtk::Window> loadWindow_;
|
||||||
Gtk::ProgressBar* loadBar_ = nullptr;
|
Gtk::ProgressBar* loadBar_ = nullptr;
|
||||||
Gtk::Label* loadFileLabel_ = nullptr;
|
Gtk::Label* loadFileLabel_ = nullptr;
|
||||||
|
Gtk::Expander sourcesExpander_;
|
||||||
Gtk::ListBox sourceList_;
|
Gtk::ListBox sourceList_;
|
||||||
|
Gtk::DropDown formatDrop_;
|
||||||
Gtk::SpinButton widthSpin_;
|
Gtk::SpinButton widthSpin_;
|
||||||
Gtk::SpinButton heightSpin_;
|
Gtk::SpinButton heightSpin_;
|
||||||
|
Gtk::SpinButton dpiSpin_;
|
||||||
|
Gtk::Label* dpiLabel_ = nullptr; // hidden along with the dpi spin
|
||||||
|
// Set while a format fills the spins, so their own handler does not queue
|
||||||
|
// a second render on top of the one the format change asks for.
|
||||||
|
bool suspendCanvasSync_ = false;
|
||||||
Gtk::Grid paramGrid_;
|
Gtk::Grid paramGrid_;
|
||||||
|
Gtk::Label paramHeading_;
|
||||||
Gtk::Entry seedEntry_;
|
Gtk::Entry seedEntry_;
|
||||||
Gtk::DropDown effectDropdown_;
|
Gtk::ListBox chainList_;
|
||||||
|
Gtk::MenuButton addEffectButton_;
|
||||||
|
Gtk::MenuButton menuButton_;
|
||||||
Gtk::Button recreateButton_{"Recreate"};
|
Gtk::Button recreateButton_{"Recreate"};
|
||||||
Gtk::Button saveButton_{"Save As…"};
|
Gtk::Button saveButton_{"Save As…"};
|
||||||
Gtk::Picture picture_;
|
Gtk::Picture picture_;
|
||||||
|
|||||||
+6
-7
@@ -8,6 +8,7 @@
|
|||||||
#include <mutex>
|
#include <mutex>
|
||||||
|
|
||||||
#include "cli/options.hpp"
|
#include "cli/options.hpp"
|
||||||
|
#include "core/chain.hpp"
|
||||||
#include "core/effect.hpp"
|
#include "core/effect.hpp"
|
||||||
#include "core/image.hpp"
|
#include "core/image.hpp"
|
||||||
#include "core/registry.hpp"
|
#include "core/registry.hpp"
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
int renderHeadless(const effector::Options& options) {
|
int renderHeadless(const effector::Options& options) {
|
||||||
const auto effect = effector::effectByName(options.effect);
|
const auto stages = effector::buildChain(options);
|
||||||
|
|
||||||
std::vector<effector::LoadFailure> failures;
|
std::vector<effector::LoadFailure> failures;
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ int renderHeadless(const effector::Options& options) {
|
|||||||
for (const auto& failure : failures) {
|
for (const auto& failure : failures) {
|
||||||
std::cerr << "effector: skipping " << failure.path << ": " << failure.message << "\n";
|
std::cerr << "effector: skipping " << failure.path << ": " << failure.message << "\n";
|
||||||
}
|
}
|
||||||
if (sources.empty()) {
|
if (sources.empty() && effector::chainNeedsSources(stages)) {
|
||||||
std::cerr << "effector: no usable source images\n";
|
std::cerr << "effector: no usable source images\n";
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
@@ -47,17 +48,15 @@ int renderHeadless(const effector::Options& options) {
|
|||||||
const std::uint64_t seed = options.seed.value_or(effector::Rng::randomSeed());
|
const std::uint64_t seed = options.seed.value_or(effector::Rng::randomSeed());
|
||||||
effector::Rng rng(seed);
|
effector::Rng rng(seed);
|
||||||
|
|
||||||
auto values = effect->defaults();
|
|
||||||
for (const auto& [key, value] : options.params) values[key] = value;
|
|
||||||
|
|
||||||
const auto start = std::chrono::steady_clock::now();
|
const auto start = std::chrono::steady_clock::now();
|
||||||
const auto image = effect->render(sources, options.width, options.height, rng, values);
|
const auto image =
|
||||||
|
effector::renderChain(stages, sources, options.width, options.height, rng);
|
||||||
const auto renderMillis =
|
const auto renderMillis =
|
||||||
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
|
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
|
||||||
image.save(options.output);
|
image.save(options.output);
|
||||||
|
|
||||||
std::cout << options.output << " " << options.width << "x" << options.height
|
std::cout << options.output << " " << options.width << "x" << options.height
|
||||||
<< " effect=" << effect->name() << " seed=" << seed << " render="
|
<< " effect=" << effector::chainSlug(stages) << " seed=" << seed << " render="
|
||||||
<< std::fixed << std::setprecision(1) << renderMillis << " ms\n";
|
<< std::fixed << std::setprecision(1) << renderMillis << " ms\n";
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
add_executable(effector_tests core_tests.cpp)
|
# The CLI parser joins the core here: it is GUI-free too, and the canvas size
|
||||||
|
# it produces has to obey the same bounds a project file does.
|
||||||
|
add_executable(effector_tests core_tests.cpp ../src/cli/options.cpp)
|
||||||
target_link_libraries(effector_tests PRIVATE effector_core)
|
target_link_libraries(effector_tests PRIVATE effector_core)
|
||||||
target_compile_options(effector_tests PRIVATE -Wall -Wextra -Wpedantic)
|
target_compile_options(effector_tests PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user