17 Commits
Author SHA1 Message Date
Si fc54d64bc4 Merge branch 'canvas-formats' 2026-09-18 22:36:24 +02:00
SiandClaude Opus 5 d48a81c665 Offer standard web and print canvas formats
The canvas was two spin buttons, so "an Instagram story" or "A4 at 300 dpi"
was a number the user had to work out. src/core/canvas.cpp now holds one
table of standard sizes both front-ends read: a web format is a fixed pixel
size, a print format 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 the 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, and where one is named it
decides the pixels — so a project saved as A4 reopens as A4 rather than as
whatever it happened to measure.

In the interface a format fills the width and height in and greys them out
rather than hiding them: the size a sheet works out to is worth seeing. A
print format also shows a DPI row, and Custom hands the spins back keeping
the last size, which makes starting from a format and then tweaking it the
ordinary path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:34:17 +02:00
Si f4d8457828 Merge branch 'image-overlay-effect' 2026-09-18 22:15:29 +02:00
SiandClaude Opus 5 749ea239bb Add a tiles effect
The two-dimensional counterpart of scanlines: rectangular tiles cut at
random positions from the source images, in three arrangements. A grid
draws its column widths and row heights once so the cells stay aligned
even when jittered; brick repacks every row on its own so the vertical
seams do not meet; scatter fills the canvas from one of the images and
drops loose tiles over it.

The plan is drawn serially from the rng as scanlines does, but scattered
tiles overlap, so they are painted in plan order on one thread: letting
threads race for the same pixels would leave the output depending on
their scheduling. A source smaller than a tile is repeated to fill the
cell, sideways and then downwards, which keeps grid and brick gapless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:10:54 +02:00
SiandClaude Opus 5 1d0343b90a Add a text effect
Writes a line or several over its input in a chosen family, weight, size
and colour, optionally on a box filled with a colour or with a picture
from disk. Like the blur and the overlay it needs no sources of its own,
so it can lead a chain; given sources it fills the canvas from a randomly
chosen one first.

Two param types rather than effect-specific branches in the front-ends:
Text, free-form text the user types, and Color, a colour written
"#rrggbb". Both travel as strings, so the interface gets an entry and a
colour button, the command line gets a flag (a colour that does not parse
is refused there rather than drawn as black), and a project keeps them
across a round trip.

The glyphs come from Pango, which draws through Cairo without a display,
so the core stays headless — but they are the one thing here that is not
byte-identical across machines: a machine without the chosen family
renders the substitute its fontconfig picks. Everything the effect is
handed, the rng included, is used exactly as every other effect uses it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 20:31:24 +02:00
SiandClaude Opus 5 c34bc426fc Write and read projects in the C locale, and keep the seed whole
gtkmm sets both the C and the C++ global locale from the environment, so
an unguarded stream writes "0,35" on a German desktop and groups 1920 as
"1.920" — neither of which the reader, or any other JSON reader, can take
back. Both the writer and the number parser now work in the classic
locale: the writer imbues its stream, and the parser uses from_chars
rather than stod, JSON having a point for a decimal mark whatever the
user's mark is.

The seed is read from the digits it was written as rather than from the
double they parsed into: 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 20:30:55 +02:00
SiandClaude Opus 5 e2252ecd30 Add an image overlay effect
Lays a single picture from disk over its input: fitted, filled, stretched,
left at its own size or tiled, centred or pinned to the top left corner,
nudged by an offset and mixed in by its opacity.

The file it needs is a new kind of param, Path, so the interface shows a
chooser and the CLI takes a file name, both without knowing an overlay
exists. Projects write it relative to their own folder, as they do a
source, and Image::load can now hand back the alpha channel instead of
flattening it onto white, so a cut-out PNG keeps its cut-out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 19:31:09 +02:00
Si 2a64ab115e Merge branch 'sidebar-chain-first' 2026-09-18 16:55:22 +02:00
SiandClaude Opus 5 efee1cf224 Add a black and white effect
Monochrome is the fourth effect and the third that declares
needsSources() false, so it works as a filter stage at the end of a
cascade and can also lead one, filling the canvas from a source the way
blur and grain do. Its knobs are the channel weighting, the contrast, a
toner and the strength: the weighting offers the mixes a photographer
would make behind the lens, so the colour names weigh that channel most
and "red" darkens a sky the way a red filter does; contrast pulls the
greys away from mid grey; the toners tint the finished greys warm
(sepia), barely (selenium) or cold (cyanotype); and strength mixes the
result back over the colour input, which at 0 is a pass through.

Contrast and toner only ever see one grey, so the whole tail of the
conversion is folded into a 256-entry table per channel, built once
before the parallel pass; the per-pixel work is then integer only, with
the weights as 1024ths and the tints as 256ths. The Rng is touched only
for the source pick and the crop offset, serially, before any threading.

The tests gain a `monochrome` section: neutrality of the output, each
weighting measured on a pure red field, the contrast pivot at mid grey,
both toner directions, strength as a pass through and as a midpoint, and
the sourceless render that stays black.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:55:00 +02:00
SiandClaude Opus 5 67417727e7 Check the canvas size before narrowing it to an int
A canvas side was cast to int with no range check, so a number that did
not fit came back as one that did: `-s 4294968296x600` rendered a
perfectly plausible 1000x600, and a hand-edited project file could ask
for a width the reader turned into something else (or, for a double out
of range, into undefined behaviour).

Both front-ends now check the side before the cast, against one shared
bound in chain.hpp — the 1..20000 the GUI spin buttons already offered,
so the help text, the CLI error, the project reader and the interface
all move together.

The tests gain a `canvas size` section covering the round trip at both
ends of the range and the refusals on either side of it, through the
project reader and through `-s`; the test binary picks up the CLI parser
to do it, which stays GUI-free.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:49:44 +02:00
SiandClaude Opus 5 82e730221e Add a film grain effect
Grain is the third effect and the second that declares needsSources()
false, so it works as a filter stage in a cascade and can also lead one,
filling the canvas from a source the way blur does. Its knobs are the
amount a grain may push a pixel, the grain size in pixels, whether the
channels are drawn apart into coloured grain, and whether the response is
weighted towards the midtones — which it is by default, since film grains
least where it is least exposed. That last one means grain over an empty
canvas stays black unless the weighting is turned off.

Determinism comes from the same shape the scanlines effect uses: the
whole grain field is drawn serially from the Rng into one cell per grain
before any threading, and only the integer pixel arithmetic afterwards
runs under parallelFor.

The canvas fill blur used to keep to itself moves to
effects/source_fill.hpp unchanged, now that two effects want it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:42:04 +02:00
SiandClaude Opus 5 b6b5f94f08 Put the chain first in the sidebar
The source images took the top of the sidebar and most of its height,
which made the list of files look like the subject of the window rather
than its input. The chain is what the interface is about, so it now comes
first with the selected stage's params right below it, taking the free
space; the sources sit at the bottom in a collapsed expander whose label
carries the count, with their own "Add images" button instead of the one
that used to be in the header bar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 16:36:43 +02:00
Si 6cb3da8dc6 Merge branch 'blur-effect' 2026-09-18 16:01:44 +02:00
SiandClaude Opus 5 4739d39a51 Let effects declare whether they need source images
Whether an effect works from the loaded photographs is now part of the
declarative plug-in contract, next to params(): Effect::needsSources()
defaults to true, and blur — which only softens what it is handed — says
no and blurs an empty canvas when given nothing.

Only the first stage of a cascade ever sees the sources, so
chainNeedsSources() just asks the first stage. Both front-ends gate on
that instead of on "are there any images": the CLI only refuses an empty
batch when the chain wants one, and the interface renders a blur-led
chain with no images loaded, saying whichever thing is actually missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:49:16 +02:00
SiandClaude Opus 5 a82fba7886 Save and open projects as JSON
A project is the whole setup as a small text file: the source paths, the
effect chain with its params, the canvas and the seed. Reading and writing
are driven by the effects' ParamSpecs rather than by whatever sits in a
stage's values, so a new effect is saveable without touching this code, a
key an effect does not declare is never written or read back, and an old
project keeps the defaults for params that did not exist when it was
saved. Paths under the project's own folder are written relative, so a
folder of images and their project can be moved whole.

src/core/project.cpp carries its own small JSON reader instead of a
dependency, and names the line when a hand-edited file is wrong.

The interface gets a header-bar menu: New, Open, Save, Save As, with
Ctrl+N/O/S. The title shows the project name. Opening a project replaces
the sources, discarding a decode still in flight rather than mixing it
into the new project.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:37:37 +02:00
SiandClaude Opus 5 89985cb989 Cascade effects through a chain
renderChain runs a list of stages in order: stage 0 works on the loaded
sources, every later one on the output of the stage before it, so blur
after scanlines softens the scanlines rather than the photographs. The
effects themselves are untouched — none of them knows whether its input
came from disk. All stages share one Rng and run in sequence, so a seed
still reproduces the cascade byte for byte.

The sidebar grows an "Effect chain" list: add a stage from a menu built
from the registry, reorder it, remove it, and edit the selected stage's
params underneath. Each stage keeps its own values. The CLI takes the
same chain from repeated -e, with every flag going to the stage whose
effect declares it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:21:29 +02:00
SiandClaude Opus 5 ee4b1038ac Add a blur effect plugin
Fills the canvas from one source, tiling it when it is smaller, then
applies a separable box blur repeated `passes` times to approximate a
gaussian. Params: radius, passes, direction and first_source, so the CLI
flags and the GUI widgets come out of the ParamSpecs as usual.

All random values (source index and crop offset) are drawn serially
before the parallel pixel work, so a seed reproduces the image exactly;
the box window clamps at both ends and the radius is clamped to the
canvas so an oversized radius cannot read out of bounds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:05:15 +02:00
36 changed files with 4250 additions and 148 deletions
+71 -12
View File
@@ -14,11 +14,11 @@ Everything is warning-clean under `-Wall -Wextra -Wpedantic`; keep it that way.
The test suite is a single hand-rolled binary (no framework), so there is no
per-test filter: run `./build/tests/effector_tests` directly to see the section
headings (`rng`, `registry`, `scanlines`, `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.
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.
Headless render (no display needed, the fastest end-to-end smoke test):
@@ -41,21 +41,77 @@ env -u WAYLAND_DISPLAY GDK_BACKEND=x11 DISPLAY=:79 build/effector ...
`Effect` (`src/core/effect.hpp`) exposes `params()` returning `std::vector<ParamSpec>`
(`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`.
Whether an effect works from the loaded photographs is part of the same
contract: `Effect::needsSources()` (default true, false for blur). Only the
first stage ever sees the sources, so `chainNeedsSources` (`src/core/chain.cpp`)
just asks the first stage — a chain led by blur renders with no images loaded at
all, and both front-ends gate on that rather than on "are there any images".
Adding an effect: new `src/effects/<name>.{hpp,cpp}` subclassing `Effect`, one
line in the registry list in `src/core/registry.cpp`, one line in the
`effector_core` sources in `CMakeLists.txt`. Nothing else.
`effector_core` sources in `CMakeLists.txt`. Nothing else — it shows up in the
CLI, in the chain menu and as a cascade stage on its own.
### Effects cascade
Effects are never applied on their own: `renderChain` (`src/core/chain.cpp`)
runs a `std::vector<ChainStage>` in order, handing stage 0 the loaded sources
and every later stage the previous stage's output as its *only* source. That is
the whole of the cascade — an effect neither knows nor cares whether its input
came from disk. All stages share one `Rng` and run strictly in sequence, so a
seed still reproduces the cascade byte for byte.
The interface builds the chain in the sidebar ("Effect chain": add from the
menu, reorder, remove, one stage selected at a time with its params below), the
CLI builds it from repeated `-e`: `-e scanlines --strip-height 6 -e blur
--radius 5`. Flags share one namespace across all effects, so `buildChain`
(`src/cli/options.cpp`) hands each flag to the stage whose effect declares it —
two effects must not declare the same param key.
### The canvas is a format or plain pixels
`src/core/canvas.cpp` holds the one table of standard sizes both front-ends
offer: web formats are a fixed pixel size, print formats a sheet in millimetres
that becomes pixels at a dpi. Adding a format is a line in `canvasFormats()` —
the dropdown, `--format` and `--help` all walk that table. The pixels stay the
truth everywhere (`renderChain` never hears about formats); a format is the
*presentation* of a canvas, carried alongside the pixels in `Options` and
`Project` and empty for a custom size. Where a format is named it decides the
pixels, so a project saved as A4 reopens as A4.
### Projects are plain JSON
`src/core/project.cpp` reads and writes the whole setup — source paths, the
chain with its params, canvas and seed — as a small JSON file, with its own
minimal reader rather than a JSON dependency. Writing is driven by the effects'
`ParamSpec`s, not by whatever happens to sit in a stage's `ParamValues`, so a
key an effect does not declare is never written and never read back: an old
project keeps the defaults for params that did not exist when it was saved.
Source paths under the project's own folder are written relative, so a folder
can be moved or shared whole.
The interface opens and saves projects from the header-bar menu (New, Open,
Save, Save As, with the usual Ctrl+N/O/S accelerators); the window title shows
the project name. Opening one replaces the sources, so a decode still in flight
is discarded rather than mixed into the new project (`dropLoadedImages_`).
### Two link targets, deliberately
`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.
@@ -64,7 +120,10 @@ adds `src/main.cpp`, `src/cli/`, `src/gui/` and links gtkmm. Keep GTK out of
`Rng` (`src/core/rng.hpp`) is a hand-written xoshiro256\*\* rather than
`<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.
+13 -1
View File
@@ -15,22 +15,34 @@ 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
src/core/params.cpp
src/core/project.cpp
src/core/registry.cpp
src/core/rng.cpp
src/effects/blur.cpp
src/effects/grain.cpp
src/effects/monochrome.cpp
src/effects/overlay.cpp
src/effects/scanlines.cpp
src/effects/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 --------------------------------------------------------------
+16
View File
@@ -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).
+76 -7
View File
@@ -1,9 +1,12 @@
#include "cli/options.hpp"
#include <charconv>
#include <cstdint>
#include <cstring>
#include <sstream>
#include "core/canvas.hpp"
#include "core/chain.hpp"
#include "core/effect.hpp"
#include "core/registry.hpp"
@@ -23,7 +26,10 @@ bool parseSize(const std::string& text, int& width, int& height) {
long w = 0;
long h = 0;
if (!parseLong(text.substr(0, sep), w) || !parseLong(text.substr(sep + 1), h)) return false;
if (w <= 0 || h <= 0) return false;
// Checked as longs, before the narrowing: 4294968296 cast straight to int
// is 1000, which would render a plausible canvas nobody asked for.
if (w < kMinCanvasSide || w > kMaxCanvasSide) return false;
if (h < kMinCanvasSide || h > kMaxCanvasSide) return false;
width = static_cast<int>(w);
height = static_cast<int>(h);
return true;
@@ -48,7 +54,6 @@ std::string requireValue(int argc, char** argv, int& i, const std::string& flag)
Options parseOptions(int argc, char** argv) {
Options options;
options.effect = defaultEffectName();
for (int i = 1; i < argc; ++i) {
const std::string arg = argv[i];
@@ -58,15 +63,32 @@ Options parseOptions(int argc, char** argv) {
} else if (arg == "--list-effects") {
options.listEffects = true;
} else if (arg == "-e" || arg == "--effect") {
options.effect = requireValue(argc, argv, i, arg);
effectByName(options.effect); // validate now, fail early
const std::string name = requireValue(argc, argv, i, arg);
effectByName(name); // validate now, fail early
options.effects.push_back(name);
} else if (arg == "-o" || arg == "--output") {
options.output = requireValue(argc, argv, i, arg);
} else if (arg == "-s" || arg == "--size") {
const std::string value = requireValue(argc, argv, i, arg);
if (!parseSize(value, options.width, options.height)) {
throw EffectError("invalid size '" + value + "', expected e.g. 1920x1080");
throw EffectError("invalid size '" + value + "', expected e.g. 1920x1080 with "
"each side between " + std::to_string(kMinCanvasSide) + " and " +
std::to_string(kMaxCanvasSide));
}
} else if (arg == "--format") {
const std::string value = requireValue(argc, argv, i, arg);
if (canvasFormatByKey(value) == nullptr) {
throw EffectError("unknown canvas format '" + value + "', see --help");
}
options.format = value;
} else if (arg == "--dpi") {
const std::string value = requireValue(argc, argv, i, arg);
long dpi = 0;
if (!parseLong(value, dpi) || dpi < kMinDpi || dpi > kMaxDpi) {
throw EffectError("invalid dpi '" + value + "', expected " +
std::to_string(kMinDpi) + "-" + std::to_string(kMaxDpi));
}
options.dpi = static_cast<int>(dpi);
} else if (arg == "--seed") {
const std::string value = requireValue(argc, argv, i, arg);
long seed = 0;
@@ -104,6 +126,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;
@@ -119,6 +155,12 @@ Options parseOptions(int argc, char** argv) {
options.images.push_back(arg);
}
}
// Applied once everything is parsed, so --dpi may come before --format and
// so a format always wins over an -s given alongside it.
if (const auto* format = canvasFormatByKey(options.format)) {
canvasFormatSize(*format, options.dpi, options.width, options.height);
}
if (options.effects.empty()) options.effects.push_back(defaultEffectName());
return options;
}
@@ -127,13 +169,25 @@ 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 (default: " << defaultEffectName() << ")\n"
<< " -e, --effect NAME effect to apply, repeat to cascade (default: " << defaultEffectName() << ")\n"
<< " -o, --output FILE render headlessly to this file and exit\n"
<< " -s, --size WxH canvas size (default: 1920x1080)\n"
<< " -s, --size WxH canvas size, each side " << kMinCanvasSide << "-"
<< kMaxCanvasSide << " (default: 1920x1080)\n"
<< " --format NAME canvas format, overrides --size (see below)\n"
<< " --dpi N resolution of a print format, " << kMinDpi << "-" << kMaxDpi
<< " (default: " << kDefaultDpi << ")\n"
<< " --seed N random seed (default: random)\n"
<< " --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()) {
@@ -147,4 +201,19 @@ std::string usage(const char* programName) {
return out.str();
}
std::vector<ChainStage> buildChain(const Options& options) {
std::vector<ChainStage> stages;
for (const auto& name : options.effects) {
ChainStage stage;
stage.effect = effectByName(name);
stage.values = stage.effect->defaults();
for (const auto& spec : stage.effect->params()) {
const auto it = options.params.find(spec.key);
if (it != options.params.end()) stage.values[spec.key] = it->second;
}
stages.push_back(std::move(stage));
}
return stages;
}
} // namespace effector
+10 -1
View File
@@ -5,16 +5,20 @@
#include <string>
#include <vector>
#include "core/canvas.hpp"
#include "core/chain.hpp"
#include "core/params.hpp"
namespace effector {
struct Options {
std::vector<std::string> images;
std::string effect;
std::vector<std::string> effects; // one per cascade stage, in order
std::string output; // empty -> launch the interface
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;
@@ -27,4 +31,9 @@ Options parseOptions(int argc, char** argv);
std::string usage(const char* programName);
// Turns the parsed names and flags into cascade stages: every flag goes to the
// stage whose effect declares it, so --strip-height and --radius can be given
// together for `-e scanlines -e blur`.
std::vector<ChainStage> buildChain(const Options& options);
} // namespace effector
+76
View File
@@ -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
+45
View File
@@ -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
+54
View File
@@ -0,0 +1,54 @@
#include "core/chain.hpp"
namespace effector {
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
int width, int height, Rng& rng) {
if (stages.empty()) throw EffectError("The effect chain is empty.");
Image image;
for (std::size_t i = 0; i < stages.size(); ++i) {
const auto& stage = stages[i];
if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
auto values = stage.effect->defaults();
for (const auto& [key, value] : stage.values) values[key] = value;
if (i == 0) {
image = stage.effect->render(sources, width, height, rng, values);
} else {
// Feeding the previous result back in as the only source is what
// cascading means here; effects need to know nothing about it.
const std::vector<Image> previous = {std::move(image)};
image = stage.effect->render(previous, width, height, rng, values);
}
}
return image;
}
bool chainNeedsSources(const std::vector<ChainStage>& stages) {
if (stages.empty() || !stages.front().effect) return false;
return stages.front().effect->needsSources();
}
std::string chainTitle(const std::vector<ChainStage>& stages) {
std::string text;
for (const auto& stage : stages) {
if (!stage.effect) continue;
if (!text.empty()) text += " → ";
text += stage.effect->title();
}
return text;
}
std::string chainSlug(const std::vector<ChainStage>& stages) {
std::string text;
for (const auto& stage : stages) {
if (!stage.effect) continue;
if (!text.empty()) text += "-";
text += stage.effect->name();
}
return text;
}
} // namespace effector
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <string>
#include <vector>
#include "core/effect.hpp"
#include "core/image.hpp"
#include "core/params.hpp"
#include "core/rng.hpp"
namespace effector {
// The canvas sides both front-ends accept. The upper bound is not a hardware
// limit but the point past which a side stops fitting comfortably in an int
// once multiplied out, so both the CLI and the project reader can reject a
// number rather than silently wrap it into a plausible-looking one.
inline constexpr int kMinCanvasSide = 1;
inline constexpr int kMaxCanvasSide = 20000;
// One link of a cascade: an effect together with the values it runs with.
struct ChainStage {
EffectPtr effect;
ParamValues values; // missing keys fall back to the effect's defaults
};
// Renders the stages in order. The first one sees the loaded sources; every
// later one sees the previous stage's output as its only source, so blur after
// scanlines softens the scanlines rather than the photographs.
//
// All stages share one Rng and run strictly in sequence, so a seed still
// reproduces the cascade byte for byte.
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
int width, int height, Rng& rng);
// Whether the chain has to be given source images. Only the first stage ever
// sees them — every later one works on the stage before it — so this is simply
// whether the first effect declares that it needs them.
bool chainNeedsSources(const std::vector<ChainStage>& stages);
// "Scanlines → Blur", for window titles and status lines.
std::string chainTitle(const std::vector<ChainStage>& stages);
// "scanlines-blur", for file names.
std::string chainSlug(const std::vector<ChainStage>& stages);
} // namespace effector
+3
View File
@@ -16,6 +16,9 @@ ParamValues Effect::defaults() const {
values[spec.key] = spec.defaultValue != 0.0;
break;
case ParamSpec::Type::Choice:
case ParamSpec::Type::Path:
case ParamSpec::Type::Text:
case ParamSpec::Type::Color:
values[spec.key] = spec.defaultChoice;
break;
}
+5
View File
@@ -27,6 +27,11 @@ public:
virtual std::string description() const = 0;
virtual const std::vector<ParamSpec>& params() const = 0;
// Whether the effect builds its image out of the loaded source images.
// Effects that only transform what they are handed (blur) say no, and a
// chain that starts with one of them needs no sources at all.
virtual bool needsSources() const { return true; }
virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const = 0;
+13 -1
View File
@@ -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
View File
@@ -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;
+82
View File
@@ -63,6 +63,37 @@ ParamSpec ParamSpec::makeChoice(std::string key, std::string label, std::string
return spec;
}
ParamSpec ParamSpec::makePath(std::string key, std::string label, std::string help) {
ParamSpec spec;
spec.type = Type::Path;
spec.key = std::move(key);
spec.label = std::move(label);
spec.help = std::move(help);
return spec;
}
ParamSpec ParamSpec::makeText(std::string key, std::string label, std::string defaultText,
std::string help) {
ParamSpec spec;
spec.type = Type::Text;
spec.key = std::move(key);
spec.label = std::move(label);
spec.help = std::move(help);
spec.defaultChoice = std::move(defaultText);
return spec;
}
ParamSpec ParamSpec::makeColor(std::string key, std::string label, std::string defaultColor,
std::string help) {
ParamSpec spec;
spec.type = Type::Color;
spec.key = std::move(key);
spec.label = std::move(label);
spec.help = std::move(help);
spec.defaultChoice = std::move(defaultColor);
return spec;
}
namespace {
const ParamValue* find(const ParamValues& values, const std::string& key) {
@@ -99,10 +130,61 @@ bool paramBool(const ParamValues& values, const std::string& key, bool fallback)
std::string paramChoice(const ParamValues& values, const std::string& key,
const std::string& fallback) {
return paramText(values, key, fallback);
}
std::string paramText(const ParamValues& values, const std::string& key,
const std::string& fallback) {
if (const auto* value = find(values, key)) {
if (const auto* v = std::get_if<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
+26 -1
View File
@@ -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;
@@ -35,6 +36,18 @@ 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>;
@@ -45,5 +58,17 @@ double paramDouble(const ParamValues& values, const std::string& key, double fal
bool paramBool(const ParamValues& values, const std::string& key, bool fallback);
std::string paramChoice(const ParamValues& values, const std::string& key,
const std::string& fallback);
std::string paramText(const ParamValues& values, const std::string& key,
const std::string& fallback);
// Reads "#rgb" or "#rrggbb" (the leading hash optional) into three bytes.
// Returns false and leaves `rgb` untouched for anything else, so a malformed
// value falls back to the default rather than painting an arbitrary colour.
bool parseColor(const std::string& text, std::uint8_t rgb[3]);
std::string formatColor(const std::uint8_t rgb[3]);
// The colour of a param, or `fallback` when the value does not parse.
void paramColor(const ParamValues& values, const std::string& key, const std::string& fallback,
std::uint8_t rgb[3]);
} // namespace effector
+543
View File
@@ -0,0 +1,543 @@
#include "core/project.hpp"
#include <charconv>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <locale>
#include <sstream>
#include "core/canvas.hpp"
#include "core/effect.hpp"
#include "core/registry.hpp"
namespace effector {
namespace {
// A project file is small and its shape is fixed, so it carries its own
// reader rather than a JSON dependency: enough of the grammar to read back
// what projectToJson writes, and to give a line number when a hand-edited
// file is wrong.
struct Json {
enum class Kind { Null, Bool, Number, String, Array, Object };
Kind kind = Kind::Null;
bool boolean = false;
double number = 0.0;
bool integral = true; // written without a fraction or exponent
std::string text; // the string, or the digits a number was written as
std::vector<Json> items;
std::vector<std::pair<std::string, Json>> members;
const Json* member(const std::string& key) const {
for (const auto& entry : members) {
if (entry.first == key) return &entry.second;
}
return nullptr;
}
};
class JsonParser {
public:
explicit JsonParser(const std::string& text) : text_(text) {}
Json parse() {
skipSpace();
Json value = parseValue();
skipSpace();
if (pos_ != text_.size()) fail("trailing text after the end of the document");
return value;
}
private:
[[noreturn]] void fail(const std::string& what) const {
int line = 1;
for (std::size_t i = 0; i < pos_ && i < text_.size(); ++i) {
if (text_[i] == '\n') ++line;
}
throw EffectError("line " + std::to_string(line) + ": " + what);
}
char peek() const { return pos_ < text_.size() ? text_[pos_] : '\0'; }
void skipSpace() {
while (pos_ < text_.size()) {
const char c = text_[pos_];
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
++pos_;
} else {
break;
}
}
}
void expect(char c) {
if (peek() != c) fail(std::string("expected '") + c + "'");
++pos_;
}
bool literal(const char* word) {
const std::size_t length = std::char_traits<char>::length(word);
if (text_.compare(pos_, length, word) != 0) return false;
pos_ += length;
return true;
}
Json parseValue() {
switch (peek()) {
case '{': return parseObject();
case '[': return parseArray();
case '"': {
Json value;
value.kind = Json::Kind::String;
value.text = parseString();
return value;
}
default: break;
}
if (literal("true")) return boolean(true);
if (literal("false")) return boolean(false);
if (literal("null")) return Json();
return parseNumber();
}
static Json boolean(bool state) {
Json value;
value.kind = Json::Kind::Bool;
value.boolean = state;
return value;
}
Json parseObject() {
Json value;
value.kind = Json::Kind::Object;
expect('{');
skipSpace();
if (peek() == '}') {
++pos_;
return value;
}
while (true) {
skipSpace();
std::string key = parseString();
skipSpace();
expect(':');
skipSpace();
value.members.emplace_back(std::move(key), parseValue());
skipSpace();
if (peek() == ',') {
++pos_;
continue;
}
expect('}');
return value;
}
}
Json parseArray() {
Json value;
value.kind = Json::Kind::Array;
expect('[');
skipSpace();
if (peek() == ']') {
++pos_;
return value;
}
while (true) {
skipSpace();
value.items.push_back(parseValue());
skipSpace();
if (peek() == ',') {
++pos_;
continue;
}
expect(']');
return value;
}
}
std::string parseString() {
expect('"');
std::string out;
while (true) {
if (pos_ >= text_.size()) fail("unterminated string");
const char c = text_[pos_++];
if (c == '"') return out;
if (c != '\\') {
out.push_back(c);
continue;
}
if (pos_ >= text_.size()) fail("unterminated escape");
switch (text_[pos_++]) {
case '"': out.push_back('"'); break;
case '\\': out.push_back('\\'); break;
case '/': out.push_back('/'); break;
case 'b': out.push_back('\b'); break;
case 'f': out.push_back('\f'); break;
case 'n': out.push_back('\n'); break;
case 'r': out.push_back('\r'); break;
case 't': out.push_back('\t'); break;
case 'u': {
// Only the basic plane, which is all a path or a choice
// name can need; surrogate pairs are passed through as is.
if (pos_ + 4 > text_.size()) fail("short \\u escape");
const unsigned code = static_cast<unsigned>(
std::strtoul(text_.substr(pos_, 4).c_str(), nullptr, 16));
pos_ += 4;
appendUtf8(out, code);
break;
}
default: fail("unknown escape");
}
}
}
static void appendUtf8(std::string& out, unsigned code) {
if (code < 0x80) {
out.push_back(static_cast<char>(code));
} else if (code < 0x800) {
out.push_back(static_cast<char>(0xC0 | (code >> 6)));
out.push_back(static_cast<char>(0x80 | (code & 0x3F)));
} else {
out.push_back(static_cast<char>(0xE0 | (code >> 12)));
out.push_back(static_cast<char>(0x80 | ((code >> 6) & 0x3F)));
out.push_back(static_cast<char>(0x80 | (code & 0x3F)));
}
}
Json parseNumber() {
const std::size_t start = pos_;
if (peek() == '-' || peek() == '+') ++pos_;
bool integral = true;
while (pos_ < text_.size()) {
const char c = text_[pos_];
if (c >= '0' && c <= '9') {
++pos_;
} else if (c == '.' || c == 'e' || c == 'E') {
integral = false;
++pos_;
} else if (c == '-' || c == '+') { // an exponent's sign
++pos_;
} else {
break;
}
}
if (pos_ == start) fail("expected a value");
Json value;
value.kind = Json::Kind::Number;
value.integral = integral;
// from_chars rather than stod: a number in JSON always has a point for
// a decimal mark, whatever LC_NUMERIC says the user's decimal mark is.
const char* const first = text_.data() + start + (text_[start] == '+' ? 1 : 0);
const char* const last = text_.data() + pos_;
const auto parsed = std::from_chars(first, last, value.number);
if (parsed.ec != std::errc() || parsed.ptr != last) {
fail("'" + text_.substr(start, pos_ - start) + "' is not a number");
}
value.text.assign(first, last); // kept whole for the seed, see below
return value;
}
const std::string& text_;
std::size_t pos_ = 0;
};
std::string quote(const std::string& text) {
std::string out = "\"";
for (const char c : text) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (static_cast<unsigned char>(c) < 0x20) {
char buffer[7];
std::snprintf(buffer, sizeof(buffer), "\\u%04x",
static_cast<unsigned>(static_cast<unsigned char>(c)));
out += buffer;
} else {
out.push_back(c);
}
}
}
return out + "\"";
}
std::string number(double value, int digits) {
std::ostringstream out;
out.imbue(std::locale::classic());
out.setf(std::ios::fixed);
out.precision(digits);
out << value;
std::string text = out.str();
// 5.00 reads better as 5.0 and 0.250 as 0.25.
while (text.size() > 1 && text.back() == '0' && text[text.size() - 2] != '.') text.pop_back();
return text;
}
// Relative when the image lives under the project folder, absolute otherwise:
// a folder holding the project and its images stays movable.
std::string relativeTo(const std::string& path, const std::string& directory) {
if (directory.empty()) return path;
std::error_code error;
const auto relative = std::filesystem::relative(path, directory, error);
if (error || relative.empty()) return path;
const auto text = relative.generic_string();
return text.rfind("..", 0) == 0 ? path : text;
}
// A canvas side is checked as a double, before it is narrowed: casting a
// number outside the range of an int is undefined, and even where it wraps
// quietly it turns a size the file never named into one that looks ordinary.
int canvasSide(const Json& value, const char* what) {
if (value.kind != Json::Kind::Number || value.number != std::trunc(value.number)) {
throw EffectError(std::string("the canvas ") + what + " must be a whole number");
}
if (!(value.number >= kMinCanvasSide) || !(value.number <= kMaxCanvasSide)) {
throw EffectError(std::string("the canvas ") + what + " must be between " +
std::to_string(kMinCanvasSide) + " and " +
std::to_string(kMaxCanvasSide) + " pixels");
}
return static_cast<int>(value.number);
}
// Checked as a double before narrowing, exactly as a canvas side is.
int canvasDpi(const Json& value) {
if (value.kind != Json::Kind::Number || value.number != std::trunc(value.number)) {
throw EffectError("the canvas dpi must be a whole number");
}
if (!(value.number >= kMinDpi) || !(value.number <= kMaxDpi)) {
throw EffectError("the canvas dpi must be between " + std::to_string(kMinDpi) + " and " +
std::to_string(kMaxDpi));
}
return static_cast<int>(value.number);
}
std::string resolve(const std::string& path, const std::string& directory) {
std::filesystem::path file(path);
if (file.is_absolute() || directory.empty()) return path;
return (std::filesystem::path(directory) / file).lexically_normal().string();
}
} // namespace
std::string projectToJson(const Project& project, const std::string& directory) {
// JSON is written in the C locale whatever the user's is: a decimal comma
// or a digit grouping would make the file something this reader — or any
// other — cannot read back. gtkmm sets the global locale from the
// environment, so a stream left to its default is the user's locale.
std::ostringstream out;
out.imbue(std::locale::classic());
out << "{\n";
out << " \"effector_project\": 1,\n";
out << " \"canvas\": {\"width\": " << project.width << ", \"height\": " << project.height;
// The pixels are always written, so a project opens the same in a reader
// that knows nothing of formats; the format and its dpi come after them
// and only when there is one to name.
if (const auto* format = canvasFormatByKey(project.format)) {
out << ", \"format\": " << quote(format->key);
if (format->print) out << ", \"dpi\": " << project.dpi;
}
out << "},\n";
if (project.seed) out << " \"seed\": " << *project.seed << ",\n";
out << " \"sources\": [";
for (std::size_t i = 0; i < project.sources.size(); ++i) {
out << (i == 0 ? "\n" : ",\n") << " " << quote(relativeTo(project.sources[i], directory));
}
out << (project.sources.empty() ? "" : "\n ") << "],\n";
out << " \"chain\": [";
for (std::size_t i = 0; i < project.stages.size(); ++i) {
const auto& stage = project.stages[i];
if (!stage.effect) continue;
out << (i == 0 ? "\n" : ",\n");
out << " {\n \"effect\": " << quote(stage.effect->name()) << ",\n";
out << " \"params\": {";
const auto& specs = stage.effect->params();
bool first = true;
for (const auto& spec : specs) {
out << (first ? "\n" : ",\n") << " " << quote(spec.key) << ": ";
first = false;
switch (spec.type) {
case ParamSpec::Type::Bool:
out << (paramBool(stage.values, spec.key, spec.defaultValue != 0.0) ? "true"
: "false");
break;
case ParamSpec::Type::Int:
out << paramInt(stage.values, spec.key, static_cast<long>(spec.defaultValue));
break;
case ParamSpec::Type::Double:
out << number(paramDouble(stage.values, spec.key, spec.defaultValue),
spec.digits);
break;
case ParamSpec::Type::Choice:
case ParamSpec::Type::Text:
out << quote(paramText(stage.values, spec.key, spec.defaultChoice));
break;
case ParamSpec::Type::Color: {
std::uint8_t rgb[3];
paramColor(stage.values, spec.key, spec.defaultChoice, rgb);
out << quote(formatColor(rgb));
break;
}
case ParamSpec::Type::Path: {
// Written relative to the project folder, exactly as the
// sources are, so a folder still opens once it is moved.
const auto path = paramText(stage.values, spec.key, "");
out << quote(path.empty() ? path : relativeTo(path, directory));
break;
}
}
}
out << (specs.empty() ? "}" : "\n }") << "\n }";
}
out << (project.stages.empty() ? "" : "\n ") << "]\n";
out << "}\n";
return out.str();
}
Project projectFromJson(const std::string& text, const std::string& directory) {
JsonParser parser(text);
const Json root = parser.parse();
if (root.kind != Json::Kind::Object) throw EffectError("a project must be a JSON object");
Project project;
if (const auto* canvas = root.member("canvas")) {
if (const auto* width = canvas->member("width")) project.width = canvasSide(*width, "width");
if (const auto* height = canvas->member("height")) {
project.height = canvasSide(*height, "height");
}
if (const auto* dpi = canvas->member("dpi")) project.dpi = canvasDpi(*dpi);
// A format the build does not know is simply a custom canvas: the
// pixels were written too, so the project still renders as it was
// saved. Where the format is known it decides the pixels, so a
// hand-edited dpi takes effect and a preset stays true to its name.
if (const auto* name = canvas->member("format"); name != nullptr &&
name->kind == Json::Kind::String) {
if (const auto* format = canvasFormatByKey(name->text)) {
project.format = format->key;
canvasFormatSize(*format, project.dpi, project.width, project.height);
}
}
}
if (const auto* seed = root.member("seed")) {
// Taken from the digits rather than from the double: a 64-bit seed has
// more bits than a double has mantissa, and a seed that comes back off
// by a few is a project that no longer renders its own picture.
std::uint64_t value = 0;
const char* const first = seed->text.data();
const char* const last = first + seed->text.size();
if (seed->kind == Json::Kind::Number && seed->integral &&
std::from_chars(first, last, value).ptr == last) {
project.seed = value;
} else if (seed->kind == Json::Kind::Number) {
throw EffectError("the seed must be a whole number that fits in 64 bits");
}
}
if (const auto* sources = root.member("sources")) {
for (const auto& item : sources->items) {
if (item.kind == Json::Kind::String) {
project.sources.push_back(resolve(item.text, directory));
}
}
}
const auto* chain = root.member("chain");
if (chain == nullptr || chain->kind != Json::Kind::Array || chain->items.empty()) {
throw EffectError("the project has no effect chain");
}
for (const auto& item : chain->items) {
const auto* name = item.member("effect");
if (name == nullptr || name->kind != Json::Kind::String) {
throw EffectError("a chain stage has no effect name");
}
ChainStage stage;
stage.effect = effectByName(name->text); // throws on an unknown effect
stage.values = stage.effect->defaults();
// Driven by the specs, not by the file: a key the effect does not
// declare is dropped, and an old project simply keeps the defaults for
// params that did not exist when it was written.
if (const auto* params = item.member("params")) {
for (const auto& spec : stage.effect->params()) {
const auto* value = params->member(spec.key);
if (value == nullptr) continue;
switch (spec.type) {
case ParamSpec::Type::Bool:
if (value->kind == Json::Kind::Bool) stage.values[spec.key] = value->boolean;
break;
case ParamSpec::Type::Int:
if (value->kind == Json::Kind::Number) {
stage.values[spec.key] = static_cast<long>(std::llround(value->number));
}
break;
case ParamSpec::Type::Double:
if (value->kind == Json::Kind::Number) stage.values[spec.key] = value->number;
break;
case ParamSpec::Type::Path:
if (value->kind == Json::Kind::String) {
stage.values[spec.key] =
value->text.empty() ? value->text : resolve(value->text, directory);
}
break;
case ParamSpec::Type::Text:
if (value->kind == Json::Kind::String) stage.values[spec.key] = value->text;
break;
case ParamSpec::Type::Color: {
// A colour that does not parse keeps the default,
// exactly as an unknown choice does.
std::uint8_t rgb[3];
if (value->kind == Json::Kind::String && parseColor(value->text, rgb)) {
stage.values[spec.key] = formatColor(rgb);
}
break;
}
case ParamSpec::Type::Choice:
for (const auto& choice : spec.choices) {
if (value->kind == Json::Kind::String && value->text == choice) {
stage.values[spec.key] = choice;
}
}
break;
}
}
}
project.stages.push_back(std::move(stage));
}
return project;
}
void saveProject(const std::string& path, const Project& project) {
const auto directory = std::filesystem::path(path).parent_path().string();
std::ofstream file(path, std::ios::binary | std::ios::trunc);
if (!file) throw EffectError("cannot write " + path);
file << projectToJson(project, directory);
if (!file) throw EffectError("could not write " + path);
}
Project loadProject(const std::string& path) {
std::ifstream file(path, std::ios::binary);
if (!file) throw EffectError("cannot read " + path);
std::ostringstream buffer;
buffer << file.rdbuf();
try {
return projectFromJson(buffer.str(), std::filesystem::path(path).parent_path().string());
} catch (const EffectError& error) {
throw EffectError(std::filesystem::path(path).filename().string() + ": " + error.what());
}
}
} // namespace effector
+47
View File
@@ -0,0 +1,47 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
#include "core/canvas.hpp"
#include "core/chain.hpp"
#include "core/params.hpp"
namespace effector {
// Everything needed to recreate a render: which images went in, the cascade
// with its settings, the canvas and the seed. Deliberately no pixels — a
// project is a small text file that points at the sources on disk.
struct Project {
std::vector<std::string> sources; // absolute paths
std::vector<ChainStage> stages;
int width = 1920;
int height = 1080;
// The named format the canvas came from, empty for a custom pixel size.
// The pixels above stay the truth for the render; this is what the
// interface puts back in its dropdown, and what a print format needs its
// resolution for.
std::string format;
int dpi = kDefaultDpi;
std::optional<std::uint64_t> seed;
};
// JSON, written for humans: two-space indent, one source path per line, and
// the params of a stage spelled with the same keys the effect declares.
// Source paths are written relative to `directory` when they live under it, so
// a project folder can be moved or shared whole.
std::string projectToJson(const Project& project, const std::string& directory = {});
// Throws EffectError on malformed JSON, an unknown effect or a missing chain.
// Relative source paths are resolved against `directory`; params the effect
// does not declare are dropped, and missing ones keep the effect's default.
Project projectFromJson(const std::string& text, const std::string& directory = {});
// Both throw EffectError: the file is the user's, so failures are reported
// rather than silently swallowed.
void saveProject(const std::string& path, const Project& project);
Project loadProject(const std::string& path);
} // namespace effector
+12
View File
@@ -1,6 +1,12 @@
#include "core/registry.hpp"
#include "effects/blur.hpp"
#include "effects/grain.hpp"
#include "effects/monochrome.hpp"
#include "effects/overlay.hpp"
#include "effects/scanlines.hpp"
#include "effects/text.hpp"
#include "effects/tiles.hpp"
namespace effector {
@@ -8,6 +14,12 @@ namespace effector {
const std::vector<EffectPtr>& effects() {
static const std::vector<EffectPtr> registry = {
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;
}
+112
View File
@@ -0,0 +1,112 @@
#include "effects/blur.hpp"
#include <algorithm>
#include <cstring>
#include <vector>
#include "core/parallel.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& BlurEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeInt("radius", "Radius", 8, 1, 256,
"Half width in pixels of the blur window."),
ParamSpec::makeInt("passes", "Passes", 2, 1, 8,
"How often the box blur is repeated; more passes look more gaussian."),
ParamSpec::makeChoice("direction", "Direction", "both", {"both", "horizontal", "vertical"},
"Axis the blur is applied along."),
ParamSpec::makeBool("first_source", "Use the first image", false,
"Always blur the first source instead of picking one at random."),
};
return specs;
}
namespace {
// One separable box blur pass. `stride` is the distance in bytes between two
// neighbouring samples along the blurred axis, `count` how many of them there
// are; the window is clamped at both ends.
void boxPass(const std::uint8_t* in, std::uint8_t* out, int count, std::size_t stride, int radius) {
const int window = 2 * radius + 1;
int sum[3] = {0, 0, 0};
for (int c = 0; c < 3; ++c) {
sum[c] = in[c] * (radius + 1);
for (int i = 1; i <= radius; ++i) {
sum[c] += in[static_cast<std::size_t>(std::min(i, count - 1)) * stride + c];
}
}
for (int i = 0; i < count; ++i) {
for (int c = 0; c < 3; ++c) {
out[static_cast<std::size_t>(i) * stride + c] =
static_cast<std::uint8_t>(sum[c] / window);
}
const int add = std::min(i + radius + 1, count - 1);
const int drop = std::max(i - radius, 0);
for (int c = 0; c < 3; ++c) {
sum[c] += in[static_cast<std::size_t>(add) * stride + c] -
in[static_cast<std::size_t>(drop) * stride + c];
}
}
}
void blurHorizontal(const Image& src, Image& dst, int radius) {
parallelFor(static_cast<std::size_t>(src.height()), [&](std::size_t begin, std::size_t end) {
for (std::size_t y = begin; y < end; ++y) {
boxPass(src.row(static_cast<int>(y)), dst.row(static_cast<int>(y)), src.width(), 3,
radius);
}
});
}
void blurVertical(const Image& src, Image& dst, int radius) {
const std::size_t stride = src.stride();
parallelFor(static_cast<std::size_t>(src.width()), [&](std::size_t begin, std::size_t end) {
for (std::size_t x = begin; x < end; ++x) {
const std::size_t offset = x * 3;
boxPass(src.data() + offset, dst.data() + offset, src.height(), stride, radius);
}
});
}
} // namespace
Image BlurEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
const int radius = static_cast<int>(std::max(1L, paramInt(values, "radius", 8)));
const int passes = static_cast<int>(std::max(1L, paramInt(values, "passes", 2)));
const std::string direction = paramChoice(values, "direction", "both");
const bool firstSource = paramBool(values, "first_source", false);
Image canvas(width, height);
if (!sources.empty()) {
// Every random value is drawn here, before any threading, so that a
// seed always gives the same image.
const std::size_t index = firstSource ? 0 : rng.below(sources.size() - 1);
const Image& src = sources[index];
const int sx = static_cast<int>(rng.inRange(0, std::max(0, src.width() - width)));
const int sy = static_cast<int>(rng.inRange(0, std::max(0, src.height() - height)));
fillFromSource(canvas, src, sx, sy);
}
Image scratch(width, height);
for (int pass = 0; pass < passes; ++pass) {
if (direction != "vertical") {
blurHorizontal(canvas, scratch, std::min(radius, std::max(1, width - 1)));
std::swap(canvas, scratch);
}
if (direction != "horizontal") {
blurVertical(canvas, scratch, std::min(radius, std::max(1, height - 1)));
std::swap(canvas, scratch);
}
}
return canvas;
}
} // namespace effector
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include "core/effect.hpp"
namespace effector {
// Softens its input with a separable box blur, repeated a few times to
// approximate a gaussian. It needs no source images of its own: given none it
// blurs an empty canvas, which is what makes it usable as a pure filter stage.
class BlurEffect : public Effect {
public:
std::string name() const override { return "blur"; }
std::string title() const override { return "Blur"; }
std::string description() const override {
return "Softens its input with a repeated box blur, horizontally, vertically or both; "
"with sources loaded it fills the canvas from a randomly chosen one first.";
}
const std::vector<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+90
View File
@@ -0,0 +1,90 @@
#include "effects/grain.hpp"
#include <algorithm>
#include <cstdint>
#include <vector>
#include "core/parallel.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& GrainEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeDouble("grain_amount", "Amount", 0.35, 0.0, 1.0,
"How far a grain may push a pixel, as a fraction of full scale."),
ParamSpec::makeInt("grain_size", "Grain size", 1, 1, 32,
"Width in pixels of one grain; larger sizes look coarser."),
ParamSpec::makeBool("grain_colour", "Coloured grain", false,
"Draw the channels separately instead of one grey grain per pixel."),
ParamSpec::makeBool("grain_midtones", "Weight towards midtones", true,
"Fade the grain out in the shadows and highlights, as film does."),
};
return specs;
}
namespace {
// Response curve: 1 at mid grey, falling to 0 at black and at white, which is
// roughly how grain behaves on film.
inline int midtoneWeight(int value) { return 255 - std::abs(2 * value - 255); }
} // namespace
Image GrainEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
const double amount = std::clamp(paramDouble(values, "grain_amount", 0.35), 0.0, 1.0);
const int size = static_cast<int>(std::max(1L, paramInt(values, "grain_size", 1)));
const bool colour = paramBool(values, "grain_colour", false);
const bool midtones = paramBool(values, "grain_midtones", true);
Image canvas(width, height);
if (!sources.empty()) {
// Drawn before any threading, so that a seed always gives the same image.
const std::size_t index = rng.below(sources.size() - 1);
const Image& src = sources[index];
const int sx = static_cast<int>(rng.inRange(0, std::max(0, src.width() - width)));
const int sy = static_cast<int>(rng.inRange(0, std::max(0, src.height() - height)));
fillFromSource(canvas, src, sx, sy);
}
if (amount <= 0.0) return canvas;
// The whole grain field is drawn serially from the rng, exactly like the
// scanlines plan, and only the pixel arithmetic afterwards is parallel.
const int cellsX = (width + size - 1) / size;
const int cellsY = (height + size - 1) / size;
const int channels = colour ? 3 : 1;
std::vector<std::int8_t> field(static_cast<std::size_t>(cellsX) * cellsY * channels);
for (auto& cell : field) cell = static_cast<std::int8_t>(rng.inRange(-127, 127));
// 127 * 255 is the largest product of a grain and a midtone weight; scaling
// by `amount` there keeps the whole thing in integers.
const int scale = static_cast<int>(amount * 255.0 * 65536.0 / 127.0);
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
for (std::size_t y = begin; y < end; ++y) {
std::uint8_t* out = canvas.row(static_cast<int>(y));
const std::size_t rowCell =
static_cast<std::size_t>(static_cast<int>(y) / size) * cellsX;
for (int x = 0; x < width; ++x) {
const std::size_t cell = (rowCell + static_cast<std::size_t>(x / size)) * channels;
for (int c = 0; c < 3; ++c) {
const int value = out[static_cast<std::size_t>(x) * 3 + c];
const int grain = field[cell + (colour ? static_cast<std::size_t>(c) : 0)];
const int weight = midtones ? midtoneWeight(value) : 255;
const int delta = grain * weight / 255 * scale / 65536;
out[static_cast<std::size_t>(x) * 3 + c] =
static_cast<std::uint8_t>(std::clamp(value + delta, 0, 255));
}
}
}
});
return canvas;
}
} // namespace effector
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include "core/effect.hpp"
namespace effector {
// Scatters film-like grain over its input. Like the blur it needs no source
// images of its own, so it can lead a chain: given sources it fills the canvas
// from one of them first, given none it grains an empty canvas — which with the
// midtone weighting on stays black, exactly as an unexposed film would.
class GrainEffect : public Effect {
public:
std::string name() const override { return "grain"; }
std::string title() const override { return "Film grain"; }
std::string description() const override {
return "Scatters film-like grain over its input, optionally coloured and weighted towards "
"the midtones; with sources loaded it fills the canvas from a randomly chosen one "
"first.";
}
const std::vector<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+105
View File
@@ -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
+28
View File
@@ -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
+220
View File
@@ -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
+45
View File
@@ -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
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <algorithm>
#include <cstring>
#include "core/image.hpp"
#include "core/parallel.hpp"
namespace effector {
// Copies `src` into the canvas starting at (sx, sy), repeating it as needed so
// that every pixel is covered even when the source is smaller than the canvas.
// Shared by the effects that do not build an image of their own but still want
// something to work on when they lead a chain.
inline void fillFromSource(Image& canvas, const Image& src, int sx, int sy) {
const int width = canvas.width();
const std::size_t total = static_cast<std::size_t>(width) * 3;
const int copyWidth = std::min(width, src.width() - sx);
const std::size_t copyBytes = static_cast<std::size_t>(copyWidth) * 3;
parallelFor(static_cast<std::size_t>(canvas.height()), [&](std::size_t begin, std::size_t end) {
for (std::size_t y = begin; y < end; ++y) {
const int srcY = sy + (static_cast<int>(y) % std::max(1, src.height() - sy));
const std::uint8_t* in = src.row(srcY) + static_cast<std::size_t>(sx) * 3;
std::uint8_t* out = canvas.row(static_cast<int>(y));
std::memcpy(out, in, copyBytes);
std::size_t filled = copyBytes;
while (filled < total) {
const std::size_t chunk = std::min(filled, total - filled);
std::memcpy(out + filled, out, chunk);
filled += chunk;
}
}
});
}
} // namespace effector
+306
View File
@@ -0,0 +1,306 @@
#include "effects/text.hpp"
#include <pango/pangocairo.h>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include "core/parallel.hpp"
#include "effects/source_fill.hpp"
namespace effector {
const std::vector<ParamSpec>& TextEffect::params() const {
static const std::vector<ParamSpec> specs = {
ParamSpec::makeText("text", "Text", "Effector",
"What to write; a line break starts a new line."),
ParamSpec::makeText("font_family", "Font", "Sans",
"Font family, as it is named on this machine: Sans, Serif, Monospace, "
"\"DejaVu Serif\", ..."),
ParamSpec::makeChoice("font_weight", "Weight", "normal",
{"thin", "light", "normal", "medium", "semibold", "bold", "heavy"},
"How bold the glyphs are; a family without the chosen weight is "
"rendered in the nearest one it has."),
ParamSpec::makeBool("font_italic", "Italic", false, "Slants the glyphs."),
ParamSpec::makeDouble("font_size", "Size", 96.0, 4.0, 2000.0,
"Height of the glyphs in pixels.", 1.0, 1),
ParamSpec::makeColor("text_color", "Colour", "#ffffff", "The colour of the glyphs."),
ParamSpec::makeChoice("text_align", "Alignment", "center", {"left", "center", "right"},
"How several lines line up against each other."),
ParamSpec::makeInt("text_wrap", "Wrap width", 0, 0, 20000,
"Breaks the text into lines no wider than this many pixels; 0 writes "
"it as given."),
ParamSpec::makeChoice("text_box", "Box", "none", {"none", "colour", "image"},
"What the text sits on: nothing, a filled rectangle, or a picture "
"from disk cropped to the rectangle."),
ParamSpec::makeColor("box_color", "Box colour", "#000000",
"The colour the box is filled with."),
ParamSpec::makePath("box_image", "Box picture",
"The picture the box is filled with, scaled to cover it."),
ParamSpec::makeInt("box_padding", "Box padding", 24, 0, 2000,
"How far the box reaches past the text on every side."),
ParamSpec::makeDouble("box_opacity", "Box opacity", 1.0, 0.0, 1.0,
"How strongly the box is mixed over the input."),
ParamSpec::makeBool("text_center", "Centre", true,
"Places the text in the middle of the canvas rather than at its top "
"left corner; the offset is counted from there either way."),
ParamSpec::makeInt("text_offset_x", "Offset X", 0, -20000, 20000,
"Moves the text right by this many pixels (left when negative)."),
ParamSpec::makeInt("text_offset_y", "Offset Y", 0, -20000, 20000,
"Moves the text down by this many pixels (up when negative)."),
ParamSpec::makeDouble("text_opacity", "Text opacity", 1.0, 0.0, 1.0,
"How strongly the glyphs are mixed over what is behind them."),
};
return specs;
}
namespace {
// Small holders so that an EffectError thrown half way through still frees the
// Cairo and Pango objects.
struct SurfaceHandle {
cairo_surface_t* get = nullptr;
~SurfaceHandle() {
if (get != nullptr) cairo_surface_destroy(get);
}
};
struct ContextHandle {
cairo_t* get = nullptr;
~ContextHandle() {
if (get != nullptr) cairo_destroy(get);
}
};
struct LayoutHandle {
PangoLayout* get = nullptr;
~LayoutHandle() {
if (get != nullptr) g_object_unref(get);
}
};
struct FontHandle {
PangoFontDescription* get = nullptr;
~FontHandle() {
if (get != nullptr) pango_font_description_free(get);
}
};
PangoWeight weightFor(const std::string& name) {
if (name == "thin") return PANGO_WEIGHT_THIN;
if (name == "light") return PANGO_WEIGHT_LIGHT;
if (name == "medium") return PANGO_WEIGHT_MEDIUM;
if (name == "semibold") return PANGO_WEIGHT_SEMIBOLD;
if (name == "bold") return PANGO_WEIGHT_BOLD;
if (name == "heavy") return PANGO_WEIGHT_HEAVY;
return PANGO_WEIGHT_NORMAL;
}
PangoAlignment alignmentFor(const std::string& name) {
if (name == "left") return PANGO_ALIGN_LEFT;
if (name == "right") return PANGO_ALIGN_RIGHT;
return PANGO_ALIGN_CENTER;
}
// An RGB image plus its alpha as a Cairo ARGB32 surface, premultiplied, which
// is the only layout Cairo paints from.
cairo_surface_t* toSurface(const Image& image, const std::vector<std::uint8_t>& alpha) {
cairo_surface_t* surface =
cairo_image_surface_create(CAIRO_FORMAT_ARGB32, image.width(), image.height());
if (cairo_surface_status(surface) != CAIRO_STATUS_SUCCESS) {
cairo_surface_destroy(surface);
throw EffectError("Cannot hold the box picture in memory.");
}
const int stride = cairo_image_surface_get_stride(surface);
unsigned char* data = cairo_image_surface_get_data(surface);
const bool hasAlpha = alpha.size() == static_cast<std::size_t>(image.width()) *
static_cast<std::size_t>(image.height());
for (int y = 0; y < image.height(); ++y) {
const std::uint8_t* in = image.row(y);
auto* out = reinterpret_cast<std::uint32_t*>(data + static_cast<std::size_t>(y) * stride);
for (int x = 0; x < image.width(); ++x) {
const std::uint32_t a =
hasAlpha ? alpha[static_cast<std::size_t>(y) * image.width() + x] : 255;
const std::uint32_t r = in[x * 3 + 0] * a / 255;
const std::uint32_t g = in[x * 3 + 1] * a / 255;
const std::uint32_t b = in[x * 3 + 2] * a / 255;
out[x] = (a << 24) | (r << 16) | (g << 8) | b;
}
}
cairo_surface_mark_dirty(surface);
return surface;
}
void setColor(cairo_t* cr, const std::uint8_t rgb[3], double opacity) {
cairo_set_source_rgba(cr, rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0, opacity);
}
// Mixes the drawn layer, ARGB32 and premultiplied, over the canvas.
void composite(Image& canvas, const cairo_surface_t* surface) {
const int stride = cairo_image_surface_get_stride(const_cast<cairo_surface_t*>(surface));
const unsigned char* data =
cairo_image_surface_get_data(const_cast<cairo_surface_t*>(surface));
const int width = canvas.width();
parallelFor(static_cast<std::size_t>(canvas.height()), [&](std::size_t begin, std::size_t end) {
for (std::size_t y = begin; y < end; ++y) {
const auto* in =
reinterpret_cast<const std::uint32_t*>(data + static_cast<std::size_t>(y) * stride);
std::uint8_t* out = canvas.row(static_cast<int>(y));
for (int x = 0; x < width; ++x) {
const std::uint32_t pixel = in[x];
const std::uint32_t a = pixel >> 24;
if (a == 0) continue;
// Premultiplied: the colour is already scaled by alpha, so the
// mix is an addition once the background is scaled down.
const std::uint32_t channel[3] = {(pixel >> 16) & 0xFF, (pixel >> 8) & 0xFF,
pixel & 0xFF};
std::uint8_t* px = out + static_cast<std::size_t>(x) * 3;
for (int c = 0; c < 3; ++c) {
const std::uint32_t value = channel[c] + px[c] * (255 - a) / 255;
px[c] = static_cast<std::uint8_t>(std::min<std::uint32_t>(value, 255));
}
}
}
});
}
} // namespace
void TextEffect::loadBoxImage(const std::string& path, Image& image,
std::vector<std::uint8_t>& alpha) const {
std::lock_guard<std::mutex> lock(cacheMutex_);
if (cachedPath_ != path || cached_.empty()) {
std::vector<std::uint8_t> loadedAlpha;
Image loaded;
try {
loaded = Image::load(path, &loadedAlpha);
} catch (const std::exception& error) {
throw EffectError("Cannot read the box picture '" + path + "': " + error.what());
}
if (loaded.empty()) throw EffectError("The box picture '" + path + "' is empty.");
cached_ = std::move(loaded);
cachedAlpha_ = std::move(loadedAlpha);
cachedPath_ = path;
}
image = cached_;
alpha = cachedAlpha_;
}
Image TextEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const {
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
Image canvas(width, height);
if (!sources.empty()) {
// Drawn before any threading, so that a seed always gives the same image.
const std::size_t index = rng.below(sources.size() - 1);
const Image& src = sources[index];
const int sx = static_cast<int>(rng.inRange(0, std::max(0, src.width() - width)));
const int sy = static_cast<int>(rng.inRange(0, std::max(0, src.height() - height)));
fillFromSource(canvas, src, sx, sy);
}
const std::string text = paramText(values, "text", "");
const double textOpacity = std::clamp(paramDouble(values, "text_opacity", 1.0), 0.0, 1.0);
const std::string boxKind = paramChoice(values, "text_box", "none");
const double boxOpacity = std::clamp(paramDouble(values, "box_opacity", 1.0), 0.0, 1.0);
const bool drawsText = !text.empty() && textOpacity > 0.0;
const bool drawsBox = boxKind != "none" && boxOpacity > 0.0;
// No text typed yet is the state the interface starts in, not an error: the
// stage simply passes its input through.
if (!drawsText && !drawsBox) return canvas;
SurfaceHandle surface;
surface.get = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, width, height);
if (cairo_surface_status(surface.get) != CAIRO_STATUS_SUCCESS) {
throw EffectError("Cannot hold the text layer in memory.");
}
ContextHandle cr;
cr.get = cairo_create(surface.get);
LayoutHandle layout;
layout.get = pango_cairo_create_layout(cr.get);
pango_layout_set_text(layout.get, text.c_str(), -1);
pango_layout_set_alignment(layout.get, alignmentFor(paramChoice(values, "text_align", "center")));
const int wrap = static_cast<int>(paramInt(values, "text_wrap", 0));
if (wrap > 0) {
pango_layout_set_width(layout.get, wrap * PANGO_SCALE);
pango_layout_set_wrap(layout.get, PANGO_WRAP_WORD_CHAR);
}
FontHandle font;
font.get = pango_font_description_new();
const std::string family = paramText(values, "font_family", "Sans");
pango_font_description_set_family(font.get, family.empty() ? "Sans" : family.c_str());
pango_font_description_set_weight(font.get, weightFor(paramChoice(values, "font_weight", "normal")));
pango_font_description_set_style(
font.get, paramBool(values, "font_italic", false) ? PANGO_STYLE_ITALIC : PANGO_STYLE_NORMAL);
// Absolute size: the param is in pixels, not in points at some unstated dpi.
pango_font_description_set_absolute_size(
font.get, std::clamp(paramDouble(values, "font_size", 96.0), 4.0, 2000.0) * PANGO_SCALE);
pango_layout_set_font_description(layout.get, font.get);
PangoRectangle logical;
pango_layout_get_pixel_extents(layout.get, nullptr, &logical);
const int padding = static_cast<int>(paramInt(values, "box_padding", 24));
const int boxWidth = std::max(1, logical.width + 2 * padding);
const int boxHeight = std::max(1, logical.height + 2 * padding);
const bool centre = paramBool(values, "text_center", true);
const double boxX = (centre ? (width - boxWidth) / 2.0 : 0.0) +
static_cast<double>(paramInt(values, "text_offset_x", 0));
const double boxY = (centre ? (height - boxHeight) / 2.0 : 0.0) +
static_cast<double>(paramInt(values, "text_offset_y", 0));
if (drawsBox) {
cairo_save(cr.get);
cairo_rectangle(cr.get, boxX, boxY, boxWidth, boxHeight);
cairo_clip(cr.get);
if (boxKind == "image") {
const std::string path = paramText(values, "box_image", "");
if (!path.empty()) {
Image picture;
std::vector<std::uint8_t> alpha;
loadBoxImage(path, picture, alpha);
SurfaceHandle fill;
fill.get = toSurface(picture, alpha);
// Scaled to cover the box, keeping its proportions, exactly as
// the overlay's "fill" does; what does not fit is clipped away.
const double factor = std::max(static_cast<double>(boxWidth) / picture.width(),
static_cast<double>(boxHeight) / picture.height());
cairo_translate(cr.get, boxX + (boxWidth - picture.width() * factor) / 2.0,
boxY + (boxHeight - picture.height() * factor) / 2.0);
cairo_scale(cr.get, factor, factor);
cairo_set_source_surface(cr.get, fill.get, 0.0, 0.0);
cairo_pattern_set_filter(cairo_get_source(cr.get), CAIRO_FILTER_BILINEAR);
cairo_paint_with_alpha(cr.get, boxOpacity);
}
} else {
std::uint8_t rgb[3];
paramColor(values, "box_color", "#000000", rgb);
setColor(cr.get, rgb, boxOpacity);
cairo_paint(cr.get);
}
cairo_restore(cr.get);
}
if (drawsText) {
std::uint8_t rgb[3];
paramColor(values, "text_color", "#ffffff", rgb);
setColor(cr.get, rgb, textOpacity);
// logical.x/y are where the layout actually starts, which a wrapped or
// right-aligned layout shifts; subtracting them puts the glyphs inside
// the padding on every side.
cairo_move_to(cr.get, boxX + padding - logical.x, boxY + padding - logical.y);
pango_cairo_show_layout(cr.get, layout.get);
}
cairo_surface_flush(surface.get);
composite(canvas, surface.get);
return canvas;
}
} // namespace effector
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <cstdint>
#include <mutex>
#include <string>
#include <vector>
#include "core/effect.hpp"
namespace effector {
// Writes a line (or several) over its input: a title, a caption, a watermark.
// The family and the weight are chosen freely, and the text can sit on a box
// filled with a colour or with a picture from disk. Like the blur and the
// overlay it needs no source images of its own, so it can lead a chain; given
// sources it fills the canvas from a randomly chosen one first.
//
// The glyphs come from Pango, so the exact pixels depend on the fonts the
// machine has installed: a seed reproduces everything this effect draws, but a
// machine without the chosen family renders the substitute its fontconfig
// picks.
class TextEffect : public Effect {
public:
std::string name() const override { return "text"; }
std::string title() const override { return "Text"; }
std::string description() const override {
return "Writes text over its input in a chosen family, weight and size, optionally on a "
"box filled with a colour or with a picture.";
}
const std::vector<ParamSpec>& params() const override;
bool needsSources() const override { return false; }
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
private:
// The box picture is re-read on every param change otherwise, and a render
// is a keystroke away in the interface. Renders can overlap, so it is
// guarded.
void loadBoxImage(const std::string& path, Image& image,
std::vector<std::uint8_t>& alpha) const;
mutable std::mutex cacheMutex_;
mutable std::string cachedPath_;
mutable Image cached_;
mutable std::vector<std::uint8_t> cachedAlpha_;
};
} // namespace effector
+180
View File
@@ -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
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include "core/effect.hpp"
namespace effector {
// Covers the canvas with rectangular tiles cut at random positions from the
// source images: the two-dimensional counterpart of the scanlines effect.
class TilesEffect : public Effect {
public:
std::string name() const override { return "tiles"; }
std::string title() const override { return "Tiles"; }
std::string description() const override {
return "Cuts rectangular tiles at random positions from the source images and lays "
"them over the canvas, in a grid, in offset rows or scattered about.";
}
const std::vector<ParamSpec>& params() const override;
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
const ParamValues& values) const override;
};
} // namespace effector
+722 -113
View File
File diff suppressed because it is too large Load Diff
+60 -3
View File
@@ -12,8 +12,10 @@
#include <vector>
#include "cli/options.hpp"
#include "core/chain.hpp"
#include "core/effect.hpp"
#include "core/image.hpp"
#include "core/project.hpp"
namespace effector {
@@ -28,8 +30,12 @@ private:
Gtk::Widget& buildPreview();
void buildParamWidgets();
ParamValues paramValues() const;
void storeParams(); // widgets -> the selected stage's values
void applyInitial(const Options& initial);
bool canRender() const; // a chain, and sources if the chain needs them
std::string missingText() const; // what is still missing before a render
void showPlaceholder(); // drops the preview and says what is missing
void requestRender(); // coalesced: restarts the short debounce timer
void startRender(); // starts a worker, or marks the result stale
void onAddImages();
@@ -42,10 +48,45 @@ private:
void closeLoadWindow();
void cancelLoad();
void appendSourceRow(const std::string& path);
void updateSourcesLabel(); // the collapsed expander shows the count
Gtk::Widget& buildCanvasSection();
// "" while "Custom" is picked, otherwise the format's key.
std::string canvasFormatKey() const;
void setCanvasFormat(const std::string& key, int dpi); // dropdown and dpi
// A format owns the pixel sizes, so the spins follow it and are greyed
// out; "Custom" hands them back to the user untouched.
void syncCanvasWidgets();
void removeSource(Gtk::ListBoxRow* row);
void onEffectChanged();
Gtk::Widget& buildChainSection();
Gtk::Widget& buildAddMenu(); // the menu of effects that can be appended
void appendStage(const EffectPtr& effect);
void removeStage(int index);
void moveStage(int index, int delta);
void rebuildChainRows();
void selectStage(int index);
void onStageSelected();
Gtk::Widget& buildMainMenu(); // the header menu: new, open, save project
Project currentProject() const;
void applyProject(const Project& project, const std::string& path);
void clearSources();
void onNewProject();
void onOpenProject();
void onProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& dialog);
void onSaveProject(); // falls back to Save As while the project is untitled
void onSaveProjectAs();
void onSaveProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
const Glib::RefPtr<Gtk::FileDialog>& dialog);
void writeProject(const std::string& path);
void updateTitle();
void onParamChanged();
// 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();
@@ -66,9 +107,14 @@ private:
// Model
std::shared_ptr<const SourceList> sources_ = std::make_shared<SourceList>();
std::vector<EffectPtr> effects_;
EffectPtr effect_;
// The cascade: stage 0 works on the sources, every later stage on the
// output of the one before it.
std::vector<ChainStage> stages_;
int selectedStage_ = 0;
bool suspendStageSelect_ = false; // guards the rebuild of the chain rows
Image result_;
std::uint64_t seed_ = 0;
std::string projectPath_; // empty until the project has been saved once
bool suspendRender_ = true;
// Source loading. Decoding a batch of large photographs takes seconds, so
@@ -85,6 +131,7 @@ private:
std::vector<LoadFailure> loadFailures_;
std::vector<std::string> loadQueue_; // chosen while another batch was loading
bool loading_ = false;
bool dropLoadedImages_ = false; // the batch in flight belongs to a replaced project
sigc::connection loadWindowDelay_;
// Render job
@@ -105,12 +152,22 @@ private:
std::unique_ptr<Gtk::Window> loadWindow_;
Gtk::ProgressBar* loadBar_ = nullptr;
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::Grid paramGrid_;
Gtk::Label paramHeading_;
Gtk::Entry seedEntry_;
Gtk::DropDown effectDropdown_;
Gtk::ListBox chainList_;
Gtk::MenuButton addEffectButton_;
Gtk::MenuButton menuButton_;
Gtk::Button recreateButton_{"Recreate"};
Gtk::Button saveButton_{"Save As…"};
Gtk::Picture picture_;
+6 -7
View File
@@ -8,6 +8,7 @@
#include <mutex>
#include "cli/options.hpp"
#include "core/chain.hpp"
#include "core/effect.hpp"
#include "core/image.hpp"
#include "core/registry.hpp"
@@ -17,7 +18,7 @@
namespace {
int renderHeadless(const effector::Options& options) {
const auto effect = effector::effectByName(options.effect);
const auto stages = effector::buildChain(options);
std::vector<effector::LoadFailure> failures;
@@ -39,7 +40,7 @@ int renderHeadless(const effector::Options& options) {
for (const auto& failure : failures) {
std::cerr << "effector: skipping " << failure.path << ": " << failure.message << "\n";
}
if (sources.empty()) {
if (sources.empty() && effector::chainNeedsSources(stages)) {
std::cerr << "effector: no usable source images\n";
return 2;
}
@@ -47,17 +48,15 @@ int renderHeadless(const effector::Options& options) {
const std::uint64_t seed = options.seed.value_or(effector::Rng::randomSeed());
effector::Rng rng(seed);
auto values = effect->defaults();
for (const auto& [key, value] : options.params) values[key] = value;
const auto start = std::chrono::steady_clock::now();
const auto image = effect->render(sources, options.width, options.height, rng, values);
const auto image =
effector::renderChain(stages, sources, options.width, options.height, rng);
const auto renderMillis =
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
image.save(options.output);
std::cout << options.output << " " << options.width << "x" << options.height
<< " effect=" << effect->name() << " seed=" << seed << " render="
<< " effect=" << effector::chainSlug(stages) << " seed=" << seed << " render="
<< std::fixed << std::setprecision(1) << renderMillis << " ms\n";
return 0;
}
+3 -1
View File
@@ -1,4 +1,6 @@
add_executable(effector_tests core_tests.cpp)
# The CLI parser joins the core here: it is GUI-free too, and the canvas size
# it produces has to obey the same bounds a project file does.
add_executable(effector_tests core_tests.cpp ../src/cli/options.cpp)
target_link_libraries(effector_tests PRIVATE effector_core)
target_compile_options(effector_tests PRIVATE -Wall -Wextra -Wpedantic)
+1068
View File
File diff suppressed because it is too large Load Diff