Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8113a06da7 | ||
|
|
3aade6f361 | ||
|
|
eb417bb41a | ||
|
|
f61161aef0 | ||
|
|
fc54d64bc4 | ||
|
|
d48a81c665 | ||
|
|
f4d8457828 | ||
|
|
749ea239bb | ||
|
|
1d0343b90a | ||
|
|
c34bc426fc | ||
|
|
e2252ecd30 | ||
|
|
2a64ab115e | ||
|
|
efee1cf224 |
@@ -14,8 +14,8 @@ Everything is warning-clean under `-Wall -Wextra -Wpedantic`; keep it that way.
|
||||
|
||||
The test suite is a single hand-rolled binary (no framework), so there is no
|
||||
per-test filter: run `./build/tests/effector_tests` directly to see the section
|
||||
headings (`rng`, `registry`, `scanlines`, `blur`, `chain`, `project`, `image
|
||||
io`, `batch loading`) and the `ok`/`FAIL` lines. To iterate on one area, comment
|
||||
headings (`rng`, `registry`, `scanlines`, `tiles`, `blur`, `chain`, `project`,
|
||||
`image io`, `batch loading`) and the `ok`/`FAIL` lines. To iterate on one area, comment
|
||||
out the other blocks in `tests/core_tests.cpp` or add a new
|
||||
`std::cout << "section\n"` block — checks are plain
|
||||
`check(condition, "description")` calls and `main()` returns the failure count.
|
||||
@@ -41,13 +41,29 @@ env -u WAYLAND_DISPLAY GDK_BACKEND=x11 DISPLAY=:79 build/effector ...
|
||||
|
||||
`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
|
||||
about an effect: `src/gui/window.cpp` builds SpinButtons/Switches/DropDowns from
|
||||
them and `src/cli/options.cpp` generates flags from them (`strip_height` →
|
||||
`--strip-height`). Never add effect-specific branches to the GUI or the parser —
|
||||
add a `ParamSpec` instead. Values travel as `ParamValues` (a
|
||||
about an effect: `src/gui/window.cpp` builds SpinButtons/Switches/DropDowns/file
|
||||
choosers from them and `src/cli/options.cpp` generates flags from them
|
||||
(`strip_height` → `--strip-height`). Never add effect-specific branches to the
|
||||
GUI or the parser — add a `ParamSpec` instead. A `Path` param names a file on
|
||||
disk; like a source path it is written relative to the project folder. A `Text`
|
||||
param is free-form text the user types, and a `Color` param is a colour written
|
||||
`#rrggbb` (`parseColor`/`formatColor`/`paramColor` in `src/core/params.hpp`),
|
||||
shown as a colour button in the interface. Values
|
||||
travel as `ParamValues` (a
|
||||
`map<string, variant<long,double,bool,string>>`), read back with
|
||||
`paramInt`/`paramDouble`/`paramBool`/`paramChoice`.
|
||||
|
||||
A param can also be marked `.styled()`, which says a *named style* carries it
|
||||
rather than the stage it sits on: the font and the colour of a text stage, but
|
||||
never its words or its placement. The pool a style comes from is named by
|
||||
`Effect::styleKind()` (empty by default — the effect takes no style; `"text"`
|
||||
for the text effect), and effects answering the same kind share one pool, so a
|
||||
second text-ish effect would slot into the styles that already exist. The
|
||||
front-ends know nothing more than that: the interface builds the style row and
|
||||
the project file writes a style's params from `styleSpecs(kind)`
|
||||
(`src/core/style.cpp`), which is to a style exactly what `params()` is to a
|
||||
stage.
|
||||
|
||||
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`)
|
||||
@@ -68,21 +84,58 @@ 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.
|
||||
|
||||
A stage carries a `ChainStage::enabled` flag, and `enabledStages`
|
||||
(`src/core/chain.cpp`) is the one place that reads it: a disabled stage is
|
||||
skipped whole — handed no image and drawing nothing from the shared rng — so
|
||||
switching one off renders exactly what the chain rendered before it was added.
|
||||
`renderChain`, `chainNeedsSources`, `chainTitle` and `chainSlug` all go through
|
||||
it, which is why the status line and the file name never mention a stage that
|
||||
did not run. A chain with every stage disabled throws rather than rendering
|
||||
nothing.
|
||||
|
||||
### A style is a look several stages share
|
||||
|
||||
`ChainStage::style` names a style, and `stageValues` (`src/core/chain.cpp`) is
|
||||
the one place that resolves one: an effect's defaults, then the stage's own
|
||||
values, then the style's values for the params it carries. A stage always keeps
|
||||
a full set of values of its own beside the style, so switching it to "Custom",
|
||||
dropping the style, or opening the project in a build that does not know the
|
||||
style renders exactly the same picture — a style moves stages together, it
|
||||
never holds the only copy of anything. The interface writes an edited param to
|
||||
both the style and the stage for that reason. `renderChain` takes the pool as a
|
||||
trailing argument; the command line builds no styles and passes none.
|
||||
|
||||
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`
|
||||
menu, reorder, remove, switch off with the check on the row, 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`, with `--disable NAME`
|
||||
leaving a stage in place but skipping it. 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
|
||||
named styles, the chain with its params (and its `"enabled": false` and
|
||||
`"style"`, each written only when there is something to say), 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.
|
||||
project keeps the defaults for params that did not exist when it was saved. The
|
||||
same leniency covers styles: one of a kind this build does not know, one with
|
||||
no name or a repeated one, and a stage naming a style the file does not hold
|
||||
are each dropped on their own rather than refusing the project.
|
||||
Source paths under the project's own folder are written relative, so a folder
|
||||
can be moved or shared whole.
|
||||
|
||||
@@ -94,7 +147,8 @@ is discarded rather than mixed into the new project (`dropLoadedImages_`).
|
||||
### Two link targets, deliberately
|
||||
|
||||
`effector_core` (static) holds image IO, params, effects, rng and links only
|
||||
`gdk-pixbuf-2.0` + Threads — **no gtkmm, no display**. The `effector` executable
|
||||
`gdk-pixbuf-2.0`, `pangocairo` (the text effect's glyphs; Pango and Cairo need
|
||||
no display) + Threads — **no gtkmm, no display**. The `effector` executable
|
||||
adds `src/main.cpp`, `src/cli/`, `src/gui/` and links gtkmm. Keep GTK out of
|
||||
`src/core/` and `src/effects/`; the tests link the core alone and rely on this.
|
||||
|
||||
@@ -103,7 +157,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
|
||||
`<random>`, because libstdc++ distributions are not portable across
|
||||
implementations. Same sources + settings + seed must produce byte-identical
|
||||
output on any machine — the tests assert this. Effects must draw *every* random
|
||||
output on any machine — the tests assert this. The one thing outside this is the
|
||||
text effect's glyphs, which are whatever shapes the machine's fonts and
|
||||
fontconfig give: everything it is handed, the rng included, is still used the
|
||||
same way. Effects must draw *every* random
|
||||
value from the `Rng&` they are handed, and must not depend on thread scheduling:
|
||||
the scanlines effect first draws the whole strip plan serially from the rng,
|
||||
then copies pixels in parallel.
|
||||
|
||||
+10
-1
@@ -15,11 +15,15 @@ set(CMAKE_CXX_FLAGS_RELEASE "-O3 -DNDEBUG")
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(GTKMM REQUIRED IMPORTED_TARGET gtkmm-4.0)
|
||||
pkg_check_modules(GDKPIXBUF REQUIRED IMPORTED_TARGET gdk-pixbuf-2.0)
|
||||
# Pango and Cairo draw the text effect's glyphs. Neither needs a display, so
|
||||
# the core stays headless.
|
||||
pkg_check_modules(PANGOCAIRO REQUIRED IMPORTED_TARGET pangocairo)
|
||||
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
# --- core: effects and image handling, no GUI ---------------------------------
|
||||
add_library(effector_core STATIC
|
||||
src/core/canvas.cpp
|
||||
src/core/chain.cpp
|
||||
src/core/effect.cpp
|
||||
src/core/image.cpp
|
||||
@@ -27,14 +31,19 @@ add_library(effector_core STATIC
|
||||
src/core/project.cpp
|
||||
src/core/registry.cpp
|
||||
src/core/rng.cpp
|
||||
src/core/style.cpp
|
||||
src/effects/blur.cpp
|
||||
src/effects/grain.cpp
|
||||
src/effects/monochrome.cpp
|
||||
src/effects/overlay.cpp
|
||||
src/effects/scanlines.cpp
|
||||
src/effects/text.cpp
|
||||
src/effects/tiles.cpp
|
||||
)
|
||||
target_include_directories(effector_core PUBLIC src)
|
||||
# The core is GUI-free: it only needs gdk-pixbuf for decoding and encoding,
|
||||
# so headless rendering works without a display.
|
||||
target_link_libraries(effector_core PUBLIC PkgConfig::GDKPIXBUF Threads::Threads)
|
||||
target_link_libraries(effector_core PUBLIC PkgConfig::GDKPIXBUF PkgConfig::PANGOCAIRO Threads::Threads)
|
||||
target_compile_options(effector_core PRIVATE -Wall -Wextra -Wpedantic)
|
||||
|
||||
# --- application --------------------------------------------------------------
|
||||
|
||||
@@ -34,6 +34,22 @@ build/effector -o out.png -s 1920x1080 \
|
||||
--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
|
||||
starting state (images, canvas size, effect settings, seed).
|
||||
|
||||
|
||||
+56
-2
@@ -1,9 +1,12 @@
|
||||
#include "cli/options.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <sstream>
|
||||
|
||||
#include "core/canvas.hpp"
|
||||
#include "core/chain.hpp"
|
||||
#include "core/effect.hpp"
|
||||
#include "core/registry.hpp"
|
||||
@@ -73,6 +76,24 @@ Options parseOptions(int argc, char** argv) {
|
||||
"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 == "--disable") {
|
||||
const std::string name = requireValue(argc, argv, i, arg);
|
||||
effectByName(name); // validate now, exactly as -e does
|
||||
options.disabled.push_back(name);
|
||||
} else if (arg == "--seed") {
|
||||
const std::string value = requireValue(argc, argv, i, arg);
|
||||
long seed = 0;
|
||||
@@ -110,6 +131,20 @@ Options parseOptions(int argc, char** argv) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ParamSpec::Type::Path:
|
||||
case ParamSpec::Type::Text:
|
||||
options.params[spec->key] = requireValue(argc, argv, i, arg);
|
||||
break;
|
||||
case ParamSpec::Type::Color: {
|
||||
const std::string text = requireValue(argc, argv, i, arg);
|
||||
std::uint8_t rgb[3];
|
||||
if (!parseColor(text, rgb)) {
|
||||
throw EffectError(arg + ": '" + text +
|
||||
"' is not a colour, expected e.g. #ff8800");
|
||||
}
|
||||
options.params[spec->key] = formatColor(rgb);
|
||||
break;
|
||||
}
|
||||
case ParamSpec::Type::Choice: {
|
||||
const std::string text = requireValue(argc, argv, i, arg);
|
||||
bool valid = false;
|
||||
@@ -125,7 +160,11 @@ Options parseOptions(int argc, char** argv) {
|
||||
options.images.push_back(arg);
|
||||
}
|
||||
}
|
||||
if (options.effects.empty()) options.effects.push_back(defaultEffectName());
|
||||
// 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);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -134,14 +173,27 @@ std::string usage(const char* programName) {
|
||||
out << "Usage: " << programName << " [options] [image ...]\n\n"
|
||||
<< "Image effects app. Runs the GTK interface unless --output is given.\n\n"
|
||||
<< "Options:\n"
|
||||
<< " -e, --effect NAME effect to apply, repeat to cascade (default: " << defaultEffectName() << ")\n"
|
||||
<< " -e, --effect NAME effect to apply, repeat to cascade (--output"
|
||||
" defaults to " << defaultEffectName() << ")\n"
|
||||
<< " -o, --output FILE render headlessly to this file and exit\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"
|
||||
<< " --disable NAME keep this effect in the chain but skip it\n"
|
||||
<< " --seed N random seed (default: random)\n"
|
||||
<< " --list-effects list available effects 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()) {
|
||||
out << "\n" << effect->title() << " options:\n";
|
||||
for (const auto& spec : effect->params()) {
|
||||
@@ -161,6 +213,8 @@ std::vector<ChainStage> buildChain(const Options& options) {
|
||||
ChainStage stage;
|
||||
stage.effect = effectByName(name);
|
||||
stage.values = stage.effect->defaults();
|
||||
stage.enabled = std::find(options.disabled.begin(), options.disabled.end(), name) ==
|
||||
options.disabled.end();
|
||||
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;
|
||||
|
||||
+9
-1
@@ -5,6 +5,7 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/canvas.hpp"
|
||||
#include "core/chain.hpp"
|
||||
#include "core/params.hpp"
|
||||
|
||||
@@ -13,9 +14,15 @@ namespace effector {
|
||||
struct Options {
|
||||
std::vector<std::string> images;
|
||||
std::vector<std::string> effects; // one per cascade stage, in order
|
||||
// The effects named by --disable: their stages stay in the chain, in
|
||||
// place, but are skipped. Named rather than numbered, exactly as the
|
||||
// params are, so two stages of one effect are disabled together.
|
||||
std::vector<std::string> disabled;
|
||||
std::string output; // empty -> launch the interface
|
||||
int width = 1920;
|
||||
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;
|
||||
ParamValues params; // only the keys the user actually passed
|
||||
bool listEffects = false;
|
||||
@@ -30,7 +37,8 @@ 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`.
|
||||
// together for `-e scanlines -e blur`. A stage named by --disable is built
|
||||
// with all its settings and marked to be skipped.
|
||||
std::vector<ChainStage> buildChain(const Options& options);
|
||||
|
||||
} // 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
|
||||
+47
-14
@@ -2,17 +2,51 @@
|
||||
|
||||
namespace effector {
|
||||
|
||||
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages) {
|
||||
std::vector<const ChainStage*> running;
|
||||
running.reserve(stages.size());
|
||||
for (const auto& stage : stages) {
|
||||
if (stage.enabled && stage.effect) running.push_back(&stage);
|
||||
}
|
||||
return running;
|
||||
}
|
||||
|
||||
ParamValues stageValues(const ChainStage& stage, const StyleSet& styles) {
|
||||
ParamValues values = stage.effect->defaults();
|
||||
for (const auto& [key, value] : stage.values) values[key] = value;
|
||||
|
||||
const Style* style = findStyle(styles, stage.effect->styleKind(), stage.style);
|
||||
if (style != nullptr) {
|
||||
// Only the params the effect marked styled(): the words and the
|
||||
// placement stay the stage's own, which is what lets two stages share
|
||||
// one look without sharing a position.
|
||||
for (const auto& spec : stage.effect->params()) {
|
||||
if (!spec.inStyle) continue;
|
||||
const auto found = style->values.find(spec.key);
|
||||
if (found != style->values.end()) values[spec.key] = found->second;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
||||
int width, int height, Rng& rng) {
|
||||
int width, int height, Rng& rng, const StyleSet& styles) {
|
||||
if (stages.empty()) throw EffectError("The effect chain is empty.");
|
||||
for (const auto& stage : stages) {
|
||||
if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
|
||||
}
|
||||
|
||||
// A disabled stage never reaches the render at all: it is not handed an
|
||||
// image and draws nothing from the shared rng, so switching one off gives
|
||||
// back exactly the picture the chain made before it was added.
|
||||
const auto running = enabledStages(stages);
|
||||
if (running.empty()) throw EffectError("Every effect in the chain is disabled.");
|
||||
|
||||
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.");
|
||||
for (std::size_t i = 0; i < running.size(); ++i) {
|
||||
const auto& stage = *running[i];
|
||||
|
||||
auto values = stage.effect->defaults();
|
||||
for (const auto& [key, value] : stage.values) values[key] = value;
|
||||
const auto values = stageValues(stage, styles);
|
||||
|
||||
if (i == 0) {
|
||||
image = stage.effect->render(sources, width, height, rng, values);
|
||||
@@ -27,26 +61,25 @@ Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image
|
||||
}
|
||||
|
||||
bool chainNeedsSources(const std::vector<ChainStage>& stages) {
|
||||
if (stages.empty() || !stages.front().effect) return false;
|
||||
return stages.front().effect->needsSources();
|
||||
const auto running = enabledStages(stages);
|
||||
if (running.empty()) return false;
|
||||
return running.front()->effect->needsSources();
|
||||
}
|
||||
|
||||
std::string chainTitle(const std::vector<ChainStage>& stages) {
|
||||
std::string text;
|
||||
for (const auto& stage : stages) {
|
||||
if (!stage.effect) continue;
|
||||
for (const auto* stage : enabledStages(stages)) {
|
||||
if (!text.empty()) text += " → ";
|
||||
text += stage.effect->title();
|
||||
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;
|
||||
for (const auto* stage : enabledStages(stages)) {
|
||||
if (!text.empty()) text += "-";
|
||||
text += stage.effect->name();
|
||||
text += stage->effect->name();
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
+33
-9
@@ -7,6 +7,7 @@
|
||||
#include "core/image.hpp"
|
||||
#include "core/params.hpp"
|
||||
#include "core/rng.hpp"
|
||||
#include "core/style.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
@@ -21,23 +22,46 @@ inline constexpr int kMaxCanvasSide = 20000;
|
||||
struct ChainStage {
|
||||
EffectPtr effect;
|
||||
ParamValues values; // missing keys fall back to the effect's defaults
|
||||
// A disabled stage is skipped whole, keeping its settings and its place in
|
||||
// the chain, so a stage can be switched off and back on while trying
|
||||
// things out without being taken apart and rebuilt.
|
||||
bool enabled = true;
|
||||
// The style this stage follows, by name, in the pool its effect names with
|
||||
// styleKind(); empty when the stage stands on its own. A stage keeps a
|
||||
// full set of values either way, so a style that is dropped or renamed
|
||||
// away leaves the stage rendering exactly what it rendered before.
|
||||
std::string style = {};
|
||||
};
|
||||
|
||||
// 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.
|
||||
// The values a stage actually runs with: its effect's defaults, then the
|
||||
// stage's own values, then the values of the style it follows for the params
|
||||
// that style carries. The one place a style is resolved.
|
||||
ParamValues stageValues(const ChainStage& stage, const StyleSet& styles);
|
||||
|
||||
// The stages that actually run, in order, as pointers into `stages`. A
|
||||
// disabled stage draws nothing from the rng and is handed no image, so a chain
|
||||
// renders exactly as it would if that stage had never been added -- this is
|
||||
// the one place the rest of the code asks what "disabled" means.
|
||||
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages);
|
||||
|
||||
// Renders the enabled 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.
|
||||
// reproduces the cascade byte for byte. Throws when no stage is left to run.
|
||||
// `styles` is the project's pool; a chain built without one -- the command
|
||||
// line's -- passes none and renders from the stages alone.
|
||||
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
||||
int width, int height, Rng& rng);
|
||||
int width, int height, Rng& rng, const StyleSet& styles = {});
|
||||
|
||||
// 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.
|
||||
// Whether the chain has to be given source images. Only the first stage that
|
||||
// runs ever sees them — every later one works on the stage before it — so this
|
||||
// is simply whether that effect declares that it needs them.
|
||||
bool chainNeedsSources(const std::vector<ChainStage>& stages);
|
||||
|
||||
// "Scanlines → Blur", for window titles and status lines.
|
||||
// "Scanlines → Blur", for window titles and status lines. Both of these name
|
||||
// what was rendered, so a disabled stage is left out of them.
|
||||
std::string chainTitle(const std::vector<ChainStage>& stages);
|
||||
|
||||
// "scanlines-blur", for file names.
|
||||
|
||||
+1
-16
@@ -4,22 +4,7 @@ namespace effector {
|
||||
|
||||
ParamValues Effect::defaults() const {
|
||||
ParamValues values;
|
||||
for (const auto& spec : params()) {
|
||||
switch (spec.type) {
|
||||
case ParamSpec::Type::Int:
|
||||
values[spec.key] = static_cast<long>(spec.defaultValue);
|
||||
break;
|
||||
case ParamSpec::Type::Double:
|
||||
values[spec.key] = spec.defaultValue;
|
||||
break;
|
||||
case ParamSpec::Type::Bool:
|
||||
values[spec.key] = spec.defaultValue != 0.0;
|
||||
break;
|
||||
case ParamSpec::Type::Choice:
|
||||
values[spec.key] = spec.defaultChoice;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const auto& spec : params()) values[spec.key] = paramDefault(spec);
|
||||
return values;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,12 @@ public:
|
||||
// chain that starts with one of them needs no sources at all.
|
||||
virtual bool needsSources() const { return true; }
|
||||
|
||||
// The pool of named styles this effect's styled params are drawn from, or
|
||||
// empty when it takes no style. Effects that answer the same kind share
|
||||
// one pool, so a stage can be pointed at a look its neighbours already
|
||||
// use. An effect with no kind must declare no styled params.
|
||||
virtual std::string styleKind() const { return {}; }
|
||||
|
||||
virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
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 *
|
||||
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;
|
||||
PixbufPtr pixbuf = wrap(gdk_pixbuf_new_from_file(path.c_str(), &error));
|
||||
if (!pixbuf) throw std::runtime_error(takeError(error));
|
||||
@@ -59,11 +59,23 @@ Image Image::load(const std::string& path) {
|
||||
const guint8* src = gdk_pixbuf_get_pixels(pixbuf.get());
|
||||
|
||||
Image image(width, height);
|
||||
if (alphaOut != nullptr) {
|
||||
alphaOut->assign(static_cast<std::size_t>(std::max(width, 0)) *
|
||||
static_cast<std::size_t>(std::max(height, 0)),
|
||||
255);
|
||||
}
|
||||
for (int y = 0; y < height; ++y) {
|
||||
const guint8* in = src + static_cast<std::size_t>(y) * srcStride;
|
||||
std::uint8_t* out = image.row(y);
|
||||
if (channels == 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 {
|
||||
// Flatten alpha onto white, matching what a viewer would show.
|
||||
for (int x = 0; x < width; ++x) {
|
||||
|
||||
+5
-1
@@ -34,7 +34,11 @@ public:
|
||||
|
||||
// Decodes via GdkPixbuf (PNG, JPEG, WebP, TIFF, BMP, ...), applying the
|
||||
// embedded EXIF orientation. Throws std::runtime_error on failure.
|
||||
static Image load(const std::string& path);
|
||||
// Transparency is flattened onto white, as a viewer would show it, unless
|
||||
// `alphaOut` is given: it then receives one byte per pixel (255 throughout
|
||||
// for a file without an alpha channel) and the colours are left untouched,
|
||||
// which is what an effect compositing the image needs.
|
||||
static Image load(const std::string& path, std::vector<std::uint8_t>* alphaOut = nullptr);
|
||||
|
||||
// Encodes by file extension; png is used when the extension is unknown.
|
||||
void save(const std::string& path) const;
|
||||
|
||||
@@ -10,6 +10,12 @@ std::string ParamSpec::flag() const {
|
||||
return flag;
|
||||
}
|
||||
|
||||
ParamSpec ParamSpec::styled() const {
|
||||
ParamSpec copy = *this;
|
||||
copy.inStyle = true;
|
||||
return copy;
|
||||
}
|
||||
|
||||
ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum,
|
||||
long maximum, std::string help, long step) {
|
||||
ParamSpec spec;
|
||||
@@ -63,6 +69,37 @@ ParamSpec ParamSpec::makeChoice(std::string key, std::string label, std::string
|
||||
return spec;
|
||||
}
|
||||
|
||||
ParamSpec ParamSpec::makePath(std::string key, std::string label, std::string help) {
|
||||
ParamSpec spec;
|
||||
spec.type = Type::Path;
|
||||
spec.key = std::move(key);
|
||||
spec.label = std::move(label);
|
||||
spec.help = std::move(help);
|
||||
return spec;
|
||||
}
|
||||
|
||||
ParamSpec ParamSpec::makeText(std::string key, std::string label, std::string defaultText,
|
||||
std::string help) {
|
||||
ParamSpec spec;
|
||||
spec.type = Type::Text;
|
||||
spec.key = std::move(key);
|
||||
spec.label = std::move(label);
|
||||
spec.help = std::move(help);
|
||||
spec.defaultChoice = std::move(defaultText);
|
||||
return spec;
|
||||
}
|
||||
|
||||
ParamSpec ParamSpec::makeColor(std::string key, std::string label, std::string defaultColor,
|
||||
std::string help) {
|
||||
ParamSpec spec;
|
||||
spec.type = Type::Color;
|
||||
spec.key = std::move(key);
|
||||
spec.label = std::move(label);
|
||||
spec.help = std::move(help);
|
||||
spec.defaultChoice = std::move(defaultColor);
|
||||
return spec;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
const ParamValue* find(const ParamValues& values, const std::string& key) {
|
||||
@@ -72,6 +109,23 @@ const ParamValue* find(const ParamValues& values, const std::string& key) {
|
||||
|
||||
} // namespace
|
||||
|
||||
ParamValue paramDefault(const ParamSpec& spec) {
|
||||
switch (spec.type) {
|
||||
case ParamSpec::Type::Int:
|
||||
return static_cast<long>(spec.defaultValue);
|
||||
case ParamSpec::Type::Double:
|
||||
return spec.defaultValue;
|
||||
case ParamSpec::Type::Bool:
|
||||
return spec.defaultValue != 0.0;
|
||||
case ParamSpec::Type::Choice:
|
||||
case ParamSpec::Type::Path:
|
||||
case ParamSpec::Type::Text:
|
||||
case ParamSpec::Type::Color:
|
||||
break;
|
||||
}
|
||||
return spec.defaultChoice;
|
||||
}
|
||||
|
||||
long paramInt(const ParamValues& values, const std::string& key, long fallback) {
|
||||
if (const auto* value = find(values, key)) {
|
||||
if (const auto* v = std::get_if<long>(value)) return *v;
|
||||
@@ -99,10 +153,61 @@ bool paramBool(const ParamValues& values, const std::string& key, bool fallback)
|
||||
|
||||
std::string paramChoice(const ParamValues& values, const std::string& key,
|
||||
const std::string& fallback) {
|
||||
return paramText(values, key, fallback);
|
||||
}
|
||||
|
||||
std::string paramText(const ParamValues& values, const std::string& key,
|
||||
const std::string& fallback) {
|
||||
if (const auto* value = find(values, key)) {
|
||||
if (const auto* v = std::get_if<std::string>(value)) return *v;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
int hexDigit(char c) {
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||
return -1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool parseColor(const std::string& text, std::uint8_t rgb[3]) {
|
||||
std::string digits = text;
|
||||
if (!digits.empty() && digits.front() == '#') digits.erase(digits.begin());
|
||||
if (digits.size() != 3 && digits.size() != 6) return false;
|
||||
|
||||
int parsed[6];
|
||||
for (std::size_t i = 0; i < digits.size(); ++i) {
|
||||
parsed[i] = hexDigit(digits[i]);
|
||||
if (parsed[i] < 0) return false;
|
||||
}
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
// "#abc" is the usual shorthand for "#aabbcc".
|
||||
const int value = digits.size() == 3 ? parsed[c] * 17 : parsed[c * 2] * 16 + parsed[c * 2 + 1];
|
||||
rgb[c] = static_cast<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
|
||||
|
||||
+38
-1
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <variant>
|
||||
@@ -10,7 +11,7 @@ namespace effector {
|
||||
// Declarative description of one effect knob. The GUI turns these into widgets
|
||||
// and the CLI turns them into flags, so neither knows about any effect.
|
||||
struct ParamSpec {
|
||||
enum class Type { Int, Double, Bool, Choice };
|
||||
enum class Type { Int, Double, Bool, Choice, Path, Text, Color };
|
||||
|
||||
Type type = Type::Int;
|
||||
std::string key;
|
||||
@@ -23,9 +24,17 @@ struct ParamSpec {
|
||||
int digits = 2;
|
||||
std::string defaultChoice;
|
||||
std::vector<std::string> choices;
|
||||
// Whether a named style carries this param rather than the stage it sits
|
||||
// on: see core/style.hpp.
|
||||
bool inStyle = false;
|
||||
|
||||
std::string flag() const; // "strip_height" -> "--strip-height"
|
||||
|
||||
// A copy of this spec marked as carried by a style, so that a declaration
|
||||
// reads makeDouble("font_size", ...).styled() and the factories keep the
|
||||
// signatures they have.
|
||||
ParamSpec styled() const;
|
||||
|
||||
static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum,
|
||||
long maximum, std::string help = {}, long step = 1);
|
||||
static ParamSpec makeDouble(std::string key, std::string label, double defaultValue,
|
||||
@@ -35,15 +44,43 @@ struct ParamSpec {
|
||||
std::string help = {});
|
||||
static ParamSpec makeChoice(std::string key, std::string label, std::string defaultChoice,
|
||||
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 ParamValues = std::map<std::string, ParamValue>;
|
||||
|
||||
// The value a spec stands for when nothing has been set: one place, so that
|
||||
// an effect's defaults and a style's are filled the same way.
|
||||
ParamValue paramDefault(const ParamSpec& spec);
|
||||
|
||||
long paramInt(const ParamValues& values, const std::string& key, long fallback);
|
||||
double paramDouble(const ParamValues& values, const std::string& key, double fallback);
|
||||
bool paramBool(const ParamValues& values, const std::string& key, bool fallback);
|
||||
std::string paramChoice(const ParamValues& values, const std::string& key,
|
||||
const std::string& fallback);
|
||||
std::string paramText(const ParamValues& values, const std::string& key,
|
||||
const std::string& fallback);
|
||||
|
||||
// Reads "#rgb" or "#rrggbb" (the leading hash optional) into three bytes.
|
||||
// Returns false and leaves `rgb` untouched for anything else, so a malformed
|
||||
// value falls back to the default rather than painting an arbitrary colour.
|
||||
bool parseColor(const std::string& text, std::uint8_t rgb[3]);
|
||||
std::string formatColor(const std::uint8_t rgb[3]);
|
||||
|
||||
// The colour of a param, or `fallback` when the value does not parse.
|
||||
void paramColor(const ParamValues& values, const std::string& key, const std::string& fallback,
|
||||
std::uint8_t rgb[3]);
|
||||
|
||||
} // namespace effector
|
||||
|
||||
+218
-56
@@ -1,12 +1,16 @@
|
||||
#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"
|
||||
|
||||
@@ -24,7 +28,7 @@ struct Json {
|
||||
bool boolean = false;
|
||||
double number = 0.0;
|
||||
bool integral = true; // written without a fraction or exponent
|
||||
std::string text;
|
||||
std::string text; // the string, or the digits a number was written as
|
||||
std::vector<Json> items;
|
||||
std::vector<std::pair<std::string, Json>> members;
|
||||
|
||||
@@ -226,11 +230,15 @@ private:
|
||||
Json value;
|
||||
value.kind = Json::Kind::Number;
|
||||
value.integral = integral;
|
||||
try {
|
||||
value.number = std::stod(text_.substr(start, pos_ - start));
|
||||
} catch (const std::exception&) {
|
||||
// from_chars rather than stod: a number in JSON always has a point for
|
||||
// a decimal mark, whatever LC_NUMERIC says the user's decimal mark is.
|
||||
const char* const first = text_.data() + start + (text_[start] == '+' ? 1 : 0);
|
||||
const char* const last = text_.data() + pos_;
|
||||
const auto parsed = std::from_chars(first, last, value.number);
|
||||
if (parsed.ec != std::errc() || parsed.ptr != last) {
|
||||
fail("'" + text_.substr(start, pos_ - start) + "' is not a number");
|
||||
}
|
||||
value.text.assign(first, last); // kept whole for the seed, see below
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -263,6 +271,7 @@ std::string quote(const std::string& text) {
|
||||
|
||||
std::string number(double value, int digits) {
|
||||
std::ostringstream out;
|
||||
out.imbue(std::locale::classic());
|
||||
out.setf(std::ios::fixed);
|
||||
out.precision(digits);
|
||||
out << value;
|
||||
@@ -298,20 +307,134 @@ int canvasSide(const Json& value, const char* what) {
|
||||
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();
|
||||
}
|
||||
|
||||
// The params of a stage or of a style, written from the specs rather than from
|
||||
// whatever the values happen to hold: a key the effect does not declare is
|
||||
// never written, so it can never be read back either.
|
||||
void writeParams(std::ostream& out, const std::vector<ParamSpec>& specs, const ParamValues& values,
|
||||
const std::string& directory) {
|
||||
out << "{";
|
||||
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(values, spec.key, spec.defaultValue != 0.0) ? "true" : "false");
|
||||
break;
|
||||
case ParamSpec::Type::Int:
|
||||
out << paramInt(values, spec.key, static_cast<long>(spec.defaultValue));
|
||||
break;
|
||||
case ParamSpec::Type::Double:
|
||||
out << number(paramDouble(values, spec.key, spec.defaultValue), spec.digits);
|
||||
break;
|
||||
case ParamSpec::Type::Choice:
|
||||
case ParamSpec::Type::Text:
|
||||
out << quote(paramText(values, spec.key, spec.defaultChoice));
|
||||
break;
|
||||
case ParamSpec::Type::Color: {
|
||||
std::uint8_t rgb[3];
|
||||
paramColor(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(values, spec.key, "");
|
||||
out << quote(path.empty() ? path : relativeTo(path, directory));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
out << (specs.empty() ? "}" : "\n }");
|
||||
}
|
||||
|
||||
// The mirror image: only declared keys are read, each behind the gate its type
|
||||
// asks for, so anything else in the file leaves the default standing.
|
||||
void readParams(const std::vector<ParamSpec>& specs, const Json& params,
|
||||
const std::string& directory, ParamValues& values) {
|
||||
for (const auto& spec : specs) {
|
||||
const auto* value = params.member(spec.key);
|
||||
if (value == nullptr) continue;
|
||||
switch (spec.type) {
|
||||
case ParamSpec::Type::Bool:
|
||||
if (value->kind == Json::Kind::Bool) values[spec.key] = value->boolean;
|
||||
break;
|
||||
case ParamSpec::Type::Int:
|
||||
if (value->kind == Json::Kind::Number) {
|
||||
values[spec.key] = static_cast<long>(std::llround(value->number));
|
||||
}
|
||||
break;
|
||||
case ParamSpec::Type::Double:
|
||||
if (value->kind == Json::Kind::Number) values[spec.key] = value->number;
|
||||
break;
|
||||
case ParamSpec::Type::Path:
|
||||
if (value->kind == Json::Kind::String) {
|
||||
values[spec.key] =
|
||||
value->text.empty() ? value->text : resolve(value->text, directory);
|
||||
}
|
||||
break;
|
||||
case ParamSpec::Type::Text:
|
||||
if (value->kind == Json::Kind::String) 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)) {
|
||||
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) {
|
||||
values[spec.key] = choice;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // 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
|
||||
<< "},\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\": [";
|
||||
@@ -320,37 +443,37 @@ std::string projectToJson(const Project& project, const std::string& directory)
|
||||
}
|
||||
out << (project.sources.empty() ? "" : "\n ") << "],\n";
|
||||
|
||||
// Before the chain, because a stage's "style" is a name from this list.
|
||||
if (!project.styles.empty()) {
|
||||
out << " \"styles\": [";
|
||||
for (std::size_t i = 0; i < project.styles.size(); ++i) {
|
||||
const auto& style = project.styles[i];
|
||||
out << (i == 0 ? "\n" : ",\n");
|
||||
out << " {\n \"kind\": " << quote(style.kind) << ",\n";
|
||||
out << " \"name\": " << quote(style.name) << ",\n";
|
||||
out << " \"params\": ";
|
||||
writeParams(out, styleSpecs(style.kind), style.values, directory);
|
||||
out << "\n }";
|
||||
}
|
||||
out << "\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:
|
||||
out << quote(paramChoice(stage.values, spec.key, spec.defaultChoice));
|
||||
break;
|
||||
}
|
||||
}
|
||||
out << (specs.empty() ? "}" : "\n }") << "\n }";
|
||||
// Only written when it is false: a stage is enabled unless the file
|
||||
// says otherwise, so an ordinary project stays as uncluttered as it
|
||||
// was before a stage could be switched off at all.
|
||||
if (!stage.enabled) out << " \"enabled\": false,\n";
|
||||
// Likewise only when there is one: a stage that stands on its own
|
||||
// values says nothing about styles at all.
|
||||
if (!stage.style.empty()) out << " \"style\": " << quote(stage.style) << ",\n";
|
||||
out << " \"params\": ";
|
||||
writeParams(out, stage.effect->params(), stage.values, directory);
|
||||
out << "\n }";
|
||||
}
|
||||
out << (project.stages.empty() ? "" : "\n ") << "]\n";
|
||||
out << "}\n";
|
||||
@@ -369,11 +492,32 @@ Project projectFromJson(const std::string& text, const std::string& directory) {
|
||||
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")) {
|
||||
if (seed->kind == Json::Kind::Number) {
|
||||
project.seed = static_cast<std::uint64_t>(seed->number);
|
||||
// Taken from the digits rather than from the double: a 64-bit seed has
|
||||
// more bits than a double has mantissa, and a seed that comes back off
|
||||
// by a few is a project that no longer renders its own picture.
|
||||
std::uint64_t value = 0;
|
||||
const char* const first = seed->text.data();
|
||||
const char* const last = first + seed->text.size();
|
||||
if (seed->kind == Json::Kind::Number && seed->integral &&
|
||||
std::from_chars(first, last, value).ptr == last) {
|
||||
project.seed = value;
|
||||
} else if (seed->kind == Json::Kind::Number) {
|
||||
throw EffectError("the seed must be a whole number that fits in 64 bits");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,6 +529,33 @@ Project projectFromJson(const std::string& text, const std::string& directory) {
|
||||
}
|
||||
}
|
||||
|
||||
// Read before the chain, so that a stage's "style" can be checked against
|
||||
// the pool as it is read.
|
||||
if (const auto* styles = root.member("styles")) {
|
||||
for (const auto& item : styles->items) {
|
||||
const auto* kind = item.member("kind");
|
||||
const auto* name = item.member("name");
|
||||
if (kind == nullptr || kind->kind != Json::Kind::String) continue;
|
||||
if (name == nullptr || name->kind != Json::Kind::String || name->text.empty()) continue;
|
||||
|
||||
// A kind no effect in this build claims carries no params, so
|
||||
// there would be nothing to keep; a repeated name would make
|
||||
// "style": "Title" ambiguous, so the first one wins.
|
||||
const auto specs = styleSpecs(kind->text);
|
||||
if (specs.empty()) continue;
|
||||
if (findStyle(project.styles, kind->text, name->text) != nullptr) continue;
|
||||
|
||||
Style style;
|
||||
style.kind = kind->text;
|
||||
style.name = name->text;
|
||||
style.values = styleDefaults(style.kind);
|
||||
if (const auto* params = item.member("params")) {
|
||||
readParams(specs, *params, directory, style.values);
|
||||
}
|
||||
project.styles.push_back(std::move(style));
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -398,35 +569,26 @@ Project projectFromJson(const std::string& text, const std::string& directory) {
|
||||
ChainStage stage;
|
||||
stage.effect = effectByName(name->text); // throws on an unknown effect
|
||||
stage.values = stage.effect->defaults();
|
||||
if (const auto* enabled = item.member("enabled");
|
||||
enabled != nullptr && enabled->kind == Json::Kind::Bool) {
|
||||
stage.enabled = enabled->boolean;
|
||||
}
|
||||
|
||||
// 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::Choice:
|
||||
for (const auto& choice : spec.choices) {
|
||||
if (value->kind == Json::Kind::String && value->text == choice) {
|
||||
stage.values[spec.key] = choice;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
readParams(stage.effect->params(), *params, directory, stage.values);
|
||||
}
|
||||
|
||||
// A style is kept only when the file actually holds one of that name
|
||||
// for this effect's pool: a stage pointing at a style that is not
|
||||
// there falls back to the values it wrote beside it, which are the
|
||||
// ones it last rendered with.
|
||||
if (const auto* style = item.member("style");
|
||||
style != nullptr && style->kind == Json::Kind::String &&
|
||||
findStyle(project.styles, stage.effect->styleKind(), style->text) != nullptr) {
|
||||
stage.style = style->text;
|
||||
}
|
||||
project.stages.push_back(std::move(stage));
|
||||
}
|
||||
|
||||
+15
-1
@@ -5,8 +5,10 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/canvas.hpp"
|
||||
#include "core/chain.hpp"
|
||||
#include "core/params.hpp"
|
||||
#include "core/style.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
@@ -16,8 +18,18 @@ namespace effector {
|
||||
struct Project {
|
||||
std::vector<std::string> sources; // absolute paths
|
||||
std::vector<ChainStage> stages;
|
||||
// The named looks the stages draw their formatting from. Kept beside the
|
||||
// chain rather than inside it because that is the whole point of a style:
|
||||
// several stages share one, and editing it moves all of them at once.
|
||||
StyleSet styles;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -29,7 +41,9 @@ 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.
|
||||
// does not declare are dropped, and missing ones keep the effect's default. A
|
||||
// style of an unknown kind, or a stage naming a style the file does not hold,
|
||||
// is dropped the same way: the stage keeps its own values and still renders.
|
||||
Project projectFromJson(const std::string& text, const std::string& directory = {});
|
||||
|
||||
// Both throw EffectError: the file is the user's, so failures are reported
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
#include "effects/blur.hpp"
|
||||
#include "effects/grain.hpp"
|
||||
#include "effects/monochrome.hpp"
|
||||
#include "effects/overlay.hpp"
|
||||
#include "effects/scanlines.hpp"
|
||||
#include "effects/text.hpp"
|
||||
#include "effects/tiles.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
@@ -12,6 +16,10 @@ const std::vector<EffectPtr>& effects() {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "core/style.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "core/registry.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
std::vector<ParamSpec> styleSpecs(const std::string& kind) {
|
||||
std::vector<ParamSpec> specs;
|
||||
if (kind.empty()) return specs;
|
||||
for (const auto& effect : effects()) {
|
||||
if (effect->styleKind() != kind) continue;
|
||||
for (const auto& spec : effect->params()) {
|
||||
if (!spec.inStyle) continue;
|
||||
const bool known = std::any_of(specs.begin(), specs.end(), [&](const ParamSpec& seen) {
|
||||
return seen.key == spec.key;
|
||||
});
|
||||
if (!known) specs.push_back(spec);
|
||||
}
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
ParamValues styleDefaults(const std::string& kind) {
|
||||
ParamValues values;
|
||||
for (const auto& spec : styleSpecs(kind)) values[spec.key] = paramDefault(spec);
|
||||
return values;
|
||||
}
|
||||
|
||||
const Style* findStyle(const StyleSet& styles, const std::string& kind, const std::string& name) {
|
||||
if (kind.empty() || name.empty()) return nullptr;
|
||||
for (const auto& style : styles) {
|
||||
if (style.kind == kind && style.name == name) return &style;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/params.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// A named set of formatting values, shared by every stage that follows it.
|
||||
//
|
||||
// A style carries only the params an effect marked styled() -- the look, never
|
||||
// the placement -- so several text stages can share one typography while each
|
||||
// keeps its own words and its own corner of the canvas. Changing the style is
|
||||
// the point: it changes every stage pointing at it at once.
|
||||
struct Style {
|
||||
std::string kind; // the pool, as Effect::styleKind() names it
|
||||
std::string name; // unique within a kind, never empty
|
||||
ParamValues values; // the styled params only
|
||||
};
|
||||
|
||||
using StyleSet = std::vector<Style>;
|
||||
|
||||
// The styled specs of a kind: every registered effect answering that kind
|
||||
// contributes its styled params, the first declaration of a key winning. This
|
||||
// is to a style what Effect::params() is to a stage -- the only thing the
|
||||
// interface and the project file know about what a style holds -- so an empty
|
||||
// or unknown kind gives an empty list.
|
||||
std::vector<ParamSpec> styleSpecs(const std::string& kind);
|
||||
|
||||
// A fresh set of values for a style of that kind: every styled param at the
|
||||
// default its effect declares, the way Effect::defaults() fills a stage.
|
||||
ParamValues styleDefaults(const std::string& kind);
|
||||
|
||||
// The style of that name in that pool, or nullptr. A stage naming a style that
|
||||
// is not there simply stands on its own values.
|
||||
const Style* findStyle(const StyleSet& styles, const std::string& kind, const std::string& name);
|
||||
|
||||
} // 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,315 @@
|
||||
#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\", ...")
|
||||
.styled(),
|
||||
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.")
|
||||
.styled(),
|
||||
ParamSpec::makeBool("font_italic", "Italic", false, "Slants the glyphs.").styled(),
|
||||
ParamSpec::makeDouble("font_size", "Size", 96.0, 4.0, 2000.0,
|
||||
"Height of the glyphs in pixels.", 1.0, 1)
|
||||
.styled(),
|
||||
ParamSpec::makeColor("text_color", "Colour", "#ffffff", "The colour of the glyphs.").styled(),
|
||||
ParamSpec::makeChoice("text_align", "Alignment", "center", {"left", "center", "right"},
|
||||
"How several lines line up against each other.")
|
||||
.styled(),
|
||||
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.")
|
||||
.styled(),
|
||||
ParamSpec::makeColor("box_color", "Box colour", "#000000",
|
||||
"The colour the box is filled with.")
|
||||
.styled(),
|
||||
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.")
|
||||
.styled(),
|
||||
ParamSpec::makeDouble("box_opacity", "Box opacity", 1.0, 0.0, 1.0,
|
||||
"How strongly the box is mixed over the input.")
|
||||
.styled(),
|
||||
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.")
|
||||
.styled(),
|
||||
};
|
||||
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,50 @@
|
||||
#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;
|
||||
std::string styleKind() const override { return "text"; }
|
||||
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
|
||||
+597
-39
@@ -2,9 +2,13 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <sstream>
|
||||
#include <utility>
|
||||
|
||||
#include "core/canvas.hpp"
|
||||
#include "core/chain.hpp"
|
||||
#include "core/project.hpp"
|
||||
#include "core/registry.hpp"
|
||||
@@ -46,7 +50,8 @@ EffectorWindow::EffectorWindow(const Options& initial) {
|
||||
set_default_size(1180, 760);
|
||||
|
||||
effects_ = effects();
|
||||
stages_.push_back({effects_.front(), effects_.front()->defaults()});
|
||||
// The chain starts empty: the interface opens on "No effects yet" and the
|
||||
// user picks the first stage, rather than being handed one at random.
|
||||
seed_ = Rng::randomSeed();
|
||||
|
||||
renderDone_.connect(sigc::mem_fun(*this, &EffectorWindow::onRenderFinished));
|
||||
@@ -173,28 +178,7 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
|
||||
sidebar->append(sourcesExpander_);
|
||||
|
||||
sidebar->append(*sectionLabel("Canvas"));
|
||||
auto* grid = Gtk::make_managed<Gtk::Grid>();
|
||||
grid->set_column_spacing(8);
|
||||
grid->set_row_spacing(6);
|
||||
widthSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
|
||||
widthSpin_.set_increments(1, 100);
|
||||
widthSpin_.set_value(1920);
|
||||
heightSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
|
||||
heightSpin_.set_increments(1, 100);
|
||||
heightSpin_.set_value(1080);
|
||||
for (auto* spin : {&widthSpin_, &heightSpin_}) {
|
||||
spin->set_hexpand(true);
|
||||
spin->signal_value_changed().connect(sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||||
}
|
||||
auto* widthLabel = Gtk::make_managed<Gtk::Label>("Width");
|
||||
widthLabel->set_xalign(0.0F);
|
||||
auto* heightLabel = Gtk::make_managed<Gtk::Label>("Height");
|
||||
heightLabel->set_xalign(0.0F);
|
||||
grid->attach(*widthLabel, 0, 0);
|
||||
grid->attach(widthSpin_, 1, 0);
|
||||
grid->attach(*heightLabel, 0, 1);
|
||||
grid->attach(heightSpin_, 1, 1);
|
||||
sidebar->append(*grid);
|
||||
sidebar->append(buildCanvasSection());
|
||||
|
||||
auto* seedBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
|
||||
auto* seedLabel = Gtk::make_managed<Gtk::Label>("Seed");
|
||||
@@ -209,6 +193,15 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
|
||||
return *sidebar;
|
||||
}
|
||||
|
||||
// Only the first stage is ever handed the sources, so whether the chain wants
|
||||
// them at all is `chainNeedsSources`. An effect that starts from nothing (blur,
|
||||
// text) makes the whole sources section beside the point, so it is hidden
|
||||
// rather than left there to be filled in vain. This is the same declarative
|
||||
// answer the render gate uses -- no effect is named here.
|
||||
void EffectorWindow::updateSourcesVisible() {
|
||||
sourcesExpander_.set_visible(chainNeedsSources(stages_));
|
||||
}
|
||||
|
||||
// The expander is collapsed by default, so its label carries the count that
|
||||
// used to be plain to see in the list.
|
||||
void EffectorWindow::updateSourcesLabel() {
|
||||
@@ -218,6 +211,122 @@ void EffectorWindow::updateSourcesLabel() {
|
||||
: "Source images (" + std::to_string(count) + ")");
|
||||
}
|
||||
|
||||
// The canvas is either a named format — a web size, or a sheet of paper at a
|
||||
// resolution — or the user's own pixels. A format owns the two spin buttons
|
||||
// while it is picked, which is why they are shown greyed out rather than
|
||||
// hidden: the size a format works out to is worth seeing.
|
||||
Gtk::Widget& EffectorWindow::buildCanvasSection() {
|
||||
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
|
||||
|
||||
// Index 0 is "Custom"; every later index is canvasFormats()[index - 1].
|
||||
std::vector<Glib::ustring> labels{"Custom"};
|
||||
for (const auto& format : canvasFormats()) {
|
||||
labels.push_back(format.label + " (" + canvasFormatDetail(format) + ")");
|
||||
}
|
||||
formatDrop_.set_model(Gtk::StringList::create(labels));
|
||||
formatDrop_.set_selected(0);
|
||||
formatDrop_.set_tooltip_text("Standard web and print sizes, or your own pixels");
|
||||
formatDrop_.property_selected().signal_changed().connect(
|
||||
sigc::mem_fun(*this, &EffectorWindow::syncCanvasWidgets));
|
||||
box->append(formatDrop_);
|
||||
|
||||
auto* grid = Gtk::make_managed<Gtk::Grid>();
|
||||
grid->set_column_spacing(8);
|
||||
grid->set_row_spacing(6);
|
||||
widthSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
|
||||
widthSpin_.set_increments(1, 100);
|
||||
widthSpin_.set_value(1920);
|
||||
heightSpin_.set_range(kMinCanvasSide, kMaxCanvasSide);
|
||||
heightSpin_.set_increments(1, 100);
|
||||
heightSpin_.set_value(1080);
|
||||
dpiSpin_.set_range(kMinDpi, kMaxDpi);
|
||||
dpiSpin_.set_increments(1, 50);
|
||||
dpiSpin_.set_value(kDefaultDpi);
|
||||
for (auto* spin : {&widthSpin_, &heightSpin_, &dpiSpin_}) {
|
||||
spin->set_hexpand(true);
|
||||
}
|
||||
for (auto* spin : {&widthSpin_, &heightSpin_}) {
|
||||
spin->signal_value_changed().connect([this] {
|
||||
if (!suspendCanvasSync_) onParamChanged();
|
||||
});
|
||||
}
|
||||
// The dpi decides the pixels of a sheet, so moving it goes through the
|
||||
// same path a format change does.
|
||||
dpiSpin_.signal_value_changed().connect([this] {
|
||||
if (!suspendCanvasSync_) syncCanvasWidgets();
|
||||
});
|
||||
|
||||
auto* widthLabel = Gtk::make_managed<Gtk::Label>("Width");
|
||||
widthLabel->set_xalign(0.0F);
|
||||
auto* heightLabel = Gtk::make_managed<Gtk::Label>("Height");
|
||||
heightLabel->set_xalign(0.0F);
|
||||
auto* dpiLabel = Gtk::make_managed<Gtk::Label>("DPI");
|
||||
dpiLabel->set_xalign(0.0F);
|
||||
dpiLabel_ = dpiLabel;
|
||||
grid->attach(*widthLabel, 0, 0);
|
||||
grid->attach(widthSpin_, 1, 0);
|
||||
grid->attach(*heightLabel, 0, 1);
|
||||
grid->attach(heightSpin_, 1, 1);
|
||||
grid->attach(*dpiLabel, 0, 2);
|
||||
grid->attach(dpiSpin_, 1, 2);
|
||||
box->append(*grid);
|
||||
|
||||
// Quietly: the sidebar is still being built, and nothing is to be rendered
|
||||
// until the initial options or a project have had their say.
|
||||
suspendCanvasSync_ = true;
|
||||
syncCanvasWidgets();
|
||||
suspendCanvasSync_ = false;
|
||||
return *box;
|
||||
}
|
||||
|
||||
std::string EffectorWindow::canvasFormatKey() const {
|
||||
const auto selected = formatDrop_.get_selected();
|
||||
if (selected == GTK_INVALID_LIST_POSITION || selected == 0) return {};
|
||||
const auto index = static_cast<std::size_t>(selected) - 1;
|
||||
const auto& formats = canvasFormats();
|
||||
return index < formats.size() ? formats[index].key : std::string();
|
||||
}
|
||||
|
||||
// A key the build does not know — an old project, or a hand-edited file — lands
|
||||
// on "Custom", which leaves the pixels the project carries alone.
|
||||
void EffectorWindow::setCanvasFormat(const std::string& key, int dpi) {
|
||||
const bool suspended = suspendCanvasSync_;
|
||||
suspendCanvasSync_ = true;
|
||||
dpiSpin_.set_value(std::clamp(dpi, kMinDpi, kMaxDpi));
|
||||
guint selected = 0;
|
||||
const auto& formats = canvasFormats();
|
||||
for (std::size_t i = 0; i < formats.size(); ++i) {
|
||||
if (formats[i].key == key) selected = static_cast<guint>(i + 1);
|
||||
}
|
||||
formatDrop_.set_selected(selected);
|
||||
syncCanvasWidgets(); // still suspended, so the spins follow without a render
|
||||
suspendCanvasSync_ = suspended;
|
||||
}
|
||||
|
||||
void EffectorWindow::syncCanvasWidgets() {
|
||||
// Suspended means a caller is putting the canvas together — the initial
|
||||
// options, or a project being opened — and will ask for the render itself.
|
||||
const bool quiet = suspendCanvasSync_;
|
||||
const auto* format = canvasFormatByKey(canvasFormatKey());
|
||||
const bool print = format != nullptr && format->print;
|
||||
dpiLabel_->set_visible(print);
|
||||
dpiSpin_.set_visible(print);
|
||||
widthSpin_.set_sensitive(format == nullptr);
|
||||
heightSpin_.set_sensitive(format == nullptr);
|
||||
|
||||
if (format != nullptr) {
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
canvasFormatSize(*format, static_cast<int>(dpiSpin_.get_value()), width, height);
|
||||
const bool suspended = suspendCanvasSync_;
|
||||
suspendCanvasSync_ = true; // one render for the change, not one per spin
|
||||
widthSpin_.set_value(width);
|
||||
heightSpin_.set_value(height);
|
||||
suspendCanvasSync_ = suspended;
|
||||
}
|
||||
if (!quiet) onParamChanged();
|
||||
}
|
||||
|
||||
Gtk::Widget& EffectorWindow::buildPreview() {
|
||||
picture_.set_can_shrink(true);
|
||||
picture_.set_content_fit(Gtk::ContentFit::CONTAIN);
|
||||
@@ -252,27 +361,62 @@ void EffectorWindow::buildParamWidgets() {
|
||||
while (auto* child = paramGrid_.get_first_child()) paramGrid_.remove(*child);
|
||||
paramWidgets_.clear();
|
||||
ownedWidgets_.clear();
|
||||
styleDrop_ = nullptr;
|
||||
|
||||
if (stages_.empty()) {
|
||||
paramHeading_.set_text("No effects");
|
||||
return;
|
||||
}
|
||||
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
|
||||
// The settings of a stage that is switched off stay editable: it is still
|
||||
// being worked on, it just is not in the picture for the moment.
|
||||
paramHeading_.set_text(std::to_string(selectedStage_ + 1) + ". " + stage.effect->title() +
|
||||
" settings");
|
||||
(stage.enabled ? " settings" : " settings (off)"));
|
||||
|
||||
int row = 0;
|
||||
// The style the stage follows, when its effect takes one at all: the
|
||||
// params it carries are then shown and edited on the style rather than on
|
||||
// the stage, which is what makes one change move every stage using it.
|
||||
const Style* style = selectedStyle();
|
||||
if (!stage.effect->styleKind().empty()) {
|
||||
auto label = std::make_unique<Gtk::Label>("Style");
|
||||
label->set_xalign(0.0F);
|
||||
label->set_tooltip_text(
|
||||
"A named set of formatting shared by several stages: the font, the colour and the "
|
||||
"box, but never the words or the placement.");
|
||||
auto widget = buildStyleRow(stage);
|
||||
widget->set_hexpand(true);
|
||||
paramGrid_.attach(*label, 0, row);
|
||||
paramGrid_.attach(*widget, 1, row);
|
||||
ownedWidgets_.push_back(std::move(label));
|
||||
ownedWidgets_.push_back(std::move(widget));
|
||||
++row;
|
||||
}
|
||||
|
||||
for (const auto& spec : stage.effect->params()) {
|
||||
// A styled param is read from the style while one is followed, so the
|
||||
// widgets show what the stage will actually render with.
|
||||
const bool shared = style != nullptr && spec.inStyle;
|
||||
const ParamValues& values = shared ? style->values : stage.values;
|
||||
|
||||
auto label = std::make_unique<Gtk::Label>(spec.label);
|
||||
label->set_xalign(0.0F);
|
||||
if (!spec.help.empty()) label->set_tooltip_text(spec.help);
|
||||
std::string help = spec.help;
|
||||
if (shared) {
|
||||
label->set_markup(Glib::Markup::escape_text(spec.label) +
|
||||
" <span alpha='55%'>(style)</span>");
|
||||
if (!help.empty()) help += "\n\n";
|
||||
help += "Part of the style \"" + style->name +
|
||||
"\": changing it changes every stage that follows it.";
|
||||
}
|
||||
if (!help.empty()) label->set_tooltip_text(help);
|
||||
|
||||
std::unique_ptr<Gtk::Widget> widget;
|
||||
switch (spec.type) {
|
||||
case ParamSpec::Type::Bool: {
|
||||
auto toggle = std::make_unique<Gtk::Switch>();
|
||||
toggle->set_halign(Gtk::Align::START);
|
||||
toggle->set_active(paramBool(stage.values, spec.key, spec.defaultValue != 0.0));
|
||||
toggle->set_active(paramBool(values, spec.key, spec.defaultValue != 0.0));
|
||||
toggle->property_active().signal_changed().connect(
|
||||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||||
widget = std::move(toggle);
|
||||
@@ -285,19 +429,69 @@ void EffectorWindow::buildParamWidgets() {
|
||||
spin->set_increments(spec.step, spec.step * 10);
|
||||
spin->set_digits(spec.type == ParamSpec::Type::Int ? 0 : spec.digits);
|
||||
spin->set_value(spec.type == ParamSpec::Type::Int
|
||||
? static_cast<double>(paramInt(stage.values, spec.key,
|
||||
? static_cast<double>(paramInt(values, spec.key,
|
||||
static_cast<long>(spec.defaultValue)))
|
||||
: paramDouble(stage.values, spec.key, spec.defaultValue));
|
||||
: paramDouble(values, spec.key, spec.defaultValue));
|
||||
spin->signal_value_changed().connect(
|
||||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||||
widget = std::move(spin);
|
||||
break;
|
||||
}
|
||||
case ParamSpec::Type::Path: {
|
||||
// An entry holding the path, with a chooser beside it; the
|
||||
// entry is what paramValues() reads back.
|
||||
auto box = std::make_unique<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
|
||||
auto* entry = Gtk::make_managed<Gtk::Entry>();
|
||||
entry->set_hexpand(true);
|
||||
entry->set_text(paramText(values, spec.key, ""));
|
||||
entry->set_placeholder_text("No image chosen");
|
||||
entry->signal_activate().connect(
|
||||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||||
auto* choose = Gtk::make_managed<Gtk::Button>("Choose…");
|
||||
const int index = selectedStage_;
|
||||
const std::string key = spec.key;
|
||||
const std::string title = spec.label;
|
||||
choose->signal_clicked().connect(
|
||||
[this, index, key, title] { onChoosePath(index, key, title); });
|
||||
box->append(*entry);
|
||||
box->append(*choose);
|
||||
paramWidgets_.push_back({spec, entry});
|
||||
widget = std::move(box);
|
||||
break;
|
||||
}
|
||||
case ParamSpec::Type::Text: {
|
||||
auto entry = std::make_unique<Gtk::Entry>();
|
||||
entry->set_text(paramText(values, spec.key, spec.defaultChoice));
|
||||
// Re-rendering on every keystroke would start a render per
|
||||
// letter typed, so the text is taken when the entry is left or
|
||||
// Enter is pressed.
|
||||
entry->signal_activate().connect(
|
||||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||||
auto focus = Gtk::EventControllerFocus::create();
|
||||
focus->signal_leave().connect(
|
||||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||||
entry->add_controller(focus);
|
||||
widget = std::move(entry);
|
||||
break;
|
||||
}
|
||||
case ParamSpec::Type::Color: {
|
||||
auto button = std::make_unique<Gtk::ColorDialogButton>(Gtk::ColorDialog::create());
|
||||
button->set_halign(Gtk::Align::START);
|
||||
std::uint8_t rgb[3];
|
||||
paramColor(values, spec.key, spec.defaultChoice, rgb);
|
||||
Gdk::RGBA colour;
|
||||
colour.set_rgba(rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0, 1.0);
|
||||
button->set_rgba(colour);
|
||||
button->property_rgba().signal_changed().connect(
|
||||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||||
widget = std::move(button);
|
||||
break;
|
||||
}
|
||||
case ParamSpec::Type::Choice: {
|
||||
std::vector<Glib::ustring> choices(spec.choices.begin(), spec.choices.end());
|
||||
auto dropdown = std::make_unique<Gtk::DropDown>();
|
||||
dropdown->set_model(Gtk::StringList::create(choices));
|
||||
const auto current = paramChoice(stage.values, spec.key, spec.defaultChoice);
|
||||
const auto current = paramChoice(values, spec.key, spec.defaultChoice);
|
||||
for (std::size_t i = 0; i < spec.choices.size(); ++i) {
|
||||
if (spec.choices[i] == current) dropdown->set_selected(i);
|
||||
}
|
||||
@@ -311,7 +505,9 @@ void EffectorWindow::buildParamWidgets() {
|
||||
widget->set_hexpand(true);
|
||||
paramGrid_.attach(*label, 0, row);
|
||||
paramGrid_.attach(*widget, 1, row);
|
||||
paramWidgets_.push_back({spec, widget.get()});
|
||||
// A path attaches a box but reads back from the entry inside it, which
|
||||
// it has registered itself.
|
||||
if (spec.type != ParamSpec::Type::Path) paramWidgets_.push_back({spec, widget.get()});
|
||||
ownedWidgets_.push_back(std::move(label));
|
||||
ownedWidgets_.push_back(std::move(widget));
|
||||
++row;
|
||||
@@ -333,6 +529,20 @@ ParamValues EffectorWindow::paramValues() const {
|
||||
case ParamSpec::Type::Double:
|
||||
values[entry.spec.key] = static_cast<Gtk::SpinButton*>(entry.widget)->get_value();
|
||||
break;
|
||||
case ParamSpec::Type::Path:
|
||||
case ParamSpec::Type::Text:
|
||||
values[entry.spec.key] =
|
||||
std::string(static_cast<Gtk::Entry*>(entry.widget)->get_text());
|
||||
break;
|
||||
case ParamSpec::Type::Color: {
|
||||
const auto colour = static_cast<Gtk::ColorDialogButton*>(entry.widget)->get_rgba();
|
||||
const std::uint8_t rgb[3] = {
|
||||
static_cast<std::uint8_t>(std::lround(colour.get_red() * 255.0)),
|
||||
static_cast<std::uint8_t>(std::lround(colour.get_green() * 255.0)),
|
||||
static_cast<std::uint8_t>(std::lround(colour.get_blue() * 255.0))};
|
||||
values[entry.spec.key] = formatColor(rgb);
|
||||
break;
|
||||
}
|
||||
case ParamSpec::Type::Choice: {
|
||||
const auto index = static_cast<Gtk::DropDown*>(entry.widget)->get_selected();
|
||||
if (index < entry.spec.choices.size()) values[entry.spec.key] = entry.spec.choices[index];
|
||||
@@ -347,7 +557,282 @@ ParamValues EffectorWindow::paramValues() const {
|
||||
// whenever they change; the other stages keep the settings they were given.
|
||||
void EffectorWindow::storeParams() {
|
||||
if (stages_.empty()) return;
|
||||
stages_[static_cast<std::size_t>(selectedStage_)].values = paramValues();
|
||||
auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
|
||||
const ParamValues values = paramValues();
|
||||
stage.values = values;
|
||||
|
||||
// A param the style carries is written to the style too -- that is how the
|
||||
// look of every stage following it is changed from here. The stage keeps
|
||||
// its own copy of the same value, so leaving the style, or losing it, does
|
||||
// not move the picture.
|
||||
if (Style* style = selectedStyle()) {
|
||||
for (const auto& spec : stage.effect->params()) {
|
||||
if (!spec.inStyle) continue;
|
||||
const auto found = values.find(spec.key);
|
||||
if (found != values.end()) style->values[spec.key] = found->second;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- text styles ---
|
||||
|
||||
std::vector<std::string> EffectorWindow::styleNames(const std::string& kind) const {
|
||||
std::vector<std::string> names;
|
||||
for (const auto& style : styles_) {
|
||||
if (style.kind == kind) names.push_back(style.name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
Style* EffectorWindow::selectedStyle() {
|
||||
return const_cast<Style*>(std::as_const(*this).selectedStyle());
|
||||
}
|
||||
|
||||
const Style* EffectorWindow::selectedStyle() const {
|
||||
if (stages_.empty()) return nullptr;
|
||||
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
|
||||
return findStyle(styles_, stage.effect->styleKind(), stage.style);
|
||||
}
|
||||
|
||||
bool EffectorWindow::styleNameTaken(const std::string& kind, const std::string& name) const {
|
||||
return findStyle(styles_, kind, name) != nullptr;
|
||||
}
|
||||
|
||||
std::unique_ptr<Gtk::Widget> EffectorWindow::buildStyleRow(const ChainStage& stage) {
|
||||
auto box = std::make_unique<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
|
||||
|
||||
const auto names = styleNames(stage.effect->styleKind());
|
||||
// "Custom" first so that the stage standing on its own is the plain case,
|
||||
// and "New style…" last so that the names in between keep their indices.
|
||||
std::vector<Glib::ustring> entries{"Custom"};
|
||||
for (const auto& name : names) entries.emplace_back(name);
|
||||
entries.emplace_back("New style…");
|
||||
|
||||
auto* drop = Gtk::make_managed<Gtk::DropDown>();
|
||||
drop->set_model(Gtk::StringList::create(entries));
|
||||
drop->set_hexpand(true);
|
||||
suspendStyleSelect_ = true;
|
||||
drop->set_selected(0);
|
||||
for (std::size_t i = 0; i < names.size(); ++i) {
|
||||
if (names[i] == stage.style) drop->set_selected(i + 1);
|
||||
}
|
||||
suspendStyleSelect_ = false;
|
||||
drop->property_selected().signal_changed().connect(
|
||||
sigc::mem_fun(*this, &EffectorWindow::onStyleSelected));
|
||||
box->append(*drop);
|
||||
styleDrop_ = drop;
|
||||
|
||||
// Renaming and removing only make sense for a style that is being followed.
|
||||
auto* rename = Gtk::make_managed<Gtk::Button>("Rename…");
|
||||
rename->set_sensitive(!stage.style.empty());
|
||||
rename->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onRenameStyle));
|
||||
box->append(*rename);
|
||||
|
||||
auto* remove = Gtk::make_managed<Gtk::Button>("Remove");
|
||||
remove->set_sensitive(!stage.style.empty());
|
||||
remove->set_tooltip_text(
|
||||
"Drops the style. Every stage following it keeps the look it has and goes back to its own "
|
||||
"settings.");
|
||||
remove->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onRemoveStyle));
|
||||
box->append(*remove);
|
||||
|
||||
return box;
|
||||
}
|
||||
|
||||
void EffectorWindow::onStyleSelected() {
|
||||
if (suspendStyleSelect_ || styleDrop_ == nullptr || stages_.empty()) return;
|
||||
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
|
||||
const auto names = styleNames(stage.effect->styleKind());
|
||||
const auto index = styleDrop_->get_selected();
|
||||
|
||||
if (index == 0) {
|
||||
adoptStyle("");
|
||||
} else if (index <= names.size()) {
|
||||
adoptStyle(names[index - 1]);
|
||||
} else {
|
||||
onNewStyle(); // the last entry
|
||||
}
|
||||
}
|
||||
|
||||
// Pointing the stage at a style, or at nothing. The style's values are copied
|
||||
// onto the stage as well, so the project file always says what the stage would
|
||||
// render if the style went away -- which is the same picture.
|
||||
void EffectorWindow::adoptStyle(const std::string& name) {
|
||||
if (stages_.empty()) return;
|
||||
auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
|
||||
stage.style = name;
|
||||
if (const Style* style = selectedStyle()) {
|
||||
for (const auto& spec : stage.effect->params()) {
|
||||
if (!spec.inStyle) continue;
|
||||
const auto found = style->values.find(spec.key);
|
||||
if (found != style->values.end()) stage.values[spec.key] = found->second;
|
||||
}
|
||||
}
|
||||
buildParamWidgets();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
void EffectorWindow::onNewStyle() {
|
||||
if (stages_.empty()) return;
|
||||
const auto& stage = stages_[static_cast<std::size_t>(selectedStage_)];
|
||||
const std::string kind = stage.effect->styleKind();
|
||||
if (kind.empty()) return;
|
||||
|
||||
askStyleName("New style", "Style " + std::to_string(styleNames(kind).size() + 1),
|
||||
[this, kind](const std::string& name) {
|
||||
// Seeded from the stage it was made on, so the style
|
||||
// starts as the look that is already on screen.
|
||||
Style style;
|
||||
style.kind = kind;
|
||||
style.name = name;
|
||||
style.values = styleDefaults(kind);
|
||||
const auto& current = stages_[static_cast<std::size_t>(selectedStage_)];
|
||||
for (const auto& spec : current.effect->params()) {
|
||||
if (!spec.inStyle) continue;
|
||||
const auto found = current.values.find(spec.key);
|
||||
if (found != current.values.end()) style.values[spec.key] = found->second;
|
||||
}
|
||||
styles_.push_back(std::move(style));
|
||||
adoptStyle(name);
|
||||
});
|
||||
}
|
||||
|
||||
void EffectorWindow::onRenameStyle() {
|
||||
const Style* style = selectedStyle();
|
||||
if (style == nullptr) return;
|
||||
const std::string kind = style->kind;
|
||||
const std::string was = style->name;
|
||||
|
||||
askStyleName("Rename style", was, [this, kind, was](const std::string& name) {
|
||||
if (name == was) return;
|
||||
for (auto& candidate : styles_) {
|
||||
if (candidate.kind == kind && candidate.name == was) candidate.name = name;
|
||||
}
|
||||
// Every stage that followed the old name follows the new one: a rename
|
||||
// must not quietly cut a stage loose from its look.
|
||||
for (auto& stage : stages_) {
|
||||
if (stage.effect->styleKind() == kind && stage.style == was) stage.style = name;
|
||||
}
|
||||
buildParamWidgets();
|
||||
});
|
||||
}
|
||||
|
||||
void EffectorWindow::onRemoveStyle() {
|
||||
const Style* style = selectedStyle();
|
||||
if (style == nullptr) return;
|
||||
const std::string kind = style->kind;
|
||||
const std::string name = style->name;
|
||||
|
||||
// The stages keep the values they were rendering with, so dropping a style
|
||||
// changes nothing in the picture -- it only stops the stages moving
|
||||
// together.
|
||||
for (auto& stage : stages_) {
|
||||
if (stage.effect->styleKind() == kind && stage.style == name) stage.style.clear();
|
||||
}
|
||||
styles_.erase(std::remove_if(styles_.begin(), styles_.end(),
|
||||
[&](const Style& candidate) {
|
||||
return candidate.kind == kind && candidate.name == name;
|
||||
}),
|
||||
styles_.end());
|
||||
buildParamWidgets();
|
||||
requestRender();
|
||||
}
|
||||
|
||||
// Built from scratch each time it is needed, like the progress window, so the
|
||||
// entry it holds is a raw pointer that closeStyleWindow() nulls.
|
||||
void EffectorWindow::askStyleName(const std::string& title, const std::string& initial,
|
||||
const std::function<void(const std::string&)>& accept) {
|
||||
closeStyleWindow();
|
||||
if (stages_.empty()) return;
|
||||
const std::string kind = stages_[static_cast<std::size_t>(selectedStage_)].effect->styleKind();
|
||||
|
||||
styleWindow_ = std::make_shared<Gtk::Window>();
|
||||
styleWindow_->set_title(title);
|
||||
styleWindow_->set_transient_for(*this);
|
||||
styleWindow_->set_modal(true);
|
||||
styleWindow_->set_resizable(false);
|
||||
styleWindow_->set_default_size(360, -1);
|
||||
|
||||
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
|
||||
box->set_margin(18);
|
||||
|
||||
auto* entry = Gtk::make_managed<Gtk::Entry>();
|
||||
entry->set_text(initial);
|
||||
entry->set_activates_default(false);
|
||||
box->append(*entry);
|
||||
styleNameEntry_ = entry;
|
||||
|
||||
auto* error = Gtk::make_managed<Gtk::Label>("");
|
||||
error->set_xalign(0.0F);
|
||||
error->add_css_class("dim-label");
|
||||
error->set_visible(false);
|
||||
box->append(*error);
|
||||
styleNameError_ = error;
|
||||
|
||||
auto* buttons = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
|
||||
buttons->set_halign(Gtk::Align::END);
|
||||
|
||||
auto* cancel = Gtk::make_managed<Gtk::Button>("Cancel");
|
||||
cancel->signal_clicked().connect([this] {
|
||||
closeStyleWindow();
|
||||
// The dropdown may be sitting on "New style…"; put it back on what the
|
||||
// stage actually follows.
|
||||
buildParamWidgets();
|
||||
});
|
||||
buttons->append(*cancel);
|
||||
|
||||
auto* confirm = Gtk::make_managed<Gtk::Button>("OK");
|
||||
confirm->add_css_class("suggested-action");
|
||||
// A name that is empty or already in the pool is refused in place rather
|
||||
// than silently changed, so two styles can never share a name.
|
||||
const auto submit = [this, kind, accept] {
|
||||
if (styleNameEntry_ == nullptr) return;
|
||||
const std::string name = styleNameEntry_->get_text();
|
||||
const char* complaint = nullptr;
|
||||
if (name.empty()) {
|
||||
complaint = "A style needs a name.";
|
||||
} else if (styleNameTaken(kind, name)) {
|
||||
complaint = "There is already a style with that name.";
|
||||
}
|
||||
if (complaint != nullptr) {
|
||||
if (styleNameError_ != nullptr) {
|
||||
styleNameError_->set_text(complaint);
|
||||
styleNameError_->set_visible(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
closeStyleWindow();
|
||||
accept(name);
|
||||
};
|
||||
confirm->signal_clicked().connect(submit);
|
||||
entry->signal_activate().connect(submit);
|
||||
buttons->append(*confirm);
|
||||
box->append(*buttons);
|
||||
|
||||
styleWindow_->signal_close_request().connect(
|
||||
[this] {
|
||||
closeStyleWindow();
|
||||
buildParamWidgets();
|
||||
return true;
|
||||
},
|
||||
false);
|
||||
|
||||
styleWindow_->set_child(*box);
|
||||
styleWindow_->present();
|
||||
entry->grab_focus();
|
||||
}
|
||||
|
||||
void EffectorWindow::closeStyleWindow() {
|
||||
styleNameEntry_ = nullptr;
|
||||
styleNameError_ = nullptr;
|
||||
if (!styleWindow_) return;
|
||||
styleWindow_->set_hide_on_close(false);
|
||||
styleWindow_->hide();
|
||||
// Closing is asked for by the window's own buttons and by its close
|
||||
// request, so it is torn down once that signal has finished running rather
|
||||
// than underneath it.
|
||||
Glib::signal_idle().connect_once([window = styleWindow_] {});
|
||||
styleWindow_.reset();
|
||||
}
|
||||
|
||||
void EffectorWindow::applyInitial(const Options& initial) {
|
||||
@@ -361,6 +846,7 @@ void EffectorWindow::applyInitial(const Options& initial) {
|
||||
|
||||
widthSpin_.set_value(initial.width);
|
||||
heightSpin_.set_value(initial.height);
|
||||
setCanvasFormat(initial.format, initial.dpi); // a format fills the spins itself
|
||||
if (initial.seed) {
|
||||
seed_ = *initial.seed;
|
||||
seedEntry_.set_text(std::to_string(seed_));
|
||||
@@ -738,6 +1224,12 @@ void EffectorWindow::rebuildChainRows() {
|
||||
// removing it would leave the empty list blank.
|
||||
while (auto* row = chainList_.get_row_at_index(0)) chainList_.remove(*row);
|
||||
|
||||
// The first stage that actually runs is the one handed the sources, which
|
||||
// is not stage 1 once stage 1 is switched off.
|
||||
const auto running = enabledStages(stages_);
|
||||
const int firstRunning =
|
||||
running.empty() ? -1 : static_cast<int>(running.front() - stages_.data());
|
||||
|
||||
for (std::size_t i = 0; i < stages_.size(); ++i) {
|
||||
const int index = static_cast<int>(i);
|
||||
auto* row = Gtk::make_managed<Gtk::ListBoxRow>();
|
||||
@@ -747,13 +1239,28 @@ void EffectorWindow::rebuildChainRows() {
|
||||
box->set_margin_start(8);
|
||||
box->set_margin_end(4);
|
||||
|
||||
// The check is what switches a stage off: it keeps its settings and
|
||||
// its place in the chain, and the render simply skips it.
|
||||
auto* check = Gtk::make_managed<Gtk::CheckButton>();
|
||||
check->set_active(stages_[i].enabled);
|
||||
check->set_tooltip_text("Apply this effect");
|
||||
check->set_valign(Gtk::Align::CENTER);
|
||||
// Connected after set_active, so filling in the rebuilt rows does not
|
||||
// look like the user reaching for the check.
|
||||
check->signal_toggled().connect([this, index, check] {
|
||||
setStageEnabled(index, check->get_active());
|
||||
});
|
||||
box->append(*check);
|
||||
|
||||
auto* label = Gtk::make_managed<Gtk::Label>(std::to_string(index + 1) + ". " +
|
||||
stages_[i].effect->title());
|
||||
label->set_xalign(0.0F);
|
||||
label->set_hexpand(true);
|
||||
label->set_ellipsize(Pango::EllipsizeMode::END);
|
||||
label->set_tooltip_text(index == 0 ? "Applied to the source images"
|
||||
: "Applied to the output of the stage above");
|
||||
if (!stages_[i].enabled) label->add_css_class("dim-label");
|
||||
label->set_tooltip_text(!stages_[i].enabled ? "Skipped while it is switched off"
|
||||
: index == firstRunning ? "Applied to the source images"
|
||||
: "Applied to the output so far");
|
||||
box->append(*label);
|
||||
|
||||
struct Action {
|
||||
@@ -787,6 +1294,20 @@ void EffectorWindow::rebuildChainRows() {
|
||||
|
||||
selectStage(selectedStage_);
|
||||
suspendStageSelect_ = false;
|
||||
updateSourcesVisible();
|
||||
}
|
||||
|
||||
// Switching a stage off leaves everything about it alone -- its settings, its
|
||||
// place in the chain, the stage the params below are showing -- so the only
|
||||
// visible change is the dimmed row and the picture the render comes back with.
|
||||
void EffectorWindow::setStageEnabled(int index, bool enabled) {
|
||||
if (index < 0 || static_cast<std::size_t>(index) >= stages_.size()) return;
|
||||
auto& stage = stages_[static_cast<std::size_t>(index)];
|
||||
if (stage.enabled == enabled) return;
|
||||
stage.enabled = enabled;
|
||||
rebuildChainRows(); // the row dims, and another stage may now lead the chain
|
||||
if (index == selectedStage_) buildParamWidgets(); // the heading says "off"
|
||||
requestRender();
|
||||
}
|
||||
|
||||
void EffectorWindow::selectStage(int index) {
|
||||
@@ -809,6 +1330,36 @@ void EffectorWindow::onParamChanged() {
|
||||
requestRender();
|
||||
}
|
||||
|
||||
void EffectorWindow::onChoosePath(int stage, const std::string& key, const std::string& label) {
|
||||
auto dialog = Gtk::FileDialog::create();
|
||||
dialog->set_title("Select the " + label);
|
||||
|
||||
auto filter = Gtk::FileFilter::create();
|
||||
filter->set_name("Images");
|
||||
for (const char* pattern : kImagePatterns) filter->add_pattern(pattern);
|
||||
auto filters = Gio::ListStore<Gtk::FileFilter>::create();
|
||||
filters->append(filter);
|
||||
dialog->set_filters(filters);
|
||||
dialog->set_default_filter(filter);
|
||||
|
||||
dialog->open(*this, [this, dialog, stage, key](const Glib::RefPtr<Gio::AsyncResult>& result) {
|
||||
std::string path;
|
||||
try {
|
||||
const auto file = dialog->open_finish(result);
|
||||
if (file) path = file->get_path();
|
||||
} catch (const Glib::Error&) {
|
||||
return; // cancelled
|
||||
}
|
||||
if (path.empty() || stage < 0 || static_cast<std::size_t>(stage) >= stages_.size()) return;
|
||||
// The widgets may be showing another stage by now: store what they hold
|
||||
// first, then write the path into the stage that asked for it.
|
||||
storeParams();
|
||||
stages_[static_cast<std::size_t>(stage)].values[key] = path;
|
||||
if (stage == selectedStage_) buildParamWidgets();
|
||||
requestRender();
|
||||
});
|
||||
}
|
||||
|
||||
void EffectorWindow::onSeedEntered() {
|
||||
try {
|
||||
seed_ = std::stoull(seedEntry_.get_text());
|
||||
@@ -833,12 +1384,13 @@ void EffectorWindow::onRecreate() {
|
||||
// ever handed the sources, so an effect that does not want them (blur) renders
|
||||
// happily with none loaded.
|
||||
bool EffectorWindow::canRender() const {
|
||||
if (stages_.empty()) return false;
|
||||
if (enabledStages(stages_).empty()) return false;
|
||||
return !sources_->empty() || !chainNeedsSources(stages_);
|
||||
}
|
||||
|
||||
std::string EffectorWindow::missingText() const {
|
||||
if (stages_.empty()) return "Add an effect to start.";
|
||||
if (enabledStages(stages_).empty()) return "Every effect is switched off.";
|
||||
return "Add source images to start.";
|
||||
}
|
||||
|
||||
@@ -877,6 +1429,7 @@ void EffectorWindow::startRender() {
|
||||
|
||||
const auto sources = sources_;
|
||||
const auto stages = stages_; // a snapshot, like the sources
|
||||
const auto styles = styles_; // the same, so the worker reads nothing live
|
||||
const int width = static_cast<int>(widthSpin_.get_value());
|
||||
const int height = static_cast<int>(heightSpin_.get_value());
|
||||
const std::uint64_t seed = seed_;
|
||||
@@ -884,13 +1437,13 @@ void EffectorWindow::startRender() {
|
||||
spinner_.start();
|
||||
recreateButton_.set_sensitive(false);
|
||||
|
||||
worker_ = std::thread([this, sources, stages, width, height, seed] {
|
||||
worker_ = std::thread([this, sources, stages, styles, width, height, seed] {
|
||||
Image image;
|
||||
std::string error;
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
try {
|
||||
Rng rng(seed);
|
||||
image = renderChain(stages, *sources, width, height, rng);
|
||||
image = renderChain(stages, *sources, width, height, rng, styles);
|
||||
} catch (const std::exception& exception) {
|
||||
error = exception.what();
|
||||
}
|
||||
@@ -998,8 +1551,11 @@ Project EffectorWindow::currentProject() const {
|
||||
Project project;
|
||||
for (const auto& image : *sources_) project.sources.push_back(image.path());
|
||||
project.stages = stages_;
|
||||
project.styles = styles_;
|
||||
project.width = static_cast<int>(widthSpin_.get_value());
|
||||
project.height = static_cast<int>(heightSpin_.get_value());
|
||||
project.format = canvasFormatKey();
|
||||
project.dpi = static_cast<int>(dpiSpin_.get_value());
|
||||
project.seed = seed_;
|
||||
return project;
|
||||
}
|
||||
@@ -1007,15 +1563,19 @@ Project EffectorWindow::currentProject() const {
|
||||
void EffectorWindow::applyProject(const Project& project, const std::string& path) {
|
||||
// One render at the end rather than one per piece of the project.
|
||||
suspendRender_ = true;
|
||||
closeStyleWindow(); // it names a style of the project being replaced
|
||||
clearSources();
|
||||
|
||||
stages_ = project.stages;
|
||||
// Before the rows and the params: both ask which style a stage follows.
|
||||
styles_ = project.styles;
|
||||
selectedStage_ = 0;
|
||||
rebuildChainRows();
|
||||
buildParamWidgets();
|
||||
|
||||
widthSpin_.set_value(project.width);
|
||||
heightSpin_.set_value(project.height);
|
||||
setCanvasFormat(project.format, project.dpi);
|
||||
seed_ = project.seed.value_or(Rng::randomSeed());
|
||||
seedEntry_.set_text(std::to_string(seed_));
|
||||
|
||||
@@ -1044,9 +1604,7 @@ void EffectorWindow::clearSources() {
|
||||
}
|
||||
|
||||
void EffectorWindow::onNewProject() {
|
||||
Project fresh;
|
||||
fresh.stages.push_back({effects_.front(), effects_.front()->defaults()});
|
||||
applyProject(fresh, "");
|
||||
applyProject(Project(), ""); // same empty chain the window opens with
|
||||
}
|
||||
|
||||
void EffectorWindow::onOpenProject() {
|
||||
|
||||
+55
-1
@@ -7,6 +7,7 @@
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
@@ -16,6 +17,7 @@
|
||||
#include "core/effect.hpp"
|
||||
#include "core/image.hpp"
|
||||
#include "core/project.hpp"
|
||||
#include "core/style.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
@@ -30,7 +32,29 @@ private:
|
||||
Gtk::Widget& buildPreview();
|
||||
void buildParamWidgets();
|
||||
ParamValues paramValues() const;
|
||||
void storeParams(); // widgets -> the selected stage's values
|
||||
void storeParams(); // widgets -> the selected stage, and its style
|
||||
|
||||
// The style row above the params of a stage whose effect takes one.
|
||||
std::unique_ptr<Gtk::Widget> buildStyleRow(const ChainStage& stage);
|
||||
// The names of the pool the selected stage draws from, in the order the
|
||||
// dropdown shows them.
|
||||
std::vector<std::string> styleNames(const std::string& kind) const;
|
||||
// The style the selected stage follows, or nullptr when it stands alone.
|
||||
Style* selectedStyle();
|
||||
const Style* selectedStyle() const;
|
||||
void onStyleSelected(); // the dropdown: adopt, drop, or make a new one
|
||||
void adoptStyle(const std::string& name);
|
||||
void onNewStyle();
|
||||
void onRenameStyle();
|
||||
void onRemoveStyle();
|
||||
// The little modal that asks for a style name, used by both New and
|
||||
// Rename. Built from scratch each time, like the progress window.
|
||||
void askStyleName(const std::string& title, const std::string& initial,
|
||||
const std::function<void(const std::string&)>& accept);
|
||||
void closeStyleWindow();
|
||||
// A name that is empty, or already used in the pool, is refused in place.
|
||||
bool styleNameTaken(const std::string& kind, const std::string& name) const;
|
||||
|
||||
void applyInitial(const Options& initial);
|
||||
|
||||
bool canRender() const; // a chain, and sources if the chain needs them
|
||||
@@ -49,6 +73,16 @@ private:
|
||||
void cancelLoad();
|
||||
void appendSourceRow(const std::string& path);
|
||||
void updateSourcesLabel(); // the collapsed expander shows the count
|
||||
// The whole sources section only shows while the chain asks for sources.
|
||||
void updateSourcesVisible();
|
||||
|
||||
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);
|
||||
|
||||
Gtk::Widget& buildChainSection();
|
||||
@@ -56,6 +90,8 @@ private:
|
||||
void appendStage(const EffectPtr& effect);
|
||||
void removeStage(int index);
|
||||
void moveStage(int index, int delta);
|
||||
// The check on a chain row: the stage stays where it is, and is skipped.
|
||||
void setStageEnabled(int index, bool enabled);
|
||||
void rebuildChainRows();
|
||||
void selectStage(int index);
|
||||
void onStageSelected();
|
||||
@@ -75,6 +111,10 @@ private:
|
||||
void updateTitle();
|
||||
|
||||
void onParamChanged();
|
||||
// The chooser behind a Path param. The chosen file goes straight into the
|
||||
// stage's values rather than into the widget, which may well have been
|
||||
// rebuilt by the time the dialog comes back.
|
||||
void onChoosePath(int stage, const std::string& key, const std::string& label);
|
||||
void onSeedEntered();
|
||||
void onRecreate();
|
||||
void onSave();
|
||||
@@ -98,6 +138,9 @@ private:
|
||||
// The cascade: stage 0 works on the sources, every later stage on the
|
||||
// output of the one before it.
|
||||
std::vector<ChainStage> stages_;
|
||||
// The named looks the stages share. Edited through the params of whichever
|
||||
// stage follows one, and saved with the project.
|
||||
StyleSet styles_;
|
||||
int selectedStage_ = 0;
|
||||
bool suspendStageSelect_ = false; // guards the rebuild of the chain rows
|
||||
Image result_;
|
||||
@@ -142,8 +185,19 @@ private:
|
||||
Gtk::Label* loadFileLabel_ = nullptr;
|
||||
Gtk::Expander sourcesExpander_;
|
||||
Gtk::ListBox sourceList_;
|
||||
Gtk::DropDown formatDrop_;
|
||||
Gtk::SpinButton widthSpin_;
|
||||
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::DropDown* styleDrop_ = nullptr; // null while the stage takes no style
|
||||
bool suspendStyleSelect_ = false; // guards filling the dropdown
|
||||
std::shared_ptr<Gtk::Window> styleWindow_;
|
||||
Gtk::Entry* styleNameEntry_ = nullptr; // nulled with the window
|
||||
Gtk::Label* styleNameError_ = nullptr;
|
||||
Gtk::Grid paramGrid_;
|
||||
Gtk::Label paramHeading_;
|
||||
Gtk::Entry seedEntry_;
|
||||
|
||||
+8
-1
@@ -18,7 +18,14 @@
|
||||
namespace {
|
||||
|
||||
int renderHeadless(const effector::Options& options) {
|
||||
const auto stages = effector::buildChain(options);
|
||||
auto stages = effector::buildChain(options);
|
||||
// A headless run always renders something, so with no -e the default
|
||||
// effect stands in. The interface has no such need: it opens on an empty
|
||||
// chain and waits for the user to add the first stage.
|
||||
if (stages.empty()) {
|
||||
const auto effect = effector::effectByName(effector::defaultEffectName());
|
||||
stages.push_back({effect, effect->defaults()});
|
||||
}
|
||||
|
||||
std::vector<effector::LoadFailure> failures;
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
// Small self-contained checks for the pieces that have no GUI dependency.
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <clocale>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <locale>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
@@ -13,13 +17,19 @@
|
||||
#include <vector>
|
||||
|
||||
#include "cli/options.hpp"
|
||||
#include "core/canvas.hpp"
|
||||
#include "core/chain.hpp"
|
||||
#include "core/project.hpp"
|
||||
#include "core/registry.hpp"
|
||||
#include "core/rng.hpp"
|
||||
#include "core/style.hpp"
|
||||
#include "effects/blur.hpp"
|
||||
#include "effects/grain.hpp"
|
||||
#include "effects/monochrome.hpp"
|
||||
#include "effects/overlay.hpp"
|
||||
#include "effects/scanlines.hpp"
|
||||
#include "effects/text.hpp"
|
||||
#include "effects/tiles.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -69,6 +79,25 @@ std::optional<std::pair<int, int>> canvasFromSize(const std::string& size) {
|
||||
}
|
||||
}
|
||||
|
||||
// The options a whole command line ends up as, or nothing when the parser
|
||||
// refuses it.
|
||||
std::optional<effector::Options> parsedOptions(const std::vector<std::string>& args) {
|
||||
std::vector<std::vector<char>> storage;
|
||||
storage.push_back({'e', 'f', 'f', 'e', 'c', 't', 'o', 'r', '\0'});
|
||||
for (const auto& arg : args) {
|
||||
std::vector<char> buffer(arg.begin(), arg.end());
|
||||
buffer.push_back('\0');
|
||||
storage.push_back(std::move(buffer));
|
||||
}
|
||||
std::vector<char*> argv;
|
||||
for (auto& buffer : storage) argv.push_back(buffer.data());
|
||||
try {
|
||||
return effector::parseOptions(static_cast<int>(argv.size()), argv.data());
|
||||
} catch (const effector::EffectError&) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
bool sameBytes(const effector::Image& a, const effector::Image& b) {
|
||||
if (a.width() != b.width() || a.height() != b.height()) return false;
|
||||
return std::equal(a.data(), a.data() + a.byteSize(), b.data());
|
||||
@@ -106,6 +135,65 @@ effector::Image renderGrain(const std::vector<effector::Image>& sources, int wid
|
||||
return effect.render(sources, width, height, rng, merged);
|
||||
}
|
||||
|
||||
effector::Image renderMono(const std::vector<effector::Image>& sources, int width, int height,
|
||||
std::uint64_t seed, const effector::ParamValues& values) {
|
||||
effector::MonochromeEffect effect;
|
||||
effector::Rng rng(seed);
|
||||
auto merged = effect.defaults();
|
||||
for (const auto& [key, value] : values) merged[key] = value;
|
||||
return effect.render(sources, width, height, rng, merged);
|
||||
}
|
||||
|
||||
effector::Image renderOverlay(const std::vector<effector::Image>& sources, int width, int height,
|
||||
const effector::ParamValues& values) {
|
||||
effector::OverlayEffect effect;
|
||||
effector::Rng rng(7);
|
||||
auto merged = effect.defaults();
|
||||
for (const auto& [key, value] : values) merged[key] = value;
|
||||
return effect.render(sources, width, height, rng, merged);
|
||||
}
|
||||
|
||||
effector::Image renderText(const std::vector<effector::Image>& sources, int width, int height,
|
||||
const effector::ParamValues& values) {
|
||||
effector::TextEffect effect;
|
||||
effector::Rng rng(7);
|
||||
auto merged = effect.defaults();
|
||||
for (const auto& [key, value] : values) merged[key] = value;
|
||||
return effect.render(sources, width, height, rng, merged);
|
||||
}
|
||||
|
||||
effector::Image renderTiles(const std::vector<effector::Image>& sources, int width, int height,
|
||||
std::uint64_t seed, const effector::ParamValues& values) {
|
||||
effector::TilesEffect effect;
|
||||
effector::Rng rng(seed);
|
||||
auto merged = effect.defaults();
|
||||
for (const auto& [key, value] : values) merged[key] = value;
|
||||
return effect.render(sources, width, height, rng, merged);
|
||||
}
|
||||
|
||||
// The three channels of one pixel, to read a placement off directly.
|
||||
std::array<int, 3> pixel(const effector::Image& image, int x, int y) {
|
||||
const auto* px = image.row(y) + static_cast<std::size_t>(x) * 3;
|
||||
return {px[0], px[1], px[2]};
|
||||
}
|
||||
|
||||
bool isRed(const std::array<int, 3>& px) { return px[0] > 200 && px[1] < 60 && px[2] < 60; }
|
||||
bool isBlue(const std::array<int, 3>& px) { return px[2] > 200 && px[0] < 60 && px[1] < 60; }
|
||||
|
||||
// A flat field of one colour, to read the channel weighting off directly.
|
||||
effector::Image solid(int width, int height, std::uint8_t r, std::uint8_t g, std::uint8_t b) {
|
||||
effector::Image image(width, height);
|
||||
for (int y = 0; y < height; ++y) {
|
||||
auto* row = image.row(y);
|
||||
for (int x = 0; x < width; ++x) {
|
||||
row[x * 3 + 0] = r;
|
||||
row[x * 3 + 1] = g;
|
||||
row[x * 3 + 2] = b;
|
||||
}
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
// A flat field of one shade, to measure what the grain does to it.
|
||||
effector::Image flat(int width, int height, std::uint8_t shade) {
|
||||
effector::Image image(width, height);
|
||||
@@ -197,6 +285,81 @@ int main() {
|
||||
check(threw, "no sources throws");
|
||||
}
|
||||
|
||||
std::cout << "tiles\n";
|
||||
{
|
||||
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90),
|
||||
gradient(300, 200, 200)};
|
||||
|
||||
for (const std::string layout : {"grid", "brick", "scatter"}) {
|
||||
const ParamValues values = {{"tile_layout", layout},
|
||||
{"tile_width", 70L},
|
||||
{"tile_height", 50L},
|
||||
{"tile_jitter", 12L}};
|
||||
const auto first = renderTiles(sources, 400, 300, 1, values);
|
||||
const auto again = renderTiles(sources, 400, 300, 1, values);
|
||||
const auto other = renderTiles(sources, 400, 300, 2, values);
|
||||
|
||||
check(first.width() == 400 && first.height() == 300, layout + " has the requested size");
|
||||
check(sameBytes(first, again), layout + ": same seed reproduces the image");
|
||||
check(!sameBytes(first, other), layout + ": a different seed changes the image");
|
||||
}
|
||||
|
||||
// With no jitter the grid cells line up: the gradient's red channel
|
||||
// counts up with x inside a tile and only breaks at a cell boundary.
|
||||
const auto aligned = renderTiles(sources, 400, 300, 5,
|
||||
{{"tile_layout", std::string("grid")}, {"tile_width", 100L},
|
||||
{"tile_height", 100L}});
|
||||
bool onlyAtBoundaries = true;
|
||||
for (int y = 0; y < aligned.height(); ++y) {
|
||||
const auto* row = aligned.row(y);
|
||||
for (int x = 1; x < aligned.width(); ++x) {
|
||||
const bool breaks = row[x * 3] != ((row[(x - 1) * 3] + 1) & 0xFF);
|
||||
if (breaks && x % 100 != 0) onlyAtBoundaries = false;
|
||||
}
|
||||
}
|
||||
check(onlyAtBoundaries, "grid cells break only on the cell boundaries");
|
||||
|
||||
// One source smaller than a single tile: the cut is repeated to fill
|
||||
// the cell, so the canvas is covered rather than left black.
|
||||
std::vector<Image> small = {gradient(60, 40, 200)};
|
||||
const auto covered = renderTiles(small, 500, 400, 9,
|
||||
{{"tile_layout", std::string("brick")},
|
||||
{"tile_width", 200L},
|
||||
{"tile_height", 150L}});
|
||||
bool filled = true;
|
||||
for (int y = 0; y < covered.height(); ++y) {
|
||||
const auto* row = covered.row(y);
|
||||
for (int x = 0; x < covered.width(); ++x) filled = filled && row[x * 3 + 2] == 200;
|
||||
}
|
||||
check(filled, "a source smaller than a tile still covers the canvas");
|
||||
|
||||
const auto tiny = renderTiles(sources, 120, 90, 3,
|
||||
{{"tile_width", 1L}, {"tile_height", 1L}});
|
||||
check(tiny.width() == 120 && tiny.height() == 90, "tiles one pixel across");
|
||||
|
||||
const auto ordered = renderTiles(sources, 300, 200, 4,
|
||||
{{"tile_width", 40L}, {"tile_keep_order", true}});
|
||||
check(ordered.height() == 200, "keep_order fills the canvas");
|
||||
|
||||
// A single scattered tile leaves the rest of the canvas to the backdrop.
|
||||
const auto sparse = renderTiles(small, 400, 300, 6,
|
||||
{{"tile_layout", std::string("scatter")}, {"tile_count", 1L}});
|
||||
bool backdrop = true;
|
||||
for (int y = 0; y < sparse.height(); ++y) {
|
||||
const auto* row = sparse.row(y);
|
||||
for (int x = 0; x < sparse.width(); ++x) backdrop = backdrop && row[x * 3 + 2] == 200;
|
||||
}
|
||||
check(backdrop, "scattered tiles land on a filled backdrop");
|
||||
|
||||
bool threw = false;
|
||||
try {
|
||||
renderTiles({}, 100, 100, 1, {});
|
||||
} catch (const EffectError&) {
|
||||
threw = true;
|
||||
}
|
||||
check(threw, "no sources throws");
|
||||
}
|
||||
|
||||
std::cout << "blur\n";
|
||||
{
|
||||
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
|
||||
@@ -312,6 +475,464 @@ int main() {
|
||||
check(speckled && any, "without sources grain works on an empty canvas");
|
||||
}
|
||||
|
||||
std::cout << "monochrome\n";
|
||||
{
|
||||
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
|
||||
|
||||
const auto first = renderMono(sources, 400, 300, 4, {});
|
||||
const auto again = renderMono(sources, 400, 300, 4, {});
|
||||
check(first.width() == 400 && first.height() == 300, "canvas has the requested size");
|
||||
check(sameBytes(first, again), "same seed reproduces the image");
|
||||
|
||||
bool grey = true;
|
||||
for (std::size_t i = 0; i + 2 < first.byteSize(); i += 3) {
|
||||
grey = grey && first.data()[i] == first.data()[i + 1] &&
|
||||
first.data()[i + 1] == first.data()[i + 2];
|
||||
}
|
||||
check(grey, "every pixel comes out neutral grey");
|
||||
|
||||
// A pure red field: the luminance weighting keeps little of it, the red
|
||||
// weighting nearly all, and the blue weighting almost none.
|
||||
std::vector<Image> red = {solid(64, 64, 255, 0, 0)};
|
||||
const auto viaLuma = renderMono(red, 64, 64, 1, {});
|
||||
const auto viaRed = renderMono(red, 64, 64, 1, {{"mono_weighting", std::string("red")}});
|
||||
const auto viaBlue = renderMono(red, 64, 64, 1, {{"mono_weighting", std::string("blue")}});
|
||||
check(viaLuma.data()[0] > 40 && viaLuma.data()[0] < 70, "luminance darkens a pure red");
|
||||
check(viaRed.data()[0] > 190, "the red weighting keeps a pure red light");
|
||||
check(viaBlue.data()[0] < 50, "the blue weighting renders a pure red dark");
|
||||
|
||||
// Contrast works about mid grey: it leaves that alone and pushes the
|
||||
// shades either side of it apart.
|
||||
const auto midHard =
|
||||
renderMono({solid(32, 32, 128, 128, 128)}, 32, 32, 1, {{"mono_contrast", 2.0}});
|
||||
check(midHard.data()[0] >= 127 && midHard.data()[0] <= 129, "contrast pivots on mid grey");
|
||||
const auto darkFlat = renderMono({solid(32, 32, 80, 80, 80)}, 32, 32, 1, {});
|
||||
const auto darkHard =
|
||||
renderMono({solid(32, 32, 80, 80, 80)}, 32, 32, 1, {{"mono_contrast", 2.0}});
|
||||
check(darkHard.data()[0] < darkFlat.data()[0], "contrast deepens the shadows");
|
||||
|
||||
// The toners tint the greys, each in its own direction.
|
||||
const auto sepia = renderMono({solid(32, 32, 128, 128, 128)}, 32, 32, 1,
|
||||
{{"mono_tone", std::string("sepia")}});
|
||||
check(sepia.data()[0] > sepia.data()[1] && sepia.data()[1] > sepia.data()[2],
|
||||
"sepia warms the greys");
|
||||
const auto cyan = renderMono({solid(32, 32, 128, 128, 128)}, 32, 32, 1,
|
||||
{{"mono_tone", std::string("cyanotype")}});
|
||||
check(cyan.data()[2] > cyan.data()[1] && cyan.data()[1] > cyan.data()[0],
|
||||
"the cyanotype toner cools them");
|
||||
|
||||
// Strength mixes back towards the colour input: zero is a pass through,
|
||||
// half sits between the two.
|
||||
check(sameBytes(
|
||||
renderMono({solid(32, 32, 200, 40, 10)}, 32, 32, 1, {{"mono_strength", 0.0}}),
|
||||
solid(32, 32, 200, 40, 10)),
|
||||
"zero strength leaves the colours untouched");
|
||||
const auto half =
|
||||
renderMono({solid(32, 32, 200, 40, 10)}, 32, 32, 1, {{"mono_strength", 0.5}});
|
||||
const auto full = renderMono({solid(32, 32, 200, 40, 10)}, 32, 32, 1, {});
|
||||
check(half.data()[0] > full.data()[0] && half.data()[0] < 200,
|
||||
"half strength sits between the grey and the colour");
|
||||
|
||||
// Like blur and grain it needs no sources: with none it converts the
|
||||
// empty canvas, which stays black.
|
||||
const auto blank = renderMono({}, 50, 50, 1, {});
|
||||
bool allBlack = blank.width() == 50 && blank.height() == 50;
|
||||
for (std::size_t i = 0; i < blank.byteSize(); ++i) {
|
||||
allBlack = allBlack && blank.data()[i] == 0;
|
||||
}
|
||||
check(allBlack, "without sources monochrome renders an empty canvas");
|
||||
}
|
||||
|
||||
std::cout << "overlay\n";
|
||||
{
|
||||
const auto dir = std::filesystem::temp_directory_path() / "effector_test_overlay";
|
||||
std::filesystem::create_directories(dir);
|
||||
const auto logo = (dir / "logo.png").string();
|
||||
solid(40, 20, 255, 0, 0).save(logo); // wider than it is tall, to tell the fittings apart
|
||||
|
||||
const std::vector<Image> blue = {solid(100, 100, 0, 0, 255)};
|
||||
const ParamValues at = {{"overlay_image", logo}, {"overlay_fit", std::string("original")}};
|
||||
|
||||
const auto centred = renderOverlay(blue, 100, 100, at);
|
||||
check(isRed(pixel(centred, 50, 50)), "the overlay lands in the middle of the canvas");
|
||||
check(isBlue(pixel(centred, 2, 2)), "and leaves the rest of the input alone");
|
||||
check(isRed(pixel(centred, 30, 40)) && isBlue(pixel(centred, 29, 40)),
|
||||
"centred, a 40x20 image starts 30 pixels in");
|
||||
|
||||
ParamValues corner = at;
|
||||
corner["overlay_center"] = false;
|
||||
const auto topLeft = renderOverlay(blue, 100, 100, corner);
|
||||
check(isRed(pixel(topLeft, 0, 0)) && isBlue(pixel(topLeft, 41, 0)),
|
||||
"without centring it sits in the top left corner");
|
||||
|
||||
ParamValues nudged = corner;
|
||||
nudged["overlay_offset_x"] = 10L;
|
||||
nudged["overlay_offset_y"] = 5L;
|
||||
const auto moved = renderOverlay(blue, 100, 100, nudged);
|
||||
check(isRed(pixel(moved, 10, 5)) && isBlue(pixel(moved, 9, 5)) && isBlue(pixel(moved, 10, 4)),
|
||||
"the offset moves it right and down");
|
||||
|
||||
ParamValues back = at;
|
||||
back["overlay_offset_x"] = -25L;
|
||||
const auto half = renderOverlay(blue, 100, 100, back);
|
||||
check(isRed(pixel(half, 5, 50)) && isBlue(pixel(half, 96, 50)),
|
||||
"a negative offset moves it off the left edge, and it is clipped");
|
||||
|
||||
// Fitting: fit shows the whole image and keeps its proportions, so a
|
||||
// 40x20 logo becomes 100x50 with a band of input above and below; fill
|
||||
// and stretch and tile all cover the canvas.
|
||||
ParamValues fitted = at;
|
||||
fitted["overlay_fit"] = std::string("fit");
|
||||
const auto fit = renderOverlay(blue, 100, 100, fitted);
|
||||
check(isBlue(pixel(fit, 50, 10)) && isRed(pixel(fit, 50, 50)) && isRed(pixel(fit, 1, 50)),
|
||||
"fit shows the whole image, in its own proportions");
|
||||
|
||||
for (const char* mode : {"fill", "stretch", "tile"}) {
|
||||
ParamValues covering = at;
|
||||
covering["overlay_fit"] = std::string(mode);
|
||||
const auto image = renderOverlay(blue, 100, 100, covering);
|
||||
bool everywhere = true;
|
||||
for (int y = 0; y < 100; y += 7) {
|
||||
for (int x = 0; x < 100; x += 7) everywhere = everywhere && isRed(pixel(image, x, y));
|
||||
}
|
||||
check(everywhere, std::string(mode) + " covers the whole canvas");
|
||||
}
|
||||
|
||||
// Scale resizes what the fitting worked out: half of 100x50 is 50x25.
|
||||
ParamValues scaled = fitted;
|
||||
scaled["overlay_scale"] = 0.5;
|
||||
const auto smaller = renderOverlay(blue, 100, 100, scaled);
|
||||
check(isRed(pixel(smaller, 50, 50)) && isBlue(pixel(smaller, 20, 50)),
|
||||
"the scale shrinks the fitted image about its centre");
|
||||
|
||||
// Opacity mixes over the input rather than replacing it.
|
||||
ParamValues faint = at;
|
||||
faint["overlay_opacity"] = 0.5;
|
||||
const auto mixed = pixel(renderOverlay(blue, 100, 100, faint), 50, 50);
|
||||
check(mixed[0] > 100 && mixed[0] < 155 && mixed[2] > 100 && mixed[2] < 155,
|
||||
"half opacity sits between the overlay and the input");
|
||||
check(sameBytes(renderOverlay(blue, 100, 100, {{"overlay_image", logo},
|
||||
{"overlay_opacity", 0.0}}),
|
||||
solid(100, 100, 0, 0, 255)),
|
||||
"zero opacity leaves the input untouched");
|
||||
|
||||
// No image chosen is the state the interface starts in: a pass through.
|
||||
check(sameBytes(renderOverlay(blue, 100, 100, {}), solid(100, 100, 0, 0, 255)),
|
||||
"with no image chosen the stage passes its input through");
|
||||
|
||||
// Like blur and monochrome it needs no sources of its own.
|
||||
check(!OverlayEffect().needsSources(), "the overlay can lead a chain");
|
||||
const auto alone = renderOverlay({}, 60, 40, {{"overlay_image", logo},
|
||||
{"overlay_fit", std::string("stretch")}});
|
||||
check(alone.width() == 60 && isRed(pixel(alone, 30, 20)),
|
||||
"without sources it draws on an empty canvas");
|
||||
|
||||
bool threw = false;
|
||||
try {
|
||||
renderOverlay(blue, 100, 100, {{"overlay_image", std::string("/nope/missing.png")}});
|
||||
} catch (const EffectError&) {
|
||||
threw = true;
|
||||
}
|
||||
check(threw, "a missing overlay image is reported");
|
||||
|
||||
// The path travels with the project, written relative to its folder.
|
||||
Project project;
|
||||
project.width = 100;
|
||||
project.height = 100;
|
||||
project.stages.push_back({effectByName("overlay"), {{"overlay_image", logo}}});
|
||||
const auto json = projectToJson(project, dir.string());
|
||||
check(json.find("\"logo.png\"") != std::string::npos,
|
||||
"an overlay image inside the project folder is written relative");
|
||||
const auto reread = projectFromJson(json, dir.string());
|
||||
check(paramText(reread.stages[0].values, "overlay_image", "") == logo,
|
||||
"and comes back as the same file");
|
||||
|
||||
std::filesystem::remove_all(dir);
|
||||
}
|
||||
|
||||
std::cout << "colour params\n";
|
||||
{
|
||||
std::uint8_t rgb[3] = {0, 0, 0};
|
||||
check(parseColor("#ff8800", rgb) && rgb[0] == 255 && rgb[1] == 136 && rgb[2] == 0,
|
||||
"a six digit colour is read");
|
||||
check(parseColor("00FF80", rgb) && rgb[1] == 255 && rgb[2] == 128,
|
||||
"the hash is optional and the case does not matter");
|
||||
check(parseColor("#f80", rgb) && rgb[0] == 255 && rgb[1] == 136 && rgb[2] == 0,
|
||||
"the three digit shorthand doubles each digit");
|
||||
check(!parseColor("#12345", rgb) && !parseColor("#gg0000", rgb) && !parseColor("", rgb),
|
||||
"anything else is refused");
|
||||
const std::uint8_t written[3] = {1, 171, 255};
|
||||
check(formatColor(written) == "#01abff", "a colour is written back as six digits");
|
||||
|
||||
// A value that does not parse falls back rather than painting
|
||||
// something arbitrary.
|
||||
std::uint8_t fallback[3] = {0, 0, 0};
|
||||
paramColor({{"c", std::string("nonsense")}}, "c", "#102030", fallback);
|
||||
check(fallback[0] == 0x10 && fallback[1] == 0x20 && fallback[2] == 0x30,
|
||||
"a malformed colour keeps the default");
|
||||
|
||||
// Both new types survive a project round trip, and the reader drops a
|
||||
// colour it cannot read exactly as it drops an unknown choice.
|
||||
Project project;
|
||||
project.width = 64;
|
||||
project.height = 64;
|
||||
project.stages.push_back({effectByName("text"),
|
||||
{{"text", std::string("a \"quoted\" line")},
|
||||
{"text_color", std::string("#ff0080")}}});
|
||||
const auto reread = projectFromJson(projectToJson(project, ""), "");
|
||||
check(paramText(reread.stages[0].values, "text", "") == "a \"quoted\" line",
|
||||
"text with quotes in it survives a project round trip");
|
||||
check(paramText(reread.stages[0].values, "text_color", "") == "#ff0080",
|
||||
"and so does a colour");
|
||||
const auto broken = projectFromJson(
|
||||
"{\"chain\": [{\"effect\": \"text\", \"params\": {\"text_color\": \"#zz\"}}]}", "");
|
||||
check(paramText(broken.stages[0].values, "text_color", "") == "#ffffff",
|
||||
"a colour a project cannot be read keeps the effect's default");
|
||||
|
||||
// The command line refuses one too, rather than drawing black.
|
||||
bool threw = false;
|
||||
try {
|
||||
const char* argv[] = {"effector", "-e", "text", "--text-color", "purple"};
|
||||
parseOptions(5, const_cast<char**>(argv));
|
||||
} catch (const EffectError&) {
|
||||
threw = true;
|
||||
}
|
||||
check(threw, "an unparseable colour on the command line is reported");
|
||||
}
|
||||
|
||||
std::cout << "text\n";
|
||||
{
|
||||
const std::vector<Image> blue = {solid(200, 120, 0, 0, 255)};
|
||||
const ParamValues plain = {{"text", std::string("Hi")},
|
||||
{"font_size", 60.0},
|
||||
{"text_color", std::string("#ff0000")}};
|
||||
|
||||
// Counting the red pixels is how every placement below is read: the
|
||||
// exact glyphs belong to whatever font this machine has.
|
||||
const auto countRed = [](const Image& image, int x0, int y0, int x1, int y1) {
|
||||
int found = 0;
|
||||
for (int y = y0; y < y1; ++y) {
|
||||
for (int x = x0; x < x1; ++x) {
|
||||
if (isRed(pixel(image, x, y))) ++found;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
const auto centred = renderText(blue, 200, 120, plain);
|
||||
check(countRed(centred, 60, 30, 140, 90) > 0, "the text lands in the middle of the canvas");
|
||||
check(countRed(centred, 0, 0, 200, 120) == countRed(centred, 50, 20, 150, 100),
|
||||
"and nothing is written outside it");
|
||||
|
||||
ParamValues corner = plain;
|
||||
corner["text_center"] = false;
|
||||
const auto topLeft = renderText(blue, 200, 120, corner);
|
||||
check(countRed(topLeft, 0, 0, 100, 60) > 0 && countRed(topLeft, 100, 60, 200, 120) == 0,
|
||||
"uncentred it starts at the top left corner");
|
||||
|
||||
ParamValues nudged = corner;
|
||||
nudged["text_offset_x"] = 100L;
|
||||
nudged["text_offset_y"] = 60L;
|
||||
const auto moved = renderText(blue, 200, 120, nudged);
|
||||
check(countRed(moved, 100, 60, 200, 120) > 0 && countRed(moved, 0, 0, 100, 60) == 0,
|
||||
"the offset moves it down and to the right");
|
||||
|
||||
// Weight and size are the two knobs the glyphs themselves answer to: a
|
||||
// bolder or a larger setting covers strictly more of the canvas.
|
||||
ParamValues bold = plain;
|
||||
bold["font_weight"] = std::string("heavy");
|
||||
check(countRed(renderText(blue, 200, 120, bold), 0, 0, 200, 120) >
|
||||
countRed(centred, 0, 0, 200, 120),
|
||||
"a heavier weight inks more pixels than a normal one");
|
||||
ParamValues large = plain;
|
||||
large["font_size"] = 90.0;
|
||||
check(countRed(renderText(blue, 200, 120, large), 0, 0, 200, 120) >
|
||||
countRed(centred, 0, 0, 200, 120),
|
||||
"and so does a larger size");
|
||||
|
||||
// The box is drawn under the text, in a colour or from a picture.
|
||||
ParamValues boxed = plain;
|
||||
boxed["text_box"] = std::string("colour");
|
||||
boxed["box_color"] = std::string("#00ff00");
|
||||
boxed["box_padding"] = 10L;
|
||||
const auto onBox = renderText(blue, 200, 120, boxed);
|
||||
const auto corner_px = pixel(onBox, 2, 2);
|
||||
check(corner_px[2] > 200, "the box leaves the canvas outside it alone");
|
||||
bool greenSomewhere = false;
|
||||
for (int y = 0; y < 120 && !greenSomewhere; ++y) {
|
||||
for (int x = 0; x < 200; ++x) {
|
||||
const auto px = pixel(onBox, x, y);
|
||||
if (px[1] > 200 && px[0] < 60 && px[2] < 60) greenSomewhere = true;
|
||||
}
|
||||
}
|
||||
check(greenSomewhere, "and is filled with the colour it was given");
|
||||
check(countRed(onBox, 0, 0, 200, 120) > 0, "the text is drawn over the box");
|
||||
|
||||
ParamValues faint = boxed;
|
||||
faint["box_opacity"] = 0.5;
|
||||
const auto half = renderText(blue, 200, 120, faint);
|
||||
bool mixed = false;
|
||||
for (int y = 0; y < 120 && !mixed; ++y) {
|
||||
for (int x = 0; x < 200; ++x) {
|
||||
const auto px = pixel(half, x, y);
|
||||
// Half the green box over the blue input, and neither of them.
|
||||
if (px[1] > 100 && px[1] < 200 && px[2] > 100 && px[2] < 200) mixed = true;
|
||||
}
|
||||
}
|
||||
check(mixed, "half a box opacity sits between the box and the input");
|
||||
|
||||
const auto dir = std::filesystem::temp_directory_path() / "effector_test_text";
|
||||
std::filesystem::create_directories(dir);
|
||||
const auto picture = (dir / "fill.png").string();
|
||||
solid(40, 40, 255, 255, 0).save(picture);
|
||||
ParamValues fromPicture = boxed;
|
||||
fromPicture["text_box"] = std::string("image");
|
||||
fromPicture["box_image"] = picture;
|
||||
const auto onPicture = renderText(blue, 200, 120, fromPicture);
|
||||
bool yellowSomewhere = false;
|
||||
for (int y = 0; y < 120 && !yellowSomewhere; ++y) {
|
||||
for (int x = 0; x < 200; ++x) {
|
||||
const auto px = pixel(onPicture, x, y);
|
||||
if (px[0] > 200 && px[1] > 200 && px[2] < 60) yellowSomewhere = true;
|
||||
}
|
||||
}
|
||||
check(yellowSomewhere, "a box picture fills the box");
|
||||
check(pixel(onPicture, 2, 2)[2] > 200, "and stops at its edge");
|
||||
|
||||
bool threw = false;
|
||||
try {
|
||||
ParamValues missing = fromPicture;
|
||||
missing["box_image"] = std::string("/nope/missing.png");
|
||||
renderText(blue, 200, 120, missing);
|
||||
} catch (const EffectError&) {
|
||||
threw = true;
|
||||
}
|
||||
check(threw, "a missing box picture is reported");
|
||||
|
||||
// Nothing to draw is the state the interface starts in: a pass through.
|
||||
check(sameBytes(renderText(blue, 200, 120, {{"text", std::string("")}}),
|
||||
solid(200, 120, 0, 0, 255)),
|
||||
"with no text typed the stage passes its input through");
|
||||
check(sameBytes(renderText(blue, 200, 120, {{"text", std::string("Hi")},
|
||||
{"text_opacity", 0.0}}),
|
||||
solid(200, 120, 0, 0, 255)),
|
||||
"zero opacity leaves the input untouched");
|
||||
|
||||
// Like blur and overlay it needs no sources of its own, and the same
|
||||
// settings render the same bytes twice over.
|
||||
check(!TextEffect().needsSources(), "text can lead a chain");
|
||||
const auto alone = renderText({}, 200, 120, plain);
|
||||
check(alone.width() == 200 && countRed(alone, 0, 0, 200, 120) > 0,
|
||||
"without sources it draws on an empty canvas");
|
||||
check(sameBytes(renderText(blue, 200, 120, boxed), onBox),
|
||||
"the same settings render the same bytes");
|
||||
|
||||
std::filesystem::remove_all(dir);
|
||||
}
|
||||
|
||||
std::cout << "text styles\n";
|
||||
{
|
||||
const std::vector<Image> blue = {solid(200, 120, 0, 0, 255)};
|
||||
const auto countRed = [](const Image& image) {
|
||||
int found = 0;
|
||||
for (int y = 0; y < image.height(); ++y) {
|
||||
for (int x = 0; x < image.width(); ++x) {
|
||||
if (isRed(pixel(image, x, y))) ++found;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
};
|
||||
|
||||
const auto specs = styleSpecs("text");
|
||||
const auto carries = [&specs](const std::string& key) {
|
||||
return std::any_of(specs.begin(), specs.end(),
|
||||
[&key](const ParamSpec& spec) { return spec.key == key; });
|
||||
};
|
||||
check(specs.size() == 11, "a text style carries eleven params");
|
||||
check(carries("font_family") && carries("font_weight") && carries("font_italic") &&
|
||||
carries("font_size") && carries("text_color") && carries("text_align") &&
|
||||
carries("text_opacity") && carries("text_box") && carries("box_color") &&
|
||||
carries("box_padding") && carries("box_opacity"),
|
||||
"the look is what it carries");
|
||||
check(!carries("text") && !carries("text_wrap") && !carries("box_image") &&
|
||||
!carries("text_center") && !carries("text_offset_x") && !carries("text_offset_y"),
|
||||
"the words and the placement stay on the stage");
|
||||
check(styleSpecs("").empty() && styleSpecs("nosuchkind").empty(),
|
||||
"a kind no effect claims carries nothing");
|
||||
check(paramDouble(styleDefaults("text"), "font_size", 0.0) == 96.0 &&
|
||||
styleDefaults("text").size() == specs.size(),
|
||||
"a fresh style starts at the defaults the effect declares");
|
||||
|
||||
StyleSet styles = {{"text", "Title", styleDefaults("text")}};
|
||||
styles[0].values["font_size"] = 40.0;
|
||||
styles[0].values["text_color"] = std::string("#ff0000");
|
||||
check(findStyle(styles, "text", "Title") != nullptr &&
|
||||
findStyle(styles, "text", "Caption") == nullptr &&
|
||||
findStyle(styles, "other", "Title") == nullptr,
|
||||
"a style is found by its kind and its name");
|
||||
|
||||
const ChainStage own{effectByName("text"),
|
||||
{{"text", std::string("Hi")},
|
||||
{"font_size", 12.0},
|
||||
{"text_color", std::string("#ff0000")}},
|
||||
true,
|
||||
""};
|
||||
ChainStage follows = own;
|
||||
follows.style = "Title";
|
||||
|
||||
check(paramDouble(stageValues(own, styles), "font_size", 0.0) == 12.0,
|
||||
"a stage on its own keeps the settings it was given");
|
||||
check(paramDouble(stageValues(follows, styles), "font_size", 0.0) == 40.0,
|
||||
"a stage following a style takes the style's");
|
||||
check(paramText(stageValues(follows, styles), "text", "") == "Hi",
|
||||
"and keeps its own words either way");
|
||||
|
||||
Rng ownRng(3);
|
||||
Rng followRng(3);
|
||||
const auto small = renderChain({own}, blue, 200, 120, ownRng, styles);
|
||||
const auto large = renderChain({follows}, blue, 200, 120, followRng, styles);
|
||||
check(countRed(small) > 0 && countRed(large) > countRed(small),
|
||||
"the style is what actually reaches the render");
|
||||
|
||||
// The whole point: two stages, one style, one look in two places.
|
||||
ChainStage left = follows;
|
||||
left.values["text_offset_x"] = -40L;
|
||||
ChainStage right = follows;
|
||||
right.values["text_offset_x"] = 40L;
|
||||
Rng leftRng(3);
|
||||
Rng rightRng(3);
|
||||
const auto leftImage = renderChain({left}, blue, 200, 120, leftRng, styles);
|
||||
const auto rightImage = renderChain({right}, blue, 200, 120, rightRng, styles);
|
||||
check(countRed(leftImage) == countRed(large) && countRed(rightImage) == countRed(large),
|
||||
"two stages following one style ink the same glyphs");
|
||||
check(!sameBytes(leftImage, rightImage), "each in its own place");
|
||||
|
||||
StyleSet edited = styles;
|
||||
edited[0].values["font_size"] = 20.0;
|
||||
Rng editedLeft(3);
|
||||
Rng editedRight(3);
|
||||
const auto afterLeft = renderChain({left}, blue, 200, 120, editedLeft, edited);
|
||||
const auto afterRight = renderChain({right}, blue, 200, 120, editedRight, edited);
|
||||
check(countRed(afterLeft) < countRed(leftImage) &&
|
||||
countRed(afterRight) < countRed(rightImage) &&
|
||||
countRed(afterLeft) == countRed(afterRight),
|
||||
"editing the style moves every stage that follows it");
|
||||
|
||||
ChainStage missing = follows;
|
||||
missing.style = "Nowhere";
|
||||
Rng missingRng(3);
|
||||
Rng bareRng(3);
|
||||
check(sameBytes(renderChain({missing}, blue, 200, 120, missingRng, styles),
|
||||
renderChain({own}, blue, 200, 120, bareRng)),
|
||||
"naming a style that is not there renders as standing alone");
|
||||
|
||||
Rng noStyleRng(3);
|
||||
Rng noPoolRng(3);
|
||||
check(sameBytes(renderChain({follows}, blue, 200, 120, noStyleRng, {}),
|
||||
renderChain({own}, blue, 200, 120, noPoolRng)),
|
||||
"and so does a chain handed no styles at all");
|
||||
}
|
||||
|
||||
std::cout << "chain\n";
|
||||
{
|
||||
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
|
||||
@@ -373,6 +994,50 @@ int main() {
|
||||
scanlinesThrew = true;
|
||||
}
|
||||
check(scanlinesThrew, "scanlines still refuses to run without sources");
|
||||
|
||||
// A stage switched off is skipped whole: it is handed no image and
|
||||
// draws nothing from the shared rng, so the cascade comes out exactly
|
||||
// as it would have without that stage in it at all.
|
||||
ChainStage blurOff = blur;
|
||||
blurOff.enabled = false;
|
||||
ChainStage scanlinesOff = scanlines;
|
||||
scanlinesOff.enabled = false;
|
||||
|
||||
Rng skipping(5);
|
||||
const auto skipped = renderChain({scanlines, blurOff}, sources, 400, 300, skipping);
|
||||
check(sameBytes(skipped, direct), "a disabled stage is skipped, its rng draws with it");
|
||||
check(chainTitle({scanlines, blurOff}) == "Scanlines", "the title names only what ran");
|
||||
check(chainSlug({scanlines, blurOff}) == "scanlines", "and so does the slug");
|
||||
|
||||
// Whoever leads the chain is handed the sources, and that is the first
|
||||
// stage still switched on.
|
||||
check(!chainNeedsSources({scanlinesOff, blur}),
|
||||
"a disabled leading stage leaves the question to the stage behind it");
|
||||
Rng leading(5);
|
||||
const auto ledByBlur = renderChain({scanlinesOff, blur}, {}, 200, 150, leading);
|
||||
check(sameBytes(ledByBlur, sourceless), "the stage behind it leads instead");
|
||||
|
||||
bool allOffThrew = false;
|
||||
try {
|
||||
Rng rng(1);
|
||||
renderChain({scanlinesOff, blurOff}, sources, 100, 100, rng);
|
||||
} catch (const EffectError&) {
|
||||
allOffThrew = true;
|
||||
}
|
||||
check(allOffThrew, "a chain with every stage disabled throws");
|
||||
|
||||
// The command line switches a stage off by effect name, the same way
|
||||
// the params pick out the stage they belong to.
|
||||
const auto cli =
|
||||
parsedOptions({"-e", "scanlines", "-e", "blur", "--disable", "blur", "--radius", "4"});
|
||||
check(cli.has_value(), "--disable is accepted");
|
||||
const auto cliStages = buildChain(*cli);
|
||||
check(cliStages.size() == 2 && cliStages[0].enabled && !cliStages[1].enabled,
|
||||
"--disable keeps the stage in the chain and switches it off");
|
||||
check(paramInt(cliStages[1].values, "radius", 0) == 4,
|
||||
"a switched-off stage still takes its flags");
|
||||
check(!parsedOptions({"-e", "blur", "--disable", "nope"}).has_value(),
|
||||
"--disable refuses an unknown effect");
|
||||
}
|
||||
|
||||
std::cout << "project\n";
|
||||
@@ -433,6 +1098,86 @@ int main() {
|
||||
check(stripped.stages[0].values.find("radius") == stripped.stages[0].values.end(),
|
||||
"a stage only keeps the params its effect declares");
|
||||
|
||||
// A stage switched off keeps its place and its settings in the file and
|
||||
// comes back switched off; nothing is written for the ordinary case.
|
||||
Project toggled = project;
|
||||
toggled.stages[1].enabled = false;
|
||||
const auto offFile = dir / "disabled.json";
|
||||
saveProject(offFile.string(), toggled);
|
||||
const auto offBack = loadProject(offFile.string());
|
||||
check(offBack.stages.size() == 2 && offBack.stages[0].enabled && !offBack.stages[1].enabled,
|
||||
"a switched-off stage comes back switched off");
|
||||
check(paramInt(offBack.stages[1].values, "radius", 0) == 3,
|
||||
"and with the settings it was switched off with");
|
||||
check(projectToJson(toggled).find("\"enabled\"") != std::string::npos &&
|
||||
projectToJson(project).find("\"enabled\"") == std::string::npos,
|
||||
"only a switched-off stage is written out as one");
|
||||
|
||||
// Styles ride along with the chain, and the stages come back pointing
|
||||
// at the ones they followed.
|
||||
Project stylish;
|
||||
stylish.styles = {{"text", "Title", styleDefaults("text")},
|
||||
{"text", "Caption", styleDefaults("text")}};
|
||||
stylish.styles[0].values["font_size"] = 120.0;
|
||||
stylish.styles[0].values["text_color"] = std::string("#ff8800");
|
||||
stylish.styles[1].values["font_family"] = std::string("Serif");
|
||||
stylish.stages.push_back(
|
||||
{effectByName("text"), {{"text", std::string("One")}}, true, "Title"});
|
||||
stylish.stages.push_back(
|
||||
{effectByName("text"), {{"text", std::string("Two")}}, true, "Title"});
|
||||
stylish.stages.push_back(
|
||||
{effectByName("text"), {{"text", std::string("Three")}}, true, ""});
|
||||
|
||||
const auto styleFile = dir / "styles.json";
|
||||
saveProject(styleFile.string(), stylish);
|
||||
const auto styleBack = loadProject(styleFile.string());
|
||||
check(styleBack.styles.size() == 2 && styleBack.styles[0].name == "Title" &&
|
||||
styleBack.styles[1].name == "Caption" && styleBack.styles[0].kind == "text",
|
||||
"the styles survive a round trip");
|
||||
check(paramDouble(styleBack.styles[0].values, "font_size", 0.0) == 120.0 &&
|
||||
paramText(styleBack.styles[0].values, "text_color", "") == "#ff8800" &&
|
||||
paramText(styleBack.styles[1].values, "font_family", "") == "Serif",
|
||||
"and so do the values they carry");
|
||||
check(styleBack.stages[0].style == "Title" && styleBack.stages[1].style == "Title" &&
|
||||
styleBack.stages[2].style.empty(),
|
||||
"and which stage follows which");
|
||||
check(paramDouble(stageValues(styleBack.stages[0], styleBack.styles), "font_size", 0.0) ==
|
||||
120.0 &&
|
||||
paramDouble(stageValues(styleBack.stages[2], styleBack.styles), "font_size",
|
||||
0.0) == 96.0,
|
||||
"a reloaded project resolves its styles as it did");
|
||||
check(projectToJson(stylish).find("\"styles\"") != std::string::npos &&
|
||||
projectToJson(project).find("\"styles\"") == std::string::npos &&
|
||||
projectToJson(project).find("\"style\"") == std::string::npos,
|
||||
"a project with no styles writes nothing about them");
|
||||
|
||||
// Everything that can be wrong with a style is dropped on its own,
|
||||
// leaving a project that still opens and still renders.
|
||||
const auto lenient = projectFromJson(R"({
|
||||
"styles": [
|
||||
{"kind": "nosuchkind", "name": "Ghost", "params": {}},
|
||||
{"kind": "text", "name": "", "params": {}},
|
||||
{"kind": "text", "name": "Title", "params": {"font_size": 60, "nonsense": 3}},
|
||||
{"kind": "text", "name": "Title", "params": {"font_size": 12}}
|
||||
],
|
||||
"chain": [
|
||||
{"effect": "text", "style": "Title", "params": {"text": "Kept"}},
|
||||
{"effect": "text", "style": "Gone", "params": {"font_size": 30}},
|
||||
{"effect": "blur", "style": "Title", "params": {}}
|
||||
]
|
||||
})");
|
||||
check(lenient.styles.size() == 1 && lenient.styles[0].name == "Title",
|
||||
"an unknown kind, a nameless style and a repeated name are dropped");
|
||||
check(paramDouble(lenient.styles[0].values, "font_size", 0.0) == 60.0 &&
|
||||
lenient.styles[0].values.find("nonsense") == lenient.styles[0].values.end(),
|
||||
"a style only keeps the params its kind declares, and the first of a name wins");
|
||||
check(lenient.stages[0].style == "Title" && lenient.stages[1].style.empty(),
|
||||
"a stage naming a style that is not there is cut loose");
|
||||
check(lenient.stages[2].style.empty(),
|
||||
"and so is one whose effect takes no style at all");
|
||||
check(paramDouble(stageValues(lenient.stages[1], lenient.styles), "font_size", 0.0) == 30.0,
|
||||
"which leaves it rendering the values written beside it");
|
||||
|
||||
int refusals = 0;
|
||||
for (const char* bad : {"{\"chain\": [{\"effect\": \"nope\"}]}", "{\"chain\": []}",
|
||||
"{\"chain\": [{\"effect\": \"blur\"},]}", "not json at all"}) {
|
||||
@@ -452,6 +1197,51 @@ int main() {
|
||||
}
|
||||
check(threw, "a missing project file throws");
|
||||
|
||||
// A project is JSON whatever the user's locale is. gtkmm sets both the
|
||||
// C and the C++ global locale from the environment, so on a German
|
||||
// desktop an unguarded stream writes "0,35" and a grouped "1.920" —
|
||||
// which is not JSON, and the project could not be opened again.
|
||||
const char* const commaLocales[] = {"de_DE.UTF-8", "de_DE.utf8", "fr_FR.UTF-8",
|
||||
"fr_FR.utf8"};
|
||||
bool decimalCommaTried = false;
|
||||
for (const char* name : commaLocales) {
|
||||
if (std::setlocale(LC_ALL, name) == nullptr) continue;
|
||||
std::locale previous;
|
||||
try {
|
||||
previous = std::locale::global(std::locale(name));
|
||||
} catch (const std::exception&) {
|
||||
continue;
|
||||
}
|
||||
decimalCommaTried = true;
|
||||
|
||||
Project wide;
|
||||
wide.width = 1920;
|
||||
wide.height = 1080;
|
||||
wide.seed = 17353667313003709502ULL; // more bits than a double holds
|
||||
wide.stages.push_back({effectByName("grain"), {{"grain_amount", 0.35}}});
|
||||
|
||||
const auto text = projectToJson(wide);
|
||||
bool reread = false;
|
||||
try {
|
||||
const auto back = projectFromJson(text);
|
||||
reread = back.width == 1920 && back.height == 1080 &&
|
||||
back.seed == wide.seed &&
|
||||
paramDouble(back.stages[0].values, "grain_amount", -1.0) == 0.35;
|
||||
} catch (const EffectError&) {
|
||||
}
|
||||
check(text.find("0.35") != std::string::npos &&
|
||||
text.find("\"width\": 1920") != std::string::npos,
|
||||
std::string("a project is written as JSON under ") + name);
|
||||
check(reread, std::string("a project written under ") + name + " opens again");
|
||||
|
||||
std::locale::global(previous);
|
||||
std::setlocale(LC_ALL, "C");
|
||||
break;
|
||||
}
|
||||
if (!decimalCommaTried) {
|
||||
std::cout << " skip a decimal-comma locale (none installed)\n";
|
||||
}
|
||||
|
||||
std::filesystem::remove_all(dir);
|
||||
}
|
||||
|
||||
@@ -521,6 +1311,120 @@ int main() {
|
||||
"-s refuses a side that is not positive");
|
||||
}
|
||||
|
||||
std::cout << "canvas formats\n";
|
||||
{
|
||||
using effector::canvasFormatByKey;
|
||||
using effector::canvasFormats;
|
||||
using effector::canvasFormatSize;
|
||||
|
||||
// Every format the menu offers must land inside the canvas bounds at
|
||||
// every resolution it can be asked for, or the dropdown would offer a
|
||||
// size the renderer refuses.
|
||||
int usable = 0;
|
||||
for (const auto& format : canvasFormats()) {
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
bool ok = true;
|
||||
for (const int dpi : {effector::kMinDpi, effector::kDefaultDpi, effector::kMaxDpi}) {
|
||||
canvasFormatSize(format, dpi, width, height);
|
||||
ok = ok && width >= kMinCanvasSide && width <= kMaxCanvasSide &&
|
||||
height >= kMinCanvasSide && height <= kMaxCanvasSide;
|
||||
}
|
||||
if (ok) ++usable;
|
||||
}
|
||||
check(usable == static_cast<int>(canvasFormats().size()),
|
||||
"every format stays inside the canvas bounds at every dpi");
|
||||
|
||||
int unique = 0;
|
||||
for (const auto& format : canvasFormats()) {
|
||||
if (canvasFormatByKey(format.key) == &format) ++unique;
|
||||
}
|
||||
check(unique == static_cast<int>(canvasFormats().size()), "format keys are unique");
|
||||
check(canvasFormatByKey("") == nullptr && canvasFormatByKey("a6") == nullptr,
|
||||
"an empty or unknown key is a custom canvas");
|
||||
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
canvasFormatSize(*canvasFormatByKey("a4"), 300, width, height);
|
||||
check(width == 2480 && height == 3508, "A4 at 300 dpi is 2480x3508");
|
||||
canvasFormatSize(*canvasFormatByKey("a4"), 600, width, height);
|
||||
check(width == 4961 && height == 7016, "doubling the dpi doubles the sheet");
|
||||
canvasFormatSize(*canvasFormatByKey("instagram-story"), 600, width, height);
|
||||
check(width == 1080 && height == 1920, "a web format ignores the dpi");
|
||||
|
||||
// The CLI: a format decides the canvas whichever side of -s it is on,
|
||||
// and --dpi may come before the format it applies to.
|
||||
const auto sheet = parsedOptions({"--format", "a4", "--dpi", "600"});
|
||||
check(sheet && sheet->width == 4961 && sheet->height == 7016,
|
||||
"--format a4 --dpi 600 sets the canvas");
|
||||
const auto reordered = parsedOptions({"--dpi", "600", "--format", "a4"});
|
||||
check(reordered && reordered->width == 4961, "--dpi may be given before --format");
|
||||
const auto both = parsedOptions({"-s", "800x600", "--format", "instagram-post"});
|
||||
check(both && both->width == 1080 && both->height == 1080, "--format overrides -s");
|
||||
const auto custom = parsedOptions({"-s", "800x600"});
|
||||
check(custom && custom->format.empty() && custom->width == 800,
|
||||
"-s alone leaves the canvas custom");
|
||||
check(!parsedOptions({"--format", "a6"}), "--format refuses an unknown format");
|
||||
check(!parsedOptions({"--dpi", "0"}) && !parsedOptions({"--dpi", "5000"}) &&
|
||||
!parsedOptions({"--dpi", "many"}),
|
||||
"--dpi refuses a resolution outside the bounds");
|
||||
|
||||
// A project keeps the format, and the format keeps the pixels: a dpi
|
||||
// edited by hand moves the canvas with it.
|
||||
Project sheetProject;
|
||||
sheetProject.stages.push_back({effectByName("blur"), {}});
|
||||
sheetProject.format = "a4";
|
||||
sheetProject.dpi = 600;
|
||||
canvasFormatSize(*canvasFormatByKey("a4"), 600, sheetProject.width, sheetProject.height);
|
||||
const auto backProject = projectFromJson(projectToJson(sheetProject));
|
||||
check(backProject.format == "a4" && backProject.dpi == 600 && backProject.width == 4961,
|
||||
"a print format and its dpi survive a round trip");
|
||||
|
||||
const auto retuned = projectFromJson(
|
||||
"{\"canvas\": {\"width\": 800, \"height\": 600, \"format\": \"a4\", "
|
||||
"\"dpi\": 300},\n \"chain\": [{\"effect\": \"blur\"}]}");
|
||||
check(retuned.width == 2480 && retuned.height == 3508,
|
||||
"a named format decides the pixels over whatever was written beside it");
|
||||
|
||||
// A format this build does not know — a newer file, or a typo — is a
|
||||
// custom canvas, so the pixels the file carries are still rendered.
|
||||
const auto unknown = projectFromJson(
|
||||
"{\"canvas\": {\"width\": 800, \"height\": 600, \"format\": \"a6\"},\n"
|
||||
" \"chain\": [{\"effect\": \"blur\"}]}");
|
||||
check(unknown.format.empty() && unknown.width == 800 && unknown.height == 600,
|
||||
"an unknown format falls back to the canvas the file names");
|
||||
|
||||
Project plainProject;
|
||||
plainProject.stages.push_back({effectByName("blur"), {}});
|
||||
plainProject.width = 800;
|
||||
plainProject.height = 600;
|
||||
const auto plainText = projectToJson(plainProject);
|
||||
check(plainText.find("format") == std::string::npos &&
|
||||
plainText.find("dpi") == std::string::npos,
|
||||
"a custom canvas writes no format and no dpi");
|
||||
|
||||
const auto webText = projectToJson([] {
|
||||
Project web;
|
||||
web.stages.push_back({effectByName("blur"), {}});
|
||||
web.format = "hd";
|
||||
return web;
|
||||
}());
|
||||
check(webText.find("\"format\": \"hd\"") != std::string::npos &&
|
||||
webText.find("dpi") == std::string::npos,
|
||||
"a web format writes no dpi it would never use");
|
||||
|
||||
int badDpi = 0;
|
||||
for (const char* bad : {"0", "5000", "300.5", "\"300\""}) {
|
||||
try {
|
||||
projectFromJson(std::string("{\"canvas\": {\"dpi\": ") + bad +
|
||||
"},\n \"chain\": [{\"effect\": \"blur\"}]}");
|
||||
} catch (const EffectError&) {
|
||||
++badDpi;
|
||||
}
|
||||
}
|
||||
check(badDpi == 4, "a dpi outside the bounds is refused");
|
||||
}
|
||||
|
||||
std::cout << "image io\n";
|
||||
{
|
||||
const auto path = std::filesystem::temp_directory_path() / "effector_test_roundtrip.png";
|
||||
|
||||
Reference in New Issue
Block a user