Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9f905e04c | ||
|
|
431fcf9c58 | ||
|
|
9fb4428a3b | ||
|
|
7d122ddd6c | ||
|
|
536b8620ea | ||
|
|
81ca17463e | ||
|
|
d198428dc4 | ||
|
|
a01ada5372 | ||
|
|
d0b5669f46 | ||
|
|
5292c101db | ||
|
|
134c575727 | ||
|
|
68a1e40d3f | ||
|
|
5a5cea69db | ||
|
|
1edf854cd1 | ||
|
|
4fd6ccc9b0 | ||
|
|
352a928149 | ||
|
|
ce3a422f6a | ||
|
|
8113a06da7 | ||
|
|
3aade6f361 | ||
|
|
eb417bb41a | ||
|
|
f61161aef0 | ||
|
|
fc54d64bc4 | ||
|
|
d48a81c665 | ||
|
|
f4d8457828 | ||
|
|
749ea239bb | ||
|
|
1d0343b90a | ||
|
|
c34bc426fc | ||
|
|
e2252ecd30 | ||
|
|
2a64ab115e | ||
|
|
efee1cf224 | ||
|
|
67417727e7 | ||
|
|
82e730221e | ||
|
|
b6b5f94f08 | ||
|
|
6cb3da8dc6 | ||
|
|
4739d39a51 | ||
|
|
a82fba7886 | ||
|
|
89985cb989 | ||
|
|
ee4b1038ac |
@@ -0,0 +1,73 @@
|
||||
# Changelog
|
||||
|
||||
Every release of Effector, newest first. The version lives in one place, the
|
||||
`project()` line of `CMakeLists.txt`, and each release is a `vX.Y.Z` tag on the
|
||||
commit that made it. The interface shows this file under Changelog in its menu.
|
||||
|
||||
## [0.5.0] - 2026-09-26
|
||||
|
||||
### Added
|
||||
- A version number: `effector --version` prints it, and About Effector in the
|
||||
menu shows it.
|
||||
- This changelog, readable from the interface under Changelog in the menu.
|
||||
- Open Recent in the project menu, listing the projects opened or saved here.
|
||||
- An optional shadow or glow under the image overlay, with an offset, size,
|
||||
spread, colour and opacity.
|
||||
- Reordering the chain by dragging a row.
|
||||
|
||||
### Fixed
|
||||
- The params' scrollbar no longer covers the spin buttons.
|
||||
|
||||
## [0.4.0] - 2026-09-23
|
||||
|
||||
### Added
|
||||
- Eight chaotic effects: slit shift, channel split, pixel sort, corruption,
|
||||
shatter, warp, bit crush and feedback. None needs sources, so any of them can
|
||||
lead a chain.
|
||||
- The sidebar split into Chain and Setup tabs.
|
||||
- A rotation for the text effect's placement.
|
||||
- Text stages labelled in the chain list with their words.
|
||||
|
||||
### Changed
|
||||
- The preview stays centred however long the status line gets.
|
||||
- The status line stays short, with the full chain and setup in its tooltip.
|
||||
- The chain/params split fits the chain's rows until it is dragged; a
|
||||
double-click on the handle fits it again.
|
||||
- The mouse wheel scrolls past spin buttons that do not have the focus.
|
||||
|
||||
## [0.3.0] - 2026-09-19
|
||||
|
||||
### Added
|
||||
- Named styles: several text stages can share one font and colour, and editing
|
||||
the style moves every stage that follows it.
|
||||
- Switching a chain stage off without taking it apart, in the interface and
|
||||
with `--disable NAME` on the command line.
|
||||
|
||||
### Changed
|
||||
- The interface opens on an empty chain, and shows the sources only when the
|
||||
chain uses them.
|
||||
|
||||
## [0.2.0] - 2026-09-18
|
||||
|
||||
### Added
|
||||
- Effect chains: stages cascade, each handed the previous one's output, built in
|
||||
the sidebar or with repeated `-e`.
|
||||
- Blur, film grain, black and white, image overlay, text and tiles effects.
|
||||
- Projects saved and opened as plain JSON.
|
||||
- Standard web and print canvas formats (`--format`, `--dpi`).
|
||||
- Effects declare whether they need source images, so a chain led by blur
|
||||
renders with none loaded.
|
||||
|
||||
### Changed
|
||||
- The chain comes first in the sidebar.
|
||||
|
||||
### Fixed
|
||||
- The canvas size is checked before it is narrowed to an int.
|
||||
- Projects are written and read in the C locale, and keep the seed whole.
|
||||
|
||||
## [0.1.0] - 2026-09-18
|
||||
|
||||
### Added
|
||||
- The C++20 port of the Python prototype: a GTK 4 interface and a headless
|
||||
command line over pluggable effects, starting with scanlines.
|
||||
- Source images load in the background, with a progress window and Cancel.
|
||||
@@ -12,13 +12,20 @@ ctest --test-dir build --output-on-failure
|
||||
|
||||
Everything is warning-clean under `-Wall -Wextra -Wpedantic`; keep it that way.
|
||||
|
||||
The test suite is a single hand-rolled binary (no framework), so there is no
|
||||
The core 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.
|
||||
|
||||
The interface has its own binary, `./build/tests/effector_gui_tests`
|
||||
(`tests/gui_tests.cpp`, same `check()` style), which opens the real window and
|
||||
drives it by widget names and tooltips (`chain-list`, `add-effect`,
|
||||
`sources`, "Move up", ...). It needs a display: ctest runs it under `xvfb-run`
|
||||
when that is installed and otherwise reports it skipped (exit 77). To run it by
|
||||
hand, use the Xvfb invocation below.
|
||||
|
||||
Headless render (no display needed, the fastest end-to-end smoke test):
|
||||
|
||||
@@ -26,6 +33,20 @@ Headless render (no display needed, the fastest end-to-end smoke test):
|
||||
build/effector -o /tmp/out.png -s 800x600 --strip-height 6 --seed 1 a.jpg b.png
|
||||
```
|
||||
|
||||
### Versions and the changelog
|
||||
|
||||
The version is the `project()` line of `CMakeLists.txt`, and nowhere else.
|
||||
CMake writes it, together with the text of `CHANGELOG.md`, into a generated
|
||||
`core/version.hpp` (`kVersion`, `kChangelog`, from `src/core/version.hpp.in`)
|
||||
in the build tree. `--version` prints it, and the header-bar menu shows it
|
||||
under About Effector and the changelog under Changelog. The `version` core test
|
||||
fails unless the newest `## [X.Y.Z]` entry in `CHANGELOG.md` is the current
|
||||
version, so a bump and its changelog entry go in together. Each release is an
|
||||
annotated `vX.Y.Z` tag on the commit that made it. When you add a user-visible
|
||||
change, add a line to the newest changelog entry, or start a new entry if that
|
||||
one is already tagged. The Changelog window reads only headings, `- ` bullets
|
||||
(wrapped onto indented lines) and `` `code` ``, so keep the file to those.
|
||||
|
||||
### Running the GUI from this shell
|
||||
|
||||
GTK picks Wayland from an inherited `WAYLAND_DISPLAY` and will not draw into an
|
||||
@@ -41,22 +62,119 @@ env -u WAYLAND_DISPLAY GDK_BACKEND=x11 DISPLAY=:79 build/effector ...
|
||||
|
||||
`Effect` (`src/core/effect.hpp`) exposes `params()` returning `std::vector<ParamSpec>`
|
||||
(`src/core/params.hpp`). Those specs are the *only* thing the GUI and CLI know
|
||||
about an effect: `src/gui/window.cpp` builds SpinButtons/Switches/DropDowns from
|
||||
them and `src/cli/options.cpp` generates flags from them (`strip_height` →
|
||||
`--strip-height`). Never add effect-specific branches to the GUI or the parser —
|
||||
add a `ParamSpec` instead. Values travel as `ParamValues` (a
|
||||
about an effect: `src/gui/window.cpp` builds SpinButtons/Switches/DropDowns/file
|
||||
choosers from them and `src/cli/options.cpp` generates flags from them
|
||||
(`strip_height` → `--strip-height`). Never add effect-specific branches to the
|
||||
GUI or the parser — add a `ParamSpec` instead. A `Path` param names a file on
|
||||
disk; like a source path it is written relative to the project folder. A `Text`
|
||||
param is free-form text the user types, and a `Color` param is a colour written
|
||||
`#rrggbb` (`parseColor`/`formatColor`/`paramColor` in `src/core/params.hpp`),
|
||||
shown as a colour button in the interface. Values
|
||||
travel as `ParamValues` (a
|
||||
`map<string, variant<long,double,bool,string>>`), read back with
|
||||
`paramInt`/`paramDouble`/`paramBool`/`paramChoice`.
|
||||
|
||||
A param can also be marked `.styled()`, which says a *named style* carries it
|
||||
rather than the stage it sits on: the font and the colour of a text stage, but
|
||||
never its words or its placement. The pool a style comes from is named by
|
||||
`Effect::styleKind()` (empty by default — the effect takes no style; `"text"`
|
||||
for the text effect), and effects answering the same kind share one pool, so a
|
||||
second text-ish effect would slot into the styles that already exist. The
|
||||
front-ends know nothing more than that: the interface builds the style row and
|
||||
the project file writes a style's params from `styleSpecs(kind)`
|
||||
(`src/core/style.cpp`), which is to a style exactly what `params()` is to a
|
||||
stage.
|
||||
|
||||
Whether an effect works from the loaded photographs is part of the same
|
||||
contract: `Effect::needsSources()` (default true, false for blur). Only the
|
||||
first stage ever sees the sources, so `chainNeedsSources` (`src/core/chain.cpp`)
|
||||
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.
|
||||
|
||||
A stage carries a `ChainStage::enabled` flag, and `enabledStages`
|
||||
(`src/core/chain.cpp`) is the one place that reads it: a disabled stage is
|
||||
skipped whole — handed no image and drawing nothing from the shared rng — so
|
||||
switching one off renders exactly what the chain rendered before it was added.
|
||||
`renderChain`, `chainNeedsSources`, `chainTitle` and `chainSlug` all go through
|
||||
it, which is why the status line and the file name never mention a stage that
|
||||
did not run. A chain with every stage disabled throws rather than rendering
|
||||
nothing.
|
||||
|
||||
### A style is a look several stages share
|
||||
|
||||
`ChainStage::style` names a style, and `stageValues` (`src/core/chain.cpp`) is
|
||||
the one place that resolves one: an effect's defaults, then the stage's own
|
||||
values, then the style's values for the params it carries. A stage always keeps
|
||||
a full set of values of its own beside the style, so switching it to "Custom",
|
||||
dropping the style, or opening the project in a build that does not know the
|
||||
style renders exactly the same picture — a style moves stages together, it
|
||||
never holds the only copy of anything. The interface writes an edited param to
|
||||
both the style and the stage for that reason. `renderChain` takes the pool as a
|
||||
trailing argument; the command line builds no styles and passes none.
|
||||
|
||||
The interface builds the chain in the sidebar ("Effect chain": add from the
|
||||
menu, reorder by dragging a row or with its arrows, remove, switch off with the check on the row, one stage selected
|
||||
at a time with its params below), the CLI builds it from repeated `-e`:
|
||||
`-e scanlines --strip-height 6 -e blur --radius 5`, with `--disable NAME`
|
||||
leaving a stage in place but skipping it. Flags share one namespace across all effects, so `buildChain`
|
||||
(`src/cli/options.cpp`) hands each flag to the stage whose effect declares it —
|
||||
two effects must not declare the same param key.
|
||||
|
||||
### The canvas is a format or plain pixels
|
||||
|
||||
`src/core/canvas.cpp` holds the one table of standard sizes both front-ends
|
||||
offer: web formats are a fixed pixel size, print formats a sheet in millimetres
|
||||
that becomes pixels at a dpi. Adding a format is a line in `canvasFormats()` —
|
||||
the dropdown, `--format` and `--help` all walk that table. The pixels stay the
|
||||
truth everywhere (`renderChain` never hears about formats); a format is the
|
||||
*presentation* of a canvas, carried alongside the pixels in `Options` and
|
||||
`Project` and empty for a custom size. Where a format is named it decides the
|
||||
pixels, so a project saved as A4 reopens as A4.
|
||||
|
||||
### Projects are plain JSON
|
||||
|
||||
`src/core/project.cpp` reads and writes the whole setup — source paths, the
|
||||
named styles, the chain with its params (and its `"enabled": false` and
|
||||
`"style"`, each written only when there is something to say), canvas and seed — as a small JSON file, with its own
|
||||
minimal reader rather than a JSON dependency. Writing is driven by the effects'
|
||||
`ParamSpec`s, not by whatever happens to sit in a stage's `ParamValues`, so a
|
||||
key an effect does not declare is never written and never read back: an old
|
||||
project keeps the defaults for params that did not exist when it was saved. The
|
||||
same leniency covers styles: one of a kind this build does not know, one with
|
||||
no name or a repeated one, and a stage naming a style the file does not hold
|
||||
are each dropped on their own rather than refusing the project.
|
||||
Source paths under the project's own folder are written relative, so a folder
|
||||
can be moved or shared whole.
|
||||
|
||||
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. Open Recent lists the projects opened or saved here, kept in
|
||||
GTK's shared `RecentManager` list and told apart from other programs' files by
|
||||
the `effector` application name on each entry; the GUI tests point
|
||||
`XDG_DATA_HOME` at a temporary folder so they never touch the user's list. 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
|
||||
adds `src/main.cpp`, `src/cli/`, `src/gui/` and links gtkmm. Keep GTK out of
|
||||
`gdk-pixbuf-2.0`, `pangocairo` (the text effect's glyphs; Pango and Cairo need
|
||||
no display) + Threads — **no gtkmm, no display**. `effector_gui` (static) adds
|
||||
`src/cli/` and `src/gui/` and links gtkmm, and the `effector` executable is
|
||||
`src/main.cpp` on top of it; the GUI tests link `effector_gui` too. Keep GTK out of
|
||||
`src/core/` and `src/effects/`; the tests link the core alone and rely on this.
|
||||
|
||||
### Determinism is a contract
|
||||
@@ -64,7 +182,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.
|
||||
|
||||
+40
-7
@@ -1,5 +1,5 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(effector VERSION 0.1.0 LANGUAGES CXX)
|
||||
project(effector VERSION 0.5.0 LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
@@ -15,32 +15,65 @@ 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)
|
||||
|
||||
# --- version: the one number, and the changelog that goes with it ------------
|
||||
# The version is the project() line above; each release is a vX.Y.Z tag on the
|
||||
# commit that set it. CHANGELOG.md is baked in so the interface can show it,
|
||||
# and editing it re-runs the configure step.
|
||||
file(READ ${CMAKE_CURRENT_SOURCE_DIR}/CHANGELOG.md EFFECTOR_CHANGELOG)
|
||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS CHANGELOG.md)
|
||||
configure_file(src/core/version.hpp.in ${CMAKE_CURRENT_BINARY_DIR}/generated/core/version.hpp @ONLY)
|
||||
|
||||
# --- 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/core/style.cpp
|
||||
src/effects/bitcrush.cpp
|
||||
src/effects/blur.cpp
|
||||
src/effects/channelsplit.cpp
|
||||
src/effects/corrupt.cpp
|
||||
src/effects/feedback.cpp
|
||||
src/effects/grain.cpp
|
||||
src/effects/monochrome.cpp
|
||||
src/effects/overlay.cpp
|
||||
src/effects/pixelsort.cpp
|
||||
src/effects/scanlines.cpp
|
||||
src/effects/shatter.cpp
|
||||
src/effects/slitshift.cpp
|
||||
src/effects/text.cpp
|
||||
src/effects/tiles.cpp
|
||||
src/effects/warp.cpp
|
||||
)
|
||||
target_include_directories(effector_core PUBLIC src)
|
||||
target_include_directories(effector_core PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/generated)
|
||||
# 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 --------------------------------------------------------------
|
||||
add_executable(effector
|
||||
src/main.cpp
|
||||
# --- interface: the GTK window, split out so the GUI tests can link it --------
|
||||
add_library(effector_gui STATIC
|
||||
src/cli/options.cpp
|
||||
src/gui/app.cpp
|
||||
src/gui/window.cpp
|
||||
)
|
||||
target_link_libraries(effector PRIVATE effector_core PkgConfig::GTKMM)
|
||||
target_link_libraries(effector_gui PUBLIC effector_core PkgConfig::GTKMM)
|
||||
target_compile_options(effector_gui PRIVATE -Wall -Wextra -Wpedantic)
|
||||
|
||||
# --- application --------------------------------------------------------------
|
||||
add_executable(effector src/main.cpp)
|
||||
target_link_libraries(effector PRIVATE effector_gui)
|
||||
target_compile_options(effector PRIVATE -Wall -Wextra -Wpedantic)
|
||||
|
||||
install(TARGETS effector RUNTIME DESTINATION bin)
|
||||
|
||||
@@ -25,6 +25,7 @@ installs it to `~/.local/bin`.
|
||||
build/effector # GTK interface
|
||||
build/effector --list-effects
|
||||
build/effector --help
|
||||
build/effector --version
|
||||
```
|
||||
|
||||
Headless rendering, no display needed:
|
||||
@@ -34,6 +35,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).
|
||||
|
||||
@@ -44,6 +61,17 @@ batch. Small batches finish before the window would be drawn, so it only appears
|
||||
if the decode is still going after 250 ms. On a terminal the headless mode keeps
|
||||
a single `reading n/total` line updated on stderr; redirected output stays clean.
|
||||
|
||||
## Versions
|
||||
|
||||
Releases are listed in [CHANGELOG.md](CHANGELOG.md) and tagged `vX.Y.Z`
|
||||
(`git tag -l 'v*'`). `effector --version` prints the version, and the menu in
|
||||
the interface has Changelog and About Effector.
|
||||
|
||||
To cut a release: bump the `project()` version in `CMakeLists.txt`, move the
|
||||
changes into a new `## [X.Y.Z] - date` entry at the top of `CHANGELOG.md` (the
|
||||
core tests fail until the two agree), commit, and tag it:
|
||||
`git tag -a vX.Y.Z -m "Effector X.Y.Z"`.
|
||||
|
||||
## Effects
|
||||
|
||||
### scanlines
|
||||
|
||||
+87
-7
@@ -1,9 +1,13 @@
|
||||
#include "cli/options.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <sstream>
|
||||
|
||||
#include "core/canvas.hpp"
|
||||
#include "core/chain.hpp"
|
||||
#include "core/effect.hpp"
|
||||
#include "core/registry.hpp"
|
||||
|
||||
@@ -23,7 +27,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,25 +55,47 @@ 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];
|
||||
|
||||
if (arg == "-h" || arg == "--help") {
|
||||
options.showHelp = true;
|
||||
} else if (arg == "-V" || arg == "--version") {
|
||||
options.showVersion = true;
|
||||
} 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 == "--disable") {
|
||||
const std::string name = requireValue(argc, argv, i, arg);
|
||||
effectByName(name); // validate now, exactly as -e does
|
||||
options.disabled.push_back(name);
|
||||
} else if (arg == "--seed") {
|
||||
const std::string value = requireValue(argc, argv, i, arg);
|
||||
long seed = 0;
|
||||
@@ -104,6 +133,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 +162,11 @@ 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);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -127,13 +175,28 @@ 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 (--output"
|
||||
" defaults to " << 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"
|
||||
<< " --disable NAME keep this effect in the chain but skip it\n"
|
||||
<< " --seed N random seed (default: random)\n"
|
||||
<< " --list-effects list available effects and exit\n"
|
||||
<< " -V, --version show the version 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 +210,21 @@ 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();
|
||||
stage.enabled = std::find(options.disabled.begin(), options.disabled.end(), name) ==
|
||||
options.disabled.end();
|
||||
for (const auto& spec : stage.effect->params()) {
|
||||
const auto it = options.params.find(spec.key);
|
||||
if (it != options.params.end()) stage.values[spec.key] = it->second;
|
||||
}
|
||||
stages.push_back(std::move(stage));
|
||||
}
|
||||
return stages;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
|
||||
+16
-1
@@ -5,20 +5,29 @@
|
||||
#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
|
||||
// The effects named by --disable: their stages stay in the chain, in
|
||||
// place, but are skipped. Named rather than numbered, exactly as the
|
||||
// params are, so two stages of one effect are disabled together.
|
||||
std::vector<std::string> disabled;
|
||||
std::string output; // empty -> launch the interface
|
||||
int width = 1920;
|
||||
int height = 1080;
|
||||
std::string format; // a named canvas format, empty for plain pixels
|
||||
int dpi = kDefaultDpi; // only a print format is rendered at a resolution
|
||||
std::optional<std::uint64_t> seed;
|
||||
ParamValues params; // only the keys the user actually passed
|
||||
bool listEffects = false;
|
||||
bool showHelp = false;
|
||||
bool showVersion = false;
|
||||
};
|
||||
|
||||
// Throws EffectError on bad input. Effect flags are generated from the
|
||||
@@ -27,4 +36,10 @@ 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`. A stage named by --disable is built
|
||||
// with all its settings and marked to be skipped.
|
||||
std::vector<ChainStage> buildChain(const Options& options);
|
||||
|
||||
} // namespace effector
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
#include "core/canvas.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <locale>
|
||||
#include <sstream>
|
||||
|
||||
#include "core/chain.hpp"
|
||||
|
||||
namespace effector {
|
||||
namespace {
|
||||
|
||||
// 210 mm reads as "210", 215.9 mm as "215.9". In the C locale whatever the
|
||||
// user's is: gtkmm sets the global locale from the environment, so a stream
|
||||
// left to its default writes "210,0" on a German desktop.
|
||||
std::string millimetres(double mm) {
|
||||
std::ostringstream out;
|
||||
out.imbue(std::locale::classic());
|
||||
out.setf(std::ios::fixed);
|
||||
out.precision(1);
|
||||
out << mm;
|
||||
std::string text = out.str();
|
||||
if (text.size() > 2 && text.compare(text.size() - 2, 2, ".0") == 0) text.resize(text.size() - 2);
|
||||
return text;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const std::vector<CanvasFormat>& canvasFormats() {
|
||||
// Written out once and handed back by reference: the table is fixed, and
|
||||
// both front-ends walk it for their menu and their --help.
|
||||
static const std::vector<CanvasFormat> formats = {
|
||||
{"instagram-post", "Instagram post", false, 1080, 1080, 0.0, 0.0},
|
||||
{"instagram-portrait", "Instagram portrait", false, 1080, 1350, 0.0, 0.0},
|
||||
{"instagram-story", "Instagram story", false, 1080, 1920, 0.0, 0.0},
|
||||
{"hd", "Full HD", false, 1920, 1080, 0.0, 0.0},
|
||||
{"uhd", "4K UHD", false, 3840, 2160, 0.0, 0.0},
|
||||
{"a5", "A5", true, 0, 0, 148.0, 210.0},
|
||||
{"a4", "A4", true, 0, 0, 210.0, 297.0},
|
||||
{"a3", "A3", true, 0, 0, 297.0, 420.0},
|
||||
{"letter", "US Letter", true, 0, 0, 215.9, 279.4},
|
||||
};
|
||||
return formats;
|
||||
}
|
||||
|
||||
const CanvasFormat* canvasFormatByKey(const std::string& key) {
|
||||
if (key.empty()) return nullptr;
|
||||
for (const auto& format : canvasFormats()) {
|
||||
if (format.key == key) return &format;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void canvasFormatSize(const CanvasFormat& format, int dpi, int& width, int& height) {
|
||||
if (!format.print) {
|
||||
width = format.width;
|
||||
height = format.height;
|
||||
return;
|
||||
}
|
||||
const int clamped = std::clamp(dpi, kMinDpi, kMaxDpi);
|
||||
const auto pixels = [clamped](double mm) {
|
||||
const long value = std::lround(mm / 25.4 * clamped);
|
||||
return static_cast<int>(std::clamp<long>(value, kMinCanvasSide, kMaxCanvasSide));
|
||||
};
|
||||
width = pixels(format.widthMm);
|
||||
height = pixels(format.heightMm);
|
||||
}
|
||||
|
||||
std::string canvasFormatDetail(const CanvasFormat& format) {
|
||||
if (format.print) {
|
||||
return millimetres(format.widthMm) + " × " + millimetres(format.heightMm) + " mm";
|
||||
}
|
||||
return std::to_string(format.width) + " × " + std::to_string(format.height) + " px";
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,45 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace effector {
|
||||
|
||||
// A named canvas size both front-ends offer, so "A4 at 300 dpi" is one choice
|
||||
// rather than a pixel count the user has to work out. Web formats are a fixed
|
||||
// pixel size; print formats are a physical size in millimetres and only become
|
||||
// pixels once a resolution is given, which is why the dpi belongs to the
|
||||
// canvas rather than to any one format.
|
||||
struct CanvasFormat {
|
||||
std::string key; // written in a project and given to --format, e.g. "a4"
|
||||
std::string label; // shown in the interface, e.g. "A4"
|
||||
bool print = false;
|
||||
int width = 0; // pixels, web formats only
|
||||
int height = 0; //
|
||||
double widthMm = 0.0; // millimetres, print formats only
|
||||
double heightMm = 0.0;
|
||||
};
|
||||
|
||||
// The dpi a print format is rendered at. The bounds keep A3 — the largest
|
||||
// sheet here — inside kMaxCanvasSide at the top end, and keep a sheet from
|
||||
// coming out too small to see at the bottom.
|
||||
inline constexpr int kMinDpi = 36;
|
||||
inline constexpr int kMaxDpi = 1200;
|
||||
inline constexpr int kDefaultDpi = 300;
|
||||
|
||||
// In the order they are offered: the web formats first, then the paper sizes.
|
||||
const std::vector<CanvasFormat>& canvasFormats();
|
||||
|
||||
// Null when the key names no format, which is how a custom canvas — the user's
|
||||
// own pixel sizes — is spelled everywhere: an empty or unknown key.
|
||||
const CanvasFormat* canvasFormatByKey(const std::string& key);
|
||||
|
||||
// The pixel size of a format. `dpi` is ignored by a web format, which already
|
||||
// is a pixel size; a print format is rounded to the nearest pixel and clamped
|
||||
// to the canvas bounds.
|
||||
void canvasFormatSize(const CanvasFormat& format, int dpi, int& width, int& height);
|
||||
|
||||
// "1080 × 1080 px" or "210 × 297 mm", for the dropdown and for --help.
|
||||
std::string canvasFormatDetail(const CanvasFormat& format);
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,87 @@
|
||||
#include "core/chain.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages) {
|
||||
std::vector<const ChainStage*> running;
|
||||
running.reserve(stages.size());
|
||||
for (const auto& stage : stages) {
|
||||
if (stage.enabled && stage.effect) running.push_back(&stage);
|
||||
}
|
||||
return running;
|
||||
}
|
||||
|
||||
ParamValues stageValues(const ChainStage& stage, const StyleSet& styles) {
|
||||
ParamValues values = stage.effect->defaults();
|
||||
for (const auto& [key, value] : stage.values) values[key] = value;
|
||||
|
||||
const Style* style = findStyle(styles, stage.effect->styleKind(), stage.style);
|
||||
if (style != nullptr) {
|
||||
// Only the params the effect marked styled(): the words and the
|
||||
// placement stay the stage's own, which is what lets two stages share
|
||||
// one look without sharing a position.
|
||||
for (const auto& spec : stage.effect->params()) {
|
||||
if (!spec.inStyle) continue;
|
||||
const auto found = style->values.find(spec.key);
|
||||
if (found != style->values.end()) values[spec.key] = found->second;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
||||
int width, int height, Rng& rng, const StyleSet& styles) {
|
||||
if (stages.empty()) throw EffectError("The effect chain is empty.");
|
||||
for (const auto& stage : stages) {
|
||||
if (!stage.effect) throw EffectError("The effect chain has an empty stage.");
|
||||
}
|
||||
|
||||
// A disabled stage never reaches the render at all: it is not handed an
|
||||
// image and draws nothing from the shared rng, so switching one off gives
|
||||
// back exactly the picture the chain made before it was added.
|
||||
const auto running = enabledStages(stages);
|
||||
if (running.empty()) throw EffectError("Every effect in the chain is disabled.");
|
||||
|
||||
Image image;
|
||||
for (std::size_t i = 0; i < running.size(); ++i) {
|
||||
const auto& stage = *running[i];
|
||||
|
||||
const auto values = stageValues(stage, styles);
|
||||
|
||||
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) {
|
||||
const auto running = enabledStages(stages);
|
||||
if (running.empty()) return false;
|
||||
return running.front()->effect->needsSources();
|
||||
}
|
||||
|
||||
std::string chainTitle(const std::vector<ChainStage>& stages) {
|
||||
std::string text;
|
||||
for (const auto* stage : enabledStages(stages)) {
|
||||
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 : enabledStages(stages)) {
|
||||
if (!text.empty()) text += "-";
|
||||
text += stage->effect->name();
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,70 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/effect.hpp"
|
||||
#include "core/image.hpp"
|
||||
#include "core/params.hpp"
|
||||
#include "core/rng.hpp"
|
||||
#include "core/style.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
|
||||
// A disabled stage is skipped whole, keeping its settings and its place in
|
||||
// the chain, so a stage can be switched off and back on while trying
|
||||
// things out without being taken apart and rebuilt.
|
||||
bool enabled = true;
|
||||
// The style this stage follows, by name, in the pool its effect names with
|
||||
// styleKind(); empty when the stage stands on its own. A stage keeps a
|
||||
// full set of values either way, so a style that is dropped or renamed
|
||||
// away leaves the stage rendering exactly what it rendered before.
|
||||
std::string style = {};
|
||||
};
|
||||
|
||||
// The values a stage actually runs with: its effect's defaults, then the
|
||||
// stage's own values, then the values of the style it follows for the params
|
||||
// that style carries. The one place a style is resolved.
|
||||
ParamValues stageValues(const ChainStage& stage, const StyleSet& styles);
|
||||
|
||||
// The stages that actually run, in order, as pointers into `stages`. A
|
||||
// disabled stage draws nothing from the rng and is handed no image, so a chain
|
||||
// renders exactly as it would if that stage had never been added -- this is
|
||||
// the one place the rest of the code asks what "disabled" means.
|
||||
std::vector<const ChainStage*> enabledStages(const std::vector<ChainStage>& stages);
|
||||
|
||||
// Renders the enabled stages in order. The first one sees the loaded sources;
|
||||
// every later one sees the previous stage's output as its only source, so blur
|
||||
// after scanlines softens the scanlines rather than the photographs.
|
||||
//
|
||||
// All stages share one Rng and run strictly in sequence, so a seed still
|
||||
// reproduces the cascade byte for byte. Throws when no stage is left to run.
|
||||
// `styles` is the project's pool; a chain built without one -- the command
|
||||
// line's -- passes none and renders from the stages alone.
|
||||
Image renderChain(const std::vector<ChainStage>& stages, const std::vector<Image>& sources,
|
||||
int width, int height, Rng& rng, const StyleSet& styles = {});
|
||||
|
||||
// Whether the chain has to be given source images. Only the first stage that
|
||||
// runs ever sees them — every later one works on the stage before it — so this
|
||||
// is simply whether that effect declares that it needs them.
|
||||
bool chainNeedsSources(const std::vector<ChainStage>& stages);
|
||||
|
||||
// "Scanlines → Blur", for window titles and status lines. Both of these name
|
||||
// what was rendered, so a disabled stage is left out of them.
|
||||
std::string chainTitle(const std::vector<ChainStage>& stages);
|
||||
|
||||
// "scanlines-blur", for file names.
|
||||
std::string chainSlug(const std::vector<ChainStage>& stages);
|
||||
|
||||
} // namespace effector
|
||||
+1
-16
@@ -4,22 +4,7 @@ namespace effector {
|
||||
|
||||
ParamValues Effect::defaults() const {
|
||||
ParamValues values;
|
||||
for (const auto& spec : params()) {
|
||||
switch (spec.type) {
|
||||
case ParamSpec::Type::Int:
|
||||
values[spec.key] = static_cast<long>(spec.defaultValue);
|
||||
break;
|
||||
case ParamSpec::Type::Double:
|
||||
values[spec.key] = spec.defaultValue;
|
||||
break;
|
||||
case ParamSpec::Type::Bool:
|
||||
values[spec.key] = spec.defaultValue != 0.0;
|
||||
break;
|
||||
case ParamSpec::Type::Choice:
|
||||
values[spec.key] = spec.defaultChoice;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const auto& spec : params()) values[spec.key] = paramDefault(spec);
|
||||
return values;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,24 @@ 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; }
|
||||
|
||||
// The pool of named styles this effect's styled params are drawn from, or
|
||||
// empty when it takes no style. Effects that answer the same kind share
|
||||
// one pool, so a stage can be pointed at a look its neighbours already
|
||||
// use. An effect with no kind must declare no styled params.
|
||||
virtual std::string styleKind() const { return {}; }
|
||||
|
||||
// How a stage of this effect is named in a list of stages, given its
|
||||
// values: the title unless the values say something more telling.
|
||||
virtual std::string label(const ParamValues& values) const {
|
||||
(void)values;
|
||||
return title();
|
||||
}
|
||||
|
||||
virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const = 0;
|
||||
|
||||
|
||||
+13
-1
@@ -43,7 +43,7 @@ Image::Image(int width, int height)
|
||||
pixels_(static_cast<std::size_t>(std::max(width, 0)) * 3 *
|
||||
static_cast<std::size_t>(std::max(height, 0))) {}
|
||||
|
||||
Image Image::load(const std::string& path) {
|
||||
Image Image::load(const std::string& path, std::vector<std::uint8_t>* alphaOut) {
|
||||
GError* error = nullptr;
|
||||
PixbufPtr pixbuf = wrap(gdk_pixbuf_new_from_file(path.c_str(), &error));
|
||||
if (!pixbuf) throw std::runtime_error(takeError(error));
|
||||
@@ -59,11 +59,23 @@ Image Image::load(const std::string& path) {
|
||||
const guint8* src = gdk_pixbuf_get_pixels(pixbuf.get());
|
||||
|
||||
Image image(width, height);
|
||||
if (alphaOut != nullptr) {
|
||||
alphaOut->assign(static_cast<std::size_t>(std::max(width, 0)) *
|
||||
static_cast<std::size_t>(std::max(height, 0)),
|
||||
255);
|
||||
}
|
||||
for (int y = 0; y < height; ++y) {
|
||||
const guint8* in = src + static_cast<std::size_t>(y) * srcStride;
|
||||
std::uint8_t* out = image.row(y);
|
||||
if (channels == 3) {
|
||||
std::memcpy(out, in, static_cast<std::size_t>(width) * 3);
|
||||
} else if (alphaOut != nullptr) {
|
||||
// Kept apart rather than flattened: the caller does the compositing.
|
||||
std::uint8_t* alpha = alphaOut->data() + static_cast<std::size_t>(y) * width;
|
||||
for (int x = 0; x < width; ++x) {
|
||||
for (int c = 0; c < 3; ++c) out[x * 3 + c] = in[x * channels + c];
|
||||
alpha[x] = in[x * channels + 3];
|
||||
}
|
||||
} else {
|
||||
// Flatten alpha onto white, matching what a viewer would show.
|
||||
for (int x = 0; x < width; ++x) {
|
||||
|
||||
+5
-1
@@ -34,7 +34,11 @@ public:
|
||||
|
||||
// Decodes via GdkPixbuf (PNG, JPEG, WebP, TIFF, BMP, ...), applying the
|
||||
// embedded EXIF orientation. Throws std::runtime_error on failure.
|
||||
static Image load(const std::string& path);
|
||||
// Transparency is flattened onto white, as a viewer would show it, unless
|
||||
// `alphaOut` is given: it then receives one byte per pixel (255 throughout
|
||||
// for a file without an alpha channel) and the colours are left untouched,
|
||||
// which is what an effect compositing the image needs.
|
||||
static Image load(const std::string& path, std::vector<std::uint8_t>* alphaOut = nullptr);
|
||||
|
||||
// Encodes by file extension; png is used when the extension is unknown.
|
||||
void save(const std::string& path) const;
|
||||
|
||||
@@ -10,6 +10,12 @@ std::string ParamSpec::flag() const {
|
||||
return flag;
|
||||
}
|
||||
|
||||
ParamSpec ParamSpec::styled() const {
|
||||
ParamSpec copy = *this;
|
||||
copy.inStyle = true;
|
||||
return copy;
|
||||
}
|
||||
|
||||
ParamSpec ParamSpec::makeInt(std::string key, std::string label, long defaultValue, long minimum,
|
||||
long maximum, std::string help, long step) {
|
||||
ParamSpec spec;
|
||||
@@ -63,6 +69,37 @@ ParamSpec ParamSpec::makeChoice(std::string key, std::string label, std::string
|
||||
return spec;
|
||||
}
|
||||
|
||||
ParamSpec ParamSpec::makePath(std::string key, std::string label, std::string help) {
|
||||
ParamSpec spec;
|
||||
spec.type = Type::Path;
|
||||
spec.key = std::move(key);
|
||||
spec.label = std::move(label);
|
||||
spec.help = std::move(help);
|
||||
return spec;
|
||||
}
|
||||
|
||||
ParamSpec ParamSpec::makeText(std::string key, std::string label, std::string defaultText,
|
||||
std::string help) {
|
||||
ParamSpec spec;
|
||||
spec.type = Type::Text;
|
||||
spec.key = std::move(key);
|
||||
spec.label = std::move(label);
|
||||
spec.help = std::move(help);
|
||||
spec.defaultChoice = std::move(defaultText);
|
||||
return spec;
|
||||
}
|
||||
|
||||
ParamSpec ParamSpec::makeColor(std::string key, std::string label, std::string defaultColor,
|
||||
std::string help) {
|
||||
ParamSpec spec;
|
||||
spec.type = Type::Color;
|
||||
spec.key = std::move(key);
|
||||
spec.label = std::move(label);
|
||||
spec.help = std::move(help);
|
||||
spec.defaultChoice = std::move(defaultColor);
|
||||
return spec;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
const ParamValue* find(const ParamValues& values, const std::string& key) {
|
||||
@@ -72,6 +109,23 @@ const ParamValue* find(const ParamValues& values, const std::string& key) {
|
||||
|
||||
} // namespace
|
||||
|
||||
ParamValue paramDefault(const ParamSpec& spec) {
|
||||
switch (spec.type) {
|
||||
case ParamSpec::Type::Int:
|
||||
return static_cast<long>(spec.defaultValue);
|
||||
case ParamSpec::Type::Double:
|
||||
return spec.defaultValue;
|
||||
case ParamSpec::Type::Bool:
|
||||
return spec.defaultValue != 0.0;
|
||||
case ParamSpec::Type::Choice:
|
||||
case ParamSpec::Type::Path:
|
||||
case ParamSpec::Type::Text:
|
||||
case ParamSpec::Type::Color:
|
||||
break;
|
||||
}
|
||||
return spec.defaultChoice;
|
||||
}
|
||||
|
||||
long paramInt(const ParamValues& values, const std::string& key, long fallback) {
|
||||
if (const auto* value = find(values, key)) {
|
||||
if (const auto* v = std::get_if<long>(value)) return *v;
|
||||
@@ -99,10 +153,61 @@ bool paramBool(const ParamValues& values, const std::string& key, bool fallback)
|
||||
|
||||
std::string paramChoice(const ParamValues& values, const std::string& key,
|
||||
const std::string& fallback) {
|
||||
return paramText(values, key, fallback);
|
||||
}
|
||||
|
||||
std::string paramText(const ParamValues& values, const std::string& key,
|
||||
const std::string& fallback) {
|
||||
if (const auto* value = find(values, key)) {
|
||||
if (const auto* v = std::get_if<std::string>(value)) return *v;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
int hexDigit(char c) {
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||
return -1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool parseColor(const std::string& text, std::uint8_t rgb[3]) {
|
||||
std::string digits = text;
|
||||
if (!digits.empty() && digits.front() == '#') digits.erase(digits.begin());
|
||||
if (digits.size() != 3 && digits.size() != 6) return false;
|
||||
|
||||
int parsed[6];
|
||||
for (std::size_t i = 0; i < digits.size(); ++i) {
|
||||
parsed[i] = hexDigit(digits[i]);
|
||||
if (parsed[i] < 0) return false;
|
||||
}
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
// "#abc" is the usual shorthand for "#aabbcc".
|
||||
const int value = digits.size() == 3 ? parsed[c] * 17 : parsed[c * 2] * 16 + parsed[c * 2 + 1];
|
||||
rgb[c] = static_cast<std::uint8_t>(value);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string formatColor(const std::uint8_t rgb[3]) {
|
||||
static const char* const kDigits = "0123456789abcdef";
|
||||
std::string text = "#";
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
text += kDigits[rgb[c] >> 4];
|
||||
text += kDigits[rgb[c] & 0x0F];
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
void paramColor(const ParamValues& values, const std::string& key, const std::string& fallback,
|
||||
std::uint8_t rgb[3]) {
|
||||
if (parseColor(paramText(values, key, fallback), rgb)) return;
|
||||
if (parseColor(fallback, rgb)) return;
|
||||
rgb[0] = rgb[1] = rgb[2] = 0;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
|
||||
+38
-1
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <variant>
|
||||
@@ -10,7 +11,7 @@ namespace effector {
|
||||
// Declarative description of one effect knob. The GUI turns these into widgets
|
||||
// and the CLI turns them into flags, so neither knows about any effect.
|
||||
struct ParamSpec {
|
||||
enum class Type { Int, Double, Bool, Choice };
|
||||
enum class Type { Int, Double, Bool, Choice, Path, Text, Color };
|
||||
|
||||
Type type = Type::Int;
|
||||
std::string key;
|
||||
@@ -23,9 +24,17 @@ struct ParamSpec {
|
||||
int digits = 2;
|
||||
std::string defaultChoice;
|
||||
std::vector<std::string> choices;
|
||||
// Whether a named style carries this param rather than the stage it sits
|
||||
// on: see core/style.hpp.
|
||||
bool inStyle = false;
|
||||
|
||||
std::string flag() const; // "strip_height" -> "--strip-height"
|
||||
|
||||
// A copy of this spec marked as carried by a style, so that a declaration
|
||||
// reads makeDouble("font_size", ...).styled() and the factories keep the
|
||||
// signatures they have.
|
||||
ParamSpec styled() const;
|
||||
|
||||
static ParamSpec makeInt(std::string key, std::string label, long defaultValue, long minimum,
|
||||
long maximum, std::string help = {}, long step = 1);
|
||||
static ParamSpec makeDouble(std::string key, std::string label, double defaultValue,
|
||||
@@ -35,15 +44,43 @@ struct ParamSpec {
|
||||
std::string help = {});
|
||||
static ParamSpec makeChoice(std::string key, std::string label, std::string defaultChoice,
|
||||
std::vector<std::string> choices, std::string help = {});
|
||||
// A path to a file on disk, empty by default: a chooser in the interface, a
|
||||
// flag taking any string on the command line, written relative to the
|
||||
// project folder so that a folder still opens after it is moved.
|
||||
static ParamSpec makePath(std::string key, std::string label, std::string help = {});
|
||||
// Free-form text the user types: an entry in the interface, a flag taking
|
||||
// any string on the command line.
|
||||
static ParamSpec makeText(std::string key, std::string label, std::string defaultText,
|
||||
std::string help = {});
|
||||
// A colour written "#rrggbb": a colour button in the interface, the same
|
||||
// spelling as a flag value.
|
||||
static ParamSpec makeColor(std::string key, std::string label, std::string defaultColor,
|
||||
std::string help = {});
|
||||
};
|
||||
|
||||
using ParamValue = std::variant<long, double, bool, std::string>;
|
||||
using ParamValues = std::map<std::string, ParamValue>;
|
||||
|
||||
// The value a spec stands for when nothing has been set: one place, so that
|
||||
// an effect's defaults and a style's are filled the same way.
|
||||
ParamValue paramDefault(const ParamSpec& spec);
|
||||
|
||||
long paramInt(const ParamValues& values, const std::string& key, long fallback);
|
||||
double paramDouble(const ParamValues& values, const std::string& key, double fallback);
|
||||
bool paramBool(const ParamValues& values, const std::string& key, bool fallback);
|
||||
std::string paramChoice(const ParamValues& values, const std::string& key,
|
||||
const std::string& fallback);
|
||||
std::string paramText(const ParamValues& values, const std::string& key,
|
||||
const std::string& fallback);
|
||||
|
||||
// Reads "#rgb" or "#rrggbb" (the leading hash optional) into three bytes.
|
||||
// Returns false and leaves `rgb` untouched for anything else, so a malformed
|
||||
// value falls back to the default rather than painting an arbitrary colour.
|
||||
bool parseColor(const std::string& text, std::uint8_t rgb[3]);
|
||||
std::string formatColor(const std::uint8_t rgb[3]);
|
||||
|
||||
// The colour of a param, or `fallback` when the value does not parse.
|
||||
void paramColor(const ParamValues& values, const std::string& key, const std::string& fallback,
|
||||
std::uint8_t rgb[3]);
|
||||
|
||||
} // namespace effector
|
||||
|
||||
@@ -0,0 +1,619 @@
|
||||
#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();
|
||||
}
|
||||
|
||||
// The params of a stage or of a style, written from the specs rather than from
|
||||
// whatever the values happen to hold: a key the effect does not declare is
|
||||
// never written, so it can never be read back either.
|
||||
void writeParams(std::ostream& out, const std::vector<ParamSpec>& specs, const ParamValues& values,
|
||||
const std::string& directory) {
|
||||
out << "{";
|
||||
bool first = true;
|
||||
for (const auto& spec : specs) {
|
||||
out << (first ? "\n" : ",\n") << " " << quote(spec.key) << ": ";
|
||||
first = false;
|
||||
switch (spec.type) {
|
||||
case ParamSpec::Type::Bool:
|
||||
out << (paramBool(values, spec.key, spec.defaultValue != 0.0) ? "true" : "false");
|
||||
break;
|
||||
case ParamSpec::Type::Int:
|
||||
out << paramInt(values, spec.key, static_cast<long>(spec.defaultValue));
|
||||
break;
|
||||
case ParamSpec::Type::Double:
|
||||
out << number(paramDouble(values, spec.key, spec.defaultValue), spec.digits);
|
||||
break;
|
||||
case ParamSpec::Type::Choice:
|
||||
case ParamSpec::Type::Text:
|
||||
out << quote(paramText(values, spec.key, spec.defaultChoice));
|
||||
break;
|
||||
case ParamSpec::Type::Color: {
|
||||
std::uint8_t rgb[3];
|
||||
paramColor(values, spec.key, spec.defaultChoice, rgb);
|
||||
out << quote(formatColor(rgb));
|
||||
break;
|
||||
}
|
||||
case ParamSpec::Type::Path: {
|
||||
// Written relative to the project folder, exactly as the
|
||||
// sources are, so a folder still opens once it is moved.
|
||||
const auto path = paramText(values, spec.key, "");
|
||||
out << quote(path.empty() ? path : relativeTo(path, directory));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
out << (specs.empty() ? "}" : "\n }");
|
||||
}
|
||||
|
||||
// The mirror image: only declared keys are read, each behind the gate its type
|
||||
// asks for, so anything else in the file leaves the default standing.
|
||||
void readParams(const std::vector<ParamSpec>& specs, const Json& params,
|
||||
const std::string& directory, ParamValues& values) {
|
||||
for (const auto& spec : specs) {
|
||||
const auto* value = params.member(spec.key);
|
||||
if (value == nullptr) continue;
|
||||
switch (spec.type) {
|
||||
case ParamSpec::Type::Bool:
|
||||
if (value->kind == Json::Kind::Bool) values[spec.key] = value->boolean;
|
||||
break;
|
||||
case ParamSpec::Type::Int:
|
||||
if (value->kind == Json::Kind::Number) {
|
||||
values[spec.key] = static_cast<long>(std::llround(value->number));
|
||||
}
|
||||
break;
|
||||
case ParamSpec::Type::Double:
|
||||
if (value->kind == Json::Kind::Number) values[spec.key] = value->number;
|
||||
break;
|
||||
case ParamSpec::Type::Path:
|
||||
if (value->kind == Json::Kind::String) {
|
||||
values[spec.key] =
|
||||
value->text.empty() ? value->text : resolve(value->text, directory);
|
||||
}
|
||||
break;
|
||||
case ParamSpec::Type::Text:
|
||||
if (value->kind == Json::Kind::String) values[spec.key] = value->text;
|
||||
break;
|
||||
case ParamSpec::Type::Color: {
|
||||
// A colour that does not parse keeps the default, exactly as
|
||||
// an unknown choice does.
|
||||
std::uint8_t rgb[3];
|
||||
if (value->kind == Json::Kind::String && parseColor(value->text, rgb)) {
|
||||
values[spec.key] = formatColor(rgb);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ParamSpec::Type::Choice:
|
||||
for (const auto& choice : spec.choices) {
|
||||
if (value->kind == Json::Kind::String && value->text == choice) {
|
||||
values[spec.key] = choice;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string projectToJson(const Project& project, const std::string& directory) {
|
||||
// JSON is written in the C locale whatever the user's is: a decimal comma
|
||||
// or a digit grouping would make the file something this reader — or any
|
||||
// other — cannot read back. gtkmm sets the global locale from the
|
||||
// environment, so a stream left to its default is the user's locale.
|
||||
std::ostringstream out;
|
||||
out.imbue(std::locale::classic());
|
||||
out << "{\n";
|
||||
out << " \"effector_project\": 1,\n";
|
||||
out << " \"canvas\": {\"width\": " << project.width << ", \"height\": " << project.height;
|
||||
// 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";
|
||||
|
||||
// Before the chain, because a stage's "style" is a name from this list.
|
||||
if (!project.styles.empty()) {
|
||||
out << " \"styles\": [";
|
||||
for (std::size_t i = 0; i < project.styles.size(); ++i) {
|
||||
const auto& style = project.styles[i];
|
||||
out << (i == 0 ? "\n" : ",\n");
|
||||
out << " {\n \"kind\": " << quote(style.kind) << ",\n";
|
||||
out << " \"name\": " << quote(style.name) << ",\n";
|
||||
out << " \"params\": ";
|
||||
writeParams(out, styleSpecs(style.kind), style.values, directory);
|
||||
out << "\n }";
|
||||
}
|
||||
out << "\n ],\n";
|
||||
}
|
||||
|
||||
out << " \"chain\": [";
|
||||
for (std::size_t i = 0; i < project.stages.size(); ++i) {
|
||||
const auto& stage = project.stages[i];
|
||||
if (!stage.effect) continue;
|
||||
out << (i == 0 ? "\n" : ",\n");
|
||||
out << " {\n \"effect\": " << quote(stage.effect->name()) << ",\n";
|
||||
// Only written when it is false: a stage is enabled unless the file
|
||||
// says otherwise, so an ordinary project stays as uncluttered as it
|
||||
// was before a stage could be switched off at all.
|
||||
if (!stage.enabled) out << " \"enabled\": false,\n";
|
||||
// Likewise only when there is one: a stage that stands on its own
|
||||
// values says nothing about styles at all.
|
||||
if (!stage.style.empty()) out << " \"style\": " << quote(stage.style) << ",\n";
|
||||
out << " \"params\": ";
|
||||
writeParams(out, stage.effect->params(), stage.values, directory);
|
||||
out << "\n }";
|
||||
}
|
||||
out << (project.stages.empty() ? "" : "\n ") << "]\n";
|
||||
out << "}\n";
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read before the chain, so that a stage's "style" can be checked against
|
||||
// the pool as it is read.
|
||||
if (const auto* styles = root.member("styles")) {
|
||||
for (const auto& item : styles->items) {
|
||||
const auto* kind = item.member("kind");
|
||||
const auto* name = item.member("name");
|
||||
if (kind == nullptr || kind->kind != Json::Kind::String) continue;
|
||||
if (name == nullptr || name->kind != Json::Kind::String || name->text.empty()) continue;
|
||||
|
||||
// A kind no effect in this build claims carries no params, so
|
||||
// there would be nothing to keep; a repeated name would make
|
||||
// "style": "Title" ambiguous, so the first one wins.
|
||||
const auto specs = styleSpecs(kind->text);
|
||||
if (specs.empty()) continue;
|
||||
if (findStyle(project.styles, kind->text, name->text) != nullptr) continue;
|
||||
|
||||
Style style;
|
||||
style.kind = kind->text;
|
||||
style.name = name->text;
|
||||
style.values = styleDefaults(style.kind);
|
||||
if (const auto* params = item.member("params")) {
|
||||
readParams(specs, *params, directory, style.values);
|
||||
}
|
||||
project.styles.push_back(std::move(style));
|
||||
}
|
||||
}
|
||||
|
||||
const auto* chain = root.member("chain");
|
||||
if (chain == nullptr || chain->kind != Json::Kind::Array || chain->items.empty()) {
|
||||
throw EffectError("the project has no effect chain");
|
||||
}
|
||||
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();
|
||||
if (const auto* enabled = item.member("enabled");
|
||||
enabled != nullptr && enabled->kind == Json::Kind::Bool) {
|
||||
stage.enabled = enabled->boolean;
|
||||
}
|
||||
|
||||
// Driven by the specs, not by the file: a key the effect does not
|
||||
// declare is dropped, and an old project simply keeps the defaults for
|
||||
// params that did not exist when it was written.
|
||||
if (const auto* params = item.member("params")) {
|
||||
readParams(stage.effect->params(), *params, directory, stage.values);
|
||||
}
|
||||
|
||||
// A style is kept only when the file actually holds one of that name
|
||||
// for this effect's pool: a stage pointing at a style that is not
|
||||
// there falls back to the values it wrote beside it, which are the
|
||||
// ones it last rendered with.
|
||||
if (const auto* style = item.member("style");
|
||||
style != nullptr && style->kind == Json::Kind::String &&
|
||||
findStyle(project.styles, stage.effect->styleKind(), style->text) != nullptr) {
|
||||
stage.style = style->text;
|
||||
}
|
||||
project.stages.push_back(std::move(stage));
|
||||
}
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
void saveProject(const std::string& path, const Project& project) {
|
||||
const auto directory = std::filesystem::path(path).parent_path().string();
|
||||
std::ofstream file(path, std::ios::binary | std::ios::trunc);
|
||||
if (!file) throw EffectError("cannot write " + path);
|
||||
file << projectToJson(project, directory);
|
||||
if (!file) throw EffectError("could not write " + path);
|
||||
}
|
||||
|
||||
Project loadProject(const std::string& path) {
|
||||
std::ifstream file(path, std::ios::binary);
|
||||
if (!file) throw EffectError("cannot read " + path);
|
||||
std::ostringstream buffer;
|
||||
buffer << file.rdbuf();
|
||||
try {
|
||||
return projectFromJson(buffer.str(), std::filesystem::path(path).parent_path().string());
|
||||
} catch (const EffectError& error) {
|
||||
throw EffectError(std::filesystem::path(path).filename().string() + ": " + error.what());
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/canvas.hpp"
|
||||
#include "core/chain.hpp"
|
||||
#include "core/params.hpp"
|
||||
#include "core/style.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;
|
||||
// The named looks the stages draw their formatting from. Kept beside the
|
||||
// chain rather than inside it because that is the whole point of a style:
|
||||
// several stages share one, and editing it moves all of them at once.
|
||||
StyleSet styles;
|
||||
int width = 1920;
|
||||
int height = 1080;
|
||||
// The named format the canvas came from, empty for a custom pixel size.
|
||||
// The pixels above stay the truth for the render; this is what the
|
||||
// interface puts back in its dropdown, and what a print format needs its
|
||||
// resolution for.
|
||||
std::string format;
|
||||
int dpi = kDefaultDpi;
|
||||
std::optional<std::uint64_t> seed;
|
||||
};
|
||||
|
||||
// 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. A
|
||||
// style of an unknown kind, or a stage naming a style the file does not hold,
|
||||
// is dropped the same way: the stage keeps its own values and still renders.
|
||||
Project projectFromJson(const std::string& text, const std::string& directory = {});
|
||||
|
||||
// Both throw EffectError: the file is the user's, so failures are reported
|
||||
// rather than silently swallowed.
|
||||
void saveProject(const std::string& path, const Project& project);
|
||||
Project loadProject(const std::string& path);
|
||||
|
||||
} // namespace effector
|
||||
@@ -1,6 +1,20 @@
|
||||
#include "core/registry.hpp"
|
||||
|
||||
#include "effects/bitcrush.hpp"
|
||||
#include "effects/blur.hpp"
|
||||
#include "effects/channelsplit.hpp"
|
||||
#include "effects/corrupt.hpp"
|
||||
#include "effects/feedback.hpp"
|
||||
#include "effects/grain.hpp"
|
||||
#include "effects/monochrome.hpp"
|
||||
#include "effects/overlay.hpp"
|
||||
#include "effects/pixelsort.hpp"
|
||||
#include "effects/scanlines.hpp"
|
||||
#include "effects/shatter.hpp"
|
||||
#include "effects/slitshift.hpp"
|
||||
#include "effects/text.hpp"
|
||||
#include "effects/tiles.hpp"
|
||||
#include "effects/warp.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
@@ -8,6 +22,20 @@ 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>(),
|
||||
std::make_shared<SlitShiftEffect>(),
|
||||
std::make_shared<ChannelSplitEffect>(),
|
||||
std::make_shared<PixelSortEffect>(),
|
||||
std::make_shared<CorruptEffect>(),
|
||||
std::make_shared<ShatterEffect>(),
|
||||
std::make_shared<WarpEffect>(),
|
||||
std::make_shared<BitCrushEffect>(),
|
||||
std::make_shared<FeedbackEffect>(),
|
||||
};
|
||||
return registry;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
#include "core/style.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "core/registry.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
std::vector<ParamSpec> styleSpecs(const std::string& kind) {
|
||||
std::vector<ParamSpec> specs;
|
||||
if (kind.empty()) return specs;
|
||||
for (const auto& effect : effects()) {
|
||||
if (effect->styleKind() != kind) continue;
|
||||
for (const auto& spec : effect->params()) {
|
||||
if (!spec.inStyle) continue;
|
||||
const bool known = std::any_of(specs.begin(), specs.end(), [&](const ParamSpec& seen) {
|
||||
return seen.key == spec.key;
|
||||
});
|
||||
if (!known) specs.push_back(spec);
|
||||
}
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
ParamValues styleDefaults(const std::string& kind) {
|
||||
ParamValues values;
|
||||
for (const auto& spec : styleSpecs(kind)) values[spec.key] = paramDefault(spec);
|
||||
return values;
|
||||
}
|
||||
|
||||
const Style* findStyle(const StyleSet& styles, const std::string& kind, const std::string& name) {
|
||||
if (kind.empty() || name.empty()) return nullptr;
|
||||
for (const auto& style : styles) {
|
||||
if (style.kind == kind && style.name == name) return &style;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/params.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// A named set of formatting values, shared by every stage that follows it.
|
||||
//
|
||||
// A style carries only the params an effect marked styled() -- the look, never
|
||||
// the placement -- so several text stages can share one typography while each
|
||||
// keeps its own words and its own corner of the canvas. Changing the style is
|
||||
// the point: it changes every stage pointing at it at once.
|
||||
struct Style {
|
||||
std::string kind; // the pool, as Effect::styleKind() names it
|
||||
std::string name; // unique within a kind, never empty
|
||||
ParamValues values; // the styled params only
|
||||
};
|
||||
|
||||
using StyleSet = std::vector<Style>;
|
||||
|
||||
// The styled specs of a kind: every registered effect answering that kind
|
||||
// contributes its styled params, the first declaration of a key winning. This
|
||||
// is to a style what Effect::params() is to a stage -- the only thing the
|
||||
// interface and the project file know about what a style holds -- so an empty
|
||||
// or unknown kind gives an empty list.
|
||||
std::vector<ParamSpec> styleSpecs(const std::string& kind);
|
||||
|
||||
// A fresh set of values for a style of that kind: every styled param at the
|
||||
// default its effect declares, the way Effect::defaults() fills a stage.
|
||||
ParamValues styleDefaults(const std::string& kind);
|
||||
|
||||
// The style of that name in that pool, or nullptr. A stage naming a style that
|
||||
// is not there simply stands on its own values.
|
||||
const Style* findStyle(const StyleSet& styles, const std::string& kind, const std::string& name);
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
// Generated by CMake from src/core/version.hpp.in; edit that, not the copy in
|
||||
// the build tree. The version comes from the project() line of CMakeLists.txt
|
||||
// and the changelog from CHANGELOG.md, both read at configure time.
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace effector {
|
||||
|
||||
inline constexpr std::string_view kVersion = "@PROJECT_VERSION@";
|
||||
|
||||
// CHANGELOG.md as it stood when the build was configured, as Markdown.
|
||||
inline constexpr std::string_view kChangelog = R"changelog(@EFFECTOR_CHANGELOG@)changelog";
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,129 @@
|
||||
#include "effects/bitcrush.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& BitCrushEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeChoice("crush_op", "Operation", "mixed",
|
||||
{"mixed", "posterize", "xor", "bitrotate", "channel_swap"},
|
||||
"posterize drops the low bits, xor flips random low bits, "
|
||||
"bitrotate turns each byte's bits round, channel_swap shuffles "
|
||||
"red, green and blue; mixed picks one for every band."),
|
||||
ParamSpec::makeInt("crush_bits", "Bits", 3, 1, 7,
|
||||
"How many bits the operation reaches: dropped by posterize, flipped "
|
||||
"at most by xor, turned at most by bitrotate."),
|
||||
ParamSpec::makeDouble("crush_coverage", "Coverage", 0.5, 0.0, 1.0,
|
||||
"Chance of each band being crushed."),
|
||||
ParamSpec::makeInt("crush_band_height", "Band height", 32, 1, 4096,
|
||||
"Thickness in pixels of each band."),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
enum class Op : std::uint8_t { None, Posterize, Xor, Rotate, Swap };
|
||||
|
||||
struct Band {
|
||||
Op op = Op::None;
|
||||
int rotate = 0;
|
||||
std::array<std::uint8_t, 3> mask{};
|
||||
std::array<int, 3> order{0, 1, 2};
|
||||
};
|
||||
|
||||
Op opNamed(const std::string& name) {
|
||||
if (name == "posterize") return Op::Posterize;
|
||||
if (name == "xor") return Op::Xor;
|
||||
if (name == "bitrotate") return Op::Rotate;
|
||||
if (name == "channel_swap") return Op::Swap;
|
||||
return Op::None; // mixed: drawn per band
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Image BitCrushEffect::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 Op fixed = opNamed(paramChoice(values, "crush_op", "mixed"));
|
||||
const int bits = static_cast<int>(std::clamp(paramInt(values, "crush_bits", 3), 1L, 7L));
|
||||
const double coverage = std::clamp(paramDouble(values, "crush_coverage", 0.5), 0.0, 1.0);
|
||||
const int bandHeight = static_cast<int>(std::max(1L, paramInt(values, "crush_band_height", 32)));
|
||||
|
||||
Image canvas = canvasFromSources(sources, width, height, rng);
|
||||
|
||||
const int bandCount = (height + bandHeight - 1) / bandHeight;
|
||||
std::vector<Band> plan(static_cast<std::size_t>(bandCount));
|
||||
for (auto& band : plan) {
|
||||
if (!chance(rng.next(), coverage)) continue;
|
||||
band.op = fixed != Op::None ? fixed : static_cast<Op>(1 + rng.below(3));
|
||||
switch (band.op) {
|
||||
case Op::Xor:
|
||||
for (auto& mask : band.mask) {
|
||||
mask = static_cast<std::uint8_t>(rng.inRange(1, (1L << bits) - 1));
|
||||
}
|
||||
break;
|
||||
case Op::Rotate:
|
||||
band.rotate = static_cast<int>(rng.inRange(1, bits));
|
||||
break;
|
||||
case Op::Swap:
|
||||
// Fisher-Yates, never leaving the order as it was.
|
||||
do {
|
||||
band.order = {0, 1, 2};
|
||||
for (int i = 2; i > 0; --i) {
|
||||
std::swap(band.order[static_cast<std::size_t>(i)],
|
||||
band.order[rng.below(static_cast<std::uint64_t>(i))]);
|
||||
}
|
||||
} while (band.order[0] == 0 && band.order[1] == 1);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const std::uint8_t keep = static_cast<std::uint8_t>(0xFF << bits);
|
||||
|
||||
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t y = begin; y < end; ++y) {
|
||||
const Band& band = plan[y / static_cast<std::size_t>(bandHeight)];
|
||||
if (band.op == Op::None) continue;
|
||||
std::uint8_t* row = canvas.row(static_cast<int>(y));
|
||||
for (int x = 0; x < width; ++x) {
|
||||
std::uint8_t* px = row + static_cast<std::size_t>(x) * 3;
|
||||
switch (band.op) {
|
||||
case Op::Posterize:
|
||||
for (int c = 0; c < 3; ++c) px[c] &= keep;
|
||||
break;
|
||||
case Op::Xor:
|
||||
for (int c = 0; c < 3; ++c) px[c] ^= band.mask[static_cast<std::size_t>(c)];
|
||||
break;
|
||||
case Op::Rotate:
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
px[c] = static_cast<std::uint8_t>((px[c] << band.rotate) |
|
||||
(px[c] >> (8 - band.rotate)));
|
||||
}
|
||||
break;
|
||||
case Op::Swap: {
|
||||
const std::uint8_t in[3] = {px[0], px[1], px[2]};
|
||||
for (int c = 0; c < 3; ++c) px[c] = in[band.order[static_cast<std::size_t>(c)]];
|
||||
break;
|
||||
}
|
||||
case Op::None:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// Mangles the bytes of its input band by band: posterising, xoring with a
|
||||
// random mask, rotating the bits or shuffling the channels.
|
||||
// A pure transform: it needs no source images, and leading a chain with
|
||||
// sources loaded it starts from a crop of a randomly chosen one.
|
||||
class BitCrushEffect : public Effect {
|
||||
public:
|
||||
std::string name() const override { return "bitcrush"; }
|
||||
std::string title() const override { return "Bit crush"; }
|
||||
std::string description() const override {
|
||||
return "Mangles the bytes of random bands: posterises them, xors them with a random "
|
||||
"mask, rotates their bits or shuffles their channels.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
bool needsSources() const override { return false; }
|
||||
|
||||
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,112 @@
|
||||
#include "effects/blur.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& BlurEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeInt("radius", "Radius", 8, 1, 256,
|
||||
"Half width in pixels of the blur window."),
|
||||
ParamSpec::makeInt("passes", "Passes", 2, 1, 8,
|
||||
"How often the box blur is repeated; more passes look more gaussian."),
|
||||
ParamSpec::makeChoice("direction", "Direction", "both", {"both", "horizontal", "vertical"},
|
||||
"Axis the blur is applied along."),
|
||||
ParamSpec::makeBool("first_source", "Use the first image", false,
|
||||
"Always blur the first source instead of picking one at random."),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// One separable box blur pass. `stride` is the distance in bytes between two
|
||||
// neighbouring samples along the blurred axis, `count` how many of them there
|
||||
// are; the window is clamped at both ends.
|
||||
void boxPass(const std::uint8_t* in, std::uint8_t* out, int count, std::size_t stride, int radius) {
|
||||
const int window = 2 * radius + 1;
|
||||
int sum[3] = {0, 0, 0};
|
||||
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
sum[c] = in[c] * (radius + 1);
|
||||
for (int i = 1; i <= radius; ++i) {
|
||||
sum[c] += in[static_cast<std::size_t>(std::min(i, count - 1)) * stride + c];
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < count; ++i) {
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
out[static_cast<std::size_t>(i) * stride + c] =
|
||||
static_cast<std::uint8_t>(sum[c] / window);
|
||||
}
|
||||
const int add = std::min(i + radius + 1, count - 1);
|
||||
const int drop = std::max(i - radius, 0);
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
sum[c] += in[static_cast<std::size_t>(add) * stride + c] -
|
||||
in[static_cast<std::size_t>(drop) * stride + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void blurHorizontal(const Image& src, Image& dst, int radius) {
|
||||
parallelFor(static_cast<std::size_t>(src.height()), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t y = begin; y < end; ++y) {
|
||||
boxPass(src.row(static_cast<int>(y)), dst.row(static_cast<int>(y)), src.width(), 3,
|
||||
radius);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void blurVertical(const Image& src, Image& dst, int radius) {
|
||||
const std::size_t stride = src.stride();
|
||||
parallelFor(static_cast<std::size_t>(src.width()), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t x = begin; x < end; ++x) {
|
||||
const std::size_t offset = x * 3;
|
||||
boxPass(src.data() + offset, dst.data() + offset, src.height(), stride, radius);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Image BlurEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const {
|
||||
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
|
||||
|
||||
const int radius = static_cast<int>(std::max(1L, paramInt(values, "radius", 8)));
|
||||
const int passes = static_cast<int>(std::max(1L, paramInt(values, "passes", 2)));
|
||||
const std::string direction = paramChoice(values, "direction", "both");
|
||||
const bool firstSource = paramBool(values, "first_source", false);
|
||||
|
||||
Image canvas(width, height);
|
||||
if (!sources.empty()) {
|
||||
// Every random value is drawn here, before any threading, so that a
|
||||
// seed always gives the same image.
|
||||
const std::size_t index = firstSource ? 0 : rng.below(sources.size() - 1);
|
||||
const Image& src = sources[index];
|
||||
const int sx = static_cast<int>(rng.inRange(0, std::max(0, src.width() - width)));
|
||||
const int sy = static_cast<int>(rng.inRange(0, std::max(0, src.height() - height)));
|
||||
fillFromSource(canvas, src, sx, sy);
|
||||
}
|
||||
|
||||
Image scratch(width, height);
|
||||
for (int pass = 0; pass < passes; ++pass) {
|
||||
if (direction != "vertical") {
|
||||
blurHorizontal(canvas, scratch, std::min(radius, std::max(1, width - 1)));
|
||||
std::swap(canvas, scratch);
|
||||
}
|
||||
if (direction != "horizontal") {
|
||||
blurVertical(canvas, scratch, std::min(radius, std::max(1, height - 1)));
|
||||
std::swap(canvas, scratch);
|
||||
}
|
||||
}
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// Softens its input with a separable box blur, repeated a few times to
|
||||
// approximate a gaussian. It needs no source images of its own: given none it
|
||||
// blurs an empty canvas, which is what makes it usable as a pure filter stage.
|
||||
class BlurEffect : public Effect {
|
||||
public:
|
||||
std::string name() const override { return "blur"; }
|
||||
std::string title() const override { return "Blur"; }
|
||||
std::string description() const override {
|
||||
return "Softens its input with a repeated box blur, horizontally, vertically or both; "
|
||||
"with sources loaded it fills the canvas from a randomly chosen one first.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
bool needsSources() const override { return false; }
|
||||
|
||||
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,97 @@
|
||||
#include "effects/channelsplit.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& ChannelSplitEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeInt("split_amount", "Amount", 12, 0, 1024,
|
||||
"Furthest in pixels a channel may be moved, along either axis."),
|
||||
ParamSpec::makeChoice("split_mode", "Mode", "global", {"global", "per_band"},
|
||||
"global moves each channel once over the whole image; per_band "
|
||||
"draws new offsets for every band."),
|
||||
ParamSpec::makeInt("split_band_height", "Band height", 24, 1, 2048,
|
||||
"Thickness in pixels of each band; per_band mode only."),
|
||||
ParamSpec::makeChoice("split_channels", "Channels", "rgb", {"rgb", "red_cyan", "random"},
|
||||
"rgb moves all three channels apart; red_cyan moves red one way and "
|
||||
"green and blue the other, as 3D glasses would; random moves one "
|
||||
"channel picked at random."),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct Offset {
|
||||
int dx = 0;
|
||||
int dy = 0;
|
||||
};
|
||||
|
||||
using ChannelOffsets = std::array<Offset, 3>;
|
||||
|
||||
ChannelOffsets drawOffsets(Rng& rng, int amount, const std::string& channels) {
|
||||
ChannelOffsets offsets{};
|
||||
auto draw = [&] {
|
||||
return Offset{static_cast<int>(rng.inRange(-amount, amount)),
|
||||
static_cast<int>(rng.inRange(-amount, amount))};
|
||||
};
|
||||
if (channels == "red_cyan") {
|
||||
const Offset red = draw();
|
||||
offsets[0] = red;
|
||||
offsets[1] = offsets[2] = Offset{-red.dx, -red.dy};
|
||||
} else if (channels == "random") {
|
||||
offsets[rng.below(2)] = draw();
|
||||
} else {
|
||||
for (auto& offset : offsets) offset = draw();
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Image ChannelSplitEffect::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 amount = static_cast<int>(std::max(0L, paramInt(values, "split_amount", 12)));
|
||||
const bool perBand = paramChoice(values, "split_mode", "global") == "per_band";
|
||||
const int bandHeight = static_cast<int>(std::max(1L, paramInt(values, "split_band_height", 24)));
|
||||
const std::string channels = paramChoice(values, "split_channels", "rgb");
|
||||
|
||||
const Image input = canvasFromSources(sources, width, height, rng);
|
||||
|
||||
// One set of offsets per band, a single band covering everything in global
|
||||
// mode; all of them drawn before the pixels are touched.
|
||||
const int band = perBand ? bandHeight : height;
|
||||
const int bandCount = (height + band - 1) / band;
|
||||
std::vector<ChannelOffsets> plan;
|
||||
plan.reserve(static_cast<std::size_t>(bandCount));
|
||||
for (int i = 0; i < bandCount; ++i) plan.push_back(drawOffsets(rng, amount, channels));
|
||||
|
||||
Image canvas(width, height);
|
||||
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t y = begin; y < end; ++y) {
|
||||
const ChannelOffsets& offsets = plan[y / static_cast<std::size_t>(band)];
|
||||
std::uint8_t* out = canvas.row(static_cast<int>(y));
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
const Offset& offset = offsets[static_cast<std::size_t>(c)];
|
||||
const int sy = std::clamp(static_cast<int>(y) - offset.dy, 0, height - 1);
|
||||
const std::uint8_t* in = input.row(sy);
|
||||
for (int x = 0; x < width; ++x) {
|
||||
const int sx = std::clamp(x - offset.dx, 0, width - 1);
|
||||
out[static_cast<std::size_t>(x) * 3 + c] = in[static_cast<std::size_t>(sx) * 3 + c];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// Knocks the red, green and blue planes out of register, either the whole image
|
||||
// at once or band by band, each band with offsets of its own.
|
||||
// A pure transform: it needs no source images, and leading a chain with
|
||||
// sources loaded it starts from a crop of a randomly chosen one.
|
||||
class ChannelSplitEffect : public Effect {
|
||||
public:
|
||||
std::string name() const override { return "channelsplit"; }
|
||||
std::string title() const override { return "Channel split"; }
|
||||
std::string description() const override {
|
||||
return "Knocks the red, green and blue planes out of register, over the whole image or "
|
||||
"band by band.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
bool needsSources() const override { return false; }
|
||||
|
||||
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,143 @@
|
||||
#include "effects/corrupt.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& CorruptEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeInt("corrupt_block", "Block size", 16, 2, 256,
|
||||
"Width and height in pixels of one block, as a codec would cut them."),
|
||||
ParamSpec::makeDouble("corrupt_rate", "Rate", 0.08, 0.0, 1.0,
|
||||
"Chance of each block being damaged."),
|
||||
ParamSpec::makeChoice("corrupt_kinds", "Damage", "mixed",
|
||||
{"mixed", "repeat", "displace", "colour"},
|
||||
"repeat freezes a block into a copy of the one to its left, "
|
||||
"displace pulls one in from elsewhere in the image, colour knocks "
|
||||
"it off colour; mixed picks one of them for every block."),
|
||||
ParamSpec::makeBool("corrupt_bleed", "Bleed", false,
|
||||
"Carry each damaged block's fault on to the end of its row, the way "
|
||||
"one broken bit spoils the rest of a stream."),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
enum class Damage : std::uint8_t { None, Repeat, Displace, Colour };
|
||||
|
||||
struct Block {
|
||||
Damage damage = Damage::None;
|
||||
int sx = 0; // displace: where the block is read from
|
||||
int sy = 0;
|
||||
int shift[3] = {0, 0, 0}; // colour: added to each channel
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Image CorruptEffect::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 size = static_cast<int>(std::max(2L, paramInt(values, "corrupt_block", 16)));
|
||||
const double rate = std::clamp(paramDouble(values, "corrupt_rate", 0.08), 0.0, 1.0);
|
||||
const std::string kinds = paramChoice(values, "corrupt_kinds", "mixed");
|
||||
const bool bleed = paramBool(values, "corrupt_bleed", false);
|
||||
|
||||
const Image input = canvasFromSources(sources, width, height, rng);
|
||||
|
||||
const int cols = (width + size - 1) / size;
|
||||
const int rows = (height + size - 1) / size;
|
||||
|
||||
auto drawBlock = [&](int bx) {
|
||||
Block block;
|
||||
if (kinds == "repeat") block.damage = Damage::Repeat;
|
||||
else if (kinds == "displace") block.damage = Damage::Displace;
|
||||
else if (kinds == "colour") block.damage = Damage::Colour;
|
||||
else block.damage = static_cast<Damage>(1 + rng.below(2));
|
||||
// The first block of a row has nothing to its left to repeat.
|
||||
if (block.damage == Damage::Repeat && bx == 0) block.damage = Damage::Displace;
|
||||
if (block.damage == Damage::Displace) {
|
||||
block.sx = static_cast<int>(rng.inRange(0, std::max(0, width - size)));
|
||||
block.sy = static_cast<int>(rng.inRange(0, std::max(0, height - size)));
|
||||
} else if (block.damage == Damage::Colour) {
|
||||
for (int& shift : block.shift) shift = static_cast<int>(rng.inRange(-128, 128));
|
||||
}
|
||||
return block;
|
||||
};
|
||||
|
||||
// The whole damage map is drawn up front, block by block in reading order.
|
||||
std::vector<Block> plan(static_cast<std::size_t>(cols) * rows);
|
||||
for (int by = 0; by < rows; ++by) {
|
||||
const Block* carried = nullptr;
|
||||
for (int bx = 0; bx < cols; ++bx) {
|
||||
Block& block = plan[static_cast<std::size_t>(by) * cols + bx];
|
||||
if (carried != nullptr) {
|
||||
block = *carried;
|
||||
// A displaced run keeps moving along with the row, so the
|
||||
// stretch reads as one shifted strip rather than one block
|
||||
// over and over.
|
||||
if (block.damage == Damage::Displace) block.sx += size;
|
||||
carried = █
|
||||
continue;
|
||||
}
|
||||
if (!chance(rng.next(), rate)) continue;
|
||||
block = drawBlock(bx);
|
||||
if (bleed) carried = █
|
||||
}
|
||||
}
|
||||
|
||||
Image canvas = input;
|
||||
// Block rows are independent (a repeat only ever reads to its left), so
|
||||
// they run in parallel and each walks its row in order.
|
||||
parallelFor(static_cast<std::size_t>(rows), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t by = begin; by < end; ++by) {
|
||||
const int y0 = static_cast<int>(by) * size;
|
||||
const int h = std::min(size, height - y0);
|
||||
for (int bx = 0; bx < cols; ++bx) {
|
||||
const Block& block = plan[by * static_cast<std::size_t>(cols) + bx];
|
||||
if (block.damage == Damage::None) continue;
|
||||
const int x0 = bx * size;
|
||||
const int w = std::min(size, width - x0);
|
||||
const std::size_t bytes = static_cast<std::size_t>(w) * 3;
|
||||
|
||||
for (int line = 0; line < h; ++line) {
|
||||
std::uint8_t* out = canvas.row(y0 + line) + static_cast<std::size_t>(x0) * 3;
|
||||
switch (block.damage) {
|
||||
case Damage::Repeat:
|
||||
std::memmove(out, out - static_cast<std::size_t>(size) * 3, bytes);
|
||||
break;
|
||||
case Damage::Displace: {
|
||||
const int sy = std::min(block.sy + line, height - 1);
|
||||
const std::uint8_t* in = input.row(sy);
|
||||
for (int x = 0; x < w; ++x) {
|
||||
const int sx = (block.sx + x) % width;
|
||||
std::memcpy(out + static_cast<std::size_t>(x) * 3,
|
||||
in + static_cast<std::size_t>(sx) * 3, 3);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Damage::Colour:
|
||||
for (std::size_t i = 0; i < bytes; ++i) {
|
||||
out[i] = static_cast<std::uint8_t>(
|
||||
std::clamp(out[i] + block.shift[i % 3], 0, 255));
|
||||
}
|
||||
break;
|
||||
case Damage::None:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// Imitates a damaged video stream: blocks of the image are frozen into a copy of
|
||||
// their neighbour, pulled in from elsewhere or knocked off colour, and with
|
||||
// bleeding switched on the damage runs on to the end of the row.
|
||||
// A pure transform: it needs no source images, and leading a chain with
|
||||
// sources loaded it starts from a crop of a randomly chosen one.
|
||||
class CorruptEffect : public Effect {
|
||||
public:
|
||||
std::string name() const override { return "corrupt"; }
|
||||
std::string title() const override { return "Corruption"; }
|
||||
std::string description() const override {
|
||||
return "Imitates a damaged video stream: random blocks are smeared, displaced or knocked "
|
||||
"off colour, optionally bleeding on to the end of their row.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
bool needsSources() const override { return false; }
|
||||
|
||||
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,103 @@
|
||||
#include "effects/feedback.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& FeedbackEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeInt("echo_count", "Echoes", 6, 1, 64,
|
||||
"How many times the image is laid back over itself."),
|
||||
ParamSpec::makeDouble("echo_scale", "Scale", 0.85, 0.1, 2.0,
|
||||
"Size of each echo against the one before; below 1 the echoes "
|
||||
"tunnel inwards, above 1 they burst outwards.",
|
||||
0.01),
|
||||
ParamSpec::makeDouble("echo_rotate", "Rotation", 4.0, -180.0, 180.0,
|
||||
"Turn in degrees of each echo against the one before.", 0.5, 1),
|
||||
ParamSpec::makeInt("echo_drift", "Drift", 20, 0, 4096,
|
||||
"Furthest in pixels each echo may wander off centre, either way."),
|
||||
ParamSpec::makeDouble("echo_fade", "Opacity", 0.8, 0.0, 1.0,
|
||||
"How strongly each echo covers what lies beneath it."),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct Echo {
|
||||
double dx = 0.0;
|
||||
double dy = 0.0;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Image FeedbackEffect::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 long count = std::clamp(paramInt(values, "echo_count", 6), 1L, 64L);
|
||||
const double scale = std::clamp(paramDouble(values, "echo_scale", 0.85), 0.1, 2.0);
|
||||
const double rotate = std::clamp(paramDouble(values, "echo_rotate", 4.0), -180.0, 180.0);
|
||||
const int drift = static_cast<int>(std::max(0L, paramInt(values, "echo_drift", 20)));
|
||||
const double fade = std::clamp(paramDouble(values, "echo_fade", 0.8), 0.0, 1.0);
|
||||
|
||||
const Image input = canvasFromSources(sources, width, height, rng);
|
||||
|
||||
std::vector<Echo> echoes(static_cast<std::size_t>(count));
|
||||
for (auto& echo : echoes) {
|
||||
echo.dx = static_cast<double>(rng.inRange(-drift, drift));
|
||||
echo.dy = static_cast<double>(rng.inRange(-drift, drift));
|
||||
}
|
||||
|
||||
double sine = 0.0;
|
||||
double cosine = 1.0;
|
||||
sinCosDegrees(rotate, sine, cosine);
|
||||
const double cx = (width - 1) * 0.5;
|
||||
const double cy = (height - 1) * 0.5;
|
||||
const int mix = static_cast<int>(fade * 256.0 + 0.5);
|
||||
|
||||
// A camera filming its own monitor: every pass lays the whole picture so
|
||||
// far, shrunk (or grown), turned and drifted, over the untouched input, so
|
||||
// the older echoes nest inside the newer ones and fade as they go.
|
||||
Image frame = input;
|
||||
Image next(width, height);
|
||||
for (const Echo& echo : echoes) {
|
||||
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t row = begin; row < end; ++row) {
|
||||
const std::uint8_t* base = input.row(static_cast<int>(row));
|
||||
std::uint8_t* out = next.row(static_cast<int>(row));
|
||||
const double ry = static_cast<double>(row) - cy - echo.dy;
|
||||
for (int col = 0; col < width; ++col) {
|
||||
const std::size_t at = static_cast<std::size_t>(col) * 3;
|
||||
const double rx = col - cx - echo.dx;
|
||||
const double sx = cx + (rx * cosine + ry * sine) / scale;
|
||||
const double sy = cy + (-rx * sine + ry * cosine) / scale;
|
||||
if (sx < 0.0 || sy < 0.0 || sx > width - 1 || sy > height - 1) {
|
||||
out[at] = base[at];
|
||||
out[at + 1] = base[at + 1];
|
||||
out[at + 2] = base[at + 2];
|
||||
continue;
|
||||
}
|
||||
std::uint8_t echoed[3];
|
||||
sampleBilinear(frame, sx, sy, echoed);
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
const int under = base[at + static_cast<std::size_t>(c)];
|
||||
out[at + static_cast<std::size_t>(c)] =
|
||||
static_cast<std::uint8_t>(under + (echoed[c] - under) * mix / 256);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
std::swap(frame, next);
|
||||
}
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// Lays the image over itself again and again, each copy zoomed, turned and
|
||||
// drifted a little further and fading as it goes, like a camera filming its
|
||||
// own monitor.
|
||||
// A pure transform: it needs no source images, and leading a chain with
|
||||
// sources loaded it starts from a crop of a randomly chosen one.
|
||||
class FeedbackEffect : public Effect {
|
||||
public:
|
||||
std::string name() const override { return "feedback"; }
|
||||
std::string title() const override { return "Feedback"; }
|
||||
std::string description() const override {
|
||||
return "Lays the image over itself again and again, each copy zoomed, turned and drifted "
|
||||
"further and fading, like a camera filming its own monitor.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
bool needsSources() const override { return false; }
|
||||
|
||||
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,90 @@
|
||||
#include "effects/grain.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& GrainEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeDouble("grain_amount", "Amount", 0.35, 0.0, 1.0,
|
||||
"How far a grain may push a pixel, as a fraction of full scale."),
|
||||
ParamSpec::makeInt("grain_size", "Grain size", 1, 1, 32,
|
||||
"Width in pixels of one grain; larger sizes look coarser."),
|
||||
ParamSpec::makeBool("grain_colour", "Coloured grain", false,
|
||||
"Draw the channels separately instead of one grey grain per pixel."),
|
||||
ParamSpec::makeBool("grain_midtones", "Weight towards midtones", true,
|
||||
"Fade the grain out in the shadows and highlights, as film does."),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Response curve: 1 at mid grey, falling to 0 at black and at white, which is
|
||||
// roughly how grain behaves on film.
|
||||
inline int midtoneWeight(int value) { return 255 - std::abs(2 * value - 255); }
|
||||
|
||||
} // namespace
|
||||
|
||||
Image GrainEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const {
|
||||
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
|
||||
|
||||
const double amount = std::clamp(paramDouble(values, "grain_amount", 0.35), 0.0, 1.0);
|
||||
const int size = static_cast<int>(std::max(1L, paramInt(values, "grain_size", 1)));
|
||||
const bool colour = paramBool(values, "grain_colour", false);
|
||||
const bool midtones = paramBool(values, "grain_midtones", true);
|
||||
|
||||
Image canvas(width, height);
|
||||
if (!sources.empty()) {
|
||||
// Drawn before any threading, so that a seed always gives the same image.
|
||||
const std::size_t index = rng.below(sources.size() - 1);
|
||||
const Image& src = sources[index];
|
||||
const int sx = static_cast<int>(rng.inRange(0, std::max(0, src.width() - width)));
|
||||
const int sy = static_cast<int>(rng.inRange(0, std::max(0, src.height() - height)));
|
||||
fillFromSource(canvas, src, sx, sy);
|
||||
}
|
||||
|
||||
if (amount <= 0.0) return canvas;
|
||||
|
||||
// The whole grain field is drawn serially from the rng, exactly like the
|
||||
// scanlines plan, and only the pixel arithmetic afterwards is parallel.
|
||||
const int cellsX = (width + size - 1) / size;
|
||||
const int cellsY = (height + size - 1) / size;
|
||||
const int channels = colour ? 3 : 1;
|
||||
std::vector<std::int8_t> field(static_cast<std::size_t>(cellsX) * cellsY * channels);
|
||||
for (auto& cell : field) cell = static_cast<std::int8_t>(rng.inRange(-127, 127));
|
||||
|
||||
// 127 * 255 is the largest product of a grain and a midtone weight; scaling
|
||||
// by `amount` there keeps the whole thing in integers.
|
||||
const int scale = static_cast<int>(amount * 255.0 * 65536.0 / 127.0);
|
||||
|
||||
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t y = begin; y < end; ++y) {
|
||||
std::uint8_t* out = canvas.row(static_cast<int>(y));
|
||||
const std::size_t rowCell =
|
||||
static_cast<std::size_t>(static_cast<int>(y) / size) * cellsX;
|
||||
|
||||
for (int x = 0; x < width; ++x) {
|
||||
const std::size_t cell = (rowCell + static_cast<std::size_t>(x / size)) * channels;
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
const int value = out[static_cast<std::size_t>(x) * 3 + c];
|
||||
const int grain = field[cell + (colour ? static_cast<std::size_t>(c) : 0)];
|
||||
const int weight = midtones ? midtoneWeight(value) : 255;
|
||||
const int delta = grain * weight / 255 * scale / 65536;
|
||||
out[static_cast<std::size_t>(x) * 3 + c] =
|
||||
static_cast<std::uint8_t>(std::clamp(value + delta, 0, 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// Scatters film-like grain over its input. Like the blur it needs no source
|
||||
// images of its own, so it can lead a chain: given sources it fills the canvas
|
||||
// from one of them first, given none it grains an empty canvas — which with the
|
||||
// midtone weighting on stays black, exactly as an unexposed film would.
|
||||
class GrainEffect : public Effect {
|
||||
public:
|
||||
std::string name() const override { return "grain"; }
|
||||
std::string title() const override { return "Film grain"; }
|
||||
std::string description() const override {
|
||||
return "Scatters film-like grain over its input, optionally coloured and weighted towards "
|
||||
"the midtones; with sources loaded it fills the canvas from a randomly chosen one "
|
||||
"first.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
bool needsSources() const override { return false; }
|
||||
|
||||
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,105 @@
|
||||
#include "effects/monochrome.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& MonochromeEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeChoice("mono_weighting", "Weighting", "luminance",
|
||||
{"luminance", "average", "red", "green", "blue"},
|
||||
"How the three channels are mixed into one grey; the colour names "
|
||||
"weigh that channel most, like a filter over the lens."),
|
||||
ParamSpec::makeDouble("mono_contrast", "Contrast", 1.0, 0.1, 4.0,
|
||||
"Pull of the greys away from mid grey; 1 leaves them as they are."),
|
||||
ParamSpec::makeChoice("mono_tone", "Toner", "none",
|
||||
{"none", "sepia", "selenium", "cyanotype"},
|
||||
"Tints the finished greys, as a darkroom toner would."),
|
||||
ParamSpec::makeDouble("mono_strength", "Strength", 1.0, 0.0, 1.0,
|
||||
"How far the result is mixed back over the colour input."),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Channel weights summing to 1024, so a grey is one integer multiply-add away.
|
||||
std::array<int, 3> weightsFor(const std::string& weighting) {
|
||||
if (weighting == "average") return {341, 342, 341};
|
||||
if (weighting == "red") return {819, 154, 51};
|
||||
if (weighting == "green") return {154, 819, 51};
|
||||
if (weighting == "blue") return {154, 51, 819};
|
||||
return {218, 732, 74}; // Rec. 709 luminance.
|
||||
}
|
||||
|
||||
// Tint multipliers per channel, as 256ths.
|
||||
std::array<int, 3> tintFor(const std::string& tone) {
|
||||
if (tone == "sepia") return {282, 251, 205};
|
||||
if (tone == "selenium") return {261, 251, 271};
|
||||
if (tone == "cyanotype") return {205, 243, 307};
|
||||
return {256, 256, 256};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Image MonochromeEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const {
|
||||
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
|
||||
|
||||
const auto weights = weightsFor(paramChoice(values, "mono_weighting", "luminance"));
|
||||
const auto tint = tintFor(paramChoice(values, "mono_tone", "none"));
|
||||
const double contrast = std::clamp(paramDouble(values, "mono_contrast", 1.0), 0.1, 4.0);
|
||||
const double strength = std::clamp(paramDouble(values, "mono_strength", 1.0), 0.0, 1.0);
|
||||
|
||||
Image canvas(width, height);
|
||||
if (!sources.empty()) {
|
||||
// Drawn before any threading, so that a seed always gives the same image.
|
||||
const std::size_t index = rng.below(sources.size() - 1);
|
||||
const Image& src = sources[index];
|
||||
const int sx = static_cast<int>(rng.inRange(0, std::max(0, src.width() - width)));
|
||||
const int sy = static_cast<int>(rng.inRange(0, std::max(0, src.height() - height)));
|
||||
fillFromSource(canvas, src, sx, sy);
|
||||
}
|
||||
|
||||
if (strength <= 0.0) return canvas;
|
||||
|
||||
// Contrast and toner only ever see one grey, so the whole tail of the
|
||||
// conversion is a table: grey in, the three toned channels out.
|
||||
std::array<std::array<std::uint8_t, 256>, 3> table{};
|
||||
for (int grey = 0; grey < 256; ++grey) {
|
||||
const int pulled =
|
||||
std::clamp(static_cast<int>(128.0 + (grey - 128) * contrast + 0.5), 0, 255);
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
table[static_cast<std::size_t>(c)][static_cast<std::size_t>(grey)] =
|
||||
static_cast<std::uint8_t>(
|
||||
std::clamp(pulled * tint[static_cast<std::size_t>(c)] / 256, 0, 255));
|
||||
}
|
||||
}
|
||||
|
||||
const int mix = static_cast<int>(strength * 256.0 + 0.5);
|
||||
|
||||
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t y = begin; y < end; ++y) {
|
||||
std::uint8_t* out = canvas.row(static_cast<int>(y));
|
||||
for (int x = 0; x < width; ++x) {
|
||||
std::uint8_t* px = out + static_cast<std::size_t>(x) * 3;
|
||||
const int grey =
|
||||
(px[0] * weights[0] + px[1] * weights[1] + px[2] * weights[2]) / 1024;
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
const int toned = table[static_cast<std::size_t>(c)]
|
||||
[static_cast<std::size_t>(std::clamp(grey, 0, 255))];
|
||||
px[c] = static_cast<std::uint8_t>(px[c] + (toned - px[c]) * mix / 256);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// Converts its input to black and white, with the choice of channel weighting
|
||||
// a photographer would make behind the lens (a red filter darkening a sky, for
|
||||
// instance), an optional contrast pull and an optional toner. Like the blur it
|
||||
// needs no source images of its own, so it can lead a chain; given sources it
|
||||
// fills the canvas from one of them first.
|
||||
class MonochromeEffect : public Effect {
|
||||
public:
|
||||
std::string name() const override { return "monochrome"; }
|
||||
std::string title() const override { return "Black and white"; }
|
||||
std::string description() const override {
|
||||
return "Converts its input to black and white with a choice of channel weighting, an "
|
||||
"optional contrast pull and an optional toner; with sources loaded it fills the "
|
||||
"canvas from a randomly chosen one first.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
bool needsSources() const override { return false; }
|
||||
|
||||
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,369 @@
|
||||
#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."),
|
||||
ParamSpec::makeChoice("overlay_halo", "Halo", "none", {"none", "shadow", "glow"},
|
||||
"Draws a soft copy of the image's outline beneath it: a shadow, "
|
||||
"moved by the shadow offset, or a glow all around it."),
|
||||
ParamSpec::makeColor("overlay_shadow_color", "Shadow colour", "#000000",
|
||||
"The colour of the shadow."),
|
||||
ParamSpec::makeColor("overlay_glow_color", "Glow colour", "#ffffff",
|
||||
"The colour of the glow."),
|
||||
ParamSpec::makeInt("overlay_halo_size", "Halo size", 12, 0, 500,
|
||||
"How far the shadow or glow fades out, in pixels; 0 gives a hard "
|
||||
"edge."),
|
||||
ParamSpec::makeDouble("overlay_halo_spread", "Halo spread", 0.0, 0.0, 0.9,
|
||||
"Pushes the full strength of the shadow or glow out from the "
|
||||
"image's edge before it starts to fade; a glow usually wants some."),
|
||||
ParamSpec::makeDouble("overlay_halo_opacity", "Halo opacity", 0.75, 0.0, 1.0,
|
||||
"How strongly the shadow or glow is drawn, on top of the image's "
|
||||
"own opacity."),
|
||||
ParamSpec::makeInt("overlay_shadow_x", "Shadow X", 8, -2000, 2000,
|
||||
"Moves the shadow right by this many pixels (left when negative); "
|
||||
"a glow stays centred."),
|
||||
ParamSpec::makeInt("overlay_shadow_y", "Shadow Y", 8, -2000, 2000,
|
||||
"Moves the shadow down by this many pixels (up when negative); a "
|
||||
"glow stays centred."),
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// One box blur pass over a line of the halo mask. Unlike the blur effect's the
|
||||
// window counts zero beyond both ends, so the halo fades out at the edge of
|
||||
// the mask instead of smearing its last value outwards. Integers throughout,
|
||||
// so a seed reproduces the halo byte for byte on any machine.
|
||||
void maskPass(const std::int32_t* in, std::int32_t* out, int count, std::size_t stride,
|
||||
int radius) {
|
||||
const int window = 2 * radius + 1;
|
||||
std::int32_t sum = 0;
|
||||
for (int i = 0; i <= radius && i < count; ++i) sum += in[static_cast<std::size_t>(i) * stride];
|
||||
for (int i = 0; i < count; ++i) {
|
||||
out[static_cast<std::size_t>(i) * stride] = (sum + window / 2) / window;
|
||||
if (i + radius + 1 < count) sum += in[static_cast<std::size_t>(i + radius + 1) * stride];
|
||||
if (i - radius >= 0) sum -= in[static_cast<std::size_t>(i - radius) * stride];
|
||||
}
|
||||
}
|
||||
|
||||
// Three box passes each way, which is close enough to a gaussian: `size` is
|
||||
// roughly how far the result reaches past the edge of what was covered.
|
||||
void blurMask(std::vector<std::int32_t>& mask, int width, int height, int size) {
|
||||
if (size <= 0) return;
|
||||
const int radius = std::max(1, (size + 2) / 3);
|
||||
std::vector<std::int32_t> scratch(mask.size());
|
||||
for (int pass = 0; pass < 3; ++pass) {
|
||||
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t y = begin; y < end; ++y) {
|
||||
const std::size_t offset = y * static_cast<std::size_t>(width);
|
||||
maskPass(mask.data() + offset, scratch.data() + offset, width, 1, radius);
|
||||
}
|
||||
});
|
||||
parallelFor(static_cast<std::size_t>(width), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t x = begin; x < end; ++x) {
|
||||
maskPass(scratch.data() + x, mask.data() + x, height,
|
||||
static_cast<std::size_t>(width), radius);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
} // 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);
|
||||
const int mix = static_cast<int>(opacity * 255.0 + 0.5);
|
||||
const std::uint8_t* alphaData = alpha.data();
|
||||
|
||||
// The halo goes down first, so that the image covers it wherever it is
|
||||
// opaque and shows it through wherever it is not.
|
||||
const std::string halo = paramChoice(values, "overlay_halo", "none");
|
||||
const double haloOpacity =
|
||||
std::clamp(paramDouble(values, "overlay_halo_opacity", 0.75), 0.0, 1.0);
|
||||
if ((halo == "shadow" || halo == "glow") && haloOpacity > 0.0) {
|
||||
const bool shadow = halo == "shadow";
|
||||
const int size =
|
||||
static_cast<int>(std::clamp<long>(paramInt(values, "overlay_halo_size", 12), 0, 500));
|
||||
const int reach = 3 * std::max(1, (size + 2) / 3);
|
||||
const int shiftX = shadow ? static_cast<int>(paramInt(values, "overlay_shadow_x", 8)) : 0;
|
||||
const int shiftY = shadow ? static_cast<int>(paramInt(values, "overlay_shadow_y", 8)) : 0;
|
||||
std::uint8_t colour[3];
|
||||
paramColor(values, shadow ? "overlay_shadow_color" : "overlay_glow_color",
|
||||
shadow ? "#000000" : "#ffffff", colour);
|
||||
|
||||
// The mask covers the canvas as the shadow sees it (moved back by the
|
||||
// offset) plus the blur's reach, so that parts of the image lying off
|
||||
// the canvas still cast onto it. Outside the image nothing is covered,
|
||||
// so the mask is cut down to the image plus that reach as well.
|
||||
int left = -shiftX - reach;
|
||||
int top = -shiftY - reach;
|
||||
int right = width - shiftX + reach;
|
||||
int bottom = height - shiftY + reach;
|
||||
if (!tiling) {
|
||||
left = std::max(left, place.x - reach);
|
||||
top = std::max(top, place.y - reach);
|
||||
right = std::min(right, place.x + place.width + reach);
|
||||
bottom = std::min(bottom, place.y + place.height + reach);
|
||||
}
|
||||
if (left < right && top < bottom) {
|
||||
const int maskWidth = right - left;
|
||||
const int maskHeight = bottom - top;
|
||||
std::vector<std::int32_t> mask(static_cast<std::size_t>(maskWidth) * maskHeight, 0);
|
||||
parallelFor(static_cast<std::size_t>(maskHeight), [&](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 = top + static_cast<int>(i);
|
||||
if (!tiling && (y < place.y || y >= place.y + place.height)) continue;
|
||||
const double v = (y - place.y + 0.5) * stepY - 0.5;
|
||||
std::int32_t* out = mask.data() + i * static_cast<std::size_t>(maskWidth);
|
||||
for (int x = left; x < right; ++x) {
|
||||
if (!tiling && (x < place.x || x >= place.x + place.width)) continue;
|
||||
const double u = (x - place.x + 0.5) * stepX - 0.5;
|
||||
sampleOverlay(overlay, alphaData, u, v, tiling, rgb, pixelAlpha);
|
||||
out[x - left] = pixelAlpha;
|
||||
}
|
||||
}
|
||||
});
|
||||
blurMask(mask, maskWidth, maskHeight, size);
|
||||
|
||||
const int haloMix = static_cast<int>(haloOpacity * mix + 0.5);
|
||||
// Spread amplifies the blurred mask, so the part near the edge
|
||||
// saturates and only the outer rim still fades.
|
||||
const double spread =
|
||||
std::clamp(paramDouble(values, "overlay_halo_spread", 0.0), 0.0, 0.9);
|
||||
const std::int32_t gain = static_cast<std::int32_t>(256.0 / (1.0 - spread) + 0.5);
|
||||
const int fromRow = std::max(0, top + shiftY);
|
||||
const int toRow = std::min(height, bottom + shiftY);
|
||||
const int fromCol = std::max(0, left + shiftX);
|
||||
const int toCol = std::min(width, right + shiftX);
|
||||
if (fromRow < toRow && fromCol < toCol) {
|
||||
parallelFor(static_cast<std::size_t>(toRow - fromRow), [&](std::size_t begin,
|
||||
std::size_t end) {
|
||||
for (std::size_t i = begin; i < end; ++i) {
|
||||
const int y = fromRow + static_cast<int>(i);
|
||||
const std::int32_t* in =
|
||||
mask.data() + static_cast<std::size_t>(y - shiftY - top) * maskWidth;
|
||||
std::uint8_t* out = canvas.row(y);
|
||||
for (int x = fromCol; x < toCol; ++x) {
|
||||
const int weight =
|
||||
std::clamp<int>(in[x - shiftX - left] * gain / 256, 0, 255) *
|
||||
haloMix / 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] + (colour[c] - px[c]) * weight / 255);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A shadow can reach the canvas from an image lying wholly beside it.
|
||||
if (firstRow >= lastRow || firstCol >= lastCol) return canvas;
|
||||
|
||||
parallelFor(static_cast<std::size_t>(lastRow - firstRow), [&](std::size_t begin,
|
||||
std::size_t end) {
|
||||
int rgb[3];
|
||||
int pixelAlpha = 255;
|
||||
for (std::size_t i = begin; i < end; ++i) {
|
||||
const int y = firstRow + static_cast<int>(i);
|
||||
const double v = (y - place.y + 0.5) * stepY - 0.5;
|
||||
std::uint8_t* out = canvas.row(y);
|
||||
for (int x = firstCol; x < lastCol; ++x) {
|
||||
const double u = (x - place.x + 0.5) * stepX - 0.5;
|
||||
sampleOverlay(overlay, alphaData, u, v, tiling, rgb, pixelAlpha);
|
||||
|
||||
const int weight = pixelAlpha * mix / 255;
|
||||
if (weight == 0) continue;
|
||||
std::uint8_t* px = out + static_cast<std::size_t>(x) * 3;
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
px[c] = static_cast<std::uint8_t>(px[c] + (rgb[c] - px[c]) * weight / 255);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,47 @@
|
||||
#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, with a
|
||||
// soft shadow or glow cut from its own outline beneath it if asked. 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, optionally with a shadow or "
|
||||
"a glow around it.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
bool needsSources() const override { return false; }
|
||||
|
||||
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const override;
|
||||
|
||||
private:
|
||||
// The same file is re-read on every param change otherwise, and a render is
|
||||
// a keystroke away in the interface. Renders can overlap, so it is guarded.
|
||||
// Returns the decoded file and its alpha, reading it only when the path
|
||||
// differs from the one already held.
|
||||
void loadOverlay(const std::string& path, Image& image,
|
||||
std::vector<std::uint8_t>& alpha) const;
|
||||
|
||||
mutable std::mutex cacheMutex_;
|
||||
mutable std::string cachedPath_;
|
||||
mutable Image cached_;
|
||||
mutable std::vector<std::uint8_t> cachedAlpha_;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,151 @@
|
||||
#include "effects/pixelsort.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& PixelSortEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeDouble("sort_low", "Lower threshold", 0.25, 0.0, 1.0,
|
||||
"Pixels darker than this break a run and stay where they are."),
|
||||
ParamSpec::makeDouble("sort_high", "Upper threshold", 0.8, 0.0, 1.0,
|
||||
"Pixels brighter than this break a run and stay where they are."),
|
||||
ParamSpec::makeChoice("sort_key", "Sort by", "luma", {"luma", "hue", "saturation"},
|
||||
"What orders the pixels inside a run."),
|
||||
ParamSpec::makeChoice("sort_direction", "Direction", "horizontal",
|
||||
{"horizontal", "vertical"}, "Whether rows or columns are sorted."),
|
||||
ParamSpec::makeBool("sort_reverse", "Reverse", false,
|
||||
"Sort from high to low instead of from low to high."),
|
||||
ParamSpec::makeDouble("sort_line_chance", "Line chance", 1.0, 0.0, 1.0,
|
||||
"Chance of each row (or column) being sorted at all."),
|
||||
ParamSpec::makeInt("sort_max_run", "Longest run", 0, 0, 8192,
|
||||
"Each run is cut after a random length up to this many pixels, which "
|
||||
"frays the edges of the sorted streaks (0 = no limit)."),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
int luma(const std::uint8_t* px) { return (px[0] * 54 + px[1] * 183 + px[2] * 19) >> 8; }
|
||||
|
||||
// Hue on a 0..1535 scale (six sectors of 256), in integers.
|
||||
int hue(const std::uint8_t* px) {
|
||||
const int r = px[0], g = px[1], b = px[2];
|
||||
const int hi = std::max({r, g, b});
|
||||
const int lo = std::min({r, g, b});
|
||||
const int range = hi - lo;
|
||||
if (range == 0) return 0;
|
||||
if (hi == r) return g >= b ? (g - b) * 255 / range : 1535 - (b - g) * 255 / range;
|
||||
if (hi == g) return 512 + (b - r) * 255 / range;
|
||||
return 1024 + (r - g) * 255 / range;
|
||||
}
|
||||
|
||||
int saturation(const std::uint8_t* px) {
|
||||
return std::max({px[0], px[1], px[2]}) - std::min({px[0], px[1], px[2]});
|
||||
}
|
||||
|
||||
// The image with rows and columns swapped, so vertical sorting is horizontal
|
||||
// sorting of the transpose.
|
||||
Image transposed(const Image& src) {
|
||||
Image out(src.height(), src.width());
|
||||
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) {
|
||||
std::uint8_t* row = out.row(static_cast<int>(x));
|
||||
for (int y = 0; y < src.height(); ++y) {
|
||||
std::memcpy(row + static_cast<std::size_t>(y) * 3, src.row(y) + x * 3, 3);
|
||||
}
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
struct LinePlan {
|
||||
bool sorted = false;
|
||||
std::uint64_t seed = 0; // for the run lengths, when they are limited
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Image PixelSortEffect::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 low = static_cast<int>(std::clamp(paramDouble(values, "sort_low", 0.25), 0.0, 1.0) * 255.0 + 0.5);
|
||||
const int high = static_cast<int>(std::clamp(paramDouble(values, "sort_high", 0.8), 0.0, 1.0) * 255.0 + 0.5);
|
||||
const std::string keyName = paramChoice(values, "sort_key", "luma");
|
||||
const bool vertical = paramChoice(values, "sort_direction", "horizontal") == "vertical";
|
||||
const bool reverse = paramBool(values, "sort_reverse", false);
|
||||
const double lineChance = std::clamp(paramDouble(values, "sort_line_chance", 1.0), 0.0, 1.0);
|
||||
const int maxRun = static_cast<int>(std::max(0L, paramInt(values, "sort_max_run", 0)));
|
||||
|
||||
Image canvas = canvasFromSources(sources, width, height, rng);
|
||||
if (vertical) canvas = transposed(canvas);
|
||||
const int lines = canvas.height();
|
||||
const int span = canvas.width();
|
||||
|
||||
// Which lines are sorted, and a seed for each line's run lengths, are drawn
|
||||
// serially; the lines then sort in parallel, each from its own generator.
|
||||
std::vector<LinePlan> plan(static_cast<std::size_t>(lines));
|
||||
for (auto& line : plan) {
|
||||
line.sorted = chance(rng.next(), lineChance);
|
||||
if (maxRun > 0) line.seed = rng.next();
|
||||
}
|
||||
|
||||
auto keyOf = [&](const std::uint8_t* px) {
|
||||
if (keyName == "hue") return hue(px);
|
||||
if (keyName == "saturation") return saturation(px);
|
||||
return luma(px);
|
||||
};
|
||||
|
||||
parallelFor(static_cast<std::size_t>(lines), [&](std::size_t begin, std::size_t end) {
|
||||
std::vector<std::pair<int, std::array<std::uint8_t, 3>>> run;
|
||||
for (std::size_t line = begin; line < end; ++line) {
|
||||
if (!plan[line].sorted) continue;
|
||||
Rng runRng(plan[line].seed);
|
||||
std::uint8_t* row = canvas.row(static_cast<int>(line));
|
||||
|
||||
auto flush = [&](int start) {
|
||||
// A stable sort, so equal keys keep their order on every
|
||||
// standard library.
|
||||
std::stable_sort(run.begin(), run.end(), [&](const auto& a, const auto& b) {
|
||||
return reverse ? a.first > b.first : a.first < b.first;
|
||||
});
|
||||
for (std::size_t i = 0; i < run.size(); ++i) {
|
||||
std::memcpy(row + (static_cast<std::size_t>(start) + i) * 3, run[i].second.data(), 3);
|
||||
}
|
||||
run.clear();
|
||||
};
|
||||
|
||||
int start = 0;
|
||||
int limit = maxRun > 0 ? static_cast<int>(runRng.inRange(1, maxRun)) : span;
|
||||
for (int x = 0; x < span; ++x) {
|
||||
const std::uint8_t* px = row + static_cast<std::size_t>(x) * 3;
|
||||
const int brightness = luma(px);
|
||||
if (brightness < low || brightness > high) {
|
||||
flush(start);
|
||||
start = x + 1;
|
||||
continue;
|
||||
}
|
||||
if (static_cast<int>(run.size()) >= limit) {
|
||||
flush(start);
|
||||
start = x;
|
||||
if (maxRun > 0) limit = static_cast<int>(runRng.inRange(1, maxRun));
|
||||
}
|
||||
run.push_back({keyOf(px), {px[0], px[1], px[2]}});
|
||||
}
|
||||
flush(start);
|
||||
}
|
||||
});
|
||||
|
||||
return vertical ? transposed(canvas) : canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// Finds runs of pixels whose brightness lies between two thresholds and sorts
|
||||
// each run by brightness, hue or saturation, which makes the image drip.
|
||||
// A pure transform: it needs no source images, and leading a chain with
|
||||
// sources loaded it starts from a crop of a randomly chosen one.
|
||||
class PixelSortEffect : public Effect {
|
||||
public:
|
||||
std::string name() const override { return "pixelsort"; }
|
||||
std::string title() const override { return "Pixel sort"; }
|
||||
std::string description() const override {
|
||||
return "Sorts the runs of pixels whose brightness lies between two thresholds, along "
|
||||
"rows or columns, so the image melts and drips.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
bool needsSources() const override { return false; }
|
||||
|
||||
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
#include "core/image.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// Reads the colour at a fractional position, mixing the four neighbouring
|
||||
// pixels; positions off the image take the nearest edge pixel. The weights are
|
||||
// cut to 1/256 steps so the mix itself is integer arithmetic.
|
||||
inline void sampleBilinear(const Image& image, double x, double y, std::uint8_t out[3]) {
|
||||
const int maxX = image.width() - 1;
|
||||
const int maxY = image.height() - 1;
|
||||
x = std::clamp(x, 0.0, static_cast<double>(maxX));
|
||||
y = std::clamp(y, 0.0, static_cast<double>(maxY));
|
||||
|
||||
const int x0 = static_cast<int>(x);
|
||||
const int y0 = static_cast<int>(y);
|
||||
const int x1 = std::min(x0 + 1, maxX);
|
||||
const int y1 = std::min(y0 + 1, maxY);
|
||||
const int fx = static_cast<int>((x - x0) * 256.0);
|
||||
const int fy = static_cast<int>((y - y0) * 256.0);
|
||||
|
||||
const std::uint8_t* a = image.row(y0) + static_cast<std::size_t>(x0) * 3;
|
||||
const std::uint8_t* b = image.row(y0) + static_cast<std::size_t>(x1) * 3;
|
||||
const std::uint8_t* c = image.row(y1) + static_cast<std::size_t>(x0) * 3;
|
||||
const std::uint8_t* d = image.row(y1) + static_cast<std::size_t>(x1) * 3;
|
||||
for (int ch = 0; ch < 3; ++ch) {
|
||||
const int top = a[ch] * (256 - fx) + b[ch] * fx;
|
||||
const int bottom = c[ch] * (256 - fx) + d[ch] * fx;
|
||||
out[ch] = static_cast<std::uint8_t>((top * (256 - fy) + bottom * fy + 32768) >> 16);
|
||||
}
|
||||
}
|
||||
|
||||
// Sine and cosine of an angle in degrees from nothing but additions and
|
||||
// multiplications, so a rotation lands on the same pixels whatever the
|
||||
// machine's libm makes of std::sin. The angle is folded into one octant and a
|
||||
// short Taylor series does the rest, far below a pixel's worth of error.
|
||||
inline void sinCosDegrees(double degrees, double& sine, double& cosine) {
|
||||
const double turns = degrees / 90.0;
|
||||
const double quadrant = std::floor(turns + 0.5);
|
||||
const double x = (turns - quadrant) * 1.5707963267948966; // [-pi/4, pi/4]
|
||||
const double x2 = x * x;
|
||||
const double s =
|
||||
x * (1.0 - x2 / 6.0 * (1.0 - x2 / 20.0 * (1.0 - x2 / 42.0 * (1.0 - x2 / 72.0))));
|
||||
const double c =
|
||||
1.0 - x2 / 2.0 * (1.0 - x2 / 12.0 * (1.0 - x2 / 30.0 * (1.0 - x2 / 56.0 * (1.0 - x2 / 90.0))));
|
||||
switch (static_cast<int>(quadrant - 4.0 * std::floor(quadrant / 4.0))) {
|
||||
case 0: sine = s; cosine = c; break;
|
||||
case 1: sine = c; cosine = -s; break;
|
||||
case 2: sine = -s; cosine = -c; break;
|
||||
default: sine = -c; cosine = s; break;
|
||||
}
|
||||
}
|
||||
|
||||
// A uniform double in [0, 1) from the top 53 bits of one rng draw.
|
||||
inline double unitDouble(std::uint64_t bits) {
|
||||
return static_cast<double>(bits >> 11) * (1.0 / 9007199254740992.0);
|
||||
}
|
||||
|
||||
// Whether an event with the given probability happens, from one rng draw.
|
||||
inline bool chance(std::uint64_t bits, double probability) {
|
||||
return unitDouble(bits) < probability;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,178 @@
|
||||
#include "effects/shatter.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& ShatterEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeInt("shatter_cells", "Shards", 40, 2, 5000,
|
||||
"How many shards the image breaks into."),
|
||||
ParamSpec::makeInt("shatter_displace", "Displacement", 40, 0, 4096,
|
||||
"Furthest in pixels a shard's picture may slide, along either axis."),
|
||||
ParamSpec::makeDouble("shatter_rotate", "Rotation", 8.0, 0.0, 180.0,
|
||||
"Largest turn in degrees of a shard's picture, either way.", 0.5, 1),
|
||||
ParamSpec::makeDouble("shatter_zoom", "Zoom", 0.05, 0.0, 0.9,
|
||||
"Largest zoom in or out of a shard's picture, as a fraction.", 0.01),
|
||||
ParamSpec::makeInt("shatter_crack_width", "Crack width", 2, 0, 64,
|
||||
"Width in pixels of the cracks between the shards (0 = none)."),
|
||||
ParamSpec::makeColor("shatter_crack_colour", "Crack colour", "#101010",
|
||||
"Colour the cracks are drawn in."),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
struct Shard {
|
||||
double x = 0.0; // the point the shard grows around
|
||||
double y = 0.0;
|
||||
double dx = 0.0;
|
||||
double dy = 0.0;
|
||||
double sine = 0.0;
|
||||
double cosine = 1.0;
|
||||
double zoom = 1.0;
|
||||
};
|
||||
|
||||
// The shard points bucketed on a coarse grid, so finding the two nearest to a
|
||||
// pixel looks at a few buckets instead of every point.
|
||||
class ShardGrid {
|
||||
public:
|
||||
ShardGrid(const std::vector<Shard>& shards, int width, int height) : shards_(shards) {
|
||||
cell_ = std::max(1.0, std::sqrt(static_cast<double>(width) * height /
|
||||
static_cast<double>(shards.size())));
|
||||
cols_ = static_cast<int>(width / cell_) + 1;
|
||||
rows_ = static_cast<int>(height / cell_) + 1;
|
||||
buckets_.resize(static_cast<std::size_t>(cols_) * rows_);
|
||||
for (std::size_t i = 0; i < shards.size(); ++i) {
|
||||
buckets_[bucketOf(shards[i].x, shards[i].y)].push_back(static_cast<int>(i));
|
||||
}
|
||||
}
|
||||
|
||||
// The nearest and second nearest shard to (x, y).
|
||||
void nearest(double x, double y, int& first, int& second) const {
|
||||
const int bx = std::clamp(static_cast<int>(x / cell_), 0, cols_ - 1);
|
||||
const int by = std::clamp(static_cast<int>(y / cell_), 0, rows_ - 1);
|
||||
double best = std::numeric_limits<double>::max();
|
||||
double next = best;
|
||||
first = second = -1;
|
||||
const int maxRing = std::max(cols_, rows_);
|
||||
for (int ring = 0; ring <= maxRing; ++ring) {
|
||||
// Anything in this ring is at least (ring - 1) cells away, so once
|
||||
// both answers are closer than that the search is over.
|
||||
const double reach = std::max(0, ring - 1) * cell_;
|
||||
if (second >= 0 && next < reach * reach) break;
|
||||
for (int gy = by - ring; gy <= by + ring; ++gy) {
|
||||
if (gy < 0 || gy >= rows_) continue;
|
||||
const bool edgeRow = gy == by - ring || gy == by + ring;
|
||||
for (int gx = bx - ring; gx <= bx + ring; gx += edgeRow ? 1 : 2 * std::max(ring, 1)) {
|
||||
if (gx < 0 || gx >= cols_) continue;
|
||||
for (int index : buckets_[static_cast<std::size_t>(gy) * cols_ + gx]) {
|
||||
const Shard& shard = shards_[static_cast<std::size_t>(index)];
|
||||
const double d = (shard.x - x) * (shard.x - x) + (shard.y - y) * (shard.y - y);
|
||||
if (d < best || (d == best && index < first)) {
|
||||
next = best;
|
||||
second = first;
|
||||
best = d;
|
||||
first = index;
|
||||
} else if (d < next || (d == next && index < second)) {
|
||||
next = d;
|
||||
second = index;
|
||||
}
|
||||
}
|
||||
if (ring == 0) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
std::size_t bucketOf(double x, double y) const {
|
||||
const int bx = std::clamp(static_cast<int>(x / cell_), 0, cols_ - 1);
|
||||
const int by = std::clamp(static_cast<int>(y / cell_), 0, rows_ - 1);
|
||||
return static_cast<std::size_t>(by) * cols_ + bx;
|
||||
}
|
||||
|
||||
const std::vector<Shard>& shards_;
|
||||
double cell_ = 1.0;
|
||||
int cols_ = 1;
|
||||
int rows_ = 1;
|
||||
std::vector<std::vector<int>> buckets_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Image ShatterEffect::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 long count = std::max(2L, paramInt(values, "shatter_cells", 40));
|
||||
const int displace = static_cast<int>(std::max(0L, paramInt(values, "shatter_displace", 40)));
|
||||
const double rotate = std::clamp(paramDouble(values, "shatter_rotate", 8.0), 0.0, 180.0);
|
||||
const double zoom = std::clamp(paramDouble(values, "shatter_zoom", 0.05), 0.0, 0.9);
|
||||
const double crackWidth = static_cast<double>(std::max(0L, paramInt(values, "shatter_crack_width", 2)));
|
||||
std::uint8_t crack[3];
|
||||
paramColor(values, "shatter_crack_colour", "#101010", crack);
|
||||
|
||||
const Image input = canvasFromSources(sources, width, height, rng);
|
||||
|
||||
std::vector<Shard> shards(static_cast<std::size_t>(count));
|
||||
for (auto& shard : shards) {
|
||||
shard.x = unitDouble(rng.next()) * width;
|
||||
shard.y = unitDouble(rng.next()) * height;
|
||||
shard.dx = static_cast<double>(rng.inRange(-displace, displace));
|
||||
shard.dy = static_cast<double>(rng.inRange(-displace, displace));
|
||||
sinCosDegrees((unitDouble(rng.next()) * 2.0 - 1.0) * rotate, shard.sine, shard.cosine);
|
||||
shard.zoom = 1.0 + (unitDouble(rng.next()) * 2.0 - 1.0) * zoom;
|
||||
}
|
||||
const ShardGrid grid(shards, width, height);
|
||||
|
||||
Image canvas(width, height);
|
||||
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t row = begin; row < end; ++row) {
|
||||
std::uint8_t* out = canvas.row(static_cast<int>(row));
|
||||
const double y = static_cast<double>(row) + 0.5;
|
||||
for (int col = 0; col < width; ++col) {
|
||||
std::uint8_t* px = out + static_cast<std::size_t>(col) * 3;
|
||||
const double x = col + 0.5;
|
||||
int first = 0;
|
||||
int second = 0;
|
||||
grid.nearest(x, y, first, second);
|
||||
const Shard& a = shards[static_cast<std::size_t>(first)];
|
||||
|
||||
if (crackWidth > 0.0 && second >= 0) {
|
||||
// Distance to the edge between the two nearest shards,
|
||||
// which is the perpendicular bisector of their points.
|
||||
const Shard& b = shards[static_cast<std::size_t>(second)];
|
||||
const double da = (x - a.x) * (x - a.x) + (y - a.y) * (y - a.y);
|
||||
const double db = (x - b.x) * (x - b.x) + (y - b.y) * (y - b.y);
|
||||
const double gap = std::sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y));
|
||||
if (gap > 0.0 && (db - da) / (2.0 * gap) < crackWidth * 0.5) {
|
||||
px[0] = crack[0];
|
||||
px[1] = crack[1];
|
||||
px[2] = crack[2];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Undo the shard's slide, then its turn and zoom about its point.
|
||||
const double rx = x - a.dx - a.x;
|
||||
const double ry = y - a.dy - a.y;
|
||||
const double sx = a.x + (rx * a.cosine + ry * a.sine) / a.zoom;
|
||||
const double sy = a.y + (-rx * a.sine + ry * a.cosine) / a.zoom;
|
||||
sampleBilinear(input, sx - 0.5, sy - 0.5, px);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// Breaks its input into random glass shards (the cells around scattered points),
|
||||
// each showing the image shifted, turned and zoomed a little on its own, with
|
||||
// optional cracks drawn along the edges.
|
||||
// A pure transform: it needs no source images, and leading a chain with
|
||||
// sources loaded it starts from a crop of a randomly chosen one.
|
||||
class ShatterEffect : public Effect {
|
||||
public:
|
||||
std::string name() const override { return "shatter"; }
|
||||
std::string title() const override { return "Shatter"; }
|
||||
std::string description() const override {
|
||||
return "Breaks the image into random shards, each shifted, turned and zoomed on its own, "
|
||||
"with optional cracks between them.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
bool needsSources() const override { return false; }
|
||||
|
||||
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,99 @@
|
||||
#include "effects/slitshift.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& SlitShiftEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeInt("shift_bands", "Bands", 24, 1, 4096,
|
||||
"How many bands are torn out of place."),
|
||||
ParamSpec::makeInt("shift_max", "Largest shift", 120, 0, 8192,
|
||||
"Furthest in pixels a band may be pushed, either way."),
|
||||
ParamSpec::makeInt("shift_band_height", "Band height", 16, 1, 2048,
|
||||
"Thickness in pixels of each band."),
|
||||
ParamSpec::makeInt("shift_height_jitter", "Height jitter", 12, 0, 2048,
|
||||
"Random +/- variation added to the band thickness (0 = uniform)."),
|
||||
ParamSpec::makeChoice("shift_edge", "Edges", "wrap", {"wrap", "smear", "black"},
|
||||
"What fills the gap a band leaves: wrap brings in what was pushed "
|
||||
"off the other side, smear stretches the last pixel, black leaves "
|
||||
"it empty."),
|
||||
ParamSpec::makeChoice("shift_axis", "Axis", "horizontal", {"horizontal", "vertical"},
|
||||
"horizontal tears bands of rows sideways, vertical tears bands of "
|
||||
"columns up and down."),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Where the pixel at `index` of a line of `count` is read from once the line is
|
||||
// pushed by `offset`, or -1 for an empty (black) pixel.
|
||||
int shiftedIndex(int index, int offset, int count, char edge) {
|
||||
const int from = index - offset;
|
||||
if (from >= 0 && from < count) return from;
|
||||
if (edge == 'w') return ((from % count) + count) % count;
|
||||
if (edge == 's') return std::clamp(from, 0, count - 1);
|
||||
return -1;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Image SlitShiftEffect::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 long bands = std::max(1L, paramInt(values, "shift_bands", 24));
|
||||
const int maxShift = static_cast<int>(std::max(0L, paramInt(values, "shift_max", 120)));
|
||||
const int bandHeight = static_cast<int>(std::max(1L, paramInt(values, "shift_band_height", 16)));
|
||||
const int jitter = static_cast<int>(std::max(0L, paramInt(values, "shift_height_jitter", 12)));
|
||||
const char edge = paramChoice(values, "shift_edge", "wrap").front();
|
||||
const bool vertical = paramChoice(values, "shift_axis", "horizontal") == "vertical";
|
||||
|
||||
const Image input = canvasFromSources(sources, width, height, rng);
|
||||
|
||||
// Every band is drawn up front, and overlapping bands simply add their
|
||||
// pushes, so what the pixels need is one offset per line.
|
||||
const int lines = vertical ? width : height;
|
||||
const int span = vertical ? height : width;
|
||||
std::vector<int> offsets(static_cast<std::size_t>(lines), 0);
|
||||
for (long band = 0; band < bands; ++band) {
|
||||
int thickness = bandHeight;
|
||||
if (jitter > 0) thickness += static_cast<int>(rng.inRange(-jitter, jitter));
|
||||
thickness = std::clamp(thickness, 1, lines);
|
||||
const int start = static_cast<int>(rng.inRange(0, lines - thickness));
|
||||
const int offset = static_cast<int>(rng.inRange(-maxShift, maxShift));
|
||||
for (int line = start; line < start + thickness; ++line) {
|
||||
offsets[static_cast<std::size_t>(line)] += offset;
|
||||
}
|
||||
}
|
||||
|
||||
Image canvas(width, height);
|
||||
parallelFor(static_cast<std::size_t>(lines), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t line = begin; line < end; ++line) {
|
||||
const int offset = offsets[line];
|
||||
for (int i = 0; i < span; ++i) {
|
||||
const int from = shiftedIndex(i, offset, span, edge);
|
||||
const int x = vertical ? static_cast<int>(line) : i;
|
||||
const int y = vertical ? i : static_cast<int>(line);
|
||||
std::uint8_t* out = canvas.row(y) + static_cast<std::size_t>(x) * 3;
|
||||
if (from < 0) {
|
||||
out[0] = out[1] = out[2] = 0;
|
||||
continue;
|
||||
}
|
||||
const int fx = vertical ? x : from;
|
||||
const int fy = vertical ? from : y;
|
||||
std::memcpy(out, input.row(fy) + static_cast<std::size_t>(fx) * 3, 3);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// Tears its input apart: random bands of rows (or columns) are pushed sideways by a
|
||||
// random distance each, the way a video signal loses its sync.
|
||||
// A pure transform: it needs no source images, and leading a chain with
|
||||
// sources loaded it starts from a crop of a randomly chosen one.
|
||||
class SlitShiftEffect : public Effect {
|
||||
public:
|
||||
std::string name() const override { return "slitshift"; }
|
||||
std::string title() const override { return "Slit shift"; }
|
||||
std::string description() const override {
|
||||
return "Tears random bands of its input sideways, like a video signal losing sync; "
|
||||
"overlapping bands add their shifts up.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
bool needsSources() const override { return false; }
|
||||
|
||||
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "core/image.hpp"
|
||||
#include "core/parallel.hpp"
|
||||
#include "core/rng.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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The canvas a pure transform starts from: a crop of a randomly chosen source,
|
||||
// or black when there is none. Inside a chain the only source is the stage
|
||||
// before, which has the canvas size, so no random value is drawn at all; only a
|
||||
// transform leading a chain with photographs loaded spends any of the rng here.
|
||||
inline Image canvasFromSources(const std::vector<Image>& sources, int width, int height, Rng& rng) {
|
||||
Image canvas(width, height);
|
||||
if (!sources.empty()) {
|
||||
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);
|
||||
}
|
||||
return canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,348 @@
|
||||
#include "effects/text.hpp"
|
||||
|
||||
#include <pango/pangocairo.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <numbers>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& TextEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeText("text", "Text", "Effector",
|
||||
"What to write; a line break starts a new line."),
|
||||
ParamSpec::makeText("font_family", "Font", "Sans",
|
||||
"Font family, as it is named on this machine: Sans, Serif, Monospace, "
|
||||
"\"DejaVu Serif\", ...")
|
||||
.styled(),
|
||||
ParamSpec::makeChoice("font_weight", "Weight", "normal",
|
||||
{"thin", "light", "normal", "medium", "semibold", "bold", "heavy"},
|
||||
"How bold the glyphs are; a family without the chosen weight is "
|
||||
"rendered in the nearest one it has.")
|
||||
.styled(),
|
||||
ParamSpec::makeBool("font_italic", "Italic", false, "Slants the glyphs.").styled(),
|
||||
ParamSpec::makeDouble("font_size", "Size", 96.0, 4.0, 2000.0,
|
||||
"Height of the glyphs in pixels.", 1.0, 1)
|
||||
.styled(),
|
||||
ParamSpec::makeColor("text_color", "Colour", "#ffffff", "The colour of the glyphs.").styled(),
|
||||
ParamSpec::makeChoice("text_align", "Alignment", "center", {"left", "center", "right"},
|
||||
"How several lines line up against each other.")
|
||||
.styled(),
|
||||
ParamSpec::makeInt("text_wrap", "Wrap width", 0, 0, 20000,
|
||||
"Breaks the text into lines no wider than this many pixels; 0 writes "
|
||||
"it as given."),
|
||||
ParamSpec::makeChoice("text_box", "Box", "none", {"none", "colour", "image"},
|
||||
"What the text sits on: nothing, a filled rectangle, or a picture "
|
||||
"from disk cropped to the rectangle.")
|
||||
.styled(),
|
||||
ParamSpec::makeColor("box_color", "Box colour", "#000000",
|
||||
"The colour the box is filled with.")
|
||||
.styled(),
|
||||
ParamSpec::makePath("box_image", "Box picture",
|
||||
"The picture the box is filled with, scaled to cover it."),
|
||||
ParamSpec::makeInt("box_padding", "Box padding", 24, 0, 2000,
|
||||
"How far the box reaches past the text on every side.")
|
||||
.styled(),
|
||||
ParamSpec::makeDouble("box_opacity", "Box opacity", 1.0, 0.0, 1.0,
|
||||
"How strongly the box is mixed over the input.")
|
||||
.styled(),
|
||||
ParamSpec::makeBool("text_center", "Centre", true,
|
||||
"Places the text in the middle of the canvas rather than at its top "
|
||||
"left corner; the offset is counted from there either way."),
|
||||
ParamSpec::makeInt("text_offset_x", "Offset X", 0, -20000, 20000,
|
||||
"Moves the text right by this many pixels (left when negative)."),
|
||||
ParamSpec::makeInt("text_offset_y", "Offset Y", 0, -20000, 20000,
|
||||
"Moves the text down by this many pixels (up when negative)."),
|
||||
ParamSpec::makeDouble("text_rotation", "Rotation", 0.0, -360.0, 360.0,
|
||||
"Turns the text and its box about their centre by this many "
|
||||
"degrees, clockwise (anticlockwise when negative).",
|
||||
1.0, 1),
|
||||
ParamSpec::makeDouble("text_opacity", "Text opacity", 1.0, 0.0, 1.0,
|
||||
"How strongly the glyphs are mixed over what is behind them.")
|
||||
.styled(),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Small holders so that an EffectError thrown half way through still frees the
|
||||
// Cairo and Pango objects.
|
||||
struct SurfaceHandle {
|
||||
cairo_surface_t* get = nullptr;
|
||||
~SurfaceHandle() {
|
||||
if (get != nullptr) cairo_surface_destroy(get);
|
||||
}
|
||||
};
|
||||
|
||||
struct ContextHandle {
|
||||
cairo_t* get = nullptr;
|
||||
~ContextHandle() {
|
||||
if (get != nullptr) cairo_destroy(get);
|
||||
}
|
||||
};
|
||||
|
||||
struct LayoutHandle {
|
||||
PangoLayout* get = nullptr;
|
||||
~LayoutHandle() {
|
||||
if (get != nullptr) g_object_unref(get);
|
||||
}
|
||||
};
|
||||
|
||||
struct FontHandle {
|
||||
PangoFontDescription* get = nullptr;
|
||||
~FontHandle() {
|
||||
if (get != nullptr) pango_font_description_free(get);
|
||||
}
|
||||
};
|
||||
|
||||
PangoWeight weightFor(const std::string& name) {
|
||||
if (name == "thin") return PANGO_WEIGHT_THIN;
|
||||
if (name == "light") return PANGO_WEIGHT_LIGHT;
|
||||
if (name == "medium") return PANGO_WEIGHT_MEDIUM;
|
||||
if (name == "semibold") return PANGO_WEIGHT_SEMIBOLD;
|
||||
if (name == "bold") return PANGO_WEIGHT_BOLD;
|
||||
if (name == "heavy") return PANGO_WEIGHT_HEAVY;
|
||||
return PANGO_WEIGHT_NORMAL;
|
||||
}
|
||||
|
||||
PangoAlignment alignmentFor(const std::string& name) {
|
||||
if (name == "left") return PANGO_ALIGN_LEFT;
|
||||
if (name == "right") return PANGO_ALIGN_RIGHT;
|
||||
return PANGO_ALIGN_CENTER;
|
||||
}
|
||||
|
||||
// An RGB image plus its alpha as a Cairo ARGB32 surface, premultiplied, which
|
||||
// is the only layout Cairo paints from.
|
||||
cairo_surface_t* toSurface(const Image& image, const std::vector<std::uint8_t>& alpha) {
|
||||
cairo_surface_t* surface =
|
||||
cairo_image_surface_create(CAIRO_FORMAT_ARGB32, image.width(), image.height());
|
||||
if (cairo_surface_status(surface) != CAIRO_STATUS_SUCCESS) {
|
||||
cairo_surface_destroy(surface);
|
||||
throw EffectError("Cannot hold the box picture in memory.");
|
||||
}
|
||||
const int stride = cairo_image_surface_get_stride(surface);
|
||||
unsigned char* data = cairo_image_surface_get_data(surface);
|
||||
const bool hasAlpha = alpha.size() == static_cast<std::size_t>(image.width()) *
|
||||
static_cast<std::size_t>(image.height());
|
||||
|
||||
for (int y = 0; y < image.height(); ++y) {
|
||||
const std::uint8_t* in = image.row(y);
|
||||
auto* out = reinterpret_cast<std::uint32_t*>(data + static_cast<std::size_t>(y) * stride);
|
||||
for (int x = 0; x < image.width(); ++x) {
|
||||
const std::uint32_t a =
|
||||
hasAlpha ? alpha[static_cast<std::size_t>(y) * image.width() + x] : 255;
|
||||
const std::uint32_t r = in[x * 3 + 0] * a / 255;
|
||||
const std::uint32_t g = in[x * 3 + 1] * a / 255;
|
||||
const std::uint32_t b = in[x * 3 + 2] * a / 255;
|
||||
out[x] = (a << 24) | (r << 16) | (g << 8) | b;
|
||||
}
|
||||
}
|
||||
cairo_surface_mark_dirty(surface);
|
||||
return surface;
|
||||
}
|
||||
|
||||
void setColor(cairo_t* cr, const std::uint8_t rgb[3], double opacity) {
|
||||
cairo_set_source_rgba(cr, rgb[0] / 255.0, rgb[1] / 255.0, rgb[2] / 255.0, opacity);
|
||||
}
|
||||
|
||||
// Mixes the drawn layer, ARGB32 and premultiplied, over the canvas.
|
||||
void composite(Image& canvas, const cairo_surface_t* surface) {
|
||||
const int stride = cairo_image_surface_get_stride(const_cast<cairo_surface_t*>(surface));
|
||||
const unsigned char* data =
|
||||
cairo_image_surface_get_data(const_cast<cairo_surface_t*>(surface));
|
||||
const int width = canvas.width();
|
||||
|
||||
parallelFor(static_cast<std::size_t>(canvas.height()), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t y = begin; y < end; ++y) {
|
||||
const auto* in =
|
||||
reinterpret_cast<const std::uint32_t*>(data + static_cast<std::size_t>(y) * stride);
|
||||
std::uint8_t* out = canvas.row(static_cast<int>(y));
|
||||
for (int x = 0; x < width; ++x) {
|
||||
const std::uint32_t pixel = in[x];
|
||||
const std::uint32_t a = pixel >> 24;
|
||||
if (a == 0) continue;
|
||||
// Premultiplied: the colour is already scaled by alpha, so the
|
||||
// mix is an addition once the background is scaled down.
|
||||
const std::uint32_t channel[3] = {(pixel >> 16) & 0xFF, (pixel >> 8) & 0xFF,
|
||||
pixel & 0xFF};
|
||||
std::uint8_t* px = out + static_cast<std::size_t>(x) * 3;
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
const std::uint32_t value = channel[c] + px[c] * (255 - a) / 255;
|
||||
px[c] = static_cast<std::uint8_t>(std::min<std::uint32_t>(value, 255));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void TextEffect::loadBoxImage(const std::string& path, Image& image,
|
||||
std::vector<std::uint8_t>& alpha) const {
|
||||
std::lock_guard<std::mutex> lock(cacheMutex_);
|
||||
if (cachedPath_ != path || cached_.empty()) {
|
||||
std::vector<std::uint8_t> loadedAlpha;
|
||||
Image loaded;
|
||||
try {
|
||||
loaded = Image::load(path, &loadedAlpha);
|
||||
} catch (const std::exception& error) {
|
||||
throw EffectError("Cannot read the box picture '" + path + "': " + error.what());
|
||||
}
|
||||
if (loaded.empty()) throw EffectError("The box picture '" + path + "' is empty.");
|
||||
cached_ = std::move(loaded);
|
||||
cachedAlpha_ = std::move(loadedAlpha);
|
||||
cachedPath_ = path;
|
||||
}
|
||||
image = cached_;
|
||||
alpha = cachedAlpha_;
|
||||
}
|
||||
|
||||
std::string TextEffect::label(const ParamValues& values) const {
|
||||
// Several lines read as one: each run of whitespace becomes a single space.
|
||||
std::string words;
|
||||
bool gap = false;
|
||||
for (const char c : paramText(values, "text", "")) {
|
||||
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
|
||||
gap = !words.empty();
|
||||
continue;
|
||||
}
|
||||
if (gap) words += ' ';
|
||||
gap = false;
|
||||
words += c;
|
||||
}
|
||||
return words.empty() ? title() : title() + " (" + words + ")";
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
// The turn is about the middle of the box, so it never moves the text away
|
||||
// from where the centring and the offset put it. Cairo's y points down,
|
||||
// which makes a positive angle clockwise on screen.
|
||||
const double rotation = paramDouble(values, "text_rotation", 0.0);
|
||||
if (rotation != 0.0) {
|
||||
const double cx = boxX + boxWidth / 2.0;
|
||||
const double cy = boxY + boxHeight / 2.0;
|
||||
cairo_translate(cr.get, cx, cy);
|
||||
cairo_rotate(cr.get, rotation * std::numbers::pi / 180.0);
|
||||
cairo_translate(cr.get, -cx, -cy);
|
||||
}
|
||||
|
||||
if (drawsBox) {
|
||||
cairo_save(cr.get);
|
||||
cairo_rectangle(cr.get, boxX, boxY, boxWidth, boxHeight);
|
||||
cairo_clip(cr.get);
|
||||
if (boxKind == "image") {
|
||||
const std::string path = paramText(values, "box_image", "");
|
||||
if (!path.empty()) {
|
||||
Image picture;
|
||||
std::vector<std::uint8_t> alpha;
|
||||
loadBoxImage(path, picture, alpha);
|
||||
SurfaceHandle fill;
|
||||
fill.get = toSurface(picture, alpha);
|
||||
// Scaled to cover the box, keeping its proportions, exactly as
|
||||
// the overlay's "fill" does; what does not fit is clipped away.
|
||||
const double factor = std::max(static_cast<double>(boxWidth) / picture.width(),
|
||||
static_cast<double>(boxHeight) / picture.height());
|
||||
cairo_translate(cr.get, boxX + (boxWidth - picture.width() * factor) / 2.0,
|
||||
boxY + (boxHeight - picture.height() * factor) / 2.0);
|
||||
cairo_scale(cr.get, factor, factor);
|
||||
cairo_set_source_surface(cr.get, fill.get, 0.0, 0.0);
|
||||
cairo_pattern_set_filter(cairo_get_source(cr.get), CAIRO_FILTER_BILINEAR);
|
||||
cairo_paint_with_alpha(cr.get, boxOpacity);
|
||||
}
|
||||
} else {
|
||||
std::uint8_t rgb[3];
|
||||
paramColor(values, "box_color", "#000000", rgb);
|
||||
setColor(cr.get, rgb, boxOpacity);
|
||||
cairo_paint(cr.get);
|
||||
}
|
||||
cairo_restore(cr.get);
|
||||
}
|
||||
|
||||
if (drawsText) {
|
||||
std::uint8_t rgb[3];
|
||||
paramColor(values, "text_color", "#ffffff", rgb);
|
||||
setColor(cr.get, rgb, textOpacity);
|
||||
// logical.x/y are where the layout actually starts, which a wrapped or
|
||||
// right-aligned layout shifts; subtracting them puts the glyphs inside
|
||||
// the padding on every side.
|
||||
cairo_move_to(cr.get, boxX + padding - logical.x, boxY + padding - logical.y);
|
||||
pango_cairo_show_layout(cr.get, layout.get);
|
||||
}
|
||||
|
||||
cairo_surface_flush(surface.get);
|
||||
composite(canvas, surface.get);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// Writes a line (or several) over its input: a title, a caption, a watermark.
|
||||
// The family and the weight are chosen freely, and the text can sit on a box
|
||||
// filled with a colour or with a picture from disk. Like the blur and the
|
||||
// overlay it needs no source images of its own, so it can lead a chain; given
|
||||
// sources it fills the canvas from a randomly chosen one first.
|
||||
//
|
||||
// The glyphs come from Pango, so the exact pixels depend on the fonts the
|
||||
// machine has installed: a seed reproduces everything this effect draws, but a
|
||||
// machine without the chosen family renders the substitute its fontconfig
|
||||
// picks.
|
||||
class TextEffect : public Effect {
|
||||
public:
|
||||
std::string name() const override { return "text"; }
|
||||
std::string title() const override { return "Text"; }
|
||||
std::string description() const override {
|
||||
return "Writes text over its input in a chosen family, weight and size, optionally on a "
|
||||
"box filled with a colour or with a picture.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
std::string styleKind() const override { return "text"; }
|
||||
bool needsSources() const override { return false; }
|
||||
// "Text (Hello world)", so stages writing different words tell apart.
|
||||
std::string label(const ParamValues& values) const override;
|
||||
|
||||
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const override;
|
||||
|
||||
private:
|
||||
// The box picture is re-read on every param change otherwise, and a render
|
||||
// is a keystroke away in the interface. Renders can overlap, so it is
|
||||
// guarded.
|
||||
void loadBoxImage(const std::string& path, Image& image,
|
||||
std::vector<std::uint8_t>& alpha) const;
|
||||
|
||||
mutable std::mutex cacheMutex_;
|
||||
mutable std::string cachedPath_;
|
||||
mutable Image cached_;
|
||||
mutable std::vector<std::uint8_t> cachedAlpha_;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,180 @@
|
||||
#include "effects/tiles.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& TilesEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeChoice("tile_layout", "Layout", "grid", {"grid", "brick", "scatter"},
|
||||
"grid lines the tiles up in rows and columns; brick packs every row "
|
||||
"on its own so the seams do not meet; scatter drops loose tiles over "
|
||||
"a filled background, overlapping."),
|
||||
ParamSpec::makeInt("tile_width", "Tile width", 96, 1, 2048,
|
||||
"Width in pixels of each cut-out tile."),
|
||||
ParamSpec::makeInt("tile_height", "Tile height", 96, 1, 2048,
|
||||
"Height in pixels of each cut-out tile."),
|
||||
ParamSpec::makeInt("tile_jitter", "Size jitter", 0, 0, 512,
|
||||
"Random +/- variation added to both tile sizes (0 = uniform)."),
|
||||
ParamSpec::makeInt("tile_count", "Tile count", 64, 1, 4096,
|
||||
"How many tiles are dropped on the canvas; scattered layout only."),
|
||||
ParamSpec::makeBool("tile_keep_order", "Cycle images in order", false,
|
||||
"Take tiles from the images in order instead of picking randomly."),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// One cut-out: `w` by `h` pixels taken from source `sourceIndex` at (sx, sy)
|
||||
// and laid down at (dstX, dstY).
|
||||
struct Tile {
|
||||
int dstX;
|
||||
int dstY;
|
||||
int w;
|
||||
int h;
|
||||
int sourceIndex;
|
||||
int sx;
|
||||
int sy;
|
||||
};
|
||||
|
||||
// Splits `span` into consecutive pieces of `base` pixels give or take `jitter`,
|
||||
// the last one clipped so that the pieces cover the span exactly.
|
||||
std::vector<int> planSpans(Rng& rng, int base, int jitter, int span) {
|
||||
std::vector<int> spans;
|
||||
spans.reserve(static_cast<std::size_t>(span / base) + 8);
|
||||
int used = 0;
|
||||
while (used < span) {
|
||||
int size = base;
|
||||
if (jitter > 0) size += static_cast<int>(rng.inRange(-jitter, jitter));
|
||||
size = std::max(1, std::min(size, span - used));
|
||||
spans.push_back(size);
|
||||
used += size;
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
void paintTile(Image& canvas, const Image& src, const Tile& tile) {
|
||||
const int copyWidth = std::min(tile.w, src.width() - tile.sx);
|
||||
const int copyHeight = std::min(tile.h, src.height() - tile.sy);
|
||||
const std::size_t copyBytes = static_cast<std::size_t>(copyWidth) * 3;
|
||||
const std::size_t total = static_cast<std::size_t>(tile.w) * 3;
|
||||
const std::size_t offset = static_cast<std::size_t>(tile.dstX) * 3;
|
||||
|
||||
for (int line = 0; line < copyHeight; ++line) {
|
||||
const std::uint8_t* in = src.row(tile.sy + line) + static_cast<std::size_t>(tile.sx) * 3;
|
||||
std::uint8_t* out = canvas.row(tile.dstY + line) + offset;
|
||||
|
||||
std::memcpy(out, in, copyBytes);
|
||||
// Repeat the cut sideways when the source is narrower than the tile,
|
||||
// doubling the filled span on each pass.
|
||||
std::size_t filled = copyBytes;
|
||||
while (filled < total) {
|
||||
const std::size_t chunk = std::min(filled, total - filled);
|
||||
std::memcpy(out + filled, out, chunk);
|
||||
filled += chunk;
|
||||
}
|
||||
}
|
||||
// Same again downwards, out of the lines already written.
|
||||
for (int line = copyHeight; line < tile.h; ++line) {
|
||||
std::memcpy(canvas.row(tile.dstY + line) + offset,
|
||||
canvas.row(tile.dstY + (line % copyHeight)) + offset, total);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Image TilesEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const {
|
||||
if (sources.empty()) throw EffectError("No source images.");
|
||||
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
|
||||
|
||||
const std::string layout = paramChoice(values, "tile_layout", "grid");
|
||||
const int tileWidth = static_cast<int>(std::max(1L, paramInt(values, "tile_width", 96)));
|
||||
const int tileHeight = static_cast<int>(std::max(1L, paramInt(values, "tile_height", 96)));
|
||||
const int jitter = static_cast<int>(std::max(0L, paramInt(values, "tile_jitter", 0)));
|
||||
const long count = std::max(1L, paramInt(values, "tile_count", 64));
|
||||
const bool keepOrder = paramBool(values, "tile_keep_order", false);
|
||||
|
||||
Image canvas(width, height);
|
||||
|
||||
// The whole plan is drawn sequentially so that a seed always gives the same
|
||||
// layout; only the pixel copying afterwards is parallelised.
|
||||
std::size_t index = 0;
|
||||
auto cut = [&](int dstX, int dstY, int w, int h) {
|
||||
const int sourceIndex = keepOrder ? static_cast<int>(index++ % sources.size())
|
||||
: static_cast<int>(rng.below(sources.size() - 1));
|
||||
const Image& src = sources[static_cast<std::size_t>(sourceIndex)];
|
||||
const int sx = static_cast<int>(rng.inRange(0, std::max(0, src.width() - w)));
|
||||
const int sy = static_cast<int>(rng.inRange(0, std::max(0, src.height() - h)));
|
||||
return Tile{dstX, dstY, w, h, sourceIndex, sx, sy};
|
||||
};
|
||||
|
||||
std::vector<Tile> plan;
|
||||
if (layout == "scatter") {
|
||||
// Loose tiles leave gaps, so the canvas starts as one of the images
|
||||
// rather than as black.
|
||||
const std::size_t backdrop = rng.below(sources.size() - 1);
|
||||
const Image& src = sources[backdrop];
|
||||
const int sx = static_cast<int>(rng.inRange(0, std::max(0, src.width() - width)));
|
||||
const int sy = static_cast<int>(rng.inRange(0, std::max(0, src.height() - height)));
|
||||
fillFromSource(canvas, src, sx, sy);
|
||||
|
||||
plan.reserve(static_cast<std::size_t>(count));
|
||||
for (long i = 0; i < count; ++i) {
|
||||
int w = tileWidth;
|
||||
int h = tileHeight;
|
||||
if (jitter > 0) {
|
||||
w += static_cast<int>(rng.inRange(-jitter, jitter));
|
||||
h += static_cast<int>(rng.inRange(-jitter, jitter));
|
||||
}
|
||||
const int dstX = static_cast<int>(rng.inRange(0, width - 1));
|
||||
const int dstY = static_cast<int>(rng.inRange(0, height - 1));
|
||||
w = std::max(1, std::min(w, width - dstX));
|
||||
h = std::max(1, std::min(h, height - dstY));
|
||||
plan.push_back(cut(dstX, dstY, w, h));
|
||||
}
|
||||
} else {
|
||||
const std::vector<int> rows = planSpans(rng, tileHeight, jitter, height);
|
||||
// In a grid every row is cut at the same columns; in the brick layout
|
||||
// each row is packed on its own, so the vertical seams do not line up.
|
||||
std::vector<int> columns;
|
||||
if (layout != "brick") columns = planSpans(rng, tileWidth, jitter, width);
|
||||
|
||||
plan.reserve(rows.size() * (columns.empty() ? 8 : columns.size()));
|
||||
int y = 0;
|
||||
for (const int rowHeight : rows) {
|
||||
if (layout == "brick") columns = planSpans(rng, tileWidth, jitter, width);
|
||||
int x = 0;
|
||||
for (const int cellWidth : columns) {
|
||||
plan.push_back(cut(x, y, cellWidth, rowHeight));
|
||||
x += cellWidth;
|
||||
}
|
||||
y += rowHeight;
|
||||
}
|
||||
}
|
||||
|
||||
if (layout == "scatter") {
|
||||
// Scattered tiles overlap, so they are painted in plan order on one
|
||||
// thread: letting threads race for the same pixels would make the
|
||||
// output depend on their scheduling.
|
||||
for (const Tile& tile : plan) {
|
||||
paintTile(canvas, sources[static_cast<std::size_t>(tile.sourceIndex)], tile);
|
||||
}
|
||||
} else {
|
||||
parallelFor(plan.size(), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t i = begin; i < end; ++i) {
|
||||
paintTile(canvas, sources[static_cast<std::size_t>(plan[i].sourceIndex)], plan[i]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// Covers the canvas with rectangular tiles cut at random positions from the
|
||||
// source images: the two-dimensional counterpart of the scanlines effect.
|
||||
class TilesEffect : public Effect {
|
||||
public:
|
||||
std::string name() const override { return "tiles"; }
|
||||
std::string title() const override { return "Tiles"; }
|
||||
std::string description() const override {
|
||||
return "Cuts rectangular tiles at random positions from the source images and lays "
|
||||
"them over the canvas, in a grid, in offset rows or scattered about.";
|
||||
}
|
||||
const std::vector<ParamSpec>& params() const override;
|
||||
|
||||
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||
const ParamValues& values) const override;
|
||||
};
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,159 @@
|
||||
#include "effects/warp.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
#include "core/parallel.hpp"
|
||||
#include "effects/sampling.hpp"
|
||||
#include "effects/source_fill.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
const std::vector<ParamSpec>& WarpEffect::params() const {
|
||||
static const std::vector<ParamSpec> specs = {
|
||||
ParamSpec::makeInt("warp_strength", "Strength", 40, 0, 4096,
|
||||
"How far in pixels the field pushes the image at its strongest."),
|
||||
ParamSpec::makeInt("warp_scale", "Scale", 160, 4, 8192,
|
||||
"Size in pixels of the largest features in the field."),
|
||||
ParamSpec::makeInt("warp_octaves", "Octaves", 3, 1, 6,
|
||||
"Finer layers of noise laid over the first; more give a turbulent "
|
||||
"edge to the flow."),
|
||||
ParamSpec::makeChoice("warp_mode", "Mode", "displace", {"displace", "swirl"},
|
||||
"displace pushes along the field; swirl flows along its contour "
|
||||
"lines, which curls the image into eddies."),
|
||||
};
|
||||
return specs;
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// One layer of value noise: random heights on a lattice `spacing` pixels
|
||||
// apart, blended smoothly between the lattice points.
|
||||
class NoiseLayer {
|
||||
public:
|
||||
NoiseLayer(Rng& rng, int width, int height, double spacing)
|
||||
: spacing_(spacing),
|
||||
cols_(static_cast<int>(width / spacing) + 2),
|
||||
rows_(static_cast<int>(height / spacing) + 2),
|
||||
lattice_(static_cast<std::size_t>(cols_) * rows_) {
|
||||
for (auto& value : lattice_) value = unitDouble(rng.next()) * 2.0 - 1.0;
|
||||
}
|
||||
|
||||
// The height at (x, y) in [-1, 1], and its slope per pixel along x and y.
|
||||
void sample(double x, double y, double& value, double& slopeX, double& slopeY) const {
|
||||
const double gx = x / spacing_;
|
||||
const double gy = y / spacing_;
|
||||
const int ix = std::min(static_cast<int>(gx), cols_ - 2);
|
||||
const int iy = std::min(static_cast<int>(gy), rows_ - 2);
|
||||
const double fx = gx - ix;
|
||||
const double fy = gy - iy;
|
||||
const double sx = fx * fx * (3.0 - 2.0 * fx);
|
||||
const double sy = fy * fy * (3.0 - 2.0 * fy);
|
||||
|
||||
const double a = at(ix, iy);
|
||||
const double b = at(ix + 1, iy);
|
||||
const double c = at(ix, iy + 1);
|
||||
const double d = at(ix + 1, iy + 1);
|
||||
const double cross = a - b - c + d;
|
||||
value = a + (b - a) * sx + (c - a) * sy + cross * sx * sy;
|
||||
slopeX = 6.0 * fx * (1.0 - fx) * ((b - a) + cross * sy) / spacing_;
|
||||
slopeY = 6.0 * fy * (1.0 - fy) * ((c - a) + cross * sx) / spacing_;
|
||||
}
|
||||
|
||||
private:
|
||||
double at(int x, int y) const { return lattice_[static_cast<std::size_t>(y) * cols_ + x]; }
|
||||
|
||||
double spacing_;
|
||||
int cols_;
|
||||
int rows_;
|
||||
std::vector<double> lattice_;
|
||||
};
|
||||
|
||||
// Octaves of noise summed, each twice as fine and half as strong as the one
|
||||
// before, and scaled back so the total stays in [-1, 1].
|
||||
class NoiseField {
|
||||
public:
|
||||
NoiseField(Rng& rng, int width, int height, double scale, int octaves) {
|
||||
double spacing = scale;
|
||||
double weight = 1.0;
|
||||
for (int i = 0; i < octaves; ++i) {
|
||||
layers_.emplace_back(rng, width, height, std::max(1.0, spacing));
|
||||
weights_.push_back(weight);
|
||||
total_ += weight;
|
||||
spacing *= 0.5;
|
||||
weight *= 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
void sample(double x, double y, double& value, double& slopeX, double& slopeY) const {
|
||||
value = slopeX = slopeY = 0.0;
|
||||
for (std::size_t i = 0; i < layers_.size(); ++i) {
|
||||
double v = 0.0, sx = 0.0, sy = 0.0;
|
||||
layers_[i].sample(x, y, v, sx, sy);
|
||||
value += v * weights_[i];
|
||||
slopeX += sx * weights_[i];
|
||||
slopeY += sy * weights_[i];
|
||||
}
|
||||
value /= total_;
|
||||
slopeX /= total_;
|
||||
slopeY /= total_;
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<NoiseLayer> layers_;
|
||||
std::vector<double> weights_;
|
||||
double total_ = 0.0;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
Image WarpEffect::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 strength = static_cast<double>(std::max(0L, paramInt(values, "warp_strength", 40)));
|
||||
const double scale = static_cast<double>(std::max(4L, paramInt(values, "warp_scale", 160)));
|
||||
const int octaves = static_cast<int>(std::clamp(paramInt(values, "warp_octaves", 3), 1L, 6L));
|
||||
const bool swirl = paramChoice(values, "warp_mode", "displace") == "swirl";
|
||||
|
||||
const Image input = canvasFromSources(sources, width, height, rng);
|
||||
|
||||
// Lattices are drawn in full before any pixel moves: one field for the
|
||||
// swirl, which flows along its contours, two for a push along x and y.
|
||||
const NoiseField first(rng, width, height, scale, octaves);
|
||||
std::vector<NoiseField> extra;
|
||||
if (!swirl) extra.emplace_back(rng, width, height, scale, octaves);
|
||||
|
||||
Image canvas(width, height);
|
||||
parallelFor(static_cast<std::size_t>(height), [&](std::size_t begin, std::size_t end) {
|
||||
for (std::size_t row = begin; row < end; ++row) {
|
||||
std::uint8_t* out = canvas.row(static_cast<int>(row));
|
||||
const double y = static_cast<double>(row);
|
||||
for (int col = 0; col < width; ++col) {
|
||||
const double x = col;
|
||||
double value = 0.0, slopeX = 0.0, slopeY = 0.0;
|
||||
first.sample(x, y, value, slopeX, slopeY);
|
||||
double dx = 0.0;
|
||||
double dy = 0.0;
|
||||
if (swirl) {
|
||||
// Along the contour: the slope turned a quarter, which
|
||||
// never piles pixels up or tears them apart. The slope of
|
||||
// a field `scale` wide is about 1/scale, hence the factor.
|
||||
dx = slopeY * scale * strength * 0.5;
|
||||
dy = -slopeX * scale * strength * 0.5;
|
||||
} else {
|
||||
double other = 0.0;
|
||||
extra.front().sample(x, y, other, slopeX, slopeY);
|
||||
dx = value * strength;
|
||||
dy = other * strength;
|
||||
}
|
||||
sampleBilinear(input, x + dx, y + dy, out + static_cast<std::size_t>(col) * 3);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
} // namespace effector
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/effect.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
// Pushes every pixel along a smooth random noise field: a haze at low strength,
|
||||
// melted paint at high. The swirl mode follows the field's contour lines,
|
||||
// which turns the image into eddies instead of ripples.
|
||||
// A pure transform: it needs no source images, and leading a chain with
|
||||
// sources loaded it starts from a crop of a randomly chosen one.
|
||||
class WarpEffect : public Effect {
|
||||
public:
|
||||
std::string name() const override { return "warp"; }
|
||||
std::string title() const override { return "Warp"; }
|
||||
std::string description() const override {
|
||||
return "Pushes every pixel along a smooth random noise field, from a gentle haze to "
|
||||
"molten eddies.";
|
||||
}
|
||||
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
|
||||
+1678
-115
File diff suppressed because it is too large
Load Diff
+130
-3
@@ -7,13 +7,17 @@
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "cli/options.hpp"
|
||||
#include "core/chain.hpp"
|
||||
#include "core/effect.hpp"
|
||||
#include "core/image.hpp"
|
||||
#include "core/project.hpp"
|
||||
#include "core/style.hpp"
|
||||
|
||||
namespace effector {
|
||||
|
||||
@@ -25,11 +29,39 @@ public:
|
||||
private:
|
||||
void buildUi();
|
||||
Gtk::Widget& buildSidebar();
|
||||
Gtk::Widget& buildChainTab();
|
||||
Gtk::Widget& buildSetupTab();
|
||||
Gtk::Widget& buildPreview();
|
||||
void buildParamWidgets();
|
||||
ParamValues paramValues() const;
|
||||
void storeParams(); // widgets -> the selected stage, and its style
|
||||
|
||||
// The style row above the params of a stage whose effect takes one.
|
||||
std::unique_ptr<Gtk::Widget> buildStyleRow(const ChainStage& stage);
|
||||
// The names of the pool the selected stage draws from, in the order the
|
||||
// dropdown shows them.
|
||||
std::vector<std::string> styleNames(const std::string& kind) const;
|
||||
// The style the selected stage follows, or nullptr when it stands alone.
|
||||
Style* selectedStyle();
|
||||
const Style* selectedStyle() const;
|
||||
void onStyleSelected(); // the dropdown: adopt, drop, or make a new one
|
||||
void adoptStyle(const std::string& name);
|
||||
void onNewStyle();
|
||||
void onRenameStyle();
|
||||
void onRemoveStyle();
|
||||
// The little modal that asks for a style name, used by both New and
|
||||
// Rename. Built from scratch each time, like the progress window.
|
||||
void askStyleName(const std::string& title, const std::string& initial,
|
||||
const std::function<void(const std::string&)>& accept);
|
||||
void closeStyleWindow();
|
||||
// A name that is empty, or already used in the pool, is refused in place.
|
||||
bool styleNameTaken(const std::string& kind, const std::string& name) const;
|
||||
|
||||
void applyInitial(const Options& initial);
|
||||
|
||||
bool canRender() const; // a chain, and sources if the chain needs them
|
||||
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 +74,66 @@ private:
|
||||
void closeLoadWindow();
|
||||
void cancelLoad();
|
||||
void appendSourceRow(const std::string& path);
|
||||
void updateSourcesLabel(); // the heading shows the count
|
||||
// The whole sources section only shows while the chain asks for sources.
|
||||
void updateSourcesVisible();
|
||||
|
||||
Gtk::Widget& buildCanvasSection();
|
||||
// "" while "Custom" is picked, otherwise the format's key.
|
||||
std::string canvasFormatKey() const;
|
||||
void setCanvasFormat(const std::string& key, int dpi); // dropdown and dpi
|
||||
// A format owns the pixel sizes, so the spins follow it and are greyed
|
||||
// out; "Custom" hands them back to the user untouched.
|
||||
void syncCanvasWidgets();
|
||||
void removeSource(Gtk::ListBoxRow* row);
|
||||
|
||||
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 from, int to); // `to` is the index it ends up at
|
||||
// Dragging a row: the slot between rows it would drop into, and the line
|
||||
// that shows it.
|
||||
int chainDropSlot(double y) const;
|
||||
void showChainDropSlot(int slot);
|
||||
// The check on a chain row: the stage stays where it is, and is skipped.
|
||||
void setStageEnabled(int index, bool enabled);
|
||||
void rebuildChainRows();
|
||||
// Rests the split under the chain's rows, until the user drags it.
|
||||
void scheduleChainSplitFit();
|
||||
void fitChainSplit();
|
||||
void refitChainSplit(); // the double-click on the handle
|
||||
void onChainSplitMoved();
|
||||
std::string stageLabel(std::size_t index) const;
|
||||
void selectStage(int index);
|
||||
void onStageSelected();
|
||||
Gtk::Widget& buildMainMenu(); // the header menu: new, open, save project
|
||||
void showChangelog();
|
||||
void showAbout();
|
||||
Project currentProject() const;
|
||||
void applyProject(const Project& project, const std::string& path);
|
||||
void clearSources();
|
||||
void onNewProject();
|
||||
void onOpenProject();
|
||||
void openProject(const std::string& path);
|
||||
// Open Recent: the projects this program opened or saved, newest first,
|
||||
// kept in the desktop's shared list of recently used files.
|
||||
void rememberProject(const std::string& path);
|
||||
void rebuildRecentMenu();
|
||||
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 +154,28 @@ 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_;
|
||||
// The named looks the stages share. Edited through the params of whichever
|
||||
// stage follows one, and saved with the project.
|
||||
StyleSet styles_;
|
||||
int selectedStage_ = 0;
|
||||
bool suspendStageSelect_ = false; // guards the rebuild of the chain rows
|
||||
// The split between the chain and the params, and where it was last put
|
||||
// to fit the rows: any other position the user reached by dragging.
|
||||
Gtk::Paned* chainSplit_ = nullptr;
|
||||
int fittedChainSplit_ = -1;
|
||||
bool chainSplitDragged_ = false;
|
||||
bool suspendSplitTrack_ = false;
|
||||
sigc::connection chainSplitFit_;
|
||||
// The name label of each chain row, renamed in place as params change.
|
||||
std::vector<Gtk::Label*> stageLabels_;
|
||||
Image result_;
|
||||
std::uint64_t seed_ = 0;
|
||||
std::string projectPath_; // empty until the project has been saved once
|
||||
Glib::RefPtr<Gio::Menu> recentMenu_;
|
||||
sigc::connection recentChanged_; // the recent list outlives the window
|
||||
bool suspendRender_ = true;
|
||||
|
||||
// Source loading. Decoding a batch of large photographs takes seconds, so
|
||||
@@ -85,6 +192,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
|
||||
@@ -103,14 +211,33 @@ private:
|
||||
Gtk::Button* addButton_ = nullptr;
|
||||
// Built fresh with the progress window, and null whenever it is closed.
|
||||
std::unique_ptr<Gtk::Window> loadWindow_;
|
||||
// Opened from the menu on first use, then shown again rather than rebuilt.
|
||||
std::unique_ptr<Gtk::Window> changelogWindow_;
|
||||
std::unique_ptr<Gtk::AboutDialog> aboutDialog_;
|
||||
Gtk::ProgressBar* loadBar_ = nullptr;
|
||||
Gtk::Label* loadFileLabel_ = nullptr;
|
||||
Gtk::Box sourcesSection_; // hidden while the chain needs no sources
|
||||
Gtk::Label sourcesHeading_;
|
||||
Gtk::ListBox sourceList_;
|
||||
Gtk::DropDown formatDrop_;
|
||||
Gtk::SpinButton widthSpin_;
|
||||
Gtk::SpinButton heightSpin_;
|
||||
Gtk::SpinButton dpiSpin_;
|
||||
Gtk::Label* dpiLabel_ = nullptr; // hidden along with the dpi spin
|
||||
// Set while a format fills the spins, so their own handler does not queue
|
||||
// a second render on top of the one the format change asks for.
|
||||
bool suspendCanvasSync_ = false;
|
||||
Gtk::DropDown* styleDrop_ = nullptr; // null while the stage takes no style
|
||||
bool suspendStyleSelect_ = false; // guards filling the dropdown
|
||||
std::shared_ptr<Gtk::Window> styleWindow_;
|
||||
Gtk::Entry* styleNameEntry_ = nullptr; // nulled with the window
|
||||
Gtk::Label* styleNameError_ = nullptr;
|
||||
Gtk::Grid paramGrid_;
|
||||
Gtk::Label paramHeading_;
|
||||
Gtk::Entry seedEntry_;
|
||||
Gtk::DropDown effectDropdown_;
|
||||
Gtk::ListBox chainList_;
|
||||
Gtk::MenuButton addEffectButton_;
|
||||
Gtk::MenuButton menuButton_;
|
||||
Gtk::Button recreateButton_{"Recreate"};
|
||||
Gtk::Button saveButton_{"Save As…"};
|
||||
Gtk::Picture picture_;
|
||||
|
||||
+18
-7
@@ -8,16 +8,25 @@
|
||||
#include <mutex>
|
||||
|
||||
#include "cli/options.hpp"
|
||||
#include "core/chain.hpp"
|
||||
#include "core/effect.hpp"
|
||||
#include "core/image.hpp"
|
||||
#include "core/registry.hpp"
|
||||
#include "core/rng.hpp"
|
||||
#include "core/version.hpp"
|
||||
#include "gui/app.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
int renderHeadless(const effector::Options& options) {
|
||||
const auto effect = effector::effectByName(options.effect);
|
||||
auto stages = effector::buildChain(options);
|
||||
// A headless run always renders something, so with no -e the default
|
||||
// effect stands in. The interface has no such need: it opens on an empty
|
||||
// chain and waits for the user to add the first stage.
|
||||
if (stages.empty()) {
|
||||
const auto effect = effector::effectByName(effector::defaultEffectName());
|
||||
stages.push_back({effect, effect->defaults()});
|
||||
}
|
||||
|
||||
std::vector<effector::LoadFailure> failures;
|
||||
|
||||
@@ -39,7 +48,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 +56,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;
|
||||
}
|
||||
@@ -72,6 +79,10 @@ int main(int argc, char** argv) {
|
||||
std::cout << effector::usage(argv[0]);
|
||||
return 0;
|
||||
}
|
||||
if (options.showVersion) {
|
||||
std::cout << "effector " << effector::kVersion << "\n";
|
||||
return 0;
|
||||
}
|
||||
if (options.listEffects) {
|
||||
for (const auto& effect : effector::effects()) {
|
||||
std::cout << effect->name() << "\t" << effect->title() << " - "
|
||||
|
||||
+19
-1
@@ -1,5 +1,23 @@
|
||||
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)
|
||||
|
||||
add_test(NAME core COMMAND effector_tests)
|
||||
|
||||
# The interface drives the real window, so it needs a display. Under ctest it
|
||||
# gets a private Xvfb one when xvfb-run is installed; without it, or without a
|
||||
# display of its own, the test reports itself skipped rather than failed.
|
||||
add_executable(effector_gui_tests gui_tests.cpp)
|
||||
target_link_libraries(effector_gui_tests PRIVATE effector_gui)
|
||||
target_compile_options(effector_gui_tests PRIVATE -Wall -Wextra -Wpedantic)
|
||||
|
||||
find_program(XVFB_RUN xvfb-run)
|
||||
if(XVFB_RUN)
|
||||
add_test(NAME gui COMMAND ${XVFB_RUN} -a -s "-screen 0 1400x900x24"
|
||||
env -u WAYLAND_DISPLAY GDK_BACKEND=x11 $<TARGET_FILE:effector_gui_tests>)
|
||||
else()
|
||||
add_test(NAME gui COMMAND effector_gui_tests)
|
||||
endif()
|
||||
set_tests_properties(gui PROPERTIES SKIP_RETURN_CODE 77)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,477 @@
|
||||
// Checks for the interface, driven through the real window: the widgets are
|
||||
// found by what a user sees (a name, a tooltip, a label) and pressed the way a
|
||||
// click would press them. Needs a display; exits 77 (skipped) without one.
|
||||
|
||||
#include <gtkmm.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "cli/options.hpp"
|
||||
#include "core/project.hpp"
|
||||
#include "core/registry.hpp"
|
||||
#include "core/version.hpp"
|
||||
#include "gui/window.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
int failures = 0;
|
||||
|
||||
void check(bool condition, const std::string& what) {
|
||||
if (condition) {
|
||||
std::cout << " ok " << what << "\n";
|
||||
} else {
|
||||
std::cout << " FAIL " << what << "\n";
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
// Lets the main loop run for a while: popovers map, renders come back, and
|
||||
// the rows rebuilt by a click are laid out.
|
||||
void pump(int milliseconds = 100) {
|
||||
auto context = Glib::MainContext::get_default();
|
||||
const auto until = std::chrono::steady_clock::now() + std::chrono::milliseconds(milliseconds);
|
||||
while (std::chrono::steady_clock::now() < until) {
|
||||
while (context->pending()) context->iteration(false);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
template <typename T, typename Pred>
|
||||
void collect(Gtk::Widget& root, Pred pred, std::vector<T*>& out) {
|
||||
for (auto* child = root.get_first_child(); child != nullptr; child = child->get_next_sibling()) {
|
||||
if (auto* match = dynamic_cast<T*>(child); match != nullptr && pred(*match)) out.push_back(match);
|
||||
collect<T>(*child, pred, out);
|
||||
}
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
std::vector<T*> all(Gtk::Widget& root) {
|
||||
std::vector<T*> out;
|
||||
collect<T>(root, [](T&) { return true; }, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
T* named(Gtk::Widget& root, const std::string& name) {
|
||||
std::vector<T*> out;
|
||||
collect<T>(root, [&](T& w) { return w.get_name() == name; }, out);
|
||||
return out.empty() ? nullptr : out.front();
|
||||
}
|
||||
|
||||
Gtk::Button* withTooltip(Gtk::Widget& root, const std::string& tip) {
|
||||
std::vector<Gtk::Button*> out;
|
||||
collect<Gtk::Button>(root, [&](Gtk::Button& b) { return b.get_tooltip_text() == tip; }, out);
|
||||
return out.empty() ? nullptr : out.front();
|
||||
}
|
||||
|
||||
bool isAncestor(const Gtk::Widget& ancestor, const Gtk::Widget& widget) {
|
||||
for (auto* w = widget.get_parent(); w != nullptr; w = w->get_parent()) {
|
||||
if (w == &ancestor) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
int rowCount(Gtk::ListBox& list) {
|
||||
int count = 0;
|
||||
while (list.get_row_at_index(count) != nullptr) ++count;
|
||||
return count;
|
||||
}
|
||||
|
||||
std::string rowTitle(Gtk::ListBox& list, int index) {
|
||||
auto* row = list.get_row_at_index(index);
|
||||
if (row == nullptr) return {};
|
||||
const auto labels = all<Gtk::Label>(*row);
|
||||
return labels.empty() ? std::string{} : std::string(labels.front()->get_text());
|
||||
}
|
||||
|
||||
// The top of `widget` in the window's coordinates.
|
||||
double topIn(Gtk::Widget& widget, Gtk::Widget& window) {
|
||||
graphene_rect_t bounds;
|
||||
if (!gtk_widget_compute_bounds(widget.gobj(), window.gobj(), &bounds)) return -1.0;
|
||||
return bounds.origin.y;
|
||||
}
|
||||
|
||||
// The menu of effects: the popover the "Add effect" button opens, and the
|
||||
// entry in it for one effect.
|
||||
Gtk::Button* menuEntry(Gtk::Popover& menu, const std::string& title) {
|
||||
for (auto* button : all<Gtk::Button>(menu)) {
|
||||
if (button->get_label() == title) return button;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Presses a button the way the keyboard does. GTK shows the press for a
|
||||
// quarter of a second before the button reports the click, so wait it out.
|
||||
void press(Gtk::Button& button) {
|
||||
button.activate();
|
||||
pump(400);
|
||||
}
|
||||
|
||||
bool addFromMenu(Gtk::MenuButton& add, const std::string& title) {
|
||||
auto* menu = add.get_popover();
|
||||
if (menu == nullptr) return false;
|
||||
add.popup();
|
||||
pump();
|
||||
auto* entry = menuEntry(*menu, title);
|
||||
if (entry == nullptr || !menu->get_mapped()) return false;
|
||||
press(*entry);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The drop target the chain list reorders its rows with.
|
||||
GtkDropTarget* chainDropTarget(Gtk::Widget& list) {
|
||||
GListModel* controllers = gtk_widget_observe_controllers(list.gobj());
|
||||
GtkDropTarget* found = nullptr;
|
||||
for (guint i = 0; i < g_list_model_get_n_items(controllers) && found == nullptr; ++i) {
|
||||
auto* controller = g_list_model_get_item(controllers, i);
|
||||
if (GTK_IS_DROP_TARGET(controller)) found = GTK_DROP_TARGET(controller);
|
||||
g_object_unref(controller);
|
||||
}
|
||||
g_object_unref(controllers);
|
||||
return found;
|
||||
}
|
||||
|
||||
// What dropping the row of stage `index` at `y` does; a real drag needs a
|
||||
// pointer, which the tests do not have.
|
||||
void dropStage(GtkDropTarget* drop, int index, double y) {
|
||||
GValue value = G_VALUE_INIT;
|
||||
g_value_init(&value, G_TYPE_INT);
|
||||
g_value_set_int(&value, index);
|
||||
gboolean handled = FALSE;
|
||||
g_signal_emit_by_name(drop, "drop", &value, 1.0, y, &handled);
|
||||
g_value_unset(&value);
|
||||
}
|
||||
|
||||
void runTests(Gtk::Window& window) {
|
||||
const auto& effects = effector::effects();
|
||||
|
||||
std::cout << "sidebar\n";
|
||||
auto* stack = all<Gtk::Stack>(window).empty() ? nullptr : all<Gtk::Stack>(window).front();
|
||||
check(stack != nullptr && stack->get_child_by_name("chain") != nullptr &&
|
||||
stack->get_child_by_name("setup") != nullptr,
|
||||
"the sidebar has a Chain and a Setup tab");
|
||||
check(stack != nullptr && stack->get_visible_child_name() == "chain",
|
||||
"it opens on the Chain tab");
|
||||
|
||||
auto* chainList = named<Gtk::ListBox>(window, "chain-list");
|
||||
auto* add = named<Gtk::MenuButton>(window, "add-effect");
|
||||
auto* sources = named<Gtk::Box>(window, "sources");
|
||||
check(chainList != nullptr && add != nullptr && sources != nullptr,
|
||||
"the chain list, the Add effect button and the sources are there");
|
||||
if (chainList == nullptr || add == nullptr || sources == nullptr) return;
|
||||
|
||||
std::vector<Gtk::Paned*> panes;
|
||||
collect<Gtk::Paned>(window, [&](Gtk::Paned& p) { return isAncestor(p, *chainList); }, panes);
|
||||
auto* split = panes.empty() ? nullptr : panes.back();
|
||||
check(split != nullptr && split->get_orientation() == Gtk::Orientation::VERTICAL,
|
||||
"the Chain tab is a vertical split");
|
||||
check(split != nullptr && split->get_start_child() != nullptr &&
|
||||
isAncestor(*split->get_start_child(), *chainList),
|
||||
"the chain list is above the split");
|
||||
check(split != nullptr && split->get_end_child() != nullptr &&
|
||||
!isAncestor(*split->get_end_child(), *chainList),
|
||||
"the params are below it, apart from the list");
|
||||
check(split != nullptr && !split->get_shrink_start_child() && !split->get_shrink_end_child(),
|
||||
"neither side can be dragged below its minimum");
|
||||
check(topIn(*add, window) >= 0.0 && topIn(*add, window) < topIn(*chainList, window),
|
||||
"Add effect sits above the chain list, not under it");
|
||||
check(isAncestor(*stack->get_child_by_name("setup"), *sources),
|
||||
"the sources are on the Setup tab");
|
||||
|
||||
std::cout << "add effect\n";
|
||||
auto* menu = add->get_popover();
|
||||
check(menu != nullptr, "Add effect has a menu");
|
||||
if (menu == nullptr) return;
|
||||
bool listed = true;
|
||||
for (const auto& effect : effects) listed = listed && menuEntry(*menu, effect->title()) != nullptr;
|
||||
check(listed, "the menu has an entry for every registered effect");
|
||||
check(all<Gtk::Button>(*menu).size() == effects.size(), "and nothing else");
|
||||
|
||||
add->popup();
|
||||
pump();
|
||||
check(menu->get_mapped(), "the menu opens");
|
||||
// A popover taller than the room below its button is not shown at all --
|
||||
// though GTK still calls it mapped -- which is what broke the button once
|
||||
// there were enough effects: the entries have to scroll instead, in a
|
||||
// menu short enough to open below a button near the top of a 768-pixel
|
||||
// screen.
|
||||
const auto scrollers = all<Gtk::ScrolledWindow>(*menu);
|
||||
const int cap = scrollers.empty() ? -1 : scrollers.front()->get_max_content_height();
|
||||
check(!scrollers.empty() && isAncestor(*scrollers.front(), *menuEntry(*menu, effects.front()->title())),
|
||||
"the entries scroll");
|
||||
check(cap > 0 && cap <= 480, "past a height that fits a small screen");
|
||||
check(menu->get_height() > 0 && menu->get_height() <= cap + 40,
|
||||
"and the menu stays within it however many effects there are");
|
||||
add->popdown();
|
||||
pump();
|
||||
|
||||
|
||||
std::vector<Gtk::ScrolledWindow*> listScrollers;
|
||||
collect<Gtk::ScrolledWindow>(window, [&](Gtk::ScrolledWindow& s) {
|
||||
return s.get_child() != nullptr && isAncestor(*s.get_child(), *chainList);
|
||||
}, listScrollers);
|
||||
auto* listScroller = listScrollers.empty() ? nullptr : listScrollers.back();
|
||||
check(listScroller != nullptr && listScroller->get_propagate_natural_height() &&
|
||||
listScroller->get_max_content_height() > 0,
|
||||
"the chain list asks for the height of its rows, up to a cap");
|
||||
const int emptySplit = split != nullptr ? split->get_position() : -1;
|
||||
|
||||
check(rowCount(*chainList) == 0, "the chain starts empty");
|
||||
check(addFromMenu(*add, "Blur"), "picking Blur from the menu");
|
||||
check(!menu->get_mapped(), "closes the menu");
|
||||
check(rowCount(*chainList) == 1 && rowTitle(*chainList, 0) == "1. Blur",
|
||||
"and appends a Blur stage");
|
||||
check(chainList->get_selected_row() == chainList->get_row_at_index(0),
|
||||
"the new stage is selected");
|
||||
check(!sources->get_visible(), "a chain led by blur hides the sources");
|
||||
|
||||
check(addFromMenu(*add, "Scanlines"), "picking Scanlines");
|
||||
check(rowCount(*chainList) == 2 && rowTitle(*chainList, 1) == "2. Scanlines",
|
||||
"appends it after Blur");
|
||||
check(chainList->get_selected_row() == chainList->get_row_at_index(1),
|
||||
"and selects it");
|
||||
|
||||
std::cout << "stage rows\n";
|
||||
if (rowCount(*chainList) != 2) return;
|
||||
auto* second = chainList->get_row_at_index(1);
|
||||
check(second->has_css_class("stage-row"), "a row is a stage row");
|
||||
std::vector<Gtk::Button*> actions;
|
||||
collect<Gtk::Button>(*second, [](Gtk::Button& b) { return b.has_css_class("stage-action"); }, actions);
|
||||
check(actions.size() == 3, "with a move up, a move down and a remove action");
|
||||
auto* first = chainList->get_row_at_index(0);
|
||||
auto* firstUp = withTooltip(*first, "Move up");
|
||||
auto* lastDown = withTooltip(*second, "Move down");
|
||||
check(firstUp != nullptr && !firstUp->get_sensitive(), "the first stage cannot move up");
|
||||
check(lastDown != nullptr && !lastDown->get_sensitive(), "the last stage cannot move down");
|
||||
|
||||
auto* up = withTooltip(*second, "Move up");
|
||||
check(up != nullptr, "the second stage has Move up");
|
||||
if (up != nullptr) press(*up);
|
||||
check(rowTitle(*chainList, 0) == "1. Scanlines" && rowTitle(*chainList, 1) == "2. Blur",
|
||||
"Move up swaps the stages");
|
||||
check(chainList->get_selected_row() == chainList->get_row_at_index(0),
|
||||
"and the moved stage stays selected");
|
||||
check(sources->get_visible(), "a chain led by scanlines shows the sources");
|
||||
|
||||
auto* remove = withTooltip(*chainList->get_row_at_index(0), "Remove");
|
||||
if (remove != nullptr) press(*remove);
|
||||
check(rowCount(*chainList) == 1 && rowTitle(*chainList, 0) == "1. Blur",
|
||||
"Remove drops the stage and renumbers the rest");
|
||||
check(!sources->get_visible(), "and the sources hide again");
|
||||
|
||||
std::cout << "drag and drop\n";
|
||||
addFromMenu(*add, "Scanlines");
|
||||
for (const auto& effect : effects) {
|
||||
if (effect->title() != "Blur" && effect->title() != "Scanlines") {
|
||||
addFromMenu(*add, effect->title());
|
||||
break;
|
||||
}
|
||||
}
|
||||
GtkDropTarget* drop = chainDropTarget(*chainList);
|
||||
check(drop != nullptr, "the chain list takes drops");
|
||||
if (drop != nullptr && rowCount(*chainList) == 3) {
|
||||
const std::string first = rowTitle(*chainList, 0).substr(3);
|
||||
const std::string second = rowTitle(*chainList, 1).substr(3);
|
||||
const std::string third = rowTitle(*chainList, 2).substr(3);
|
||||
const double bottom = chainList->get_row_at_index(2)->get_height() * 3.0;
|
||||
GdkDragAction action = GDK_ACTION_NONE;
|
||||
g_signal_emit_by_name(drop, "motion", 1.0, bottom, &action);
|
||||
check(chainList->get_row_at_index(2)->has_css_class("drop-after"),
|
||||
"dragging past the last row marks its bottom edge");
|
||||
dropStage(drop, 0, bottom);
|
||||
pump();
|
||||
check(rowTitle(*chainList, 0) == "1. " + second && rowTitle(*chainList, 1) == "2. " + third &&
|
||||
rowTitle(*chainList, 2) == "3. " + first,
|
||||
"dropping the first stage past the last moves it to the end");
|
||||
check(chainList->get_selected_row() == chainList->get_row_at_index(2),
|
||||
"and selects it");
|
||||
check(!chainList->get_row_at_index(2)->has_css_class("drop-after"),
|
||||
"and clears the mark");
|
||||
dropStage(drop, 2, 1.0);
|
||||
pump();
|
||||
check(rowTitle(*chainList, 0) == "1. " + first && rowTitle(*chainList, 1) == "2. " + second,
|
||||
"dropping on the top edge of the first row moves it to the front");
|
||||
}
|
||||
for (int i = rowCount(*chainList) - 1; i > 0; --i) {
|
||||
if (auto* removeRow = withTooltip(*chainList->get_row_at_index(i), "Remove")) press(*removeRow);
|
||||
}
|
||||
|
||||
std::cout << "many stages\n";
|
||||
for (int i = 0; i < 4; ++i) addFromMenu(*add, effects[static_cast<std::size_t>(i) % effects.size()]->title());
|
||||
pump();
|
||||
const int fewSplit = split != nullptr ? split->get_position() : -1;
|
||||
check(split != nullptr && fewSplit > emptySplit,
|
||||
"the split moves down as the chain grows");
|
||||
for (int i = 4; i < 12; ++i) addFromMenu(*add, effects[static_cast<std::size_t>(i) % effects.size()]->title());
|
||||
check(rowCount(*chainList) == 13, "the menu still works with a long chain");
|
||||
check(addFromMenu(*add, effects.back()->title()) && rowCount(*chainList) == 14,
|
||||
"including for the last effect in the menu");
|
||||
pump();
|
||||
const int longSplit = split != nullptr ? split->get_position() : -1;
|
||||
check(longSplit > fewSplit && listScroller != nullptr &&
|
||||
listScroller->get_height() <= listScroller->get_max_content_height() + 2,
|
||||
"up to the cap, past which the list scrolls");
|
||||
if (split != nullptr) split->set_position(emptySplit + 20);
|
||||
pump();
|
||||
addFromMenu(*add, effects.front()->title());
|
||||
pump();
|
||||
check(split != nullptr && split->get_position() == emptySplit + 20,
|
||||
"once dragged, the split stays put as the chain grows");
|
||||
|
||||
// With the split dragged all the way down, the button must still work.
|
||||
if (split != nullptr) split->set_position(10000);
|
||||
pump();
|
||||
add->popup();
|
||||
pump();
|
||||
check(menu->get_mapped(), "the menu opens with the split dragged to the bottom");
|
||||
add->popdown();
|
||||
pump();
|
||||
}
|
||||
|
||||
// The labels of the entries under the "Open Recent" submenu of `model`, or
|
||||
// nothing when there is no such submenu.
|
||||
void recentLabels(GMenuModel* model, std::vector<std::string>& out, bool& found) {
|
||||
for (int i = 0; i < g_menu_model_get_n_items(model); ++i) {
|
||||
char* label = nullptr;
|
||||
g_menu_model_get_item_attribute(model, i, G_MENU_ATTRIBUTE_LABEL, "s", &label);
|
||||
const std::string text = label != nullptr ? label : "";
|
||||
g_free(label);
|
||||
if (GMenuModel* section = g_menu_model_get_item_link(model, i, G_MENU_LINK_SECTION)) {
|
||||
recentLabels(section, out, found);
|
||||
g_object_unref(section);
|
||||
}
|
||||
if (GMenuModel* submenu = g_menu_model_get_item_link(model, i, G_MENU_LINK_SUBMENU)) {
|
||||
if (text == "Open Recent") {
|
||||
found = true;
|
||||
for (int j = 0; j < g_menu_model_get_n_items(submenu); ++j) {
|
||||
char* entry = nullptr;
|
||||
g_menu_model_get_item_attribute(submenu, j, G_MENU_ATTRIBUTE_LABEL, "s", &entry);
|
||||
out.emplace_back(entry != nullptr ? entry : "");
|
||||
g_free(entry);
|
||||
}
|
||||
}
|
||||
g_object_unref(submenu);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<std::string> recentEntries(Gtk::Window& window) {
|
||||
std::vector<Gtk::MenuButton*> buttons;
|
||||
collect<Gtk::MenuButton>(window, [](Gtk::MenuButton& b) { return b.get_tooltip_text() == "Project menu"; },
|
||||
buttons);
|
||||
std::vector<std::string> labels;
|
||||
bool found = false;
|
||||
if (buttons.empty()) return labels;
|
||||
auto* popover = dynamic_cast<Gtk::PopoverMenu*>(buttons.front()->get_popover());
|
||||
if (popover == nullptr || !popover->get_menu_model()) return labels;
|
||||
recentLabels(popover->get_menu_model()->gobj(), labels, found);
|
||||
if (!found) labels.emplace_back("<no Open Recent submenu>");
|
||||
return labels;
|
||||
}
|
||||
|
||||
// The version and the changelog, each a menu entry away.
|
||||
void testHelpWindows(Gtk::Window& window) {
|
||||
std::cout << "changelog and about\n";
|
||||
auto toplevel = [](const std::string& title) -> Gtk::Window* {
|
||||
for (auto* w : Gtk::Window::list_toplevels()) {
|
||||
if (w->get_title() == title && w->get_visible()) return w;
|
||||
}
|
||||
return nullptr;
|
||||
};
|
||||
|
||||
window.activate_action("win.show-changelog");
|
||||
pump(200);
|
||||
auto* changelog = toplevel("Changelog");
|
||||
check(changelog != nullptr, "Changelog opens a window");
|
||||
auto* text = changelog != nullptr ? named<Gtk::TextView>(*changelog, "changelog") : nullptr;
|
||||
const std::string shown = text != nullptr ? std::string(text->get_buffer()->get_text()) : "";
|
||||
check(shown.find(std::string(effector::kVersion) + " — ") != std::string::npos,
|
||||
"listing this version as a release");
|
||||
check(shown.find("[") == std::string::npos && shown.find("`") == std::string::npos &&
|
||||
shown.find("### ") == std::string::npos,
|
||||
"with the Markdown laid out rather than shown");
|
||||
if (changelog != nullptr) changelog->close();
|
||||
pump(50);
|
||||
check(toplevel("Changelog") == nullptr, "and closes");
|
||||
|
||||
window.activate_action("win.about");
|
||||
pump(200);
|
||||
Gtk::AboutDialog* about = nullptr;
|
||||
for (auto* w : Gtk::Window::list_toplevels()) {
|
||||
if (auto* dialog = dynamic_cast<Gtk::AboutDialog*>(w); dialog != nullptr && dialog->get_visible()) {
|
||||
about = dialog;
|
||||
}
|
||||
}
|
||||
check(about != nullptr && about->get_version() == std::string(effector::kVersion),
|
||||
"About Effector shows the version");
|
||||
if (about != nullptr) about->close();
|
||||
pump(50);
|
||||
}
|
||||
|
||||
void testRecentProjects(Gtk::Window& window, const std::filesystem::path& dir) {
|
||||
std::cout << "recent projects\n";
|
||||
check(recentEntries(window) == std::vector<std::string>{"No Recent Projects"},
|
||||
"Open Recent starts out empty");
|
||||
|
||||
// Two projects of the same name in different folders, and a third.
|
||||
std::filesystem::create_directories(dir / "first");
|
||||
std::filesystem::create_directories(dir / "second");
|
||||
const std::filesystem::path paths[] = {dir / "first" / "my_look.json", dir / "second" / "my_look.json",
|
||||
dir / "other.json"};
|
||||
effector::Project project;
|
||||
project.stages.push_back({effector::effectByName("blur"), {}}); // renders with no sources
|
||||
for (const auto& path : paths) {
|
||||
effector::saveProject(path.string(), project);
|
||||
window.activate_action("win.open-recent",
|
||||
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
|
||||
pump(50);
|
||||
}
|
||||
pump(500); // the recent list is written, and announced, from an idle
|
||||
check(window.get_title() == "other — Effector", "an entry opens its project");
|
||||
const auto labels = recentEntries(window);
|
||||
check(labels == std::vector<std::string>{"other", "my__look (second)", "my__look (first)"},
|
||||
"opened projects are listed newest first, a shared name with its folder");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
// A crash should still leave every check before it on the screen.
|
||||
std::cout << std::unitbuf;
|
||||
const char* x11 = std::getenv("DISPLAY");
|
||||
const char* wayland = std::getenv("WAYLAND_DISPLAY");
|
||||
if ((x11 == nullptr || *x11 == '\0') && (wayland == nullptr || *wayland == '\0')) {
|
||||
std::cout << "no display, skipped\n";
|
||||
return 77;
|
||||
}
|
||||
|
||||
// Keep the projects the tests open out of the user's own recent list.
|
||||
std::string dataTemplate = (std::filesystem::temp_directory_path() / "effector-gui-XXXXXX").string();
|
||||
const std::filesystem::path dataDir = g_mkdtemp(dataTemplate.data());
|
||||
g_setenv("XDG_DATA_HOME", dataDir.c_str(), TRUE);
|
||||
|
||||
auto app = Gtk::Application::create("org.effector.GuiTests",
|
||||
Gio::Application::Flags::NON_UNIQUE);
|
||||
app->signal_activate().connect([&app, &dataDir] {
|
||||
auto window = std::make_unique<effector::EffectorWindow>(effector::Options{});
|
||||
app->add_window(*window);
|
||||
window->present();
|
||||
pump(300);
|
||||
runTests(*window);
|
||||
testHelpWindows(*window);
|
||||
testRecentProjects(*window, dataDir / "projects");
|
||||
app->remove_window(*window);
|
||||
});
|
||||
const int status = app->run(argc > 0 ? 1 : 0, argv);
|
||||
std::filesystem::remove_all(dataDir);
|
||||
if (status != 0) return status;
|
||||
std::cout << (failures == 0 ? "all passed\n" : std::to_string(failures) + " failed\n");
|
||||
return failures;
|
||||
}
|
||||
Reference in New Issue
Block a user