Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
520c7c45fb | ||
|
|
b9f905e04c | ||
|
|
431fcf9c58 | ||
|
|
9fb4428a3b | ||
|
|
7d122ddd6c | ||
|
|
536b8620ea | ||
|
|
81ca17463e | ||
|
|
d198428dc4 | ||
|
|
a01ada5372 | ||
|
|
d0b5669f46 | ||
|
|
5292c101db | ||
|
|
134c575727 | ||
|
|
68a1e40d3f | ||
|
|
5a5cea69db | ||
|
|
1edf854cd1 | ||
|
|
4fd6ccc9b0 | ||
|
|
352a928149 | ||
|
|
ce3a422f6a |
@@ -5,3 +5,7 @@ compile_commands.json
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.o
|
*.o
|
||||||
*.a
|
*.a
|
||||||
|
packaging/arch/effector/
|
||||||
|
packaging/arch/src/
|
||||||
|
packaging/arch/pkg/
|
||||||
|
packaging/arch/*.pkg.tar*
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# 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.1] - 2026-09-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- An Arch Linux `PKGBUILD` in `packaging/arch`: `makepkg -si` there builds and
|
||||||
|
installs the tagged release with pacman.
|
||||||
|
- A desktop entry, so an installed Effector shows up in the app 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,7 +12,7 @@ ctest --test-dir build --output-on-failure
|
|||||||
|
|
||||||
Everything is warning-clean under `-Wall -Wextra -Wpedantic`; keep it that way.
|
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
|
per-test filter: run `./build/tests/effector_tests` directly to see the section
|
||||||
headings (`rng`, `registry`, `scanlines`, `tiles`, `blur`, `chain`, `project`,
|
headings (`rng`, `registry`, `scanlines`, `tiles`, `blur`, `chain`, `project`,
|
||||||
`image io`, `batch loading`) and the `ok`/`FAIL` lines. To iterate on one area, comment
|
`image io`, `batch loading`) and the `ok`/`FAIL` lines. To iterate on one area, comment
|
||||||
@@ -20,12 +20,35 @@ out the other blocks in `tests/core_tests.cpp` or add a new
|
|||||||
`std::cout << "section\n"` block — checks are plain
|
`std::cout << "section\n"` block — checks are plain
|
||||||
`check(condition, "description")` calls and `main()` returns the failure count.
|
`check(condition, "description")` calls and `main()` returns the failure count.
|
||||||
|
|
||||||
|
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):
|
Headless render (no display needed, the fastest end-to-end smoke test):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
build/effector -o /tmp/out.png -s 800x600 --strip-height 6 --seed 1 a.jpg b.png
|
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, and it also fails unless
|
||||||
|
`pkgver` in `packaging/arch/PKGBUILD` matches (that PKGBUILD builds the
|
||||||
|
`vX.Y.Z` tag, so reset `pkgrel=1` on a bump). 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
|
### Running the GUI from this shell
|
||||||
|
|
||||||
GTK picks Wayland from an inherited `WAYLAND_DISPLAY` and will not draw into an
|
GTK picks Wayland from an inherited `WAYLAND_DISPLAY` and will not draw into an
|
||||||
@@ -106,7 +129,7 @@ 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.
|
trailing argument; the command line builds no styles and passes none.
|
||||||
|
|
||||||
The interface builds the chain in the sidebar ("Effect chain": add from the
|
The interface builds the chain in the sidebar ("Effect chain": add from the
|
||||||
menu, reorder, remove, switch off with the check on the row, one stage selected
|
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`:
|
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`
|
`-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`
|
leaving a stage in place but skipping it. Flags share one namespace across all effects, so `buildChain`
|
||||||
@@ -141,15 +164,19 @@ can be moved or shared whole.
|
|||||||
|
|
||||||
The interface opens and saves projects from the header-bar menu (New, Open,
|
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
|
Save, Save As, with the usual Ctrl+N/O/S accelerators); the window title shows
|
||||||
the project name. Opening one replaces the sources, so a decode still in flight
|
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_`).
|
is discarded rather than mixed into the new project (`dropLoadedImages_`).
|
||||||
|
|
||||||
### Two link targets, deliberately
|
### Two link targets, deliberately
|
||||||
|
|
||||||
`effector_core` (static) holds image IO, params, effects, rng and links only
|
`effector_core` (static) holds image IO, params, effects, rng and links only
|
||||||
`gdk-pixbuf-2.0`, `pangocairo` (the text effect's glyphs; Pango and Cairo need
|
`gdk-pixbuf-2.0`, `pangocairo` (the text effect's glyphs; Pango and Cairo need
|
||||||
no display) + Threads — **no gtkmm, no display**. The `effector` executable
|
no display) + Threads — **no gtkmm, no display**. `effector_gui` (static) adds
|
||||||
adds `src/main.cpp`, `src/cli/`, `src/gui/` and links gtkmm. Keep GTK out of
|
`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.
|
`src/core/` and `src/effects/`; the tests link the core alone and rely on this.
|
||||||
|
|
||||||
### Determinism is a contract
|
### Determinism is a contract
|
||||||
|
|||||||
+28
-6
@@ -1,5 +1,5 @@
|
|||||||
cmake_minimum_required(VERSION 3.20)
|
cmake_minimum_required(VERSION 3.20)
|
||||||
project(effector VERSION 0.1.0 LANGUAGES CXX)
|
project(effector VERSION 0.5.1 LANGUAGES CXX)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
@@ -21,6 +21,14 @@ pkg_check_modules(PANGOCAIRO REQUIRED IMPORTED_TARGET pangocairo)
|
|||||||
|
|
||||||
find_package(Threads REQUIRED)
|
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 ---------------------------------
|
# --- core: effects and image handling, no GUI ---------------------------------
|
||||||
add_library(effector_core STATIC
|
add_library(effector_core STATIC
|
||||||
src/core/canvas.cpp
|
src/core/canvas.cpp
|
||||||
@@ -32,31 +40,45 @@ add_library(effector_core STATIC
|
|||||||
src/core/registry.cpp
|
src/core/registry.cpp
|
||||||
src/core/rng.cpp
|
src/core/rng.cpp
|
||||||
src/core/style.cpp
|
src/core/style.cpp
|
||||||
|
src/effects/bitcrush.cpp
|
||||||
src/effects/blur.cpp
|
src/effects/blur.cpp
|
||||||
|
src/effects/channelsplit.cpp
|
||||||
|
src/effects/corrupt.cpp
|
||||||
|
src/effects/feedback.cpp
|
||||||
src/effects/grain.cpp
|
src/effects/grain.cpp
|
||||||
src/effects/monochrome.cpp
|
src/effects/monochrome.cpp
|
||||||
src/effects/overlay.cpp
|
src/effects/overlay.cpp
|
||||||
|
src/effects/pixelsort.cpp
|
||||||
src/effects/scanlines.cpp
|
src/effects/scanlines.cpp
|
||||||
|
src/effects/shatter.cpp
|
||||||
|
src/effects/slitshift.cpp
|
||||||
src/effects/text.cpp
|
src/effects/text.cpp
|
||||||
src/effects/tiles.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,
|
# The core is GUI-free: it only needs gdk-pixbuf for decoding and encoding,
|
||||||
# so headless rendering works without a display.
|
# so headless rendering works without a display.
|
||||||
target_link_libraries(effector_core PUBLIC PkgConfig::GDKPIXBUF PkgConfig::PANGOCAIRO Threads::Threads)
|
target_link_libraries(effector_core PUBLIC PkgConfig::GDKPIXBUF PkgConfig::PANGOCAIRO Threads::Threads)
|
||||||
target_compile_options(effector_core PRIVATE -Wall -Wextra -Wpedantic)
|
target_compile_options(effector_core PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
|
||||||
# --- application --------------------------------------------------------------
|
# --- interface: the GTK window, split out so the GUI tests can link it --------
|
||||||
add_executable(effector
|
add_library(effector_gui STATIC
|
||||||
src/main.cpp
|
|
||||||
src/cli/options.cpp
|
src/cli/options.cpp
|
||||||
src/gui/app.cpp
|
src/gui/app.cpp
|
||||||
src/gui/window.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)
|
target_compile_options(effector PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
|
||||||
install(TARGETS effector RUNTIME DESTINATION bin)
|
install(TARGETS effector RUNTIME DESTINATION bin)
|
||||||
|
# The launcher entry, so the interface shows up in the desktop's app menu.
|
||||||
|
install(FILES data/org.effector.Effector.desktop DESTINATION share/applications)
|
||||||
|
|
||||||
# --- tests --------------------------------------------------------------------
|
# --- tests --------------------------------------------------------------------
|
||||||
include(CTest)
|
include(CTest)
|
||||||
|
|||||||
@@ -19,12 +19,20 @@ ctest --test-dir build --output-on-failure
|
|||||||
The binary lands at `build/effector`; `cmake --install build --prefix ~/.local`
|
The binary lands at `build/effector`; `cmake --install build --prefix ~/.local`
|
||||||
installs it to `~/.local/bin`.
|
installs it to `~/.local/bin`.
|
||||||
|
|
||||||
|
On Arch, `packaging/arch/PKGBUILD` builds the release tagged `v<version>` from
|
||||||
|
this repository and installs it with pacman, desktop entry included:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packaging/arch && makepkg -si
|
||||||
|
```
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
build/effector # GTK interface
|
build/effector # GTK interface
|
||||||
build/effector --list-effects
|
build/effector --list-effects
|
||||||
build/effector --help
|
build/effector --help
|
||||||
|
build/effector --version
|
||||||
```
|
```
|
||||||
|
|
||||||
Headless rendering, no display needed:
|
Headless rendering, no display needed:
|
||||||
@@ -60,6 +68,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
|
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.
|
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
|
## Effects
|
||||||
|
|
||||||
### scanlines
|
### scanlines
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Effector
|
||||||
|
GenericName=Image Effects
|
||||||
|
Comment=Cascade glitch and collage effects over your photographs
|
||||||
|
Exec=effector %F
|
||||||
|
Icon=applications-graphics
|
||||||
|
Terminal=false
|
||||||
|
Categories=Graphics;2DGraphics;RasterGraphics;GTK;
|
||||||
|
MimeType=image/jpeg;image/png;image/webp;image/tiff;image/bmp;
|
||||||
|
Keywords=glitch;collage;scanlines;photo;effect;
|
||||||
|
StartupNotify=true
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Builds the release tagged v$pkgver from the repository this file sits in,
|
||||||
|
# so pkgver must follow the project() line of CMakeLists.txt (the version core
|
||||||
|
# test checks it). From this folder: makepkg -si
|
||||||
|
pkgname=effector
|
||||||
|
pkgver=0.5.1
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc='Image effects app: a GTK4 interface and a headless CLI over pluggable effects'
|
||||||
|
arch=('x86_64' 'aarch64')
|
||||||
|
url='https://git.plan9.uber.space/hans/effector'
|
||||||
|
license=('unknown')
|
||||||
|
depends=('gtkmm-4.0' 'gdk-pixbuf2' 'pango' 'cairo' 'glib2' 'glibc' 'gcc-libs')
|
||||||
|
makedepends=('cmake' 'ninja' 'git')
|
||||||
|
checkdepends=('xorg-server-xvfb')
|
||||||
|
source=("$pkgname::git+file://${startdir%/packaging/arch}#tag=v$pkgver")
|
||||||
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
|
build() {
|
||||||
|
cmake -S "$pkgname" -B build -G Ninja \
|
||||||
|
-DCMAKE_BUILD_TYPE=Release \
|
||||||
|
-DCMAKE_INSTALL_PREFIX=/usr
|
||||||
|
cmake --build build
|
||||||
|
}
|
||||||
|
|
||||||
|
check() {
|
||||||
|
ctest --test-dir build --output-on-failure
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
DESTDIR="$pkgdir" cmake --install build
|
||||||
|
}
|
||||||
@@ -61,6 +61,8 @@ Options parseOptions(int argc, char** argv) {
|
|||||||
|
|
||||||
if (arg == "-h" || arg == "--help") {
|
if (arg == "-h" || arg == "--help") {
|
||||||
options.showHelp = true;
|
options.showHelp = true;
|
||||||
|
} else if (arg == "-V" || arg == "--version") {
|
||||||
|
options.showVersion = true;
|
||||||
} else if (arg == "--list-effects") {
|
} else if (arg == "--list-effects") {
|
||||||
options.listEffects = true;
|
options.listEffects = true;
|
||||||
} else if (arg == "-e" || arg == "--effect") {
|
} else if (arg == "-e" || arg == "--effect") {
|
||||||
@@ -184,6 +186,7 @@ std::string usage(const char* programName) {
|
|||||||
<< " --disable NAME keep this effect in the chain but skip it\n"
|
<< " --disable NAME keep this effect in the chain but skip it\n"
|
||||||
<< " --seed N random seed (default: random)\n"
|
<< " --seed N random seed (default: random)\n"
|
||||||
<< " --list-effects list available effects and exit\n"
|
<< " --list-effects list available effects and exit\n"
|
||||||
|
<< " -V, --version show the version and exit\n"
|
||||||
<< " -h, --help show this help and exit\n";
|
<< " -h, --help show this help and exit\n";
|
||||||
|
|
||||||
out << "\nCanvas formats (--format):\n";
|
out << "\nCanvas formats (--format):\n";
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ struct Options {
|
|||||||
ParamValues params; // only the keys the user actually passed
|
ParamValues params; // only the keys the user actually passed
|
||||||
bool listEffects = false;
|
bool listEffects = false;
|
||||||
bool showHelp = false;
|
bool showHelp = false;
|
||||||
|
bool showVersion = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Throws EffectError on bad input. Effect flags are generated from the
|
// Throws EffectError on bad input. Effect flags are generated from the
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ public:
|
|||||||
// use. An effect with no kind must declare no styled params.
|
// use. An effect with no kind must declare no styled params.
|
||||||
virtual std::string styleKind() const { return {}; }
|
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,
|
virtual Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||||
const ParamValues& values) const = 0;
|
const ParamValues& values) const = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
#include "core/registry.hpp"
|
#include "core/registry.hpp"
|
||||||
|
|
||||||
|
#include "effects/bitcrush.hpp"
|
||||||
#include "effects/blur.hpp"
|
#include "effects/blur.hpp"
|
||||||
|
#include "effects/channelsplit.hpp"
|
||||||
|
#include "effects/corrupt.hpp"
|
||||||
|
#include "effects/feedback.hpp"
|
||||||
#include "effects/grain.hpp"
|
#include "effects/grain.hpp"
|
||||||
#include "effects/monochrome.hpp"
|
#include "effects/monochrome.hpp"
|
||||||
#include "effects/overlay.hpp"
|
#include "effects/overlay.hpp"
|
||||||
|
#include "effects/pixelsort.hpp"
|
||||||
#include "effects/scanlines.hpp"
|
#include "effects/scanlines.hpp"
|
||||||
|
#include "effects/shatter.hpp"
|
||||||
|
#include "effects/slitshift.hpp"
|
||||||
#include "effects/text.hpp"
|
#include "effects/text.hpp"
|
||||||
#include "effects/tiles.hpp"
|
#include "effects/tiles.hpp"
|
||||||
|
#include "effects/warp.hpp"
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
|
|
||||||
@@ -20,6 +28,14 @@ const std::vector<EffectPtr>& effects() {
|
|||||||
std::make_shared<OverlayEffect>(),
|
std::make_shared<OverlayEffect>(),
|
||||||
std::make_shared<TextEffect>(),
|
std::make_shared<TextEffect>(),
|
||||||
std::make_shared<TilesEffect>(),
|
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;
|
return registry;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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
|
||||||
+151
-2
@@ -32,6 +32,28 @@ const std::vector<ParamSpec>& OverlayEffect::params() const {
|
|||||||
ParamSpec::makeDouble("overlay_opacity", "Opacity", 1.0, 0.0, 1.0,
|
ParamSpec::makeDouble("overlay_opacity", "Opacity", 1.0, 0.0, 1.0,
|
||||||
"How strongly the image is mixed over the input; its own "
|
"How strongly the image is mixed over the input; its own "
|
||||||
"transparency is taken into account as well."),
|
"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;
|
return specs;
|
||||||
}
|
}
|
||||||
@@ -123,6 +145,44 @@ void sampleOverlay(const Image& src, const std::uint8_t* alpha, double u, double
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
} // namespace
|
||||||
|
|
||||||
void OverlayEffect::loadOverlay(const std::string& path, Image& image,
|
void OverlayEffect::loadOverlay(const std::string& path, Image& image,
|
||||||
@@ -187,11 +247,100 @@ Image OverlayEffect::render(const std::vector<Image>& sources, int width, int he
|
|||||||
const int lastRow = tiling ? height : std::min(height, place.y + place.height);
|
const int lastRow = tiling ? height : std::min(height, place.y + place.height);
|
||||||
const int firstCol = tiling ? 0 : std::max(0, place.x);
|
const int firstCol = tiling ? 0 : std::max(0, place.x);
|
||||||
const int lastCol = tiling ? width : std::min(width, place.x + place.width);
|
const int lastCol = tiling ? width : std::min(width, place.x + place.width);
|
||||||
if (firstRow >= lastRow || firstCol >= lastCol) return canvas; // entirely off the canvas
|
|
||||||
|
|
||||||
const int mix = static_cast<int>(opacity * 255.0 + 0.5);
|
const int mix = static_cast<int>(opacity * 255.0 + 0.5);
|
||||||
const std::uint8_t* alphaData = alpha.data();
|
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,
|
parallelFor(static_cast<std::size_t>(lastRow - firstRow), [&](std::size_t begin,
|
||||||
std::size_t end) {
|
std::size_t end) {
|
||||||
int rgb[3];
|
int rgb[3];
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ namespace effector {
|
|||||||
|
|
||||||
// Lays one picture from disk over its input: a logo, a texture, a frame. The
|
// 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
|
// overlay is fitted to the canvas in the chosen way, placed from the top left
|
||||||
// or from the centre, nudged by an offset and mixed in by its opacity. Like the
|
// 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
|
// 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.
|
// sources it fills the canvas from a randomly chosen one first.
|
||||||
class OverlayEffect : public Effect {
|
class OverlayEffect : public Effect {
|
||||||
@@ -20,7 +21,8 @@ public:
|
|||||||
std::string title() const override { return "Image overlay"; }
|
std::string title() const override { return "Image overlay"; }
|
||||||
std::string description() const override {
|
std::string description() const override {
|
||||||
return "Lays a single image from disk over its input, fitted to the canvas, centred or "
|
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.";
|
"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;
|
const std::vector<ParamSpec>& params() const override;
|
||||||
bool needsSources() const override { return false; }
|
bool needsSources() const override { return false; }
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include "core/image.hpp"
|
#include "core/image.hpp"
|
||||||
#include "core/parallel.hpp"
|
#include "core/parallel.hpp"
|
||||||
|
#include "core/rng.hpp"
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
|
|
||||||
@@ -35,4 +37,20 @@ inline void fillFromSource(Image& canvas, const Image& src, int sx, int sy) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
} // namespace effector
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <numbers>
|
||||||
|
|
||||||
#include "core/parallel.hpp"
|
#include "core/parallel.hpp"
|
||||||
#include "effects/source_fill.hpp"
|
#include "effects/source_fill.hpp"
|
||||||
@@ -57,6 +58,10 @@ const std::vector<ParamSpec>& TextEffect::params() const {
|
|||||||
"Moves the text right by this many pixels (left when negative)."),
|
"Moves the text right by this many pixels (left when negative)."),
|
||||||
ParamSpec::makeInt("text_offset_y", "Offset Y", 0, -20000, 20000,
|
ParamSpec::makeInt("text_offset_y", "Offset Y", 0, -20000, 20000,
|
||||||
"Moves the text down by this many pixels (up when negative)."),
|
"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,
|
ParamSpec::makeDouble("text_opacity", "Text opacity", 1.0, 0.0, 1.0,
|
||||||
"How strongly the glyphs are mixed over what is behind them.")
|
"How strongly the glyphs are mixed over what is behind them.")
|
||||||
.styled(),
|
.styled(),
|
||||||
@@ -198,6 +203,22 @@ void TextEffect::loadBoxImage(const std::string& path, Image& image,
|
|||||||
alpha = cachedAlpha_;
|
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,
|
Image TextEffect::render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||||
const ParamValues& values) const {
|
const ParamValues& values) const {
|
||||||
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
|
if (width < 1 || height < 1) throw EffectError("Canvas size must be at least 1x1 pixels.");
|
||||||
@@ -264,6 +285,18 @@ Image TextEffect::render(const std::vector<Image>& sources, int width, int heigh
|
|||||||
const double boxY = (centre ? (height - boxHeight) / 2.0 : 0.0) +
|
const double boxY = (centre ? (height - boxHeight) / 2.0 : 0.0) +
|
||||||
static_cast<double>(paramInt(values, "text_offset_y", 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) {
|
if (drawsBox) {
|
||||||
cairo_save(cr.get);
|
cairo_save(cr.get);
|
||||||
cairo_rectangle(cr.get, boxX, boxY, boxWidth, boxHeight);
|
cairo_rectangle(cr.get, boxX, boxY, boxWidth, boxHeight);
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ public:
|
|||||||
const std::vector<ParamSpec>& params() const override;
|
const std::vector<ParamSpec>& params() const override;
|
||||||
std::string styleKind() const override { return "text"; }
|
std::string styleKind() const override { return "text"; }
|
||||||
bool needsSources() const override { return false; }
|
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,
|
Image render(const std::vector<Image>& sources, int width, int height, Rng& rng,
|
||||||
const ParamValues& values) const override;
|
const ParamValues& values) const override;
|
||||||
|
|||||||
@@ -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
|
||||||
+640
-46
@@ -13,6 +13,7 @@
|
|||||||
#include "core/project.hpp"
|
#include "core/project.hpp"
|
||||||
#include "core/registry.hpp"
|
#include "core/registry.hpp"
|
||||||
#include "core/rng.hpp"
|
#include "core/rng.hpp"
|
||||||
|
#include "core/version.hpp"
|
||||||
|
|
||||||
namespace effector {
|
namespace effector {
|
||||||
namespace {
|
namespace {
|
||||||
@@ -21,6 +22,11 @@ const char* const kImagePatterns[] = {"*.png", "*.jpg", "*.jpeg", "*.webp", "*
|
|||||||
"*.tif", "*.tiff", "*.gif", "*.ppm", "*.PNG",
|
"*.tif", "*.tiff", "*.gif", "*.ppm", "*.PNG",
|
||||||
"*.JPG", "*.JPEG", "*.WEBP"};
|
"*.JPG", "*.JPEG", "*.WEBP"};
|
||||||
|
|
||||||
|
// How this program signs the entries it adds to the shared recent list, so
|
||||||
|
// Open Recent lists its projects and nothing else.
|
||||||
|
const char* const kRecentApp = "effector";
|
||||||
|
const std::size_t kRecentCount = 10;
|
||||||
|
|
||||||
Glib::RefPtr<Gio::ListStore<Gtk::FileFilter>> projectFilters() {
|
Glib::RefPtr<Gio::ListStore<Gtk::FileFilter>> projectFilters() {
|
||||||
auto filter = Gtk::FileFilter::create();
|
auto filter = Gtk::FileFilter::create();
|
||||||
filter->set_name("Effector projects");
|
filter->set_name("Effector projects");
|
||||||
@@ -43,6 +49,40 @@ Gtk::Label* sectionLabel(const char* text) {
|
|||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A GtkSpinButton takes every wheel turn over it: it grabs the focus and
|
||||||
|
// spins its value, so scrolling down a column of params stops dead at the
|
||||||
|
// first spin under the pointer and quietly changes it. This hands the wheel to
|
||||||
|
// the view instead unless the spin already has the focus -- the user clicked
|
||||||
|
// into it and means to adjust it. It runs in the capture phase, ahead of the
|
||||||
|
// spin's own controller, and so has to scroll the enclosing ScrolledWindow
|
||||||
|
// itself: stopping the event there stops it for the window as well.
|
||||||
|
void scrollPastUnlessFocused(Gtk::SpinButton& spin) {
|
||||||
|
auto scroll = Gtk::EventControllerScroll::create();
|
||||||
|
scroll->set_propagation_phase(Gtk::PropagationPhase::CAPTURE);
|
||||||
|
scroll->set_flags(Gtk::EventControllerScroll::Flags::VERTICAL);
|
||||||
|
auto* controller = scroll.get();
|
||||||
|
scroll->signal_scroll().connect(
|
||||||
|
[&spin, controller](double, double dy) {
|
||||||
|
if ((spin.get_state_flags() & Gtk::StateFlags::FOCUS_WITHIN) == Gtk::StateFlags::FOCUS_WITHIN) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
auto* scroller = spin.get_ancestor(GTK_TYPE_SCROLLED_WINDOW);
|
||||||
|
if (scroller == nullptr) return true;
|
||||||
|
auto adjustment = static_cast<Gtk::ScrolledWindow*>(scroller)->get_vadjustment();
|
||||||
|
// The step a ScrolledWindow itself takes for one notch of a wheel;
|
||||||
|
// a touchpad already reports its deltas in pixels.
|
||||||
|
double step = dy;
|
||||||
|
if (controller->get_unit() == Gdk::ScrollUnit::WHEEL) {
|
||||||
|
step *= std::pow(adjustment->get_page_size(), 2.0 / 3.0);
|
||||||
|
}
|
||||||
|
adjustment->set_value(std::clamp(adjustment->get_value() + step, adjustment->get_lower(),
|
||||||
|
adjustment->get_upper() - adjustment->get_page_size()));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
false);
|
||||||
|
spin.add_controller(scroll);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
EffectorWindow::EffectorWindow(const Options& initial) {
|
EffectorWindow::EffectorWindow(const Options& initial) {
|
||||||
@@ -73,6 +113,8 @@ EffectorWindow::EffectorWindow(const Options& initial) {
|
|||||||
|
|
||||||
EffectorWindow::~EffectorWindow() {
|
EffectorWindow::~EffectorWindow() {
|
||||||
debounce_.disconnect();
|
debounce_.disconnect();
|
||||||
|
chainSplitFit_.disconnect();
|
||||||
|
recentChanged_.disconnect();
|
||||||
loadWindowDelay_.disconnect();
|
loadWindowDelay_.disconnect();
|
||||||
loadCancel_.store(true, std::memory_order_relaxed);
|
loadCancel_.store(true, std::memory_order_relaxed);
|
||||||
if (loader_.joinable()) loader_.join();
|
if (loader_.joinable()) loader_.join();
|
||||||
@@ -118,29 +160,140 @@ void EffectorWindow::buildUi() {
|
|||||||
root->append(*paned);
|
root->append(*paned);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The chain is what the interface is about, so it comes first and the params
|
// The sidebar is two tabs. "Chain" is what the interface is about: the stages
|
||||||
// of the selected stage sit right below it. The sources are a prerequisite,
|
// and the params of the selected one, with the whole height to themselves.
|
||||||
// not the subject: they live at the bottom in a collapsed expander.
|
// "Setup" holds what is set once per project -- the sources, the canvas and
|
||||||
|
// the seed -- so it never takes room from the chain however long that grows.
|
||||||
Gtk::Widget& EffectorWindow::buildSidebar() {
|
Gtk::Widget& EffectorWindow::buildSidebar() {
|
||||||
|
auto* stack = Gtk::make_managed<Gtk::Stack>();
|
||||||
|
stack->set_vexpand(true);
|
||||||
|
stack->set_transition_type(Gtk::StackTransitionType::CROSSFADE);
|
||||||
|
stack->add(buildChainTab(), "chain", "Chain");
|
||||||
|
stack->add(buildSetupTab(), "setup", "Setup");
|
||||||
|
|
||||||
|
auto* switcher = Gtk::make_managed<Gtk::StackSwitcher>();
|
||||||
|
switcher->set_stack(*stack);
|
||||||
|
switcher->set_halign(Gtk::Align::CENTER);
|
||||||
|
|
||||||
auto* sidebar = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
|
auto* sidebar = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
|
||||||
sidebar->set_margin(12);
|
sidebar->set_margin(12);
|
||||||
|
sidebar->append(*switcher);
|
||||||
|
sidebar->append(*stack);
|
||||||
|
return *sidebar;
|
||||||
|
}
|
||||||
|
|
||||||
sidebar->append(buildChainSection());
|
// The chain and the params of the selected stage share the tab through a
|
||||||
|
// split. Until the user drags it, the split rests under the chain's rows
|
||||||
|
// (fitChainSplit), so a short chain leaves the tab to the params and a long
|
||||||
|
// one grows up to chainListMaxHeight and then scrolls; once dragged, it stays
|
||||||
|
// where it was put.
|
||||||
|
// The tallest the chain list grows on its own before it scrolls, in pixels:
|
||||||
|
// room for eight stages.
|
||||||
|
constexpr int chainListMaxHeight = 270;
|
||||||
|
|
||||||
|
Gtk::Widget& EffectorWindow::buildChainTab() {
|
||||||
|
auto* params = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
|
||||||
|
params->set_margin_top(12);
|
||||||
paramHeading_.set_xalign(0.0F);
|
paramHeading_.set_xalign(0.0F);
|
||||||
paramHeading_.add_css_class("heading");
|
paramHeading_.add_css_class("heading");
|
||||||
sidebar->append(paramHeading_);
|
params->append(paramHeading_);
|
||||||
paramGrid_.set_column_spacing(8);
|
paramGrid_.set_column_spacing(8);
|
||||||
paramGrid_.set_row_spacing(6);
|
paramGrid_.set_row_spacing(6);
|
||||||
|
// A gutter for the overlay scrollbar, which otherwise sits over the spin
|
||||||
|
// buttons' "+" and takes the clicks meant for it.
|
||||||
|
paramGrid_.set_margin_end(14);
|
||||||
auto* paramScroller = Gtk::make_managed<Gtk::ScrolledWindow>();
|
auto* paramScroller = Gtk::make_managed<Gtk::ScrolledWindow>();
|
||||||
paramScroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
|
paramScroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
|
||||||
paramScroller->set_child(paramGrid_);
|
paramScroller->set_child(paramGrid_);
|
||||||
paramScroller->set_vexpand(true);
|
paramScroller->set_vexpand(true);
|
||||||
sidebar->append(*paramScroller);
|
params->append(*paramScroller);
|
||||||
|
|
||||||
|
auto& chain = buildChainSection();
|
||||||
|
chain.set_margin_bottom(6);
|
||||||
|
|
||||||
|
auto* split = Gtk::make_managed<Gtk::Paned>(Gtk::Orientation::VERTICAL);
|
||||||
|
split->set_start_child(chain);
|
||||||
|
split->set_end_child(*params);
|
||||||
|
split->set_resize_start_child(false);
|
||||||
|
split->set_resize_end_child(true);
|
||||||
|
split->set_shrink_start_child(false);
|
||||||
|
split->set_shrink_end_child(false);
|
||||||
|
chainSplit_ = split;
|
||||||
|
split->property_position().signal_changed().connect([this] { onChainSplitMoved(); });
|
||||||
|
// The room the split has changes with the window, and a fit it had to
|
||||||
|
// clamp may fit whole now.
|
||||||
|
split->property_max_position().signal_changed().connect([this] { scheduleChainSplitFit(); });
|
||||||
|
// A double-click on the handle undoes the drag: the split fits the rows
|
||||||
|
// again, and follows them from then on. The handle is the one child of the
|
||||||
|
// split that is neither pane, but the click is heard on the split itself,
|
||||||
|
// grouped with the split's own drag: that drag claims every press on the
|
||||||
|
// handle, which would cancel a click gesture anywhere else.
|
||||||
|
Gtk::Widget* handle = nullptr;
|
||||||
|
for (auto* child = split->get_first_child(); child != nullptr; child = child->get_next_sibling()) {
|
||||||
|
if (child != &chain && child != params) handle = child;
|
||||||
|
}
|
||||||
|
if (handle != nullptr) {
|
||||||
|
handle->set_tooltip_text("Drag to share the room, double-click to fit the chain");
|
||||||
|
auto click = Gtk::GestureClick::create();
|
||||||
|
click->set_propagation_phase(Gtk::PropagationPhase::CAPTURE);
|
||||||
|
// The split's drag ends every press on the handle, which starts the
|
||||||
|
// click's count over, so the second press is counted here. And that
|
||||||
|
// drag is still going on during the second press -- the least jitter
|
||||||
|
// puts the handle back under the pointer -- so the fit waits for it
|
||||||
|
// to end.
|
||||||
|
std::vector<Glib::RefPtr<Gtk::GestureDrag>> drags;
|
||||||
|
const auto controllers = split->observe_controllers();
|
||||||
|
for (guint i = 0; i < controllers->get_n_items(); ++i) {
|
||||||
|
auto other = std::dynamic_pointer_cast<Gtk::Gesture>(controllers->get_object(i));
|
||||||
|
if (other) click->group(other);
|
||||||
|
if (auto drag = std::dynamic_pointer_cast<Gtk::GestureDrag>(other)) drags.push_back(drag);
|
||||||
|
}
|
||||||
|
auto lastPress = std::make_shared<guint32>(0);
|
||||||
|
auto refitPending = std::make_shared<bool>(false);
|
||||||
|
for (const auto& drag : drags) {
|
||||||
|
drag->signal_drag_end().connect([this, refitPending](double, double) {
|
||||||
|
if (!*refitPending) return;
|
||||||
|
*refitPending = false;
|
||||||
|
refitChainSplit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
auto* gesture = click.get(); // not the RefPtr: the gesture owns this slot
|
||||||
|
const bool waitForDrag = !drags.empty();
|
||||||
|
click->signal_pressed().connect(
|
||||||
|
[this, split, handle, gesture, lastPress, refitPending, waitForDrag](int, double x, double y) {
|
||||||
|
// The handle is thin, and the drag takes presses a little past it.
|
||||||
|
const auto bounds = handle->compute_bounds(*split);
|
||||||
|
constexpr double slack = 6.0;
|
||||||
|
if (!bounds || y < bounds->get_y() - slack ||
|
||||||
|
y > bounds->get_y() + bounds->get_height() + slack ||
|
||||||
|
x < bounds->get_x() || x > bounds->get_x() + bounds->get_width()) {
|
||||||
|
*lastPress = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const guint32 now = gesture->get_current_event_time();
|
||||||
|
const int doubleClick = Gtk::Settings::get_default()->property_gtk_double_click_time();
|
||||||
|
if (*lastPress != 0 && now - *lastPress <= static_cast<guint32>(doubleClick)) {
|
||||||
|
*lastPress = 0;
|
||||||
|
if (waitForDrag) {
|
||||||
|
*refitPending = true;
|
||||||
|
} else {
|
||||||
|
refitChainSplit();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
*lastPress = now;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
split->add_controller(click);
|
||||||
|
}
|
||||||
rebuildChainRows();
|
rebuildChainRows();
|
||||||
buildParamWidgets();
|
buildParamWidgets();
|
||||||
|
return *split;
|
||||||
|
}
|
||||||
|
|
||||||
sidebar->append(*Gtk::make_managed<Gtk::Separator>(Gtk::Orientation::HORIZONTAL));
|
// The sources come first because they are the first thing a project needs;
|
||||||
|
// their list takes whatever height the canvas and seed leave over.
|
||||||
|
Gtk::Widget& EffectorWindow::buildSetupTab() {
|
||||||
|
auto* tab = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 12);
|
||||||
|
|
||||||
sourceList_.set_selection_mode(Gtk::SelectionMode::NONE);
|
sourceList_.set_selection_mode(Gtk::SelectionMode::NONE);
|
||||||
sourceList_.add_css_class("boxed-list");
|
sourceList_.add_css_class("boxed-list");
|
||||||
@@ -153,10 +306,16 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
|
|||||||
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
|
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
|
||||||
scroller->set_child(sourceList_);
|
scroller->set_child(sourceList_);
|
||||||
scroller->set_size_request(-1, 140);
|
scroller->set_size_request(-1, 140);
|
||||||
scroller->set_margin_top(6);
|
scroller->set_vexpand(true);
|
||||||
|
|
||||||
auto* sourcesBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
|
sourcesHeading_.set_xalign(0.0F);
|
||||||
sourcesBox->append(*scroller);
|
sourcesHeading_.add_css_class("heading");
|
||||||
|
sourcesSection_.set_name("sources");
|
||||||
|
sourcesSection_.set_orientation(Gtk::Orientation::VERTICAL);
|
||||||
|
sourcesSection_.set_spacing(6);
|
||||||
|
sourcesSection_.set_vexpand(true);
|
||||||
|
sourcesSection_.append(sourcesHeading_);
|
||||||
|
sourcesSection_.append(*scroller);
|
||||||
|
|
||||||
// Same shape as "Add effect": the icon and the text are put together by
|
// Same shape as "Add effect": the icon and the text are put together by
|
||||||
// hand because set_label() and set_icon_name() replace one another.
|
// hand because set_label() and set_icon_name() replace one another.
|
||||||
@@ -169,16 +328,14 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
|
|||||||
addButton->set_halign(Gtk::Align::START);
|
addButton->set_halign(Gtk::Align::START);
|
||||||
addButton->set_tooltip_text("Add source images");
|
addButton->set_tooltip_text("Add source images");
|
||||||
addButton->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onAddImages));
|
addButton->signal_clicked().connect(sigc::mem_fun(*this, &EffectorWindow::onAddImages));
|
||||||
sourcesBox->append(*addButton);
|
sourcesSection_.append(*addButton);
|
||||||
addButton_ = addButton;
|
addButton_ = addButton;
|
||||||
|
|
||||||
sourcesExpander_.set_child(*sourcesBox);
|
|
||||||
sourcesExpander_.set_expanded(false);
|
|
||||||
updateSourcesLabel();
|
updateSourcesLabel();
|
||||||
sidebar->append(sourcesExpander_);
|
tab->append(sourcesSection_);
|
||||||
|
|
||||||
sidebar->append(*sectionLabel("Canvas"));
|
tab->append(*sectionLabel("Canvas"));
|
||||||
sidebar->append(buildCanvasSection());
|
tab->append(buildCanvasSection());
|
||||||
|
|
||||||
auto* seedBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
|
auto* seedBox = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 8);
|
||||||
auto* seedLabel = Gtk::make_managed<Gtk::Label>("Seed");
|
auto* seedLabel = Gtk::make_managed<Gtk::Label>("Seed");
|
||||||
@@ -188,9 +345,9 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
|
|||||||
seedEntry_.set_text(std::to_string(seed_));
|
seedEntry_.set_text(std::to_string(seed_));
|
||||||
seedEntry_.signal_activate().connect(sigc::mem_fun(*this, &EffectorWindow::onSeedEntered));
|
seedEntry_.signal_activate().connect(sigc::mem_fun(*this, &EffectorWindow::onSeedEntered));
|
||||||
seedBox->append(seedEntry_);
|
seedBox->append(seedEntry_);
|
||||||
sidebar->append(*seedBox);
|
tab->append(*seedBox);
|
||||||
|
|
||||||
return *sidebar;
|
return *tab;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only the first stage is ever handed the sources, so whether the chain wants
|
// Only the first stage is ever handed the sources, so whether the chain wants
|
||||||
@@ -199,15 +356,14 @@ Gtk::Widget& EffectorWindow::buildSidebar() {
|
|||||||
// rather than left there to be filled in vain. This is the same declarative
|
// rather than left there to be filled in vain. This is the same declarative
|
||||||
// answer the render gate uses -- no effect is named here.
|
// answer the render gate uses -- no effect is named here.
|
||||||
void EffectorWindow::updateSourcesVisible() {
|
void EffectorWindow::updateSourcesVisible() {
|
||||||
sourcesExpander_.set_visible(chainNeedsSources(stages_));
|
sourcesSection_.set_visible(chainNeedsSources(stages_));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The expander is collapsed by default, so its label carries the count that
|
// The heading carries the count, which a long list scrolled away would hide.
|
||||||
// used to be plain to see in the list.
|
|
||||||
void EffectorWindow::updateSourcesLabel() {
|
void EffectorWindow::updateSourcesLabel() {
|
||||||
int count = 0;
|
int count = 0;
|
||||||
while (sourceList_.get_row_at_index(count) != nullptr) ++count;
|
while (sourceList_.get_row_at_index(count) != nullptr) ++count;
|
||||||
sourcesExpander_.set_label(count == 0 ? "Source images"
|
sourcesHeading_.set_text(count == 0 ? "Source images"
|
||||||
: "Source images (" + std::to_string(count) + ")");
|
: "Source images (" + std::to_string(count) + ")");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +400,7 @@ Gtk::Widget& EffectorWindow::buildCanvasSection() {
|
|||||||
dpiSpin_.set_value(kDefaultDpi);
|
dpiSpin_.set_value(kDefaultDpi);
|
||||||
for (auto* spin : {&widthSpin_, &heightSpin_, &dpiSpin_}) {
|
for (auto* spin : {&widthSpin_, &heightSpin_, &dpiSpin_}) {
|
||||||
spin->set_hexpand(true);
|
spin->set_hexpand(true);
|
||||||
|
scrollPastUnlessFocused(*spin);
|
||||||
}
|
}
|
||||||
for (auto* spin : {&widthSpin_, &heightSpin_}) {
|
for (auto* spin : {&widthSpin_, &heightSpin_}) {
|
||||||
spin->signal_value_changed().connect([this] {
|
spin->signal_value_changed().connect([this] {
|
||||||
@@ -331,7 +488,7 @@ Gtk::Widget& EffectorWindow::buildPreview() {
|
|||||||
picture_.set_can_shrink(true);
|
picture_.set_can_shrink(true);
|
||||||
picture_.set_content_fit(Gtk::ContentFit::CONTAIN);
|
picture_.set_content_fit(Gtk::ContentFit::CONTAIN);
|
||||||
|
|
||||||
placeholder_.set_text("Add source images to start.");
|
placeholder_.set_text("Add source images under Setup to start.");
|
||||||
placeholder_.set_wrap(true);
|
placeholder_.set_wrap(true);
|
||||||
placeholder_.add_css_class("dim-label");
|
placeholder_.add_css_class("dim-label");
|
||||||
|
|
||||||
@@ -350,6 +507,12 @@ Gtk::Widget& EffectorWindow::buildPreview() {
|
|||||||
|
|
||||||
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL);
|
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL);
|
||||||
box->append(*overlay);
|
box->append(*overlay);
|
||||||
|
// A long chain makes a long status line; left to its natural width it
|
||||||
|
// would widen the preview column past the pane and push the picture off
|
||||||
|
// centre. Ellipsized, it never asks for more room than it is given.
|
||||||
|
status_.set_ellipsize(Pango::EllipsizeMode::END);
|
||||||
|
status_.set_margin_start(8);
|
||||||
|
status_.set_margin_end(8);
|
||||||
status_.set_margin_top(6);
|
status_.set_margin_top(6);
|
||||||
status_.set_margin_bottom(8);
|
status_.set_margin_bottom(8);
|
||||||
status_.add_css_class("dim-label");
|
status_.add_css_class("dim-label");
|
||||||
@@ -434,6 +597,7 @@ void EffectorWindow::buildParamWidgets() {
|
|||||||
: paramDouble(values, spec.key, spec.defaultValue));
|
: paramDouble(values, spec.key, spec.defaultValue));
|
||||||
spin->signal_value_changed().connect(
|
spin->signal_value_changed().connect(
|
||||||
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
sigc::mem_fun(*this, &EffectorWindow::onParamChanged));
|
||||||
|
scrollPastUnlessFocused(*spin);
|
||||||
widget = std::move(spin);
|
widget = std::move(spin);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1129,10 +1293,50 @@ void EffectorWindow::onImagesChosen(const Glib::RefPtr<Gio::AsyncResult>& result
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- chain ---
|
// ---------------------------------------------------------------- chain ---
|
||||||
|
|
||||||
Gtk::Widget& EffectorWindow::buildChainSection() {
|
// The move and remove buttons are faded out until the pointer is over their
|
||||||
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
|
// row, or the row is selected or focused, so a long chain reads as a list of
|
||||||
box->append(*sectionLabel("Effect chain"));
|
// names rather than a column of icons. They keep their place while hidden,
|
||||||
|
// so the names do not jump as the pointer moves.
|
||||||
|
const char* const stageRowCss = R"css(
|
||||||
|
row.stage-row .stage-action {
|
||||||
|
min-width: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 120ms;
|
||||||
|
}
|
||||||
|
row.stage-row:hover .stage-action,
|
||||||
|
row.stage-row:selected .stage-action,
|
||||||
|
row.stage-row:focus-within .stage-action {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
row.stage-row.drop-before {
|
||||||
|
box-shadow: inset 0 2px @theme_fg_color;
|
||||||
|
}
|
||||||
|
row.stage-row.drop-after {
|
||||||
|
box-shadow: inset 0 -2px @theme_fg_color;
|
||||||
|
}
|
||||||
|
)css";
|
||||||
|
|
||||||
|
Gtk::Widget& EffectorWindow::buildChainSection() {
|
||||||
|
auto css = Gtk::CssProvider::create();
|
||||||
|
css->load_from_string(stageRowCss);
|
||||||
|
Gtk::StyleProvider::add_provider_for_display(Gdk::Display::get_default(), css,
|
||||||
|
GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
|
||||||
|
|
||||||
|
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::VERTICAL, 6);
|
||||||
|
|
||||||
|
// "Add effect" sits beside the heading rather than under the list: under
|
||||||
|
// it, the split could drag it so far down the screen that the menu of
|
||||||
|
// effects had no room left to open.
|
||||||
|
auto* heading = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 6);
|
||||||
|
auto* title = sectionLabel("Effect chain");
|
||||||
|
title->set_hexpand(true);
|
||||||
|
heading->append(*title);
|
||||||
|
heading->append(addEffectButton_);
|
||||||
|
box->append(*heading);
|
||||||
|
|
||||||
|
chainList_.set_name("chain-list");
|
||||||
chainList_.add_css_class("boxed-list");
|
chainList_.add_css_class("boxed-list");
|
||||||
chainList_.set_selection_mode(Gtk::SelectionMode::SINGLE);
|
chainList_.set_selection_mode(Gtk::SelectionMode::SINGLE);
|
||||||
auto* empty = Gtk::make_managed<Gtk::Label>("No effects yet");
|
auto* empty = Gtk::make_managed<Gtk::Label>("No effects yet");
|
||||||
@@ -1142,10 +1346,44 @@ Gtk::Widget& EffectorWindow::buildChainSection() {
|
|||||||
chainList_.signal_row_selected().connect(
|
chainList_.signal_row_selected().connect(
|
||||||
[this](Gtk::ListBoxRow*) { onStageSelected(); });
|
[this](Gtk::ListBoxRow*) { onStageSelected(); });
|
||||||
|
|
||||||
|
// A row is dragged by its index (see rebuildChainRows) and dropped between
|
||||||
|
// two rows, which a line along the edge of the row below or above shows.
|
||||||
|
auto drop = Gtk::DropTarget::create(Glib::Value<int>::value_type(), Gdk::DragAction::MOVE);
|
||||||
|
drop->signal_motion().connect(
|
||||||
|
[this](double, double y) {
|
||||||
|
showChainDropSlot(chainDropSlot(y));
|
||||||
|
return Gdk::DragAction::MOVE;
|
||||||
|
},
|
||||||
|
false);
|
||||||
|
drop->signal_leave().connect([this] { showChainDropSlot(-1); });
|
||||||
|
drop->signal_drop().connect(
|
||||||
|
[this](const Glib::ValueBase& value, double, double y) {
|
||||||
|
showChainDropSlot(-1);
|
||||||
|
Glib::Value<int> from;
|
||||||
|
from.init(value.gobj());
|
||||||
|
const int index = from.get();
|
||||||
|
const int slot = chainDropSlot(y);
|
||||||
|
// The slot counts the dragged row itself, which leaves its place
|
||||||
|
// before it takes the new one.
|
||||||
|
const int to = slot > index ? slot - 1 : slot;
|
||||||
|
// Not while the drag is still finishing: the move rebuilds the
|
||||||
|
// rows, the dragged one and its drag source with them.
|
||||||
|
Glib::signal_idle().connect_once([this, index, to] { moveStage(index, to); });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
false);
|
||||||
|
chainList_.add_controller(drop);
|
||||||
|
|
||||||
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
|
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
|
||||||
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
|
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
|
||||||
scroller->set_child(chainList_);
|
scroller->set_child(chainList_);
|
||||||
scroller->set_size_request(-1, 150);
|
// The list asks for the height of its rows, up to a cap, which is where
|
||||||
|
// the split above the params rests until it is dragged; the size request
|
||||||
|
// is only the least it may be dragged down to.
|
||||||
|
scroller->set_propagate_natural_height(true);
|
||||||
|
scroller->set_max_content_height(chainListMaxHeight);
|
||||||
|
scroller->set_size_request(-1, 100);
|
||||||
|
scroller->set_vexpand(true);
|
||||||
box->append(*scroller);
|
box->append(*scroller);
|
||||||
|
|
||||||
// set_label() and set_icon_name() replace one another, so the icon and the
|
// set_label() and set_icon_name() replace one another, so the icon and the
|
||||||
@@ -1158,12 +1396,14 @@ Gtk::Widget& EffectorWindow::buildChainSection() {
|
|||||||
addEffectButton_.set_always_show_arrow(true);
|
addEffectButton_.set_always_show_arrow(true);
|
||||||
addEffectButton_.set_tooltip_text("Append an effect, applied to the output so far");
|
addEffectButton_.set_tooltip_text("Append an effect, applied to the output so far");
|
||||||
addEffectButton_.set_popover(static_cast<Gtk::Popover&>(buildAddMenu()));
|
addEffectButton_.set_popover(static_cast<Gtk::Popover&>(buildAddMenu()));
|
||||||
addEffectButton_.set_halign(Gtk::Align::START);
|
addEffectButton_.set_name("add-effect");
|
||||||
box->append(addEffectButton_);
|
|
||||||
|
|
||||||
return *box;
|
return *box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The tallest the menu of effects grows before it scrolls, in pixels.
|
||||||
|
constexpr int addMenuMaxHeight = 480;
|
||||||
|
|
||||||
// One entry per registered effect, so a new effect shows up here without this
|
// One entry per registered effect, so a new effect shows up here without this
|
||||||
// file knowing anything about it.
|
// file knowing anything about it.
|
||||||
Gtk::Widget& EffectorWindow::buildAddMenu() {
|
Gtk::Widget& EffectorWindow::buildAddMenu() {
|
||||||
@@ -1182,7 +1422,15 @@ Gtk::Widget& EffectorWindow::buildAddMenu() {
|
|||||||
list->append(*item);
|
list->append(*item);
|
||||||
}
|
}
|
||||||
|
|
||||||
popover->set_child(*list);
|
// A popover that does not fit on the screen is not shown at all, and the
|
||||||
|
// list grows with every effect registered, so past a point it scrolls.
|
||||||
|
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
|
||||||
|
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
|
||||||
|
scroller->set_propagate_natural_height(true);
|
||||||
|
scroller->set_max_content_height(addMenuMaxHeight);
|
||||||
|
scroller->set_child(*list);
|
||||||
|
|
||||||
|
popover->set_child(*scroller);
|
||||||
return *popover;
|
return *popover;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1205,12 +1453,13 @@ void EffectorWindow::removeStage(int index) {
|
|||||||
requestRender();
|
requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
void EffectorWindow::moveStage(int index, int delta) {
|
void EffectorWindow::moveStage(int from, int to) {
|
||||||
const int target = index + delta;
|
const auto count = static_cast<int>(stages_.size());
|
||||||
if (index < 0 || static_cast<std::size_t>(index) >= stages_.size()) return;
|
if (from < 0 || from >= count || to < 0 || to >= count || from == to) return;
|
||||||
if (target < 0 || static_cast<std::size_t>(target) >= stages_.size()) return;
|
ChainStage stage = std::move(stages_[static_cast<std::size_t>(from)]);
|
||||||
std::swap(stages_[static_cast<std::size_t>(index)], stages_[static_cast<std::size_t>(target)]);
|
stages_.erase(stages_.begin() + from);
|
||||||
selectedStage_ = target;
|
stages_.insert(stages_.begin() + to, std::move(stage));
|
||||||
|
selectedStage_ = to;
|
||||||
rebuildChainRows();
|
rebuildChainRows();
|
||||||
buildParamWidgets();
|
buildParamWidgets();
|
||||||
requestRender();
|
requestRender();
|
||||||
@@ -1223,6 +1472,7 @@ void EffectorWindow::rebuildChainRows() {
|
|||||||
// Only the rows: the placeholder is a child of the list box as well, and
|
// Only the rows: the placeholder is a child of the list box as well, and
|
||||||
// removing it would leave the empty list blank.
|
// removing it would leave the empty list blank.
|
||||||
while (auto* row = chainList_.get_row_at_index(0)) chainList_.remove(*row);
|
while (auto* row = chainList_.get_row_at_index(0)) chainList_.remove(*row);
|
||||||
|
stageLabels_.clear();
|
||||||
|
|
||||||
// The first stage that actually runs is the one handed the sources, which
|
// The first stage that actually runs is the one handed the sources, which
|
||||||
// is not stage 1 once stage 1 is switched off.
|
// is not stage 1 once stage 1 is switched off.
|
||||||
@@ -1233,9 +1483,10 @@ void EffectorWindow::rebuildChainRows() {
|
|||||||
for (std::size_t i = 0; i < stages_.size(); ++i) {
|
for (std::size_t i = 0; i < stages_.size(); ++i) {
|
||||||
const int index = static_cast<int>(i);
|
const int index = static_cast<int>(i);
|
||||||
auto* row = Gtk::make_managed<Gtk::ListBoxRow>();
|
auto* row = Gtk::make_managed<Gtk::ListBoxRow>();
|
||||||
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 4);
|
row->add_css_class("stage-row");
|
||||||
box->set_margin_top(4);
|
auto* box = Gtk::make_managed<Gtk::Box>(Gtk::Orientation::HORIZONTAL, 2);
|
||||||
box->set_margin_bottom(4);
|
box->set_margin_top(1);
|
||||||
|
box->set_margin_bottom(1);
|
||||||
box->set_margin_start(8);
|
box->set_margin_start(8);
|
||||||
box->set_margin_end(4);
|
box->set_margin_end(4);
|
||||||
|
|
||||||
@@ -1252,8 +1503,8 @@ void EffectorWindow::rebuildChainRows() {
|
|||||||
});
|
});
|
||||||
box->append(*check);
|
box->append(*check);
|
||||||
|
|
||||||
auto* label = Gtk::make_managed<Gtk::Label>(std::to_string(index + 1) + ". " +
|
auto* label = Gtk::make_managed<Gtk::Label>(stageLabel(i));
|
||||||
stages_[i].effect->title());
|
stageLabels_.push_back(label);
|
||||||
label->set_xalign(0.0F);
|
label->set_xalign(0.0F);
|
||||||
label->set_hexpand(true);
|
label->set_hexpand(true);
|
||||||
label->set_ellipsize(Pango::EllipsizeMode::END);
|
label->set_ellipsize(Pango::EllipsizeMode::END);
|
||||||
@@ -1275,12 +1526,14 @@ void EffectorWindow::rebuildChainRows() {
|
|||||||
button->set_icon_name(action.icon);
|
button->set_icon_name(action.icon);
|
||||||
button->set_tooltip_text(action.tip);
|
button->set_tooltip_text(action.tip);
|
||||||
button->add_css_class("flat");
|
button->add_css_class("flat");
|
||||||
|
button->add_css_class("stage-action");
|
||||||
|
button->set_valign(Gtk::Align::CENTER);
|
||||||
const int delta = action.delta;
|
const int delta = action.delta;
|
||||||
button->signal_clicked().connect([this, index, delta] {
|
button->signal_clicked().connect([this, index, delta] {
|
||||||
if (delta == 0) {
|
if (delta == 0) {
|
||||||
removeStage(index);
|
removeStage(index);
|
||||||
} else {
|
} else {
|
||||||
moveStage(index, delta);
|
moveStage(index, index + delta);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (delta == -1) button->set_sensitive(index > 0);
|
if (delta == -1) button->set_sensitive(index > 0);
|
||||||
@@ -1289,12 +1542,112 @@ void EffectorWindow::rebuildChainRows() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
row->set_child(*box);
|
row->set_child(*box);
|
||||||
|
|
||||||
|
// The row itself is what is dragged, under the pointer where it was
|
||||||
|
// picked up; a press that goes nowhere is still a click on the row.
|
||||||
|
auto drag = Gtk::DragSource::create();
|
||||||
|
drag->set_actions(Gdk::DragAction::MOVE);
|
||||||
|
drag->signal_prepare().connect(
|
||||||
|
[source = drag.get(), row, index](double x, double y) {
|
||||||
|
source->set_icon(Gtk::WidgetPaintable::create(*row), static_cast<int>(x),
|
||||||
|
static_cast<int>(y));
|
||||||
|
Glib::Value<int> value;
|
||||||
|
value.init(Glib::Value<int>::value_type());
|
||||||
|
value.set(index);
|
||||||
|
return Gdk::ContentProvider::create(value);
|
||||||
|
},
|
||||||
|
false);
|
||||||
|
row->add_controller(drag);
|
||||||
|
|
||||||
chainList_.append(*row);
|
chainList_.append(*row);
|
||||||
}
|
}
|
||||||
|
|
||||||
selectStage(selectedStage_);
|
selectStage(selectedStage_);
|
||||||
suspendStageSelect_ = false;
|
suspendStageSelect_ = false;
|
||||||
updateSourcesVisible();
|
updateSourcesVisible();
|
||||||
|
scheduleChainSplitFit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where a row dropped at `y` goes: before the row whose upper half `y` is in,
|
||||||
|
// or after the last row.
|
||||||
|
int EffectorWindow::chainDropSlot(double y) const {
|
||||||
|
for (int i = 0;; ++i) {
|
||||||
|
auto* row = chainList_.get_row_at_index(i);
|
||||||
|
if (row == nullptr) return i;
|
||||||
|
graphene_rect_t bounds;
|
||||||
|
if (!gtk_widget_compute_bounds(GTK_WIDGET(row->gobj()), GTK_WIDGET(chainList_.gobj()),
|
||||||
|
&bounds))
|
||||||
|
continue;
|
||||||
|
if (y < bounds.origin.y + bounds.size.height / 2) return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marks the edge a drop into `slot` lands on; -1 clears the mark.
|
||||||
|
void EffectorWindow::showChainDropSlot(int slot) {
|
||||||
|
const int count = static_cast<int>(stages_.size());
|
||||||
|
for (int i = 0; auto* row = chainList_.get_row_at_index(i); ++i) {
|
||||||
|
const bool before = i == slot;
|
||||||
|
const bool after = slot == count && i == count - 1;
|
||||||
|
if (before) {
|
||||||
|
row->add_css_class("drop-before");
|
||||||
|
} else {
|
||||||
|
row->remove_css_class("drop-before");
|
||||||
|
}
|
||||||
|
if (after) {
|
||||||
|
row->add_css_class("drop-after");
|
||||||
|
} else {
|
||||||
|
row->remove_css_class("drop-after");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once the rows are laid out, not in the middle of a rebuild or of an
|
||||||
|
// allocation, where moving the split would ask for another one.
|
||||||
|
void EffectorWindow::scheduleChainSplitFit() {
|
||||||
|
if (chainSplitDragged_ || chainSplitFit_.connected()) return;
|
||||||
|
chainSplitFit_ = Glib::signal_idle().connect([this] {
|
||||||
|
fitChainSplit();
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GtkPaned would size the fixed pane from its minimum height, which is only
|
||||||
|
// the floor it may be dragged down to, so the fit to the rows' natural height
|
||||||
|
// is done here.
|
||||||
|
void EffectorWindow::fitChainSplit() {
|
||||||
|
if (chainSplit_ == nullptr || chainSplitDragged_) return;
|
||||||
|
auto* chain = chainSplit_->get_start_child();
|
||||||
|
if (chain == nullptr) return;
|
||||||
|
int minimum = 0;
|
||||||
|
int natural = 0;
|
||||||
|
int minimumBaseline = 0;
|
||||||
|
int naturalBaseline = 0;
|
||||||
|
const int width = chainSplit_->get_width();
|
||||||
|
chain->measure(Gtk::Orientation::VERTICAL, width > 0 ? width : -1, minimum, natural,
|
||||||
|
minimumBaseline, naturalBaseline);
|
||||||
|
fittedChainSplit_ = natural;
|
||||||
|
suspendSplitTrack_ = true;
|
||||||
|
chainSplit_->set_position(natural);
|
||||||
|
suspendSplitTrack_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void EffectorWindow::refitChainSplit() {
|
||||||
|
chainSplitDragged_ = false;
|
||||||
|
fitChainSplit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any move but a fit, or GTK clamping a fit into a window too short for it,
|
||||||
|
// is the user's drag.
|
||||||
|
void EffectorWindow::onChainSplitMoved() {
|
||||||
|
if (suspendSplitTrack_ || fittedChainSplit_ < 0) return;
|
||||||
|
const int position = chainSplit_->get_position();
|
||||||
|
if (position == fittedChainSplit_) return;
|
||||||
|
const int lowest = chainSplit_->property_min_position();
|
||||||
|
const int highest = chainSplit_->property_max_position();
|
||||||
|
if (fittedChainSplit_ > highest && position == highest) return;
|
||||||
|
if (fittedChainSplit_ < lowest && position == lowest) return;
|
||||||
|
chainSplitDragged_ = true;
|
||||||
|
chainSplitFit_.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switching a stage off leaves everything about it alone -- its settings, its
|
// Switching a stage off leaves everything about it alone -- its settings, its
|
||||||
@@ -1325,8 +1678,17 @@ void EffectorWindow::onStageSelected() {
|
|||||||
|
|
||||||
// ------------------------------------------------------------ rendering ---
|
// ------------------------------------------------------------ rendering ---
|
||||||
|
|
||||||
|
std::string EffectorWindow::stageLabel(std::size_t index) const {
|
||||||
|
const ChainStage& stage = stages_[index];
|
||||||
|
return std::to_string(index + 1) + ". " + stage.effect->label(stageValues(stage, styles_));
|
||||||
|
}
|
||||||
|
|
||||||
void EffectorWindow::onParamChanged() {
|
void EffectorWindow::onParamChanged() {
|
||||||
storeParams();
|
storeParams();
|
||||||
|
// A param can be what names the stage (the words of a text stage), and a
|
||||||
|
// styled one can rename every stage sharing the style.
|
||||||
|
for (std::size_t i = 0; i < stageLabels_.size() && i < stages_.size(); ++i)
|
||||||
|
stageLabels_[i]->set_text(stageLabel(i));
|
||||||
requestRender();
|
requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1391,7 +1753,7 @@ bool EffectorWindow::canRender() const {
|
|||||||
std::string EffectorWindow::missingText() const {
|
std::string EffectorWindow::missingText() const {
|
||||||
if (stages_.empty()) return "Add an effect to start.";
|
if (stages_.empty()) return "Add an effect to start.";
|
||||||
if (enabledStages(stages_).empty()) return "Every effect is switched off.";
|
if (enabledStages(stages_).empty()) return "Every effect is switched off.";
|
||||||
return "Add source images to start.";
|
return "Add source images under Setup to start.";
|
||||||
}
|
}
|
||||||
|
|
||||||
void EffectorWindow::showPlaceholder() {
|
void EffectorWindow::showPlaceholder() {
|
||||||
@@ -1400,6 +1762,7 @@ void EffectorWindow::showPlaceholder() {
|
|||||||
placeholder_.set_text(missingText());
|
placeholder_.set_text(missingText());
|
||||||
previewStack_.set_visible_child("placeholder");
|
previewStack_.set_visible_child("placeholder");
|
||||||
status_.set_text("");
|
status_.set_text("");
|
||||||
|
status_.set_tooltip_text("");
|
||||||
}
|
}
|
||||||
|
|
||||||
void EffectorWindow::requestRender() {
|
void EffectorWindow::requestRender() {
|
||||||
@@ -1496,15 +1859,20 @@ void EffectorWindow::onRenderFinished() {
|
|||||||
previewStack_.set_visible_child("picture");
|
previewStack_.set_visible_child("picture");
|
||||||
saveButton_.set_sensitive(true);
|
saveButton_.set_sensitive(true);
|
||||||
|
|
||||||
|
// The line itself stays short; the full chain and setup go in the tooltip.
|
||||||
|
const std::size_t effects = enabledStages(stages_).size();
|
||||||
std::ostringstream status;
|
std::ostringstream status;
|
||||||
status.setf(std::ios::fixed);
|
status.setf(std::ios::fixed);
|
||||||
status.precision(1);
|
status.precision(1);
|
||||||
status << chainTitle(stages_) << " · " << result_.width() << "×" << result_.height() << " · ";
|
status << effects << (effects == 1 ? " effect" : " effects") << " · " << millis << " ms";
|
||||||
|
std::ostringstream details;
|
||||||
|
details << chainTitle(stages_) << " · " << result_.width() << "×" << result_.height() << " · ";
|
||||||
if (!sources_->empty()) {
|
if (!sources_->empty()) {
|
||||||
status << sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · ";
|
details << sources_->size() << (sources_->size() == 1 ? " source" : " sources") << " · ";
|
||||||
}
|
}
|
||||||
status << "seed " << seed_ << " · " << millis << " ms";
|
details << "seed " << seed_;
|
||||||
status_.set_text(status.str());
|
status_.set_text(status.str());
|
||||||
|
status_.set_tooltip_text(details.str());
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------- project ---
|
// -------------------------------------------------------------- project ---
|
||||||
@@ -1516,16 +1884,37 @@ Gtk::Widget& EffectorWindow::buildMainMenu() {
|
|||||||
add_action("open-project", sigc::mem_fun(*this, &EffectorWindow::onOpenProject));
|
add_action("open-project", sigc::mem_fun(*this, &EffectorWindow::onOpenProject));
|
||||||
add_action("save-project", sigc::mem_fun(*this, &EffectorWindow::onSaveProject));
|
add_action("save-project", sigc::mem_fun(*this, &EffectorWindow::onSaveProject));
|
||||||
add_action("save-project-as", sigc::mem_fun(*this, &EffectorWindow::onSaveProjectAs));
|
add_action("save-project-as", sigc::mem_fun(*this, &EffectorWindow::onSaveProjectAs));
|
||||||
|
// The entry carries the file's URI rather than its path: a target must be
|
||||||
|
// UTF-8, and a file name need not be.
|
||||||
|
add_action_with_parameter("open-recent", Glib::VARIANT_TYPE_STRING,
|
||||||
|
[this](const Glib::VariantBase& target) {
|
||||||
|
const auto uri =
|
||||||
|
Glib::VariantBase::cast_dynamic<Glib::Variant<Glib::ustring>>(target).get();
|
||||||
|
openProject(Glib::filename_from_uri(uri));
|
||||||
|
});
|
||||||
|
// Never enabled: it only stands in for an empty list.
|
||||||
|
add_action("no-recent")->set_enabled(false);
|
||||||
|
add_action("show-changelog", sigc::mem_fun(*this, &EffectorWindow::showChangelog));
|
||||||
|
add_action("about", sigc::mem_fun(*this, &EffectorWindow::showAbout));
|
||||||
|
|
||||||
auto menu = Gio::Menu::create();
|
auto menu = Gio::Menu::create();
|
||||||
auto open = Gio::Menu::create();
|
auto open = Gio::Menu::create();
|
||||||
open->append("New Project", "win.new-project");
|
open->append("New Project", "win.new-project");
|
||||||
open->append("Open Project…", "win.open-project");
|
open->append("Open Project…", "win.open-project");
|
||||||
|
recentMenu_ = Gio::Menu::create();
|
||||||
|
open->append_submenu("Open Recent", recentMenu_);
|
||||||
|
rebuildRecentMenu();
|
||||||
|
recentChanged_ = Gtk::RecentManager::get_default()->signal_changed().connect(
|
||||||
|
sigc::mem_fun(*this, &EffectorWindow::rebuildRecentMenu));
|
||||||
menu->append_section(open);
|
menu->append_section(open);
|
||||||
auto save = Gio::Menu::create();
|
auto save = Gio::Menu::create();
|
||||||
save->append("Save Project", "win.save-project");
|
save->append("Save Project", "win.save-project");
|
||||||
save->append("Save Project As…", "win.save-project-as");
|
save->append("Save Project As…", "win.save-project-as");
|
||||||
menu->append_section(save);
|
menu->append_section(save);
|
||||||
|
auto help = Gio::Menu::create();
|
||||||
|
help->append("Changelog", "win.show-changelog");
|
||||||
|
help->append("About Effector", "win.about");
|
||||||
|
menu->append_section(help);
|
||||||
|
|
||||||
// The shortcuts hang off the window rather than the application, which the
|
// The shortcuts hang off the window rather than the application, which the
|
||||||
// window does not belong to yet while it is being built.
|
// window does not belong to yet while it is being built.
|
||||||
@@ -1627,11 +2016,213 @@ void EffectorWindow::onProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& resul
|
|||||||
return; // cancelled
|
return; // cancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openProject(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EffectorWindow::openProject(const std::string& path) {
|
||||||
try {
|
try {
|
||||||
applyProject(loadProject(path), path);
|
applyProject(loadProject(path), path);
|
||||||
} catch (const std::exception& error) {
|
} catch (const std::exception& error) {
|
||||||
showError(std::string("Could not open the project: ") + error.what());
|
showError(std::string("Could not open the project: ") + error.what());
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
rememberProject(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EffectorWindow::rememberProject(const std::string& path) {
|
||||||
|
Gtk::RecentManager::Data data;
|
||||||
|
data.app_name = kRecentApp;
|
||||||
|
data.app_exec = "effector";
|
||||||
|
data.mime_type = "application/json";
|
||||||
|
try {
|
||||||
|
Gtk::RecentManager::get_default()->add_item(
|
||||||
|
Glib::filename_to_uri(std::filesystem::absolute(path).string()), data);
|
||||||
|
} catch (const Glib::Error&) {
|
||||||
|
// Not a path a URI can name; the project is open all the same.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EffectorWindow::rebuildRecentMenu() {
|
||||||
|
std::vector<Glib::RefPtr<Gtk::RecentInfo>> recent;
|
||||||
|
for (const auto& info : Gtk::RecentManager::get_default()->get_items()) {
|
||||||
|
if (info->has_application(kRecentApp) && info->exists()) recent.push_back(info);
|
||||||
|
}
|
||||||
|
std::sort(recent.begin(), recent.end(), [](const auto& a, const auto& b) {
|
||||||
|
return a->get_modified().compare(b->get_modified()) > 0;
|
||||||
|
});
|
||||||
|
if (recent.size() > kRecentCount) recent.resize(kRecentCount);
|
||||||
|
|
||||||
|
// A project is listed by its name, and by its folder too only where two
|
||||||
|
// of the same name would otherwise read alike.
|
||||||
|
std::vector<std::filesystem::path> paths;
|
||||||
|
for (const auto& info : recent) paths.emplace_back(Glib::filename_from_uri(info->get_uri()));
|
||||||
|
recentMenu_->remove_all();
|
||||||
|
for (std::size_t i = 0; i < recent.size(); ++i) {
|
||||||
|
std::string label = paths[i].stem().string();
|
||||||
|
const auto sameName = std::count_if(paths.begin(), paths.end(), [&](const auto& other) {
|
||||||
|
return other.stem() == paths[i].stem();
|
||||||
|
});
|
||||||
|
if (sameName > 1) label += " (" + paths[i].parent_path().filename().string() + ")";
|
||||||
|
// The menu reads an underscore as a mnemonic.
|
||||||
|
const std::string display = Glib::filename_display_name(label);
|
||||||
|
std::string escaped;
|
||||||
|
for (const char c : display) {
|
||||||
|
escaped += c;
|
||||||
|
if (c == '_') escaped += '_';
|
||||||
|
}
|
||||||
|
auto item = Gio::MenuItem::create(escaped, "");
|
||||||
|
item->set_action_and_target("win.open-recent",
|
||||||
|
Glib::Variant<Glib::ustring>::create(recent[i]->get_uri()));
|
||||||
|
recentMenu_->append_item(item);
|
||||||
|
}
|
||||||
|
if (recent.empty()) recentMenu_->append("No Recent Projects", "win.no-recent");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ changelog ---
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Lays CHANGELOG.md out in a text buffer. It reads only the little Markdown
|
||||||
|
// the file uses -- headings, bullets wrapped onto indented lines, and `code`
|
||||||
|
// -- which is all a changelog needs.
|
||||||
|
void fillChangelog(const Glib::RefPtr<Gtk::TextBuffer>& buffer, std::string_view markdown) {
|
||||||
|
auto tags = buffer->get_tag_table();
|
||||||
|
auto tag = [&](const char* name, auto&& configure) {
|
||||||
|
auto made = Gtk::TextTag::create(name);
|
||||||
|
configure(*made);
|
||||||
|
tags->add(made);
|
||||||
|
};
|
||||||
|
tag("title", [](Gtk::TextTag& t) {
|
||||||
|
t.property_weight() = Pango::Weight::BOLD;
|
||||||
|
t.property_scale() = 1.6;
|
||||||
|
t.property_pixels_below_lines() = 6;
|
||||||
|
});
|
||||||
|
tag("release", [](Gtk::TextTag& t) {
|
||||||
|
t.property_weight() = Pango::Weight::BOLD;
|
||||||
|
t.property_scale() = 1.3;
|
||||||
|
t.property_pixels_above_lines() = 18;
|
||||||
|
t.property_pixels_below_lines() = 2;
|
||||||
|
});
|
||||||
|
tag("section", [](Gtk::TextTag& t) {
|
||||||
|
t.property_weight() = Pango::Weight::BOLD;
|
||||||
|
t.property_pixels_above_lines() = 8;
|
||||||
|
t.property_pixels_below_lines() = 2;
|
||||||
|
});
|
||||||
|
tag("item", [](Gtk::TextTag& t) {
|
||||||
|
t.property_left_margin() = 28;
|
||||||
|
t.property_indent() = -14;
|
||||||
|
t.property_pixels_below_lines() = 3;
|
||||||
|
});
|
||||||
|
tag("para", [](Gtk::TextTag& t) { t.property_pixels_below_lines() = 6; });
|
||||||
|
tag("code", [](Gtk::TextTag& t) { t.property_family() = "monospace"; });
|
||||||
|
|
||||||
|
// Gather each block -- a heading, a bullet, a paragraph -- whole first,
|
||||||
|
// since a bullet or a paragraph goes on over several lines.
|
||||||
|
struct Block {
|
||||||
|
std::string kind;
|
||||||
|
std::string text;
|
||||||
|
};
|
||||||
|
std::vector<Block> blocks;
|
||||||
|
bool open = false; // the last block still takes continuation lines
|
||||||
|
std::size_t pos = 0;
|
||||||
|
while (pos <= markdown.size()) {
|
||||||
|
auto end = markdown.find('\n', pos);
|
||||||
|
if (end == std::string_view::npos) end = markdown.size();
|
||||||
|
std::string line(markdown.substr(pos, end - pos));
|
||||||
|
pos = end + 1;
|
||||||
|
|
||||||
|
const auto first = line.find_first_not_of(' ');
|
||||||
|
if (first == std::string::npos) {
|
||||||
|
open = false;
|
||||||
|
} else if (line.rfind("# ", 0) == 0) {
|
||||||
|
blocks.push_back({"title", line.substr(2)});
|
||||||
|
open = false;
|
||||||
|
} else if (line.rfind("## ", 0) == 0) {
|
||||||
|
// "[0.5.0] - 2026-09-26" reads as "0.5.0 — 2026-09-26".
|
||||||
|
std::string text = line.substr(3);
|
||||||
|
std::erase(text, '[');
|
||||||
|
std::erase(text, ']');
|
||||||
|
if (const auto dash = text.find(" - "); dash != std::string::npos) {
|
||||||
|
text.replace(dash, 3, " — ");
|
||||||
|
}
|
||||||
|
blocks.push_back({"release", text});
|
||||||
|
open = false;
|
||||||
|
} else if (line.rfind("### ", 0) == 0) {
|
||||||
|
blocks.push_back({"section", line.substr(4)});
|
||||||
|
open = false;
|
||||||
|
} else if (line.rfind("- ", 0) == 0) {
|
||||||
|
blocks.push_back({"item", "• " + line.substr(2)});
|
||||||
|
open = true;
|
||||||
|
} else if (open) {
|
||||||
|
blocks.back().text += " " + line.substr(first);
|
||||||
|
} else {
|
||||||
|
blocks.push_back({"para", line.substr(first)});
|
||||||
|
open = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto at = buffer->end();
|
||||||
|
for (std::size_t i = 0; i < blocks.size(); ++i) {
|
||||||
|
const auto& block = blocks[i];
|
||||||
|
// Split on the backticks: every odd piece is code.
|
||||||
|
std::size_t start = 0;
|
||||||
|
bool code = false;
|
||||||
|
while (start <= block.text.size()) {
|
||||||
|
auto tick = block.text.find('`', start);
|
||||||
|
if (tick == std::string::npos) tick = block.text.size();
|
||||||
|
if (tick > start) {
|
||||||
|
std::vector<Glib::ustring> names{block.kind};
|
||||||
|
if (code) names.emplace_back("code");
|
||||||
|
at = buffer->insert_with_tags_by_name(at, block.text.substr(start, tick - start), names);
|
||||||
|
}
|
||||||
|
start = tick + 1;
|
||||||
|
code = !code;
|
||||||
|
}
|
||||||
|
if (i + 1 < blocks.size()) at = buffer->insert_with_tag(at, "\n", block.kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void EffectorWindow::showChangelog() {
|
||||||
|
if (!changelogWindow_) {
|
||||||
|
changelogWindow_ = std::make_unique<Gtk::Window>();
|
||||||
|
changelogWindow_->set_title("Changelog");
|
||||||
|
changelogWindow_->set_transient_for(*this);
|
||||||
|
changelogWindow_->set_default_size(560, 640);
|
||||||
|
changelogWindow_->set_hide_on_close(true);
|
||||||
|
|
||||||
|
auto* text = Gtk::make_managed<Gtk::TextView>();
|
||||||
|
text->set_name("changelog");
|
||||||
|
text->set_editable(false);
|
||||||
|
text->set_cursor_visible(false);
|
||||||
|
text->set_wrap_mode(Gtk::WrapMode::WORD);
|
||||||
|
text->set_left_margin(18);
|
||||||
|
text->set_right_margin(18);
|
||||||
|
text->set_top_margin(12);
|
||||||
|
text->set_bottom_margin(18);
|
||||||
|
fillChangelog(text->get_buffer(), kChangelog);
|
||||||
|
|
||||||
|
auto* scroller = Gtk::make_managed<Gtk::ScrolledWindow>();
|
||||||
|
scroller->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
|
||||||
|
scroller->set_child(*text);
|
||||||
|
changelogWindow_->set_child(*scroller);
|
||||||
|
}
|
||||||
|
changelogWindow_->present();
|
||||||
|
}
|
||||||
|
|
||||||
|
void EffectorWindow::showAbout() {
|
||||||
|
if (!aboutDialog_) {
|
||||||
|
aboutDialog_ = std::make_unique<Gtk::AboutDialog>();
|
||||||
|
aboutDialog_->set_transient_for(*this);
|
||||||
|
aboutDialog_->set_modal(true);
|
||||||
|
aboutDialog_->set_hide_on_close(true);
|
||||||
|
aboutDialog_->set_program_name("Effector");
|
||||||
|
aboutDialog_->set_version(std::string(kVersion));
|
||||||
|
aboutDialog_->set_comments("Image effects, cascaded through a chain.");
|
||||||
|
aboutDialog_->set_logo_icon_name("applications-graphics");
|
||||||
|
}
|
||||||
|
aboutDialog_->present();
|
||||||
}
|
}
|
||||||
|
|
||||||
void EffectorWindow::onSaveProject() {
|
void EffectorWindow::onSaveProject() {
|
||||||
@@ -1678,7 +2269,9 @@ void EffectorWindow::writeProject(const std::string& path) {
|
|||||||
clearError();
|
clearError();
|
||||||
projectPath_ = path;
|
projectPath_ = path;
|
||||||
updateTitle();
|
updateTitle();
|
||||||
|
rememberProject(path);
|
||||||
status_.set_text("Saved " + path);
|
status_.set_text("Saved " + path);
|
||||||
|
status_.set_tooltip_text("Saved " + path);
|
||||||
}
|
}
|
||||||
|
|
||||||
void EffectorWindow::updateTitle() {
|
void EffectorWindow::updateTitle() {
|
||||||
@@ -1722,6 +2315,7 @@ void EffectorWindow::onSaveChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
|
|||||||
}
|
}
|
||||||
clearError();
|
clearError();
|
||||||
status_.set_text("Saved " + path.string());
|
status_.set_text("Saved " + path.string());
|
||||||
|
status_.set_tooltip_text("Saved " + path.string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------- errors ---
|
// --------------------------------------------------------------- errors ---
|
||||||
|
|||||||
+37
-3
@@ -29,6 +29,8 @@ public:
|
|||||||
private:
|
private:
|
||||||
void buildUi();
|
void buildUi();
|
||||||
Gtk::Widget& buildSidebar();
|
Gtk::Widget& buildSidebar();
|
||||||
|
Gtk::Widget& buildChainTab();
|
||||||
|
Gtk::Widget& buildSetupTab();
|
||||||
Gtk::Widget& buildPreview();
|
Gtk::Widget& buildPreview();
|
||||||
void buildParamWidgets();
|
void buildParamWidgets();
|
||||||
ParamValues paramValues() const;
|
ParamValues paramValues() const;
|
||||||
@@ -72,7 +74,7 @@ private:
|
|||||||
void closeLoadWindow();
|
void closeLoadWindow();
|
||||||
void cancelLoad();
|
void cancelLoad();
|
||||||
void appendSourceRow(const std::string& path);
|
void appendSourceRow(const std::string& path);
|
||||||
void updateSourcesLabel(); // the collapsed expander shows the count
|
void updateSourcesLabel(); // the heading shows the count
|
||||||
// The whole sources section only shows while the chain asks for sources.
|
// The whole sources section only shows while the chain asks for sources.
|
||||||
void updateSourcesVisible();
|
void updateSourcesVisible();
|
||||||
|
|
||||||
@@ -89,18 +91,35 @@ private:
|
|||||||
Gtk::Widget& buildAddMenu(); // the menu of effects that can be appended
|
Gtk::Widget& buildAddMenu(); // the menu of effects that can be appended
|
||||||
void appendStage(const EffectPtr& effect);
|
void appendStage(const EffectPtr& effect);
|
||||||
void removeStage(int index);
|
void removeStage(int index);
|
||||||
void moveStage(int index, int delta);
|
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.
|
// The check on a chain row: the stage stays where it is, and is skipped.
|
||||||
void setStageEnabled(int index, bool enabled);
|
void setStageEnabled(int index, bool enabled);
|
||||||
void rebuildChainRows();
|
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 selectStage(int index);
|
||||||
void onStageSelected();
|
void onStageSelected();
|
||||||
Gtk::Widget& buildMainMenu(); // the header menu: new, open, save project
|
Gtk::Widget& buildMainMenu(); // the header menu: new, open, save project
|
||||||
|
void showChangelog();
|
||||||
|
void showAbout();
|
||||||
Project currentProject() const;
|
Project currentProject() const;
|
||||||
void applyProject(const Project& project, const std::string& path);
|
void applyProject(const Project& project, const std::string& path);
|
||||||
void clearSources();
|
void clearSources();
|
||||||
void onNewProject();
|
void onNewProject();
|
||||||
void onOpenProject();
|
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,
|
void onProjectChosen(const Glib::RefPtr<Gio::AsyncResult>& result,
|
||||||
const Glib::RefPtr<Gtk::FileDialog>& dialog);
|
const Glib::RefPtr<Gtk::FileDialog>& dialog);
|
||||||
void onSaveProject(); // falls back to Save As while the project is untitled
|
void onSaveProject(); // falls back to Save As while the project is untitled
|
||||||
@@ -143,9 +162,20 @@ private:
|
|||||||
StyleSet styles_;
|
StyleSet styles_;
|
||||||
int selectedStage_ = 0;
|
int selectedStage_ = 0;
|
||||||
bool suspendStageSelect_ = false; // guards the rebuild of the chain rows
|
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_;
|
Image result_;
|
||||||
std::uint64_t seed_ = 0;
|
std::uint64_t seed_ = 0;
|
||||||
std::string projectPath_; // empty until the project has been saved once
|
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;
|
bool suspendRender_ = true;
|
||||||
|
|
||||||
// Source loading. Decoding a batch of large photographs takes seconds, so
|
// Source loading. Decoding a batch of large photographs takes seconds, so
|
||||||
@@ -181,9 +211,13 @@ private:
|
|||||||
Gtk::Button* addButton_ = nullptr;
|
Gtk::Button* addButton_ = nullptr;
|
||||||
// Built fresh with the progress window, and null whenever it is closed.
|
// Built fresh with the progress window, and null whenever it is closed.
|
||||||
std::unique_ptr<Gtk::Window> loadWindow_;
|
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::ProgressBar* loadBar_ = nullptr;
|
||||||
Gtk::Label* loadFileLabel_ = nullptr;
|
Gtk::Label* loadFileLabel_ = nullptr;
|
||||||
Gtk::Expander sourcesExpander_;
|
Gtk::Box sourcesSection_; // hidden while the chain needs no sources
|
||||||
|
Gtk::Label sourcesHeading_;
|
||||||
Gtk::ListBox sourceList_;
|
Gtk::ListBox sourceList_;
|
||||||
Gtk::DropDown formatDrop_;
|
Gtk::DropDown formatDrop_;
|
||||||
Gtk::SpinButton widthSpin_;
|
Gtk::SpinButton widthSpin_;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
#include "core/image.hpp"
|
#include "core/image.hpp"
|
||||||
#include "core/registry.hpp"
|
#include "core/registry.hpp"
|
||||||
#include "core/rng.hpp"
|
#include "core/rng.hpp"
|
||||||
|
#include "core/version.hpp"
|
||||||
#include "gui/app.hpp"
|
#include "gui/app.hpp"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -78,6 +79,10 @@ int main(int argc, char** argv) {
|
|||||||
std::cout << effector::usage(argv[0]);
|
std::cout << effector::usage(argv[0]);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
if (options.showVersion) {
|
||||||
|
std::cout << "effector " << effector::kVersion << "\n";
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
if (options.listEffects) {
|
if (options.listEffects) {
|
||||||
for (const auto& effect : effector::effects()) {
|
for (const auto& effect : effector::effects()) {
|
||||||
std::cout << effect->name() << "\t" << effect->title() << " - "
|
std::cout << effect->name() << "\t" << effect->title() << " - "
|
||||||
|
|||||||
@@ -3,5 +3,23 @@
|
|||||||
add_executable(effector_tests core_tests.cpp ../src/cli/options.cpp)
|
add_executable(effector_tests core_tests.cpp ../src/cli/options.cpp)
|
||||||
target_link_libraries(effector_tests PRIVATE effector_core)
|
target_link_libraries(effector_tests PRIVATE effector_core)
|
||||||
target_compile_options(effector_tests PRIVATE -Wall -Wextra -Wpedantic)
|
target_compile_options(effector_tests PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
|
# The version test reads the Arch PKGBUILD to keep its pkgver in step.
|
||||||
|
target_compile_definitions(effector_tests PRIVATE EFFECTOR_SOURCE_DIR="${PROJECT_SOURCE_DIR}")
|
||||||
|
|
||||||
add_test(NAME core COMMAND effector_tests)
|
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)
|
||||||
|
|||||||
+255
-1
@@ -12,6 +12,7 @@
|
|||||||
#include <locale>
|
#include <locale>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
#include <set>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
#include "core/registry.hpp"
|
#include "core/registry.hpp"
|
||||||
#include "core/rng.hpp"
|
#include "core/rng.hpp"
|
||||||
#include "core/style.hpp"
|
#include "core/style.hpp"
|
||||||
|
#include "core/version.hpp"
|
||||||
#include "effects/blur.hpp"
|
#include "effects/blur.hpp"
|
||||||
#include "effects/grain.hpp"
|
#include "effects/grain.hpp"
|
||||||
#include "effects/monochrome.hpp"
|
#include "effects/monochrome.hpp"
|
||||||
@@ -201,6 +203,18 @@ effector::Image flat(int width, int height, std::uint8_t shade) {
|
|||||||
return image;
|
return image;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Renders any registered effect by name with its defaults, overridden by
|
||||||
|
// `values`, from a generator seeded with `seed`.
|
||||||
|
effector::Image renderNamed(const std::string& name, const std::vector<effector::Image>& sources,
|
||||||
|
int width, int height, std::uint64_t seed,
|
||||||
|
const effector::ParamValues& values) {
|
||||||
|
const auto effect = effector::effectByName(name);
|
||||||
|
effector::Rng rng(seed);
|
||||||
|
auto merged = effect->defaults();
|
||||||
|
for (const auto& [key, value] : values) merged[key] = value;
|
||||||
|
return effect->render(sources, width, height, rng, merged);
|
||||||
|
}
|
||||||
|
|
||||||
effector::Image render(const std::vector<effector::Image>& sources, int width, int height,
|
effector::Image render(const std::vector<effector::Image>& sources, int width, int height,
|
||||||
std::uint64_t seed, const effector::ParamValues& values) {
|
std::uint64_t seed, const effector::ParamValues& values) {
|
||||||
effector::ScanlinesEffect effect;
|
effector::ScanlinesEffect effect;
|
||||||
@@ -243,6 +257,15 @@ int main() {
|
|||||||
threw = true;
|
threw = true;
|
||||||
}
|
}
|
||||||
check(threw, "unknown effect throws");
|
check(threw, "unknown effect throws");
|
||||||
|
|
||||||
|
// Flags share one namespace, so a key two effects declared would be
|
||||||
|
// handed to whichever came first.
|
||||||
|
std::set<std::string> keys;
|
||||||
|
bool unique = true;
|
||||||
|
for (const auto& effect : effects()) {
|
||||||
|
for (const auto& spec : effect->params()) unique = keys.insert(spec.key).second && unique;
|
||||||
|
}
|
||||||
|
check(unique, "no two effects declare the same param key");
|
||||||
}
|
}
|
||||||
|
|
||||||
std::cout << "scanlines\n";
|
std::cout << "scanlines\n";
|
||||||
@@ -616,6 +639,49 @@ int main() {
|
|||||||
solid(100, 100, 0, 0, 255)),
|
solid(100, 100, 0, 0, 255)),
|
||||||
"zero opacity leaves the input untouched");
|
"zero opacity leaves the input untouched");
|
||||||
|
|
||||||
|
// Halo: none by default, a shadow moved by its offset, a glow all round.
|
||||||
|
check(sameBytes(renderOverlay(blue, 100, 100, at),
|
||||||
|
renderOverlay(blue, 100, 100, [&] {
|
||||||
|
ParamValues none = at;
|
||||||
|
none["overlay_halo"] = std::string("none");
|
||||||
|
return none;
|
||||||
|
}())),
|
||||||
|
"no halo is the default");
|
||||||
|
ParamValues shadowed = at;
|
||||||
|
shadowed["overlay_halo"] = std::string("shadow");
|
||||||
|
shadowed["overlay_halo_size"] = 0L;
|
||||||
|
shadowed["overlay_halo_opacity"] = 1.0;
|
||||||
|
shadowed["overlay_shadow_x"] = 5L;
|
||||||
|
shadowed["overlay_shadow_y"] = 5L;
|
||||||
|
const auto shadow = renderOverlay(blue, 100, 100, shadowed);
|
||||||
|
const auto dark = pixel(shadow, 72, 62); // past the logo's bottom right corner (70, 60)
|
||||||
|
check(dark[0] == 0 && dark[1] == 0 && dark[2] == 0,
|
||||||
|
"a hard shadow is cast down and right by its offset");
|
||||||
|
check(isBlue(pixel(shadow, 28, 50)) && isRed(pixel(shadow, 50, 50)),
|
||||||
|
"it leaves the other side alone and stays beneath the image");
|
||||||
|
shadowed["overlay_halo_size"] = 12L;
|
||||||
|
const auto soft = pixel(renderOverlay(blue, 100, 100, shadowed), 78, 50);
|
||||||
|
check(soft[2] > 0 && soft[2] < 255, "a soft shadow fades out past the edge");
|
||||||
|
|
||||||
|
ParamValues glowing = at;
|
||||||
|
glowing["overlay_halo"] = std::string("glow");
|
||||||
|
const auto glow = renderOverlay(blue, 100, 100, glowing);
|
||||||
|
const auto left = pixel(glow, 27, 50);
|
||||||
|
const auto right = pixel(glow, 72, 50);
|
||||||
|
check(left[0] > 0 && left[0] == right[0] && left[1] == right[1],
|
||||||
|
"a glow lightens both sides alike");
|
||||||
|
check(isBlue(pixel(glow, 2, 2)), "and fades out before the far corners");
|
||||||
|
glowing["overlay_halo_spread"] = 0.5;
|
||||||
|
check(pixel(renderOverlay(blue, 100, 100, glowing), 27, 50)[0] > left[0],
|
||||||
|
"spread strengthens the halo near the edge");
|
||||||
|
|
||||||
|
ParamValues offCanvas = shadowed;
|
||||||
|
offCanvas["overlay_halo_size"] = 0L;
|
||||||
|
offCanvas["overlay_offset_x"] = -70L; // the logo ends just left of the canvas
|
||||||
|
offCanvas["overlay_shadow_x"] = 20L;
|
||||||
|
const auto cast = pixel(renderOverlay(blue, 100, 100, offCanvas), 5, 50);
|
||||||
|
check(cast[2] == 0, "an image off the canvas still casts its shadow onto it");
|
||||||
|
|
||||||
// No image chosen is the state the interface starts in: a pass through.
|
// No image chosen is the state the interface starts in: a pass through.
|
||||||
check(sameBytes(renderOverlay(blue, 100, 100, {}), solid(100, 100, 0, 0, 255)),
|
check(sameBytes(renderOverlay(blue, 100, 100, {}), solid(100, 100, 0, 0, 255)),
|
||||||
"with no image chosen the stage passes its input through");
|
"with no image chosen the stage passes its input through");
|
||||||
@@ -737,6 +803,20 @@ int main() {
|
|||||||
check(countRed(moved, 100, 60, 200, 120) > 0 && countRed(moved, 0, 0, 100, 60) == 0,
|
check(countRed(moved, 100, 60, 200, 120) > 0 && countRed(moved, 0, 0, 100, 60) == 0,
|
||||||
"the offset moves it down and to the right");
|
"the offset moves it down and to the right");
|
||||||
|
|
||||||
|
// A wide line turned a quarter stands upright: it reaches above and
|
||||||
|
// below the band it filled unturned, and no longer out to the sides.
|
||||||
|
ParamValues wide = plain;
|
||||||
|
wide["text"] = std::string("HHHHHH");
|
||||||
|
wide["font_size"] = 40.0;
|
||||||
|
const auto flat = renderText(blue, 200, 120, wide);
|
||||||
|
ParamValues turned = wide;
|
||||||
|
turned["text_rotation"] = 90.0;
|
||||||
|
const auto upright = renderText(blue, 200, 120, turned);
|
||||||
|
check(countRed(flat, 0, 0, 200, 25) == 0 && countRed(upright, 0, 0, 200, 25) > 0,
|
||||||
|
"rotation turns the text about its centre");
|
||||||
|
check(countRed(flat, 0, 0, 50, 120) > 0 && countRed(upright, 0, 0, 50, 120) == 0,
|
||||||
|
"and takes it in from the sides");
|
||||||
|
|
||||||
// Weight and size are the two knobs the glyphs themselves answer to: a
|
// Weight and size are the two knobs the glyphs themselves answer to: a
|
||||||
// bolder or a larger setting covers strictly more of the canvas.
|
// bolder or a larger setting covers strictly more of the canvas.
|
||||||
ParamValues bold = plain;
|
ParamValues bold = plain;
|
||||||
@@ -821,6 +901,13 @@ int main() {
|
|||||||
// Like blur and overlay it needs no sources of its own, and the same
|
// Like blur and overlay it needs no sources of its own, and the same
|
||||||
// settings render the same bytes twice over.
|
// settings render the same bytes twice over.
|
||||||
check(!TextEffect().needsSources(), "text can lead a chain");
|
check(!TextEffect().needsSources(), "text can lead a chain");
|
||||||
|
|
||||||
|
// A text stage is named after its words, on one line.
|
||||||
|
check(TextEffect().label({{"text", std::string(" Two\nlines ")}}) == "Text (Two lines)",
|
||||||
|
"a text stage is labelled with its words");
|
||||||
|
check(TextEffect().label({{"text", std::string(" \n")}}) == "Text",
|
||||||
|
"a text stage with no words is labelled with the title alone");
|
||||||
|
check(effectByName("blur")->label({}) == "Blur", "other effects are labelled with their title");
|
||||||
const auto alone = renderText({}, 200, 120, plain);
|
const auto alone = renderText({}, 200, 120, plain);
|
||||||
check(alone.width() == 200 && countRed(alone, 0, 0, 200, 120) > 0,
|
check(alone.width() == 200 && countRed(alone, 0, 0, 200, 120) > 0,
|
||||||
"without sources it draws on an empty canvas");
|
"without sources it draws on an empty canvas");
|
||||||
@@ -855,7 +942,8 @@ int main() {
|
|||||||
carries("box_padding") && carries("box_opacity"),
|
carries("box_padding") && carries("box_opacity"),
|
||||||
"the look is what it carries");
|
"the look is what it carries");
|
||||||
check(!carries("text") && !carries("text_wrap") && !carries("box_image") &&
|
check(!carries("text") && !carries("text_wrap") && !carries("box_image") &&
|
||||||
!carries("text_center") && !carries("text_offset_x") && !carries("text_offset_y"),
|
!carries("text_center") && !carries("text_offset_x") && !carries("text_offset_y") &&
|
||||||
|
!carries("text_rotation"),
|
||||||
"the words and the placement stay on the stage");
|
"the words and the placement stay on the stage");
|
||||||
check(styleSpecs("").empty() && styleSpecs("nosuchkind").empty(),
|
check(styleSpecs("").empty() && styleSpecs("nosuchkind").empty(),
|
||||||
"a kind no effect claims carries nothing");
|
"a kind no effect claims carries nothing");
|
||||||
@@ -933,6 +1021,150 @@ int main() {
|
|||||||
"and so does a chain handed no styles at all");
|
"and so does a chain handed no styles at all");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::cout << "chaos transforms\n";
|
||||||
|
{
|
||||||
|
const std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
|
||||||
|
const std::vector<Image> input = {gradient(400, 300, 40)};
|
||||||
|
|
||||||
|
// What every one of them must do, and what switches each one off.
|
||||||
|
const std::vector<std::pair<std::string, ParamValues>> transforms = {
|
||||||
|
{"slitshift", {{"shift_max", 0L}}},
|
||||||
|
{"channelsplit", {{"split_amount", 0L}}},
|
||||||
|
{"pixelsort", {{"sort_line_chance", 0.0}}},
|
||||||
|
{"corrupt", {{"corrupt_rate", 0.0}}},
|
||||||
|
{"shatter",
|
||||||
|
{{"shatter_displace", 0L}, {"shatter_rotate", 0.0}, {"shatter_zoom", 0.0},
|
||||||
|
{"shatter_crack_width", 0L}}},
|
||||||
|
{"warp", {{"warp_strength", 0L}}},
|
||||||
|
{"bitcrush", {{"crush_coverage", 0.0}}},
|
||||||
|
{"feedback", {{"echo_fade", 0.0}}},
|
||||||
|
};
|
||||||
|
for (const auto& [name, off] : transforms) {
|
||||||
|
check(!effectByName(name)->needsSources(), name + " needs no source images");
|
||||||
|
|
||||||
|
const auto first = renderNamed(name, sources, 400, 300, 4, {});
|
||||||
|
const auto again = renderNamed(name, sources, 400, 300, 4, {});
|
||||||
|
const auto other = renderNamed(name, sources, 400, 300, 11, {});
|
||||||
|
check(first.width() == 400 && first.height() == 300,
|
||||||
|
name + " renders the requested size");
|
||||||
|
check(sameBytes(first, again), name + " reproduces itself from a seed");
|
||||||
|
check(!sameBytes(first, other), name + " changes with the seed");
|
||||||
|
check(!sameBytes(renderNamed(name, input, 400, 300, 4, {}), input[0]),
|
||||||
|
name + " changes its input");
|
||||||
|
check(sameBytes(renderNamed(name, input, 400, 300, 4, off), input[0]),
|
||||||
|
name + " switched right down leaves its input alone");
|
||||||
|
|
||||||
|
const auto blank = renderNamed(name, {}, 120, 80, 1, {});
|
||||||
|
check(blank.width() == 120 && blank.height() == 80,
|
||||||
|
name + " renders without sources");
|
||||||
|
|
||||||
|
ChainStage scanlines{effectByName("scanlines"), {{"strip_height", 6L}}};
|
||||||
|
ChainStage stage{effectByName(name), {}};
|
||||||
|
Rng a(9);
|
||||||
|
Rng b(9);
|
||||||
|
check(sameBytes(renderChain({scanlines, stage}, sources, 300, 200, a),
|
||||||
|
renderChain({scanlines, stage}, sources, 300, 200, b)),
|
||||||
|
name + " cascades reproducibly");
|
||||||
|
check(!chainNeedsSources({stage, scanlines}), "a chain led by " + name + " needs no sources");
|
||||||
|
|
||||||
|
// A canvas one pixel wide or high must not trip any of them up.
|
||||||
|
check(renderNamed(name, {}, 1, 1, 2, {}).width() == 1 &&
|
||||||
|
renderNamed(name, input, 1, 57, 2, {}).height() == 57 &&
|
||||||
|
renderNamed(name, input, 57, 1, 2, {}).width() == 57,
|
||||||
|
name + " copes with a sliver of a canvas");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrapped slit shifts only rotate each row, so every row keeps its
|
||||||
|
// pixels, just not their places.
|
||||||
|
const auto wrapped = renderNamed("slitshift", input, 400, 300, 5, {{"shift_bands", 200L}});
|
||||||
|
bool rotated = true;
|
||||||
|
for (int y = 0; y < 300; ++y) {
|
||||||
|
std::vector<std::uint8_t> before(input[0].row(y), input[0].row(y) + 1200);
|
||||||
|
std::vector<std::uint8_t> after(wrapped.row(y), wrapped.row(y) + 1200);
|
||||||
|
std::sort(before.begin(), before.end());
|
||||||
|
std::sort(after.begin(), after.end());
|
||||||
|
rotated = rotated && before == after;
|
||||||
|
}
|
||||||
|
check(rotated, "a wrapped slit shift keeps every row's pixels");
|
||||||
|
const auto upright = renderNamed("slitshift", input, 400, 300, 5,
|
||||||
|
{{"shift_axis", std::string("vertical")}});
|
||||||
|
check(!sameBytes(upright, wrapped), "the vertical axis tears columns instead");
|
||||||
|
|
||||||
|
// Moving a plane of one colour moves nothing that can be seen.
|
||||||
|
const auto split = renderNamed("channelsplit", {solid(80, 60, 200, 90, 30)}, 80, 60, 3,
|
||||||
|
{{"split_mode", std::string("per_band")},
|
||||||
|
{"split_band_height", 7L}});
|
||||||
|
check(sameBytes(split, solid(80, 60, 200, 90, 30)), "a channel split keeps a flat colour");
|
||||||
|
const auto rc = renderNamed("channelsplit", input, 400, 300, 3,
|
||||||
|
{{"split_channels", std::string("red_cyan")}});
|
||||||
|
check(pixel(rc, 200, 150)[2] == pixel(input[0], 200, 150)[2],
|
||||||
|
"red_cyan leaves blue where a flat plane has it");
|
||||||
|
|
||||||
|
// With the thresholds wide open every row comes out sorted.
|
||||||
|
const auto sorted = renderNamed("pixelsort", {stripes(64, 16)}, 64, 16, 1,
|
||||||
|
{{"sort_low", 0.0}, {"sort_high", 1.0}});
|
||||||
|
bool ordered = true;
|
||||||
|
for (int y = 0; y < 16; ++y) {
|
||||||
|
for (int x = 1; x < 64; ++x) ordered = ordered && pixel(sorted, x - 1, y)[0] <= pixel(sorted, x, y)[0];
|
||||||
|
}
|
||||||
|
check(ordered, "an open pixel sort orders every row");
|
||||||
|
const auto downward = renderNamed("pixelsort", {stripes(64, 16)}, 64, 16, 1,
|
||||||
|
{{"sort_low", 0.0}, {"sort_high", 1.0},
|
||||||
|
{"sort_direction", std::string("vertical")}});
|
||||||
|
check(sameBytes(downward, stripes(64, 16)), "sorting uniform columns changes nothing");
|
||||||
|
|
||||||
|
// Every block repeated is the first block smeared across the row.
|
||||||
|
const auto smeared = renderNamed("corrupt", input, 64, 32, 2,
|
||||||
|
{{"corrupt_rate", 1.0},
|
||||||
|
{"corrupt_block", 16L},
|
||||||
|
{"corrupt_kinds", std::string("repeat")}});
|
||||||
|
bool periodic = true;
|
||||||
|
for (int y = 0; y < 32; ++y) {
|
||||||
|
for (int x = 16; x < 64; ++x) periodic = periodic && pixel(smeared, x, y) == pixel(smeared, x - 16, y);
|
||||||
|
}
|
||||||
|
check(periodic, "repeated blocks copy the block to their left");
|
||||||
|
const auto bled = renderNamed("corrupt", input, 400, 300, 2,
|
||||||
|
{{"corrupt_rate", 0.02}, {"corrupt_bleed", true}});
|
||||||
|
check(!sameBytes(bled, renderNamed("corrupt", input, 400, 300, 2, {{"corrupt_rate", 0.02}})),
|
||||||
|
"bleeding carries the damage further");
|
||||||
|
|
||||||
|
const auto cracked = renderNamed("shatter", {flat(200, 150, 128)}, 200, 150, 6,
|
||||||
|
{{"shatter_crack_colour", std::string("#ff0000")}});
|
||||||
|
bool red = false;
|
||||||
|
for (int y = 0; y < 150; ++y) {
|
||||||
|
for (int x = 0; x < 200; ++x) red = red || isRed(pixel(cracked, x, y));
|
||||||
|
}
|
||||||
|
check(red, "shatter draws its cracks in the crack colour");
|
||||||
|
|
||||||
|
check(!sameBytes(renderNamed("warp", input, 400, 300, 4, {{"warp_mode", std::string("swirl")}}),
|
||||||
|
renderNamed("warp", input, 400, 300, 4, {})),
|
||||||
|
"swirl flows differently from a plain push");
|
||||||
|
|
||||||
|
const auto poster = renderNamed("bitcrush", input, 400, 300, 3,
|
||||||
|
{{"crush_op", std::string("posterize")},
|
||||||
|
{"crush_coverage", 1.0}});
|
||||||
|
bool coarse = true;
|
||||||
|
for (std::size_t i = 0; i < poster.byteSize(); ++i) coarse = coarse && poster.data()[i] % 8 == 0;
|
||||||
|
check(coarse, "posterising three bits leaves multiples of eight");
|
||||||
|
const auto swapped = renderNamed("bitcrush", {solid(40, 40, 250, 120, 5)}, 40, 40, 3,
|
||||||
|
{{"crush_op", std::string("channel_swap")},
|
||||||
|
{"crush_coverage", 1.0}});
|
||||||
|
check(pixel(swapped, 20, 20) != std::array<int, 3>{250, 120, 5},
|
||||||
|
"a channel swap never leaves the order as it was");
|
||||||
|
|
||||||
|
// A shrunk, fully opaque echo lands the image's corner inside the
|
||||||
|
// canvas, while the border outside every echo stays the input's.
|
||||||
|
const auto tunnel = renderNamed("feedback", input, 400, 300, 1,
|
||||||
|
{{"echo_scale", 0.5}, {"echo_rotate", 0.0},
|
||||||
|
{"echo_drift", 0L}, {"echo_fade", 1.0}});
|
||||||
|
check(pixel(tunnel, 0, 0) == pixel(input[0], 0, 0), "the border outside the echoes is the input");
|
||||||
|
// Halfway to the corner reads the corner of the image at half size.
|
||||||
|
const auto near = pixel(tunnel, 100, 75);
|
||||||
|
check(std::abs(near[0] - pixel(input[0], 0, 0)[0]) < 4 &&
|
||||||
|
std::abs(near[1] - pixel(input[0], 0, 0)[1]) < 4,
|
||||||
|
"the echoes nest inside it");
|
||||||
|
}
|
||||||
|
|
||||||
std::cout << "chain\n";
|
std::cout << "chain\n";
|
||||||
{
|
{
|
||||||
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
|
std::vector<Image> sources = {gradient(1200, 800, 10), gradient(640, 480, 90)};
|
||||||
@@ -1484,6 +1716,28 @@ int main() {
|
|||||||
std::filesystem::remove_all(dir);
|
std::filesystem::remove_all(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::cout << "version\n";
|
||||||
|
{
|
||||||
|
// A release is the version in CMakeLists.txt and its entry at the top
|
||||||
|
// of CHANGELOG.md; bumping one without the other fails here.
|
||||||
|
const std::string_view log = kChangelog;
|
||||||
|
const auto newest = log.find("\n## [");
|
||||||
|
check(newest != std::string_view::npos &&
|
||||||
|
log.substr(newest + 5).starts_with(std::string(kVersion) + "]"),
|
||||||
|
"the newest changelog entry is this version");
|
||||||
|
std::ifstream pkgbuild(std::string(EFFECTOR_SOURCE_DIR) + "/packaging/arch/PKGBUILD");
|
||||||
|
std::string line;
|
||||||
|
std::string pkgver;
|
||||||
|
while (std::getline(pkgbuild, line)) {
|
||||||
|
if (line.starts_with("pkgver=")) pkgver = line.substr(7);
|
||||||
|
}
|
||||||
|
check(pkgver == kVersion, "the Arch PKGBUILD packages this version");
|
||||||
|
const auto options = parsedOptions({"--version"});
|
||||||
|
check(options && options->showVersion, "--version is understood");
|
||||||
|
const auto shortOptions = parsedOptions({"-V"});
|
||||||
|
check(shortOptions && shortOptions->showVersion, "and so is -V");
|
||||||
|
}
|
||||||
|
|
||||||
std::cout << (failures == 0 ? "\nall checks passed\n" : "\n" + std::to_string(failures) + " failed\n");
|
std::cout << (failures == 0 ? "\nall checks passed\n" : "\n" + std::to_string(failures) + " failed\n");
|
||||||
return failures == 0 ? 0 : 1;
|
return failures == 0 ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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